# 模块维护说明(中文) 本文件面向维护者,给出 `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; 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 <模板><状态>`、 `backpack ()…`、`streak `(**仅 `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/`。