diff --git a/package.json b/package.json
index b3a27df3c..43f20797b 100644
--- a/package.json
+++ b/package.json
@@ -90,6 +90,7 @@
"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:production-ops": "node scripts/check-production-ops-guardrails.mjs",
"check:preview-deployer": "node scripts/check-preview-deployer.mjs",
"check:maintenance-page": "node scripts/check-maintenance-page.mjs",
diff --git a/scripts/check-game-distribution-lineage-e2e.mjs b/scripts/check-game-distribution-lineage-e2e.mjs
new file mode 100644
index 000000000..273f3a238
--- /dev/null
+++ b/scripts/check-game-distribution-lineage-e2e.mjs
@@ -0,0 +1,1188 @@
+// 游戏分发「创作族谱与衍生列表(M3)」链路真实行为验收。
+// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server [+ web])+ 管理员账号。
+//
+// 用法(Windows + bash 均可;只依赖 Node 内置模块 + 仓库已有依赖):
+// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \
+// node scripts/check-game-distribution-lineage-e2e.mjs
+// SKIP_BROWSER=1 只跑 HTTP 契约部分(跳过 Playwright 步骤)
+// E2E_API_BASE / E2E_WEB_BASE 覆盖地址;默认从 CWD 的 .app/dev-stack.json 读取(不写死端口)
+// E2E_PLAYWRIGHT_DIR 指向临时装了 playwright 的目录(仓库 devDependencies 不含 playwright)
+// E2E_CHROMIUM_EXECUTABLE 指定 Chromium 可执行文件(默认用 Playwright 自带浏览器)
+// E2E_LINEAGE_LABEL_* 覆盖前端文案(默认对齐实现里的字符串)
+//
+// 参考的仓库既有实现与脚本(本脚本按同一风格写成,契约形状全部来自代码而不是猜测):
+// [1] 路由挂载(公开、匿名、no-store):server-rs/crates/api-server/src/modules/game_distribution.rs:419-426(public_games)
+// [2] 「不可读即 404」映射:api-server/src/modules/game_distribution.rs:1090-1092(lineage_read_or_not_found)
+// [3] 两个 handler:api-server/src/modules/game_distribution.rs:1096(get_game_lineage)/ :1114(get_game_derived_games)
+// [4] 节点 DTO 的 7 个键:server-rs/crates/shared-contracts/src/game_distribution.rs:262-281
+// 响应::283-293(LineageResponse)/ :295-301(DerivedResponse)
+// [5] 上限 200 / 锚点可读性 / 稳定排序:server-rs/crates/module-game-distribution/src/lineage.rs:22 / :28-30 / :135-143
+// [6] 作者软删除:api-server/src/modules/game_distribution.rs:1518-1550;事务语义(撤销当前公开版本 + deleted_at)
+// server-rs/crates/spacetime-module/src/game_distribution.rs:3599-3660
+// [7] 改编声明 DTO:shared-contracts/src/game_distribution.rs:241-245 + :367-368;
+// 消费点 api-server/...:1611-1615;事务校验 spacetime-module/...:2155-2160 + :2250-2254
+// [8] 发布链路(建版本 → 传包 → 送审 → 管理员通过)照抄 scripts/check-game-distribution-media-e2e.mjs:391-400,431-450,455-470,479-495,523-527,561-573
+// [9] 封面直传 + 确认:scripts/check-game-distribution-owner-isolation.mjs:97-150
+// [10] 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-ratings-e2e.mjs:15-27
+// [11] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs:51-57,74-90,148-159
+// [12] 前端契约:详情页入口 GameDetailPage.tsx:409「查看创作族谱」;族谱页 GameLineagePage.tsx:65-70(节点卡片类名)、
+// :93「原作品已不可用」、:163-164 标题;路由 /games/lineage:src/routing/activeAppPageRoutes.ts:18
+// [13] 节点作者名读时联账号表:spacetime-module/src/game_distribution.rs:4294-4308(所以脚本会先给作者改名,让「删除后不外发作者名」这条断言有意义)
+//
+// 依赖提示(重要):
+// 改编声明 `fork: { parentGameId, parentVersionId }` 的消费链路当前在**未提交的 M2a WIP** 里
+// (工作树 api-server/...:1611-1615 已接,但提交基线 4207d24f0 尚未包含)。在 M2a 落地前:
+// - A 段(404 规则)与 E 段的「未公开锚点 404」可以照跑;
+// - B/C 段(真实树、软删除降级)会因为创建 B 时 fork 声明不生效而失败 —— 那是依赖未就绪,不是脚本问题。
+//
+// 契约假设(读代码确认,不猜):
+// - anchor 必须公开可读,否则 /lineage 与 /derived 都返回 **404**(不是 200 空树、也不是 409)。
+// - 节点只含「已公开且未软删除」的作品;根被删时 `root` 为 null,但 `rootGameId` 仍回传。
+// - 节点键集合恰好 7 个:gameId / title / authorName / generation / parentGameId / playCount / status。
+// - 排序:代际升序 → 创建时间升序 → gameId 升序(本脚本只断言代际升序这一段)。
+// - 上限 200 且超限时 `truncated: true`;本脚本不构造 >200 真实节点,只把常量钉在源码上(见 E 段说明)。
+
+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';
+import path from 'node:path';
+
+import JSZip from 'jszip';
+
+const COVER_PNG = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
+ 'base64',
+);
+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';
+const SKIP_BROWSER = (process.env.SKIP_BROWSER ?? '').trim() === '1';
+
+const LINEAGE_ENTRY_LABEL =
+ process.env.E2E_LINEAGE_LABEL_ENTRY ?? '查看创作族谱';
+const LINEAGE_PAGE_LABEL = process.env.E2E_LINEAGE_LABEL_PAGE ?? '创作族谱';
+const LINEAGE_ROOT_LABEL = process.env.E2E_LINEAGE_LABEL_ROOT ?? '母版';
+const LINEAGE_ANCHOR_LABEL = process.env.E2E_LINEAGE_LABEL_ANCHOR ?? '当前作品';
+const LINEAGE_PARENT_UNAVAILABLE =
+ process.env.E2E_LINEAGE_LABEL_PARENT_UNAVAILABLE ?? '原作品已不可用';
+
+// 节点键集合由实现钉死(shared-contracts/src/game_distribution.rs:262-281),多一个键就失败。
+const LINEAGE_NODE_KEYS = [
+ 'authorName',
+ 'gameId',
+ 'generation',
+ 'parentGameId',
+ 'playCount',
+ 'status',
+ 'title',
+];
+// 这 7 个键之外不该出现的东西:对象键 / 素材键 / 包摘要等私有字段。
+const PRIVATE_KEY_PATTERN =
+ /objectKey|assetId|coverObjectKey|screenshots|packageSha|sha256|bucket|oss|downloadPath/i;
+// 取件元数据里 sha256 / bytes / downloadPath 本身是契约字段,所以这里只查「对象键 / 存储域名」。
+const FORK_SOURCE_LEAK_PATTERN =
+ /objectKey|coverObjectKey|bucket|aliyuncs|oss-cn|\.zip/iu;
+
+if (!ADMIN_USER || !ADMIN_PASSWORD) {
+ console.error(
+ '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' +
+ 'game-distribution:publish 写入口并走完整发布链路;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' +
+ 'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
+ );
+ process.exit(2);
+}
+
+const devStack = JSON.parse(
+ readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'),
+);
+const API = (
+ process.env.E2E_API_BASE ??
+ devStack.services?.['api-server']?.url ??
+ ''
+).replace(/\/+$/u, '');
+const WEB = (
+ process.env.E2E_WEB_BASE ??
+ devStack.services?.web?.url ??
+ 'http://127.0.0.1:3000'
+).replace(/\/+$/u, '');
+if (!API) {
+ console.error(
+ '无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' +
+ '或用 E2E_API_BASE 显式指定。',
+ );
+ process.exit(2);
+}
+
+let checks = 0;
+let failures = 0;
+let skipped = 0;
+function check(name, ok, detail = '') {
+ checks += 1;
+ if (!ok) failures += 1;
+ console.log(
+ `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
+ );
+}
+function skip(name, detail = '') {
+ skipped += 1;
+ console.log(`SKIP ${name}${detail ? ` :: ${detail}` : ''}`);
+}
+// 不计入断言、但必须让读者看到的信息(例如按成本降级的覆盖项)。
+function note(message) {
+ console.log(`NOTE ${message}`);
+}
+
+// ---------- HTTP helpers(对齐 check-game-distribution-fork-authorization-e2e.mjs) ----------
+
+async function api(pathname, options = {}) {
+ const { method = 'GET', token, body, headers = {}, binary } = options;
+ const finalHeaders = { ...ENVELOPE, ...headers };
+ if (token) finalHeaders.Authorization = `Bearer ${token}`;
+ let finalBody;
+ if (binary) {
+ finalBody = binary;
+ } else if (body !== undefined) {
+ finalHeaders['Content-Type'] = 'application/json';
+ finalBody = JSON.stringify(body);
+ }
+ const response = await fetch(`${API}${pathname}`, {
+ method,
+ headers: finalHeaders,
+ body: finalBody,
+ signal: AbortSignal.timeout(30_000),
+ });
+ const text = await response.text();
+ let json = null;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ json = null;
+ }
+ return {
+ status: response.status,
+ text,
+ json,
+ data: json?.data,
+ error: json?.error,
+ cacheControl: response.headers.get('cache-control') ?? '',
+ };
+}
+
+function brief(body) {
+ const code = body?.error?.code ?? body?.json?.error?.code ?? '';
+ return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 200)}`;
+}
+
+async function register(prefix) {
+ const phone = `${prefix}${String(Date.now()).slice(-8)}`;
+ const response = await api('/api/auth/entry', {
+ method: 'POST',
+ body: { purePhoneNumber: phone, password: DEV_PASSWORD },
+ });
+ return { phone, response, token: response.data?.token };
+}
+
+// 作者名读时联账号表(spacetime-module/src/game_distribution.rs:4294-4308),
+// 所以先改名,C 段的「删除后不外发作者名」才有实际含义。
+async function setDisplayName(token, displayName) {
+ return api('/api/profile/me', {
+ method: 'PATCH',
+ token,
+ body: { displayName },
+ });
+}
+
+function gameMetadata({ title, coverAssetId }) {
+ return {
+ title,
+ summary: '创作族谱验收临时作品',
+ description: '',
+ category: '休闲',
+ tags: ['e2e'],
+ coverAssetId,
+ deviceSupport: { desktop: true, mobile: false, touch: false },
+ inputModes: ['keyboard', 'mouse'],
+ orientation: 'landscape',
+ };
+}
+
+// 封面直传 + 确认(对齐 owner-isolation.mjs:97-150;只往 dev bucket 写一个 67 字节 PNG)。
+async function uploadCover(token, id) {
+ const fileName = `lineage-${id}.png`;
+ const ticket = await api('/api/assets/direct-upload-tickets', {
+ method: 'POST',
+ token,
+ body: {
+ legacyPrefix: 'generated-character-drafts',
+ pathSegments: ['game-distribution', 'lineage', String(id)],
+ fileName,
+ contentType: 'image/png',
+ access: 'private',
+ maxSizeBytes: COVER_PNG.length,
+ metadata: { asset_kind: 'game_distribution_cover' },
+ },
+ });
+ if (ticket.status !== 200) {
+ throw new Error(
+ `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
+ );
+ }
+ const upload = ticket.data.upload;
+ const form = new FormData();
+ for (const [key, value] of Object.entries(upload.formFields ?? {})) {
+ if (value !== null && value !== undefined) form.append(key, String(value));
+ }
+ form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
+ const put = await fetch(upload.host, { method: 'POST', body: form });
+ if (!put.ok) {
+ throw new Error(`直传对象存储失败 ${put.status}`);
+ }
+ const confirm = await api('/api/assets/objects/confirm', {
+ method: 'POST',
+ token,
+ body: {
+ bucket: upload.bucket,
+ objectKey: upload.objectKey,
+ contentType: 'image/png',
+ contentLength: COVER_PNG.length,
+ assetKind: 'game_distribution_cover',
+ accessPolicy: 'private',
+ entityId: 'game-distribution-lineage',
+ },
+ });
+ if (confirm.status !== 200) {
+ throw new Error(
+ `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
+ );
+ }
+ return confirm.data.assetObject.assetObjectId;
+}
+
+// 最小合法发行包(对齐 media-e2e.mjs:201-215 的 fixture 形态:index.html + 一个资源)。
+async function buildZip(marker) {
+ const zip = new JSZip();
+ zip.file(
+ 'index.html',
+ `
lineage ${marker}` +
+ '' +
+ marker +
+ '
',
+ );
+ zip.file(
+ 'assets/app.js',
+ `document.documentElement.dataset.marker='${marker}';`,
+ );
+ const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
+ return {
+ bytes,
+ fileCount: 2,
+ sha256: createHash('sha256').update(bytes).digest('hex'),
+ };
+}
+
+async function createGame({ token, metadata, idemKey, fork }) {
+ return api('/api/game-distribution/games', {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': idemKey },
+ body: fork ? { ...metadata, fork } : metadata,
+ });
+}
+
+async function ownerGame(token, gameId) {
+ const response = await api(`/api/game-distribution/my-games/${gameId}`, {
+ token,
+ });
+ return { response, game: response.data?.game ?? null };
+}
+
+/// 完整发布链路:建版本 → 传包 → 送审 → 管理员通过(照抄 media-e2e.mjs:431-573 的顺序与请求体)。
+async function publishToPublic({
+ token,
+ admin,
+ gameId,
+ gameRevision,
+ metadata,
+ zip,
+ stamp,
+ tag,
+ label,
+}) {
+ const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': `lineage-version-${tag}-${stamp}` },
+ body: {
+ packageSha256: zip.sha256,
+ packageBytes: zip.bytes.length,
+ packageFileCount: zip.fileCount,
+ packageEntryPath: 'index.html',
+ gameMetadata: metadata,
+ },
+ });
+ const versionId = created.data?.versionId;
+ check(
+ `${label} 创建版本成功`,
+ created.status === 200 && Boolean(versionId),
+ `status=${created.status} ${created.text.slice(0, 160)}`,
+ );
+ if (!versionId) return { versionId: null };
+
+ const upload = await api(
+ `/api/game-distribution/versions/${versionId}/package`,
+ {
+ method: 'PUT',
+ token,
+ headers: {
+ 'Idempotency-Key': `lineage-upload-${tag}-${stamp}`,
+ 'Content-Type': 'application/zip',
+ },
+ binary: zip.bytes,
+ },
+ );
+ check(
+ `${label} 上传发行包成功`,
+ upload.status === 200 && upload.data?.status === 'uploaded',
+ `status=${upload.status}`,
+ );
+
+ const submitted = await api(
+ `/api/game-distribution/versions/${versionId}/submit`,
+ {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': `lineage-submit-${tag}-${stamp}` },
+ body: { expectedPublicationRevision: gameRevision },
+ },
+ );
+ check(
+ `${label} 送审成功(202 + pending_review)`,
+ submitted.status === 202 &&
+ submitted.data?.version?.status === 'pending_review',
+ `status=${submitted.status} ${submitted.text.slice(0, 160)}`,
+ );
+
+ const readback = await api(`/api/game-distribution/versions/${versionId}`, {
+ token,
+ });
+ const approved = await api(
+ `/admin/api/game-distribution/versions/${versionId}/review`,
+ {
+ method: 'POST',
+ token: admin,
+ headers: { 'Idempotency-Key': `lineage-approve-${tag}-${stamp}` },
+ body: {
+ decision: 'approve',
+ expectedPublicationRevision:
+ readback.data?.version?.publicationRevision ?? gameRevision,
+ },
+ },
+ );
+ check(
+ `${label} 管理员审核通过并公开`,
+ approved.status === 200,
+ `status=${approved.status} ${approved.text.slice(0, 160)}`,
+ );
+ return { versionId, approved };
+}
+
+async function lineageOf(gameId) {
+ return api(`/api/game-distribution/games/${gameId}/lineage`);
+}
+
+async function derivedOf(gameId) {
+ return api(`/api/game-distribution/games/${gameId}/derived`);
+}
+
+/// 取件包必须按字节校验,不能走读文本的 api()。
+async function downloadBinary(pathname, token) {
+ const headers = { ...ENVELOPE };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ const response = await fetch(`${API}${pathname}`, {
+ headers,
+ signal: AbortSignal.timeout(60_000),
+ });
+ const bytes = Buffer.from(await response.arrayBuffer());
+ return {
+ status: response.status,
+ contentType: response.headers.get('content-type') ?? '',
+ contentLength: response.headers.get('content-length') ?? '',
+ cacheControl: response.headers.get('cache-control') ?? '',
+ bytes,
+ };
+}
+
+function checkNodeShape(label, node) {
+ const keys = Object.keys(node ?? {}).sort();
+ const expected = [...LINEAGE_NODE_KEYS].sort();
+ check(
+ `${label}(节点键集合恰好 ${LINEAGE_NODE_KEYS.length} 个)`,
+ JSON.stringify(keys) === JSON.stringify(expected),
+ `keys=${keys.join(',')}`,
+ );
+}
+
+function checkNoPrivateKeys(label, response) {
+ check(
+ `${label}(不下发对象键/素材键/包摘要)`,
+ !PRIVATE_KEY_PATTERN.test(String(response?.text ?? '')),
+ 'pattern=objectKey|assetId|coverObjectKey|screenshots|packageSha|sha256|bucket|oss|downloadPath',
+ );
+}
+
+/// 锚点不可读(未公开 / 已软删除 / 不存在)时,两个读接口都必须 404,且不能是成功信封空树。
+async function checkAnchorNotReadable(label, gameId) {
+ const lineage = await lineageOf(gameId);
+ check(`${label} /lineage → 404`, lineage.status === 404, brief(lineage));
+ check(
+ `${label} /lineage 404 不是成功信封空树`,
+ lineage.json?.ok !== true && lineage.data == null,
+ `ok=${String(lineage.json?.ok)} data=${JSON.stringify(lineage.data ?? null).slice(0, 80)}`,
+ );
+ const derived = await derivedOf(gameId);
+ check(`${label} /derived → 404`, derived.status === 404, brief(derived));
+ check(
+ `${label} /derived 404 不是成功信封空树`,
+ derived.json?.ok !== true && derived.data == null,
+ `ok=${String(derived.json?.ok)} data=${JSON.stringify(derived.data ?? null).slice(0, 80)}`,
+ );
+}
+
+// ---------- Playwright helpers(对齐 web-publish-e2e.mjs:51-90,148-159) ----------
+
+async function loadPlaywright() {
+ const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
+ if (!dir) return import('playwright');
+ const requireFromDir = createRequire(path.join(dir, 'noop.js'));
+ return requireFromDir('playwright');
+}
+
+async function loginThroughWebUi(page, phone, expectedText) {
+ await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
+ await page.waitForSelector('.platform-account-entry', { timeout: 120_000 });
+ await page.locator('.platform-account-entry').first().click();
+ await page.getByRole('tab', { name: '密码登录' }).first().click();
+ await page.waitForTimeout(600);
+ await page
+ .getByLabel('同意法律协议')
+ .first()
+ .check({ force: true })
+ .catch(() => {});
+ await page
+ .locator('input[placeholder="13800000000"]:visible')
+ .first()
+ .fill(phone);
+ await page
+ .locator('input[placeholder="输入密码"]:visible')
+ .first()
+ .fill(DEV_PASSWORD);
+ await page.getByRole('button', { name: '登录', exact: true }).first().click();
+ // 账号入口显示的是 displayName + publicUserCode(PlatformEntryActiveFlowShell.tsx:806-810),
+ // 不保证含手机号后四位,因此以「登录页签消失 + 入口出现」为成功判据,再按需校验展示名。
+ await page
+ .getByRole('tab', { name: '密码登录' })
+ .first()
+ .waitFor({ state: 'hidden', timeout: 30_000 })
+ .catch(() => {});
+ await page
+ .locator('.platform-account-entry')
+ .first()
+ .waitFor({ state: 'visible', timeout: 30_000 });
+ if (expectedText) {
+ await page
+ .locator('.platform-account-entry')
+ .first()
+ .filter({ hasText: expectedText })
+ .waitFor({ state: 'visible', timeout: 30_000 })
+ .catch(() => {});
+ }
+}
+
+async function visible(locator, timeout = 30_000) {
+ return locator
+ .first()
+ .waitFor({ state: 'visible', timeout })
+ .then(() => true)
+ .catch(() => false);
+}
+
+// ---------- 浏览器步骤 ----------
+
+async function runBrowserStep({
+ phone,
+ expectedAccountText,
+ childId,
+ childTitle,
+ parentId,
+ parentTitle,
+ parentAuthorName,
+ deleteParent,
+}) {
+ if (SKIP_BROWSER) {
+ skip(
+ '浏览器视觉:/games/detail 族谱入口与 /games/lineage 树渲染',
+ 'SKIP_BROWSER=1',
+ );
+ return;
+ }
+
+ let chromium;
+ try {
+ ({ chromium } = await loadPlaywright());
+ } catch (error) {
+ check(
+ '浏览器视觉:Playwright 可用',
+ false,
+ `仓库 devDependencies 不含 playwright(package.json),请先执行 ` +
+ `\`npm install --prefix %TEMP%\\genarrative-pw --no-save --no-package-lock playwright\` ` +
+ `并设置 E2E_PLAYWRIGHT_DIR=%TEMP%\\genarrative-pw;或设置 SKIP_BROWSER=1 只跑 HTTP 部分。` +
+ `原始错误:${error?.message ?? error}`,
+ );
+ return;
+ }
+
+ const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
+ const browser = await chromium.launch({
+ headless: true,
+ ...(executablePath ? { executablePath } : {}),
+ });
+ const screenshotDir = await mkdtemp(
+ path.join(tmpdir(), 'genarrative-lineage-e2e-'),
+ );
+ try {
+ const page = await browser.newPage({
+ viewport: { width: 1280, height: 900 },
+ });
+ await loginThroughWebUi(page, phone, expectedAccountText);
+ check('浏览器视觉:作者在网页里用密码登录成功', true, `phone=${phone}`);
+
+ await page.goto(`${WEB}/games/detail?id=${childId}`, {
+ waitUntil: 'commit',
+ timeout: 120_000,
+ });
+ const entry = page.getByRole('button', {
+ name: LINEAGE_ENTRY_LABEL,
+ exact: true,
+ });
+ const entryVisible = await visible(entry, 60_000);
+ check(
+ '浏览器视觉:已公开作品的详情页出现「查看创作族谱」入口',
+ entryVisible,
+ `child=${childId} label=${LINEAGE_ENTRY_LABEL}`,
+ );
+
+ if (entryVisible) {
+ await entry.click();
+ await page.waitForTimeout(800);
+ check(
+ '浏览器视觉:进入 /games/lineage 且 id=根作品',
+ page.url().includes('/games/lineage') &&
+ page.url().includes(`id=${parentId}`),
+ `url=${page.url()} rootId=${parentId}`,
+ );
+ check(
+ '浏览器视觉:族谱页标题可见',
+ await visible(page.getByText(LINEAGE_PAGE_LABEL)),
+ `label=${LINEAGE_PAGE_LABEL}`,
+ );
+ check(
+ '浏览器视觉:母版节点与当前作品节点同时可见',
+ (await visible(page.getByText(LINEAGE_ROOT_LABEL))) &&
+ (await visible(page.getByText(LINEAGE_ANCHOR_LABEL))),
+ `root=${LINEAGE_ROOT_LABEL} anchor=${LINEAGE_ANCHOR_LABEL}`,
+ );
+ check(
+ '浏览器视觉:树上能看到母版与当前作品标题',
+ (await visible(page.getByText(parentTitle, { exact: false }))) &&
+ (await visible(page.getByText(childTitle, { exact: false }))),
+ `parent=${parentTitle} child=${childTitle}`,
+ );
+ if (parentAuthorName) {
+ check(
+ '浏览器视觉:母版节点显示作者名',
+ await visible(page.getByText(parentAuthorName, { exact: false })),
+ `author=${parentAuthorName}`,
+ );
+ }
+
+ const beforeShot = path.join(
+ screenshotDir,
+ 'lineage-tree-before-delete.png',
+ );
+ await page.screenshot({ path: beforeShot, fullPage: true });
+ check('浏览器视觉:删除前族谱已截图', true, `截图路径=${beforeShot}`);
+
+ // 父作品软删除后:树仍在,但父节点内容不得再出现。
+ const deleted = await deleteParent();
+ check(
+ '(前置)浏览器阶段完成父作品软删除',
+ deleted?.status === 200,
+ brief(deleted),
+ );
+ await page.reload({ waitUntil: 'commit', timeout: 120_000 });
+ check(
+ '浏览器视觉:父作品不可用后族谱页显示降级文案',
+ await visible(page.getByText(LINEAGE_PARENT_UNAVAILABLE), 60_000),
+ `label=${LINEAGE_PARENT_UNAVAILABLE}`,
+ );
+ check(
+ '浏览器视觉:父作品标题不再出现在族谱页',
+ (await page.getByText(parentTitle, { exact: false }).count()) === 0,
+ `parent=${parentTitle}`,
+ );
+
+ const afterShot = path.join(
+ screenshotDir,
+ 'lineage-tree-after-delete.png',
+ );
+ await page.screenshot({ path: afterShot, fullPage: true });
+ check('浏览器视觉:删除后族谱已截图', true, `截图路径=${afterShot}`);
+ }
+ } finally {
+ await browser.close().catch(() => {});
+ console.log(`[lineage-e2e] 截图目录:${screenshotDir}`);
+ }
+}
+
+// ---------- 主流程 ----------
+
+async function main() {
+ console.log(
+ `[lineage-e2e] api-server=${API} web=${WEB} database=${devStack.database}`,
+ );
+
+ const adminLogin = await api('/admin/api/login', {
+ method: 'POST',
+ body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
+ });
+ const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
+ check(
+ '管理员登录成功',
+ adminLogin.status === 200 && Boolean(admin),
+ `status=${adminLogin.status}`,
+ );
+ if (!admin) process.exit(1);
+
+ const gate = await api('/admin/api/feature-gates', {
+ method: 'PUT',
+ token: admin,
+ body: {
+ gateKey: GATE_KEY,
+ enabled: true,
+ rolloutPercent: 100,
+ allowUserIds: [],
+ allowUserTags: [],
+ denyUserIds: [],
+ description: 'E2E 创作族谱链路',
+ },
+ });
+ check('发布灰度已开启', gate.status === 200, `status=${gate.status}`);
+
+ const stamp = Date.now();
+ const suffix = String(stamp).slice(-6);
+ const author = await register('132');
+ const forker = await register('133');
+ check(
+ '母版作者注册拿到 token',
+ author.response.status === 200 && Boolean(author.token),
+ `status=${author.response.status} phone=${author.phone}`,
+ );
+ check(
+ '改编作者注册拿到 token',
+ forker.response.status === 200 && Boolean(forker.token),
+ `status=${forker.response.status} phone=${forker.phone}`,
+ );
+ if (!author.token || !forker.token) process.exit(1);
+
+ const authorDisplayName = `族谱母版作者${suffix}`;
+ const forkerDisplayName = `族谱改编作者${suffix}`;
+ const renameAuthor = await setDisplayName(author.token, authorDisplayName);
+ const renameForker = await setDisplayName(forker.token, forkerDisplayName);
+ check(
+ '两名作者都已设置展示名(让作者名相关断言有意义)',
+ renameAuthor.status === 200 && renameForker.status === 200,
+ `author=${renameAuthor.status} forker=${renameForker.status}`,
+ );
+
+ // A 段:404 规则(防泄露回归网)
+ const parentTitle = `族谱母版 ${suffix}`;
+ const childTitle = `族谱改编 ${suffix}`;
+ const blockedTitle = `族谱禁改编 ${suffix}`;
+ const parentCover = await uploadCover(author.token, `${stamp}-parent`);
+ const parentCreated = await createGame({
+ token: author.token,
+ metadata: gameMetadata({ title: parentTitle, coverAssetId: parentCover }),
+ idemKey: `lineage-parent-${stamp}`,
+ });
+ const parentId = parentCreated.data?.id;
+ check(
+ '母版作品(草稿)创建成功',
+ parentCreated.status === 200 && Boolean(parentId),
+ `status=${parentCreated.status} id=${parentId ?? ''} ${parentCreated.text.slice(0, 120)}`,
+ );
+ const parentRevision = parentCreated.data?.publicationRevision ?? 0;
+ if (!parentId) process.exit(1);
+
+ await checkAnchorNotReadable('未公开草稿锚点', parentId);
+ await checkAnchorNotReadable('不存在的 gameId', `game_${'0'.repeat(32)}`);
+ const lineageNoStore = await lineageOf(parentId);
+ const derivedNoStore = await derivedOf(parentId);
+ check(
+ '两个读接口都返回 no-store(404 也带)',
+ lineageNoStore.cacheControl.includes('no-store') &&
+ derivedNoStore.cacheControl.includes('no-store'),
+ `lineage=${lineageNoStore.cacheControl} derived=${derivedNoStore.cacheControl}`,
+ );
+
+ // B 段:把母版推到公开
+ const parentZip = await buildZip(`parent-${suffix}`);
+ const parentMetadata = gameMetadata({
+ title: parentTitle,
+ coverAssetId: parentCover,
+ });
+ await publishToPublic({
+ token: author.token,
+ admin,
+ gameId: parentId,
+ gameRevision: parentRevision,
+ metadata: parentMetadata,
+ zip: parentZip,
+ stamp,
+ tag: 'parent',
+ label: '母版',
+ });
+ const parentPublicDetail = await api(
+ `/api/game-distribution/games/${parentId}`,
+ );
+ check(
+ '母版已公开可读(公开详情 200 + status=published)',
+ parentPublicDetail.status === 200 &&
+ parentPublicDetail.data?.status === 'published',
+ `status=${parentPublicDetail.status} visibility=${parentPublicDetail.data?.status ?? ''}`,
+ );
+
+ // 授权阶梯:改编需要来源作品允许共创
+ const promote = await api(
+ `/api/game-distribution/games/${parentId}/fork-authorization`,
+ {
+ method: 'PUT',
+ token: author.token,
+ headers: { 'Idempotency-Key': `lineage-promote-${stamp}` },
+ body: {
+ expectedForkAuthorization: 'forbidden',
+ forkAuthorization: 'nonCommercial',
+ },
+ },
+ );
+ check(
+ '母版共创授权提升为 nonCommercial',
+ promote.status === 200 &&
+ (promote.data?.game?.forkAuthorization ?? null) === 'nonCommercial',
+ brief(promote),
+ );
+
+ const parentOwnerView = await ownerGame(author.token, parentId);
+ const parentVersions = parentOwnerView.game?.versions ?? [];
+ const parentPublicVersion =
+ parentVersions.find((version) => version.status === 'published') ?? null;
+ const parentPublicVersionId = parentPublicVersion?.versionId ?? '';
+ check(
+ '取到母版当前公开版本 id(改编声明需要)',
+ Boolean(parentPublicVersionId),
+ `versions=${parentVersions.map((version) => `${version.versionNumber}:${version.status}`).join(',')}`,
+ );
+ if (!parentPublicVersionId) process.exit(1);
+
+ // 创建携带改编声明的子作品(依赖 M2a fork 声明链路)
+ const childCover = await uploadCover(forker.token, `${stamp}-child`);
+ const childMetadata = gameMetadata({
+ title: childTitle,
+ coverAssetId: childCover,
+ });
+ const childCreated = await createGame({
+ token: forker.token,
+ metadata: childMetadata,
+ idemKey: `lineage-child-${stamp}`,
+ fork: {
+ parentGameId: parentId,
+ parentVersionId: parentPublicVersionId,
+ },
+ });
+ const childId = childCreated.data?.id;
+ check(
+ '携带改编声明的子作品创建成功',
+ childCreated.status === 200 && Boolean(childId),
+ `status=${childCreated.status} id=${childId ?? ''} ${childCreated.text.slice(0, 200)}`,
+ );
+ const childRevision = childCreated.data?.publicationRevision ?? 0;
+ if (!childId) process.exit(1);
+
+ const childZip = await buildZip(`child-${suffix}`);
+ await publishToPublic({
+ token: forker.token,
+ admin,
+ gameId: childId,
+ gameRevision: childRevision,
+ metadata: childMetadata,
+ zip: childZip,
+ stamp,
+ tag: 'child',
+ label: '子作品',
+ });
+
+ // C 段:真实树形状(锚点 = 子作品)
+ const tree = await lineageOf(childId);
+ check('族谱接口 200', tree.status === 200, brief(tree));
+ const treeData = tree.data ?? {};
+ check(
+ 'rootGameId 指向母版',
+ treeData.rootGameId === parentId,
+ `rootGameId=${treeData.rootGameId ?? ''} 期望=${parentId}`,
+ );
+ check(
+ 'root 指向母版节点',
+ treeData.root?.gameId === parentId,
+ `root=${treeData.root?.gameId ?? 'null'}`,
+ );
+ check(
+ '小树未截断(truncated=false)',
+ treeData.truncated === false,
+ `truncated=${String(treeData.truncated)}`,
+ );
+ const treeNodes = treeData.nodes ?? [];
+ const nodeIds = treeNodes.map((node) => node.gameId);
+ check(
+ 'nodes 同时含母版与子作品',
+ nodeIds.includes(parentId) && nodeIds.includes(childId),
+ `nodes=${nodeIds.join(',')}`,
+ );
+ const parentNode = treeNodes.find((node) => node.gameId === parentId);
+ const childNode = treeNodes.find((node) => node.gameId === childId);
+ check(
+ '母版 generation=0 且无父',
+ parentNode?.generation === 0 && parentNode?.parentGameId == null,
+ `generation=${parentNode?.generation} parentGameId=${parentNode?.parentGameId ?? 'null'}`,
+ );
+ check(
+ '子作品 generation=1 且 parentGameId=母版',
+ childNode?.generation === 1 && childNode?.parentGameId === parentId,
+ `generation=${childNode?.generation} parentGameId=${childNode?.parentGameId ?? 'null'}`,
+ );
+ check(
+ '两个节点都标为 published',
+ parentNode?.status === 'published' && childNode?.status === 'published',
+ `parent=${parentNode?.status} child=${childNode?.status}`,
+ );
+ check(
+ '母版节点作者名读取自账号展示名',
+ parentNode?.authorName === authorDisplayName,
+ `authorName=${parentNode?.authorName ?? 'null'} 期望=${authorDisplayName}`,
+ );
+ const generations = treeNodes.map((node) => node.generation);
+ check(
+ '节点按代际升序(母版在前)',
+ generations.every(
+ (value, index) => index === 0 || generations[index - 1] <= value,
+ ) && nodeIds.indexOf(parentId) < nodeIds.indexOf(childId),
+ `generations=${generations.join(',')}`,
+ );
+ checkNodeShape(`族谱节点 ${parentId}`, parentNode);
+ checkNodeShape(`族谱节点 ${childId}`, childNode);
+ checkNoPrivateKeys('族谱响应', tree);
+
+ // 锚点换成母版:同一棵树
+ const treeFromParent = await lineageOf(parentId);
+ check(
+ '锚点换成母版仍返回同一棵树',
+ treeFromParent.status === 200 &&
+ treeFromParent.data?.rootGameId === parentId &&
+ (treeFromParent.data?.nodes ?? []).length === treeNodes.length,
+ `status=${treeFromParent.status} nodes=${(treeFromParent.data?.nodes ?? []).length}`,
+ );
+
+ // 衍生列表(锚点 = 母版):恰好一个直接子代
+ const derived = await derivedOf(parentId);
+ check('衍生列表接口 200', derived.status === 200, brief(derived));
+ const derivedNodes = derived.data?.nodes ?? [];
+ check(
+ '衍生列表恰好含子作品',
+ derivedNodes.length === 1 && derivedNodes[0]?.gameId === childId,
+ `nodes=${derivedNodes.map((node) => node.gameId).join(',')}`,
+ );
+ check(
+ '衍生列表未截断且 gameId 为母版',
+ derived.data?.truncated === false && derived.data?.gameId === parentId,
+ `truncated=${String(derived.data?.truncated)} gameId=${derived.data?.gameId ?? ''}`,
+ );
+ checkNodeShape(`衍生节点 ${childId}`, derivedNodes[0]);
+ checkNoPrivateKeys('衍生列表响应', derived);
+
+ // G 段:Fork 取件通道(M2a)
+ // 契约(读代码确认):路由 api-server/...:307-320(Bearer + no-store,不叠发布灰度);
+ // 404/409/403 映射 api-server/...:2519-2560(FORK_SOURCE_NOT_FOUND / NOT_AVAILABLE / NOT_AUTHORIZED);
+ // 元数据载荷 api-server/...:2586-2604(同源相对 downloadPath,绝不下发对象键);
+ // 整包响应 api-server/...:2633-2665(application/zip + content-length + 附件名 + no-store)。
+ const forkSourceOf = (gameId, token) =>
+ api(`/api/game-distribution/games/${gameId}/fork-source`, { token });
+
+ // G1:未公开作品的取件通道 → 409(行存在但不可作来源;与 lineage 的 404 是两套口径)
+ const blockedCover = await uploadCover(author.token, `${stamp}-blocked`);
+ const blockedMetadata = gameMetadata({
+ title: blockedTitle,
+ coverAssetId: blockedCover,
+ });
+ const blockedCreated = await createGame({
+ token: author.token,
+ metadata: blockedMetadata,
+ idemKey: `lineage-blocked-${stamp}`,
+ });
+ const blockedId = blockedCreated.data?.id;
+ const blockedRevision = blockedCreated.data?.publicationRevision ?? 0;
+ check(
+ '禁改编 fixture(草稿)创建成功',
+ blockedCreated.status === 200 && Boolean(blockedId),
+ `status=${blockedCreated.status} id=${blockedId ?? ''}`,
+ );
+ if (!blockedId) process.exit(1);
+ const draftSource = await forkSourceOf(blockedId, author.token);
+ check(
+ '未公开作品的取件通道 → 409 FORK_SOURCE_NOT_AVAILABLE',
+ draftSource.status === 409 &&
+ (draftSource.error?.code ?? '') === 'FORK_SOURCE_NOT_AVAILABLE',
+ brief(draftSource),
+ );
+
+ // G2:公开但未提升授权(forbidden)→ 403
+ await publishToPublic({
+ token: author.token,
+ admin,
+ gameId: blockedId,
+ gameRevision: blockedRevision,
+ metadata: blockedMetadata,
+ zip: await buildZip(`blocked-${suffix}`),
+ stamp,
+ tag: 'blocked',
+ label: '禁改编作品',
+ });
+ const forbiddenSource = await forkSourceOf(blockedId, author.token);
+ check(
+ '已公开但授权为 forbidden 的取件通道 → 403 FORK_NOT_AUTHORIZED',
+ forbiddenSource.status === 403 &&
+ (forbiddenSource.error?.code ?? '') === 'FORK_NOT_AUTHORIZED',
+ brief(forbiddenSource),
+ );
+
+ // G3:母版(公开 + nonCommercial)→ 元数据与公开版本行一致
+ const parentSource = await forkSourceOf(parentId, author.token);
+ const forkSource = parentSource.data?.forkSource ?? null;
+ check(
+ '母版取件元数据 200 且带 forkSource',
+ parentSource.status === 200 && Boolean(forkSource),
+ brief(parentSource),
+ );
+ check(
+ 'forkSource.gameId === 母版',
+ forkSource?.gameId === parentId,
+ `gameId=${forkSource?.gameId ?? ''}`,
+ );
+ check(
+ 'forkSource.source === package',
+ forkSource?.source === 'package',
+ `source=${forkSource?.source ?? ''}`,
+ );
+ check(
+ 'forkSource.versionId === 母版当前公开版本',
+ forkSource?.versionId === parentPublicVersionId,
+ `versionId=${forkSource?.versionId ?? ''} 期望=${parentPublicVersionId}`,
+ );
+ check(
+ 'forkSource.sha256 / bytes 与公开版本行一致',
+ forkSource?.sha256 === parentPublicVersion?.packageSha256 &&
+ forkSource?.bytes === parentPublicVersion?.packageBytes,
+ `sha256=${forkSource?.sha256 ?? ''}/${parentPublicVersion?.packageSha256 ?? ''} bytes=${forkSource?.bytes ?? ''}/${parentPublicVersion?.packageBytes ?? ''}`,
+ );
+ check(
+ 'downloadPath 是同源相对路径(不含协议/主机)',
+ typeof forkSource?.downloadPath === 'string' &&
+ forkSource.downloadPath.startsWith('/api/game-distribution/games/') &&
+ !forkSource.downloadPath.includes('://'),
+ `downloadPath=${forkSource?.downloadPath ?? ''}`,
+ );
+ check(
+ '取件元数据不下发对象键 / OSS 域名 / 存储路径',
+ !FORK_SOURCE_LEAK_PATTERN.test(parentSource.text) &&
+ !/https?:\/\//u.test(parentSource.text),
+ 'pattern=objectKey|coverObjectKey|bucket|aliyuncs|oss-cn|.zip + 绝对 URL',
+ );
+
+ // G4:按 downloadPath 真实下载并校验字节(客户端最重要的契约)
+ const forkDownload = await downloadBinary(
+ forkSource?.downloadPath ?? '',
+ author.token,
+ );
+ check(
+ '取件下载 200 + application/zip',
+ forkDownload.status === 200 &&
+ forkDownload.contentType.includes('application/zip'),
+ `status=${forkDownload.status} content-type=${forkDownload.contentType}`,
+ );
+ check(
+ '取件下载 content-length 与实际字节数一致',
+ Number(forkDownload.contentLength) === forkDownload.bytes.length,
+ `content-length=${forkDownload.contentLength} actual=${forkDownload.bytes.length}`,
+ );
+ check(
+ '取件下载字节数等于元数据 bytes',
+ forkDownload.bytes.length === forkSource?.bytes,
+ `actual=${forkDownload.bytes.length} meta=${forkSource?.bytes ?? ''}`,
+ );
+ const forkDownloadSha = createHash('sha256')
+ .update(forkDownload.bytes)
+ .digest('hex');
+ check(
+ '取件下载 sha256 与元数据一致',
+ forkDownloadSha === forkSource?.sha256,
+ `download=${forkDownloadSha} meta=${forkSource?.sha256 ?? ''}`,
+ );
+ check(
+ '取件包是 ZIP(PK 头)',
+ forkDownload.bytes.length > 2 &&
+ forkDownload.bytes[0] === 0x50 &&
+ forkDownload.bytes[1] === 0x4b,
+ `head=${forkDownload.bytes.subarray(0, 2).toString('hex')}`,
+ );
+
+ // G5:鉴权边界
+ const anonSource = await api(
+ `/api/game-distribution/games/${parentId}/fork-source`,
+ );
+ const anonDownload = await downloadBinary(forkSource?.downloadPath ?? '', '');
+ check(
+ '未带 Bearer 的取件元数据 → 401',
+ anonSource.status === 401,
+ brief(anonSource),
+ );
+ check(
+ '未带 Bearer 的取件下载 → 401',
+ anonDownload.status === 401,
+ `status=${anonDownload.status}`,
+ );
+ const otherAuthorSource = await forkSourceOf(parentId, forker.token);
+ check(
+ '另一名已登录作者也能取件(非 owner 限定)',
+ otherAuthorSource.status === 200 &&
+ otherAuthorSource.data?.forkSource?.gameId === parentId,
+ brief(otherAuthorSource),
+ );
+
+ // D 段:软删除降级。删除动作在浏览器阶段(页面断言之间)触发;SKIP_BROWSER 时在这里直接删。
+ const deleteParent = async () => {
+ const parentNow = await ownerGame(author.token, parentId);
+ const revision = parentNow.game?.publicationRevision ?? 0;
+ return api(
+ `/api/game-distribution/my-games/${parentId}?expectedPublicationRevision=${revision}`,
+ {
+ method: 'DELETE',
+ token: author.token,
+ headers: { 'Idempotency-Key': `lineage-delete-${stamp}` },
+ },
+ );
+ };
+
+ // F 段:浏览器(必须在删除前打开页面断言「删除前形态」,再在页面内触发删除)
+ await runBrowserStep({
+ phone: forker.phone,
+ expectedAccountText: forkerDisplayName,
+ childId,
+ childTitle,
+ parentId,
+ parentTitle,
+ parentAuthorName: authorDisplayName,
+ deleteParent,
+ });
+ if (SKIP_BROWSER) {
+ const deleted = await deleteParent();
+ check(
+ '母版软删除成功(HTTP 路径)',
+ deleted.status === 200,
+ brief(deleted),
+ );
+ }
+
+ const afterTree = await lineageOf(childId);
+ check(
+ '删除母版后族谱接口仍 200(锚点=子作品)',
+ afterTree.status === 200,
+ brief(afterTree),
+ );
+ const afterNodes = afterTree.data?.nodes ?? [];
+ check(
+ '删除后 nodes 不再含母版',
+ !afterNodes.some((node) => node.gameId === parentId),
+ `nodes=${afterNodes.map((node) => node.gameId).join(',')}`,
+ );
+ check(
+ '删除后 root=null 但 rootGameId 仍为母版',
+ afterTree.data?.root == null && afterTree.data?.rootGameId === parentId,
+ `root=${afterTree.data?.root == null ? 'null' : 'present'} rootGameId=${afterTree.data?.rootGameId ?? ''}`,
+ );
+ const afterChildNode = afterNodes.find((node) => node.gameId === childId);
+ check(
+ '删除后子节点保留 generation=1 与 parentGameId=母版',
+ afterChildNode?.generation === 1 &&
+ afterChildNode?.parentGameId === parentId,
+ `generation=${afterChildNode?.generation} parentGameId=${afterChildNode?.parentGameId ?? 'null'}`,
+ );
+ check(
+ '删除后响应文本不含母版标题与作者名',
+ !String(afterTree.text).includes(parentTitle) &&
+ !String(afterTree.text).includes(authorDisplayName),
+ `title=${parentTitle} author=${authorDisplayName}`,
+ );
+ await checkAnchorNotReadable('已软删除锚点', parentId);
+
+ // E 段:上限常量(真实 >200 节点按成本不做,见 NOTE)
+ const lineageSourcePath = path.resolve(
+ process.cwd(),
+ 'server-rs/crates/module-game-distribution/src/lineage.rs',
+ );
+ let lineageSource = '';
+ try {
+ lineageSource = readFileSync(lineageSourcePath, 'utf8');
+ } catch (error) {
+ check(
+ '节点上限常量可读(源码钉住)',
+ false,
+ `读取失败 ${lineageSourcePath}: ${error?.message ?? error}`,
+ );
+ }
+ if (lineageSource) {
+ check(
+ '节点上限常量仍为 200(源码钉住)',
+ /GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT:\s*usize\s*=\s*200\b/u.test(
+ lineageSource,
+ ),
+ 'module-game-distribution/src/lineage.rs:22',
+ );
+ }
+ note(
+ '未做 >200 真实节点的 truncated 断言(需要 201 个真实公开作品,成本过高);' +
+ '本脚本只断言小树 truncated=false + 源码常量 200。若要真验截断,建议在 module-game-distribution 单测层做(已有 truncation 单测)。',
+ );
+
+ console.log(
+ `[lineage-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`,
+ );
+ if (failures > 0) {
+ process.exitCode = 1;
+ }
+}
+
+main().catch((error) => {
+ console.error(`[lineage-e2e] 未捕获异常:${error?.stack ?? error}`);
+ process.exitCode = 1;
+});