WIP: 游戏共创(作品 Fork):共创授权与改编血缘、创作族谱、取件与改编闭环 #622
Draft
suzmii
wants to merge 110 commits from
feat/game-fork into master
pull from: feat/game-fork
merge into: GenarrativeAI:master
GenarrativeAI:master
GenarrativeAI:chore/rust-compile-warnings
GenarrativeAI:feat/adapt-xhs-skill
GenarrativeAI:fix/525-art-package-contract
GenarrativeAI:chore/agc-warnings-misc
GenarrativeAI:chore/agc-warnings-session
GenarrativeAI:chore/agc-warnings-agent
GenarrativeAI:chore/agc-compile-warnings
GenarrativeAI:codex/agc-error-convergence-20261004
GenarrativeAI:feat/tribo3d-integeration
GenarrativeAI:feat/gptimage2to2.5
GenarrativeAI:feat/tn-ads-sdk
GenarrativeAI:feat/agc-ui-motion-ux
GenarrativeAI:feat/thread-manager-fork
GenarrativeAI:codex/release-macos-fetch-heartbeat
GenarrativeAI:codex/agc-harness-cloud-config
GenarrativeAI:codex/payment-service-20261003
GenarrativeAI:codex/profile-invite-gate-fix
GenarrativeAI:codex/profile-invite-entry
GenarrativeAI:feat/ui-editor-model-override
GenarrativeAI:fix/agc-release-gate-channel
GenarrativeAI:fix/dev-loopback-token-trust
GenarrativeAI:fix/api-timeout
GenarrativeAI:codex/admin-gray-game-publish
GenarrativeAI:codex/remove-agc-codex-restrictions
GenarrativeAI:backup/agc-macos-dualarch-node-runtime
GenarrativeAI:feat/Crt_Ws
GenarrativeAI:codex/preview-llm-router-config
GenarrativeAI:fix/stroke-width-regression-from-285
GenarrativeAI:agent-organize
GenarrativeAI:feat/game-agent-canvas-resource-workbench-v2
GenarrativeAI:feat/agc-new-workflow
GenarrativeAI:fix/agc-release-version-sync
GenarrativeAI:codex/shared-components-followup
GenarrativeAI:codex/game-agent-runtime-interaction-design
GenarrativeAI:feat/spine-sequence-export-v2
GenarrativeAI:codex/game-agent-run
GenarrativeAI:codex/ui-spritesheet-durable-transaction-latest
GenarrativeAI:feat/art-agent-more-tools
GenarrativeAI:codex/editor-canvas-null-source-repair
GenarrativeAI:hotfix/editor-layout-2mib
GenarrativeAI:codex/showcase
No Reviewers
Labels
Clear labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Breaking change that won't be backward compatible
Something is not working
Documentation changes
Improve existing functionality
New functionality
This is security issue
Issue or pull request related to testing
Priority
Critical
1
The priority is critical
Priority
High
2
The priority is high
Priority
Low
4
The priority is low
Priority
Medium
3
The priority is medium
Reviewed
Confirmed
1
Issue has been confirmed
Reviewed
Duplicate
2
This issue or pull request already exists
Reviewed
Invalid
3
Invalid issue
Reviewed
Won't Fix
3
This issue won't be fixed
Status
Abandoned
3
Somebody has started to work on this but abandoned work
Status
Blocked
1
Something is blocking this issue or pull request
Status
Need More Info
2
Feedback is required to reproduce issue or to continue work
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: GenarrativeAI/Genarrative#622
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/game-fork"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
目标与不做什么
目标:让平台上的游戏可以被他人合法地继续改造,并让世代链路可展示、可追溯、原作者可署名。
本轮不做:收益分成与算力成本结算、相似度反洗稿校验、上链、关注 / 粉丝 / 收藏、跨作品类型 Fork。
交付范围(按里程碑)
M1 共创授权与改编血缘
forbidden/nonCommercial/full),只升不降并要求expectedForkAuthorization期望值 CAS(并发不符 409)。game_distribution_lineage血缘表:父作品、建立血缘时的来源公开版本、根作品、代际;一次性写入、不可变。FORK_*前缀。forkAuthorization/forkCount/lineage(含代际与溯源);后台游戏管理列展示授权与代际。FORK_SOURCE_NOT_AVAILABLE);② 「已被改编 N 次」只统计未删除且已公开的子作品;③ 父 / 根作品被删时溯源只展示「原作品已不可用」,不外泄被删作品的标题与作者名。三条判定提取为module-game-distribution的纯函数 + 单测,防止后续退化。M3 创作族谱与衍生列表
GET /games/{gameId}/lineage与GET /games/{gameId}/derived:公开、匿名可读、no-store。truncated;代际升序 + 同代创建时间升序稳定排序;父 / 根被删时只给不透明 ID 与代际,标题与作者名不外发。/games/lineage?id=(详情页入口 + 我的作品页「被改编 N」)。M2a 取件与改编闭环
GET /games/{gameId}/fork-source(元数据)+GET /games/{gameId}/fork-source/package(整包下发)。必须 Bearer、不叠加发布灰度(灰度只针对「发布」);sha256/bytes取自版本行,downloadPath是同源相对路径,不下发对象键;读包复用现役发行网关的内存 + 缓存通道(没有第二条 OSS 路径)。forkAuthorization(缺省forbidden保持兼容),非法值 400 + 平台信封、失败关闭。reference/<gameId>/参考产物 +.agent/fork-source.json来源记录;首页入口(粘贴作品 ID / 链接、四类错误分类、加载态);发布时首次自动带改编声明;发布面板上架时可选三态档位。creation_source=platform_game跨端同步(客户端枚举 + 服务端合同 + 文档取值表)。关键设计决策
game而非version:血缘是作品间关系,父作品之后发新版本不该改写子作品的父指针;随内容演进的是被复刻的内容本身(成品包 / 工程源包),它才在version上。package.json与源码):这是对本需求的口径修正,详见主规范§2.5/§3.5.4。验证证据(真实本地栈 + 真实 Chrome)
npm run check:game-distribution-lineage-e2enpm run check:game-distribution-fork-authorization-e2enpx vitest run apps/ai-game-creator-shell/testscargo test -- forkcargo check通过cargo test -p module-game-distributioncargo test -p api-server game_distributioncargo test -p module-runtimecargo test -p spacetime-clientcheck:doc-index、check:encoding、git diff --check、cargo fmt --check全绿(其中 rustfmt 是本轮修好的一条真门禁:7 个本分支文件不合规,而它在npm run lint里)截图落在本机临时目录(未入库),可用上面的脚本重新生成。
如何复跑
npm run dev(端口会漂移,从.app/dev-stack.json读实际地址);管理员凭据用E2E_ADMIN_USER/E2E_ADMIN_PASSWORD(本地.env.local的 admin 凭据);浏览器段需要 Chromium 路径E2E_CHROMIUM_EXECUTABLE(Playwright 不在devDependencies,脚本支持SKIP_BROWSER=1跳过浏览器段)。npm run check:game-distribution-fork-authorization-e2e、npm run check:game-distribution-lineage-e2e。已知限制 / 未验证
cargo test会超时(仓库既有规模问题,非本次引入),只跑了定向子集。dd605a030)单独 checkout 可能不绿(分支尖端全绿);如需 bisect 请注意这一条。待拍板(按优先级)
index.html、拒*.map)+ 新下载路径与缓存键 + 客户端打包器。且必须先定一个产品口径:「源码可见性」目前没有独立开关——一旦版本带工程包,任何登录用户都能整包取走源码,是否接受?还是需要「可被改编」与「可读源码」两个独立开关?genarrative://deep link 未注册,当前是「复制作品 ID → 客户端粘贴」。接受?还是要做平台取件码 / deep link(后者要动安装器、capabilities、单实例与升级链路)?文档索引
合并注意事项
server-rs/crates/spacetime-client/src/module_bindings/**为生成物:合并后需重跑npm run spacetime:generate,不要手工合并生成文件。247625d61dto018f9d58e9阶段进展:M1 已落地(rebase 完成 + 已推送)
本阶段完成
game追加fork_authorization(三态、只升不降)、新增game_distribution_lineage血缘表、set_game_distribution_fork_authorization_and_returnprocedure 与PUT /api/game-distribution/games/{gameId}/fork-authorization、公开 DTO 增量(forkAuthorization/forkCount/lineage)、详情页共创卡、我的作品页授权提升入口。6c9566058)并推送;无 merge commit,未改动 master。POST的地方(改为PUT,与实现、前端 client、M1 实施计划一致)。验收证据
cargo test -p module-game-distributioncargo test -p api-server game_distributioncheck-game-distribution-dto-parity)check:spacetime-schemacheck:encoding/git diff --checkscripts/check-game-distribution-fork-authorization-e2e.mjs(npm run check:game-distribution-fork-authorization-e2e)48 项断言 48 PASS,含真实 Chrome 走完「我的作品页提升共创授权」的三态交互截图在本机临时目录(未入库);需要时可重跑上面的脚本生成。
对 #565 的适配(本次 rebase 的重点)
软删除语义收敛三处,并把判定提取为
module-game-distribution的纯函数 + 单测,避免后续改动悄悄退化:409 FORK_SOURCE_NOT_AVAILABLE(can_serve_as_fork_source)。counts_as_public_derivative)。fork_lineage_visible_identity,父不可用时强制返回(None, None),不做任何回退)。口径调整(按只读事实修正,欢迎复核)
第二阶段「一键复刻完整工程」在现有体系下做不到:
export.rs:262-265的「已有可玩入口」分支同样调用ensure_publish_project_stack(实检export.rs:405-470,注释与反例测试见:407-408、:1239-1243);package.json与源码(export.rs:770-804);⇒ 拿成品包只能本地试玩 / 当参考,不能直接重新发布。方案已按此修正为「参考式改编(先行)+ 工程源包(唯一能直接发布的正解,排后续)」,取舍见技术方案 §3.5.4(A/B/C 三条路线与推荐、A 路线的脚手架互斥点)。
下一步
GET /games/{gameId}/fork-source+ 按版本下发 ZIP(含下载鉴权、体积与内存策略),排队中。待产品/负责人确认(括号内为当前实现取的口径)
需求文档里有、但当前方案范围外的三条(避免范围被默认为已全覆盖)
以上为阶段性进展,评审意见直接在本 PR 或对应 issue 里提。
阶段进展 2:M3 族谱 + M2a 取件通道 + 上架时选档位(均已端到端验证)
本轮完成(都在本分支 PR 内)
GET /games/{gameId}/lineage与/derived(公开、匿名可读、no-store);树的可见性 / 排序 / 上限 / 截断 / 降级都做成纯函数并有单测;新增族谱页/games/lineage?id=(详情页入口 + 我的作品页「被改编 N」)。GET /games/{gameId}/fork-source(Bearer,不叠加发布灰度)与{downloadPath}整包下发(application/zip+ 长度 + 附件名 +no-store),sha256/bytes取自行元数据、不下发对象键;读包复用现役发行网关的内存 + 缓存通道(没有第二条 OSS 路径)。forkAuthorization(缺省forbidden保持兼容),非法值 400 + 平台信封、失败关闭。端到端验证证据(真实本地栈 + 真实 Chrome)
scripts/check-game-distribution-lineage-e2e.mjs(npm run check:game-distribution-lineage-e2e):90 项断言 90 PASS,含浏览器段(族谱页截图两张:删除前后)。scripts/check-game-distribution-fork-authorization-e2e.mjs复跑:48 项 48 PASS(无回归)。sha256与字节数与版本行一致、下载字节 hash 与元数据一致、content-length等于实际字节、非 owner 已登录用户也能取件、未公开 409 / 授权禁止 403 / 未带 Bearer 401。module-game-distribution53 passed、api-server game_distribution54 passed;DTO parity 42 组;schema guard 92 tables;check:encoding、git diff --check、cargo fmt --check全绿。check:rustfmt就在npm run lint里,已单独提交修正(无语义变化,逐文件比对过)。本轮定下的口径(欢迎复核)
data.forkSource),与模块其它接口一致。已知取舍与未覆盖
truncated=false;孙代(>2 层)与同代多节点的二级排序同理未跑。Content-Disposition附件名未断言(已断言更关键的 type / length / hash / ZIP 头)。source: "project"(工程源包)当前恒为package。一条协作事实,需要评审知悉
本工作树被多个 session 并行使用、git index 是共享的,出现过两次「A 的
git add被 B 的 commit 带走」:4207d24f0带走了 3 个 fork-source 生成物,dd605a030带走了当时未提交的 shared-contracts 字段。分支尖端是完整且全绿的,但dd605a030这个中间提交单独 checkout 可能不绿(bisect 时请从尖端往后注意这一条)。后续已改为「add 完立刻 commit」并收敛为同一时刻只有一个 writer。下一步
AGC 客户端接线(进行中)→ 之后是 M2b 工程源包(唯一能让用户拿到可编辑工程的路径,需要新增工程包上行与下发优先级)。
交付态小结:M1 授权血缘 + M3 族谱 + M2a 取件与改编(含客户端/网页端入口)已完成并端到端验证;M2b 待决策
1. 本 PR 现在包含什么(按里程碑)
GET /games/{id}/lineage与/derived(公开、匿名可读、no-store);锚点必须公开可读否则 404(防止泄露未公开作品的存在与血缘位置);树只含已公开未删除节点、上限 200 带truncated、稳定排序、父 / 根被删时只给不透明 ID 与代际;网页端新增族谱页/games/lineage?id=与详情页入口、我的作品页「被改编 N」。fork-source元数据 + 整包下发,Bearer、不叠发布灰度、不下发对象键);上架时即可指定授权档位;AGC 客户端新增「从平台作品开始创作」(取件 → sha256 / 字节校验 → 解压 → 建合规 Phaser4 + Vite 工程 +reference/<gameId>/参考产物 +.agent/fork-source.json来源记录)、首页入口(粘贴作品 ID / 链接、四类错误分类、加载态);发布时首次自动带改编声明;AGC 发布面板上架时可选三态档位;网页端详情页新增「改造这个作品」入口(复制作品 ID / 链接 → 客户端粘贴;未登录走既有登录门禁;禁止共创不出现);埋点creation_source=platform_game跨端同步。2. 端到端与门禁证据(真实本地栈 + 真实 Chrome)
npm run check:game-distribution-lineage-e2e:99 项 99 PASS(族谱树、软删除降级不外泄标题、网页端改造入口及对照断言、截图 3 张)npm run check:game-distribution-fork-authorization-e2e:48 项 48 PASSnpx vitest run apps/ai-game-creator-shell/tests1957 passed / 17 skipped;定向cargo test -- fork11 passed;非 test 构建cargo check通过module-game-distribution53、api-server game_distribution54、module-runtime62、spacetime-client30check:spacetime-schema92 tables、check:doc-index、check:encoding、git diff --check、cargo fmt --check全绿(其中 rustfmt 是本轮修好的一条真门禁:7 个本分支文件不合规,而它在npm run lint里)3. 已知限制 / 未验证(诚实列出)
cargo test会超时(仓库既有规模问题,非本次引入),只跑了定向子集。dd605a030单独 checkout 可能不绿(尖端全绿;如需 bisect 请注意这一条)。4. 待拍板(按优先级)
index.html、拒*.map)+ 新下载路径与缓存键 + 客户端打包器。且必须先定一个产品口径:「源码可见性」目前没有独立开关——一旦版本带工程包,任何登录用户都能整包取走源码,是否接受?还是需要「可被改编」与「可读源码」两个独立开关?genarrative://deep link 未注册,当前是「复制作品 ID → 客户端粘贴」。接受?还是要做平台取件码 / deep link(后者要动安装器、capabilities、单实例与升级链路)?5. 下一步建议
先定第 1、2 条(决定 M2b 与文案口径);第 3 条可延后;第 4 条建议等实际使用数据。代码侧可以随时继续,但 M2b 开工前需要上面那个源码可见性口径。
WIP: 游戏共创 M1:作品级共创授权与改编血缘to 游戏共创(作品 Fork):共创授权与改编血缘、创作族谱、取件与改编闭环CI 收尾:8/8 全绿
a6a235521,事件pull_request,结论 success:8 个 job 全 success(AGC Rust lane 1/2、lane 2/2、AGC Rust crates、AGC web tests、Backend tests、Frontend tests、Native shell tests、Repository checks)。为了转绿,先后修掉三个本分支引入的门禁问题(都是纯机械 / 无语义变化)
317644aab:AGC tauri 侧 3 个文件不满足 rustfmt —— 当时的本地门禁只跑了server-rs的 fmt,漏了 AGC workspace,而check:rustfmt覆盖四个 workspace。83c2553eb:M3 新增的 SPA 路由/games/lineage未登记进三份 nginx 模板的 SPA allowlist(每份 3 条断言)。a6a235521:同一路由未登记进 Pingora 的MAIN_SPA_PATHS(漏一条就把该深链在网关下打成 404);以及 6 处simple-import-sort+ 2 处react-hooks/exhaustive-deps。hook 那两条不是压告警:把lineage?.nodes ?? []的 identity 稳定在lineage变化时,取值不变、只少做无谓重算。一条教训(值得记):
pitfalls.md早就写了「新增 SPA 路由要三处同批更新(前端路由源 / 三份 nginx 模板 / PingoraMAIN_SPA_PATHS)」和「修门禁要跑到底,不要只看第一个红」。本轮因为 CI 是串行暴露(rustfmt → nginx → pingora → eslint 逐层被掩盖),白跑了两次 13 分钟的 run。一句提醒:
Repository checks是本 PR 最容易漏的一环 —— 本地只跑check:rustfmt会漏掉 AGC workspace 与网关路由清单,建议本地按check:repository-ci的环节跑到底(本机不可执行的check:git-hooks(Windows EBUSY) 与check:generated-bindings(AGCexport_bindings本地构建) 在 CI 上是绿的)。口径决定:允许共创即含源码开放,不设独立开关
nonCommercial的技术约束力:一旦给出源码,就无法技术性阻止他人改成商用版本,只能靠平台规则与事后追责——这是平台规则层面的约束,不是技术保证。.env*、*.pem/*.key/*.p12/*.pfx、.npmrc/.netrc)、依赖与 VCS(node_modules、.git、.svn)、构建产物与 IDE 目录、以及绝对路径/../盘符/符号链接。source: "project"+ 缓存键区分)。- game_distribution_game 表尾追加 fork_authorization(默认 forbidden,只升不降,旧行自动取默认) - 新增 game_distribution_lineage 表,记录父作品、来源版本、根作品与代际,并按 parent/root/owner 建索引 - migration.rs 登记新表并注明血缘随迁移导出 - 新增 set_game_distribution_fork_authorization_and_return procedure 与幂等收据、期望值 CAS - 创建作品时校验改编声明:来源存在、已公开、授权开放、来源版本等于当前公开版本;复用既有身份时拒绝携带血缘 - 公开与后台响应增量:forkAuthorization、forkCount、lineage、generation、forkedFromGameId、derivedCount - 公开快照的作者名与头像改为读时联 user_account,不再落到「创作者」兜底文案 - api-server 新增 PUT /api/game-distribution/games/{game_id}/fork-authorization(Bearer、幂等键、期望值校验) - 错误映射新增 FORK_ 前缀分支并置于通用子串分支之前,避免被误判成 409 或 404 - module-game-distribution 增加授权阶梯、代际计算与错误码,并补单测覆盖阶梯与错误码前缀 - 网页端详情页新增共创信息卡,我的作品页新增授权提升入口,补服务层调用与定向测试 - 重新生成 SpacetimeDB 绑定,登记 DTO 一致性脚本的共创类型- domain.rs:新增三个不依赖 SpacetimeDB / ReducerContext 的纯函数 · `can_serve_as_fork_source(is_deleted, is_published)`:来源可用性 = 未软删除 且 已公开 · `counts_as_public_derivative(is_deleted, is_published)`:衍生计数纳入,同条件但动机不同(避免虚报热度、避免泄露「这里曾有个作品」),单独成函数以免两条规则将来互相牵连 · `fork_lineage_visible_identity(parent_available, parent_title, parent_author_name)`:父可用才透传;父不可用必须返回 (None, None),不做任何「拿不到标题就回退用行数据」的降级 - lib.rs:在既有 `pub use domain::{...}` 列表中导出上述三个函数(一行) - spacetime-module:三处调用点改为调用纯函数,行为完全不变——来源守卫合并为一个 `if`(仍统一映射 FORK_SOURCE_NOT_AVAILABLE)、衍生计数 filter 传入两个布尔、血缘快照的父身份与根标题共用同一函数;`visibility` 判定仍由 module 侧折叠成布尔 - domain.rs 测试:新增 `fork_soft_delete_tests` 5 条——来源可用性四组合(含「已删但 visibility 仍是 published」必须 false)、计数纳入四组合、父可用透传、父不可用即使传入非空标题/作者名也必须 (None, None)、作者名三态(None→None / 空串→空串 / 只含空白原样透传) - 技术方案 §5.1:把「权限与边界」行的说明改为已由纯函数单测覆盖的三条规则(真实栈用例仍标注待补);「软删除语义收敛 ③溯源摘要」行补上 `fork_lineage_visible_identity` 单测证据- §2.6 流程图 A:`后续提升授权(POST fork-authorization,仅升)` → `后续提升授权(PUT /games/{gameId}/fork-authorization,仅升)` - §3.4 作者路由表:`POST /games/{gameId}/fork-authorization`(新) → `PUT /games/{gameId}/fork-authorization`(新) - 依据:实现侧(api-server `set_fork_authorization` 的 PUT 路由 + 前端 `updateGameForkAuthorization` 的 PUT + M1 实施计划 `PUT /api/game-distribution/games/{game_id}/fork-authorization`)一致为 PUT;表中路径沿用本节开头声明的 `/api/game-distribution` 命名空间,故未重复写前缀 - 顺手扫了全仓 `docs/`:除本文件这两处外没有其它把该路由写成 POST 的地方(`POST /games` 是既有的创建作品路由,与本次无关,未动)- 新增 module-game-distribution/src/lineage.rs:锚点可读性、可见性过滤、稳定排序、上限截断的纯函数,附 15 个单测(复用 M1 的软删除判定,不重写第二套规则) - 新增公开只读 procedure get_game_distribution_lineage / list_game_distribution_derived_games;锚点必须公开可读(未公开/已软删除/不存在一律按不存在处理),树内只出现未软删除且已公开的节点,不补 null 占位节点 - api-server 新增 GET /games/{id}/lineage 与 /games/{id}/derived(匿名可读、no-store),响应走结构化 DTO,节点键集合由测试钉死,杜绝下发对象键/素材键 - 作者自有游戏聚合追加 forkCount(与公开详情 forkCount 同口径),供「被改编 N」入口使用 - shared-contracts 与 TS 契约新增 LineageNode / LineageResponse / DerivedResponse 三型并登记 DTO 一致性检查 - 前端新增 /games/lineage 族谱页,登记 stage 类型、路由表、页面标题与壳层渲染分支;详情页共创卡新增「查看创作族谱」入口(仅已公开作品) - 我的作品页新增「被改编 N」行内展开,列出直接子代的标题/作者/代际/状态,含空态与失败重试(仅已公开作品) - 补前端组件/路由/标题/客户端用例与 api-server 路由、负载映射用例;同步里程碑验收标准与技术方案 §3.4 的锚点可读性口径- 新增 scripts/check-game-distribution-lineage-e2e.mjs:真实 HTTP + 浏览器验收 M3 与 M2a · 404 规则:未公开草稿锚点与不存在的 gameId,/lineage 与 /derived 都断言 404 且不是成功信封空树,并断言 no-store · 真实树:完整发布链路(建版本→传包→送审→管理员通过)把母版与带改编声明的子作品推公开, 断言 rootGameId/root/节点集合/generation/parentGameId/status/作者名读取自账号展示名/代际升序/ 节点键集合恰好 7 个/响应不含对象键与素材键 · 衍生列表:锚点=母版时恰好含直接子作品且未截断 · 软删除降级:作者软删除母版后 nodes 不再含母版、root=null 但 rootGameId 仍回传、 子节点保留 generation/parentGameId、响应文本不含母版标题与作者名、已删锚点两个接口都 404 · Fork 取件通道(M2a):未公开作品 409、公开但授权 forbidden 403、元数据 sha256/bytes/versionId 与公开版本行一致、downloadPath 为同源相对路径、按该路径下载后校验 content-type/长度/sha256/ZIP 头、 未带 Bearer 401、另一名已登录作者可取件 200 · 浏览器:详情页「查看创作族谱」入口 → /games/lineage 树渲染(母版/当前作品/作者名)→ 页面内触发父作品软删除 → 降级为「原作品已不可用」且父标题不再出现,删除前后各留截图 · 节点上限 200 未用真实 201 节点验证:改为源码常量钉住并在脚本里 NOTE 说明降级理由 - package.json:新增 npm script check:game-distribution-lineage-e2e,命名与既有 check:game-distribution-*-e2e 一致 - 实测(本地 dev 栈 + Chrome + 临时 playwright):本脚本 90 项 90 PASS / 0 FAIL; 同时复跑 check:game-distribution-fork-authorization-e2e 48/48 未回归- 新增 `project/fork_source.rs`:项目改编来源记录落在 `.agent/fork-source.json` (`{schemaVersion,gameId,versionId,adoptedAtMillis}`,版本 `agc-fork-source.v1`)。 位置理由:`.agent/**` 随项目快照上云、随用户整目录拷贝,且导出包不带 `.agent`, 既随行又不干扰游戏产物;不改 `manifest.json`(它是 deny_unknown_fields + 只接受 v1)。 写入走统一入口 `write_game_creator_private_file`;读取容忍缺失/损坏/异形/非普通文件, 一律返回 None 而不报错、不 panic。 - 新增 `game_fork.rs` + `game_fork/desktop.rs` 与命令 `create_local_project_from_platform_game`(在 `desktop.rs` 的 generate_handler 注册): 取平台会话 → 取件元数据 → 带 Bearer 下载整包 → 校验字节数与 SHA-256(失败关闭、不落盘) → `init_local_game_project_at` 生成合规 Phaser4+Vite 脚手架 → 参考副本解压到 `<project>/reference/<gameId>/`(复用 `safe_archive_relative_path` 与 `extract_template_archive` 的条目数/单文件/符号链接门禁,按最小改动把后者提到 pub(crate)) → 写来源记录;任一步失败都删掉半成品项目目录。 参考副本必须落在子目录:`create_npm_scaffold` 的判据是「没有 manifest 且根/`game` 都没有 index.html、package.json」,成品包若落在项目根或 `game/` 根,脚手架一个文件都不会生成。 - `game_distribution_publish.rs` 新增受鉴权取件实现:`fetch_platform_game_fork_source` (元数据 GET + 带 Bearer 的整包下载 + 404/409/403/401 四态可区分映射 + 下载路径同源校验)。 不复用模板库的 `fetch_limited_bytes`:它写死 `client.get(url)`,带不了鉴权头, 复用它会让受保护内容变成匿名下载。 - 埋点:新增 `CreationSource::PlatformGame`(`analytics/contract.rs` 的 values! 登记)并在建项 成功后上报,来源标识沿用模板链路的 ID 槽位。 - 修既有编译错误:`game_distribution_publish.rs` 的资料摘要测试缺 `fork` 字段 (上一提交给 `GameDistributionCreateGameRequest` 加了该字段但没同步该测试字面量), 补 `fork: None` 以恢复测试目标可编译。- 改编声明:`publish_local_project_game` 在**首次发布**(不带 gameId)时读取项目内 `.agent/fork-source.json`,把 `fork: { parentGameId, parentVersionId }` 并入创建作品请求; 带 gameId 的更新发布不重复带声明(服务端对「复用既有作品身份时再声明来源」失败关闭, 重复带会变成不可恢复的 409)。判据与注入点都在原生链路,渲染层既传不进也伪造不了; 声明在 `metadata_digest` 之前并入,幂等根键随之变化属预期行为。 - 授权档位:`GameDistributionPublishMetadata` / `normalizeMetadata` / `toCreateGameRequest` 接通 `forkAuthorization`(缺省 `forbidden`),发布面板新增「授权共创」三态单选, 文案复用 `packages/shared/src/contracts/gameDistribution.ts` 的标签常量;更新发布时该栏只读 并提示「共创授权只在首次发布时生效」——档位只在创建作品时生效,上架后只能单向提升。 附带按 `styles.css` 既有面板变量补齐这组 radio 的样式(不能套用整行输入框那条规则)。 - 门禁登记:新命令本轮只落 native 能力,按 `scripts/check-config.mjs` 的 native-only 清单登记。 - 测试:Rust 钉住「资料摘要同内容稳定、并入声明即变」;TS 钉住请求体形状 (带/不带档位、声明不由渲染层提交)与面板三态受控行为、更新模式只读。- 依据客户端提交 `5d01e43b6`:AGC 的 `CreationSource` 新增 `PlatformGame`,按 serde snake_case 落到线上取值 **`platform_game`**(同提交的 `creation_source_gains_platform_game_without_breaking_existing_values` 用例显式给出该字面量),上报字段仍是 `project_create_success` 的属性 `creation_source`。服务端枚举此前没有它,整条事件会被判 `invalid_properties` 直接丢弃 - `shared-contracts/src/agc_analytics.rs`:`values!(CreationSource { .. })` 追加 `PlatformGame`(注释与客户端一致);解析走 serde 派生,无需新增匹配分支 - `module-runtime/src/agc_analytics.rs`:新增 `agc_accepts_every_creation_source_and_rejects_unknown`——五个受支持取值(home_game / home_design / template / selected_directory / **platform_game**)逐个通过整批校验(事件形如 `project_create_success` 且带 `project_id`),未知取值(`remix` / `platform` / `PLATFORM_GAME` / 空串)仍然失败关闭为 `invalid_properties` - 文档:`docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md` §6.6 的 `creation_source` 取值列表补 `platform_game`(保持 `/` 分隔与 snake_case 风格) - 全仓核对:该枚举只有两处定义(AGC `analytics/contract.rs` 与 `shared-contracts`),**无第二处服务端镜像**、无 TS 联合类型、无 schema/OpenAPI 取值表;生成绑定不含该类型(埋点批量跨模块边界走 `payload_json: String`),因此无需重跑 spacetime generate- scripts/check-game-distribution-lineage-e2e.mjs 新增 5 条浏览器断言,插入位置在族谱树断言之后、 软删除母版之前(母版一删该入口即消失,顺序不能反): · 已公开且授权开放的母版详情页出现「改造这个作品」入口 · 展开面板:作品 ID 等于母版;可复制作品链接指向 /games/detail?id=<母版>;面板给出 「打开陶泥儿客户端 → 首页『从平台作品开始创作』→ 粘贴」引导,并给出成品包只能作为 可玩参考与素材、不能直接再次发布的口径 · 点「复制作品 ID」后只断言可观察 UI 反馈(按钮变为「作品 ID 已复制」):真实剪贴板内容 需要额外权限,不在自动化范围,属人工验收项 · 对照断言:已公开但授权为 forbidden 的作品详情页不出现该入口(也不出现「登录后可改造」) · 展开态截图存临时目录并打印路径 - 入口文案常量做成可覆盖(E2E_REMIX_*),取值对齐 GameDetailPage.tsx:430-490(Commit 392cb3bb0) - 删除后回看族谱改为导航到先前记录的 lineage URL(原实现用 page.reload,新增入口步骤会先切走页面) - 实测:本脚本 99 项 99 PASS / 0 FAIL / 0 SKIP;复跑 check:game-distribution-fork-authorization-e2e 48/48 未回归- 技术方案 §3.4:删掉「GET /games/{gameId} 追加 forkSourceAvailable」——实现里不存在该字段;改为如实口径:改造入口显隐只依据 forkAuthorization,真实可复刻形态由 /fork-source 的 source 回答,若 M2b 要区分「可源码级改造 / 只能参考」届时再加 - 实施计划(M1)§5:同步去掉 forkSourceAvailable 并注明「未实现、也不需要」,保留其余字段清单不动 - 技术方案 §2.6 时序图:说明字句从「GET …/fork-source 是待实现接口」改为「M2a 已实现(形状见 §3.4 登录用户表)」,并在图内加一行 Note 标注「图中字段名是 M2b 目标形状」;图属目标形态的免责声明保留 - parity 脚本:game_payload 增加 mustEmit ['forkAuthorization']——该字段 TS 侧可选(旧响应可省),但公开与作者响应必须发出,否则详情页授权徽章与改造入口会静默退化成「禁止共创」;public_game_payload 以 game_payload 为 base,因此一并受保护 - e2e 脚本头部:把全部行号引用(含已过期 2–4 行的那些)改为「文件 + 符号」引用(router()/lineage_read_or_not_found()/GameDistributionLineageNode/lineage.rs 常量与函数等),并删除「消费链路在未提交的 M2a WIP 里」这类会过期的措辞,改为「已在 M2a 落地」;只改注释,断言逻辑未动CI run 3386 的 `Repository checks` 失败于 `cargo fmt --all -- --check`(AGC tauri workspace),本分支引入;本次只跑 `cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,漂移文件与 CI 报告完全一致,共 3 个: - game_distribution_publish.rs:404 分支的 `StatusCode::NOT_FOUND =>` 改成块、`game_id` 字符集判据的 `.chars().all(...)` 换行、`if let Some(...) = crate::project::read_project_fork_source(...)` 换行、下载路径测试里的 `fork_source_download_segments(...)` 调用换行。 - game_fork.rs:测试里两处 `writer.start_file(...).expect(...)` 与一处 `assert!(...)` 折行、`filter(|entry| ...)` 闭包折行。 - game_fork/desktop.rs:`use crate::game_distribution_publish::{...}` 收成单行。 逐文件核对过 `git diff`:只有折行 / 缩进 / 大括号位置 / 尾随逗号类变化,标识符、字符串 字面量、注释文本、逻辑一律未动。未改 server-rs、src、scripts,也未动 PR 状态。- `server-rs/crates/spacetime-module/src/game_distribution.rs`:`game_distribution_version` 表尾追加三列(明确默认值,遵循既有追加规则)——`project_bundle_object_key: Option<String>`(`#[default(None)]`)、`project_bundle_bytes: u64`(`#[default(0)]` = 未上传)、`project_bundle_sha256: Option<String>`(`#[default(None)]`);三列同步进 `GameDistributionVersionSnapshot`、版本快照构造与建版本时的初始化 - 同文件:`GameDistributionForkSourceSnapshot` 与扁平结果类型追加 `project_bundle_sha256` / `project_bundle_bytes`,下行据此判断走 `project` 还是回落 `package`(字节数与摘要成对,缺一视为没有工程包) - 同文件:新增 `GAME_DISTRIBUTION_ACTION_UPLOAD_PROJECT_BUNDLE` 动作常量、输入 `GameDistributionConfirmProjectBundleInput`、事务 `confirm_game_distribution_project_bundle_tx` 与 procedure `confirm_game_distribution_project_bundle_and_return`。语义:可选资产、**不驱动版本状态机**;owner 校验 → 阶段门(`awaiting_upload` / `upload_failed`,与发行包确认同口径,已公开/已取消/已撤回一律拒绝)→ 同内容幂等重放 / 换内容冲突 → 写三列 + 幂等收据 - `spacetime-client`:`GameDistributionVersionRecord` 与 `map_version` 同步三列(生成绑定把 `project_bundle_sha256` 折成 `project_bundle_sha_256`,与 `package_sha_256` 同约定);新增 `GameDistributionConfirmProjectBundleRecordInput` 与 `confirm_game_distribution_project_bundle`(沿用 `map_game_distribution_game_result` 再取 version 的既有写法) - 契约:`shared-contracts` 的 `GameDistributionPrivateVersion` 追加 `project_bundle_bytes`(必需,`0` = 未上传)与 `project_bundle_sha256`(可选);`packages/shared` TS 镜像同步;api-server `private_version_payload` 追加同名键——**对象键不下发**,只回摘要与字节数 - 生成物:重跑 `npm run spacetime:generate -- --rust-only`,只保留 `module_bindings/game_distribution*`(含 2 个新文件)与 `module_bindings.rs`,其余 `module_bindings/**` 的 rustfmt 漂移已 `git checkout --` 还原 - 文档:数据契约表目录 `game_distribution_version` 小节补三列语义与对象键不出服务端的口径 - 顺手记下一条门禁陷阱:`scripts/check-game-distribution-dto-parity.mjs` 抓 `json!` 顶层键时要求「键前一个非空字符是 `{` 或 `,`」且**不剥注释**,在字面量里插注释行会让紧随其后的键被判成缺失(已写进 `private_version_payload` 的文档注释) - 门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p module-game-distribution` 69 passed;`cargo test -p api-server game_distribution` 54 passed;DTO parity 42 组 OK;`check:spacetime-schema` 92 tables OK;`check:encoding` 5301 files OK;`git diff --check` 0;`cargo fmt --all -- --check` 0- 新增 `server-rs/crates/module-game-distribution/src/project_bundle.rs`:`validate_project_bundle_zip(bytes) -> Result<ProjectBundleManifest, ProjectBundleError>` · 复用 `crate::package::normalize_archive_path`(不复制路径安全逻辑),路径不安全统一归 `InvalidPath` · 拒绝清单:空包 / 非 zip / 条目数 > 10 000 / 符号链接 / 加密条目 / 嵌套 `.zip` / 完全重复与大小写折叠重复路径 / `node_modules`(任意层级)/ `.git`、`.svn`(任意层级)/ `.agent`(任意层级:避免把上一个作品的改编来源记录带给下一个人)/ 根级 `dist`、`build`、`library`、`temp`、`local`(子目录同名允许,与模板包规则一致)/ 根级 `.idea`、`.vscode` / 凭据与隐私:`.env`、`.env*`、`*.pem`、`*.key`、`*.p12`、`*.pfx`、`.npmrc`、`.netrc`、`.git-credentials`、`id_rsa*`、`id_ed25519*`、`*.map` / 单文件 > 64 MiB / 累计 > 500 MiB / 单文件 > 包体 × 100 · 规模上限与发行包**逐项相等**(200 MiB / 500 MiB / 64 MiB / 10 000 / 100):两者共用同一条上传链路,反代与 Pingora 的放行量就是按 200 MiB 校准的,放宽就得同步放开多处部署配置;单测用 `assert_eq!` 钉住这条等价关系 · 不要求根 `index.html`(源码包没有入口约定),非空即可 - `lib.rs` 只追加 `mod project_bundle;` 与 `pub use project_bundle::{...}`(+6 行);`package.rs` **零改动**,发行包行为与测试不受影响 - 测试 16 条:合法源码包通过(故意不含 `index.html`)、上述每条拒绝规则各一条、「子目录里的 `dist/` 允许」反例、常量与发行包逐项相等、manifest 的 sha256/字节数与输入一致 - 门禁:`cargo test -p module-game-distribution` 69 passed(含本校验器 16 条);wasm build 0;`cargo fmt --all -- --check` 0- 技术方案 §3.2.3:三列语义改为与实现一致;删掉原「保留一次补齐机会」的规则(**已公开版本的补传未实现**,本期不做),改为「只在 `awaiting_upload` / `upload_failed` 可写一次、写入后换内容 409、不做版本回溯」;补产品口径(不做独立「源码可见性」开关、作者不传即产物级改编、`nonCommercial` 约束力属平台规则层面);对象键改为实现值 `…/{version_id}.project.zip` - 技术方案 §2.3:把「公开后可补传一次工程源包」的表述改为如实口径(公开后不接受补传,只能走试玩 + 素材) - 技术方案 §3.3:version 行的 `project_bundle_*` 迁移规则改为「只在未公开时可写一次」 - 技术方案 §3.4:`PUT /versions/{versionId}/project-bundle` 从「规划」改为**已实现**(整包 + 4 条分片族路由、作者限定、阶段门、无既有工程包、服务端派生对象键);「登录用户」小节把取件接口从两个改为三个并说明 `source` 可能是 `project`(优先)或 `package`,新增 `/fork-source/project` 本体行(失败关闭、缓存含资产维度) - 技术方案 §3.5.2:打包/服务端复核/上传/下载建项/优先级五条按实现重写——写明服务端 `validate_project_bundle_zip` 的完整拒绝清单与与发行包逐项相等的上限(200 MiB / 500 MiB / 64 MiB / 10 000 / 100,理由:共用同一上传链路且反代按 200 MiB 校准),以及「打包器已落地、上传与建项接线待做」 - 技术方案 §3.5.4:记录产品已采纳推荐口径(A 默认继续服务未上传工程包的作品、B 服务端已实现、C 不做) - 里程碑「作品工程源包与一键改造」:Status → in-progress(服务端已落地 / 客户端接线待做)、Version 1.2;范围去掉「是否公开工程」选项(不存在独立开关);不在范围内新增「已公开版本补传」;两条前置依赖标注已落地并写清 512 MiB 与 200 MiB 的对齐口径(客户端须按服务端上限预检);验收标准改为带 ✅/⏳ 标注、删掉两条 backfill 验收、补阶段门与半写行回落等已实现项 - 门禁:`check:doc-index` 248 份 OK;`check:encoding` 5301 files OK;`git diff --check` 0- 上行(作者、Bearer + 发布灰度,与发行包族逐条对齐):`PUT /versions/{id}/project-bundle`(整包一次上传,≤ 200 MiB)、`GET …/project-bundle/upload-state`、`PUT …/project-bundle/chunk`(`x-genarrative-upload-offset`,分片上限同发行包)、`POST …/project-bundle/complete`、`POST …/project-bundle/reset`;载体一律 `application/octet-stream`,分片边界与偏移语义只有一套 - 对象键:新增 `game_distribution_project_bundle_object_key(game_id, version_id)` = `…/{version_id}.project.zip`;发行包 helper **未改**(仍是 `.zip`)→ 同一(作品, 版本)的两份资产互不覆盖、也不会串用读取缓存 - 阶段门:新增**唯一**共享函数 `ensure_project_bundle_uploadable`,5 条上行路由全部调用、无重复实现。判定顺序:① 已确认(`project_bundle_bytes > 0`)→ 409 `PROJECT_BUNDLE_ALREADY_EXISTS`(必须先判:确认工程包不驱动版本状态机,已确认的版本可能仍停在 `awaiting_upload`);② `status` ∉ {`awaiting_upload`, `upload_failed`} → 409 `PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED`(与模块事务同口径) - `complete`:回读整包 → 与 HEAD 权威长度比对 → `validate_project_bundle_zip`(失败删半包对象 + 记上传失败 + 422 `PROJECT_BUNDLE_VALIDATION_FAILED`)→ `confirm_game_distribution_project_bundle`;幂等键与摘要口径照抄发行包 complete - 下行优先:`fork_source_target` 选定资产 = 有工程包时 `Project`(优先)否则 `Package`;「字节数 > 0 **且**摘要非空」才算有工程包,半写行回落 `package`(失败关闭)。元数据 `source` / `sha256` / `bytes` / `downloadPath` 随所选资产;新增 `GET /games/{id}/fork-source/project`(同两层中间件、无 Query 提取器;没有工程包时 409 `FORK_SOURCE_NOT_AVAILABLE`,**绝不静默回落成品包**),响应头与成品包同形(文件名 `{gameId}-{versionId}-project.zip`) - 缓存键加资产维度:`release_package_bytes` 泛化为 `release_asset_bytes(state, object_key, max_bytes)`(缓存键即对象键),发行包侧退化为薄封装——键字符串、上限、`Bytes` 值类型与 4 条 / 256 MiB 预算逐字节不变,既有缓存测试继续通过 - 顺着同一「不复制第二套」原则抽出/参数化的共享件:`fork_source_bundle_response`(两份资产共用响应构造)、`package_upload_offset(headers, asset)`、`require_octet_stream_content_type(headers, asset)`(按资产给错误文案,发行包文案不变) - 补上块 A 遗漏的两处客户端登记(否则下行字段读不到):`spacetime_client` 对 `GameDistributionConfirmProjectBundleRecordInput` 的 re-export,以及 `GameDistributionForkSourceRecord` 新增 `project_bundle_sha256` / `project_bundle_bytes` 与对应映射 - 测试 6 条:5 条上行 + 1 条下行共 6 条路由未带 Bearer → 401(逐条);`ensure_project_bundle_uploadable` 全状态(含「已确认优先于阶段」)+ 7 档拒绝;`fork_source_target` 6 种资产组合(工程包优先 / 回落成品包 / 两种半写行 / 只有工程包 / 两份都缺 → 409);两个对象键互不相等 + `ReleasePackageCache` 双资产不串味;上限镜像(`MAX_PROJECT_BUNDLE_BYTES == MAX_PACKAGE_BYTES == shared_contracts::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES`、分片放行量 > 分片大小);校验失败 422 映射 - 门禁:`cargo check --all-targets` 0;`cargo test -p api-server game_distribution` **60 passed**(基线 54;既有发行包族用例全绿 → 抽取共享件未改发行包行为);DTO parity 42 组 OK;`check:encoding` 5301 files OK;`git diff --check` 0;`cargo fmt --all -- --check` 0规则对齐(以服务端为权威,`module-game-distribution/src/project_bundle.rs`): - 凭据/隐私两侧补齐:客户端新增 `.git-credentials`、`id_rsa*`、`id_ed25519*`、`*.map`, 并把 `.env` 放宽成服务端同形的 `starts_with(".env")`(`.envrc` 之类一并排掉)。 反向无需补:客户端没有服务端缺的凭据类规则,比对一次后两侧凭据集合完全一致。 - 新增被漏掉的嵌套压缩包规则:服务端对任意层级 `*.zip` 一律拒收(解包阶段不递归校验, 等于绕过整份清单),客户端原来会把它打进包里 → 现在同样排除。 - `.agent` 由「项目根首层」对齐到**任意层级**(服务端口径;平台保留名两边一致比更宽松重要)。 - 客户端额外项保留并逐项写明理由:`game/dist`、`game/build`(AGC 脚手架构建输出)、 `.godot`(编辑器缓存,模板包指南同规则)、`exports`/`memory`(AGC 生成物与 Agent 记忆)。 - 规模上限统一到服务端数值:压缩包 ≤ 200 MiB(新增)、展开 ≤ 500 MiB、单文件 ≤ 64 MiB、 条目 ≤ 10 000、单文件 ≤ 包体 × 100(新增)。客户端先拦,不再出现「传到一半被 422 拒掉」。 - 顺手补上服务端已有的「大小写折叠后重名条目」防线:客户端提前失败关闭。 可机器比对的常量:规则集拆成 `BUNDLE_EXCLUDED_*` 常量、上限拆成 `PROJECT_BUNDLE_MAX_*`, 新增 `scripts/check-project-bundle-policy-parity.mjs` 逐 token/逐数值比对两侧(服务端每条规则 必须在客户端存在,客户端额外项只打印不算失败;抽不到 token 直接报错,避免假绿),并照既有 DTO parity 的形态接到 `package.json` 的 `check:*` 与 `lint` 链。 测试:客户端 17 条(新增 `.git-credentials`/`id_rsa`/`*.map`/`.envrc`/`.zip`/任意层级 `.agent`、 压缩包上限、压缩比上限各自一条;合同常量锚点改为与服务端逐项相等)。- 来源记录 v2:新增 `source`(`package` / `project`),当前写入 `agc-fork-source.v2`; 解析同时接受 v1——v1 没有该字段,按「成品包」解释(当时只有这一条取件通道), 因此老项目仍能正确读出「改编自哪个作品」,发布时照常带上声明。未知版本与非法取值仍按 「没有来源记录」失败关闭。 - 建项按取件形态分支(`create_project_from_platform_fork_at` 增加 `source` 入参,远程 `source` 取值在命令层映射): · `project`(工程源包):先把包解到**项目根**再走标准初始化。源包自带 `package.json` / `vite.config.*`,初始化会跳过 npm 脚手架判据,只补 `.agent` 身份、 目录与 agent.db,得到一份能继续改、能重新构建的工程;不再生成 `reference/` 参考副本。 · `package`(成品包):维持原形态(合规脚手架 + `reference/<gameId>/` 可玩参考)。 - 解压仍走既有门禁(`extract_template_archive`:条目数/单文件上限、拒符号链接、条目路径 只允许项目内相对路径;ZIP 根 == 项目根与模板包同一合同)。 - 命令文档同步说明两种形态的差别(工程源包=解压即工程;成品包=可玩参考,不能直接再发布)。 测试:新增「工程源包建项得到可编辑工程」用例(源码逐字节不被脚手架覆盖、`.agent` 身份已补、 无 `reference/`、记录 source=project)与「v1 记录仍可读且按成品包解释」用例; 既有成品包建项用例补断言 source=package。- `scripts/check-project-bundle-policy-parity.mjs` 新增第 3 维:服务端 `normalize_archive_path` 的拒绝形状必须被客户端打包器的三个常量逐 token 覆盖——**哨兵段**(`.` / `..`)与**路径段结尾字符**(空格 / 点)分别用 `rustStringArray` / `rustCharArray` 抽取,**路径段禁止字符**同时抓 `.contains('x')` 与 `matches!(expr, 'a' | 'b')` 两种写法;再单独钉「两侧都必须显式拒 `/` 开头与 `\`」两条独立分支(它们靠常量表覆盖不到)。**空集合一律 throw**,沿用既有「抽不到不许当绿灯」的写法 - 修掉我自己在第一版里写错的两处(由补强过程中的实测发现,并已用临时副本验证修法):① `BUNDLE_FORBIDDEN_PATH_SEGMENTS` 是 `&[&str]` 而非 `&[char]`,须用 `rustStringArray`(`".."` 也无法用 char 字面量表达);② 反斜杠分支的正则写成了 4 个字面反斜杠,而源码是 `contains('\\')`(2 个),已改为 `/contains\('\\\\'\)/u` - **「故意破坏会红」已验证**:临时从客户端 `BUNDLE_FORBIDDEN_PATH_CHARS` 去掉 `'?'` 与 `'*'` → 门禁立即报两条「路径段禁止字符:服务端有「?」/「*」,客户端没有」,随后已原样恢复 - 文档 `§3.5.2/§3.2.3`:校验器清单同步到新规则(新增目录/前缀/后缀/全名 + 内容嗅探及其取舍理由、嵌套包扩展名并集与 magic 嗅探、读取层封顶与压缩比口径更正、路径形状维度),并新增**产品口径「知情同意」**一条——清单是黑名单,`docs/`、`*.pdf`、`notes.txt`、截图等不在任何拒绝集内会原样外发,发布面板必须写明「整个项目目录(除少数排除项)会原样公开」 - 门禁:policy parity 0(服务端 51 条全被客户端覆盖 + 路径形状维度 OK);`check:doc-index` 248 份 OK;`check:encoding` OK;`git diff --check` 0d0c0ca5033todf43ffd05a- rebase 到 origin/master 后的第一次实跑里,lineage 与(同源的)fork 脚本在浏览器段都卡在 `page.waitForSelector('.platform-account-entry', { timeout: 120_000 })` 并超时: Vite dev server 重启后首次导航要现编译整张模块图,rebase 引入大量新代码时首屏会超过 120s。 证据:两次运行都在该行超时退出;随后用 Playwright 直接探针(同一 URL)页面正常渲染、 `.platform-account-entry` count=1、无 pageerror(仅两条未登录态 401 的资源错误), 紧接着再跑同一脚本即 99/99 全绿。 - 修法:登录等待改成 240s + state:attached,并在超时后重载一次再等(避免把「开发服务器还没编译完」 误判成页面坏了)。两处改动逐字相同,lineage 与 fork 脚本各一处。 - 实测(HEAD 3ae0d129e,固定端口栈 3110/8188):lineage 99/99、fork 48/48、project-bundle 72/72; 三条只读门禁 policy-parity / dto-parity / spacetime-schema 均 exit 0。交付态小结 2:M1 + M3 + M2a + M2b 全部落地,CI 8/8 全绿
1. 分支状态
126fb7bc0,已 rebase 到origin/master21c79f54a:41 ahead / 0 behind,rebase 时 41 个提交零冲突(本分支与 master 新增的 25 个提交没有文件重叠,因此没有需要「两边都保留」的取舍)。AI game creator shell Rust lane 1/2、AI game creator shell Rust lane 2/2、AI game creator shell Rust crates、AI game creator shell web tests、Backend tests、Frontend tests、Native shell tests、Repository checks(后者已包含本轮新增的工程包策略 parity 第 4 维)。2. 相对上一条交付小结新增了什么
game_distribution_version三列)+独立上行路由族(整包 PUT / upload-state / 8 MiB 分片 / complete / reset,Bearer + 发布灰度)+源码包专属校验器+下行优先(有工程包返回source: "project",否则回落package;/fork-source/project在无工程包时 409 而不静默回落)+读取缓存按资产区分键。source分支建项(源码包直接解压建工程;成品包走「合规工程 +reference/<gameId>/」)+来源记录升 v2(兼容 v1)+面板知情同意文案。.aws/.ssh/.kube/.docker/.gnupg/.terraform/.secrets;id_ecdsa*/id_dsa*/*.jks/*.keystore/*.ppk/*.p8/*.kdbx/*.der/.htpasswd/.pgpass/terraform.tfstate*/service-account*)+小文本内容嗅探(PEM 私钥块 /AKIA+16 位 /ghp_/github_pat_/xox*);嵌套包改为 magic bytes 嗅探(改名成.dat也拦)+扩展名并集;读取层按声明值封顶(消除「声明说谎 → 内存放大」,并同步修掉发行包校验器的同源写法);压缩比改按单条目压缩字节;parity 门禁新增路径形状与嗅探/magic 两维,各做了一次「故意破坏会红」验证。3. 验证证据(在新基线
21c79f54a上重跑)check:game-distribution-project-bundle-e2e72/72(含 23 条对抗用例:路径穿越 / 尾点空格 / 大小写变体 / 符号链接 / 嵌套包改名 / 凭据文件 / 内容嗅探 / 声明说谎,外加「不过度拦截」的对照包)、check:game-distribution-lineage-e2e99/99(含浏览器段与软删除降级)、check:game-distribution-fork-authorization-e2e48/48;合计 219 条断言、0 FAIL / 0 SKIP。module-game-distribution73、api-server game_distribution60、module-runtime62、spacetime-client30;AGC 定向cargo test -- project_bundle24(含 dev-dependency 执行级交叉测试);AGC vitest 1961+。git diff --check—— 全绿。4. 已知限制 / 未覆盖(如实列出)
reset路由未在 e2e 中断言。ReadFailed),未量测服务端内存 / RSS。.bz2/.xz/.zst、*.db/*.sql转储、.gcloud/.azure/.password-store目录未纳入拒绝集;CP437 / 非 UTF-8 条目名与external_attributes == 0未覆盖。tracing日志,带operation/request_id标签)。attached+ 超时 reload 重试)。根因未确定性复现,提交信息里写的「Vite 首编译」是假设而非结论。5. 复跑方法
npm run dev(端口会漂移,从.app/dev-stack.json读实际地址;建议固定端口避免与其它工作树抢占)、E2E_ADMIN_USER/E2E_ADMIN_PASSWORD;浏览器段需要 Chromium(支持SKIP_BROWSER=1跳过)。npm run check:game-distribution-*-e2e。6. 待拍板(剩余 4 条,未变)
genarrative://deep link 未注册,当前是「复制作品 ID → 客户端粘贴」。7. 合并前提醒
server-rs/crates/spacetime-client/src/module_bindings/**为生成物,合并后如再有 schema 变更需重跑npm run spacetime:generate(不要手工合并生成文件)。口径决定(四项):文案收敛、唤起客户端、需求外三条纳入范围
genarrative://fork?gameId=<id>,网页端「Fork 这个作品」直接唤起;唤起失败(未安装 / 移动端 / 被拦)降级为显示作品 ID 与指引。已在实现中。冻结约定:`genarrative://fork?gameId=<gameId>`,同时接受 `genarrative://fork/<gameId>`。 方案(用官方两个插件,不手搓单实例与注册表): - `tauri-plugin-single-instance`(开 `deep-link` feature)**第一个注册**:Windows/Linux 上深链是 「带 URL 启动第二个进程」,该插件把第二个实例的 argv 转交给已有实例,并(feature 生效时) 交给 deep-link 插件发事件——否则用户点第二次链接会再开一个窗口。macOS 上它用 Unix socket 做同样的收口(系统本身也只会有一个 .app 实例)。 - `tauri-plugin-deep-link`:`tauri.conf.json > plugins.deep-link.desktop.schemes = ["genarrative"]`。 · macOS:打包器按该配置往 `Info.plist` 注入 `CFBundleURLTypes`(插件 `config.rs` 里那句 `// Used in tauri-bundler` 是代码级证据),运行时由系统把 URL 交给已运行的 .app。 · Windows:运行时 `register_all()` 写 `HKCU\Software\Classes\genarrative`(`URL Protocol` 标记 + `DefaultIcon` + `shell\open\command`,不需要管理员);安装器侧同样按该配置注册。 · Linux:运行时写用户级 `applications/<exe>-handler.desktop` 并调 xdg-mime(AGC 未以此平台分发, 但仍走同一条运行时路径)。 - 冷启动:插件在插件初始化阶段就解析 argv 并暂存,而应用 `setup` 晚于插件,因此 `initialize_fork_deep_link` **先取 `get_current()`**、再 `on_open_url` 订阅后续链接(漏了前者会 丢冷启动那一次)。 - 收到 URL 后:聚焦主窗口(unminimize + show + set_focus)→ 发 `game-fork-deep-link` 事件 `{ gameId | message }` → 渲染层把请求存进 store 并切回首页;**不自动下载**,用户按 「Fork 到我的项目」才走与手工输入完全相同的提交路径。正停在项目工作区时不强行切走 (避免把用户从进行中的项目里踢出来),请求留在 store 里等用户回到首页。 - 非法链接:解析出可读原因(空链接 / 非 genarrative 方案 / 非 fork 形式 / 缺 gameId / gameId 不合法) 一并聚焦窗口并交给渲染层展示,不做静默失败。 测试:`fork_game_id_from_deep_link` 的合法(两种形式、大小写、多余参数、game_id/id 别名)与 非法(空、错方案、错形式、缺 gameId、`../escape`、URL 编码斜杠、非 URL)各一组;`gameId` 形状 判据与取件路径共用(`is_safe_fork_game_id`);TS 侧覆盖载荷解析、订阅映射、深链预填后**不自动 下载**、非法链接展示原因且不请求。- `spacetime-module`:新增表 `game_distribution_collection`(`collection_id` 主键、`user_id` / `game_id` 各具名 btree 索引、`created_at`)。**防重靠确定性主键** `collection_id = "{user_id}:{game_id}"`(与 `profile_save_archive.archive_id` 同一写法):同一 (用户, 作品) 在结构上不可能有第二行,不依赖「事务里先查后写」(那种写法只在单写者假设下成立,分片/并发时两个事务都可能先查到不存在再各写一行);正因主键即约束,**不需要**再建 `(user_id, game_id)` 唯一索引,两个索引只服务两个读取方向 - 下架(未公开/软删除)**不删除**收藏行:收藏是用户态事实,读侧投影时才按当前公开性过滤,作品重新公开后同一行自动回到列表(注释写明) - 新增动作常量、4 个输入类型、3 个结果类型、3 个事务(collect / uncollect / list)+ 读态 helper + 4 个 procedure;`uncollect` 按确定性键删除且**不要求作品仍公开**(下架后拒绝取消会给用户留下清理不掉的脏行);`list` 走 `user_id` 索引 + 既有 `public_game_distribution_snapshot` 投影,只含当前公开可读作品 - 新增 `module-game-distribution/src/collection.rs`:两条纯函数(`game_distribution_collection_id`、`game_distribution_collection_visible`)+ 单测——后者用「同一行、四种可见性输入」直接钉住「已收藏 → 下架 → 列表不含 → 重新公开 → 列表又含」这条链路,不必起真库 - `migration.rs`:新表进白名单并补中文注释 - `spacetime-client`:record + 3 个 mapper + 4 个方法 + 2 个 RecordInput,并按既有做法只保留 `module_bindings/game_distribution*`(13 个新文件)与 `module_bindings.rs` 的生成改动、还原其余 rustfmt 漂移 - 门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p module-game-distribution` 75 passed;`cargo test -p spacetime-module` 273 passed / 1 ignored(含 4 条新事务结构断言);`cargo fmt --all -- --check` 0- 新增 `collections` 路由组(整组 `require_bearer_auth` + `add_no_store_response_headers`,用户态读不会被任何缓存复用): · `PUT /api/game-distribution/games/{game_id}/collection` → `{ collected: true, replayed }`,要求 `Idempotency-Key`(沿用既有 `idempotency_key(&headers)?`);同键重放 `replayed=true`,同键不同请求(摘要绑定 `(userId, gameId)`)→ 409,重复收藏(不同键)只留一行且成功 · `DELETE .../collection` → `{ collected: false }`,**不要求 Idempotency-Key**:按确定性主键删除本就幂等(不存在也算成功),没有「重放 vs 新意图」可区分;也**不要求作品仍公开**(下架后仍可清理) · `GET /api/game-distribution/my-collections` → `{ games: [<public_game_payload>…], nextCursor: null }`,逐条复用既有公开目录的组装方式,只含当前公开可读作品 - 公开详情 `GET /games/{game_id}` 增量:**已认证**返回 `collected: true|false`;**匿名不返回该键**(选这种口径的理由:`false` 会把「未登录」说成「没收藏」,客户端无法区分就会渲染出错误的按钮态);携带无效/过期 token 时按**匿名**处理(沿用 `record_game_play` 的取舍——公开详情匿名可读,不该因此掉 401),因此该响应与纯匿名逐字节相同(单测断言)。实现复用既有 `auth::optional_access_token_from_headers`(`auth.rs:223`),**没有自造 extractor**;值来自新 procedure(真实投影),**未改动既有公开快照契约** - **共享缓存核实(硬性项)**:公开详情所在的 `public_games` 路由组带 `add_no_store_response_headers`;`app::build_router` 只做 merge/背压/错误归一化/request_id/tracing/`attach_request_context`,**没有任何响应体缓存或预渲染**;本模块唯一进程内缓存是 `RELEASE_PACKAGE_CACHE`(键 = OSS 对象键、值 = 发行包字节),与 per-user JSON 无关。结论:**该路径无共享缓存,不需要绕开** - 错误码:作品不存在 → 404;未公开/已软删除/无当前公开版本 → 409(文案「作品状态不允许收藏(未公开或已软删除)」刻意含「状态」而不含「已被删除」,以命中既有映射的 409 分支而非 404 分支) - 测试(本文件内,新增 5 条):三条路由无 Bearer → 401 且 `no-store`;404/409/摘要不一致 409 的映射;摘要绑定 `(user, game)` 的幂等隔离;PUT/DELETE 的载荷形状;公开详情「匿名无 collected 且与目录负载逐字节相等、登录时 Some(true)/Some(false) 各出现且只多这一个键」 - 门禁:`cargo test -p api-server game_distribution` **70 passed**(含新增 5 条);`cargo check --all-targets` 0- `shared-contracts`:新增 `GameDistributionCollectionState { collected, replayed: Option<bool> }` 承载 PUT/DELETE 共用形状(PUT 才发 `replayed`);既有 `GameDistributionGameSummary` 增 `collected: Option<bool>`(公开详情登录时才带)——该类型只被 api-server 测试与 `GameDistributionListResponse` 引用,加字段不影响任何现有发送方 - `packages/shared` TS 镜像同步(`{ collected: boolean; replayed?: boolean }` 与 `GameDistributionGame.collected?: boolean`),并按既有约定登记进 `scripts/check-game-distribution-dto-parity.mjs`(PAIRS + 手拼响应构建器);注意 `json!` 字面量里不插注释行(parity 脚本抓顶层键时不剥注释,这个坑今天踩过一次) - 技术方案:`§2.4` 入口矩阵补「收藏按钮 / 我的收藏(登录)与我的作品页同行风格」;`§3.4` 接口表补三条路由(含 404/409 语义、PUT 要求幂等键而 DELETE 不要求及理由、`my-collections` 只含公开作品且不删行)与公开详情 `collected` 的可见性口径;`§7` 把「收录/收藏」从范围外移除并标注已纳入落地 - 数据契约表目录:新增 `game_distribution_collection` 小节(主键确定性、两个索引、为什么不删行、为什么不建唯一索引),供 `check:spacetime-schema` 守卫通过(94 tables) - 门禁:DTO parity 0(**49 组** Rust/TS 一致,较本轮前 +7);`check:spacetime-schema` 0(94 tables,对照新 merge-base `51cb05f4`);`check:encoding` 0;`git diff --check` 0- 客户端:新增 setGameCollection(PUT 带 Idempotency-Key;DELETE 不带)与 listMyCollections({games,nextCursor},条目形状与公开目录逐字一致,可复用同一套卡片);getGame 返回类型放宽为 GameDistributionGameWithCollection,collected 为可选 - 详情页:在动作区(「立即玩」旁,而不是共创信息卡——那张卡讲改编关系)新增收藏切换。三态严格区分:collected=true → 「已收藏」点击取消;collected=false → 「收藏」点击收藏;**缺字段(匿名 / 无效 token 被按匿名处理)绝不落成「未收藏」**,按钮 title 标注「登录后可收藏」,点击走既有登录门禁且不发请求 - 乐观更新 + 回滚:先切换本地态,失败回滚到服务端投影并用服务端 message 提示(role=status);成功以服务端返回的 collected 为准 - 广场卡片:卡片本身没有操作区,按「没有就别硬塞」不动 - 我的收藏:落在 /games/mine 的页签(「我的作品」/「我的收藏」)。理由:两者都是登录后的个人内容、该页已有登录门禁;且不需要新路由,避免牵动「路由表 + nginx×3 + Pingora allowlist」这组必须同批登记的清单。复用公开目录同一套卡片(shared 的 GameCard + GameCover),空态/失败重试/登录态照既有写法 - 分页按「将来会有 cursor」的形态写:读 nextCursor,非空才渲染「加载更多」并在点击时追加;服务端当前恒为 null,不伪造分页数据 - 文案统一「收藏 / 已收藏」(不用需求文档旧词「收录」) - 测试:详情页收藏四例(已收藏→取消 / 未收藏→收藏 / 缺字段走门禁且零请求且不渲染已收藏 / 失败回滚+服务端文案);我的收藏两例(空态且默认页签不预取 / 列表渲染+卡片点击跳详情+无 cursor 不出现「加载更多」)- 新增 scripts/check-game-distribution-collection-e2e.mjs(约 750 行):真实 HTTP 验收收藏链路, 复用 lineage 脚本的发布链路 helper(脚本头注释逐条标注出处),覆盖 9 组共 46 条断言: · 幂等与「只一行」:两次不同键都成功(replayed=false);同键重放 replayed=true;两种情况都断言 my-collections 长度=1 且该作品只出现一次(无收藏计数接口,按工单允许的列表去重与长度证明) · 同键不同请求:换作品 → 409(「幂等键对应的请求摘要不一致」);**换用户 → 200 新收藏**,与 api-server handler 注释「换用户也是 409」不一致(收据键含 user_id,模块侧注释与实测一致), 脚本按实测断言并在 NOTE 与报告里标注 · DELETE 幂等:已收藏 / 未收藏 / 从未收藏且未公开 三种情形都 200 {collected:false} 且不发 replayed · 鉴权与缓存:三条路由未登录都 401 且带 Cache-Control: no-store(证明挂载而非 404/405); 已登录但缺 Idempotency-Key 的 PUT → 400 · 状态门:草稿 PUT → 409「作品状态不允许收藏(未公开或已软删除)」(含「状态」不含「不存在」, 不泄露未公开作品存在性);不存在的 gameId → 404「作品不存在」 · 下架 → 重新公开:收藏后下架读侧过滤掉该作品,重新公开(新版本审核通过)后又回到列表且仍只一行 (证明下架不删行、只在读侧过滤) · 公开详情 collected:匿名 23 键无 collected;已登录 24 键恰好多 collected=true; 去掉该键后与匿名逐字段一致;公开目录同作品条目与匿名详情键集合与取值完全一致 · 跨用户隔离:A 收藏后 B 的列表不含该作品、B 的 collected=false - package.json:新增 npm script check:game-distribution-collection-e2e - 实测:本地 dev 栈(SpacetimeDB 3110 / api-server 8188)46 项 46 PASS / 0 FAIL / 0 SKIP- 纯函数(`module-game-distribution/src/collection.rs`,与收藏另两条规则同文件):`encode/parse_game_distribution_collection_cursor`(`"{created_at_micros}:{collection_id}"`,复用仓库既有 `"{micros}:{id}"` 惯例;非法 → Err「收藏列表游标格式无效」→ 400)、`game_distribution_collection_page_limit`(0/缺省 → 20,超界 → 50)、`game_distribution_collection_page`(稳定全序排序 → 跳过游标之前 → 取 limit → 多取一条判 has_more,只对页内最后一条编码下一游标) - 排序与顺序语义:`created_at` 倒序 + `collection_id` 升序兜底(主键、同用户内唯一 ⇒ 全序,翻页不重不漏);**先按可见性过滤、再排序切页**——游标位置必须落在已过滤序列上,否则被过滤的行会让下一页漏项(推理写进 tx 与纯函数注释);snapshot 只对最终页成员计算 - 模块:list 的 input 增 `limit` / `cursor`,result 增 `next_cursor`;api-server `my-collections` 加 `Query<MyCollectionsQuery>`,默认 20 / 上限 50(网格一屏 vs 单响应体积;与后台列表的 200 口径不必相等,理由写进注释),超界**截断**不报错,非法游标 400 透传不吞,日志带 `has_more` - 契约:Rust DTO 增 `next_cursor`、TS 镜像同步、parity 登记(50 组一致) - 文档:§3.4 的 `my-collections` 行补分页语义(默认/上限/游标格式/非法 400/末页 null/排序口径) - 测试 13 条:纯函数 8(不足一页 → None;恰好整页不产生空游标;5 条 limit=2 连翻 3 页不重不漏;同 `created_at` 用 `collection_id` 兜底;limit=0 与超界;游标往返 + 7 种非法输入)、模块事务结构 2、api-server 3(默认与截断、`nextCursor` 透传、非法游标 → 400) - 门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p module-game-distribution` **81 passed**;`cargo test -p api-server game_distribution` **73 passed**;`cargo test -p spacetime-module` 274 passed / 1 ignored;DTO parity **50 组**;`check:spacetime-schema` 0(94 tables);`check:encoding` 0(5382 files);`cargo fmt --all -- --check` 0;`git diff --check` 0需求形态(飞书):共创 Tab 下有多个「共创主题」,点进去是一棵(或一组)作品树;**主题由平台/运营命名,不由根作品决定**;作品在游戏 Tab 里仍作为独立作品展示,详情页有 Fork 入口并能看到 / 跳到对应主题页。本轮只落设计(不写业务代码),实施清单见里程碑文件。 设计要点与理由(详见 §3.10): - 实体:`game_distribution_theme`(`theme_id` 主键、三态 `draft|published|archived`、`sort_order`、`badge`、`created_by_user_id`)+ `game_distribution_theme_member`(确定性 `member_id = "{theme_id}:{root_game_id}"`,`theme_id` / `root_game_id` 各具名 btree 索引)。 - **成员只允许根作品**:树查询 `/games/{id}/lineage` 按根聚合,否则同一棵树会在主题页里出现多次(去重/高亮打架);代价=要挂就挂它所属的根。跳到某一代节点属 UI 层(族谱页已有 `from` 高亮)。 - **允许跨主题多归属**:主题是运营叙事,「平台精选」「双人合作」会同时收录同一个根;确定性主键只保证**同一主题内**不重复。代价=作品详情要返回多值、运营需知悉。 - **树直接复用 `/games/{id}/lineage`**,主题侧**不重建**任何树查询/树整形(没有第二套可见性/排序可漂移);主题详情只返回成员根清单(N+1 本轮接受,批量树端点留白)。 - 可见性:公开侧只见 `published` 主题;成员只出现公开未删且有当前公开版本的根(**投影跳过但不删行**,重新公开自动回来);主题不存在/未发布 → **404**;已发布但可见成员为空 → **200 + 空成员列表**。 - 公开三接口:`GET /themes`(游标分页沿用默认 20/上限 50/非法 400/末页 null)、`GET /themes/{theme_id}`(+ `roots`)、作品详情 `themes` 增量(**先取该作品的根、再按根反查**,因此第 N 代作品也能跳到主题页)。 - 后台五接口(创建/编辑/增删成员/后台列表,均幂等),鉴权**复用既有 `require_admin_auth` + `AuthenticatedAdmin`**,不自造;后台 UI 不在本轮。 - 留白 5 项(各写不做理由与将来接法):主题封面图、slug/URL 别名、埋点统计、跳到某一代的高亮、批量树端点。 - 同步收敛:§6 里程碑表 M4 行指向新里程碑;§7 第 3 条「共创主题」从**待拍板**改为**已拍板采纳**,并把「主题级排序口径」留作唯一待产品确认项。 门禁:`check:doc-index` 0(252 份 md;新里程碑在 `plans/` 下免分类);`check:encoding` 0(5383 files);`git diff --check` 0。 注:§3.10 的前约 200 行因共享工作树/索引,被上一提交 `f703bc49d`(注释修正)一并带上;本提交补齐其余章节 + 里程碑文件。按 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md §3.10` 与里程碑《共创主题与作品树-2026-10-06》的清单 A/B/F 落地(本块**不含** procedure / 路由)。 - **表**:`game_distribution_theme`(`theme_id` 主键、`name`/`summary`/`badge`/`sort_order`/`status ∈ draft|published|archived`/`created_by_user_id`/`created_at`/`updated_at`)+ `game_distribution_theme_member`(确定性主键 `member_id = "{theme_id}:{root_game_id}"`,`theme_id`、`root_game_id` 各具名 btree 索引,`sort_order`、`created_at`);归档 / 下架只改公开投影,不删成员行。`migration.rs` 白名单登记两张表(随迁移导出/导入)。 - **生成绑定**:4 个 `game_distribution_theme*` 生成文件 + `module_bindings.rs` 入口(纯追加)。生成时**不覆盖工作树**(本仓共享工作树禁止 git 写命令):先 `spacetime generate --lang rust --out-dir <仓外临时目录>`,rustfmt 后只拷回本次相关文件,避免上一轮踩过的「rustfmt 漂移把别人文件一起改」。 - **领域纯函数**(`module-game-distribution/src/theme.rs`,风格照同目录 `collection.rs`):状态合法/公开可见判定、`game_distribution_theme_member_id`、成员可见性(**委托** `collection.rs` 的判定,不复制第二套)、`game_distribution_theme_root_acceptable(has_lineage_row)`、页大小(缺省 20 / 上限 50 / 0 取默认 / 超界截断)、游标编解码(`"{createdAtMicros}:{themeId}"`,Err 文案「主题列表游标格式无效」,已核对不含 404/409 关键词)、`sort_public_themes`(`created_at` 倒序 + `theme_id` 兜底)、`page_public_themes`(排序 → retain 游标之后 → 多取一条判 has_more;**先过滤再切页**的顺序语义写进文档注释)、`sort_theme_members`(`sort_order` 升序 + `member_id` 兜底)与 `page_theme_members`。 - **契约**:`shared-contracts` 新增 `GameDistributionThemeStatus` / `GameDistributionThemeReference` / `GameDistributionThemeSummary` / `GameDistributionThemeListResponse` / `GameDistributionThemeDetail` / `GameDistributionCreateThemeRequest` / `GameDistributionUpdateThemeRequest` / `GameDistributionUpsertThemeMemberRequest`,`GameDistributionGameSummary` 追加可选 `themes`(按 `collected` 先例只在详情路径下发);TS 镜像同步;parity 登记 **58 组**(响应构建器待路由落地后再登记,parity 要求函数真实存在)。 - **数据契约表目录**:补两张表小节,94 → **96**。 - **测试**:`theme.rs` 16 个单测(成员 id 确定性、状态三态与公开可见、成员可见性逐格与收藏判定等价、根判定、页大小边界、游标往返与非法输入、排序稳定性与同 `sort_order` 兜底、分页不重不漏)。 - **门禁**:wasm build 0;`cargo check --all-targets` 0;`cargo test -p module-game-distribution` **97 passed**;`cargo test -p spacetime-module` 274 passed / 1 ignored;DTO parity **58 组**;`check:spacetime-schema` 0(**96** tables);`check:server-rs-ddd` 0;`check:encoding` 0(5388 files);`cargo fmt --all -- --check` 0;`git diff --check` 0(8 个改动文件 0 删除行)。- **事务**(`spacetime-module`):公共列表 `list_game_distribution_themes_tx`(匿名可读;先按 `published` 过滤、再排序切页——`created_at` 倒序 + `theme_id` 兜底;只对当页现算 `member_count`;不写库);主题详情 `get_game_distribution_theme_detail_tx`(不存在 / 未发布**同一句**「主题不存在」→ 404;`roots` 逐条复用公开目录同一份 `public_game_distribution_snapshot`;成员可见性委托 `game_distribution_theme_member_visible`,被滤掉的行**不删除**;已发布但可见成员为空 = Ok + 空 roots = 200);作品→主题 `list_game_distribution_theme_refs_for_root_tx`(入参任意代作品,先取**根**再按根反查成员、只保留 `published` 主题;排序复用 `sort_public_themes`)。**根解析只有一份实现**:抽成 `game_distribution_theme_root_of` 并让既有 `game_distribution_lineage_tx` 复用(测试钉住)。 - **client**:3 个新方法 + mapper 记录类型(`spacetime-client`)。 - **公开路由**(`api-server`):`GET /api/game-distribution/themes?limit=&cursor=`(匿名可读 + `no-store`;`limit` 缺省 20 / 上限 50 / 超界截断;非法游标 **400** 透传;末页 `nextCursor: null`)、`GET /api/game-distribution/themes/{theme_id}`(顶层扁平 `{themeId,name,summary,badge,memberCount,roots}`;不存在 / draft / archived → **404**,不回空壳、不带 `THEME_` 码)、既有公开作品详情追加 `themes`(匿名与登录**都**发、空数组恒发;`collected` 仅登录发的老口径不变;因按根反查,**第 N 代作品也能跳到主题页**)。 - **错误码**:新增 6 个 `THEME_*` 稳定码集中在 `map_spacetime_error` 映射(不落 axum 默认 422 纯文本);未登记码兜底 **400 `THEME_ERROR`**。 - **契约/parity**:5 条手拼响应构建器登记(`public_themes_payload` / `public_theme_detail_payload` / `public_theme_summary_payload` / `theme_reference_payload`,并给 `public_game_detail_payload` 加 `mustEmit: [themes]`)→ 58 组类型 + 10 个构建器一致。 - **测试 +13**:spacetime-module 4 条源码结构断言(根解析单一实现、先过滤再切页、投影不删行、详情只有两种失败且同一 404 文案、refs 走具名根索引 + 只保留 published)、api-server 7 条(路由挂载与 `no-store`、列表/详情响应形状与末页 null、`roots` 逐字节等于 `public_game_payload`、limit 缺省与截断、非法游标 400、404 与 6 个 THEME_ 码映射、作品详情 `themes` 恒发而 `collected` 仅登录)、client mapper 2 条。 - **门禁**:wasm build 0;`cargo check --all-targets` 0;`cargo test -p api-server game_distribution` **79 passed**;`cargo test -p module-game-distribution` **97 passed**;`cargo test -p spacetime-module` **278 passed** / 1 ignored;`cargo test -p spacetime-client` 34 passed;DTO parity **58 组 / 10 构建器**;`check:spacetime-schema` 0(96 tables);`check:encoding` 0(5399 files);`cargo fmt --all -- --check` 0;`git diff --check` 0。 已知限制(**已上报,待拍板**):主题详情的 `roots` 沿用页上限 50,`memberCount == roots.len()`,因此可见成员 > 50 时**静默截断且无「还有更多」标志**——设计文档未定义该行为,三个备选(去上限 / 加成员游标 / memberCount 报真实数)待定;本轮不改契约。按 §3.10.8:后台 UI 不在本轮,本轮只落**接口与事务**。 - **鉴权(照仓库既有 admin 体系,不自造)**:5 条路由挂在既有 `/admin/api/game-distribution/*` 支,统一 `route_layer(require_admin_auth)`,handler 取 `Extension<AuthenticatedAdmin>`;未带 / 失效会话 → **401 `UNAUTHORIZED`**(与同组 games/reviews 路由同码同形),非 admin role → 403。模块侧第二层:写 procedure 与后台列表都要求受信服务身份(`require_editor_generation_runtime_service_identity`),只有 api-server 能调。 - **5 条路由**:`POST /themes`(创建;服务端生成 `theme-{uuid}`;要求 `Idempotency-Key`,摘要只覆盖客户端可见请求体,**不含重试时重新生成的 `theme_id`**)、`PUT /themes/{theme_id}`(整体覆盖 name/summary/badge/sortOrder/status,生效即刷新 `updated_at`,重放不写库不刷新)、`PUT /themes/{theme_id}/members/{root_game_id}`(**不要求幂等键**——确定性主键天然幂等;可带 `sortOrder`)、`DELETE …/members/{root_game_id}`(不要求幂等键;成员不存在也算 200,响应刻意不含「之前存不存在」→ 重复调用逐字节相同)、`GET /themes?limit=&status=`(**含 draft/archived**,status 白名单 `all|draft|published|archived`,limit 缺省与上限 200、超界截断、无游标)。 - **成员只允许根作品**:用 `game_distribution_theme_root_acceptable(has_lineage_row)`,非根 → **409 `THEME_MEMBER_NOT_ROOT`**;作品不存在 → 404 `THEME_MEMBER_GAME_NOT_FOUND`;主题不存在 → 404 `THEME_NOT_FOUND`。**不做「成员必须已公开」的前置校验**(运营可先挂草稿根,作品公开后自动出现在公开投影里);归档主题**不清理**成员行(与公开投影「跳过不删行」同一口径)。 - **领域错误码**:`module-game-distribution/src/errors.rs` 新增 5 个带码错误(`ThemeNotFound` / `ThemeBadRequest{reason}` / `ThemeIdempotencyConflict` / `ThemeMemberNotRoot{game_id}` / `ThemeMemberGameNotFound{game_id}`),Display 一律 `CODE: 文案`,并由测试钉住「码后必须跟冒号」;api-server 集中映射,不落 axum 默认 422 纯文本。 - **纯函数补充**:后台 status 过滤器白名单(`all` + 三态的单一实现)、文本上限常量(名称 40 / 简介 200 / 角标 16,按 UI 一行与卡片摘要的量级取,写进注释)与 `game_distribution_theme_text_violation`(trim 后判空名与超长,返回可拼进 `THEME_BAD_REQUEST:` 的原因)。 - **测试**:`theme.rs` +2 纯函数(status 过滤器白名单、文本违规判定)、`domain.rs` +1(错误码前缀)、spacetime-module +8 事务结构断言、client mapper +3、api-server +6(401 同码同形、创建/编辑/成员增删的形状与错误码、后台列表含 draft、重复 PUT 幂等、非根成员被拒)→ `cargo test -p api-server game_distribution` **85 passed**、`module-game-distribution` **100 passed**、`spacetime-module` **286 passed**、`spacetime-client` 37 passed。 - **门禁**:wasm build 0;`cargo check --all-targets` 0;`cargo test -p api-server game_distribution` 85 passed;`cargo test -p module-game-distribution` 100 passed;`cargo test -p spacetime-module` 286 passed / 1 ignored;`cargo test -p spacetime-client` 37 passed;DTO parity 58 组 / 10 构建器;`check:spacetime-schema` 0(96 tables);`check:encoding` 0(5414 files);`cargo fmt --all -- --check` 0;`git diff --check` 0。 - 生成绑定:新增 15 个 `module_bindings/*theme*/admin_theme*` 文件 + 入口声明(无重排);其余 202 个文件的 rustfmt 漂移按上一轮做法逐字节还原,未跑任何 git 写命令。服务端三块(`2fa201e0d` 模型与纯函数、`742723a58` 公开读、`033e3aa79` 后台写)已落地,本次把文档从「只落设计」补齐到「服务端已实现」,并写死一条已知限制。 - **技术方案 `§3.4`**:新增 5 条接口行(真实路由/鉴权/错误码/字段名均以 `git show` 核对代码为准,非猜测)——公开 `GET /api/game-distribution/themes`(匿名 + `no-store`;只含 `published`;limit 缺省 20 / 上限 50 / 超界截断;游标 `"{createdAtMicros}:{themeId}"`;非法游标 400;末页 `nextCursor: null`;`memberCount` = 公开可见成员数)、`GET /api/game-distribution/themes/{themeId}`(顶层扁平 `{themeId,name,summary,badge,memberCount,roots}`;`roots` 复用公开目录同一份投影;不存在/draft/archived → 404 不返回空壳;已发布空成员 → 200 + 空 `roots`),以及 `GET /games/{gameId}` 的 `themes` 增量(匿名与登录都发、空数组恒发;先取根再按根反查 ⇒ 第 N 代作品也能跳主题页);后台 5 条(`require_admin_auth` + `AuthenticatedAdmin`,未登录/失效 401 `UNAUTHORIZED`、非 admin 403;创建与编辑要求 `Idempotency-Key`,同键重放 `replayed: true`、同键换请求 409 `THEME_IDEMPOTENCY_CONFLICT`;成员增删**不要求**幂等键(确定性主键天然幂等,删不存在也 200);非根成员 409 `THEME_MEMBER_NOT_ROOT`;后台列表含 draft/archived、limit 缺省与上限 200、无游标;`theme_id` 服务端生成 `theme-{uuid}`)。 - **`§3.10`**:各小节「本轮实现 / 留白」标注更新为现状(服务端已实现;公开前端与后台 UI 仍留白);`§3.10.6`/`§3.10.9` 明确写下**已知限制**——主题详情 `roots` 沿用页上限 **50** 且 `memberCount == roots.len()`,可见成员 > 50 时会**静默截断且没有「还有更多」标志**,三个备选(去上限 / 给成员加游标 / `memberCount` 报真实可见数)待拍板,本轮不改契约。 - **`§7`**:第 3 条(共创主题)改为「设计已采纳 + 服务端已实现」,仍待拍板收敛为两条:① 主题级排序口径(公开列表当前按 `created_at` 倒序 + `themeId` 兜底;若要按运营 `sort_order` 展示需改游标格式并同步前端);② 上述 `roots` 50 上限行为。 - **里程碑文件**:`Status` 由 `proposed` → 「服务端已实现(附四个提交号);公开前端与后台管理 UI 待做」,Version 1.0 → 1.1,新增「已落地提交」行;实施清单 A–F 标注已落地提交、G 标未做(待前端)、H 分「已落地层 / 未做层」、I 补执行状态;**验收标准勾 17/19**(第 17 条公开前端、第 18 条 SPA 路由门禁留空并注「等前端」);文末写明「dev 栈端到端整链未跑(`scripts/check-game-distribution-theme-e2e.mjs` 未创建),依赖真实库存的运行时分支目前只到单元/结构/映射层」。 - **数据契约文档**:两张主题表小节的「写入路径属后续里程碑」改为现状(读/写 procedure 与事务名),表结构描述未动。 - **门禁**:`check:doc-index` 0(252 份 Markdown,current=121 / historical=15 / review=1);`check:encoding` 0(5414 files);`git diff --check` 0。 - **环境限制(标注,非绿)**:`check:generated-bindings` 本机 `EXIT=1`,原因是 `apps/ai-game-creator-shell/src-tauri/build.rs:167` 校验随包资源时缺 `bin/win-x64/agc_godot_editor.dll`(需先跑随包资源准备步骤)——纯环境前置,与本次只改 `docs/**` 无关;该检查的 AGC 段本地从来跑不了(CI 绿)。- 新增 scripts/check-game-distribution-theme-e2e.mjs(约 1160 行):真库验收公开读 + 后台写 + 作品详情 themes 增量,复用 lineage 脚本的完整发布链路 helper(脚本头注释逐条标注出处), 覆盖 9 组共 54 条断言: · 建主题→加根成员→公开读:公开列表只含 published(且不含 status 字段)、详情 roots 逐条与公开 目录同一投影(键集合 23=23)、memberCount 与 roots 长度一致 · 只允许根:第 1 代子作品当成员 → 409 THEME_MEMBER_NOT_ROOT;不存在的 gameId → 404 THEME_MEMBER_GAME_NOT_FOUND · 跨主题多归属:同一个根加入两个主题,两条主题都能读到它,作品详情 themes 长度=2 · 作品详情 themes 口径:匿名与登录都发;**专门构造第 1 代衍生作品**(带 fork 声明 + 完整发布链路) 并断言它也返回根所属的两个主题(先取根再反查);无归属作品返回空数组 · 可见性:draft 主题公开 404(后台按 draft 过滤仍可见)、archived 公开 404 且作品详情 themes 同步消失、 改回 published 恢复;已发布但成员全不可见 → 200 + 空 roots + memberCount=0; 成员下架 → 从 roots 消失,重新公开(新版本审核通过)→ 同一行自动回到 roots 且仍只一行 · 分页:默认 20、limit=999 截断到 50、limit=20 游标翻页 3 页 54 条不重不漏、末页 nextCursor=null、 ?cursor=abc → **400**(状态码符合契约) · 后台权限:五条路由未登录 401;普通作者 token 403;成员 PUT/DELETE 免幂等键、重复 PUT 只留一行 且 createdAt 不变、DELETE 幂等(data 载荷逐字段相同,信封 meta.requestId 每请求不同属既有协议) · 后台幂等:同键重放 replayed=true 且不产生第二个主题、同键换请求 409 THEME_IDEMPOTENCY_CONFLICT、 缺 Idempotency-Key → 400 · 三条公开路由匿名 200 且带 Cache-Control: no-store - **实测 53 PASS / 1 FAIL**:唯一失败是已确认的产品缺口(仅报告、未改后端)——非法游标的状态码是 400 ✓,但稳定码 THEME_INVALID_CURSOR 不可达:api-server/...:5343-5346 为该码注册了 400 映射, 而模块侧只回纯文案「主题列表游标格式无效」(module-game-distribution/src/theme.rs:205-215), 不含分支前置所需的 `THEME_` 前缀,客户端实际拿到 code=BAD_REQUEST。脚本刻意保留这条红断言 (契约真值),并附 NOTE 说明成因与证据。 - package.json:新增 npm script check:game-distribution-theme-e2e端到端实测(54 项里唯一红的那条):`GET /api/game-distribution/themes?cursor=abc` 回 400,但 `error.code = "BAD_REQUEST"`——技术方案 §3.4 与主题 e2e 脚本承诺的稳定码 `THEME_INVALID_CURSOR` 在 HTTP 面上拿不到,客户端只能去匹配中文文案。 根因:`map_spacetime_error` 的 `THEME_` 分支前置条件是「消息含 `THEME_`」,而模块侧游标解析只回 中文串「主题列表游标格式无效」⇒ 落兜底分支成 400 + 通用 `BAD_REQUEST`;契约常量与那条映射分支 因此是死代码。且 api-server 原有单测**明确要求**文案不含 `THEME_`(口径留了两套)。 口径二选一,选**让码可达**(模块产出前缀),删掉「要求不含 `THEME_`」的断言: - `module-game-distribution/src/errors.rs`:新增 6 个主题码常量 `GAME_DISTRIBUTION_THEME_{NOT_FOUND,BAD_REQUEST,INVALID_CURSOR,IDEMPOTENCY_CONFLICT,MEMBER_NOT_ROOT,MEMBER_GAME_NOT_FOUND}_CODE`, 并把 `Display` 从字面量改为**复用同一批常量**(消灭「常量改了、`Display` 没改」这类只有真栈才发现的漂移)。 - `theme.rs`:`parse_game_distribution_theme_cursor` 产出 `THEME_INVALID_CURSOR: 主题列表游标格式无效`; 文档注释更新为「只保留 `FORK_` 这个排在 `THEME_` 分支之前的子串禁忌」(其余子串已抢不到)。 - `lib.rs`:导出新增的 6 个码常量。 - 测试: · `domain.rs::theme_errors_are_prefixed_with_their_machine_code` 改为**枚举全部 6 个主题码**(含唯一 不走领域变体的游标路径——正是当初漏掉它的原因),并加「任何新增码都必须进这张表」的覆盖完整性断言; · `theme.rs::invalid_cursor_message_stays_in_the_bad_request_bucket` 增加「必须以稳定码开头」; · api-server 新增 `theme_error_codes_are_reachable_from_module_messages`:用**模块真实产出的消息** 逐码断言 `(状态码, 稳定码)` 可达,并比对模块常量与 `shared-contracts` 常量是同一组字符串; · api-server `theme_list_invalid_cursor_maps_to_bad_request` 改为断言映射出 `THEME_INVALID_CURSOR`, 只把 `FORK_` 留在禁忌列表里(并写明理由)。 门禁:`cargo test -p module-game-distribution` **100 passed**;`cargo test -p api-server game_distribution` **86 passed**;`cargo check --all-targets` 0;DTO parity 58 组 / 10 构建器;`check:encoding` 0; `cargo fmt --all -- --check` 0;`git diff --check` 0。缺口:后台原先只能借**公开投影** `GET /api/game-distribution/themes/{themeId}` 列成员,而公开投影只服务 `published` 主题 ⇒ **草稿 / 已归档主题在后台看不到成员名单**,运营没法在发布前核对。 新增 `GET /admin/api/game-distribution/themes/{theme_id}/members?limit=&cursor=`: - **鉴权照既有两层**:路由挂既有 admin 支的 `route_layer(require_admin_auth)`,handler 取 `Extension<AuthenticatedAdmin>`(未登录/失效 → 401 `UNAUTHORIZED`、非 admin → 403,同组同码同形); 模块侧 procedure 先跑 `require_editor_generation_runtime_service_identity`(照 `033e3aa79` 五条写接口)。 - **返回全部成员行,不套公开可见性过滤**,且**不要求主题已发布**(`draft` / `archived` 照常可读; 只有主题真的不存在才 404 `THEME_NOT_FOUND`)。响应: `{ themeId, totalMembers, members: [{ rootGameId, title, sortOrder, createdAt, visible, visibility }], nextCursor }`。 - **两个可见性字段刻意独立**: · `visible` = 「公开侧此刻会不会出现」,**复用** `game_distribution_theme_member_visible`(未删 + 已公开 + 有当前公开版本),不另写第二套判定; · `visibility` = 更细状态(新纯函数):游戏行不存在 → `missing`(优先于 `deleted`),软删除行 → `deleted`, 否则原样透传游戏行 `visibility`(`published` / `unpublished` / `suspended`)。 因此 `visibility == "published"` 但无当前公开版本时 `visible = false` ——运营能看出「公开了但没公开版本」; 三处钉住这个组合(纯函数单测、api-server payload 单测、事务结构断言)。 - **排序与分页照既有口径**:复用 `sort_theme_members`(`sort_order` 升序 + 成员 id 兜底)+ 新增 `page_admin_theme_members`;`limit` 缺省 20 / 上限 50 / `0` 取默认 / 超界截断(同一归一化); 游标 `"{sortOrder}:{memberId}"`,解析**只委托** `parse_game_distribution_theme_cursor` ⇒ 非法游标仍是 上一轮刚修好的**可达** `THEME_INVALID_CURSOR`(不另造一个不可达码);`totalMembers` 是成员**行**总数, 切页前算,不受分页影响。 - **契约与登记**:`packages/shared/src/contracts/gameDistribution.ts` 新增 `GameDistributionAdminThemeMemberRow` / `…ListResponse` / `…MemberVisibility`;parity 登记 `TS_ONLY_TYPES` +3、`RESPONSE_BUILDERS` +2(列表响应与单条成员各一条,条目形状有独立证据);api-server 用命名构建器(不内联 `json!`)。 - **测试**:module-game-distribution +5 条纯函数(`missing` 优先于 `deleted`、三类不可见各一条并断言 `visible`/`visibility` 组合、`published` 无公开版本 ⇒ `visible=false`、游标委托共享实现、翻页不重不漏、 末页 null、复用共享比较器与同一归一化);spacetime-module +1 并扩 2(结构断言:走主题索引、不套公开可见性 过滤、不复用「只取可见成员」的助手、不自写排序、不删行、`totalMembers` 在切页前取);spacetime-client +1 mapper(行总数与游标透传、两个可见性字段不互相推导、空名单是正常结果);api-server 例(401/403、草稿主题 可读、含不可见成员仍返回、`totalMembers` 不受分页影响、limit 缺省与截断、非法游标 → 400 `THEME_INVALID_CURSOR`、 主题不存在 → 404)。 - **文档**:技术方案 `§3.10.8`(新行 + `visible`/`visibility` 语义 + 「草稿也能读」的理由)与 `§3.4` 后台接口表; 里程碑(后台路由清单、实施清单、验收与测试清单,原「只能借公开投影」的留白项标记为已落地)。 - **生成绑定**:新增 4 个 `admin_theme_member_list*` / `list_admin_…_procedure` 生成文件 + `module_bindings.rs` 入口;wire format 有新增(新 procedure/结果类型)⇒ 部署需重新 publish 模块。 门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p api-server game_distribution` **89 passed**; `cargo test -p module-game-distribution` **106 passed**;`cargo test -p spacetime-module` **287 passed** / 1 ignored; `cargo test -p spacetime-client` 39 passed;DTO parity 0(58 组 / **17** 构建器 / **15** 手拼类型); `check:spacetime-schema` 0(96 tables);`check:encoding` 0(5424 files);`cargo fmt --all -- --check` 0; `git diff --check` 0。 不在本提交内(属另一个 session 的文件,工作区里由我的子代理顺带做了**纯新增性**改动,未提交): `apps/admin-web/src/api/adminGameThemeTypes.ts` 增加了三个指向共享契约新类型的别名 + 一段注释 —— 与该 session 「登记好之后改回引用共享契约」的计划一致,交由其 review / 提交(或直接删掉其本地重复定义)。1) 类型去重:`api/adminGameThemeTypes.ts` 不再本地定义任何载荷,改成对 `packages/shared/src/contracts/gameDistribution` 的**纯透传**(相对路径导入,不走包名——仓库根的 `node_modules/@genarrative/shared` 软链指向主工作树,主题契约只在本分支上,走包名会拿到缺类型的 旧副本,实测 TS2724)。页面与 api 客户端改用契约原名(`GameDistributionAdminTheme` 等), 只保留一个后台独有的查询值别名 `AdminThemeStatusFilter = GameDistributionThemeStatus | 'all'` (`all` 是接口查询白名单取值,不是主题状态本身,没有进契约)。删除重复定义后只剩一份真相。 2) 成员面板改用 `GET /admin/api/game-distribution/themes/{themeId}/members?limit=&cursor=`: - 草稿 / 归档主题也能读到成员名单(旧做法借公开投影,对未发布主题 404,看不到名单)——公开投影 那条路(`getPublicGameDistributionTheme`)整体删掉,不留两套读法。 - 每行展示 rootGameId / 标题(游戏行不存在时为「(游戏行不存在)」)/ 排序 / 挂载时间 + 可见性徽标:`visible` 与 `visibility` 按服务端事实**分别展示、互不推导**—— 「公开可见」;「已公开,但无公开版本」(`visible=false` + `visibility=published`,刻意要让人 看出来的异常);「作品未公开」;「作品已下架」;「作品已删除」;「游戏行不存在」。 - 分页:每页 20(上限 50,由 api 客户端按服务端口径夹住),`nextCursor` 驱动「加载更多成员」; 页脚显示「成员行总数 N;已加载 M 条」(`totalMembers` 不受分页影响,不与 `members.length` 互相推导)。 - 增删成员后**重读第一页**而不是本地删行(游标基于排序,本地删会让后续翻页错位); 读取失败如实报错(如非法游标 400),不渲染成「还没有成员」。 测试(10 条,含新增/改写 4 条):草稿主题也能读到名单并逐条移除(移除后重读第一页)、 「已公开但无公开版本」与其它不可见原因分别展示、游标加载更多(末页按钮消失)、 读取失败如实报错不显示空名单。- scripts/check-game-distribution-theme-e2e.mjs: · 头部契约引用**全部改成符号检索**(函数名/常量名),不再写会随提交漂移的行号 · 新增「公开详情顶层键集合恰为契约七个(含 rootsTruncated)」与「小主题下 rootsTruncated 是布尔且 为 false」两条;同时把「memberCount === roots.length」明确标注为只在小主题成立的观察,不再当不变式 · 新增第 10 组:后台成员名单(未套公开可见性过滤、draft 也能读)——51 个未公开根作品 + 1 个软删除成员 共 52 行,逐条断言:limit=999 截断到 50 且 totalMembers 仍为 52(limit=1 时 members 只有 1 行、 totalMembers 不变)、默认 20 游标翻页 3 页 52 行不重不漏且末页 nextCursor=null、成员行键集合恰为 {rootGameId,title,sortOrder,createdAt,visible,visibility}、按 sortOrder 升序、未公开成员 visible=false+visibility=unpublished、软删除成员 visible=false+visibility=deleted(落在末页)、 非法游标 400 THEME_INVALID_CURSOR、主题不存在 404、draft 主题也能读到成员、未登录 401 / 普通作者 403 · 上一轮标红的「?cursor=abc → 稳定错误码 THEME_INVALID_CURSOR」断言**本轮转绿**(模块侧已把 GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE 前缀拼进游标错误文案,映射分支可达) · 实测:71 项 71 PASS / 0 FAIL / 0 SKIP - scripts/check-game-distribution-collection-e2e.mjs:修正随主题功能上线的**过期断言**—— 匿名作品详情现在比公开目录条目多一个 `themes` 键(详情侧增量,目录侧保持精简)。断言改为 「目录条目是详情子集:共有键取值全一致 + 详情只多 themes 一处」,不再要求两边键数相等。 实测:46 项 46 PASS / 0 FAIL / 0 SKIP用户看了真机截图判定首页那张内联卡片太丑,撤掉并改成独立一页。 - 删掉首页内联面板:`src/view/home/PlatformGameForkSection.tsx`(整文件)与首页里的渲染块、两个 专属 props(`onPlatformGameProjectCreated`)及其 import(该 prop 一路从 shell 传下来,一并清掉, 不留死代码);首页恢复成「最近项目 + 模板库推荐」。旧组件测试文件 `tests/platformGameFork.test.tsx` 删除,其中仍有价值的解析/分类用例抽成 `tests/platformGameForkModel.test.ts`(纯模型,不依赖组件)。 - 侧边栏新增「共创」:`LauncherView` 加 `co-creation`,`view/layout.tsx` 的 rail 加一个按钮 (icon 用仓库既有的 lucide-react,选 `GitFork` 对应「像 git fork」的口径;`aria-label`/`title` 都是「共创」,与既有 rail 项的悬停提示方式一致)。 - 新页 `src/view/co-creation/index.tsx`: · 数据源是平台公开目录(匿名可读):新增原生命令 `list_platform_game_catalog(limit, cursor)` (不带 Bearer——带鉴权头会把「匿名可读」变成依赖登录态;仍取会话只为拿 base URL;limit 与服务端同口径:缺省 20 / 上限 50,用 `url` 拼查询串避免手拼游标)。 · 只保留支持共创的:`forkAuthorization !== 'forbidden'`(字段缺省按 forbidden)。 · 排序:只按接口顺序(新 → 旧);推荐算法留 TODO,替换点在 `loadForkableGames` (UI 只消费它的返回值)。 · 翻页:最多 3 页 / 150 条,扫到 12 个可 Fork 作品就提前停;被上限截断时页头如实标注 「已检查最新 N 个作品(M 页,最多 3 页)」。理由写在常量注释里:目录是新→旧的、可 Fork 的作品可能稀疏,3 页足够填满一屏,同时把最坏请求数与耗时钉住。 · 卡片:与首页「最近项目」同一套视觉语言(同尺寸圆角卡、首字占位块用作品的 `coverColor`), 展示标题 / 作者 / 类型 / 更新时间,卡片内一个「Fork」按钮——**一步可达**,点击即走取件建项。 · 空态:扫完仍没有可 Fork 作品时给明确文案(而不是空白);加载态 / 失败态(含重试)照既有写法。 - 「填详细内容」随页搬走:原来的「输入作品 ID 或链接」输入框放到列表上方,解析仍用 `parsePlatformGameId`(与 deep link 完全同一份,没有第二套)。 - 提交路径只留一份实现:新增 `usePlatformFork` 状态机,卡片按钮与粘贴输入共用它 (取件、失败分类、加载态、进项目与埋点都是同一段代码;`busy` 用 ref 挡同一 tick 的重复提交)。 - deep link:`genarrative://fork?gameId=…` 现在切到「共创」页并预填输入框(仍**不自动下载**), 不再指向首页内联面板;正停在项目工作区时行为不变(不强行切走,请求留着)。 测试:新增 `tests/coCreation.test.tsx`(12 条:目录模型过滤/提前停/页数上限、列表渲染与过滤、 点卡片 Fork 的参数与「点击前不发请求」、粘贴链接同一解析、非法输入本地拦截、空态、失败态与重试、 深链预填且不自动下载、深链错误原因、取件失败分类);`tests/platformForkDeepLink.test.tsx` 收窄为 事件解析 + 订阅(预填行为改由共创页测试覆盖,不重复断言);`tests/appSurface/home.suite.ts` 增两条 外壳级回归:首页不再出现内联面板(标题与按钮都不在)、侧边栏「共创」可切换并落在共创页; Rust 增一条 `catalog_limit_clamps_to_server_range`。为「Fork 工程确认页」提供两件原生能力。 1. 真实阶段事件(不造百分比):ForkSyncStage 四类真实阶段 download/verify/extract/project (带稳定事件键与可读名),并在注释里写明与需求文档「源码/素材/场景/配置」四分类的关系—— 文档假设按资产种类分别同步,而客户端这条链路是整包取回(一次下载、一次校验、一次解压、 一次建项),没有按种类分开的可测量阶段,因此如实降级成四个真实阶段; create_project_from_platform_fork_at 加 on_stage 回调(4 处测试调用点补 no-op 闭包), 在解压前/生成工程前按真实发生顺序报阶段;命令在取件前/校验前发 download/verify,新增事件 game-fork-progress({ stage, label })。 2. read_public_game_detail(gameId):复用平台既有公开详情接口(匿名、不带 Bearer),给确认页的 父作品信息卡拿标题/作者/授权类型/封面对象键;顺手把匿名 GET 抽成 get_public_platform_json, 目录命令改走同一段(不再各写一遍请求与错误映射)。 3. 适配共享契约新增字段:创建版本请求补 change_summary: None(AGC 暂无收集入口,服务端把 None 当「未填写」并省略该键,与旧客户端行为一致)。 测试:阶段键与 serde 序列化一致 + 阶段名可读(新增);既有 Fork 用例与深链解析用例全绿。产品口径:「子代所有的都算父代的」,并要能溯源「多少是子代给的、多少是自己的」。本期**只做计算**, 不含资金/分成结算。 - 递归定义:`inherited(W) = Σ_{c∈直接子代} total(c)`,`total(W) = own(W) + inherited(W)`;每个后代在它的每个 祖先里只计一次。 - 接口:`GET /api/game-distribution/games/{game_id}/contribution`(Bearer + `no-store`)——**仅该作品作者**: 未登录/失效 401(中间件)、非作者 403(复用既有 owner-mismatch → FORBIDDEN,不新增第二套鉴权)、 作品不存在或根自身已软删 404。响应:`{ gameId, own, inherited, total, byGeneration[], directChildren[], nodeCount, truncated, truncatedReason }`;指标结构 `ContributionTotals{playCount}` 可扩展(将来加点赞/收藏/收入)。 - 不变量(测试钉住):`inherited == Σ byGeneration.total == Σ directChildren.total`、`total == own + inherited`、 `nodeCount == Σ byGeneration.gameCount`;`byGeneration` 只含后代(绝对代际,升序)。 - 上限与截断:节点 500 / 深度 32;超限如实标 `truncated: true` + `truncatedReason ∈ {node_limit, depth_limit}`, 已计入部分仍自洽(与族谱 `truncated` 同约定,不静默给半个数);遍历只用血缘表既有索引,**不新增表/索引**。 - 契约:shared-contracts 4 个 DTO + TS 镜像 + parity(+4 组 → 66 组);7 个生成绑定(含 procedure)。 - 文档:技术方案新增 `§3.11 贡献归集与权益归因`(定义/归因分解/接口/上限与截断/鉴权口径与理由)+ §3.4 路由行 + §5.4 实施证据;**任务 B**:反洗稿(相似度校验)从「待拍板」改为「**产品已决定搁置(2026-10-06)**」并从待拍板 清单移除,结算口径统一为「本期只做贡献归集与归因计算」。 - 测试:module-game-distribution **131 passed**(contribution 12 条,含 A→B→C 三层链路不变量);spacetime-module **304 passed / 1 ignored**(+3 结构断言);spacetime-client 43 passed;api-server game_distribution **107 passed**(+3)。 门禁:wasm build 0;`cargo check --all-targets` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema` 0(98 表);DTO parity 0(66 组 / 17 构建器 / 15 手拼类型);`check:project-bundle-policy-parity` OK; `check:encoding` 0(5543 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0。 注:本块未提交 `scripts/*.mjs`(`check-game-distribution-lineage-e2e.mjs` / `-theme-e2e.mjs` / `capture-game-lineage-visual.mjs`)——属其它 owner,工作区保留未提交。漏洞:继承是「创建时快照等值」,而 PUT 只限方向不限对象 ⇒ 作者可把继承来的 `nonCommercial` 再提成 `full`, 得到比祖先更宽的子作品,把父作品仅在非商用授权下公开的工程内容变成可商用。裁定:衍生作品档位是终态。 - **服务端**:`set_game_distribution_fork_authorization_tx` 在**幂等重放之后、CAS 之前**先查血缘行 (`game_distribution_lineage().game_id().find(...)`),衍生作品(有血缘行)**任何** PUT 都拒绝——含「传同值」 这种幂等重试,客户端不会遇到「有时成功有时失败」的随机性;裁决集中到新纯函数 `resolve_fork_authorization_promotion(has_lineage_row, current, expected, target)`(规则唯一实现,事务不再自己比 CAS / 不再出现 `can_promote_to`)。 - **新稳定错误码**:`FORK_AUTHORIZATION_INHERITED` → **409**;模块 `Display` 产出 `"{CODE}: 衍生作品的共创授权由 父作品继承,不能自行修改"`;api-server `FORK_` 映射登记;纳入 `fork_error_codes_are_reachable_from_module_messages` 枚举(状态码 + 稳定码),并保留「未登记码退化成 FORK_ERROR」的反证。 - **母版不变**:只升不降(可跳级)+ CAS + 幂等重放 + 未知档位 400 + 非 owner 403 全部照旧。 - **无 schema/契约形状变更**(无列/表/索引/DTO 新字段)。 - **测试**:新增 5 组衍生矩阵(同值 / 再提一级 / 终态再传同值 / 期望值过期 / 期望值与当前都不符 → 全 409 + 稳定码)、 母版提升与 CAS 语义回归、码前缀与可达性枚举扩展、以及「血缘行判定早于 CAS、事务不再自比 CAS」的结构断言。 **Rust 侧原本没有任何「衍生 PUT 成功」的断言可改**(已 grep 核对:该路径此前只有 401/400 信封用例),未删除或 弱化任何既有断言;断言旧语义的是 `scripts/check-game-distribution-lineage-e2e.mjs` 与 `scripts/capture-game-lineage-visual.mjs`(属其它 owner,本轮未改,已在报告里点名需其把衍生提升步骤改成「必被拒」)。 - **文档**:技术方案 §2.3(母版 vs 衍生两档 + 状态机只发生在母版行)、§2.6 时序图、§3.3 状态表、§3.4(PUT 行、 POST /games 行、lineage 行)、§3.9 新增「授权终态」补充与 §5.5 实施证据;并写明三件口径:**累计世代数由前端自算** (子树最大代际 − 本作品代际,根有 3 层后代则为 3;服务端不加字段)、**changeSummary 已在版本摘要 payload 上** (族谱面板只对选中节点查一次公开详情,不做 N+1、不给节点贴字段)、**衍生作品不会继承到 `forbidden`** (父为 forbidden 时不允许共创,根本产生不了衍生作品)。decision-log 追加 2026-10-06 决策条。 门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p module-game-distribution` **134 passed**; `cargo test -p spacetime-module` **305 passed / 1 ignored**;`cargo test -p spacetime-client` 43 passed; `cargo test -p api-server game_distribution` 107 passed;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema` 0(98 表);DTO parity 0(66 组 / 17 构建器 / 15 手拼类型);`check:encoding` 0(5544 files); `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0。View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.