summaryrefslogtreecommitdiff
path: root/docs/maintenance.md
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-29 00:34:50 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-29 00:34:50 +0800
commit06f011b85e9ef5df8004ee74f2d6842dc38829f4 (patch)
treee304a9dc5297963a4c1b09f46560e159c70e5cb9 /docs/maintenance.md
parent5d0f7bf2bf8997d9c4ca65ace3fe89a881dc790a (diff)
downloadliving-village-06f011b85e9ef5df8004ee74f2d6842dc38829f4.tar.gz
p56: 中文模块维护文档——src 各模块职责/数据结构/不变量/失败分支/单位口径 + 性能基准实测口径(吞吐/分配/GC + 40天长局曲线 + digest 953775FA 与 determinism 验证法),docs-only
Diffstat (limited to 'docs/maintenance.md')
-rw-r--r--docs/maintenance.md279
1 files changed, 279 insertions, 0 deletions
diff --git a/docs/maintenance.md b/docs/maintenance.md
new file mode 100644
index 0000000..3a6323c
--- /dev/null
+++ b/docs/maintenance.md
@@ -0,0 +1,279 @@
+# 模块维护说明(中文)
+
+本文件面向维护者,给出 `src/` 各模块的**职责、关键数据结构、不变量、失败分支、单位与口径**,
+并写死性能基准的**实测口径与数字**。历史批验流水(P16…P49 的逐单记录)在
+[`docs/维护说明.md`](维护说明.md);本文件按**模块**组织,是补充而非替代。
+
+> 适用范围:`LivingVillage.Kernel`(纯模拟)、`LivingVillage.Desktop`(渲染/交互/职业接线)、
+> `LivingVillage.Headless`(无头批处理与性能探针)。不含测试工程内部结构。
+
+---
+
+## 0. 全局单位与口径(先读)
+
+| 量 | 单位 / 取值 | 出处 |
+|---|---|---|
+| 模拟 tick | 1/60 秒(`ticksPerSecond = 60`) | `Sim.fs:246` |
+| `dtSeconds` | `1/60`(tick 对应秒) | `Sim.fs:249` |
+| 一个模拟日 | `ticksPerDay = 86,400 × 60 = 5,184,000` tick | `Sim.fs:248` |
+| 瓦片 | `tilePixels = 32` 像素/格 | `Sim.fs:263` |
+| 位置 | 像素(浮点 `Vec2`,世界坐标) | `Sim.fs` / `WorldBootstrap.fs` |
+| 速度 | 像素/秒(avatar 160、npc 80) | `Sim.fs:264,266` |
+| 需求 `Needs` | 0…100,`needsClamp` 每步夹取;每 tick 衰减 | `Sim.fs:398,308-311` |
+| 钱 `Money` | `float32`,交易与衰减在 `Avatar.Mind.Needs.Money` | `Sim.fs:1140`、`PlayerTrade.fs` |
+| 关系 | `Valence` 为 `float32`,按半衰期衰减;`|rel| > 0.5` 视为有关系 | `Sim.fs:295-296` |
+| 职业 | 玩家侧侧车,**不进 `World`**;`Kind = Farmer|Fisher|Peddler|Scholar` | `Occupation.fs` |
+| 存档 | v1/v2/v3 文本;**无职业 = v2**,与旧版逐字节一致 | `WorldSave.fs` |
+
+**群组不变量(任何改动都不得破坏)**:
+1. `Sim.step` 是纯函数:不改传入 `World`,返回新 `World`;数值路径**不读职业**。
+2. 每 tick 结束 `Events` 清空(不承载历史);历史集合全部**有上界**(见 §4.3)。
+3. 无职业世界 `WorldSave.save` 输出 `LV_WORLD_SAVE_V2`,与历史基线**逐字节一致**。
+4. `--performance-baseline` 三次 `final_digest` 恒为
+ `953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`(见 §4.2)。
+
+---
+
+## 1. Kernel(`src/LivingVillage.Kernel`)
+
+### 1.1 `Sim.fs` —— 世界与数值模拟
+
+- **职责**:定义世界结构、需求/关系/谣言/年鉴机制与 `Sim.step` 数值演化;是**唯一**的模拟真值来源。
+- **关键数据结构**:
+ - `World { Tick; Time; Rng; Avatar; NoHost; Npcs: Npc[]; Events; Rumors; RumorCount; RumorOldestDay; Annals }`(`Sim.fs:186`)。
+ `RumorCount`/`RumorOldestDay` 是**派生镜像**(不参与序列化,读档由列表重建),用于把裁剪判定从全表扫描降为 O(1)。
+ - `Avatar { Pos; Mind }`、`Npc { Id; Pos; Inventory: Map<ItemKind,int>; Mind }`、`Mind { Needs; Personality; Action; Target; ActionAge; EffectDone; HungerFlagged; Memory }`。
+ - `ItemKind = Food | Fish | Spice | Scroll`;`NpcId` 为 int,玩家 `playerId = NpcId -1`。
+ - `InteractionEvent`(`DialogueEvent`/`TradeEvent`…)、`RumorEvent`、`AnnalEntry`、`MemoryEvent`。
+- **常量口径**(`Sim.fs:246-311`):`chatRangePx = 96`(对话范围)、`chatTicks = 300`;
+ 衰减 `hunger/energy/social/money = 0.0012/0.0010/0.0008/0.0006` per tick;
+ 谣言 `rumorFreshnessTicks = 3 天`、`rumorHalfLifeTicks = 1 天`、`rumorMinimumStrength = 0.125`。
+- **上界**:`maxAnnalEntries = 128`(`appendAnnal` 头插 + `truncate`);`memoryCapacity = 64`/NPC;
+ `rumorCapacity = 16384`、`rumorRetentionDays = 3`。
+- **失败分支 / 边界**:地图 64×48 为默认,`configureBounds` 支持大图并按边界夹取 avatar/NPC
+ (`LargeMapBoundsClampAvatarAndNpcs` 已锁);世界长期运行保持有限、`Time` 跟随 `Tick`。
+- **算法取舍**:NPC 决策 `decideAction` 基于 `scoreAction(night, needs, personality)`,动作最短持续
+ `minActionTicks = 600`;关系用半衰期衰减而非全局重算;谣言裁剪用派生镜像计数,避免 O(n) 扫描。
+- **失败分支(注意)**:`Sim.step` 无 IO/异常路径;非法输入由调用方保证(测试覆盖纯函数性)。
+
+### 1.2 `WorldSave.fs` —— 存档与版本兼容
+
+- **职责**:`World` ↔ 文本的序列化/反序列化;承载职业 v3 侧车尾段。
+- **格式**:`LV_WORLD_SAVE_V2`(无职业,含 `mapWidthTiles|mapHeightTiles` 后进入世界体);
+ `LV_WORLD_SAVE_V3` 在 v2 的 bounds 后追加 `occupationKind|TaskToken|StoryStage`,世界体之后追加可选尾段。
+- **v3 尾段(按序)**:`task-today <模板><状态><offeredTick><dueTick><targetNpc><targetTile>`、
+ `backpack <n> (<item><qty>)…`、`streak <n>`(**仅 `Streak > 0` 时写**,故无 streak 的旧 v3 文本布局不变)。
+ 链式读取用 `reader.Remaining` 循环,`TokenReader` 仅 `Take`/`Remaining`。
+- **关键函数**:`saveWith occupation world`(None→v2)、`save = saveWith None`、`load`、
+ `loadFromFileWith`(返回 `World * Occupation.State option`)。
+- **不变量**:
+ - 无职业路径 `save` 与历史 v2 **逐字节一致**。
+ - 新写出的 v3 文本 round-trip **逐字节稳定**(`saveWith (loadFromFileWith …)` 文本相同)。
+- **失败分支**:`load null → Error`;未知/不支持的版本头 → `unsupported save format`;
+ 读到多余尾段 → `trailing save data`;背包数量 `< 0` → `invalid`;
+ `loadFromFileWith` 捕获 `IOException` → `could not read save`;格式/溢出/参数异常统一转 `Error`。
+- **旧档兼容**:v1/v2 读回 `occupation = None`(不吞档);v3 未知 kind → `None`;
+ v3 无 `streak`/无 `backpack` 段 → 默认 `0` / 由 `profileOf` 初始清单补齐。
+
+### 1.3 `Occupation.fs` —— 职业数据与纯规则
+
+- **职责**:职业档案、每日任务状态机、设计口径报酬、连续天数、剧情线、对话/报价偏置;全部纯函数。
+- **关键数据结构**:
+ - `Profile { Kind; InitialMoney; InitialEnergy; InitialInventory }`(农夫 60/100/Food×12 等,`Occupation.fs:39`)。
+ - `DailyTask { TemplateId; TargetNpc; TargetTile; OfferedTick; DueTick; State }`,`State = Offered|Active|Done|Failed`。
+ - `TaskSignal = Dialogued | Traded | Purchased | ArrivedAt | Observed | NightAtWater`。
+ - `State { Profile; TaskToken; Today; StoryStage; Backpack; Streak }`(玩家侧侧车,不进 `World`)。
+- **任务口径**:`dailyTaskOf` 用 `hash(seed ⊕ occupationSeedOf kind, dayIndex) mod |pool|`(与 `Rng` splitmix 同源,禁 `System.Random`);
+ `TargetNpc/TargetTile` 目前恒 `None`(通配,具体化留后续单)。
+- **完成与报酬**:`satisfiesCompletion` 按模板匹配信号;`applySignalToState` 仅在 `Active` 命中时置 `Done`、
+ `Streak+1`、并按 `rewardOf` 发放**设计文档写明的完成结果物品**:
+ `NightCatch → Fish×2`、`DeliverGrain → Food×1`,**其余模板不发**(禁发明金额/新物品)。
+- **连续天数**:`refreshState` 同日保留 `Streak`;跨日仅当上一日**紧邻且 `Done`** 才保留,漏完成(跳日)清零。
+- **失败分支**:`Offered` 未接受不结算;`Done/Failed` 终态不可复活;无职业/无当日任务原样返回;
+ 非玩家事件(NPC↔NPC)不产生任务信号(`signalOfInteraction` 只认 `playerId`)。
+- **报价偏置**:4 位定点整数基点,货郎 ±3%、书生 ±1%,钳 `[0.95,1.05]`;农夫/渔夫/无职业恒 1.0。
+- **剧情线**:`Story.onDialogue` 每次玩家对话至多推进一段(`StoryStage 0→1→2→3`),结局由关系均值分档
+ (≥0.3 热络 / [0,0.3) 平常 / <0 淡漠),纯函数、无随机。
+
+### 1.4 `Rng.fs` / `RumorBench.fs` / `SimulationControl.fs`
+
+- `Rng.fs`:splitmix64(`goldenGamma = 0x9E3779B97F4A7C15`);`nextUInt64`/`nextFloat32` 纯函数,
+ 有已知向量测试(`SplitMix64MatchesKnownVectors`)。
+- `RumorBench.fs`:谣言工作集基准的纯逻辑。
+- `SimulationControl.fs`:倍速/暂停控制(1x/2x/5x),`stepsPerFrameWithLegacy` 供 Game 取每帧步数。
+
+---
+
+## 2. Desktop(`src/LivingVillage.Desktop`)
+
+### 2.1 `Game.fs` —— 主循环与装配
+
+- **职责**:Game 主类:输入分发、世界推进、相机、绘制、存档、菜单/弹层、各 `LV_*` 证据钩子。
+- **关键状态**:`world`、`m5View`(交互视图)、`menu`、`simulationControl`、`lastAvatarTile`(环境信号基线)、
+ 各 `pNN` 证据钩子游标(`p37…p55`)。
+- **更新顺序**:菜单/弹层输入 → `m5Command` 选择 → 世界移动 `Sim.step` → `refreshOccupationToday`
+ (P55 走 `refreshState`)→ 折叠环境信号(`ArrivedAt` 跨瓦片 / `NightAtWater` 夜+邻水)→ `refreshPrompt`。
+- **不变量**:弹层开启时世界交互键被屏蔽(P44 输入隔离);职业选择不改变世界 seed;相机不越界。
+- **失败分支**:存档 F6/F7 失败走 `MenuState.showLoadError`,不崩溃;无附近交互 → `no nearby interaction`。
+- **证据钩子**:`LV_P36…P55_*` 环境变量触发无头截图/录制,**只读**、不改模拟语义。
+
+### 2.2 `TaskRuntime.fs` —— 任务与奖励接线(P54/P55)
+
+- **职责**:把 Kernel 任务状态机接到 Desktop 运行时:`accept`(Offered→Active)、
+ `advanceWithEvent`/`advanceWithSignal`(经 `advanceTaskWithReward`/`applySignalToState`,含物品奖励)、
+ `foldSignals`、`environmentSignals`、`completionText`。
+- **不变**:全部纯函数;完成反馈为短中文(如 `任务完成:夜捕 +鱼×2`)。
+- **失败分支**:无任务/终态/非命中信号 → 无变化、无反馈。
+
+### 2.3 `Interaction.fs` —— M5 交互视图与命令
+
+- **职责**:`M5View`/`M5Command`、面板(世界/任务/对话/观察/需求/年鉴)、`InteractionResolver`(就近交互解析)、
+ `statusLabel`/`statusText`(中文短反馈)、`refreshPrompt`、`panelLines`。
+- **命令**:`Interact`、`Intent1..6`、`BuyFromTarget`、`SellToTarget`、`AcceptTask`、`Observe`、`ToggleNeeds`、
+ `ShowChronicle`、`ShowTaskPanel`、`ClosePanel`;`commandAllowed` 按面板门控。
+- **不变量 / 失败分支**:`AcceptTask` 仅 `TaskPanel`;`Buy/Sell` 仅 `DialoguePanel`;
+ 无效选项 → `invalid dialogue option`;无菜单 → `no dialogue menu`;无目标 → `no nearby interaction`。
+- **单位口径**:状态字符串前缀决定 HUD 文案(`trade ok|`、`trade fail|`、`task|`…)。
+
+### 2.4 `PlayerTrade.fs` —— 玩家买卖通道(P52/P55)
+
+- **职责**:玩家买/卖经 `Sim.quotePrice` → `Occupation.biasedQuote`;钱写 `Avatar.Mind.Needs.Money`,
+ 物品写 `Occupation.State.Backpack`;追加 `TradeEvent`+`TradeAnnal` 与记忆;顺带推进当日任务。
+- **镜像技巧**:玩家不是 `Npcs` 成员,报价时把玩家侧临时镜像成 `Npc` 放进**报价世界副本**,不写回真实世界。
+- **关键类型**:`Outcome { World; Occupation; Succeeded; UnitPrice; Reward; Message }`。
+- **失败分支(中文短反馈)**:`未选择职业`、`找不到对方`、`数量必须大于零`、`库存不足`、`资金不足`、
+ `背包不足`、`对方资金不足`、`背包里没有可卖物品`。
+- **不变量**:只动 Decimal 侧车与钱,不动 `Sim.step`/地图/seed;背包归零移除、新物品追加尾部(稳定序列化)。
+
+### 2.5 `WorldBootstrap.fs` / `MenuState.fs`
+
+- `WorldBootstrap`:`occupationStateFor`(新档当日任务)、`refreshOccupationToday`(跨日,P55 走 `refreshState`)、
+ `appendIdentityAnnal`、`initialWorldWithPlacement/WithOccupation/InMap`。开局把 `InitialMoney/Energy` 写入 avatar。
+ 失败分支:`count` 越界 → `invalidArg`。
+- `MenuState`:菜单页(主菜单/设置/职业选择/暂停帮助)、`occupationOptions`(含「暂不选择」=`None`)、
+ `occupationLabel`、`StartNewGameWith`、导航与错误提示。
+
+### 2.6 渲染/美术模块(纯函数优先)
+
+| 模块 | 职责 | 关键不变量 |
+|---|---|---|
+| `VillageArt.fs` | 图集取景、NPC 视觉变体/交易/行走节奏与帧 | `npcVisualVariant`/`characterFrameFor` 等为 (id/tick) 纯函数 |
+| `MenuAmbience.fs` | P46 水面涟漪/灯笼呼吸/双层云影 | 帧计数纯函数,32 tick 边界连续、无随机 |
+| `SceneDetail.fs` | 河道倒影、岸边芦苇、漂浮元素、灯笼光源的**纯数据** | `isWater` 边界检查;同 (tick,map) 可复现 |
+| `SceneDetailRender.fs` | 把 `SceneDetail` 结果落成画面 | 只读,不改数据层 |
+| `M6Presentation.fs` | 昼夜时段/色温/tint/月光/灯笼光晕 | `profileAtTick`、`lightingAtTick` 纯函数 |
+| `HudLayout.fs` | 顶部窄条 HUD 布局/配色 + hotbar 输入门控 | 确定性布局,不整屏纯色/大黑块 |
+| `TitleScreen.fs` / `LaunchScreen.fs` | 标题页/启动画面几何与帧动画 | 帧计数纯函数,可跳过 |
+| `DialogOverlay.fs` | 对话/文字弹层模型(断行/分页/隔离) | 纯模型,绘制在 Game |
+| `CharacterArt.fs` / `FloaterArt.fs` | 原创点阵角色/漂浮元素图集生成 | cell 布局固定(见文件头),无外部素材 |
+| `ChineseText.fs` / `CjkGlyphAtlas.fs` | 中文文案与 CJK 图集覆盖 | `requiredUiLabels` 全覆盖,无缺字方框 |
+| `VillagePresentation.fs` | 原型场景几何、进门/出门状态 | `enterHome`/`exitHome` 返回 `Result`,失败不崩 |
+| `MapGen.fs` / `ProceduralMap.fs` | 确定性地图生成(512×384)与旧图激活 | 同 seed 逐字节一致;P48 digest 钉值 |
+| `PerformanceSummary.fs` | FPS 样本均值/最低/最高 | 纯函数,warmup 可剔除 |
+| `SampleScript.fs` | 自动演示脚本状态机 | 有超时上限,确定性 |
+
+---
+
+## 3. Headless(`src/LivingVillage.Headless`)
+
+### 3.1 `Program.fs`
+
+- **职责**:无头入口。命令:`--days/--seed/--npc`(+ `--dump-relations/--dump-rumors/--replay-rumors`)、
+ `--m5-smoke`、`--m6a-smoke`、`--batch K D`、`--performance-baseline`、`--cost-probe [D1,D2,…]`、
+ `--profile-long-run [days] [sampleEveryDays] [npcs] [seed]`、`--rumor-bench`。
+- **`--profile-long-run`**:逐模拟日推进,每 `sampleEveryDays` 天流式打印并 flush:
+ `GC.GetTotalMemory(true)`(GC live set)、`Process.WorkingSet64`(OS RSS)、
+ `Events/Rumors/Annals/Memory` 条目数与保守估算字节、首末点 world digest。
+ **只读**,不改模拟行为;退出码 0 当且仅当 `world.Tick = days × ticksPerDay`。
+- **估算模型**(仅数量级参考,ground truth 是 GC/RSS):`InteractionEvent≈48B`、`RumorEvent≈112B`、
+ `AnnalEntry≈96B + Summary×2`、`MemoryEvent≈72B`。
+
+### 3.2 `PerformanceProbe.fs`
+
+- **职责**:固定配置的吞吐/分配/GC 测量与快照 digest。
+- **配置**:`defaultConfiguration = { Seed=42; NpcCount=4; WarmupTicks=120; MeasureTicks=6000; Repetitions=3 }`,
+ 输入为固定八步循环(不依赖时钟/全局随机)。
+- **口径**:计时仅覆盖预热后的 `MeasureTicks` 次 `Sim.step`;分配量为测量线程
+ `GC.GetAllocatedBytesForCurrentThread` 增量;GC 次数为前后进程计数器差;需**单进程单测量线程**。
+- **digest**:`worldDigestOfText` 剥版本头与 bounds,把 v1/v2/v3 归一到单一规范体
+ `SHA256("LV_WORLD_SAVE_V1" + body)`;`WorldDigestOfText` 为公开包装。
+- **失败分支**:非法配置 → `performance_baseline=INVALID` 且返回 2;重复样本 digest 不一致 →
+ `performance_determinism=FAIL`、返回 1。
+
+---
+
+## 4. 性能基准(实测口径与数字)
+
+> 原则:**测量再宣称**。以下为本机 linux / .NET 8 的实测,非硬性门限;compare 时须连同运行时与配置记录。
+
+### 4.1 吞吐 / 分配 / GC(`--performance-baseline`)
+
+命令:`dotnet run -c Release --project src/LivingVillage.Headless -- --performance-baseline`
+
+配置:`seed=42 npc_count=4 warmup_ticks=120 measure_ticks=6000 repetitions=3`,8 方向循环输入。
+实测(单进程单线程,本机一次运行,数值随机器/负载浮动):
+
+- `elapsed_seconds ≈ 0.006–0.014`,`ticks_per_second ≈ 0.4M–1.0M`(噪声大,仅作量级)。
+- `allocated_bytes ≈ 3,032,104–3,032,344`(**≈3.03 MB / 6000 ticks**)——稳定。
+- `gen0 = gen1 = gen2 = 0`(测量区间无 GC)。
+- `final_tick = 6120`,`runtime = .NET 8.0.x`。
+
+### 4.2 digest 钉值与确定性验证法
+
+- 钉值:`final_digest = 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`。
+- 验证法:**连续运行 3 次** `--performance-baseline`,逐次比对 `performance_sample repetition=1/2/3` 的
+ `final_digest`;三次全等即输出 `performance_determinism=PASS`(PASS 由探针自动判定,非人工)。
+- 任何触碰 `Sim.step` 数值路径/`World` 结构/`Rng` 输入的改动都会改变该 digest → 视为**回归**;
+ 职业/背包/任务/streak 等侧车改动**不得**改变它(`WorldSave.save` 走 `None`,digest 不变)。
+- 单测级锁:`PerformanceTests.FixedSeedAndInputSequenceProduceTheSameWorldDigest`、
+ `MeasurementReportsConfiguredTicksAndRuntimeCounters`、`InvalidConfigurationReportsAUsefulFailure`。
+
+### 4.3 长局内存(`--profile-long-run`,P49)
+
+命令:`dotnet run -c Release --project src/LivingVillage.Headless -- --profile-long-run 40 4 30 42`
+(1 世界 × 40 天 = 207,360,000 tick;证据 `artifacts/perf-independent/20260928T134226/`)。
+
+40 天曲线(11 采样点,每 4 天):
+
+| day | GC live MB | WorkingSet MB | Events | Rumors | Annals | Mind.Memory |
+|---:|---:|---:|---:|---:|---:|---:|
+| 0 | 0.09 | 38.75 | 0 | 0 | 0 | 0 |
+| 4 | 1.43 | 62.19 | 0 | 7843 | 128 | 1920 |
+| 12 | 1.44 | 66.68 | 0 | 7727 | 128 | 1920 |
+| 24 | 1.40 | 68.26 | 0 | 7788 | 128 | 1920 |
+| 40 | 1.42 | 68.35 | 0 | 7892 | 128 | 1920 |
+
+**结论**:`Events` 恒 0(每 tick 清空);`Annals` 恒 128(capacity);`Mind.Memory` 恒 1920(64×30);
+`Rumors` 稳态 ~7.7k–8.0k(3 天 retention 绑定);GC live 0.09→~1.4 MB 后平稳、
+WorkingSet 38.75→~68 MB 后平稳(一次性预热,**无持续增长**)→ **无长局内存泄漏,按数据不修**。
+集合上界见 §1.1;多世界为独立实例,内存线性叠加,无跨世界共享增长态。
+
+### 4.4 数组快照不变 / 地图 digest
+
+- 世界快照不变:`DeterminismTests.StepIsPureAndDoesNotMutateItsInput`(`Sim.step` 不改传入世界)、
+ `SameSeedSameInputSequenceTracesAreTickByTickEqual`、`NpcDecisionTraceIsTickByTickDeterministic`。
+- 存档快照:`WorldSave` round-trip 文本稳定(`P55`/`P52`/`P51` 测试)。
+- 地图生成:`P48MapDigestTests.SameSeed512DigestIsStableAcrossRuns` / `Pinned512DigestLocksGeneratorOutput`
+ (同 seed 稳定、异 seed 发散、跨尺寸发散、512 钉值)。
+
+---
+
+## 5. 复现入口(维护者速查)
+
+```bash
+# 构建
+dotnet build LivingVillage.sln -c Release
+
+# 全量测试(先 build,再用 --no-build 复跑须一致)
+dotnet test src/LivingVillage.Kernel.Tests -c Release --no-build # 119/119
+dotnet test src/LivingVillage.Desktop.Tests -c Release --no-build # 282+(随切片增长)
+
+# 确定性钉值(三次 final_digest 全等 + performance_determinism=PASS)
+for i in 1 2 3; do dotnet run -c Release --project src/LivingVillage.Headless -- --performance-baseline; done
+
+# 长局内存曲线
+dotnet run -c Release --project src/LivingVillage.Headless -- --profile-long-run 40 4 30 42
+```
+
+证据钩子(只读截图/录制,如 `LV_P54_SHOT=1`、`LV_P55_SHOT=1`)与对应
+`scripts/analyze-pNN.py` 的用法见各切片提交与 `docs/evidence/`。