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; +});