游戏共创(作品 Fork):共创授权与改编血缘、创作族谱、取件与改编闭环 #622
Reference in New Issue
Block 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?
一句话:陶泥儿「游戏共创」=作品可按作者授权被复制整个工程继续改造,并形成可展示、可追溯的世代链路。
1. 交付范围(按里程碑,逐条一句)
game_distribution_lineage血缘表、创建时六条失败关闭校验、上架时即可指定档位、衍生作品创建即继承父作品档位且为终态(再提升一律 409FORK_AUTHORIZATION_INHERITED)、公开/后台投影增量(forkAuthorization/forkCount/coCreationCount/lineage)、公开页作者署名修复(冻结值为空时读时联账号表)。.agent/fork-source.json来源记录、fork 后即可运行/发布、深链genarrative://fork?gameId=、Fork 工程确认页(3 秒强制阅读 + 真实阶段进度)。source:"project"优先、缓存键按资产区分。from=co-creation透传到详情/族谱/对比/游玩页并可返回backTo)。created_at倒序 +game_id兜底、末页 null、非法游标 400CATALOG_INVALID_CURSOR)+forkable=1只列支持共创的作品(事务内先过滤再切页)。coCreationCount(服务端):公开投影恒发,= 「公开可见的全部后代」数(不含自己;无后代为0);forkCount= 直接子代。作者侧同形响应不发该键 ⇒ 读取用coCreationCount ?? forkCount。total = own + Σ 直接子代 total,作者视角只读);Fork →「开始共创」词表统一。b1c89fbba及此前多次);master 侧成套退役(AI 资料建议/封面生成、exports 包上传)已按 master 口径采纳。2. 已知回归(必须看)
GameDistributionPublishFormView.tsx+useGameDistributionPublishForm.ts里;网页上架流程仍可配置档位,AGC 侧需按local://publish-reattach-plan.md的 30 条清单接到 master 新面板(tabs/taonier/FormCard+useTaonierExport)。changeSummary/includeProjectBundle/forkAuthorization均已在 AGC 侧 service 与后端可用,只缺 UI 落点。3. 数据与契约
game_distribution_lineage/_collection/_theme/_theme_member,后两者主键为确定性复合键);既有表新增 6 列(game.fork_authorization;version.project_bundle_object_key/_bytes/_sha256、change_summary)。FORK_*/THEME_*/CATALOG_*均由模块产出"{CODE}: 中文"、api-server 按前缀映射(不退化成通用BAD_REQUEST),并有「码由模块真实文案可达」的枚举测试兜底。4. 验证证据
共创演示·A0;A0 的forkCount = 3;带上forkable=1由 51 → 45(恰为 6 条forbidden);跨页 20/20/5 不重不漏、末页nextCursor: null。check --all-targets/ 三个 crate 测试 / dto parity / schema guard(98 表)/ typecheck / eslint / encoding / fmt / diff-check 全绿(AGC 侧编译见下文注)。5. CI 与已知限制
npm run check:native-shells:agc-rust-shard-1被build.rs随包资源校验挡住(stagedagc-godot-editor目录内容与当前 feature 组合不符;随包资源准备步骤命中缓存未重写)⇒ 该证据交由 CI 的 AGC 三条 job 承担,合并后立即盯它。AGC Rust lane 1/2、lane 2/2(llm_catalog)、AGC shell web tests;归因见评论 #6993。6. 文档索引
主规范
docs/【技术方案】游戏共创与作品Fork-2026-10-03.md(§2 授权 / §3.4 接口 / §3.10 共创主题 / §3.11 贡献归集 / §5 实施证据 / §7)+docs/project-memory/plans/里程碑 +docs/project-memory/shared-memory/decision-log.md+ 补接计划local://publish-reattach-plan.md。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。真实库 `GET /games/{A111}` 的血缘摘要里 `parentTitle` 有值而 `parentAuthorName` 为 `null`,详情页溯源条 「父作品 · 作者名」开天窗。根因核实:`game_distribution_lineage_snapshot` 直接用了发布时冻结的 `parent.author_name`,而老数据 / 程序化创建的作品上该列常为 `None`——公开投影与族谱节点早已改成**读时联 `user_account`**,只有血缘摘要没跟上(同一处语义没同步)。 - 血缘摘要改用 `game_distribution_lineage_author_name`(与族谱节点**同一实现**):账号显示名优先 → 账号不可得/昵称为空白时退回冻结作者名 → 两者都为空白则 `None`(不占位、不回退空串、不编造), 与公开详情同口径;父作品不可用时仍走原有的 `fork_lineage_visible_identity` 三态裁剪,不泄露已删作品身份。 - 新增结构回归测试 `lineage_summary_resolves_parent_author_name_at_read_time`:血缘快照必须调用账号兜底 函数,并**不得**再直接出现 `parent.author_name`(宿主起不了真库,行为证据留给真实栈复验)。 - parity 新增登记 `lineage_payload`(构建器 17→**18**):把「builder 发出的键 == DTO 字段」钉住,并要求 `parentAuthorName` **恒发该键**(值为 `null` 也要发——缺键与 `null` 在前端是两种渲染分支,不能用 optional 表达)。 - 文档 §3.4:写明读时兜底的口径与优先级;并明确**不新增 `rootAuthorName`**——已只读核实前端 `GameDetailPage` 只消费 `parentAuthorName`(`CoCreationLineageStrip` 用的是族谱节点的 `authorName`), 根侧只发 `rootTitle`,不加字段以免契约膨胀。 - **无字段形状变更**(Rust DTO 与 TS 均未增删字段,故无 TS/绑定改动)。 门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p spacetime-module` **306 passed / 1 ignored**; `cargo test -p module-game-distribution` 134 passed;`cargo test -p api-server game_distribution` 107 passed; DTO parity 0(66 组 / **18** 构建器 / 15 手拼类型);`check:spacetime-schema` 0(98 表); `check:encoding` 0(5546 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0; `git diff --check` 0。 未在真实库手动改任何数据(按要求);修完请另一个 session 用真实数据复验 `parentAuthorName`。真实库实证:51 条 published 时**最老的 3 条**(含最关键的 A0/A1/A11 母版与主干)在公开目录里不可见 (AGC 共创页显示「已检查最新 48 个作品(1 页)」;数据本身没丢)。根因:handler 写死 `limit: MAX_LIST_LIMIT(48)`(并忽略客户端 `limit`),事务过滤排序后 `truncate(limit)`,**既无 has_more 也无游标**。 - **页大小**:默认 **48**(保持既有 AGC 共创页首屏语义——48 只该是默认值而非硬上限)、上限 **100** (单响应体量);`0` 取默认、超界**截断**(不报错);api-server 与事务共用同一归一函数。 - **游标**:`"{created_at_micros}:{game_id}"`(复用仓库既有 `"{micros}:{id}"` 惯例,与主题列表同构); 排序键 `created_at` **倒序** + `game_id` **升序**兜底(与改前比较器逐字同序 ⇒ 首页顺序不变;全序 ⇒ 翻页不重不漏);**先过滤再切页**;「多取一条」判 has_more;`nextCursor` 为真值、**末页为 `null`**。 - **非法游标**:模块产出 `CATALOG_INVALID_CURSOR: 目录游标格式无效` → api-server 新增 `CATALOG_` 前缀分支 (**排在 owner 不匹配 / 不存在 / 状态 等子串分支之前**)→ **400 + 稳定码 `CATALOG_INVALID_CURSOR`**, 并纳入「码由模块真实文案可达」的枚举测试(防上次那种不可达码);未登记的 `CATALOG_` 码兜底 `CATALOG_ERROR`(400)。 - **响应形状不变**:仍是 `{ games, nextCursor }`(`nextCursor` 从恒 `null` 变成真值);抽命名构建器 `public_games_payload` 并登记 parity(构建器 18→**19**,`mustEmit: ['games','nextCursor']`)。 - **生成绑定**:`game_distribution_public_game_list_input_type.rs`(+`cursor`)、 `game_distribution_game_list_procedure_result_type.rs`(+`next_cursor`);client facade 改返回 `(games, next_cursor)`,mapper 同步。 - **文档**(同批):主规范 HTTP 表 `GET /games` 行改为真游标分页;创作者主页工程设计两处「最多 48 / 不分页」 改写(并注明前端尚未消费游标);`docs/README.md` 索引行同步。 - **测试 +15**:纯函数 9(**51 条能翻到最老那条**、恰好整页不产生空游标、同 `created_at` 用 `game_id` 兜底、 **先过滤再切页**、游标指向页内最后一条、limit 0/超界、非法游标 7 种);事务结构断言 1;api-server 5 (形状、`limit` 默认与截断、非法游标 400 + 稳定码、码可达性枚举、**51 条走真实 handler 链路翻页且 未公开/已软删/无公开版本三种不可见行不出现**)。 门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p module-game-distribution` **143 passed**; `-p spacetime-module` **307 passed / 1 ignored**;`-p spacetime-client` 44 passed;`-p api-server game_distribution` **112 passed**;DTO parity 0(66 组 / **19** 构建器 / 15 手拼类型);`check:spacetime-schema` 0(98 表); `check:encoding` 0(5548 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0; `git diff --check` 0。CI 判定与红点归因(最新尖端 9f13a3b45,run 3660)
1) 8 个 job:5 绿 / 3 红 —— 绿:
Repository checks、Backend tests、Frontend tests、Native shell tests、AGC Rust crates。即本 PR 自身面的门禁全绿:schema guard 按新 PR base 复跑(98 表)、DTO parity 66 组 / 19 构建器、全仓 eslint、encoding、两套 rustfmt、git diff --check均 0。2) 3 条红全在 AGC 侧,且均不由本 PR 引入:
AGC Rust lane 1/2(config::custom_llm_tests::custom_llm_discovery_reports_safe_errors_and_bounds_response,panic 于src/config.rs:5123)与AGC shell web tests—— 已证为 master 侧既有失败:master 尖端(8ceada869)自身的 push run 里这两个 job 同红。AGC Rust lane 2/2(llm_catalog::tests::catalog_missing_data_preserves_protocol_detail,panic 于apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs:380,panic 文案「模型列表响应缺少 data,请稍后重试」)—— 不是 flake,是 master 自己那次合并引入的确定性矛盾:git merge-base --is-ancestor 8ceada869 origin/master成立(8ceada869即当前 master 尖端);git diff origin/master...HEAD -- apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs为空,git log origin/master..HEAD -- 该文件为空(本 PR 从未碰过它);:380断言错误文案包含「服务端未返回模型目录」),但保留了 master 的通用文案实现(:56-58不拼接envelope.error.message)⇒ 该断言在任何执行顺序下都不可能通过;CARGO_TARGET_DIR)仍然失败 ⇒ 与执行顺序 / 分片成员 / 共享状态无关。(我们新增的 44 条 AGC 测试确实改变了rust-shards的分片成员,但对这条纯函数测例无影响——该假设已被证伪。)3) 修法与归属:一行级修法在 master 的新模块里(要么实现恢复拼接协议明细,要么改测例口径并同步改名)。本 PR 不改 master 代码;合规路径是 master 侧修好后 merge master 再复跑。本 PR 对该文件保持零改动。
4) 已知限制:本机
Gitea API 401(无 token),无法直接读 run 的head_sha,因此「master 那条绿 run 的 head_sha 是否等于8ceada869」未验证;但上面的merge-base+ 零 diff + 单测复现三条已足够支撑归因。