# 环节零接缝契约(Stage Zero Seam Contract) GALFree v1 的**唯一测试接缝** = Host 侧项目服务(`src/service/project-service.ts` 的 `ProjectService`)。本文档定稿其接口清单、`.studio/` 布局、校验契约与事件/推送协议; 是后续所有环节票(T8+)的测试骨架。架构权衡不在此重复,见 `docs/adr/`。 - 方言子集语法清单:单独成文于 [dialect-subset.md](./dialect-subset.md)。 - 硬约束来源:ADR-0003/0004/0005/0006/0008/0009/0011(经 `AGENTS.md` 摘要)。 - **v3 生成线(2026-09-12 已批准,不再是草稿)**:ADR-0012「生成通道(图像/音乐/语音)」 与 ADR-0013「语音接线走对话 id + `config.auto_voice`」—— 它们**放宽了** v1 的边界 (本文件「音频接线」节那句"没有音乐生成、没有 TTS"、ADR-0005 环节 4/5); 票据见 #33–#39。**本文件里与此冲突的旧句子以这两份 ADR 为准。** - 一条与"界面换皮"有关的**已核实事实**(#38/#39 的分工据此):`game/gui/*.png` 不是随包静态资源, 而是 `launcher/game/gui7/` 按九宫格模板 + 参数**程序生成**的(基准 1280×720, `scale = min(w/1280, h/720)`)—— 所以游戏内主题应当**给生成器参数**,AI 出图只管 封面/主菜单背景/窗口图标。 ## 总则 1. **磁盘上的项目目录是唯一真相源**(ADR-0003)。服务不在内存里维护第二套项目状态; 所有状态对象都是"读盘 + 纯函数推导"的产物,可随时全量重算。 2. **项目文件的一切插件内写必须走写网关**(ADR-0004)。网关是快照/审计/校验的挂载点。 外部(VSCode/git)修改 = **观察**(fs.watch → 事件广播 → 客户端刷新),网关对版本 漂移做冲突拒绝,绝不覆盖外部内容。 - 实现注记:DSH `fs` 服务(`ctx.fs`)的 CAS 写只支持文本且面向会话执行世界; 项目是磁盘任意目录且需要二进制(图像)写与批原子性,故网关自带内容哈希版本戳 (sha256 前 16 位,`absent` = 不存在)。CAS 语义与 `fs.writeText(expected)` 对齐。 3. **进度是推导的,审读戳只能由人盖**(ADR-0008)。接缝上没有任何"设置进度/进度字段" 的写方法;`stampScene/stampSlot` 要求 `via:'human'`,agent 侧一律 `stamp-forbidden`。 戳记**内容指纹**:覆盖写(网关或外部)使指纹变化 → 推导为 `stale`(待复审), 历史记录保留。 4. **快照 = 写批提交后的本地 git commit**(ADR-0011),作者 `GALFree `, message 携带 `origin/stage/scene/slot + batchId`。永不 push、不改写用户手动提交。 5. **v1 项目只从模板新建**(ADR-0005)。模板见 `src/service/template.ts`。 ## 接口清单(接缝公共面) 构造:`createProjectService({ dataDir, validator?, playtest? })`。`projectRef` = 项目 id 或唯一 name。错误一律 `GalfreeError{code,message,details?}`(按 code 分支,不解析文本)。 ### 注册表与模板新建(T1) | 方法 | 语义 | 错误 code | |---|---|---| | `createProject({projectsRoot,name,title?})` | 模板 → `projectsRoot//`,经网关落盘 + git init + 初始快照;入注册表并**立即激活** | `invalid-name` `project-exists` `git-init-failed` | | `listProjects()` | `ProjectInfo[]`(含 `active/missing` 派生标志) | — | | `getProject(id)` / `getActiveProject()` / `setActive(id)` | 注册表 = 指针表(列表 + 单激活 id);内容不落注册表;`setActive` 为数据模型端口(切换 UI 后补,v1 不在路由/面板暴露) | `unknown-project` | `ProjectInfo = { id, name, title, root, createdAt, active, missing }`。 ### 写网关(T2) | 方法 | 语义 | 错误 code | |---|---|---| | `readProjectFile(ref, relPath)` | 现读磁盘 + 版本戳(内容哈希;缺失 = `ABSENT='absent'`,唯一哨兵,可直接回填 `expectVersion`) | `path-escape` | | `writeProjectFiles(ref, ops[], {origin,reason,scene?,slot?})` | **串行**原子批:每个 op **必须**带 `expectVersion`(CAS;新建传 `'absent'` 断言不存在,缺省 → `expect-required` 拒绝)。先全批校验(快速失败,通常无需回滚),再逐文件落盘;**落盘写前用刚读的旧内容做权威 CAS 复校**,关闭校验→写入之间的外部写 TOCTOU 缝隙;中途失败逐文件回滚 | `version-drift` `expect-required` `write-failed` | | `observeChanges(ref, listener)` | 订阅 `{path,version,kind:'internal'|'external'}` | — | | `writeLog(ref)` | 插桩:每条形如 `{path,batchId,version,reason,origin,at}`;"无旁路写"断言源 | — | | `gatewayErrors(ref)` | 非致命故障如实呈现:`snapshot-failed`(批已落盘但 git commit 失败)/ `rollback-failed` / `watch-failed` | — | 网关单例:每项目**恰好一个** `WriteGateway`(懒建竞态安全:promise 在 await 前同步 入表),保证"一项目一队列"的串行与单一写日志。 ### 快照(T3) | 方法 | 语义 | |---|---| | `snapshotHistory(ref, relPath)` | 单文件历史 `SnapshotEntry[]`(真 git;最新在前) | | `snapshotDiff(ref, relPath, from, to)` | 两版本 diff 文本 | | `snapshotRollback(ref, relPath, toCommit)` | `git show` 取旧内容 → **经网关写回** → 自动产生回滚快照;历史只追加 | ### 结构解析与校验回路(T4/T5) | 方法 | 语义 | |---|---| | `branchGraph(ref)` | 方言子集派生骨架 `{dialect,scenes,edges,problems,degraded}`;纯函数、幂等、可全量重算;场景含 `text`(原始文本块)与 `showing`(对白行画面的图像引用) | | `validateActiveProject()` | `ValidationReport = {ok, problems[], validator:'fake'|'sdk', at, sdkNote?}`。端口 `ValidatorPort = (ProjectInfo) => Promise`:缺省 = 假验证器(子集解析 + 结构规则:悬空跳转/重复 label/缺 start = error);生产装配注入**合成验证器** —— 假 lint 恒跑,钉版/覆盖 SDK 就绪时叠加真 `renpy lint` 并升级 `validator:'sdk'`,未就绪在 `sdkNote` 如实标注 | | (T5)`SdkProvisioner.ensure()` | 钉版 SDK 下载状态机 `idle→downloading(进度)→verifying(sha256,官方 checksums 钉死)→extracting→ready`;幂等、可 retry | | (T5)`probeOverrideSdk` | 覆盖路径探测:启动器存在 = 可用;版本 ≠ 钉版 → `mismatch` 警告进状态、**不阻塞**试玩/校验 | ### 推导进度与审读戳(T6) | 方法 | 语义 | |---|---| | `progress(ref)` | 纯推导快照(无时间戳、幂等):场景视图 `{label,readOnly,missingDialogue,dialogueCount,slots[],missingSlots[],stamp,stampable,stampableBlockedBy?,lintErrors,marks[]}` + `lint` + `playtest` + `summary` | | `stampRecords(ref)` | 历史戳(含失效者) | | `stampScene(ref,label,{via})` | 人盖场景戳(记场景内容指纹);`via!=='human'` → `stamp-forbidden` | | `stampSlot(ref,slot,{via})` | 人盖槽戳;未填 → `slot-not-filled` | **场景视图里的派生便利字段**(均为推导,UI 只渲染、不复述规则): - `marks[] = {code,severity,label,count?,detail?}` —— 舞台板一行要显示的"这一场怎么了", 由 `deriveSceneMarks()` 按 `error → warn → info` 排序给出。`label` **不带计数** (计数走 `count`),文案与严重度都由接缝定,agent 工具面与工作台读同一份。 code 取值:`lint-error` / `missing-slots` / `missing-dialogue` / `content-changed` / `read-only-degraded` / `settled`(已定稿)/ `clear`(暂时无毛病但未盖戳)。 - `slot.approvable` + `approvableBlockedBy` —— 这一槽**能不能**给人盖戳(与 `stampSlot` 的守卫同源:未填不可),UI 不再自己复述 `slot-not-filled` 这条规则。 - `scene.stampable` + `stampableBlockedBy` —— 只读降级的场景不可盖戳(先改回子集内)。 戳失效**判定是推导**:盖戳时存内容指纹(场景 = **原始文本块**哈希 —— 含被解析器 跳过的子集外内容,防止"加一段怪代码但戳还绿"的旁路;槽 = 素材文件哈希),推导时对比 当前指纹 → `approved/stale`。任何覆盖写(含外部编辑器)自动"清戳 → 待复审", 无需显式清除动作。指纹统一 `sha256 前 16 hex`(`hash.ts`,与网关版本戳、试玩 `contentFingerprint` 同口径)。 素材槽推导:`show/scene ` 引用即槽;id = `tag attrs…`(空格分隔); 约定路径 `game/images/.png`(`slotAssetPath`)。素材定义(`image x = …`) 可改路径映射(T8 账本)。 ### 试玩(T7) | 方法 | 语义 | |---|---| | `playtestStart(ref, from?, options?)` | 钉版 SDK 启动项目 → 退出回传 `{at,exitCode,technicalPass,traceback,fingerprint,from,timedOut,elapsedMs}`;事实经网关落 `.studio/playtest.json` → 进快照 | | `cancelPlaytest()` | 中止**正在跑**的那一次(杀掉游戏进程)→ `true`;没有在跑 → `false`(不假装杀掉了什么) | | `playtestRunning()` | 此刻有没有一次在跑(运行时事实,面板那颗「取消」的依据) | | 板上 `progress.playtest` | 推导:`pass/fail` + 内容再变 → `stale`;SDK 未就绪 → `sdk-not-ready` 如实报错 | | 板上 `progress.playtestRunning` | 同上那个运行时事实(板读的是同一份) | 技术通过 = 退出码 0 且日志无 traceback(`extractTraceback`);主观"玩过了、行" 由人盖场景戳表达(审读戳账本同套机制)。 ## `.studio/` 布局(契约骨架) ``` / # 标准 Ren'Py 项目(唯一真相源) .gitignore # logs/cache/saves/.studio/tmp 不入快照 game/ script.rpy # 方言子集模板入口(label start + menu + jump) options.rpy # config.name/version/save_directory images/ audio/ fonts/ tl/ # Ren'Py 搜索目录(.gitkeep 入快照) .studio/ project.json # {schemaVersion,id,name,title,dialect} characters.json # 角色登记簿骨架(T8 填充) slots.json # 素材槽制作信息账本(T8 填充;槽本身从 .rpy 派生) stamps.json # 审读戳账本 {schemaVersion,stamps:[{target,fingerprint,at}]} playtest.json # 试玩事实账本 {schemaVersion,last,history[]} bible/ # 设定集(T9) tmp/ # 临时区(不追踪) ``` 铁律(ADR-0009):`.studio/` **只放引用与制作信息,永不复制叙述内容**; 悬空引用(槽/戳指向不存在的 label、文件)= 校验错误,进推导板。 `target` 命名:`scene: