diff --git a/scripts/capture-game-lineage-visual.mjs b/scripts/capture-game-lineage-visual.mjs
index ac5c604d4..0dc01947f 100644
--- a/scripts/capture-game-lineage-visual.mjs
+++ b/scripts/capture-game-lineage-visual.mjs
@@ -503,7 +503,210 @@ async function loadPlaywright() {
return requireFromDir('playwright');
}
+/// 「只截图、不造树」模式:给已有作品(例如 scripts/seed-game-distribution-co-creation.mjs 灌的共创网络)
+/// 出验收截图。设 `E2E_CAPTURE_LINEAGE_URL` 即进入该模式——不注册账号、不建作品、不下架,纯 GET + 截图。
+async function captureExistingUrls() {
+ const lineageUrl = (process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim();
+ const themeUrl = (process.env.E2E_CAPTURE_THEME_URL ?? '').trim();
+ const selectTitle = (process.env.E2E_CAPTURE_SELECT_TITLE ?? '').trim();
+ // 视口可配:全树总览常常比 960 高,给个高的窗口才能真的「一眼看全」。
+ const sizeMatch = /^(\d{3,4})x(\d{3,4})$/u.exec(
+ (process.env.E2E_CAPTURE_WINDOW ?? '').trim(),
+ );
+ const desktopViewport = sizeMatch
+ ? { width: Number(sizeMatch[1]), height: Number(sizeMatch[2]) }
+ : { width: 1440, height: 960 };
+ const { chromium } = await loadPlaywright();
+ const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
+ const browser = await chromium.launch({
+ headless: true,
+ ...(executablePath ? { executablePath } : {}),
+ });
+ const shotDir = await mkdtemp(
+ path.join(tmpdir(), 'genarrative-co-creation-visual-'),
+ );
+ const shots = [];
+ const take = async (target, name) => {
+ const file = path.join(shotDir, name);
+ await target.screenshot({ path: file, fullPage: false });
+ shots.push(file);
+ console.log(`[lineage-visual] 截图 ${file}`);
+ return file;
+ };
+ try {
+ // 封面是远端资源:等所有
落地再截,否则会出现灰块假象。
+ const waitImages = async (target) => {
+ await target
+ .waitForFunction(
+ () =>
+ [...document.images].every(
+ (img) => img.complete && img.naturalWidth > 0,
+ ),
+ undefined,
+ { timeout: 60_000 },
+ )
+ .catch(() => {});
+ await target.waitForTimeout(600);
+ };
+ // ---------- 桌面:全树总览 ----------
+ const desktop = await browser.newContext({
+ viewport: desktopViewport,
+ });
+ const page = await desktop.newPage();
+ await page.goto(lineageUrl, { waitUntil: 'commit', timeout: 120_000 });
+ await page.waitForFunction(
+ () => document.querySelectorAll('.game-lineage-node').length >= 3,
+ undefined,
+ { timeout: 120_000 },
+ );
+ await page.waitForTimeout(1400);
+ await waitImages(page);
+ const nodeCount = await page.locator('.game-lineage-node').count();
+ check(
+ '截图模式:/games/lineage 已渲染多代节点',
+ nodeCount >= 3,
+ `nodes=${nodeCount} url=${lineageUrl}`,
+ );
+ // 首屏视野契约(与 lineage 工具同口径):要么真的全览,要么按可读缩放下限 + 提示。
+ const fit = await page.evaluate(() => {
+ const canvas = document
+ .querySelector('.game-lineage-canvas')
+ ?.getBoundingClientRect();
+ if (!canvas) return { allInside: false, hint: false };
+ const allInside = [
+ ...document.querySelectorAll('.game-lineage-node'),
+ ].every((node) => {
+ const box = node.getBoundingClientRect();
+ return (
+ box.left >= canvas.left - 2 &&
+ box.top >= canvas.top - 2 &&
+ box.right <= canvas.right + 2 &&
+ box.bottom <= canvas.bottom + 2
+ );
+ });
+ return {
+ allInside,
+ hint: [...document.querySelectorAll('*')].some((element) =>
+ (element.textContent ?? '').includes('已按可读缩放显示'),
+ ),
+ };
+ });
+ check(
+ '截图模式:首屏要么全树在画布内,要么给出「已按可读缩放显示」提示',
+ fit.allInside || fit.hint,
+ `allInside=${fit.allInside} hint=${fit.hint} 视口=${desktopViewport.width}x${desktopViewport.height}`,
+ );
+ await take(page, '01-lineage-overview.png');
+
+ // ---------- 桌面:选中态(信息面板 + 面包屑) ----------
+ // 注意:卡片主体是「打开详情」按钮(stopPropagation),点它会跳走;要「选中/锁定」得点卡片**内边距**(6,6)。
+ const card = selectTitle
+ ? page
+ .locator('.game-lineage-node')
+ .filter({ hasText: selectTitle })
+ .first()
+ : page.locator('.game-lineage-node').nth(1);
+ let selected = false;
+ if ((await card.count()) > 0) {
+ await card.click({ position: { x: 6, y: 6 } });
+ selected = true;
+ } else {
+ await page
+ .locator('.game-lineage-node')
+ .nth(1)
+ .click({ position: { x: 6, y: 6 } });
+ selected = true;
+ }
+ if (selected) {
+ await page
+ .locator('.game-lineage-panel')
+ .first()
+ .waitFor({ state: 'visible', timeout: 30_000 });
+ await page.waitForTimeout(700);
+ const crumbs = await page
+ .locator('.game-lineage-breadcrumb__crumb')
+ .count();
+ check(
+ '截图模式:选中节点后出现信息面板与面包屑',
+ crumbs >= 1,
+ `面包屑=${crumbs}`,
+ );
+ await waitImages(page);
+ await take(page, '02-lineage-selected.png');
+ }
+
+ // ---------- 窄屏 ----------
+ const mobile = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ });
+ const mobilePage = await mobile.newPage();
+ await mobilePage.goto(lineageUrl, {
+ waitUntil: 'commit',
+ timeout: 120_000,
+ });
+ await mobilePage.waitForFunction(
+ () => document.querySelectorAll('.game-lineage-node').length >= 3,
+ undefined,
+ { timeout: 120_000 },
+ );
+ await mobilePage.waitForTimeout(1400);
+ // 窄屏首次进入会有「移动端仅支持作品展示」的欢迎弹窗(未 dismiss 时弹):先点掉再截图,
+ // 否则截出来只剩一个遮罩弹窗,看不到窄屏族谱本身。
+ const welcomeOk = mobilePage.getByRole('button', {
+ name: '好',
+ exact: true,
+ });
+ if ((await welcomeOk.count()) > 0) {
+ await welcomeOk.first().click();
+ await mobilePage.waitForTimeout(800);
+ }
+ await waitImages(mobilePage);
+ const narrow = await mobilePage.evaluate(() => ({
+ canvas: Boolean(document.querySelector('.game-lineage-canvas')),
+ nodes: document.querySelectorAll('.game-lineage-node').length,
+ overflow: document.documentElement.scrollWidth - window.innerWidth > 1,
+ }));
+ check(
+ '截图模式:窄屏下族谱画布可见、节点已渲染且页面不横向溢出',
+ narrow.canvas && narrow.nodes >= 3 && !narrow.overflow,
+ `canvas=${narrow.canvas} nodes=${narrow.nodes} overflow=${narrow.overflow}`,
+ );
+ await take(mobilePage, '03-lineage-narrow.png');
+ await mobile.close();
+
+ // ---------- 主题页 ----------
+ if (themeUrl) {
+ const themePage = await desktop.newPage();
+ await themePage.goto(themeUrl, { waitUntil: 'commit', timeout: 120_000 });
+ await themePage
+ .locator('.game-theme-root')
+ .first()
+ .waitFor({ state: 'visible', timeout: 60_000 });
+ await themePage.waitForTimeout(1000);
+ await waitImages(themePage);
+ const roots = await themePage.locator('.game-theme-root').count();
+ check('截图模式:主题页已渲染作品树', roots >= 1, `roots=${roots}`);
+ await take(themePage, '04-theme-page.png');
+ void themePage;
+ }
+ await desktop.close();
+ console.log(`[lineage-visual] 截图目录=${shotDir}`);
+ console.log(`[lineage-visual] 结果:check=${checks} fail=${failures}`);
+ } finally {
+ await browser.close();
+ }
+ return shots;
+}
+
async function main() {
+ if ((process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim()) {
+ await captureExistingUrls();
+ const failed = failures > 0;
+ console.log(
+ `[lineage-visual] 只用现有作品截图:check=${checks} fail=${failures}`,
+ );
+ process.exit(failed ? 1 : 0);
+ }
console.log(`[lineage-visual] api-server=${API} web=${WEB}`);
const stamp = Date.now();
const suffix = String(stamp).slice(-6);
diff --git a/scripts/seed-game-distribution-co-creation.mjs b/scripts/seed-game-distribution-co-creation.mjs
new file mode 100644
index 000000000..b00beaa2b
--- /dev/null
+++ b/scripts/seed-game-distribution-co-creation.mjs
@@ -0,0 +1,1091 @@
+// 往「正在运行的」本地 dev 栈里灌一批**真实**的游戏共创演示数据(多树 / 多代 / 多分支 / 多作者 / 授权档位差异)。
+//
+// 只做 HTTP 调用:读 `.app/dev-stack.json` 拿实际 api-server / web / 库名 → 建作品(封面走真实直传链路)→
+// 建版本 → 传发行包 → 送审 → 管理员通过;衍生作品一律带 `fork` 声明与必填的「本次核心改动说明」。
+// **不直接写库、不停服务、不清库、不重启**。
+//
+// 幂等口径:所有演示作品标题统一为 `共创演示·<节点键>|<中文名>`,脚本按「该节点的作者 + 完全相等标题」
+// 在其 `/api/game-distribution/my-games` 里查重;命中就复用(必要时补发布),不重复创建。
+// 主题按名称 `共创演示主题` 查重;收藏是幂等 PUT。
+//
+// 用法:
+// E2E_ADMIN_USER=admin E2E_ADMIN_PASSWORD=<密码> node scripts/seed-game-distribution-co-creation.mjs
+// 可选环境变量:
+// E2E_API_BASE / E2E_WEB_BASE 覆盖地址(默认读 .app/dev-stack.json)
+// SEED_COVER_DIR 封面目录(默认 C:/Users/dongy/Pictures/壁纸)
+// SEED_COVER_MAX_BYTES 封面单文件上限(默认 6 MiB,超过的候选跳过,避免把几十 MB 原图塞进 dev 库)
+// SEED_SKIP_THEME=1 / SEED_SKIP_COLLECTIONS=1 跳过主题 / 收藏步骤
+
+import { createHash } from 'node:crypto';
+import { readdirSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+
+import JSZip from 'jszip';
+
+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 TITLE_PREFIX = '共创演示·';
+const THEME_NAME = '共创演示主题';
+const COVER_DIR = process.env.SEED_COVER_DIR ?? 'C:/Users/dongy/Pictures/壁纸';
+const COVER_MAX_BYTES = Number(
+ process.env.SEED_COVER_MAX_BYTES ?? 6 * 1024 * 1024,
+);
+
+if (!ADMIN_USER || !ADMIN_PASSWORD) {
+ console.error(
+ '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD(要开 game-distribution:publish 灰度并用管理员审核)。',
+ );
+ 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 地址。');
+ process.exit(2);
+}
+
+let checks = 0;
+let failures = 0;
+function check(name, ok, detail = '') {
+ checks += 1;
+ if (!ok) failures += 1;
+ console.log(
+ `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
+ );
+}
+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(180_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,
+ };
+}
+
+function brief(body) {
+ const code = body?.error?.code ?? body?.json?.error?.code ?? '';
+ return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 180)}`;
+}
+
+// ---------- 作者账号 ----------
+
+const AUTHOR_SPECS = [
+ { key: '甲', ascii: 'jia', phone: '13290000001', displayName: '演示作者甲' },
+ { key: '乙', ascii: 'yi', phone: '13290000002', displayName: '演示作者乙' },
+ { key: '丙', ascii: 'bing', phone: '13290000003', displayName: '演示作者丙' },
+ { key: '丁', ascii: 'ding', phone: '13290000004', displayName: '演示作者丁' },
+];
+
+async function ensureAuthor(spec) {
+ // 固定手机号 + 固定密码:重复运行时**登录同一批演示账号**(而不是每次新注册),
+ // 这样收藏/主题成员/作者名这些按账号维度的演示效果在重跑后保持一致。
+ let phone = spec.phone;
+ let registered = await api('/api/auth/entry', {
+ method: 'POST',
+ body: { purePhoneNumber: phone, password: DEV_PASSWORD },
+ });
+ if (!registered.data?.token) {
+ // 固定号被占用且密码不同(例如别人先用过):退回随机号,保证脚本仍能跑通。
+ phone = `13${spec.prefix.slice(-1)}${String(Date.now()).slice(-8)}`;
+ registered = await api('/api/auth/entry', {
+ method: 'POST',
+ body: { purePhoneNumber: phone, password: DEV_PASSWORD },
+ });
+ }
+ const token = registered.data?.token;
+ if (token) {
+ await api('/api/profile/me', {
+ method: 'PATCH',
+ token,
+ body: { displayName: spec.displayName },
+ });
+ }
+ return { ...spec, phone, token };
+}
+
+// ---------- 封面 ----------
+
+function readImageSize(filePath) {
+ const buffer = readFileSync(filePath);
+ if (buffer[0] === 0x89 && buffer.toString('ascii', 1, 4) === 'PNG') {
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
+ }
+ if (buffer[0] === 0xff && buffer[1] === 0xd8) {
+ let index = 2;
+ while (index < buffer.length - 9) {
+ if (buffer[index] !== 0xff) {
+ index += 1;
+ continue;
+ }
+ const marker = buffer[index + 1];
+ const length = buffer.readUInt16BE(index + 2);
+ if (
+ marker >= 0xc0 &&
+ marker <= 0xcf &&
+ ![0xc4, 0xc8, 0xcc].includes(marker)
+ ) {
+ return {
+ height: buffer.readUInt16BE(index + 5),
+ width: buffer.readUInt16BE(index + 7),
+ };
+ }
+ index += 2 + length;
+ }
+ }
+ return null;
+}
+
+/// 枚举封面目录:只取横版(宽>高)且不超过上限的文件,按「体积从小到大」排序(避免把几十 MB 原图塞进 dev 库)。
+function listCoverCandidates() {
+ const entries = readdirSync(COVER_DIR)
+ .filter((name) => /\.(jpe?g|png|webp)$/iu.test(name))
+ .map((name) => {
+ const filePath = path.join(COVER_DIR, name);
+ const buffer = readFileSync(filePath);
+ const size = readImageSize(filePath);
+ return {
+ name,
+ filePath,
+ bytes: buffer,
+ width: size?.width ?? 0,
+ height: size?.height ?? 0,
+ landscape: size ? size.width > size.height : true,
+ };
+ })
+ .filter(
+ (entry) =>
+ entry.landscape &&
+ entry.bytes.length > 0 &&
+ entry.bytes.length <= COVER_MAX_BYTES,
+ );
+ entries.sort((a, b) => a.bytes.length - b.bytes.length);
+ return entries;
+}
+
+async function uploadCover({ token, cover, label }) {
+ const contentType = /\.png$/iu.test(cover.name) ? 'image/png' : 'image/jpeg';
+ const ticket = await api('/api/assets/direct-upload-tickets', {
+ method: 'POST',
+ token,
+ body: {
+ legacyPrefix: 'generated-character-drafts',
+ pathSegments: [
+ 'game-distribution',
+ 'co-creation-demo',
+ label,
+ String(Date.now()),
+ ],
+ fileName: cover.name,
+ contentType,
+ access: 'private',
+ maxSizeBytes: cover.bytes.length,
+ metadata: { asset_kind: 'game_distribution_cover' },
+ },
+ });
+ if (ticket.status !== 200) {
+ return { assetObjectId: null, error: brief(ticket) };
+ }
+ 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.bytes], { type: contentType }),
+ cover.name,
+ );
+ const put = await fetch(upload.host, { method: 'POST', body: form });
+ if (!put.ok) {
+ return { assetObjectId: null, error: `OSS PUT ${put.status}` };
+ }
+ const confirm = await api('/api/assets/objects/confirm', {
+ method: 'POST',
+ token,
+ body: {
+ bucket: upload.bucket,
+ objectKey: upload.objectKey,
+ contentType,
+ contentLength: cover.bytes.length,
+ assetKind: 'game_distribution_cover',
+ accessPolicy: 'private',
+ entityId: 'game-distribution-co-creation',
+ },
+ });
+ if (confirm.status !== 200) {
+ return { assetObjectId: null, error: brief(confirm) };
+ }
+ return {
+ assetObjectId: confirm.data.assetObject.assetObjectId,
+ objectKey: upload.objectKey,
+ };
+}
+
+// ---------- 作品 / 发布链 ----------
+
+async function listMyGames(token) {
+ const games = [];
+ let cursor;
+ for (let page = 0; page < 10; page += 1) {
+ const suffix = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '';
+ const response = await api(`/api/game-distribution/my-games${suffix}`, {
+ token,
+ });
+ if (response.status !== 200) return games;
+ games.push(...(response.data?.games ?? []));
+ cursor = response.data?.nextCursor ?? null;
+ if (!cursor) break;
+ }
+ return games;
+}
+
+/// 公开目录查重:演示作品的判重**不绑定账号**——只要公开目录里已有完全同名的一条,就复用它。
+/// 这样账号被换掉(例如上一次跑批用的是临时手机号)时也不会重复灌一份演示数据。
+async function findPublicGameByTitle(title) {
+ let cursor;
+ for (let page = 0; page < 12; page += 1) {
+ const suffix = cursor
+ ? `?limit=50&cursor=${encodeURIComponent(cursor)}`
+ : '?limit=50';
+ const response = await api(`/api/game-distribution/games${suffix}`);
+ if (response.status !== 200) return null;
+ const found = (response.data?.games ?? []).find(
+ (game) => game.title === title,
+ );
+ if (found) return found;
+ cursor = response.data?.nextCursor ?? null;
+ if (!cursor) return null;
+ }
+ return null;
+}
+
+async function publicVersionIdOf(token, gameId) {
+ const games = await listMyGames(token);
+ const game = games.find((entry) => entry.id === gameId);
+ const versions = game?.versions ?? [];
+ return (
+ versions.find((version) => version.status === 'published')?.versionId ?? ''
+ );
+}
+
+function gameMetadata({ title, coverAssetId, summary }) {
+ return {
+ title,
+ summary,
+ description: '',
+ category: '休闲',
+ tags: ['共创演示'],
+ coverAssetId,
+ deviceSupport: { desktop: true, mobile: false, touch: false },
+ inputModes: ['keyboard', 'mouse'],
+ orientation: 'landscape',
+ };
+}
+
+async function buildReleaseZip(marker) {
+ const zip = new JSZip();
+ zip.file(
+ 'index.html',
+ `
${marker}${marker}
`,
+ );
+ const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
+ return { bytes, fileCount: 1, sha256: hash(bytes) };
+}
+
+function hash(bytes) {
+ return createHash('sha256').update(bytes).digest('hex');
+}
+
+async function publishVersion({
+ token,
+ admin,
+ gameId,
+ gameRevision,
+ metadata,
+ changeSummary,
+ label,
+ stamp,
+}) {
+ const zip = await buildReleaseZip(`${label}-${stamp}`);
+ const versionBody = {
+ packageSha256: zip.sha256,
+ packageBytes: zip.bytes.length,
+ packageFileCount: zip.fileCount,
+ packageEntryPath: 'index.html',
+ gameMetadata: metadata,
+ ...(changeSummary ? { changeSummary } : {}),
+ };
+ const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': `seed-version-${label}-${stamp}` },
+ body: versionBody,
+ });
+ const versionId = created.data?.versionId;
+ if (created.status !== 200 || !versionId) {
+ return { ok: false, error: `建版本失败 ${brief(created)}` };
+ }
+ const upload = await api(
+ `/api/game-distribution/versions/${versionId}/package`,
+ {
+ method: 'PUT',
+ token,
+ headers: {
+ 'Idempotency-Key': `seed-upload-${label}-${stamp}`,
+ 'Content-Type': 'application/zip',
+ },
+ binary: zip.bytes,
+ },
+ );
+ if (upload.status !== 200) {
+ return { ok: false, error: `传包失败 ${brief(upload)}` };
+ }
+ const submitted = await api(
+ `/api/game-distribution/versions/${versionId}/submit`,
+ {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': `seed-submit-${label}-${stamp}` },
+ body: { expectedPublicationRevision: gameRevision },
+ },
+ );
+ if (submitted.status !== 202) {
+ return { ok: false, error: `送审失败 ${brief(submitted)}` };
+ }
+ 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': `seed-approve-${label}-${stamp}` },
+ body: {
+ decision: 'approve',
+ expectedPublicationRevision:
+ readback.data?.version?.publicationRevision ?? gameRevision,
+ },
+ },
+ );
+ if (approved.status !== 200) {
+ return { ok: false, error: `审核失败 ${brief(approved)}` };
+ }
+ return { ok: true, versionId };
+}
+
+// ---------- 演示网络定义 ----------
+
+const NODES = [
+ // 树 A:A0(full) → A1(nonCommercial 子树) → A11 → A111;A12;A2(full) → A21;A3
+ {
+ key: 'A0',
+ author: '甲',
+ name: '星海工坊',
+ parent: null,
+ tier: 'nonCommercial',
+ },
+ {
+ key: 'A1',
+ author: '乙',
+ name: '星海工坊·深海回声',
+ parent: 'A0',
+ summary: '把场景换成深海遗迹,重写了光照与水流,并补了三段可探索的支线。',
+ },
+ {
+ key: 'A11',
+ author: '丁',
+ name: '深海回声·潮汐秘境',
+ parent: 'A1',
+ summary: '在深海回声的支线里加了一个潮汐涨落机制,谜题改成按时辰解锁。',
+ },
+ {
+ key: 'A111',
+ author: '丙',
+ name: '潮汐秘境·灯塔之夜',
+ parent: 'A11',
+ summary: '把最终关挪到灯塔夜景,补了全程配音与一段潜行玩法,难度曲线重排。',
+ },
+ {
+ key: 'A12',
+ author: '乙',
+ name: '深海回声·珊瑚改良',
+ parent: 'A1',
+ summary: '重做珊瑚关卡的地形与音效,把三处跳台改成可骑乘的海龟路线。',
+ },
+ {
+ key: 'A2',
+ author: '丙',
+ name: '星海工坊·轨道集市',
+ parent: 'A0',
+ summary: '把工坊搬进轨道集市,新增买卖与讨价还价玩法,界面按交易流重做。',
+ },
+ {
+ key: 'A21',
+ author: '甲',
+ name: '轨道集市·夜航商队',
+ parent: 'A2',
+ summary: '加了夜航商队事件链与三段随机护送任务,重排了经济数值。',
+ },
+ {
+ key: 'A3',
+ author: '甲',
+ name: '星海工坊·废弃船坞',
+ parent: 'A0',
+ summary: '场景改成废弃船坞,玩法从建造转为打捞,新增三种可拆解零件。',
+ },
+ // 树 B:与 A 无血缘
+ { key: 'B0', author: '乙', name: '青苔邮局', parent: null, tier: 'full' },
+ {
+ key: 'B1',
+ author: '甲',
+ name: '青苔邮局·雨季分拣',
+ parent: 'B0',
+ summary: '把分拣台改成雨季湿滑的传送带,新增湿度对包裹的影响。',
+ },
+ {
+ key: 'B2',
+ author: '丁',
+ name: '雨季分拣·末班列车',
+ parent: 'B1',
+ summary: '补了一趟末班列车收尾关卡,加了限时排班与两条隐藏结局。',
+ },
+ // 树 C:专门演示「nonCommercial 子树」(母版保持 nonCommercial,子代继承同档位)
+ {
+ key: 'C0',
+ author: '丙',
+ name: '南岭茶馆',
+ parent: null,
+ tier: 'nonCommercial',
+ },
+ {
+ key: 'C1',
+ author: '丁',
+ name: '南岭茶馆·雨前茶会',
+ parent: 'C0',
+ summary: '把泡茶玩法扩成雨前茶会事件,新增三款茶叶与一条老客线。',
+ },
+];
+
+// 树 A 的母版必须在 A1 子树全部建完之后才提升成 full,否则 A1 会继承到 full(演示失真)。
+const DEFERRED_ROOT_PROMOTION = {
+ key: 'A0',
+ afterKey: 'A12',
+ from: 'nonCommercial',
+ to: 'full',
+};
+
+const titleOf = (node) => `${TITLE_PREFIX}${node.key}|${node.name}`;
+
+// ---------- 主流程 ----------
+
+async function main() {
+ console.log(
+ `[seed] api-server=${API} web=${WEB} database=${devStack.database}`,
+ );
+ console.log(
+ `[seed] 封面目录=${COVER_DIR}(上限 ${(COVER_MAX_BYTES / 1024 / 1024).toFixed(1)} MiB,优先横版且体积小)`,
+ );
+
+ 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: '共创演示数据',
+ },
+ });
+ check('发布灰度已开启', gate.status === 200, `status=${gate.status}`);
+
+ const authors = {};
+ for (const spec of AUTHOR_SPECS) {
+ authors[spec.key] = await ensureAuthor(spec);
+ }
+ check(
+ '四个演示作者账号就绪(甲/乙/丙/丁,均设了展示名)',
+ Object.values(authors).every((author) => Boolean(author.token)),
+ Object.values(authors)
+ .map((author) => `${author.key}=${author.phone}`)
+ .join(' '),
+ );
+
+ const covers = listCoverCandidates();
+ check(
+ '封面候选足够(横版且不超过体积上限)',
+ covers.length >= NODES.length,
+ `候选=${covers.length} 需要=${NODES.length}(跳过超大原图)`,
+ );
+ if (covers.length < NODES.length) process.exit(1);
+ // 「同族树尽量同色系」:不解析图片内容,只按**文件名里的题材关键词**粗分色系;同一棵树优先用同一色系的候选,
+ // 不足再退回剩余候选。做的是「尽量」,所以命名信息缺失时会退化成按体积顺序取,不影响正确性。
+ const TONE_KEYWORDS = [
+ {
+ tone: '自然山水',
+ pattern:
+ /山水|雪山|湖|夜空|晨曦|lake|mountain|landscape|风景|雪山|山|天空|sky|路/i,
+ },
+ {
+ tone: '动漫插画',
+ pattern: /幸运星|卖萌|插画|动漫|二次元|星星|anime|girl/i,
+ },
+ ];
+ const toneOf = (entry) =>
+ TONE_KEYWORDS.find((keyword) => keyword.pattern.test(entry.name))?.tone ??
+ '其他';
+ const remainingByTone = new Map();
+ for (const entry of covers) {
+ const tone = toneOf(entry);
+ if (!remainingByTone.has(tone)) remainingByTone.set(tone, []);
+ remainingByTone.get(tone).push(entry);
+ }
+ const coverByNodeKey = new Map();
+ for (const tree of [...new Set(NODES.map((node) => node.key[0]))]) {
+ const treeNodes = NODES.filter((node) => node.key[0] === tree);
+ // 先挑「最能覆盖这棵树」的色系(覆盖数并列时取候选更多的),再从该色系取满,不够按顺序用别的色系补。
+ const ranked = [...remainingByTone.entries()]
+ .filter(([, list]) => list.length > 0)
+ .sort(
+ (a, b) =>
+ Math.min(b[1].length, treeNodes.length) -
+ Math.min(a[1].length, treeNodes.length) ||
+ b[1].length - a[1].length ||
+ a[1][0].bytes.length - b[1][0].bytes.length,
+ );
+ const picks = [];
+ for (const [, list] of ranked) {
+ while (picks.length < treeNodes.length && list.length > 0) {
+ picks.push(list.shift());
+ }
+ if (picks.length >= treeNodes.length) break;
+ }
+ treeNodes.forEach((node, index) => {
+ if (picks[index]) coverByNodeKey.set(node.key, picks[index]);
+ });
+ }
+ const coverFor = (node) =>
+ coverByNodeKey.get(node.key) ?? covers[NODES.indexOf(node) % covers.length];
+ // 「只列封面分配」模式:不动任何数据,只把「哪个节点用哪张原图」打出来(验收/排查用)。
+ if ((process.env.SEED_LIST_COVERS ?? '') === '1') {
+ for (const node of NODES) {
+ const cover = coverFor(node);
+ console.log(
+ `${node.key} | ${toneOf(cover)} | ${cover.filePath} | ${(cover.bytes.length / 1024 / 1024).toFixed(2)} MiB | ${cover.width}x${cover.height}`,
+ );
+ }
+ return;
+ }
+
+ // 1) 逐节点创建/复用(按 父→子 顺序,保证父已公开且有当前公开版本)
+ const state = new Map();
+ const stamp = Date.now();
+ for (const node of NODES) {
+ const author = authors[node.author];
+ const title = titleOf(node);
+ // 判重口径:先看**公开目录**(不绑账号 → 账号换了也不重复灌),再看本账号 my-games(处理未公开的同名作品)。
+ const publicMatch = await findPublicGameByTitle(title);
+ let existing = publicMatch ? { id: publicMatch.id } : null;
+ if (existing) {
+ state.set(node.key, { ...node, gameId: existing.id, reused: true });
+ console.log(
+ `[seed] 复用公开目录里的同标题作品 ${node.key}|${node.name}(${existing.id})`,
+ );
+ continue;
+ }
+ const mine = await listMyGames(author.token);
+ existing = mine.find((game) => game.title === title) ?? null;
+ if (existing) {
+ console.log(
+ `[seed] 复用本账号的未公开作品并补发布 ${node.key}|${node.name}(${existing.id})`,
+ );
+ const cover = coverFor(node);
+ const coverResult = await uploadCover({
+ token: author.token,
+ cover,
+ label: `${node.key}-reuse`,
+ });
+ const parent = node.parent ? state.get(node.parent) : null;
+ const published2 = await publishVersion({
+ token: author.token,
+ admin,
+ gameId: existing.id,
+ gameRevision: existing.publicationRevision ?? 0,
+ metadata: gameMetadata({
+ title,
+ coverAssetId: coverResult.assetObjectId,
+ summary: `共创演示:${node.name}`,
+ }),
+ changeSummary: parent ? node.summary : undefined,
+ label: `${node.key}-reuse`,
+ stamp,
+ });
+ if (!published2.ok) {
+ // 既有同标题作品可能是软删除/不可发布状态:不要卡死,改为新建同标题作品(SpacetimeDB 不限制标题唯一)。
+ console.log(
+ `[seed] 既有作品 ${node.key}(${existing.id})无法复用:${published2.error} → 改为新建同标题作品`,
+ );
+ existing = null;
+ } else {
+ state.set(node.key, { ...node, gameId: existing.id, reused: true });
+ continue;
+ }
+ }
+ const cover = coverFor(node);
+ const coverResult = await uploadCover({
+ token: author.token,
+ cover,
+ label: node.key,
+ });
+ if (!coverResult.assetObjectId) {
+ check(
+ `封面上传 ${node.key}`,
+ false,
+ `${cover.name} → ${coverResult.error}`,
+ );
+ process.exit(1);
+ }
+ const parent = node.parent ? state.get(node.parent) : null;
+ const parentVersionId = parent
+ ? await publicVersionIdOf(authors[parent.author].token, parent.gameId)
+ : '';
+ if (parent && !parentVersionId) {
+ check(
+ `父作品有当前公开版本 ${node.parent}`,
+ false,
+ `parent=${parent.gameId}`,
+ );
+ process.exit(1);
+ }
+ const created = await api('/api/game-distribution/games', {
+ method: 'POST',
+ token: author.token,
+ headers: { 'Idempotency-Key': `seed-game-${node.key}-${stamp}` },
+ body: {
+ ...gameMetadata({
+ title,
+ coverAssetId: coverResult.assetObjectId,
+ summary: `共创演示:${node.name}`,
+ }),
+ ...(parent
+ ? { fork: { parentGameId: parent.gameId, parentVersionId } }
+ : {}),
+ },
+ });
+ const gameId = created.data?.id;
+ if (created.status !== 200 || !gameId) {
+ check(`创建作品 ${node.key}`, false, brief(created));
+ process.exit(1);
+ }
+ // 母版先按目标子树档位落库(让子代继承到预期档位),随后再提升回演示档位
+ if (!parent && node.tier) {
+ const promoted = await api(
+ `/api/game-distribution/games/${gameId}/fork-authorization`,
+ {
+ method: 'PUT',
+ token: author.token,
+ headers: { 'Idempotency-Key': `seed-tier-${node.key}-${stamp}` },
+ body: {
+ expectedForkAuthorization: 'forbidden',
+ forkAuthorization: node.tier,
+ },
+ },
+ );
+ if (promoted.status !== 200) {
+ check(`设定母版档位 ${node.key}`, false, brief(promoted));
+ process.exit(1);
+ }
+ }
+ const published = await publishVersion({
+ token: author.token,
+ admin,
+ gameId,
+ gameRevision: created.data?.publicationRevision ?? 0,
+ metadata: gameMetadata({
+ title,
+ coverAssetId: coverResult.assetObjectId,
+ summary: `共创演示:${node.name}`,
+ }),
+ changeSummary: parent ? node.summary : undefined,
+ label: node.key,
+ stamp,
+ });
+ if (!published.ok) {
+ check(`发布 ${node.key}`, false, published.error);
+ process.exit(1);
+ }
+ state.set(node.key, {
+ ...node,
+ gameId,
+ coverName: cover.filePath ?? cover.name,
+ reused: false,
+ });
+ console.log(
+ `[seed] 新建并公开 ${node.key}|${node.name}(${node.author},封面 ${cover.name})`,
+ );
+
+ // 母版档位推迟提升:树 A 想演示「A1 子树继承 nonCommercial」,就必须等 A1/A11/A12 都建完再
+ // 把 A0 提升成 full;这里按里程碑节点触发,且对「已经是目标档位」保持幂等(不报错)。
+ if (node.key === DEFERRED_ROOT_PROMOTION.afterKey) {
+ const target = state.get(DEFERRED_ROOT_PROMOTION.key);
+ const owner = authors[target.author];
+ const view = (await listMyGames(owner.token)).find(
+ (game) => game.id === target.gameId,
+ );
+ if (view?.forkAuthorization === DEFERRED_ROOT_PROMOTION.to) {
+ console.log(
+ `[seed] ${DEFERRED_ROOT_PROMOTION.key} 已是 ${DEFERRED_ROOT_PROMOTION.to},跳过推迟提升(幂等)`,
+ );
+ } else {
+ const promoted = await api(
+ `/api/game-distribution/games/${target.gameId}/fork-authorization`,
+ {
+ method: 'PUT',
+ token: owner.token,
+ headers: {
+ 'Idempotency-Key': `seed-tier-after-${DEFERRED_ROOT_PROMOTION.key}-${stamp}`,
+ },
+ body: {
+ expectedForkAuthorization: DEFERRED_ROOT_PROMOTION.from,
+ forkAuthorization: DEFERRED_ROOT_PROMOTION.to,
+ },
+ },
+ );
+ check(
+ `母版 ${DEFERRED_ROOT_PROMOTION.key} 推迟提升为 ${DEFERRED_ROOT_PROMOTION.to}(A1 子树已建完)`,
+ promoted.status === 200,
+ brief(promoted),
+ );
+ }
+ }
+ }
+
+ // 2) 逐节点回读:标题/作者/档位/generation/parentGameId
+ console.log('');
+ console.log('节点键 | 标题 | 作者 | 档位 | generation | parentGameId | 封面');
+ for (const node of NODES) {
+ const current = state.get(node.key);
+ const publicDetail = await api(
+ `/api/game-distribution/games/${current.gameId}`,
+ );
+ // 档位从**公开详情**读:复用旧跑批作品时作者账号可能已经换了(owner 视角会拿不到),
+ // 而公开 payload 本身就带 forkAuthorization。
+ const tier = publicDetail.data?.forkAuthorization ?? '?';
+ const lineage = await api(
+ `/api/game-distribution/games/${current.gameId}/lineage`,
+ );
+ const self = (lineage.data?.nodes ?? []).find(
+ (entry) => entry.gameId === current.gameId,
+ );
+ console.log(
+ [
+ node.key,
+ publicDetail.data?.title ?? '?',
+ node.author,
+ tier,
+ self?.generation ?? '?',
+ self?.parentGameId ?? 'null',
+ current.coverName ?? (current.reused ? '(复用时未换封面)' : '?'),
+ ].join(' | '),
+ );
+ }
+
+ // 3) 树形状断言
+ const a0 = state.get('A0');
+ const b0 = state.get('B0');
+ const treeA = await api(`/api/game-distribution/games/${a0.gameId}/lineage`);
+ const treeB = await api(`/api/game-distribution/games/${b0.gameId}/lineage`);
+ const nodesA = treeA.data?.nodes ?? [];
+ check(
+ '树 A /lineage:truncated=false 且 rootGameId=A0',
+ treeA.status === 200 &&
+ treeA.data?.truncated === false &&
+ treeA.data?.rootGameId === a0.gameId,
+ `status=${treeA.status} nodes=${nodesA.length} truncated=${String(treeA.data?.truncated)}`,
+ );
+ check(
+ '树 A 节点数 = 8(A0/A1/A11/A111/A12/A2/A21/A3)',
+ nodesA.length === 8,
+ `nodes=${nodesA.length} ids=${nodesA.map((node) => node.gameId.slice(-6)).join(',')}`,
+ );
+ const expectedParents = {
+ A0: null,
+ A1: 'A0',
+ A11: 'A1',
+ A111: 'A11',
+ A12: 'A1',
+ A2: 'A0',
+ A21: 'A2',
+ A3: 'A0',
+ };
+ const generations = {
+ A0: 0,
+ A1: 1,
+ A11: 2,
+ A111: 3,
+ A12: 2,
+ A2: 1,
+ A21: 2,
+ A3: 1,
+ };
+ for (const node of NODES.filter((entry) => entry.key.startsWith('A'))) {
+ const found = nodesA.find(
+ (entry) => entry.gameId === state.get(node.key).gameId,
+ );
+ const expectedParentId = expectedParents[node.key]
+ ? state.get(expectedParents[node.key]).gameId
+ : null;
+ check(
+ `树 A 节点 ${node.key}:generation=${generations[node.key]}、parent=${expectedParents[node.key] ?? 'null'}`,
+ found?.generation === generations[node.key] &&
+ (expectedParentId
+ ? found?.parentGameId === expectedParentId
+ : found?.parentGameId == null),
+ `generation=${found?.generation ?? '∅'} parentGameId=${found?.parentGameId ?? 'null'}`,
+ );
+ }
+ const derivedA = await api(
+ `/api/game-distribution/games/${a0.gameId}/derived`,
+ );
+ check(
+ '树 A /derived:恰好 3 个直接子(A1/A2/A3)',
+ derivedA.status === 200 &&
+ (derivedA.data?.nodes ?? []).length === 3 &&
+ ['A1', 'A2', 'A3'].every((key) =>
+ (derivedA.data?.nodes ?? []).some(
+ (entry) => entry.gameId === state.get(key).gameId,
+ ),
+ ),
+ `derived=${(derivedA.data?.nodes ?? []).map((entry) => entry.gameId.slice(-6)).join(',')}`,
+ );
+ check(
+ '树 B /lineage:3 个节点、truncated=false、根=B0',
+ treeB.status === 200 &&
+ (treeB.data?.nodes ?? []).length === 3 &&
+ treeB.data?.truncated === false &&
+ treeB.data?.rootGameId === b0.gameId,
+ `nodes=${(treeB.data?.nodes ?? []).length}`,
+ );
+ check(
+ '两棵树互不串血(A0 的树里没有 B 系节点,反之亦然)',
+ !nodesA.some((node) => node.gameId === b0.gameId) &&
+ !(treeB.data?.nodes ?? []).some((node) => node.gameId === a0.gameId),
+ );
+ const c0 = state.get('C0');
+ const treeC = await api(`/api/game-distribution/games/${c0.gameId}/lineage`);
+ const nodesC = treeC.data?.nodes ?? [];
+ check(
+ '树 C /lineage:2 个节点、truncated=false、根=C0、C1 generation=1',
+ treeC.status === 200 &&
+ nodesC.length === 2 &&
+ treeC.data?.truncated === false &&
+ treeC.data?.rootGameId === c0.gameId &&
+ nodesC.find((node) => node.gameId === state.get('C1').gameId)
+ ?.generation === 1,
+ `nodes=${nodesC.length} root=${String(treeC.data?.rootGameId).slice(-6)}`,
+ );
+
+ // 4) 授权继承与不可提升(演示「授权继承 + 衍生档位不可再提升」)
+ const tierOfPublic = async (key) =>
+ (await api(`/api/game-distribution/games/${state.get(key).gameId}`)).data
+ ?.forkAuthorization ?? null;
+ const c0Tier = await tierOfPublic('C0');
+ const c1Tier = await tierOfPublic('C1');
+ const a0Tier = await tierOfPublic('A0');
+ check(
+ '树 C 演示 nonCommercial 子树:C0=nonCommercial 且 C1 创建时继承同档位',
+ c0Tier === 'nonCommercial' && c1Tier === 'nonCommercial',
+ `C0=${c0Tier} C1=${c1Tier}`,
+ );
+ check(
+ '树 A 母版最终档位 full(A1 子树是在它还是 nonCommercial 时建出来的)',
+ a0Tier === 'full',
+ `A0=${a0Tier}`,
+ );
+ for (const [key, owner] of [
+ ['A1', '乙'],
+ ['C1', '丁'],
+ ]) {
+ const promoteDerived = await api(
+ `/api/game-distribution/games/${state.get(key).gameId}/fork-authorization`,
+ {
+ method: 'PUT',
+ token: authors[owner].token,
+ headers: { 'Idempotency-Key': `seed-derive-promote-${key}-${stamp}` },
+ body: {
+ expectedForkAuthorization: 'nonCommercial',
+ forkAuthorization: 'full',
+ },
+ },
+ );
+ check(
+ `衍生作品 ${key} 提升被拒(本人=409 FORK_AUTHORIZATION_INHERITED;复用旧跑批作品时=403 owner 不匹配)`,
+ (promoteDerived.status === 409 &&
+ (promoteDerived.error?.code ?? '') ===
+ 'FORK_AUTHORIZATION_INHERITED') ||
+ (promoteDerived.status === 403 &&
+ (promoteDerived.error?.code ?? '') === 'FORBIDDEN'),
+ brief(promoteDerived),
+ );
+ }
+
+ // 5) 主题(管理员建、把 A0 作为根成员加入)
+ if ((process.env.SEED_SKIP_THEME ?? '') !== '1') {
+ const adminThemes = await api(
+ '/admin/api/game-distribution/themes?limit=200',
+ {
+ token: admin,
+ },
+ );
+ let theme = (adminThemes.data?.themes ?? []).find(
+ (entry) => entry.name === THEME_NAME,
+ );
+ if (!theme) {
+ const created = await api('/admin/api/game-distribution/themes', {
+ method: 'POST',
+ token: admin,
+ headers: { 'Idempotency-Key': `seed-theme-${stamp}` },
+ body: {
+ name: THEME_NAME,
+ summary: '共创演示:把星海工坊这一族的作品按主题聚合',
+ badge: '演示',
+ sortOrder: 1,
+ status: 'published',
+ },
+ });
+ theme = created.data?.theme;
+ check(
+ '管理员创建主题',
+ created.status === 200 && Boolean(theme),
+ brief(created),
+ );
+ } else {
+ console.log(`[seed] 复用已存在主题 ${THEME_NAME}`);
+ }
+ const member = await api(
+ `/admin/api/game-distribution/themes/${theme.themeId}/members/${a0.gameId}`,
+ { method: 'PUT', token: admin, body: { sortOrder: 0 } },
+ );
+ check('A0 作为根成员加入主题', member.status === 200, brief(member));
+ console.log(`[seed] 主题页 URL=${WEB}/games/theme?id=${theme.themeId}`);
+ console.log(`[seed] 主题 ID=${theme.themeId}`);
+ }
+
+ // 6) 收藏(两个作者各收藏两件别人的作品)
+ if ((process.env.SEED_SKIP_COLLECTIONS ?? '') !== '1') {
+ const collectionPlan = [
+ { author: '甲', targets: ['B1', 'A21'] },
+ { author: '乙', targets: ['A0', 'B2'] },
+ ];
+ for (const entry of collectionPlan) {
+ for (const key of entry.targets) {
+ const response = await api(
+ `/api/game-distribution/games/${state.get(key).gameId}/collection`,
+ {
+ method: 'PUT',
+ token: authors[entry.author].token,
+ headers: {
+ 'Idempotency-Key': `seed-collect-${authors[entry.author].ascii}-${key}-${stamp}`,
+ },
+ },
+ );
+ check(
+ `${entry.author} 收藏 ${key}`,
+ response.status === 200 || response.data?.collected === true,
+ brief(response),
+ );
+ }
+ }
+ }
+
+ // 7) 打印可点的 URL
+ console.log('');
+ console.log(`[seed] 树 A 根:${state.get('A0').gameId}`);
+ console.log(`[seed] 树 A lineage URL=${WEB}/games/lineage?id=${a0.gameId}`);
+ console.log(`[seed] 树 A 根详情 URL=${WEB}/games/detail?id=${a0.gameId}`);
+ console.log(`[seed] 树 B 根:${state.get('B0').gameId}`);
+ console.log(`[seed] 树 B lineage URL=${WEB}/games/lineage?id=${b0.gameId}`);
+ console.log(`[seed] 树 C 根:${state.get('C0').gameId}`);
+ console.log(`[seed] 树 C lineage URL=${WEB}/games/lineage?id=${c0.gameId}`);
+ console.log(
+ `[seed] 作者账号:${Object.values(authors)
+ .map((author) => `${author.displayName}=${author.phone}`)
+ .join(',')}(密码 ${DEV_PASSWORD})`,
+ );
+ // 复用旧跑批数据时,作品可能不是当前这批演示账号建的(判重按标题、不绑账号):把「当前账号实际拥有几个节点」
+ // 打出来,免得有人用演示账号登录后看到「我的作品」是空的却不知道为什么。
+ for (const spec of AUTHOR_SPECS) {
+ const author = authors[spec.key];
+ const mine = await listMyGames(author.token);
+ const expected = NODES.filter((node) => node.author === spec.key);
+ const owned = expected.filter((node) =>
+ mine.some((game) => game.id === state.get(node.key)?.gameId),
+ ).length;
+ console.log(
+ `[seed] 演示账号 ${spec.displayName}(${author.phone})拥有本演示的 ${owned}/${expected.length} 个节点作品`,
+ );
+ }
+
+ console.log(
+ `[seed] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures}`,
+ );
+ if (failures > 0) process.exitCode = 1;
+}
+
+main().catch((error) => {
+ console.error(`[seed] 未捕获异常:${error?.stack ?? error}`);
+ process.exitCode = 1;
+});