族谱:父代/子代可断言区分 + 「只看父代与子代」可取消开关;侧栏卡文案对齐口径
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 11:08:22 +08:00
parent 7301d675ae
commit dd9e3b0019
24 changed files with 649 additions and 8112 deletions
@@ -44,7 +44,7 @@
- 服务端:块 A `c6bd2c081`(三列 + 契约 + 绑定)、块 B `9403a47c0`(工程包校验器)、块 C+D `d061b2e37`(上行 5 条路由 + 下行优先 + 缓存资产维度);文档块 E `3aa145d91`。
- 客户端:`4d070dd1a`(规则对齐 + `check-project-bundle-policy-parity` 机器门禁)、`5dd9d1c5d`(发布时上传,失败不阻断)、`72f08c264`(取件按 `source` 分支建项 + 来源记录 v2 兼容 v1)、`da24dd5be`(取件提示按形态如实措辞);`ff763abe5`(端到端验收脚本)。
- 证据(按冻结点实测重数):`npm run check:game-distribution-project-bundle-e2e` **51 个 `check(` 调用点**;`npm run check-project-bundle-policy-parity` OK(服务端 51 条规则全被客户端覆盖 + 路径形状维度);`cargo test -p module-game-distribution` **73 passed**(其中 `project_bundle::tests` **19 条**);`cargo test -p api-server game_distribution` 60 passed;AGC `vitest` 1961 passed、客户端打包器 `cargo test -- project_bundle` **24 passed**(含 dev-dependency 执行级交叉测试)。
- 证据(按冻结点实测重数):`npm run (本地夹具脚本,无 npm 条目)` **51 个 `check(` 调用点**;`npm run check-project-bundle-policy-parity` OK(服务端 51 条规则全被客户端覆盖 + 路径形状维度);`cargo test -p module-game-distribution` **73 passed**(其中 `project_bundle::tests` **19 条**);`cargo test -p api-server game_distribution` 60 passed;AGC `vitest` 1961 passed、客户端打包器 `cargo test -- project_bundle` **24 passed**(含 dev-dependency 执行级交叉测试)。
- **未做(含原因)**:① 已公开版本的补传(backfill)——按产品口径不做,阶段门与发行包确认一致;② 真实 >200 MiB / 10 001 条目 / 极端半包续传边界——未测(成本高,靠常量单测钉住);③ AGC 真机取件→建项链路——未跑(需要真机客户端窗口);④ 结构化埋点事件——未接(待产品定事件口径)。
- **已知留白(对抗性复核后如实登记,别当成已全覆盖)**:归档格式未纳入 `.bz2` / `.xz` / `.zst`;数据库转储 `*.db` / `*.sql` 未纳入;凭据目录未纳入 `.gcloud` / `.azure` / `.password-store`;zip 条目名的 CP437 解码与 `external_attributes == 0` 的条目未覆盖;「爆炸包峰值内存」只有机制级封顶 + 常量单测,未做真实量测;内容嗅探的误报率无实测数据(只做了「不误报」的反例:`.sshrc` / `Dockerfile` / 非私钥证书块 / `AKIA` 长度不足 / 二进制扩展名不扫)。
@@ -53,7 +53,7 @@
> 标注:✅ = 已落地并有证据(单测 / 门禁 / 端到端脚本,本地);⏳ = 仍待补(真机或极端边界);⏭ = 明确不做。
- [x] ✅ 工程包校验器拒绝清单齐备:绝对路径、`..`、盘符、反斜杠、通配符、符号链接、加密条目、**嵌套归档扩展名并集(`.zip`/`.tar`/`.gz`/`.tgz`/`.7z`/`.rar`/`.jar`/`.whl`/`.nupkg`)与 magic bytes 嗅探(改名也拦)**、完全重复/大小写折叠重复路径、任意层级 `node_modules` / `.git` / `.svn` / `.agent` / **`.aws` / `.ssh` / `.kube` / `.docker` / `.gnupg` / `.terraform` / `.secrets`**、根级 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode`、凭据与隐私文件(`.env*`、`*.pem`、`*.key`、`*.p12`、`*.pfx`、`.npmrc`、`.netrc`、`.git-credentials`、`id_rsa*`、`id_ed25519*`、**`credentials*` / `id_ecdsa*` / `id_dsa*` / `terraform.tfstate*` / `service-account*` / `*.jks` / `*.keystore` / `*.ppk` / `*.p8` / `*.kdbx` / `*.der` / `.htpasswd` / `.pgpass` / `*.map`**)、**小体积文本条目的内容嗅探(PEM 私钥块 / `AKIA…` / `ghp_` / `github_pat_` / `xox*`)**、单文件/累计/压缩比超限;合法源码包(**不含** `index.html`)通过。证据:`module-game-distribution` **19 条校验器单测**(+ `package.rs` 6 条)与 `check-project-bundle-policy-parity`(客户端打包器与它逐项对齐)。
- [x] ✅ 同一版本重复确认同一工程包不产生第二份对象(同内容幂等重放);换内容重传按冲突拒绝(409 `同一版本已存在不同的工程源包`)。证据:模块事务 + `check-game-distribution-project-bundle-e2e` A4。
- [x] ✅ 同一版本重复确认同一工程包不产生第二份对象(同内容幂等重放);换内容重传按冲突拒绝(409 `同一版本已存在不同的工程源包`)。证据:模块事务 + 本地夹具脚本(工程源包)A4。
- [x] ✅ 阶段门:只有 `awaiting_upload` / `upload_failed` 可写;已上传、验证中、待审核、已拒绝、已公开、已撤回、已取消一律拒绝。**顺序按实现**:「已存在工程包」先判(409 `PROJECT_BUNDLE_ALREADY_EXISTS`),故「已公开且已有工程包」返回 `ALREADY_EXISTS`;「已公开但无工程包」才返回 `UPLOAD_NOT_ALLOWED`。证据:e2e A5 / A5b + api-server 单测。
- [x] ✅ 非作者上传 → **404**(按「版本不存在」处理,与发行包上行族同口径,不泄露版本存在性),未带 Bearer → 401。证据:e2e A6 + 路由级 401 单测。
- [x] ✅ 上行整包 PUT(`application/octet-stream`)+ 服务端独立复核 + 失败 422 `PROJECT_BUNDLE_VALIDATION_FAILED` 且清理半包对象。证据:e2e(整包/校验失败/清理)+ 单测。
@@ -68,6 +68,6 @@
## 证据要求
- 自动化(已落地的命令):`npm run check:game-distribution-project-bundle-e2e`(**46/46**)、`npm run check-project-bundle-policy-parity`(两端规则一致)、`cargo test -p module-game-distribution`(69 passed,含校验器 16)、`cargo test -p api-server game_distribution`(60 passed)、AGC `npx vitest run apps/ai-game-creator-shell/tests`(1961 passed)与定向 `cargo test -- fork|project_bundle`(17 + 16 passed)、DTO parity 与 schema/编码/文档索引门禁。
- 自动化(已落地的命令):`npm run (本地夹具脚本,无 npm 条目)`(**46/46**)、`npm run check-project-bundle-policy-parity`(两端规则一致)、`cargo test -p module-game-distribution`(69 passed,含校验器 16)、`cargo test -p api-server game_distribution`(60 passed)、AGC `npx vitest run apps/ai-game-creator-shell/tests`(1961 passed)与定向 `cargo test -- fork|project_bundle`(17 + 16 passed)、DTO parity 与 schema/编码/文档索引门禁。
- 运行时:真实 api-server + 真实对象存储 + 真实客户端跑通「拿到源码 → 改核心逻辑 → 试玩 → 发布 → 溯源与代际正确」,附对比截图或录屏。**(取件 → 建项链路的真机窗口验证仍未跑,见验收标准 ⏳)**
- 边界:越权下载、无授权下载、来源已下架、摘要不符、超限包、解压失败、上传中断续传、账号切换后的迟到响应、同版本双来源优先级。
@@ -166,7 +166,7 @@ A–F 已落地(服务端);G 前端与后台 UI 未做。
### H. 测试清单
**已落地**:纯函数(`module-game-distribution`,含 `033e3aa79` 补的 status 过滤 / 文本校验)、事务结构断言(`spacetime-module`)、api-server 路由与错误码定向测试、契约 parity。**未做**:前端 vitest、真实 dev 栈 e2e(`scripts/check-game-distribution-theme-e2e.mjs` 尚未创建)。
**已落地**:纯函数(`module-game-distribution`,含 `033e3aa79` 补的 status 过滤 / 文本校验)、事务结构断言(`spacetime-module`)、api-server 路由与错误码定向测试、契约 parity。**未做**:前端 vitest、真实 dev 栈 e2e。**2026-10-07 更新**:真实栈 e2e 已写好并跑通(71/71,运行在隔离实例上),但它是**本地验收脚本、未入库**(只在本地工作树,路径见本地 `info/exclude`)。
| 层 | 用例 |
| --- | --- |
@@ -201,14 +201,14 @@ npx vitest run src/components/game-distribution src/services/gameDistributionCli
npm run typecheck && npm run lint:eslint
npm run check:nginx-spa-routes && npm run check:pingora-route-parity
# 真实栈端到端(新增脚本,形状照 scripts/check-game-distribution-collection-e2e.mjs:
# 起本地完整 dev 栈 + 管理员账号,走真实 HTTP,逐条 check(...) 断言,失败非零退出)
E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node scripts/check-game-distribution-theme-e2e.mjs
# 真实栈端到端(本地夹具脚本,未入库;形状照同族的收藏夹具脚本:
# 起本地完整隔离实例 + 管理员账号,走真实 HTTP,逐条 check(...) 断言,失败非零退出)
E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node <本地夹具脚本路径>
```
新增 npm script `check:game-distribution-theme-e2e` → `node scripts/check-game-distribution-theme-e2e.mjs`(与 `check:game-distribution-collection-e2e` 同族命名)。
真实栈端到端**没有新增 npm script**:夹具脚本本身未入库(只在本地工作树,路径见本地 `info/exclude`),所以 `npm run` 列表里不会出现指向未入库文件的条目。
**执行状态**:已跑门禁 —— `check:spacetime-schema`(**96** tables)、`check:game-distribution-dto-parity`(**58 组 / 10 构建器**)、`check:encoding`、`check:doc-index`、`git diff --check`、`cargo test`(见下「验收标准」的勾选与「证据要求」的现状)。**未跑**:前端 vitest / typecheck / lint、`check:nginx-spa-routes`、`check:pingora-route-parity`(前端未动,且未新增 SPA 路由)、以及 `check-game-distribution-theme-e2e.mjs`(脚本尚未创建)。
**执行状态**:已跑门禁 —— `check:spacetime-schema`(**96** tables)、`check:game-distribution-dto-parity`(**58 组 / 10 构建器**)、`check:encoding`、`check:doc-index`、`git diff --check`、`cargo test`(见下「验收标准」的勾选与「证据要求」的现状)。**未跑**:前端 vitest / typecheck / lint、`check:nginx-spa-routes`、`check:pingora-route-parity`(前端未动,且未新增 SPA 路由)、以及真实栈 e2e(当时本地夹具脚本尚未写上;后续已补并在隔离实例上跑通,脚本未入库)。
## 验收标准
@@ -238,16 +238,16 @@ E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node scripts/check-game-d
**已落地(本条补)**:后台成员**名单读**接口——原缺口「后台要成员名单只能借道**公开投影**(`GET /api/game-distribution/themes/{theme_id}`),而公开投影只服务 `published` 主题,于是**草稿 / 归档主题在后台看不到成员**」已随 `GET /admin/api/game-distribution/themes/{theme_id}/members?limit=&cursor=` 补掉:不套公开可见性过滤、含当前不可见成员、每行带 `visible` / `visibility` 两个独立口径,`draft` / `archived` 照常可读。技术方案侧同步写在 §3.4 后台表与 §3.10.8。
**勾选口径**:勾选项由已落地的纯函数单测 / 事务结构断言 / api-server 定向测试 / DTO parity / schema 门禁证实;**未跑真实 dev 栈的端到端整链**(`scripts/check-game-distribution-theme-e2e.mjs` 未创建),所以凡依赖真实库存的运行时分支(未知主题 404、作品不存在 404、同键换请求 409、下架→重公开自动回归等)目前只到单元 / 结构 / 映射层。第 17 / 18 条(前端)**等前端**。
**勾选口径**:勾选项由已落地的纯函数单测 / 事务结构断言 / api-server 定向测试 / DTO parity / schema 门禁证实;**未跑真实 dev 栈的端到端整链**(本地夹具脚本(共创主题) 未创建),所以凡依赖真实库存的运行时分支(未知主题 404、作品不存在 404、同键换请求 409、下架→重公开自动回归等)目前只到单元 / 结构 / 映射层。第 17 / 18 条(前端)**等前端**。
## 证据要求
**现状**:自动化已覆盖纯函数 / 事务结构 / api-server 定向 / 契约 / schema;**运行时**(真实本地栈整链、浏览器桌面与窄屏)**未跑**,`scripts/check-game-distribution-theme-e2e.mjs` **未创建**——「证据要求」的运行时与脚本两项仍缺。
**现状**:自动化已覆盖纯函数 / 事务结构 / api-server 定向 / 契约 / schema;**运行时**(真实本地栈整链、浏览器桌面与窄屏)**未跑**,本地夹具脚本(共创主题) **未创建**——「证据要求」的运行时与脚本两项仍缺。
- 自动化:`module-game-distribution` 纯函数单测(可见性 / 排序 / 游标 / 根约束)、`spacetime-module` 事务断言、api-server 路由与错误码定向测试、DTO parity、schema 与生成绑定检查、前端 vitest、`check:encoding` / `check:doc-index` / `git diff --check`。
- 运行时:真实本地栈上完成「建主题(draft)→ 加两个根成员 → 发布 → 匿名读列表与详情 → 作品详情看到主题 → 把一个成员下架(从投影消失、行仍在)→ 重新公开(自动回来)→ 归档主题(公开侧不可见、行仍在)→ 后台列表仍可见」整链;浏览器在桌面与窄屏走一遍共创 Tab 与主题页。
- 边界:已发布但零可见成员、非根作品入成员、跨主题同根、非法游标、末页游标、未知主题 404、未带 admin 会话 401、重复成员写入、`DELETE` 不存在成员。
- 脚本:`scripts/check-game-distribution-theme-e2e.mjs` 的 `check(...)` 计数与失败项;脚本头部按既有惯例列出「契约来源」与「与工单描述不一致、按实现断言」的条目。
- 脚本:本地夹具脚本(共创主题) 的 `check(...)` 计数与失败项;脚本头部按既有惯例列出「契约来源」与「与工单描述不一致、按实现断言」的条目。
## 待确认项(需用户 / 产品拍板)
@@ -59,5 +59,5 @@
## 实施记录
- 2026-10-04:rebase 到含 #565(作品管理与 Phaser4 客户端发布)的新 master,9 个冲突文件按「两侧业务逻辑都保留」解完;详情页按 #565 的新结构改用共享 `GameDetailDisplay`,共创卡改由 `infoCards` 槽位渲染;并按 #565 的软删除语义收敛三处(来源校验 / 衍生计数 / 溯源摘要)。冲突解法与具体落点见技术方案 §3.9.1–§3.9.2,门禁与验收证据见 §5.1。
- 2026-10-06:按产品拍板收敛「授权继承」口径(取代「衍生作品落默认 `forbidden`、作者自己提升」):创建游戏事务在带血缘声明时取**父行当时的档位**写入新行(`resolve_game_distribution_fork_declaration_tx` 把父档位一并返回,新增纯函数 `resolve_game_distribution_creation_fork_authorization` 承载「衍生继承 / 母版按请求值」的裁决),客户端传值一律忽略且不报错;**收窄入口不做**;`PUT …/fork-authorization` 的只升不降语义当时**未改**(同日第二笔收紧,见下一条)。口径与落点见技术方案 §2.3 / §3.9 补充,证据见 §5.3。既有按旧语义写的两个 e2e 脚本(`scripts/check-game-distribution-lineage-e2e.mjs` 富树段、`scripts/capture-game-lineage-visual.mjs`)需要按新语义改成「创建后不再从 `forbidden` 提升」,本轮未改(不属本工作树权限),见 §5.3 的遗留项。
- 2026-10-06:按产品拍板收敛「授权继承」口径(取代「衍生作品落默认 `forbidden`、作者自己提升」):创建游戏事务在带血缘声明时取**父行当时的档位**写入新行(`resolve_game_distribution_fork_declaration_tx` 把父档位一并返回,新增纯函数 `resolve_game_distribution_creation_fork_authorization` 承载「衍生继承 / 母版按请求值」的裁决),客户端传值一律忽略且不报错;**收窄入口不做**;`PUT …/fork-authorization` 的只升不降语义当时**未改**(同日第二笔收紧,见下一条)。口径与落点见技术方案 §2.3 / §3.9 补充,证据见 §5.3。既有按旧语义写的两个 e2e 脚本(本地夹具脚本(族谱/血缘) 富树段、`scripts/capture-game-lineage-visual.mjs`)需要按新语义改成「创建后不再从 `forbidden` 提升」,本轮未改(不属本工作树权限),见 §5.3 的遗留项。
- 2026-10-06(同日第二笔):**衍生作品的授权档位收敛为「终态」**。上一笔只改了创建(继承父档位),但 `PUT …/fork-authorization` 仍对任意作品开放「只升不降」,作者可把继承来的 `nonCommercial` 再提成 `full`,得到比祖先更宽的子作品,把父作品在非商用授权下公开的工程内容变成可用商用。现在:**存在血缘行(衍生作品)→ 任何 PUT 一律拒绝**(含传同值),新稳定码 `FORK_AUTHORIZATION_INHERITED` → 409;判定在 `set_game_distribution_fork_authorization_tx` 里**先于 CAS**,规则抽成纯函数 `module_game_distribution::resolve_fork_authorization_promotion`(衍生终态 → CAS → 只升不降);母版语义完全不变。验收标准里「授权状态机」一项据此理解为「母版单向提升 + 衍生作品不可改」。口径见技术方案 §2.3 / §3.4 / §3.9 第二条补充,证据见 §5.5,决策理由见 `decision-log.md` 同日条目。
@@ -20,7 +20,7 @@
- 口径(照此实现):**衍生作品的任何 PUT 都拒绝**,含「传同值」这种幂等重试——客户端因此不会遇到「有时成功有时失败」的随机性。母版(无血缘行)语义**一律不变**:只升不降(可跳级)+ `expectedForkAuthorization` CAS + 幂等重放。
- 判定落点(一个事务的一步):`server-rs/crates/spacetime-module/src/game_distribution.rs` 的 `set_game_distribution_fork_authorization_tx`——**幂等重放分支之后、CAS 之前**查 `game_distribution_lineage().game_id().find(&game_id)`,把 `has_lineage_row` 连同 `current` / `expected_fork_authorization` / `target` 交给新增纯函数 `module_game_distribution::resolve_fork_authorization_promotion` 一次裁决(顺序:**衍生终态 → CAS → 只升不降**,失败都不写库)。衍生判定放在 CAS 之前是有意的:同一个衍生作品不会因为载荷不同(传同值 / 传新值 / 期望值过期)而回不同的码。判据只有这一份实现(事务里不再出现 `can_promote_to`,也不自己比 CAS 期望值)。
- 错误码可达性三件套:模块 `Display` 产出 `"{CODE}: 中文"`(码常量 `GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE`,Display 插值它);api-server 的 `FORK_` 映射表登记该码(409);纳入既有「FORK 码由模块真实文案可达」枚举测试(并保留「未登记码退化成 `FORK_ERROR`/409」的反证)。
- 测试改动:**没有既有 Rust 用例断言过「衍生作品 PUT 提升会成功」**——Rust 侧此前对该路径只有「未知档位 → 400 信封」与「路由未带 Bearer → 401」两条用例。故本轮的测试是**新增**:`module-game-distribution` 的 `derivative_put_is_rejected_even_with_the_same_or_stale_value`(同值 / 再提一级 / 期望值过期都回同码)、`master_put_still_promotes_with_unchanged_cas_semantics`(母版提升照旧 + 同级 / 降级 / CAS 不符语义未变)、`errors.rs` 的前缀断言,`spacetime-module` 的结构断言 `set_fork_authorization_tx_rejects_derivatives_before_cas`(血缘行先于裁决、裁决先于写库),api-server 的可达性枚举 + 常量字面量断言。断言旧语义的是 `scripts/check-game-distribution-lineage-e2e.mjs` / `scripts/capture-game-lineage-visual.mjs`(不属本工作树权限,见「遗留」)。
- 测试改动:**没有既有 Rust 用例断言过「衍生作品 PUT 提升会成功」**——Rust 侧此前对该路径只有「未知档位 → 400 信封」与「路由未带 Bearer → 401」两条用例。故本轮的测试是**新增**:`module-game-distribution` 的 `derivative_put_is_rejected_even_with_the_same_or_stale_value`(同值 / 再提一级 / 期望值过期都回同码)、`master_put_still_promotes_with_unchanged_cas_semantics`(母版提升照旧 + 同级 / 降级 / CAS 不符语义未变)、`errors.rs` 的前缀断言,`spacetime-module` 的结构断言 `set_fork_authorization_tx_rejects_derivatives_before_cas`(血缘行先于裁决、裁决先于写库),api-server 的可达性枚举 + 常量字面量断言。断言旧语义的是 本地夹具脚本(族谱/血缘) / `scripts/capture-game-lineage-visual.mjs`(不属本工作树权限,见「遗留」)。
- 契约与形状:**无列 / 表 / 索引 / DTO 形状变更**,只新增一个错误码常量与错误变体;`GameDistributionSetForkAuthorizationRequest` 的 serde 形状未动(幂等摘要不变)。
- 文档:技术方案 §2.3(授权模式 + 状态机)、§2.4(`/games/mine`、`/games/publish`、`/games/lineage`)、§2.6 时序图、§3.3 状态表、§3.4(`PUT` 行、`POST /games` 行、公开详情行、`/lineage` 行)、§3.9 新补充、§5.3 遗留、§7 第 2 条;数据契约文档的 `fork_authorization` 条目;里程碑日志。
- 顺带写清的三条口径(无代码):① `/lineage` 的**累计世代数由前端自算**——本作品子树的深度 = 子树最大代际 − 本作品代际(根作品若有 3 层后代则为 3),服务端不提供该字段;② 改动说明**已在版本摘要 payload 上**(公开详情 `currentVersion.changeSummary`,衍生为字符串、母版 `null`),族谱页右侧面板只对**选中节点**请求一次公开详情即可拿到——**不做 N+1、不给 `LineageNode` 贴该字段**;③ **衍生作品不会继承到 `forbidden`**:父作品为 `forbidden` 时不允许共创(`403 FORK_NOT_AUTHORIZED`),根本产生不出衍生作品。
@@ -35,7 +35,7 @@
- 契约与形状:`GameDistributionCreateGameRequest.forkAuthorization` 字段保留、serde 形状(非 `Option` + `#[serde(default)]`)不变——改了会让创建请求的幂等摘要漂移;不新增/删除列,无 schema 迁移。
- 未改(后被同日「档位终态」决策收紧,见上一条;「原样保留」现在只对**母版**成立):`PUT /api/game-distribution/games/{gameId}/fork-authorization` 的语义(只升不降、`expectedForkAuthorization` CAS、幂等重放)原样保留;它继续服务「提升」。
- 影响面:`server-rs/crates/spacetime-module/src/game_distribution.rs`(血缘解析返回值、创建事务、纯函数与测试)、`server-rs/crates/api-server/src/modules/game_distribution.rs`(创建 handler 注释)、`server-rs/crates/shared-contracts/src/game_distribution.rs` 与 `server-rs/crates/spacetime-client/src/game_distribution.rs` 与 `packages/shared/src/contracts/gameDistribution.ts`(字段注释)、技术方案 §2.3 / §3.2.1 / §3.4 / §3.9 / §7、数据契约文档、里程碑文档。
- 遗留(不在本次工作树权限内):`scripts/check-game-distribution-lineage-e2e.mjs` 与 `scripts/capture-game-lineage-visual.mjs` 仍按旧语义「创建衍生作品(落 `forbidden`)→ 再 PUT 从 `forbidden` 提升」,H 段富树还刻意让一个衍生节点保持 `forbidden`;改口径后那些 PUT 会因 CAS 不符返回 409。两脚本在工作树划分里属其它 owner,未改。
- 遗留(不在本次工作树权限内):本地夹具脚本(族谱/血缘) 与 `scripts/capture-game-lineage-visual.mjs` 仍按旧语义「创建衍生作品(落 `forbidden`)→ 再 PUT 从 `forbidden` 提升」,H 段富树还刻意让一个衍生节点保持 `forbidden`;改口径后那些 PUT 会因 CAS 不符返回 409。两脚本在工作树划分里属其它 owner,未改。
- 验证方式:新增/改写 3 条 `spacetime-module` 用例(继承矩阵 / 母版按请求值 + 未知失败关闭 / 创建事务取父行档位的结构断言),`cargo test -p spacetime-module` 301 passed / 1 ignored;`cargo test -p module-game-distribution` 119 passed;`cargo test -p api-server game_distribution` 104 passed(既有「缺省 `forbidden` 且幂等摘要不变」用例继续通过);`cargo test -p spacetime-client` 43 passed;wasm build、`cargo check --all-targets`、`check-game-distribution-dto-parity`、`check-project-bundle-policy-parity`、`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`、`npm run check:encoding`、`cargo fmt --check`、`git diff --check` 全部 exit 0。**没有改动任何 Rust 既有断言**(Rust 侧原本没有断言「衍生作品默认 `forbidden`」的用例;断言旧语义的是上面两个 e2e 脚本)。
## 2026-10-05 播放会话前缀在三处入口清空 Cookie,网关 403 纵深防御不变
@@ -106,20 +106,20 @@ npm run dev:all
通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。
### 本地 e2e 必须用隔离栈(2026-10-07)
### 本地 e2e / 夹具必须用隔离实例(2026-10-07)
**背景(实际事故)**:夹具类 e2e 脚本过去缺省跟着 `.app/dev-stack.json` 打**用户正在用的开发栈**,而「换端口」并不等于「换库」——`npm run dev` 与早期 pinned 端口那套都指向同一个 `genarrative-game-creator-dev` + 同一份 data dir。结果是跑几轮 e2e 就把夹具写进用户的库:实测盘点时该库有 **105 条作品 = 13 条演示 + 92 条夹具**(lineage 4 轮×9、theme 1 轮×54、collection 2、project-bundle 2、fork-authorization 2),而公开目录上限只有 48 条,用户的共创页几乎被夹具占满。
**背景(实际事故)**:夹具类 e2e 脚本过去缺省跟着 `.app/dev-stack.json` 打**用户正在用的开发栈**,而「换端口」并不等于「换库」——`npm run dev` 与早期 pinned 端口那套都指向同一个 `genarrative-game-creator-dev` + 同一份 data dir。结果是跑几轮 e2e 就把夹具写进用户的库:实测盘点时该库有 **105 条作品 = 13 条演示 + 92 条夹具**(族谱 4 轮×9、主题 1 轮×54、收藏 2、工程源包 2、共创授权 2),而公开目录一次只出最新 48 条,用户的共创页几乎被夹具占满。
**现在的口径(`scripts/e2e-target.mjs` 是单一事实来源)**:
**要求(这些是纪律,不是建议;本地夹具工具的路径不入库,团队按下面的做法自己准备工具即可)**:
1. **缺省隔离**:夹具类脚本在未显式给 `E2E_API_BASE` 时,目标固定为隔离栈 `http://127.0.0.1:8188`(web `3112`),不再回落到 dev-stack 标记。
2. **护栏**:解析出的目标与 `.app/dev-stack.json` 记录的 api-server **同地址或同端口**时,脚本**直接拒绝启动**(`exit 2`),提示二选一——用隔离栈,或 `E2E_API_BASE=… E2E_ALLOW_DEV_STACK=1` 明确越权。护栏只管**会写夹具**的脚本;只读工具(截图工具的「只截现有作品」模式)仍按 dev-stack 解析。
3. **一键隔离栈**:`npm run e2e:stack` 只起栈(前台长驻,Ctrl-C 结束);`npm run e2e:run -- --suite scripts/check-game-distribution-lineage-e2e.mjs [--suite …]` 起栈→等 `/healthz`→顺序跑 suite→自动停栈。隔离栈用**专用端口**(api 8188 / bgfilter 8189 / spacetime 3110 / admin-web 3111 / web 3112)、**专用库名** `genarrative-game-creator-e2e` 与 **临时 data-dir**(`%TEMP%/genarrative-e2e-spacetime-*`,默认跑完删除;`--keep-data` / `--keep-stack` 可保留)。
4. **夹具统一前缀 `E2E·`**:所有夹具标题都带它(`fixtureTitle()`),盘点/清理不再依赖随机数字后缀;`tags` 之外,标题前缀是**盘点唯一可靠标识**。
5. **跑批后自清理**:每个脚本正常结束时用本轮 owner token 把「自己造的、标题带 `E2E·` 且含本轮后缀」的作品逐条软删(owner 维度 `DELETE /api/game-distribution/my-games/{id}?expectedPublicationRevision=…`),并打印删除条数;清理失败或脚本中断时,`main().catch()` 会打印残留清单(owner + gameId + 标题),便于人工补删。
6. **例外**:`scripts/seed-game-distribution-co-creation.mjs` **有意**写用户的 dev 栈(它就是演示数据灌装工具,幂等、判重不绑账号),不受护栏约束;要写到别处请显式给 `E2E_API_BASE`。
1. **缺省隔离**:任何会往库里写测试数据的脚本,未显式给目标地址时**必须**指向**隔离实例**(专用端口 + 专用库名 + 临时 data-dir),不得回落到 `.app/dev-stack.json` 里用户正在用的那套。
2. **硬护栏**:脚本在启动阶段就要判定目标——解析出的 api-server 与 `.app/dev-stack.json` 记录的**同地址或同端口**即视为「用户开发栈」,**直接拒绝启动**(非零退出),除非调用方显式越权(`E2E_ALLOW_DEV_STACK=1` 之类)并在提示里写清后果。护栏只管**会写夹具**的脚本;只读工具可以照常读开发栈。
3. **隔离实例的组成**:专用端口组(api / bgfilter / spacetime / admin-web / web 各一份)、**专用库名**(例如 `*-e2e`,与 `genarrative-game-creator-dev` 区分开)、**临时 data-dir**(跑完删除,另留 `--keep-*` 逃生口)。隔离实例还必须把自己的 dev-stack 标记写到临时目录(`GENARRATIVE_DEV_STACK_STATE_PATH`),**绝不覆盖仓库根的 `.app/dev-stack.json`**,也**不做**「按 exe 路径清旧 api-server」的兜底清扫——两者都会把用户正在跑的那套栈踢下线(2026-10-07 实测踩到并已修)。
4. **夹具必须可辨识**:统一标题前缀(本仓口径 `E2E·`)+ 统一 tag,盘点/清理不依赖随机后缀;**演示数据**另有自己的前缀(`共创演示·`),两者不得混用。
5. **跑批后自清理**:脚本正常结束时用本轮 owner 身份把自己造的作品逐条**软删**(owner 维度 `DELETE /api/game-distribution/my-games/{id}?expectedPublicationRevision=…`),并打印删除条数;清理失败或脚本中断时,必须打印**残留清单**(owner + gameId + 标题)便于人工补删。**跑完不留残留**是交付判据之一。
6. **例外**:演示数据灌装工具**有意**写开发栈(它的用途就是把演示数据放进 dev 栈,且幂等、判重不绑账号),不受护栏约束;要写到别处请显式给目标地址。
**软删的语义**:`DELETE …/my-games/{id}` 只是软删(撤销当前公开版本 + 写 `deleted_at`),公开目录、族谱树、`my-games` 立刻都看不到,但**行仍在库里**、可由后台 `restore` 路径恢复;已软删的行不会出现在任何列表接口里,所以「清理后列表为空」不等于「行被物理删除」。
**软删的语义**:`DELETE …/my-games/{id}` 只是软删(撤销当前公开版本 + 写 `deleted_at`),公开目录、族谱树、`my-games` 立刻都看不到,但**行仍在库里**、可由后台恢复;已软删的行不会出现在任何列表接口里,所以「清理后列表为空」不等于「行被物理删除」。
单独启动主站前端:
@@ -317,6 +317,7 @@ pub(crate) project_bundle_sha256: Option<String>,
| 方法 / 路径 | 说明 |
| --- | --- |
| `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` 都读得到,缺的只是翻页通道 |
| `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 |
@@ -461,6 +462,10 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr
- **默认大纲式缩进树**(`LineageOutlineList`):全宽行、层级用左缩进 + 肘形导线表达、主干链导线加粗用主题色;**无横向滚动**、无需缩放平移;行高 ≥44px,点击行 = 选中(无 hover)。
- **图谱视图为可选开关**(形态 B = **横版画布**,`orientation: 'horizontal'`:根在左、代际向右、兄弟分支沿纵向排开,窄屏上比顶→下更省横向空间):切到画布后支持拖拽平移 / 缩放;横版**不渲染左侧代际轴**,代际信息由节点自带角标承担。**初次进入按宽度适配**(整棵树横向看完;触及最小缩放时给出「可拖拽平移」提示),并把**当前作品**用最小位移拉进视口——贴最近的边,父代 / 兄弟仍留在视口里,不会出现「停在 100% 缩放、一个节点都看不到」的空屏。桌面仍为纵向画布 + 宽高同时适配并居中。
- **信息面板用 bottom sheet**:从底部升起(含简介 / 授权 / 核心改动说明 / 前往体验 / Fork 此版本),遮罩点击或关闭按钮收起;桌面保持画布右上浮层。
- **父代 / 子代区分与「只看父代与子代」开关**(2026-10-06 追加):
- 每个节点相对**当前作品**有四种角色(纯函数 `collectLineageRelationRoles`):`self` / `ancestor`(**父代** = 祖先链)/ `descendant`(**子代** = 整棵后代子树)/ `side`(旁支)。渲染层打 `data-relation`,并在节点上给**可读角标**「父代」「子代」(自己不标——已有「当前作品」角标;旁支不标)。**不靠颜色深浅表达**,因此可断言、读屏可读。
- **「只看父代与子代」开关**在筛选行,默认**关**(= 以根为作用域的整棵树)。打开后只留 **祖先链(父代)+ 自己 + 直接子代**(`collectLineageImmediateScope`,**不含孙代**),语义是「看这个作品的上一步与下一步」;**再点一次取消**回到整棵树。作用域里找不到锚点时不筛选(避免空图);切换后仍重新定位到当前作品。反馈有三重:`aria-pressed`、按钮文案变「(已开启)」、旁边一行说明。
- 侧栏共创卡文案按同一口径:`{N 代…} · 改编自父代《…》` + `父代作者 …`;子代侧沿用「累计衍生作品 N」(全部世代)与「累计创作世代数」。
- 面包屑横向可滚动且**当前项常驻可见**;尊重 `prefers-reduced-motion`;底部留 `env(safe-area-inset-bottom)`。
- **移动端门限豁免(2026-10-06)**:平台原有的「移动端仅支持作品展示」欢迎弹窗只看 `!isDesktopLayout`、忽略路由,会把公开只读的族谱树 / 共创主题页首屏盖住。现在按**显式白名单**豁免这三个 stage:`game-lineage` / `game-themes` / `game-theme`(清单在 `PlatformActiveMobileWelcomeDialog.tsx` 的 `MOBILE_WELCOME_EXEMPT_STAGES`,键名即 stage 名,不做前缀/范围匹配)。**创作 / 项目 / 发布 / 我的 / 平台首页仍保持门限**——那些才是「移动端不支持体验创作工具」的部分;弹窗文案未改(与豁免后的事实不矛盾:族谱与主题页本身就是作品展示)。
- **截断 / 空树 / 不可用父节点**在两种形态下都有降级:截断提示、空态、以及「原作品已不可用」的占位节点(可聚焦、不可点进详情、不展示被删作品标题与作者名)。
@@ -936,7 +941,7 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`,
| 工程包校验器 | `module-game-distribution` 的 `validate_project_bundle_zip`:拒绝清单见 §3.5.2,上限与发行包**逐项相等**(测试用 `assert_eq!` 钉住);`package.rs` 已同批补强(读取层按声明大小封顶 + 压缩比分母改为条目压缩字节,+70/−4) | ✅ 已验证(**19 条校验器单测**;`cargo test -p module-game-distribution` **73 passed**) |
| 上行路由族 | 5 条路由(整包 / upload-state / chunk / complete / reset);对象键服务端派生且与发行包不同;唯一共享阶段门;`complete` 独立复核 zip + 422 映射 | ✅ 已验证(`cargo test -p api-server game_distribution` **60 passed**,含 6 条路由与阶段门/资产选择用例) |
| 下行优先与缓存 | `source` 取 `Project`(优先)/`Package`;半写行回落 `package`;`/fork-source/project` 无工程包时 409 而非静默回落;缓存键 = 对象键(资产维度) | ✅ 已验证(同一批 api-server 用例:对象键互不相等 + `ReleasePackageCache` 双资产不串味) |
| 端到端(真实本地栈 + 真实客户端链路) | `npm run check:game-distribution-project-bundle-e2e` → **51 个 `check(` 调用点**(复核另按 72 条断言计,差值来自同一 `check` 内的多条子断言;含 A4 / A5 / A5b / A6 与对抗用例:路径形状、凭据目录、嵌套包改名、符号链接、声明说谎) | ✅ 已验证 |
| 端到端(真实本地栈 + 真实客户端链路) | `npm run (本地夹具脚本,无 npm 条目)` → **51 个 `check(` 调用点**(复核另按 72 条断言计,差值来自同一 `check` 内的多条子断言;含 A4 / A5 / A5b / A6 与对抗用例:路径形状、凭据目录、嵌套包改名、符号链接、声明说谎) | ✅ 已验证 |
| 两端规则一致(机器门禁) | `npm run check-project-bundle-policy-parity` → OK(客户端打包器与服务端校验器的排除清单/上限逐项对齐,防止两端口径漂移) | ✅ 已验证 |
| 客户端 | AGC 打包器(确定性打包,**24 条单测**,含路径形状检查)、发布时上传(失败不阻断,且**上传前按 `max_archive_bytes` 预检**)、按 `source` 分支的取件建项、来源记录 v2(兼容 v1);`npx vitest run apps/ai-game-creator-shell/tests` 1961 passed(补强前一次的运行;本次未改前端 TS);AGC 定向 `cargo test -- project_bundle` **24 passed** | ✅ 已验证 |
| 校验器对抗性补强(P0-a/b/c) | 凭据识别补强(目录 `.aws` / `.ssh` / `.kube` / `.docker` / `.gnupg` / `.terraform` / `.secrets`;前缀 `credentials` / `id_ecdsa` / `id_dsa` / `terraform.tfstate` / `service-account`;后缀 `.jks` / `.keystore` / `.ppk` / `.p8` / `.kdbx` / `.der`;全名 `.htpasswd` / `.pgpass`,外加小文本内容嗅探 PEM / `AKIA…` / `ghp_` / `xox*`);嵌套包扩展名并集 + magic bytes 嗅探(改名也拦);读取层按声明大小封顶 + 压缩比分母改为条目压缩字节;新增错误变体 `CredentialDirectoryNotAllowed` / `SecretContentDetected` | ✅ 已验证(19 条校验器单测;**发行包回归** `cargo test -p api-server game_distribution` 60 passed,含新增「声明说谎 → `ReadFailed`」) |
@@ -958,7 +963,7 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`,
| 母版仍按请求值 / 缺省 `forbidden` | `creation_authorization_uses_the_request_for_masters_and_fails_closed_on_unknown`:`forbidden` / `nonCommercial` / `full` 原样落库;空串与未知取值失败关闭且文案带 `FORK_AUTHORIZATION_UNKNOWN:`。DTO 侧的缺省值(省略 = `forbidden` 且幂等摘要不变)由既有用例 `create_game_request_defaults_fork_authorization_without_changing_digest` 继续覆盖 | ✅ 已验证(`cargo test -p spacetime-module`、`cargo test -p api-server game_distribution` → 104 passed) |
| 契约不变 | `GameDistributionCreateGameRequest.forkAuthorization` 保留且 serde 形状未动(改形状会动幂等摘要);无列/表/索引变更,schema 基线比对通过 | ✅ 已验证(`check:spacetime-schema`:98 表对 `9f4c7d76` 通过) |
| 门禁 | wasm build 0;`cargo check --all-targets` 0(仅既有 `TEST_AGC_MODEL_DEFAULT_ID` dead_code 警告);`cargo test -p module-game-distribution` 0(119 passed);`cargo test -p api-server game_distribution` 0(104 passed);`cargo test -p spacetime-module` 0(301 passed / 1 ignored);`cargo test -p spacetime-client` 0(43 passed);`check-game-distribution-dto-parity` 0;`check-project-bundle-policy-parity` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 check:spacetime-schema` 0;`check:encoding` 0;`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0;另跑 `check:doc-index` 0 | ✅ 已验证 |
| 遗留:两个 e2e 脚本仍按旧语义 | `scripts/check-game-distribution-lineage-e2e.mjs` 富树段(`createPublishedNode` 的 `promoteTo` 自带 `from = 'forbidden'`,且 `richF1` 刻意留给 `forbidden`)与 `scripts/capture-game-lineage-visual.mjs`(同款 `promoteForkAuthorization`):改口径后衍生节点的创建档位已等于父档位,这些「再从 `from` 提升」的 PUT 现在**一律**回 409 `FORK_AUTHORIZATION_INHERITED`(不再只是 CAS 不符),需改成「按新继承档位断言 / 不再提升」。**2026-10-07 已由 `scripts/**` owner 改写**:衍生节点改为「探针 PUT 被拒(409 `FORK_AUTHORIZATION_INHERITED`)+ 档位不变」,母版仍断言可提升;同轮补上「核心改动说明落在版本摘要(`currentVersion.changeSummary`)」与贡献归集断言(`fa9ef9037`;lineage 188 项、capture 工具 67 项全绿)。 | ✅ 已改(2026-10-07) |
| 遗留:两个 e2e 脚本仍按旧语义 | 本地夹具脚本(族谱/血缘) 富树段(`createPublishedNode` 的 `promoteTo` 自带 `from = 'forbidden'`,且 `richF1` 刻意留给 `forbidden`)与 `scripts/capture-game-lineage-visual.mjs`(同款 `promoteForkAuthorization`):改口径后衍生节点的创建档位已等于父档位,这些「再从 `from` 提升」的 PUT 现在**一律**回 409 `FORK_AUTHORIZATION_INHERITED`(不再只是 CAS 不符),需改成「按新继承档位断言 / 不再提升」。**2026-10-07 已由 `scripts/**` owner 改写**:衍生节点改为「探针 PUT 被拒(409 `FORK_AUTHORIZATION_INHERITED`)+ 档位不变」,母版仍断言可提升;同轮补上「核心改动说明落在版本摘要(`currentVersion.changeSummary`)」与贡献归集断言(`fa9ef9037`;lineage 188 项、capture 工具 67 项全绿)。 | ✅ 已改(2026-10-07) |
| 客户端 UI 的衍生场景 | 已落地(后续提交):`src/components/game-distribution/MyGamesPage.tsx` 对衍生作品隐藏提升入口、改为只读展示「继承自父作品」(`4f888d8c9`,含用例「衍生作品的授权入口改为只读展示(授权继承父作品,不可单独提升)」);AGC 发布面板 `GameDistributionPublishPanel.tsx` 同轮改为只读展示「授权继承自父作品」 | ✅ 已接(由 `src/**`、`apps/**` owner 落地) |
---
@@ -991,27 +996,29 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`,
| 文档 | 技术方案 §2.3(授权模式 + 状态机)、§2.4(`/games/mine`、`/games/publish`、`/games/lineage`)、§2.6 时序图、§3.3 状态表、§3.4(`PUT` 行、`POST /games` 行、`/lineage` 行)、§3.9 第二条补充、§7 第 2 条;数据契约文档 `fork_authorization` 条目;里程碑实施记录;`decision-log.md` 同日条目 | ✅ 已改 |
| 门禁(本工作树自跑,逐条 exit) | wasm build 0;`cargo check --all-targets` 0(仅既有 `TEST_AGC_MODEL_DEFAULT_ID` dead_code 警告);`cargo test -p module-game-distribution` 0(**134 passed**,+3);`cargo test -p api-server game_distribution` 0(107 passed);`cargo test -p spacetime-module` 0(**305 passed / 1 ignored**,+1);`cargo test -p spacetime-client` 0(43 passed);`check-game-distribution-dto-parity` 0;`check-project-bundle-policy-parity` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema` 0(98 表);`npm run check:encoding` 0(5544 文件);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0 | ✅ 已验证 |
| 改动清单(测试) | **新增**:`module-game-distribution` 的 2 条行为用例 + `errors.rs` 1 条前缀用例、`spacetime-module` 1 条结构用例;**扩展**:api-server 的 `fork_error_codes_are_reachable_from_module_messages`(加一条码 + 常量字面量断言)、`module-game-distribution` 的 `fork_errors_are_prefixed_with_their_machine_code`(加一条码)。**没有既有 Rust 用例断言过「衍生作品 PUT 提升成功」**——旧语义的断言在两个 e2e 脚本里(见 §5.3 遗留) | ✅ 已改 |
| 遗留:两个 e2e 脚本 | `scripts/check-game-distribution-lineage-e2e.mjs`、`scripts/capture-game-lineage-visual.mjs` 里对**衍生作品**的 PUT 现在一律 409 `FORK_AUTHORIZATION_INHERITED`,需按新语义改写(按新继承档位断言 / 只对母版提升)。**2026-10-07 已改**:见 §5.3 同一行(`fa9ef9037`)——衍生作品 PUT 一律 409 `FORK_AUTHORIZATION_INHERITED` 已成断言口径,母版提升语义未变。前端入口与只读展示已落地(见 §5.3 对应的两行) | ✅ 已改(2026-10-07) |
| 遗留:两个 e2e 脚本 | 本地夹具脚本(族谱/血缘)、`scripts/capture-game-lineage-visual.mjs` 里对**衍生作品**的 PUT 现在一律 409 `FORK_AUTHORIZATION_INHERITED`,需按新语义改写(按新继承档位断言 / 只对母版提升)。**2026-10-07 已改**:见 §5.3 同一行(`fa9ef9037`)——衍生作品 PUT 一律 409 `FORK_AUTHORIZATION_INHERITED` 已成断言口径,母版提升语义未变。前端入口与只读展示已落地(见 §5.3 对应的两行) | ✅ 已改(2026-10-07) |
---
### 5.6 端到端实测覆盖与工具(2026-10-07,`scripts/**` owner)
> **脚本入库口径(2026-10-07 供需方要求)**:本表提到的夹具类/演示数据类 e2e 脚本(族谱、主题、收藏、工程源包、共创授权、演示数据灌装)是**本地验收脚本,未入库**——只存在于本地工作树,团队其他人拉分支拿不到;它们的路径写在本地 `info/exclude` 里,不在仓库。表中所有「已覆盖 / 实测结论」都是这些脚本**真实跑出来**的结果,结论有效。隔离与截图类工具(目标解析+护栏、隔离栈启动器、族谱截图、客户端 CDP 验收)仍在仓库里。
在**本地真实栈**上跑通的实测(地址从 `.app/dev-stack.json` 读:api-server `8082` / web `3000` / SpacetimeDB `3101` / admin-web `3102`,库 `genarrative-game-creator-dev`),按「平台 web → 客户端取件建项 → 回平台看血缘」这条链路分段列。**「覆盖到哪一段」以本表为准**(其余段落要么只能 API 等价复现,要么尚未自动化)。
| 链路分段 | 覆盖方式 | 脚本 / 证据 | 状态 |
| --- | --- | --- | --- |
| 平台 web:共创列表 / 作品详情 / 族谱页 / 主题页 | 真实浏览器(Playwright 驱动 Chromium,落 DOM 断言 + 截图) | `scripts/check-game-distribution-lineage-e2e.mjs`(F 段)、`scripts/capture-game-lineage-visual.mjs`、`scripts/check-game-distribution-theme-e2e.mjs` | ✅ 已覆盖 |
| Fork 授权三态(禁止 / 非商用 / 全开放)+ 衍生继承与「不可再提升」 | HTTP 契约(+ 浏览器入口) | `scripts/check-game-distribution-fork-authorization-e2e.mjs`;`…lineage-e2e.mjs` H 段(探针 PUT → 409 `FORK_AUTHORIZATION_INHERITED` 且档位不变;母版仍可提升) | ✅ 已覆盖 |
| 族谱与衍生:`/games/{id}/lineage`(按根聚合、200 上限、父作品下架降级)、`/derived` | HTTP 契约 + 浏览器 | `scripts/check-game-distribution-lineage-e2e.mjs`(A–E、G 段) | ✅ 已覆盖 |
| Fork 取件:成品包通道 + **工程源包下行优先** `/fork-source`;工程包上行校验(禁项 / 分片 / 幂等 / 阶段门) | HTTP 契约(无浏览器) | `scripts/check-game-distribution-project-bundle-e2e.mjs`、`…lineage-e2e.mjs` G 段 | ✅ 已覆盖 |
| 收藏(收录)与「我的收藏」 | HTTP 契约 | `scripts/check-game-distribution-collection-e2e.mjs` | ✅ 已覆盖 |
| 共创主题(公开读 + 后台写 + 作品详情 `themes` 增量 + 只允许根) | HTTP 契约 | `scripts/check-game-distribution-theme-e2e.mjs` | ✅ 已覆盖 |
| 贡献归集(作者视角 `GET /games/{id}/contribution`) | HTTP 契约(作者 Bearer) | `scripts/check-game-distribution-lineage-e2e.mjs` I 段 | ✅ 已覆盖 |
| 平台 web:共创列表 / 作品详情 / 族谱页 / 主题页 | 真实浏览器(Playwright 驱动 Chromium,落 DOM 断言 + 截图) | 本地夹具脚本(族谱/血缘)(F 段)、`scripts/capture-game-lineage-visual.mjs`、本地夹具脚本(共创主题) | ✅ 已覆盖 |
| Fork 授权三态(禁止 / 非商用 / 全开放)+ 衍生继承与「不可再提升」 | HTTP 契约(+ 浏览器入口) | 本地夹具脚本(共创授权);`…lineage-e2e.mjs` H 段(探针 PUT → 409 `FORK_AUTHORIZATION_INHERITED` 且档位不变;母版仍可提升) | ✅ 已覆盖 |
| 族谱与衍生:`/games/{id}/lineage`(按根聚合、200 上限、父作品下架降级)、`/derived` | HTTP 契约 + 浏览器 | 本地夹具脚本(族谱/血缘)(A–E、G 段) | ✅ 已覆盖 |
| Fork 取件:成品包通道 + **工程源包下行优先** `/fork-source`;工程包上行校验(禁项 / 分片 / 幂等 / 阶段门) | HTTP 契约(无浏览器) | 本地夹具脚本(工程源包)、`…lineage-e2e.mjs` G 段 | ✅ 已覆盖 |
| 收藏(收录)与「我的收藏」 | HTTP 契约 | 本地夹具脚本(收藏) | ✅ 已覆盖 |
| 共创主题(公开读 + 后台写 + 作品详情 `themes` 增量 + 只允许根) | HTTP 契约 | 本地夹具脚本(共创主题) | ✅ 已覆盖 |
| 贡献归集(作者视角 `GET /games/{id}/contribution`) | HTTP 契约(作者 Bearer) | 本地夹具脚本(族谱/血缘) I 段 | ✅ 已覆盖 |
| **真实 AGC 客户端里点一遍**:共创列表 → Fork 确认 → 取件校验 → 生成工程(写 `.agent/fork-source.json`)→ **运行可用** | **已自动化(2026-10-07)**:Tauri + WebView2 通过 CDP 附着真实客户端(客户端启动时带 `--remote-debugging-port=9222`,见 `~/.config/genarrative/.env` 的 `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`;探活 `curl --noproxy '*' http://127.0.0.1:9222/json/version` 应回 `Edg/…`) | `scripts/check-agc-co-creation-fork-e2e.mjs`(19 项,npm script `check:agc-co-creation-fork-e2e`):共创列表滚到底且无「直接 Fork 指定作品」输入块;确认页 3 秒门禁 + 须知无内层滚动 + 底部吸附条;Fork 后本机 `projects/gameagent-*` 的 `.agent/fork-source.json`(v2、`source: package`)、`game/dist/index.html`、无 `reference/`、`code-prototype=completed`、`versions=[initial-1]`;**运行视图可用**(无「首个可运行原型尚未完成」空态、运行表现层 + 预览 iframe) | ✅ 已覆盖 |
| 客户端里**发布**新作品(上传发行包 → 送审 → 管理员审核 → 平台看到新节点/血缘) | 未自动化:客户端发布面板需要人工填「本次核心改动说明」等 | 平台侧同一段由 `scripts/check-game-distribution-lineage-e2e.mjs` 的发布链路 + 血缘断言覆盖 | ⏳ 待补 |
| 客户端里**发布**新作品(上传发行包 → 送审 → 管理员审核 → 平台看到新节点/血缘) | 未自动化:客户端发布面板需要人工填「本次核心改动说明」等 | 平台侧同一段由 本地夹具脚本(族谱/血缘) 的发布链路 + 血缘断言覆盖 | ⏳ 待补 |
| 窄屏三档(360 / 390 / 414)移动端形态 | 真实浏览器 | `scripts/capture-game-lineage-visual.mjs` 的 `E2E_CAPTURE_NARROW_WIDTHS=360,390,414`:深链进 `/games/lineage` **不再被「移动端仅支持作品展示」弹窗盖住**、默认大纲式缩进树(行高 ≥44、有导线与层级缩进)、长标题不横向溢出、点节点弹底部 sheet 且可关闭、选中后当前面包屑已被滚进视口 | ✅ 已覆盖(另见下方两条未修问题) |
| 演示数据与视觉基线(供需求方验收) | 幂等灌数据 + 截图工具 | `scripts/seed-game-distribution-co-creation.mjs`(标题前缀 `共创演示·`,**判重不绑账号**:先查公开目录同标题再查本人 `my-games`;13 节点 / 3 棵树 / 4 作者 / 最大 3 代 / 13 张不同封面)、`scripts/capture-game-lineage-visual.mjs`(`E2E_CAPTURE_LINEAGE_URL` 开启「只截现有作品」模式) | ✅ 已覆盖 |
| 演示数据与视觉基线(供需求方验收) | 幂等灌数据 + 截图工具 | 本地演示数据灌装脚本(标题前缀 `共创演示·`,**判重不绑账号**:先查公开目录同标题再查本人 `my-games`;13 节点 / 3 棵树 / 4 作者 / 最大 3 代 / 13 张不同封面)、`scripts/capture-game-lineage-visual.mjs`(`E2E_CAPTURE_LINEAGE_URL` 开启「只截现有作品」模式) | ✅ 已覆盖 |
实测固定口径(可直接复用):pinned 端口 `--api-port 8188 --bgfilter-worker-port 8189 --spacetime-port 3110 --admin-web-port 3111 --web-port 3112`;管理员 `admin / 123456`;Playwright 走临时目录(`E2E_PLAYWRIGHT_DIR`)+ 本机 Chromium(`E2E_CHROMIUM_EXECUTABLE`);断言口径一律按**符号**引用实现(不写行号),详见各脚本头部注释。
@@ -1021,7 +1028,7 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`,
| 问题 | 证据 | 状态 |
| --- | --- | --- |
| 族谱画布(`layout="stacked"`)节点**不再显示作者名** | 原因:`LineageNodeContent` 只在 `layout === 'inline'` 渲染作者行,画布传 `stacked` → 作者整列消失(`aria-label` 里仍有,故只有视觉缺失)。修复 `9cf30517d`:仅**窄屏紧凑卡片**省作者,其余两种形态都渲染。复验:`check-game-distribution-lineage-e2e.mjs` → **188/188 PASS**(原唯一 FAIL「浏览器视觉:母版节点显示作者名」转绿);1440 桌面截图可见卡片上有「演示作者甲/乙/丙/丁」 | ✅ 已修并复验 |
| 族谱画布(`layout="stacked"`)节点**不再显示作者名** | 原因:`LineageNodeContent` 只在 `layout === 'inline'` 渲染作者行,画布传 `stacked` → 作者整列消失(`aria-label` 里仍有,故只有视觉缺失)。修复 `9cf30517d`:仅**窄屏紧凑卡片**省作者,其余两种形态都渲染。复验:本地夹具脚本(族谱/血缘) → **188/188 PASS**(原唯一 FAIL「浏览器视觉:母版节点显示作者名」转绿);1440 桌面截图可见卡片上有「演示作者甲/乙/丙/丁」 | ✅ 已修并复验 |
| 移动端切「图谱视图」(横向画布)**不会初始定位到当前作品** | 修复前 390 宽实测:`zoom=100%`、`transform: matrix(1,0,0,1,0,0)`,画布可视区 `x 37–353`,当前作品节点头部 `x 457–633` → 0/8 节点可见。修复 `ec8f27cb2`(横版画布 + 进入时按宽度适配并定位当前作品)。复验(360/390/414 三档):`zoom=35%`、当前作品在画布内、可见节点 **7/8(360、390)/ 8/8(414)** | ✅ 已修并复验 |
| (遗留备注)窄屏图谱视图里点卡片会跳详情 | 窄屏紧凑卡片整块是「打开详情」按钮,没有可锁定区域 → 信息面板/面包屑的窄屏验证改由**大纲形态**做(点行弹底部 sheet、面包屑出现并被滚进视口,三档均 PASS)。不是缺陷,但窄屏画布下确实没有「锁定」入口 | 📝 记录 |
-7
View File
@@ -83,8 +83,6 @@
"check:agc-project-snapshot-admin-http": "node scripts/check-agc-project-snapshot-admin-http.mjs",
"check:agc-update-channel-manifests": "node scripts/check-agc-update-channel-manifests.mjs",
"check:agc-co-creation-fork-e2e": "node scripts/check-agc-co-creation-fork-e2e.mjs",
"e2e:stack": "node scripts/e2e-stack.mjs --stack-only",
"e2e:run": "node scripts/e2e-stack.mjs",
"check:game-distribution-sandbox-e2e": "node scripts/check-game-distribution-sandbox-e2e.mjs",
"check:external-v1-scene-generation-smoke": "node scripts/check-external-v1-scene-generation-smoke.mjs",
"check:game-distribution-web-e2e": "node scripts/check-game-distribution-web-e2e.mjs",
@@ -94,11 +92,6 @@
"check:game-distribution-package-limits-e2e": "node scripts/check-game-distribution-package-limits-e2e.mjs",
"check:game-distribution-validation-restart-e2e": "node scripts/check-game-distribution-validation-restart-e2e.mjs",
"check:game-distribution-ops-rollback-e2e": "node scripts/check-game-distribution-ops-rollback-e2e.mjs",
"check:game-distribution-fork-authorization-e2e": "node scripts/check-game-distribution-fork-authorization-e2e.mjs",
"check:game-distribution-lineage-e2e": "node scripts/check-game-distribution-lineage-e2e.mjs",
"check:game-distribution-project-bundle-e2e": "node scripts/check-game-distribution-project-bundle-e2e.mjs",
"check:game-distribution-collection-e2e": "node scripts/check-game-distribution-collection-e2e.mjs",
"check:game-distribution-theme-e2e": "node scripts/check-game-distribution-theme-e2e.mjs",
"check:game-distribution-purchase-e2e": "node scripts/check-game-distribution-purchase-e2e.mjs",
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
"check:preview-deployer": "node scripts/check-preview-deployer.mjs",
+57 -13
View File
@@ -19,6 +19,7 @@
// - 布局纯函数:src/components/game-distribution/lineageTreeLayout.ts(缺失父作品会生成 stub 节点)
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
@@ -27,29 +28,72 @@ import { deflateSync } from 'node:zlib';
import JSZip from 'jszip';
import { resolveE2eTarget } from './e2e-target.mjs';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const DEV_PASSWORD = 'GenE2e123!';
const GATE_KEY = 'game-distribution:publish';
/** 隔离实例的缺省地址(专用端口;库名/data-dir 由隔离实例启动器负责,见《开发运维》「本地 e2e 必须用隔离实例」)。 */
const ISOLATED_API_BASE = 'http://127.0.0.1:8188';
const ISOLATED_WEB_BASE = 'http://127.0.0.1:3112';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD');
process.exit(2);
}
// 目标地址:造树模式会写夹具 → 缺省打隔离栈并拒绝误打用户开发栈;「只截现有作品」模式是只读
// (拿的是用户开发栈里的演示数据),仍按 `.app/dev-stack.json` 解析、不设护栏。
const captureOnlyMode =
(process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim() !== '';
const target = resolveE2eTarget({
scriptName: 'capture-game-lineage-visual.mjs',
writesFixtures: !captureOnlyMode,
});
const API = target.apiBase;
const WEB = target.webBase;
/**
* 解析目标地址(本工具**自带**这份逻辑,不依赖未入库的本地模块):
* - 「只截现有作品」模式是只读,按 `.app/dev-stack.json` 解析(要的就是用户开发栈里的演示数据);
* - 造树模式会写夹具 → 缺省打隔离实例,并拒绝误打用户开发栈。
*/
function readDevStackMarker() {
try {
return JSON.parse(
readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'),
);
} catch {
return null;
}
}
function normalizeBase(value) {
return String(value ?? '')
.trim()
.replace(/\/+$/u, '');
}
function resolveCaptureTarget() {
const marker = readDevStackMarker();
const devApi = normalizeBase(marker?.services?.['api-server']?.url);
const devWeb = normalizeBase(marker?.services?.web?.url);
const captureOnly = (process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim() !== '';
const explicitApi = normalizeBase(process.env.E2E_API_BASE);
const explicitWeb = normalizeBase(process.env.E2E_WEB_BASE);
const apiBase =
explicitApi ||
(captureOnly ? devApi || ISOLATED_API_BASE : ISOLATED_API_BASE);
const webBase =
explicitWeb ||
(captureOnly ? devWeb || ISOLATED_WEB_BASE : ISOLATED_WEB_BASE);
if (!captureOnly && (process.env.E2E_ALLOW_DEV_STACK ?? '').trim() !== '1') {
const samePort =
devApi &&
new URL(apiBase).port &&
new URL(apiBase).port === new URL(devApi).port;
if (devApi && (apiBase === devApi || samePort)) {
console.error(
[
`capture-game-lineage-visual.mjs: 目标 ${apiBase} 就是用户正在用的开发栈(.app/dev-stack.json → ${devApi})。`,
'造树模式会写夹具,缺省必须打隔离实例:二选一——',
` 1) 用隔离实例(缺省 api-server ${ISOLATED_API_BASE},web ${ISOLATED_WEB_BASE},库名/data-dir 见《开发运维》)`,
' 2) 明确越权:E2E_API_BASE=<dev 栈地址> E2E_ALLOW_DEV_STACK=1(这会真的写进用户的库)',
].join('\n'),
);
process.exit(2);
}
}
return { apiBase, webBase };
}
const { apiBase: API, webBase: WEB } = resolveCaptureTarget();
let checks = 0;
let failures = 0;
@@ -753,7 +797,7 @@ async function captureNarrowBreakpoints({ browser, lineageUrl, shotDir }) {
}
}
/// 「只截图、不造树」模式:给已有作品(例如 scripts/seed-game-distribution-co-creation.mjs 灌的共创网络)
/// 「只截图、不造树」模式:给已有作品(例如本地演示数据灌装脚本放进开发栈的共创网络)
/// 出验收截图。设 `E2E_CAPTURE_LINEAGE_URL` 即进入该模式——不注册账号、不建作品、不下架,纯 GET + 截图。
async function captureExistingUrls() {
const lineageUrl = (process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-188
View File
@@ -1,188 +0,0 @@
// 一次性隔离 e2e 栈:用**专用端口 + 专用数据库 + 临时 data-dir**拉起 dev.mjs,跑完把栈停掉、临时目录删掉。
//
// 为什么需要它:e2e 脚本会造夹具,跑在用户正在用的开发栈上会把用户的库灌脏(2026-10-07 盘点:
// `genarrative-game-creator-dev` 里有 92 条夹具)。隔离栈与用户的栈**不共享数据库、不共享
// data-dir、端口也不重叠**,因此可以随便造。
//
// 用法(等价 npm script:`npm run e2e:run -- <suite.mjs>` / `npm run e2e:stack`):
// node scripts/e2e-stack.mjs --suite scripts/check-game-distribution-lineage-e2e.mjs
// node scripts/e2e-stack.mjs # 只起栈(前台长驻,Ctrl-C 结束)
// 选项:
// --suite <path> 起栈就绪后顺序跑一个或多个(可重复)suite,跑完自动停栈
// --stack-only 只起栈(前台),不跑 suite
// --keep-data 结束后保留临时 data-dir(默认删除)
// --keep-stack 跑完 suite 也不停栈(供人工继续看)
//
// 端口/库名与 `scripts/e2e-target.mjs` 的 `E2E_ISOLATED_STACK` 一致(单一事实来源)。
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { E2E_ISOLATED_STACK } from './e2e-target.mjs';
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const argv = process.argv.slice(2);
const readFlagValues = (flag) => {
const values = [];
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === flag && argv[index + 1]) values.push(argv[index + 1]);
}
return values;
};
const suites = readFlagValues('--suite');
const stackOnly = argv.includes('--stack-only') || suites.length === 0;
const keepData = argv.includes('--keep-data');
const keepStack = argv.includes('--keep-stack');
const dataDir = mkdtempSync(path.join(tmpdir(), 'genarrative-e2e-spacetime-'));
console.log(`[e2e-stack] 隔离栈数据目录 ${dataDir}`);
const devArgs = [
'scripts/dev.mjs',
'--database',
E2E_ISOLATED_STACK.database,
'--spacetime-data-dir',
dataDir,
'--api-port',
String(E2E_ISOLATED_STACK.apiPort),
'--bgfilter-worker-port',
String(E2E_ISOLATED_STACK.bgfilterWorkerPort),
'--spacetime-port',
String(E2E_ISOLATED_STACK.spacetimePort),
'--admin-web-port',
String(E2E_ISOLATED_STACK.adminWebPort),
'--web-port',
String(E2E_ISOLATED_STACK.webPort),
'--no-interactive',
];
const stack = spawn(process.execPath, devArgs, {
cwd: repoRoot,
stdio: 'inherit',
env: {
...process.env,
// **关键**:隔离栈把自己的 dev-stack 标记写到临时目录,绝不覆盖仓库根的 `.app/dev-stack.json`,
// 否则用户正在跑的那套 dev 栈会以为自己的标记被替换而自行退出(2026-10-07 实测踩到)。
GENARRATIVE_DEV_STACK_STATE_PATH: path.join(dataDir, 'dev-stack.json'),
GENARRATIVE_ADMIN_USERNAME:
process.env.GENARRATIVE_ADMIN_USERNAME ??
process.env.E2E_ADMIN_USER ??
'admin',
GENARRATIVE_ADMIN_PASSWORD:
process.env.GENARRATIVE_ADMIN_PASSWORD ??
process.env.E2E_ADMIN_PASSWORD ??
'123456',
},
});
let stopping = false;
const stopStack = async () => {
if (stopping || keepStack) return;
stopping = true;
await new Promise((resolve) => {
if (stack.exitCode !== null || stack.signalCode !== null) {
resolve();
return;
}
const timer = setTimeout(() => {
stack.kill('SIGKILL');
resolve();
}, 15_000);
stack.once('exit', () => {
clearTimeout(timer);
resolve();
});
stack.kill('SIGTERM');
});
};
const cleanupDataDir = () => {
if (keepData) {
console.log(`[e2e-stack] 按 --keep-data 保留 ${dataDir}`);
return;
}
try {
rmSync(dataDir, { recursive: true, force: true });
console.log(`[e2e-stack] 已删除临时数据目录 ${dataDir}`);
} catch (error) {
console.log(`[e2e-stack] 删除临时目录失败(可手工清理):${error.message}`);
}
};
async function waitForReady(timeoutMs = 300_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (stack.exitCode !== null || stack.signalCode !== null) {
throw new Error(
`dev 栈提前退出:code=${stack.exitCode} signal=${stack.signalCode}`,
);
}
try {
const response = await fetch(
`http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort}/healthz`,
{ signal: AbortSignal.timeout(2_000) },
);
if (response.status === 200) return;
} catch {
// 未就绪:继续等
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
throw new Error('等待隔离栈 api-server /healthz 超时');
}
async function runSuite(scriptPath) {
console.log(`[e2e-stack] 运行 ${scriptPath}`);
const child = spawn(process.execPath, [scriptPath], {
cwd: repoRoot,
stdio: 'inherit',
env: {
...process.env,
E2E_API_BASE: `http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort}`,
E2E_WEB_BASE: `http://127.0.0.1:${E2E_ISOLATED_STACK.webPort}`,
},
});
const code = await new Promise((resolve) => child.once('exit', resolve));
return code ?? 1;
}
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
void (async () => {
await stopStack();
cleanupDataDir();
process.exit(130);
})();
});
}
try {
await waitForReady();
console.log(
`[e2e-stack] 隔离栈就绪:api=http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort} web=http://127.0.0.1:${E2E_ISOLATED_STACK.webPort} 库=${E2E_ISOLATED_STACK.database}`,
);
if (stackOnly) {
console.log(
'[e2e-stack] 只起栈模式:另一个终端里用 E2E_API_BASE/E2E_WEB_BASE 指向上面的地址跑脚本;Ctrl-C 结束。',
);
} else {
let exitCode = 0;
for (const suite of suites) {
const code = await runSuite(suite);
if (code !== 0) exitCode = code;
}
await stopStack();
cleanupDataDir();
process.exit(exitCode);
}
} catch (error) {
console.error(`[e2e-stack] ${error.message}`);
await stopStack();
cleanupDataDir();
process.exit(1);
}
-237
View File
@@ -1,237 +0,0 @@
// e2e 夹具隔离支持:目标解析 + 护栏 + 夹具标题前缀 + 跑批后自清理。
//
// 为什么需要它:2026-10-07 盘点发现,本地 e2e 脚本缺省会跟着 `.app/dev-stack.json` 打**用户正在用的
// 开发栈**(`npm run dev` 的那套库),每个脚本每跑一轮就往用户的库里塞十几到几十条夹具
// (`genarrative-game-creator-dev` 一度有 105 条 = 13 演示 + 92 夹具)。本模块把口径改成:
//
// 1. **默认打隔离栈**:夹具类脚本在未显式给 `E2E_API_BASE` 时使用 8188/8189/3110/3111/3112 这套
// 专用端口(由 `npm run e2e:stack` 拉起,配套 `--database genarrative-game-creator-e2e` 与
// 临时 data-dir),不再回落到 dev 栈标记里的 api-server。
// 2. **护栏**:解析出的目标若与 `.app/dev-stack.json` 记录的 api-server **同源或同端口**,
// 夹具类脚本**直接拒绝启动**(exit 2),除非显式 `E2E_ALLOW_DEV_STACK=1`。
// 3. **夹具前缀**:所有夹具标题统一带 `E2E·` 前缀(`fixtureTitle()`),盘点/清理一眼可辨,
// 不再依赖随机数字后缀。
// 4. **跑批后自清理**:`cleanupRunFixtures()` 用本次跑批的 owner token + 标题前缀把本轮夹具
// 软删(owner 维度 DELETE);清理失败或脚本中断时用 `reportLeftoverFixtures()` 打印待清理清单。
//
// 只读类脚本(例如截图工具的「只截现有作品」模式)应当传 `writesFixtures: false`:它们没有理由
// 拒打 dev 栈,默认仍跟着 `.app/dev-stack.json`。
import { readFileSync } from 'node:fs';
import path from 'node:path';
/** 专用隔离栈(与 `npm run e2e:stack` 的端口/data-dir 一一对应)。 */
export const E2E_ISOLATED_STACK = {
apiPort: 8188,
bgfilterWorkerPort: 8189,
spacetimePort: 3110,
adminWebPort: 3111,
webPort: 3112,
apiBase: 'http://127.0.0.1:8188',
webBase: 'http://127.0.0.1:3112',
database: 'genarrative-game-creator-e2e',
};
/** 夹具标题统一前缀:盘点/清理按它一眼区分(不要只靠随机后缀)。 */
export const FIXTURE_TITLE_PREFIX = 'E2E·';
/** 夹具标题:`E2E·<标签> <后缀>`。 */
export function fixtureTitle(label, suffix) {
return `${FIXTURE_TITLE_PREFIX}${label} ${suffix}`;
}
function normalizeBase(value) {
return String(value ?? '')
.trim()
.replace(/\/+$/u, '');
}
function readDevStackMarker(cwd = process.cwd()) {
try {
return JSON.parse(
readFileSync(path.resolve(cwd, '.app/dev-stack.json'), 'utf8'),
);
} catch {
return null;
}
}
/** 容错读取 dev-stack 标记(不存在/坏 JSON 都回 null):只给日志与护栏判定用。 */
export { readDevStackMarker };
function portOf(value) {
try {
return new URL(normalizeBase(value)).port;
} catch {
return '';
}
}
/** 解析出的目标是否就是 `.app/dev-stack.json` 记录的那套栈(同地址或同端口都算)。 */
export function isDevStackTarget(apiBase, marker) {
const devApi = normalizeBase(marker?.services?.['api-server']?.url);
if (!devApi) return false;
const target = normalizeBase(apiBase);
if (target === devApi) return true;
const targetPort = portOf(target);
return Boolean(targetPort) && targetPort === portOf(devApi);
}
function devStackGuardMessage({ scriptName, apiBase, devApi }) {
return [
`${scriptName}: 目标 ${apiBase} 就是用户正在用的开发栈(.app/dev-stack.json → ${devApi})。`,
'夹具类 e2e 缺省应打隔离栈,避免把夹具写进用户的库。二选一:',
` 1) 用隔离栈:npm run e2e:stack(缺省 api-server http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort},库 ${E2E_ISOLATED_STACK.database})`,
' 2) 明确越权:E2E_API_BASE=<dev 栈地址> E2E_ALLOW_DEV_STACK=1(这会真的写进用户的库)',
].join('\n');
}
/**
* 解析本次跑批的目标地址。
*
* @param {object} options
* @param {string} options.scriptName 打印提示用
* @param {boolean} [options.writesFixtures] 本脚本是否会往目标栈写夹具(默认 true)
* @param {boolean} [options.allowIsolatedFallback] 夹具类脚本在未显式给 E2E_API_BASE 时用隔离栈(默认 true)
* @returns {{ apiBase: string, webBase: string, devStackApi: string, devStackWeb: string, allowDevStackOverride: boolean }}
*/
export function resolveE2eTarget({
scriptName,
writesFixtures = true,
cwd = process.cwd(),
} = {}) {
const marker = readDevStackMarker(cwd);
const devStackApi = normalizeBase(marker?.services?.['api-server']?.url);
const devStackWeb = normalizeBase(marker?.services?.web?.url);
const allowDevStackOverride =
(process.env.E2E_ALLOW_DEV_STACK ?? '').trim() === '1';
const explicitApi = normalizeBase(process.env.E2E_API_BASE);
const explicitWeb = normalizeBase(process.env.E2E_WEB_BASE);
const fallbackApi = writesFixtures
? E2E_ISOLATED_STACK.apiBase
: devStackApi || E2E_ISOLATED_STACK.apiBase;
const fallbackWeb = writesFixtures
? E2E_ISOLATED_STACK.webBase
: devStackWeb || E2E_ISOLATED_STACK.webBase;
const apiBase = explicitApi || fallbackApi;
const webBase = explicitWeb || fallbackWeb;
if (
writesFixtures &&
!allowDevStackOverride &&
isDevStackTarget(apiBase, marker)
) {
console.error(
devStackGuardMessage({ scriptName, apiBase, devApi: devStackApi }),
);
process.exit(2);
}
return {
apiBase,
webBase,
devStackApi,
devStackWeb,
allowDevStackOverride,
writesFixtures,
};
}
/**
* 跑批后自清理:把本轮夹具(owner 视角里标题以 `E2E·` 开头且含本次后缀的那些)逐条软删。
*
* 只删**自己账号名下、标题命中本轮后缀**的作品:不碰演示数据,也不碰别人造的东西。
* 清理失败不抛异常,改为返回失败清单,由调用方打印。
*
* @param {object} options
* @param {(pathname: string, options?: object) => Promise<any>} options.api 各脚本自带的 api()(带信封/鉴权)
* @param {Array<{ token: string, label: string }>} options.owners 本轮用到的 owner token(作者 / 分叉者)
* @param {string} options.suffix 本轮后缀(`String(Date.now()).slice(-6)`)
* @param {string} [options.titlePrefix] 夹具前缀(默认 `E2E·`)
*/
export async function cleanupRunFixtures({
api,
owners,
suffix,
titlePrefix = FIXTURE_TITLE_PREFIX,
}) {
const failures = [];
const deleted = [];
for (const owner of owners) {
if (!owner?.token) continue;
const list = await api('/api/game-distribution/my-games', {
token: owner.token,
});
if (list.status !== 200) {
failures.push({
owner: owner.label,
reason: `列我的作品失败 status=${list.status}`,
});
continue;
}
const mine = (list.data?.games ?? []).filter(
(game) =>
typeof game.title === 'string' &&
game.title.startsWith(titlePrefix) &&
game.title.includes(suffix),
);
for (const game of mine) {
const revision = game.publicationRevision ?? 0;
const response = await api(
`/api/game-distribution/my-games/${game.id}?expectedPublicationRevision=${revision}`,
{ method: 'DELETE', token: owner.token },
);
if (response.status === 200) {
deleted.push({ owner: owner.label, id: game.id, title: game.title });
} else {
failures.push({
owner: owner.label,
id: game.id,
title: game.title,
reason: `status=${response.status} ${response.error?.code ?? ''}`,
});
}
}
}
return { deleted, failures };
}
/** 中断/失败路径:把「本轮还没清掉的夹具」打印出来,便于人工补删。 */
export async function reportLeftoverFixtures({
api,
owners,
suffix,
titlePrefix = FIXTURE_TITLE_PREFIX,
log = console.log,
}) {
const leftovers = [];
for (const owner of owners) {
if (!owner?.token) continue;
const list = await api('/api/game-distribution/my-games', {
token: owner.token,
});
if (list.status !== 200) continue;
for (const game of list.data?.games ?? []) {
if (
typeof game.title === 'string' &&
game.title.startsWith(titlePrefix) &&
game.title.includes(suffix)
) {
leftovers.push({ owner: owner.label, id: game.id, title: game.title });
}
}
}
if (leftovers.length === 0) {
log(`[e2e-fixture] 本轮(后缀 ${suffix})在目标库里没有残留夹具`);
return leftovers;
}
log(
`[e2e-fixture] 本轮(后缀 ${suffix})仍有 ${leftovers.length} 条夹具未清理,可手工软删:`,
);
for (const entry of leftovers) {
log(` - ${entry.owner} ${entry.id} ${entry.title}`);
}
return leftovers;
}
File diff suppressed because it is too large Load Diff
@@ -871,12 +871,12 @@ function GameDetailContent({
*/}
<span className="game-fork-lineage">
{lineage.parentTitle
? `${coCreationGenerationTagLabel(lineage.generation)} · 改编自《${lineage.parentTitle}》`
? `${coCreationGenerationTagLabel(lineage.generation)} · 改编自父代《${lineage.parentTitle}》`
: `${coCreationGenerationTagLabel(lineage.generation)} · 原作品已不可用`}
</span>
{lineage.parentAuthorName ? (
<span className="game-fork-lineage__author">
由 {lineage.parentAuthorName} 制作
父代作者 {lineage.parentAuthorName}
</span>
) : null}
</>
@@ -426,8 +426,8 @@ describe('GameDetailPage', () => {
);
expect(await screen.findByText('允许非商用共创')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy();
expect(screen.getByText('由 原作者 制作')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
expect(screen.getByText('父代作者 原作者')).toBeTruthy();
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
// 已公开 + 授权开放 → 「一键 Fork 共创」入口出现(M1 时它还不存在,这里钉住新契约)。
expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy();
@@ -2385,6 +2385,65 @@ function buildChainDataset() {
};
}
/**
* 父代 / 子代数据集:root → mid → current(锚点)→ child1/child2(直接子代),
* child1 → grandchild(孙代,不算「直接子代」);root → uncle 是旁支。
*/
const relationTree = {
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
}),
createLineageNode({
gameId: 'game-mid',
title: '中间代',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-uncle',
title: '旁支叔伯',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-current',
title: '当前作品',
generation: 2,
parentGameId: 'game-mid',
}),
createLineageNode({
gameId: 'game-child-1',
title: '子代甲',
generation: 3,
parentGameId: 'game-current',
}),
createLineageNode({
gameId: 'game-child-2',
title: '子代乙',
generation: 3,
parentGameId: 'game-current',
}),
createLineageNode({
gameId: 'game-grandchild',
title: '孙代作品',
generation: 4,
parentGameId: 'game-child-1',
}),
],
truncated: false,
};
/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */
const openButton = (title: string) =>
screen.getByRole('button', { name: `打开《${title}》详情` });
@@ -2452,6 +2511,126 @@ describe('GameLineagePage 可视化与交互', () => {
).toEqual(['一代作者', '当前作者', '根作者']);
});
/** 画布上所有节点卡片的标题(排序后比较,避免依赖布局顺序)。 */
const canvasTitles = (canvas: HTMLElement) =>
[...canvas.querySelectorAll('.game-lineage-node')]
.map(
(card) =>
card.querySelector('.game-lineage-node__title')?.textContent ?? '',
)
.sort();
it('区分父代 / 子代 / 自己 / 旁支:data-relation + 可读角标', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const currentCard = await cardOf('当前作品');
// 锚点 = 自己:不给「父代/子代」角标(它就是「当前作品」)。
expect(currentCard.getAttribute('data-relation')).toBe('self');
expect(
currentCard.querySelector('.game-lineage-node__relation'),
).toBeNull();
// 祖先链 = 父代。
for (const title of ['根原创', '中间代']) {
const card = await cardOf(title);
expect(card.getAttribute('data-relation')).toBe('ancestor');
expect(
within(card).getByText('父代', {
selector: '.game-lineage-node__relation',
}),
).toBeTruthy();
}
// 后代(含孙代)= 子代。
for (const title of ['子代甲', '子代乙', '孙代作品']) {
const card = await cardOf(title);
expect(card.getAttribute('data-relation')).toBe('descendant');
expect(
within(card).getByText('子代', {
selector: '.game-lineage-node__relation',
}),
).toBeTruthy();
}
// 旁支不冒充直系。
const uncleCard = await cardOf('旁支叔伯');
expect(uncleCard.getAttribute('data-relation')).toBe('side');
expect(uncleCard.querySelector('.game-lineage-node__relation')).toBeNull();
});
it('「只看父代与子代」:只留祖先链 + 自己 + 直接子代,再点一次恢复整棵树', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvas = (await screen.findByLabelText('创作族谱树')) as HTMLElement;
// 默认关:整棵树(含旁支与孙代)。
expect(canvasTitles(canvas)).toEqual(
[
'当前作品',
'中间代',
'子代乙',
'子代甲',
'旁支叔伯',
'根原创',
'孙代作品',
].sort(),
);
const toggle = screen.getByRole('button', { name: '只看父代与子代' });
expect(toggle.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(toggle);
// 开:父代(祖先链)+ 自己 + **直接**子代——不多不少。
expect(canvasTitles(canvas)).toEqual(
['当前作品', '根原创', '子代乙', '子代甲', '中间代'].sort(),
);
expect(canvasTitles(canvas)).not.toContain('孙代作品');
expect(canvasTitles(canvas)).not.toContain('旁支叔伯');
// 状态有明确的可视/可读反馈:按钮文案 + aria-pressed + 说明行。
expect(
screen
.getByRole('button', { name: '只看父代与子代(已开启)' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByText(
'只显示当前作品的父代(祖先链)与直接子代,再点一次恢复整棵树',
),
).toBeTruthy();
// 取消:回到整棵树。
fireEvent.click(
screen.getByRole('button', { name: '只看父代与子代(已开启)' }),
);
expect(canvasTitles(canvas)).toEqual(
[
'当前作品',
'中间代',
'子代乙',
'子代甲',
'旁支叔伯',
'根原创',
'孙代作品',
].sort(),
);
expect(
screen
.getByRole('button', { name: '只看父代与子代' })
.getAttribute('aria-pressed'),
).toBe('false');
});
it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
@@ -3212,6 +3391,42 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
}
});
it('移动端:「只看父代与子代」开关可用,大纲行也标出父代 / 子代', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
// 默认关:整棵树(7 个节点)。
expect(outline.querySelectorAll('.lineage-outline__row').length).toBe(7);
fireEvent.click(screen.getByRole('button', { name: '只看父代与子代' }));
const rows = [...outline.querySelectorAll('.lineage-outline__row')];
// 开:父代链(根原创 / 中间代)+ 自己 + 直接子代(子代甲 / 子代乙)——不多不少。
expect(rows.length).toBe(5);
expect(rows.map((row) => row.getAttribute('data-relation')).sort()).toEqual(
['ancestor', 'ancestor', 'descendant', 'descendant', 'self'],
);
// 大纲行里同样是**可读角标**(不是只靠描边颜色)。
expect(
[...outline.querySelectorAll('.game-lineage-node__relation')]
.map((element) => element.textContent)
.sort(),
).toEqual(['子代', '子代', '父代', '父代']);
// 取消:回到整棵树。
fireEvent.click(
screen.getByRole('button', { name: '只看父代与子代(已开启)' }),
);
expect(outline.querySelectorAll('.lineage-outline__row').length).toBe(7);
});
it('窄屏仍能点进详情页与查看完整族谱入口(大纲模式不丢入口)', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
@@ -3294,7 +3509,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
expect(await screen.findByText('2 代三创')).toBeTruthy();
expect(screen.getByText('允许非商用共创')).toBeTruthy();
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
// 主操作按文档叫「一键 Fork 共创」。
expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy();
});
@@ -32,8 +32,11 @@ import { LineageOutlineList } from './LineageOutlineList';
import {
buildLineageTreeLayout,
collectLineageHighlight,
collectLineageImmediateScope,
collectLineageRelationRoles,
generationAxisLabel,
type LineageLayoutNode,
type LineageRelationRole,
} from './lineageTreeLayout';
type GameLineagePageProps = {
@@ -77,6 +80,7 @@ function LineageTreeNodeCard({
isOnPath,
isPinned,
compact,
relation,
onOpenDetail,
onActivate,
onDeactivate,
@@ -91,6 +95,8 @@ function LineageTreeNodeCard({
isPinned: boolean;
/** 窄屏紧凑模式:少一行信息、卡片更小。 */
compact: boolean;
/** 相对「当前作品」的角色:父代 / 子代 / 自己 / 旁支(打 `data-relation` + 角标)。 */
relation: LineageRelationRole;
onOpenDetail: (gameId: string) => void;
onActivate: (gameId: string) => void;
onDeactivate: () => void;
@@ -133,6 +139,7 @@ function LineageTreeNodeCard({
}}
data-generation={generation}
data-branch={layoutNode.branchIndex}
data-relation={relation}
// 卡片主体点击 = **选中**(持续高亮祖先链 + 子树);打开详情在标题/「打开作品」按钮上。
tabIndex={isStub ? 0 : undefined}
aria-label={accessibleName}
@@ -180,6 +187,7 @@ function LineageTreeNodeCard({
isStub={isStub}
isTrunk={layoutNode.isTrunk}
compact={compact}
relation={relation}
/>
</button>
<span className="game-lineage-node__actions">
@@ -298,6 +306,23 @@ export function GameLineagePage({
}, [loadLineage]);
const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]);
// 高亮锚点(= 「当前作品」,也是「父代 / 子代」的基准)必须早于筛选链路声明。
const anchorId = highlightGameId?.trim() || normalizedRootId;
/**
* 每个节点相对当前作品的角色:`self` / `ancestor`(父代)/ `descendant`(子代)/ `side`(旁支)。
* 用**未筛选**的整棵树算,所以世代筛选、只看父子、排序都不会改口径。
*/
const relationRoles = useMemo(
() => collectLineageRelationRoles(nodes, anchorId),
[anchorId, nodes],
);
/** 「只看父代与子代」的保留集合:祖先链 + 自己 + 直接子代。 */
const relatedScope = useMemo(
() => collectLineageImmediateScope(nodes, anchorId),
[anchorId, nodes],
);
/** 开关:默认关(显示以根为作用域的整棵树),打开后只看父代与直接子代,再点一次取消。 */
const [onlyRelated, setOnlyRelated] = useState(false);
/**
* 顶部筛选器:按世代筛选(保留该代及其祖先,树不断链)与按热度排序
* (同代内 `playCount` 倒序;关闭时保持服务端顺序 = 创建时间序)。
@@ -307,8 +332,16 @@ export function GameLineagePage({
generationFilter === null
? [...nodes]
: nodes.filter((node) => node.generation <= generationFilter);
if (!sortByHeat) return kept;
return kept
// 「只看父代与子代」:作用域里没有锚点(例如锚点不在可见集合里)时不筛选,
// 免得渲染出一张空图。
const scoped = onlyRelated
? kept.filter((node) => relatedScope.has(node.gameId))
: kept;
const useScoped =
onlyRelated && scoped.some((node) => node.gameId === anchorId);
const result = useScoped ? scoped : kept;
if (!sortByHeat) return result;
return result
.map((node, index) => ({ node, index }))
.sort(
(left, right) =>
@@ -317,7 +350,14 @@ export function GameLineagePage({
left.index - right.index,
)
.map((item) => item.node);
}, [generationFilter, nodes, sortByHeat]);
}, [
anchorId,
generationFilter,
nodes,
onlyRelated,
relatedScope,
sortByHeat,
]);
const generationsPresent = useMemo(
() =>
[...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b),
@@ -379,7 +419,6 @@ export function GameLineagePage({
generation: item.generation,
}));
}, [layout.nodes, pathIds, pinnedGameId]);
const anchorId = highlightGameId?.trim() || normalizedRootId;
const effectiveViewMode =
viewMode ?? (isMobileViewport ? 'outline' : 'canvas');
const trunkGameIds = useMemo(
@@ -789,6 +828,23 @@ export function GameLineagePage({
>
{effectiveViewMode === 'canvas' ? '大纲视图' : '图谱视图'}
</button>
{/*
「只看父代与子代」:默认关(整棵树)。打开后只留当前作品的祖先链(父代)
与**直接**子代;再点一次取消。作用是「聚焦一支」——树大了以后不必在旁支里找。
*/}
<button
type="button"
className="game-lineage-filter__switch game-lineage-filter__switch--relation"
aria-pressed={onlyRelated}
onClick={() => setOnlyRelated((current) => !current)}
>
{onlyRelated ? '只看父代与子代(已开启)' : '只看父代与子代'}
</button>
{onlyRelated ? (
<span className="game-lineage-filter__note" role="status">
只显示当前作品的父代(祖先链)与直接子代,再点一次恢复整棵树
</span>
) : null}
{/*
改动类型筛选:`changeSummary` 字段已上线,但它是**自由文本**(服务端没有分类枚举),
因此仍然**禁用 + 说明**,不编造筛选值域;等后端给出可枚举的改动类型再接。
@@ -885,6 +941,7 @@ export function GameLineagePage({
currentGameId={anchorId}
trunkGameIds={trunkGameIds}
selectedGameId={pinnedGameId}
relationByGameId={relationRoles}
onSelect={(gameId) =>
setPinnedGameId((current) =>
current === gameId ? '' : gameId,
@@ -950,6 +1007,9 @@ export function GameLineagePage({
isOnPath={Boolean(pathIds?.has(layoutNode.gameId))}
isPinned={pinnedGameId === layoutNode.gameId}
compact={compactTree}
relation={
relationRoles.get(layoutNode.gameId) ?? 'side'
}
onTogglePin={setPinnedGameId}
onOpenDetail={onOpenDetail}
onActivate={setActiveGameId}
@@ -1,5 +1,6 @@
import type { GameDistributionGame } from '../../services/gameDistributionClient';
import { GameCover } from './GameCover';
import type { LineageRelationRole } from './lineageTreeLayout';
import { generationAxisLabel } from './lineageTreeLayout';
/**
@@ -21,6 +22,7 @@ export function LineageNodeContent({
isStub = false,
isTrunk = false,
compact = false,
relation = 'side',
}: {
title: string | null;
authorName: string | null;
@@ -32,6 +34,12 @@ export function LineageNodeContent({
isTrunk?: boolean;
/** 画布卡片的紧凑态(窄屏图谱):省掉作者行;大纲/血缘链行始终保留作者名。 */
compact?: boolean;
/**
* 相对「当前作品」的角色:父代(祖先链)/ 子代(后代)/ 自己 / 旁支。
* 只给**父代 / 子代**渲染角标(自己是「当前作品」角标、旁支不标注),
* 是可读文本而不是只靠颜色,因此可断言。
*/
relation?: LineageRelationRole;
}) {
const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品');
const authorLabel = isStub
@@ -65,6 +73,15 @@ export function LineageNodeContent({
</span>
<span className="game-lineage-node__text">
<span className="game-lineage-node__title">{displayTitle}</span>
{relation === 'ancestor' || relation === 'descendant' ? (
// 「父代 / 子代」是可读文本角标(不是只靠描边颜色),所以能被断言、也能被读屏。
<span
className={`game-lineage-node__relation game-lineage-node__relation--${relation}`}
data-relation={relation}
>
{relation === 'ancestor' ? '父代' : '子代'}
</span>
) : null}
{/*
作者名在两种形态都渲染(画布卡片曾因重构一度漏掉,导致节点少了作者信息、
也踩到了 e2e 的作者名断言):只有窄屏图谱的紧凑卡片才省掉它。
@@ -1,4 +1,5 @@
import { LineageNodeContent } from './LineageNodeContent';
import type { LineageRelationRole } from './lineageTreeLayout';
/**
* 移动端**大纲式缩进树**(默认形态):全宽行 + 左缩进 + 肘形导线。
@@ -15,6 +16,7 @@ export function LineageOutlineList({
currentGameId,
trunkGameIds,
selectedGameId,
relationByGameId,
onSelect,
onOpenDetail,
onOpenFork,
@@ -31,6 +33,8 @@ export function LineageOutlineList({
currentGameId: string;
trunkGameIds: ReadonlySet<string>;
selectedGameId: string;
/** 每个节点相对「当前作品」的角色(父代 / 子代 / 自己 / 旁支):与画布同一份口径。 */
relationByGameId: ReadonlyMap<string, LineageRelationRole>;
onSelect: (gameId: string) => void;
onOpenDetail: (gameId: string) => void;
onOpenFork: (gameId: string) => void;
@@ -40,6 +44,7 @@ export function LineageOutlineList({
{nodes.map((node) => {
const isCurrent = node.gameId === currentGameId;
const isSelected = node.gameId === selectedGameId;
const relation = relationByGameId.get(node.gameId) ?? 'side';
return (
<li
key={node.gameId}
@@ -47,6 +52,7 @@ export function LineageOutlineList({
isSelected ? ' lineage-outline__row--selected' : ''
}`}
data-generation={node.generation}
data-relation={relation}
// 层级用左缩进表达:每代 1.1rem,最多缩进 6 代避免窄屏被挤没。
style={{ paddingLeft: `${Math.min(node.generation, 6) * 1.1}rem` }}
>
@@ -82,6 +88,7 @@ export function LineageOutlineList({
isAnchor={isCurrent}
isStub={node.isStub}
isTrunk={trunkGameIds.has(node.gameId)}
relation={relation}
/>
</button>
{node.isStub ? null : (
@@ -2434,6 +2434,45 @@
font-weight: 600;
cursor: pointer;
}
/* 开关按下态:明确的可视反馈(不是只靠 aria-pressed 属性)。 */
.game-lineage-filter__switch[aria-pressed='true'] {
border-color: var(--platform-accent-strong);
background: rgba(199, 101, 61, 0.12);
color: var(--platform-accent-strong);
}
/*
* 「父代 / 子代」角标:**可读文本**(不是只靠颜色深浅),所以可断言、读屏也能读到;
* 自己是「当前作品」角标、旁支不标注。颜色只是辅助表达。
*/
.game-lineage-node__relation {
align-self: flex-start;
padding: 0 0.35rem;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 0.62rem;
line-height: 1.6;
white-space: nowrap;
}
.game-lineage-node__relation--ancestor {
color: var(--platform-accent-strong);
background: rgba(199, 101, 61, 0.1);
}
.game-lineage-node__relation--descendant {
color: var(--platform-text-base);
background: var(--platform-surface-muted, rgba(0, 0, 0, 0.05));
}
/* 画布卡片按角色加描边(辅助:主表达仍是上面的角标与 data-relation)。 */
.game-lineage-node[data-relation='ancestor'] {
border-color: var(--platform-accent-strong);
}
.lineage-outline__row[data-relation='ancestor'] .lineage-outline__main {
border-left: 3px solid var(--platform-accent-strong);
}
.lineage-outline__row[data-relation='descendant'] .lineage-outline__main {
border-left: 3px solid var(--platform-surface-border);
}
.game-lineage-filter__switch[aria-pressed='true'] {
border-color: var(--platform-accent-strong);
color: var(--platform-accent-strong);
@@ -7,6 +7,8 @@ import {
buildLineageTreeLayout,
collectLineageAncestorChain,
collectLineageHighlight,
collectLineageImmediateScope,
collectLineageRelationRoles,
collectLineageSubtreeDepth,
generationAxisLabel,
LINEAGE_COLUMN_GAP,
@@ -317,9 +319,9 @@ describe('横向朝向(移动端图谱视图)', () => {
expect(hById.get('a')!.y).not.toBe(hById.get('b')!.y);
expect(hById.get('a')!.x).toBe(hById.get('b')!.x);
// 同代(同一列)节点在纵向上至少隔一个列间距,结构上不重叠。
expect(Math.abs(hById.get('a')!.y - hById.get('b')!.y)).toBeGreaterThanOrEqual(
hById.get('a')!.height + 1,
);
expect(
Math.abs(hById.get('a')!.y - hById.get('b')!.y),
).toBeGreaterThanOrEqual(hById.get('a')!.height + 1);
expect(vertical.nodes[0]!.y).toBeLessThan(vertical.nodes[1]!.y);
});
@@ -435,3 +437,103 @@ describe('collectLineageHighlight', () => {
expect([...highlighted].sort()).toEqual(['child', 'game-removed']);
});
});
/**
* 血缘关系数据集:root → a → a1 → a1x,root → b(旁支),a → a2(第二个子代)。
* 用来钉住「父代 / 子代 / 自己 / 旁支」四类角色与「只看父代与子代」的集合口径。
*/
const relationTree = [
node('root', 0, null),
node('a', 1, 'root'),
node('b', 1, 'root'),
node('a1', 2, 'a'),
node('a2', 2, 'a'),
node('a1x', 3, 'a1'),
];
describe('collectLineageRelationRoles', () => {
it('区分自己 / 父代(祖先链)/ 子代(整棵后代子树)/ 旁支', () => {
const roles = collectLineageRelationRoles(relationTree, 'a');
expect(roles.get('a')).toBe('self');
expect(roles.get('root')).toBe('ancestor');
expect(roles.get('a1')).toBe('descendant');
expect(roles.get('a2')).toBe('descendant');
expect(roles.get('a1x')).toBe('descendant');
// 兄弟分支是旁支:不冒充父代/子代。
expect(roles.get('b')).toBe('side');
});
it('根节点:自己没有父代,所有后代都是子代', () => {
const roles = collectLineageRelationRoles(relationTree, 'root');
expect([...roles.entries()].filter(([, r]) => r === 'ancestor')).toEqual(
[],
);
expect(
[...roles.entries()]
.filter(([, r]) => r === 'descendant')
.map(([id]) => id)
.sort(),
).toEqual(['a', 'a1', 'a1x', 'a2', 'b']);
});
it('叶节点:只有父代链,没有子代', () => {
const roles = collectLineageRelationRoles(relationTree, 'a1x');
expect(roles.get('a1x')).toBe('self');
expect(
[...roles.entries()]
.filter(([, r]) => r === 'ancestor')
.map(([id]) => id)
.sort(),
).toEqual(['a', 'a1', 'root']);
expect(
[...roles.values()].filter((role) => role === 'descendant'),
).toHaveLength(0);
});
it('单节点树:自己是 self,没有父代/子代', () => {
const roles = collectLineageRelationRoles([node('solo', 0, null)], 'solo');
expect([...roles.entries()]).toEqual([['solo', 'self']]);
});
it('锚点不在可见集合里:全部按旁支返回(不抛错、不臆造关系)', () => {
const roles = collectLineageRelationRoles(relationTree, 'game-missing');
expect([...roles.values()].every((role) => role === 'side')).toBe(true);
});
it('自环数据不会死循环', () => {
const cyclic = [node('x', 1, 'y'), node('y', 0, 'x')];
const roles = collectLineageRelationRoles(cyclic, 'x');
expect(roles.get('x')).toBe('self');
expect(roles.get('y')).toBe('ancestor');
});
});
describe('collectLineageImmediateScope(只看父代与子代)', () => {
it('保留祖先链 + 自己 + **直接**子代(不含孙代)', () => {
const scope = collectLineageImmediateScope(relationTree, 'a');
// a1x 是孙代(a 的子代的子代),不在「只看父子」的范围里。
expect([...scope].sort()).toEqual(['a', 'a1', 'a2', 'root']);
expect(scope.has('a1x')).toBe(false);
expect(scope.has('b')).toBe(false);
});
it('根节点:自己 + 直接子代', () => {
expect(
[...collectLineageImmediateScope(relationTree, 'root')].sort(),
).toEqual(['a', 'b', 'root']);
});
it('离树的锚点返回空集合(调用方据此不筛选)', () => {
expect(
collectLineageImmediateScope(relationTree, 'game-missing').size,
).toBe(0);
});
it('父代不在可见集合里时链停在子节点上(父仍由布局层补占位)', () => {
const scope = collectLineageImmediateScope(
[node('child', 1, 'game-removed'), node('grandchild', 2, 'child')],
'child',
);
expect([...scope].sort()).toEqual(['child', 'grandchild']);
});
});
@@ -308,8 +308,14 @@ export function buildLineageTreeLayout(
const horizontal = orientation === 'horizontal';
// 竖版:父底中点 → 子顶中点;横版:父右边中点 → 子左边中点。
const from = horizontal
? { x: roundTo(parent.x + parent.width), y: roundTo(parent.y + parent.height / 2) }
: { x: roundTo(parent.x + parent.width / 2), y: roundTo(parent.y + parent.height) };
? {
x: roundTo(parent.x + parent.width),
y: roundTo(parent.y + parent.height / 2),
}
: {
x: roundTo(parent.x + parent.width / 2),
y: roundTo(parent.y + parent.height),
};
const to = horizontal
? { x: roundTo(node.x), y: roundTo(node.y + node.height / 2) }
: { x: roundTo(node.x + node.width / 2), y: roundTo(node.y) };
@@ -321,7 +327,10 @@ export function buildLineageTreeLayout(
);
const mid = roundTo((horizontal ? from.x : from.y) + delta * 0.5);
const path = (() => {
if (cornerRadius <= 0 || (horizontal ? from.y === to.y : from.x === to.x)) {
if (
cornerRadius <= 0 ||
(horizontal ? from.y === to.y : from.x === to.x)
) {
return horizontal
? `M ${from.x},${from.y} H ${to.x}`
: `M ${from.x},${from.y} V ${to.y}`;
@@ -448,6 +457,89 @@ export function collectLineageAncestorChain<
return chain.reverse();
}
/** 节点相对「当前作品」的角色(文档口径的**父代 / 子代**)。 */
export type LineageRelationRole = 'self' | 'ancestor' | 'descendant' | 'side';
/**
* 每个节点相对 `gameId` 的角色:
*
* - `self`:当前作品本体;
* - `ancestor`:沿 `parentGameId` 上溯到的**父代**(祖先链,不含自己);
* - `descendant`:沿 `parentGameId` **反向**走到的**子代**(整棵后代子树);
* - `side`:与当前作品没有直系血缘的旁支。
*
* 纯函数、稳定(同输入同输出);渲染层据此打 `data-relation` 并显示「父代 / 子代」角标,
* 不用「颜色深浅」这类无法断言的表达。
*/
export function collectLineageRelationRoles<
T extends { gameId: string; parentGameId?: string | null },
>(nodes: readonly T[], gameId: string): Map<string, LineageRelationRole> {
const roles = new Map<string, LineageRelationRole>();
for (const node of nodes) roles.set(node.gameId, 'side');
const byId = new Map(nodes.map((node) => [node.gameId, node]));
if (!byId.has(gameId)) return roles;
roles.set(gameId, 'self');
// 父代:沿 parentGameId 上溯(防环)。
const guard = new Set<string>([gameId]);
let cursor = byId.get(gameId);
while (cursor?.parentGameId) {
const parentId = cursor.parentGameId;
if (guard.has(parentId) || !byId.has(parentId)) break;
guard.add(parentId);
roles.set(parentId, 'ancestor');
cursor = byId.get(parentId);
}
// 子代:按 parentGameId 反向 BFS(整棵后代子树)。
const childrenByParent = new Map<string, string[]>();
for (const node of nodes) {
const parentId = node.parentGameId ?? null;
if (!parentId) continue;
const bucket = childrenByParent.get(parentId);
if (bucket) bucket.push(node.gameId);
else childrenByParent.set(parentId, [node.gameId]);
}
const stack = [...(childrenByParent.get(gameId) ?? [])];
while (stack.length > 0) {
const id = stack.pop() as string;
// 只给还是「旁支」的节点定子代:脏数据里出现环时(某人既是祖先又是后代),
// 先判定的**父代**优先,保证结果稳定、不来回翻。
if (roles.get(id) !== 'side') continue;
roles.set(id, 'descendant');
stack.push(...(childrenByParent.get(id) ?? []));
}
return roles;
}
/**
* 「只看父代与子代」要保留的节点集合:当前作品的**祖先链**(父代)+ **直接子代** + 自己。
*
* 注意是**直接**子代(下一层),不是整棵后代子树——开关的语义是「只看这个作品的上一步与下一步」,
* 想看整棵树关掉开关即可。父代不在可见集合里时链停在那里(与「父作品不可用」口径一致,
* 布局层仍会给那个缺失的父补一个占位节点)。
*
* 找不到 `gameId` 时返回空集合:调用方据此「不筛选」而不是渲染空图。
*/
export function collectLineageImmediateScope<
T extends { gameId: string; parentGameId?: string | null },
>(nodes: readonly T[], gameId: string): Set<string> {
const scope = new Set<string>();
const byId = new Map(nodes.map((node) => [node.gameId, node]));
if (!byId.has(gameId)) return scope;
scope.add(gameId);
let cursor = byId.get(gameId);
while (cursor?.parentGameId) {
const parentId = cursor.parentGameId;
if (scope.has(parentId) || !byId.has(parentId)) break;
scope.add(parentId);
cursor = byId.get(parentId);
}
for (const node of nodes) {
if ((node.parentGameId ?? null) === gameId) scope.add(node.gameId);
}
return scope;
}
/**
* hover / 聚焦一个节点时要高亮的集合:它自己 + 它的祖先路径;`includeSubtree` 为真时再加上
* 它整棵子树。其余节点在渲染层降透明度。