diff --git a/scripts/capture-game-lineage-visual.mjs b/scripts/capture-game-lineage-visual.mjs index b8dda4d72..ac5c604d4 100644 --- a/scripts/capture-game-lineage-visual.mjs +++ b/scripts/capture-game-lineage-visual.mjs @@ -329,6 +329,7 @@ async function publishToPublic({ stamp, tag, label, + changeSummary, }) { const zip = await buildZip(`visual-${tag}-${stamp}`); const created = await api(`/api/game-distribution/games/${gameId}/versions`, { @@ -341,6 +342,8 @@ async function publishToPublic({ packageFileCount: zip.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, + // 衍生作品必填「本次核心改动说明」;根节点不传(传了也会被忽略)。 + ...(changeSummary ? { changeSummary } : {}), }, }); const versionId = created.data?.versionId; @@ -451,6 +454,12 @@ async function createPublishedNode({ stamp, tag: `visual-${label}`, label, + // 衍生节点(有父)必填核心改动说明:截图里的「每一代差异」用它渲染。 + ...(parent + ? { + changeSummary: `视觉夹具:节点 ${label} 相对 ${parent.label} 的核心改动说明`, + } + : {}), }); const detail = await publicDetail(gameId); check( @@ -458,7 +467,18 @@ async function createPublishedNode({ detail.data?.status === 'published', `status=${detail.data?.status ?? '∅'}`, ); - if (promoteTo) { + // 新语义:衍生作品在创建时**继承**父作品档位(创建后忽略请求里的档位、母版才允许 PUT 提升)。 + if (parent) { + const childView = await ownerGame(owner.token, gameId); + const parentView = await ownerGame(parent.token, parent.gameId); + const childTier = childView?.forkAuthorization ?? null; + const parentTier = parentView?.forkAuthorization ?? null; + check( + `造树:衍生节点 ${label} 继承父档位(${childTier} === ${parentTier})`, + childTier !== null && childTier === parentTier, + `child=${childTier} parent=${parentTier}`, + ); + } else if (promoteTo) { const promoted = await promoteForkAuthorization({ token: owner.token, gameId, @@ -466,7 +486,7 @@ async function createPublishedNode({ key: `visual-promote-${label}-${stamp}`, }); check( - `造树:节点 ${label} 授权=${promoteTo}`, + `造树:母版节点 ${label} 授权提升=${promoteTo}`, promoted.status === 200, brief(promoted), ); diff --git a/scripts/check-game-distribution-lineage-e2e.mjs b/scripts/check-game-distribution-lineage-e2e.mjs index fc0c4b554..bec73e0e3 100644 --- a/scripts/check-game-distribution-lineage-e2e.mjs +++ b/scripts/check-game-distribution-lineage-e2e.mjs @@ -80,8 +80,9 @@ const LINEAGE_ANCHOR_LABEL = process.env.E2E_LINEAGE_LABEL_ANCHOR ?? '当前作 const LINEAGE_PARENT_UNAVAILABLE = process.env.E2E_LINEAGE_LABEL_PARENT_UNAVAILABLE ?? '原作品已不可用'; // 详情页「改造这个作品」入口(392cb3bb0):标签与文案取自 GameDetailPage.tsx:430-490。 -const REMIX_ENTRY_LABEL = process.env.E2E_REMIX_ENTRY_LABEL ?? 'Fork 这个作品'; -const REMIX_LOGIN_LABEL = process.env.E2E_REMIX_LOGIN_LABEL ?? '登录后可 Fork'; +const REMIX_ENTRY_LABEL = process.env.E2E_REMIX_ENTRY_LABEL ?? '一键 Fork 共创'; +const REMIX_LOGIN_LABEL = + process.env.E2E_REMIX_LOGIN_LABEL ?? '登录后一键 Fork'; const REMIX_STEPS_TEXT = process.env.E2E_REMIX_STEPS_TEXT ?? '从平台作品开始创作'; const REMIX_NOTE_TEXT = process.env.E2E_REMIX_NOTE_TEXT ?? '整个工程'; @@ -351,6 +352,9 @@ async function promoteForkAuthorization({ } /// 完整发布链路:建版本 → 传包 → 送审 → 管理员通过(照抄 media-e2e.mjs:431-573 的顺序与请求体)。 +/// +/// `changeSummary` 只在**衍生作品**上传(§3.4:衍生作品发布必填「本次核心改动说明」, +/// 母版传了也会被忽略)——调用方按 `fork` 是否存在决定要不要带。 async function publishToPublic({ token, admin, @@ -361,6 +365,7 @@ async function publishToPublic({ stamp, tag, label, + changeSummary, }) { const created = await api(`/api/game-distribution/games/${gameId}/versions`, { method: 'POST', @@ -372,6 +377,7 @@ async function publishToPublic({ packageFileCount: zip.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, + ...(changeSummary ? { changeSummary } : {}), }, }); const versionId = created.data?.versionId; @@ -784,7 +790,11 @@ async function runBrowserStep({ const bump = (key) => () => { counters[key] += 1; }; - document.addEventListener('pointerdown', bump('pointerdown'), true); + document.addEventListener( + 'pointerdown', + bump('pointerdown'), + true, + ); document.addEventListener('mousedown', bump('mousedown'), true); document.addEventListener('click', bump('click'), true); window.__lineageMouseProbe = counters; @@ -857,17 +867,76 @@ async function runBrowserStep({ timeout: 120_000, }); await page.waitForTimeout(1500); - check( - '浏览器视觉:授权 forbidden 的作品详情页没有「Fork 这个作品」入口', + // 新前端:forbidden 作品的入口**照常渲染**,但为禁用态(aria-disabled + --blocked), + // 点它只给一句说明、不展开获取面板。这里先点一次再把可观测量喂给断言。 + const blockedEntry = page + .locator('.co-creation-fork-primary--blocked') + .first(); + if ((await blockedEntry.count()) > 0) { + // aria-disabled 的元素会被 Playwright 的可操作性检查拦下(element is not enabled); + // 这里测的正是应用自己的禁用分支,因此用 force 绕过检查、把点击真的发出去。 + await blockedEntry.click({ force: true }).catch(() => {}); + await page.waitForTimeout(400); + } + const blockedTitle = (await page - .getByRole('button', { name: REMIX_ENTRY_LABEL, exact: true }) - .count()) === 0 && - (await page - .getByRole('button', { name: REMIX_LOGIN_LABEL, exact: true }) - .count()) === 0, - `forbidden=${forbiddenGameId}`, + .locator('.co-creation-fork-primary--blocked') + .first() + .getAttribute('title') + .catch(() => null)) ?? ''; + const blockedPanelCount = await page + .locator('#game-fork-remix-panel') + .count(); + check( + '浏览器视觉:授权 forbidden 的作品入口为禁用态(aria-disabled + --blocked + 原因 title),点它不展开面板', + (await page + .locator('.co-creation-fork-primary[aria-disabled="true"]') + .count()) >= 1 && + (await page.locator('.co-creation-fork-primary--blocked').count()) >= + 1 && + blockedTitle.includes('无法 Fork 这部作品') && + blockedPanelCount === 0, + `forbidden=${forbiddenGameId} title=${blockedTitle.slice(0, 40)} panel=${blockedPanelCount}`, ); + // 未登录访客:入口文案是另一套(登录后一键 Fork),点击走登录门禁而不是展开面板。 + const anonymousContext = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + }); + try { + const anonymousPage = await anonymousContext.newPage(); + await anonymousPage.goto(`${WEB}/games/detail?id=${parentId}`, { + waitUntil: 'commit', + timeout: 120_000, + }); + const anonymousEntry = anonymousPage.getByRole('button', { + name: REMIX_LOGIN_LABEL, + exact: true, + }); + const anonymousEntryVisible = await visible(anonymousEntry, 60_000); + check( + '浏览器视觉:未登录访客看到的是「登录后一键 Fork」入口', + anonymousEntryVisible, + `label=${REMIX_LOGIN_LABEL}`, + ); + if (anonymousEntryVisible) { + await anonymousEntry.first().click(); + await anonymousPage.waitForTimeout(800); + check( + '浏览器视觉:未登录点击该入口走登录门禁(不展开获取面板)', + (await anonymousPage + .getByRole('tab', { name: '密码登录' }) + .count()) >= 1 && + (await anonymousPage + .locator('#game-fork-remix-panel') + .count()) === 0, + `loginTab=${await anonymousPage.getByRole('tab', { name: '密码登录' }).count()} panel=${await anonymousPage.locator('#game-fork-remix-panel').count()}`, + ); + } + } finally { + await anonymousContext.close().catch(() => {}); + } + // 父作品软删除后:树仍在,但父节点内容不得再出现。 const deleted = await deleteParent(); check( @@ -1074,6 +1143,38 @@ async function main() { if (!childId) process.exit(1); const childZip = await buildZip(`child-${suffix}`); + const childChangeSummary = 'E2E:把跳台改成三段,并重画全部背景与角色立绘'; + + // 衍生作品必填「本次核心改动说明」(§3.4):缺失与超短都必须失败关闭(400 + 稳定码), + // 且都不写库——先各试一次(用不同的 Idempotency-Key,失败不消耗版本号), + // 再带合法说明走完整发布链路,两头都钉住。 + for (const [tag, summary, expectedCode] of [ + ['missing', undefined, 'FORK_CHANGE_SUMMARY_REQUIRED'], + ['too-short', '改了', 'FORK_CHANGE_SUMMARY_INVALID'], + ]) { + const rejected = await api( + `/api/game-distribution/games/${childId}/versions`, + { + method: 'POST', + token: forker.token, + headers: { 'Idempotency-Key': `lineage-child-summary-${tag}-${stamp}` }, + body: { + packageSha256: childZip.sha256, + packageBytes: childZip.bytes.length, + packageFileCount: childZip.fileCount, + packageEntryPath: 'index.html', + gameMetadata: childMetadata, + ...(summary === undefined ? {} : { changeSummary: summary }), + }, + }, + ); + check( + `衍生作品版本:核心改动说明${tag === 'missing' ? '缺失' : '超短'} → 400 ${expectedCode}`, + rejected.status === 400 && rejected.error?.code === expectedCode, + brief(rejected), + ); + } + await publishToPublic({ token: forker.token, admin, @@ -1084,7 +1185,21 @@ async function main() { stamp, tag: 'child', label: '子作品', + changeSummary: childChangeSummary, }); + const childPublicVersion = (await publicDetail(childId)).data?.currentVersion; + check( + '衍生作品当前版本公开下发 changeSummary', + childPublicVersion?.changeSummary === childChangeSummary, + `changeSummary=${String(childPublicVersion?.changeSummary ?? '∅').slice(0, 40)}`, + ); + const parentDetailVersion = (await publicDetail(parentId)).data + ?.currentVersion; + check( + '母版当前版本不发 changeSummary(null,不是省略键)', + parentDetailVersion?.changeSummary === null, + `changeSummary=${JSON.stringify(parentDetailVersion?.changeSummary ?? '∅')}`, + ); // C 段:真实树形状(锚点 = 子作品) const tree = await lineageOf(childId); @@ -1233,6 +1348,12 @@ async function main() { tag: `rich-${label}`, label: `H) 节点 ${label}`, quiet: true, + // 衍生节点(有父)必填核心改动说明;根节点不传(传了也会被忽略)。 + ...(parent + ? { + changeSummary: `H) 节点 ${label} 相对 ${parent.label} 的核心改动说明`, + } + : {}), }); const detail = await publicDetail(gameId); check( @@ -1240,7 +1361,38 @@ async function main() { published.versionId !== null && detail.data?.status === 'published', `versionId=${published.versionId ?? '∅'} status=${detail.data?.status ?? '∅'} cover=${coverAssetId}`, ); - if (promoteTo) { + // 新语义:**衍生作品在创建时继承父作品档位**(创建时快照),创建后该请求里的档位字段一律被忽略; + // 母版(根)才允许用 PUT 提升。这里按「根 → 提升 / 衍生 → 断言继承」分流。 + if (parent) { + const childView = await ownerGame(owner.token, gameId); + const parentView = await ownerGame(parent.owner.token, parent.gameId); + const childTier = childView.game?.forkAuthorization ?? null; + const parentTier = parentView.game?.forkAuthorization ?? null; + check( + `H) 衍生节点 ${label} 创建时继承父作品档位(child=${childTier} === parent=${parentTier})`, + childTier !== null && childTier === parentTier, + `child=${childTier} parent=${parentTier}`, + ); + if (promoteTo && promoteTo !== childTier) { + // 新语义(实测确认):衍生作品创建后**禁止**再用 PUT 提升——稳定错误码 FORK_AUTHORIZATION_INHERITED, + // 且档位必须保持不变(失败关闭)。这里按实测口径钉死。 + const probe = await promoteForkAuthorization({ + token: owner.token, + gameId, + key: `lineage-rich-probe-promote-${label}-${stamp}`, + to: promoteTo, + }); + const after = await ownerGame(owner.token, gameId); + const tierAfter = after.game?.forkAuthorization ?? null; + check( + `H) 衍生节点 ${label} 的 PUT 提升被拒(409 FORK_AUTHORIZATION_INHERITED)且档位不变`, + probe.status === 409 && + (probe.error?.code ?? '') === 'FORK_AUTHORIZATION_INHERITED' && + tierAfter === childTier, + `status=${probe.status} code=${probe.error?.code ?? '∅'} tier=${childTier}→${tierAfter}`, + ); + } + } else if (promoteTo) { const promoted = await promoteForkAuthorization({ token: owner.token, gameId, @@ -1248,7 +1400,7 @@ async function main() { to: promoteTo, }); check( - `H) 节点 ${label} 共创授权提升为 ${promoteTo}(供后续继续 Fork)`, + `H) 母版节点 ${label} 共创授权提升为 ${promoteTo}(母版仍可提升)`, promoted.status === 200 && (promoted.data?.game?.forkAuthorization ?? null) === promoteTo, brief(promoted), @@ -1423,6 +1575,173 @@ async function main() { `[lineage-e2e] 富树 lineage URL=${WEB}/games/lineage?id=${richRootId}`, ); } + // ---------- I 段:贡献归集 /api/game-distribution/games/{game_id}/contribution(作者视角) ---------- + // 契约(读实现 + DTO 注释):Bearer;data 直接是 + // { gameId, own{playCount}, inherited{playCount}, total{playCount}, + // byGeneration[{generation,gameCount,own,inherited,total}], + // directChildren[{gameId,generation,own,inherited,total}], nodeCount, truncated, truncatedReason } + // 不变式:total = own + inherited;inherited = Σ byGeneration.total = Σ directChildren.total; + // nodeCount = Σ byGeneration.gameCount(**不含根自身**);非作者按「不存在」处理。 + if (RICH_TREE) { + const totals = (node) => node?.playCount ?? null; + // 先记几笔游玩,让不变式里的数字不是全 0(计数端点是公开路由;失败只记 NOTE,不作为本票的断言) + const playResults = []; + for (const [label, node, times] of [ + ['R', richNodes.R, 1], + ['E', richNodes.E, 2], + ['F1', richNodes.F1, 1], + ]) { + for (let index = 0; index < times; index += 1) { + const response = await api( + `/api/game-distribution/games/${node.gameId}/plays`, + { + method: 'POST', + token: author.token, + body: { clientId: `lineage-e2e-${label}-${index}-${stamp}` }, + }, + ); + playResults.push(`${label}:${response.status}`); + } + } + note(`游玩计数探针(让贡献不变式非零):${playResults.join(' ')}`); + + const contributionResponse = await api( + `/api/game-distribution/games/${richNodes.R.gameId}/contribution`, + { token: author.token }, + ); + const contribution = + contributionResponse.data?.contribution ?? + contributionResponse.data ?? + null; + check( + 'I) 作者可读贡献归集(200)', + contributionResponse.status === 200 && contribution !== null, + brief(contributionResponse), + ); + const own = totals(contribution?.own); + const inherited = totals(contribution?.inherited); + const total = totals(contribution?.total); + check( + 'I) 不变式:total = own + inherited', + own !== null && inherited !== null && total === own + inherited, + `own=${own} inherited=${inherited} total=${total}`, + ); + const children = contribution?.directChildren ?? []; + const sumChildren = children.reduce( + (sum, child) => sum + (totals(child.total) ?? 0), + 0, + ); + check( + 'I) 不变式:inherited = Σ directChildren.total', + inherited === sumChildren && children.length === 3, + `inherited=${inherited} Σchildren=${sumChildren} children=${children + .map((child) => `${child.gameId.slice(-6)}:${totals(child.total)}`) + .join(',')}`, + ); + const generations = contribution?.byGeneration ?? []; + const sumGenerationTotals = generations.reduce( + (sum, entry) => sum + (totals(entry.total) ?? 0), + 0, + ); + const sumGenerationCounts = generations.reduce( + (sum, entry) => sum + (entry.gameCount ?? 0), + 0, + ); + check( + 'I) 不变式:inherited = Σ byGeneration.total', + inherited === sumGenerationTotals, + `inherited=${inherited} Σgen=${sumGenerationTotals} gen=${JSON.stringify( + generations.map((entry) => [ + entry.generation, + entry.gameCount, + totals(entry.total), + ]), + )}`, + ); + check( + 'I) 不变式:nodeCount = Σ byGeneration.gameCount = 5(不含根自身)', + contribution?.nodeCount === sumGenerationCounts && + contribution?.nodeCount === 5, + `nodeCount=${contribution?.nodeCount} ΣgameCount=${sumGenerationCounts}`, + ); + check( + 'I) 小树不截断(truncated=false / truncatedReason=null)', + contribution?.truncated === false && + (contribution?.truncatedReason ?? null) === null, + `truncated=${contribution?.truncated} reason=${contribution?.truncatedReason ?? 'null'}`, + ); + + // 三层链路的传递性:R.inherited = B1.total + C1.total + F1.total;B1.total = B1.own + D.total + const b1ContributionResponse = await api( + `/api/game-distribution/games/${richNodes.B1.gameId}/contribution`, + { token: author.token }, + ); + const b1Contribution = + b1ContributionResponse.data?.contribution ?? + b1ContributionResponse.data ?? + null; + const dOfB1 = (b1Contribution?.directChildren ?? []).find( + (child) => child.gameId === richNodes.D.gameId, + ); + const findChild = (id) => children.find((child) => child.gameId === id); + check( + 'I) 链路传递:B1.total = B1.own + D.total', + totals(b1Contribution?.total) === + (totals(b1Contribution?.own) ?? NaN) + (totals(dOfB1?.total) ?? NaN), + `B1.own=${totals(b1Contribution?.own)} D.total=${totals(dOfB1?.total)} B1.total=${totals(b1Contribution?.total)}`, + ); + check( + 'I) 链路传递:R.inherited = B1.total + C1.total + F1.total', + inherited === + ['B1', 'C1', 'F1'].reduce( + (sum, key) => + sum + (totals(findChild(richNodes[key].gameId)?.total) ?? NaN), + 0, + ), + `R.inherited=${inherited} Σ=${['B1', 'C1', 'F1'] + .map( + (key) => + `${key}:${totals(findChild(richNodes[key].gameId)?.total)}`, + ) + .join(',')}`, + ); + // E 属作者乙(forker)——贡献归集是**作者视角**接口,非作者会 403,所以用它的 owner token 查。 + const eContributionResponse = await api( + `/api/game-distribution/games/${richNodes.E.gameId}/contribution`, + { token: forker.token }, + ); + const eContribution = + eContributionResponse.data?.contribution ?? + eContributionResponse.data ?? + null; + check( + 'I) 最深一层 E:无子代(nodeCount=0、inherited=0、total=own)', + eContribution?.nodeCount === 0 && + totals(eContribution?.inherited) === 0 && + totals(eContribution?.total) === totals(eContribution?.own), + `nodeCount=${eContribution?.nodeCount} own=${totals(eContribution?.own)} inherited=${totals(eContribution?.inherited)} total=${totals(eContribution?.total)}`, + ); + + const foreignContribution = await api( + `/api/game-distribution/games/${richNodes.R.gameId}/contribution`, + { token: forker.token }, + ); + check( + 'I) 非作者读贡献归集被拒(403 FORBIDDEN「游戏 owner 不匹配」,不泄露计数)', + foreignContribution.status === 403 && + (foreignContribution.error?.code ?? '') === 'FORBIDDEN' && + !foreignContribution.text.includes('playCount'), + brief(foreignContribution), + ); + const anonymousContribution = await api( + `/api/game-distribution/games/${richNodes.R.gameId}/contribution`, + ); + check( + 'I) 匿名读贡献归集 → 401', + anonymousContribution.status === 401, + brief(anonymousContribution), + ); + } } // G 段:Fork 取件通道(M2a) diff --git a/scripts/check-game-distribution-theme-e2e.mjs b/scripts/check-game-distribution-theme-e2e.mjs index cc87bec17..ea1422906 100644 --- a/scripts/check-game-distribution-theme-e2e.mjs +++ b/scripts/check-game-distribution-theme-e2e.mjs @@ -249,6 +249,8 @@ async function buildReleaseZip(marker) { } /// 完整发布链路(照抄 lineage 脚本 :320-407):建版本 → 传发行包 → 送审 → 管理员通过。 +/// +/// `changeSummary` 只在**衍生作品**上传:它是衍生作品发布新版本的必填项(母版忽略)。 async function publishToPublic({ token, admin, @@ -259,6 +261,7 @@ async function publishToPublic({ tag, label, quiet = false, + changeSummary, }) { const zip = await buildReleaseZip(`theme-${tag}-${stamp}`); const created = await api(`/api/game-distribution/games/${gameId}/versions`, { @@ -271,6 +274,7 @@ async function publishToPublic({ packageFileCount: zip.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, + ...(changeSummary ? { changeSummary } : {}), }, }); const versionId = created.data?.versionId; @@ -561,6 +565,8 @@ async function main() { tag: 'child', label: '衍生作品 C', quiet: true, + // 衍生作品必填「本次核心改动说明」,母版忽略该字段。 + changeSummary: 'E2E:主题用例里的衍生作品核心改动说明', }); check( '衍生作品 C 已公开可读',