diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..440747c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto eol=lf + +*.ico binary +*.png binary +*.webp binary +*.wav binary +*.mp3 binary diff --git a/crates/nana-contracts/src/main.rs b/crates/nana-contracts/src/main.rs index 131e1db..4db4224 100644 --- a/crates/nana-contracts/src/main.rs +++ b/crates/nana-contracts/src/main.rs @@ -35,7 +35,10 @@ fn main() -> Result<(), Box> { if check { let mut stale = Vec::new(); for (path, expected) in outputs { - if fs::read(&path).ok().as_deref() != Some(expected.as_slice()) { + let matches = fs::read(&path).ok().is_some_and(|actual| { + normalize_line_endings(&actual) == normalize_line_endings(&expected) + }); + if !matches { stale.push(path); } } @@ -57,6 +60,23 @@ fn main() -> Result<(), Box> { Ok(()) } +fn normalize_line_endings(bytes: &[u8]) -> Vec { + let mut normalized = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') { + normalized.push(b'\n'); + index += 2; + } else { + normalized.push(bytes[index]); + index += 1; + } + } + + normalized +} + fn generated_outputs(root: &Path) -> Result> { let mut outputs = Vec::new(); let schema_dir = root.join("contracts/schema"); @@ -100,7 +120,10 @@ fn generated_outputs(root: &Path) -> Result( outputs.push((schema_dir.join(format!("{name}.schema.json")), bytes)); Ok(()) } + +#[cfg(test)] +mod tests { + use super::normalize_line_endings; + + #[test] + fn source_hash_input_is_independent_of_checkout_line_endings() { + assert_eq!( + normalize_line_endings(b"first\r\nsecond\nthird\r"), + b"first\nsecond\nthird\r" + ); + assert_eq!( + normalize_line_endings(b"first\nsecond\nthird\r"), + b"first\nsecond\nthird\r" + ); + } +} diff --git a/scripts/verify-contracts.mjs b/scripts/verify-contracts.mjs index f18ca07..47b2cc3 100644 --- a/scripts/verify-contracts.mjs +++ b/scripts/verify-contracts.mjs @@ -9,7 +9,8 @@ const schemaDirectory = resolve(root, "contracts/schema"); const source = await readFile(sourcePath); const expectedHash = (await readFile(hashPath, "utf8")).trim(); -const actualHash = createHash("sha256").update(source).digest("hex"); +const normalizedSource = source.toString("utf8").replaceAll("\r\n", "\n"); +const actualHash = createHash("sha256").update(normalizedSource).digest("hex"); if (actualHash !== expectedHash) { throw new Error( diff --git a/tasks/plan.md b/tasks/plan.md new file mode 100644 index 0000000..8b6e7ea --- /dev/null +++ b/tasks/plan.md @@ -0,0 +1,286 @@ +# Implementation Plan: Wave 7 本地 Windows 闭环 + +## Overview + +从 `integration/v1@23672e857bfa854930b0c8ab8aca316a95ba5d09` 继续开发。先关闭真实 +Windows 基线暴露出的可重复性问题,再补齐真实模型的当前分支连续上下文与超预算检查点, +随后实现“风险预检 → 玩家确认 → 隐藏检定失败 → 可推骰 / 可重新生成”的纵向切片。 +每个阶段都必须保持回合原子提交、分支隔离和 `PlayerView` 脱敏边界。 + +## Architecture Decisions + +- 继续使用 `integration/v1`,不在本地另起一套重建工程。 +- `lapp-rs` 保持相邻 path dependency,并固定到 `5ba3c659…`。 +- 契约源码哈希统一按 LF 规范化;同时用 `.gitattributes` 固定文本文件换行,避免 + Windows `core.autocrlf=true` 产生假漂移。 +- 模型上下文固定为“系统约束 → 稳定资源 → 当前分支检查点 / 原始历史 → 本轮动态尾部”, + 提升前缀缓存命中;兄弟分支内容不得进入上下文。 +- 压缩检查点是可丢弃缓存,不是事实来源;删除检查点不得改变 `RuntimeState`。 +- 风险提示是“不落节点的预检结果”,只有玩家确认后才进入现有隐藏判定与原子提交链。 +- `Regenerate` 复用原行动的判定记录;`PushCheck` 是带明确后果的新行动和新节点。 +- 在重新生成 / 推骰语义闭环前,不扩大到内容包导入、TTS、生图或第二完整角色。 + +## Dependency Graph + +```text +Windows 可重复契约门禁 + └─ 精确 Node/pnpm + Rust/MSVC 工具链 + └─ Wave 6 全量基线 + └─ 当前分支连续上下文 + └─ 超预算检查点压缩 + └─ 风险预检契约 + ├─ Runtime / Store 语义 + └─ Tauri 窄命令 + └─ Vue 确认、推骰、重新生成界面 + └─ Windows Demo + LAPP 冒烟 +``` + +## Task 1: 修复跨平台契约哈希 + +**Description:** 消除 Windows CRLF checkout 对契约源码哈希的影响,同时固定未来 checkout +的文本换行。 + +**Acceptance criteria:** + +- [ ] 干净 Windows checkout 上 `node scripts/verify-contracts.mjs` 通过。 +- [ ] Rust 生成器对 LF 与 CRLF 输入计算相同源码哈希。 +- [ ] Linux 既有 `.source.sha256` 不发生无意义变化。 + +**Verification:** + +- [ ] `node scripts/verify-contracts.mjs` +- [ ] `cargo test -p nana-contracts` +- [ ] `cargo run -p nana-contracts -- --check` +- [ ] `git diff --check` + +**Dependencies:** None + +**Files likely touched:** + +- `.gitattributes` +- `scripts/verify-contracts.mjs` +- `crates/nana-contracts/src/main.rs` + +**Estimated scope:** Medium + +## Task 2: 恢复锁定的本地工具链 + +**Description:** 让 Windows 开发机满足仓库已声明的精确依赖,不修改项目依赖版本。 + +**Acceptance criteria:** + +- [ ] Node.js 24+ 与 pnpm 10.29.2 可用。 +- [ ] Rust 1.96.0、rustfmt、clippy、MSVC Build Tools、Windows SDK 可用。 +- [ ] 相邻 `lapp-rs` 位于固定提交且工作树干净。 + +**Verification:** + +- [ ] `node --version` +- [ ] `pnpm --version` +- [ ] `rustc --version` +- [ ] `cargo clippy --version` +- [ ] `git -C ..\lapp-rs rev-parse HEAD` + +**Dependencies:** Task 1 可并行 + +**Files likely touched:** None(仅本机工具链与依赖目录) + +**Estimated scope:** Medium + +## Task 3: 关闭 Wave 6 Windows 机械门禁 + +**Description:** 在真实 Windows 目标上重新验证既有 119 项核心 Rust、18 项 Tauri、 +29 项 Web 测试和无 bundle 桌面编译。 + +**Acceptance criteria:** + +- [ ] 依赖安装使用锁文件且不修改锁文件。 +- [ ] `pnpm verify` 全绿。 +- [ ] `pnpm tauri build --no-bundle` 成功。 + +**Verification:** + +- [ ] `powershell.exe -NoProfile -File .\scripts\windows-smoke.ps1 -InstallDependencies` + +**Dependencies:** Tasks 1-2 + +**Files likely touched:** None + +**Estimated scope:** Small + +## Checkpoint: Windows 基线 + +- [ ] 工作树只包含已审阅的 Task 1 变更。 +- [ ] 契约、Web、Rust、Tauri 门禁均可重复。 +- [ ] 记录首个无法自动关闭的环境阻塞。 + +## Task 4: 恢复当前分支连续上下文 + +**Description:** Store 提供按 `parent_id` 追溯的祖先链;Runtime 将稳定资源、当前分支 +历史和本轮输入按固定顺序编译给 LAPP,解决真实模型第二轮起失忆的问题。 + +**Acceptance criteria:** + +- [ ] 第三轮模型输入包含根到当前节点的前两轮玩家输入和演出节拍。 +- [ ] 分叉后只包含本分支祖先,兄弟分支文本和隐藏状态不泄漏。 +- [ ] 系统约束、角色卡和 Persona 在连续回合中保持逐字节稳定,本轮输入位于尾部。 +- [ ] 精确骰点、目标值、状态 delta、NPC 隐藏物品和未触发世界书不进入上下文。 + +**Verification:** + +- [ ] Store 祖先链测试覆盖分叉共享祖先。 +- [ ] Runtime 捕获模型输入的三轮与兄弟分支隔离测试。 +- [ ] 既有 PlayerView / 上下文泄密 canary 通过。 + +**Dependencies:** Checkpoint: Windows 基线 + +**Files likely touched:** + +- `crates/nana-store/src/lib.rs` +- `crates/nana-runtime/src/context.rs` +- `crates/nana-runtime/src/lib.rs` +- `crates/nana-runtime/src/lapp_provider.rs` +- `src-tauri/src/lib.rs` + +**Estimated scope:** Medium + +## Task 5: 增加超预算分支检查点 + +**Description:** 根据模型上下文窗口预算保留“最近祖先检查点 + 检查点后原始节点 + 本轮 +输入”;超预算时使用同一 LAPP 模型压缩旧段,并把检查点作为当前节点的非权威缓存。 + +**Acceptance criteria:** + +- [ ] 500 节点分支恢复不扫描无关分支并保持在输入预算内。 +- [ ] 检查点只继承自当前祖先链,来源哈希不符时拒绝使用。 +- [ ] 删除检查点后 `RuntimeState`、分支头和 `PlayerView` 不变。 +- [ ] 压缩取消、超时或畸形输出不创建节点、不移动分支头。 + +**Verification:** + +- [ ] SQLite v2 → v3 无损迁移测试。 +- [ ] 预算边界、检查点继承、来源哈希和删除重建测试。 +- [ ] 压缩调用复用既有取消控制与原生单飞闸门。 + +**Dependencies:** Task 4 + +**Files likely touched:** + +- `crates/nana-domain/src/lib.rs` +- `crates/nana-store/src/lib.rs` +- `crates/nana-runtime/src/context.rs` +- `crates/nana-runtime/src/lapp_provider.rs` +- `src-tauri/src/lib.rs` + +**Estimated scope:** Medium(Core 与 Runtime 可在内部契约冻结后并行) + +## Checkpoint: 连续上下文 + +- [ ] 三轮真实上下文连续。 +- [ ] 兄弟分支隔离。 +- [ ] 稳定前缀可缓存。 +- [ ] 500 节点与超预算检查点测试通过。 + +## Task 6: 增加不落节点的风险预检契约 + +**Description:** 高风险玩家行动先返回脱敏风险描述和确认令牌,不调用隐藏判定、不推进 +状态、不创建故事节点。 + +**Acceptance criteria:** + +- [ ] 普通行动仍沿用现有单次提交路径。 +- [ ] 高风险行动首次提交只返回玩家可理解的风险提示。 +- [ ] 预检结果不包含骰点、目标值、NPC 隐藏信息或精确关系数值。 + +**Verification:** + +- [ ] Domain / Runtime 契约测试。 +- [ ] Tauri 命令测试证明预检不写 SQLite。 +- [ ] PlayerView 泄密 canary 继续通过。 + +**Dependencies:** Checkpoint: 连续上下文 + +**Files likely touched:** + +- `crates/nana-domain/src/lib.rs` +- `crates/nana-runtime/src/` +- `src-tauri/src/lib.rs` +- `contracts/` + +**Estimated scope:** Medium + +## Task 7: 完成确认、推骰与重新生成纵切 + +**Description:** 把现有 `Regenerate` / `PushCheck` 领域语义接到 Tauri 与 Vue,让玩家在失败 +后选择承担结果、推骰或重新生成叙述。 + +**Acceptance criteria:** + +- [ ] 确认风险后只产生一个完整节点。 +- [ ] `Regenerate` 复用原判定,不允许刷骰。 +- [ ] `PushCheck` 明示后果并创建新行动、新判定和新节点。 +- [ ] 回溯后两条线路的判定、物品、许诺和关系互不污染。 + +**Verification:** + +- [ ] Runtime / Store 端到端测试。 +- [ ] Tauri 命令测试。 +- [ ] Vue 交互测试覆盖确认、取消、推骰、重新生成。 +- [ ] 完整 `pnpm verify`。 + +**Dependencies:** Task 6 + +**Files likely touched:** + +- `crates/nana-runtime/src/adjudication.rs` +- `crates/nana-store/src/lib.rs` +- `src-tauri/src/lib.rs` +- `src/app/` +- `src/App.vue` + +**Estimated scope:** Medium(按契约、后端、前端三个小提交交付) + +## Checkpoint: 可玩纵切 + +- [ ] 风险提示先于隐藏检定。 +- [ ] 失败、推骰、重新生成的语义可被玩家区分。 +- [ ] 取消、超时和畸形响应仍不产生半轮。 +- [ ] Demo 与真实 LAPP 各完成一次隐藏判定。 + +## Task 8: Windows 桌面冒烟与交付 + +**Description:** 启动隔离存档的 Demo 窗口,验证两次重启、终局、回溯与双线路隔离;随后 +执行真实 LAPP 最小连接及隐藏判定。 + +**Acceptance criteria:** + +- [ ] Demo 窗口启动并正常退出。 +- [ ] 两次重启恢复、终局和双线路隔离通过。 +- [ ] 真实 LAPP 连接测试及一轮工具调用通过,日志无凭据。 + +**Verification:** + +- [ ] `scripts/windows-smoke.ps1 -Launch -Demo` +- [ ] `scripts/windows-smoke.ps1 -Launch` + +**Dependencies:** Task 7 + +**Files likely touched:** 仅状态报告;若冒烟发现缺陷则另建小任务。 + +**Estimated scope:** Medium + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|---|---|---| +| Gitea SSH 2222 被本机代理拦截 | 无法自动 fetch/push | 本地开发不依赖 fetch;提交前使用用户终端 SSH 或短期 HTTPS 凭据 | +| Rust/MSVC 未安装 | 无法关闭 Tauri 门禁 | 先完成纯文本跨平台修复;工具链作为显式环境任务 | +| 精确 pnpm 下载不稳定 | Web 验证延迟 | 保留锁文件;优先使用 Corepack 官方包并重试,不升级项目版本 | +| 祖先链错误按 branch_id 过滤 | 分叉后丢失共享历史 | 只按 parent_id 追溯并做兄弟分支隔离测试 | +| 压缩摘要被误当事实 | 删除缓存后剧情状态变化 | 检查点保持非权威,状态仍只由根状态 + delta 重建 | +| 风险预检变成第二套状态机 | 原子性回归 | 预检只产生短期确认数据,唯一 reducer / SQLite 提交路径不变 | +| 重新生成可刷骰 | 破坏公平性 | 判定绑定原 action/check;只有 PushCheck 创建新判定 | + +## Open Questions + +- 无产品阻塞问题。Rust/MSVC 安装若需要管理员交互,将在 Task 2 单独请求用户操作。 diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..fc6f028 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,59 @@ +# Wave 7 Checklist + +## 本地恢复 + +- [x] 从私有 Gitea 恢复 `integration/v1@23672e857b`。 +- [x] 确认 Git 对象完整且工作树干净。 +- [x] 临时只读 Token 已撤销,认证临时文件与 bundle 已删除。 +- [x] 克隆相邻 `lapp-rs` 并固定到 `5ba3c659…`。 +- [ ] 恢复可 fetch/push 的远端认证路径。 + +## Task 1:跨平台契约门禁 + +- [x] 添加文本换行约束。 +- [x] Node 契约检查按 LF 规范化源码。 +- [x] Rust 契约生成器按 LF 规范化源码。 +- [x] 增加 LF / CRLF 等价测试。 +- [x] Windows 上运行 Node 契约检查。 +- [ ] Rust 可用后运行生成器检查与测试。 + +## Task 2:本机工具链 + +- [x] Node.js 24+。 +- [x] WebView2。 +- [x] pnpm 10.29.2。 +- [x] Rust 1.96.0、rustfmt、clippy。 +- [ ] Microsoft C++ Build Tools 与 Windows SDK。 +- [x] 安装锁定的 JavaScript 依赖。 + +## Task 3:Wave 6 门禁 + +- [x] `pnpm verify:web`(25 份契约、29 项 Web 测试及生产构建通过) +- [ ] `pnpm verify:rust` +- [ ] `pnpm tauri build --no-bundle` + +## Task 4-5:连续上下文与检查点 + +- [ ] Store 按 parent_id 读取当前祖先链。 +- [ ] 第三轮包含前两轮原始剧情。 +- [ ] 兄弟分支上下文隔离。 +- [ ] 稳定前缀与动态尾部固定编排。 +- [ ] 超预算检查点与来源哈希。 +- [ ] SQLite v3 迁移与 500 节点测试。 + +## Task 6-7:风险 / 判定纵切 + +- [ ] 风险预检不落节点。 +- [ ] 玩家确认后进入隐藏判定。 +- [ ] 失败后提供推骰入口。 +- [ ] 重新生成复用原判定。 +- [ ] 推骰创建新行动与新判定。 +- [ ] Vue / Tauri / Runtime / Store 测试全绿。 + +## Task 8:桌面冒烟 + +- [ ] Demo 窗口。 +- [ ] 重启恢复。 +- [ ] 终局与双线路隔离。 +- [ ] 真实 LAPP 连接。 +- [ ] 真实隐藏判定工具调用。