diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-29 10:05:58 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-29 10:05:58 +0800 |
| commit | 6c68927c7466dfa988a52933a29d487222cf3211 (patch) | |
| tree | 3c1e3351d832c3f8ea97d53de0ccc8ece6e49e8e | |
| parent | 8159519a81b3c6fd2a066623db1b660347963806 (diff) | |
| download | living-village-6c68927c7466dfa988a52933a29d487222cf3211.tar.gz | |
p71: 中文 API/模块说明(docs/api-reference)
- 新增 docs/api-reference/:README + 6 模块(Sim/地图生成/桌面渲染/交互交易/职业/存读档)
每节含 职责·对外 API 概览·单位与量纲·不变量·失败分支·算法与性能取舍
- 口径以当前源码为准;性能引用实测 p70 基准(不含未测宣称)
- P71ApiDocsTests(3 例,红先→绿):文件齐备/章节齐备/关键接口与单位锚点
- Desktop 341/341、Kernel 120/120;未改生产 .fs/.fsproj、未 push
| -rw-r--r-- | docs/api-reference/README.md | 41 | ||||
| -rw-r--r-- | docs/api-reference/interaction-and-trade.md | 60 | ||||
| -rw-r--r-- | docs/api-reference/map-and-mapgen.md | 64 | ||||
| -rw-r--r-- | docs/api-reference/occupation.md | 60 | ||||
| -rw-r--r-- | docs/api-reference/rendering-desktop.md | 65 | ||||
| -rw-r--r-- | docs/api-reference/sim-kernel.md | 82 | ||||
| -rw-r--r-- | docs/api-reference/world-save.md | 57 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P71ApiDocsTests.fs | 93 |
9 files changed, 523 insertions, 0 deletions
diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md new file mode 100644 index 0000000..26db279 --- /dev/null +++ b/docs/api-reference/README.md @@ -0,0 +1,41 @@ +# 中文 API / 模块说明(api-reference) + +本目录为《活着的村庄》玩家可见代码模块的系统性中文说明,面向维护者与验收者。 +每条口径以**当前源码**为准(F# 8 / .NET 8);文档只描述既有实现,不发明接口。 + +## 阅读约定 + +- **单位**:模拟时间以 `tick` 为基本单位,`1 tick = 1/60 秒`;`1 模拟日 = 86,400 秒 = 5,184,000 tick`。 + 位置以**像素**为单位(`Sim.tilePixels = 32` 像素/瓦片);地图以**瓦片**为单位。 +- **确定性**:世界推进只由 `World.Rng`(splitmix64 同源)与 `seed` 派生,禁止墙钟与 `System.Random`。 +- **性能**:只引用**实测**基准(`docs/evidence/p70-*.txt`),不外推、不宣称未测数字。 +- **交叉引用**:`docs/维护说明.md`(模块职责 / 状态不变量 / 已验证命令)、 + `docs/chinese-village-art-direction.md`(中文与美术方向)。 + +## 模块索引 + +| 文件 | 覆盖模块 | 关键接口 | +| --- | --- | --- | +| `sim-kernel.md` | `LivingVillage.Kernel/Sim.fs` | `Sim.step`、`World`、`chat`、`trade`、`chooseDialogueWith` | +| `map-and-mapgen.md` | `MapGen.fs`、`ProceduralMap.fs`、`MapSnapshot.fs` | `MapGen.generateWithSize`、`MapGen.serialize`、`MapSnapshot.renderPng` | +| `rendering-desktop.md` | `Game.fs`、`VillageArt.fs`、`CjkGlyphAtlas.fs` | `LivingVillageGame` 主循环、`VillageArt.drawWorld`、`CjkGlyphAtlas.sourceRectangle` | +| `interaction-and-trade.md` | `Interaction.fs`、`PlayerTrade.fs` | `M5Interaction.apply`、`M5View`、`PlayerTrade.buy/sell` | +| `occupation.md` | `Occupation.fs` | `Occupation.saveToken`、`Occupation.dailyTaskOfWith`、`Occupation.applySignal` | +| `world-save.md` | `WorldSave.fs` | `WorldSave.save/saveWith`、`WorldSave.loadFromFileWith` | + +## 统一小节口径 + +每个模块文件固定包含六节: + +1. **职责**:一句话说明该模块负责什么、不负责什么。 +2. **对外 API 概览**:公开类型与函数的真实签名/语义。 +3. **单位与量纲**:输入/输出的单位、取值范围。 +4. **不变量**:可验收的硬约束。 +5. **失败分支**:错误/拒绝/异常路径与处理方式。 +6. **算法与性能取舍**:算法要点与**实测**性能引用。 + +## 相关证据与基准 + +- Kernel 吞吐 / GC:`docs/evidence/p70-kernel-baseline.txt`(`--performance-baseline`)。 +- Desktop 帧率 / 每帧分配:`docs/evidence/p70-analysis.txt`、`docs/evidence/p70-frames-sample.txt`。 +- 复跑:`bash scripts/bench-p70.sh /tmp/opencode/lv-p70`。 diff --git a/docs/api-reference/interaction-and-trade.md b/docs/api-reference/interaction-and-trade.md new file mode 100644 index 0000000..bbc0687 --- /dev/null +++ b/docs/api-reference/interaction-and-trade.md @@ -0,0 +1,60 @@ +# 交互与交易(M5Interaction / PlayerTrade) + +源码:`src/LivingVillage.Desktop/Interaction.fs`、`src/LivingVillage.Desktop/PlayerTrade.fs`。 + +## 职责 + +把键盘输入翻译为 M5 面板/对话/交易命令,并维护玩家可见面板与状态文案; +`PlayerTrade` 把职业背包侧车变成世界里可买卖的物品(不改 `Sim.step` 数值路径)。 + +## 对外 API 概览 + +- `type M5Panel = WorldPanel | NeedsPanel | ObservationPanel | DialoguePanel | ChroniclePanel | TaskPanel`。 +- `type M5Command = Interact | Intent1..Intent6 | BuyFromTarget | SellToTarget | AcceptTask | + Observe | ToggleNeeds | ShowChronicle | ShowTaskPanel | ClosePanel`。 +- `type M5View = { Panel; Menu; Needs; Observations; Chronicle; Status; StatusTick; HomeMode; + Prompt; PromptTargetPos; Task: Occupation.State option; Seed: uint64 }`。 +- `M5Interaction.initial : M5View`;`M5Interaction.apply command world view : World * M5View`(public,纯函数); + `panelLines world view`、`statusText view`、`panelName view`、`titleText view`、 + `transientStatusLine nowTick view`、`cornerStatusLines world`、`refreshPrompt world view`。 +- `PlayerTrade.Outcome = { World; Occupation; Succeeded; UnitPrice: float32 option; + Reward: (ItemKind*int) option; Message: string }`。 +- `PlayerTrade.quoteBase / quote`、`PlayerTrade.buy state counterparty item quantity world`、 + `PlayerTrade.sell ...`、`PlayerTrade.buyFood`、`PlayerTrade.sellFirst`。 + +## 单位与量纲 + +- `Status: string` 为**内部 token**(如 `"dialogue target=..."`、`"task|..."`、`"inquiry|..."`), + 经 `statusLabel` 映射为中文;`StatusTick: int64 option` 为显示计时(`statusDisplayDurationTicks = 150L`)。 +- 交易金额/单价为 `float32`,与 `Sim.Needs.Money` 同尺度;数量 `int`(必须 > 0)。 +- 面板快捷键:`1..6` → 六种对话意图;`B` 买入食物×1、`V` 卖出背包首件×1;`Tab` 需求、`Q` 观察、 + `C` 年鉴、`T`/回车 任务面板、`Esc` 关闭;`E` 互动/进屋/出屋。文案见 `ChineseText.requiredUiLabels`。 + +## 不变量 + +1. **apply 纯函数**:返回新 `(World, M5View)`,不改动入参;世界交互仅在 `WorldPanel` 生效 + (`worldInputAllowed`)。 +2. **面板行来源唯一**:所有面板正文由 `panelLines` 生成;文案经 `CjkGlyphAtlas` 全可渲染。 +3. **无伪造状态**:面板只展示 `M5View`/`World` 中真实存在的值(需求、观察、年鉴、任务)。 +4. **交易不改数值路径**:`PlayerTrade` 只追加 `TradeEvent`/`TradeAnnal` 并写双方记忆与背包, + 不动 `Sim.step`/地图/seed;玩家以临时镜像 `NpcId -1` 仅用于报价。 +5. **背包口径**:`Occupation.State.Backpack` 为声明顺序稳定的 `(ItemKind*int) list`,归零移除、新物品追加尾部。 + +## 失败分支 + +- 命令不可用(如非对话面板发 `Intent1`):`applyAllowed` 返回 `(world, view)` 原样,不改变状态。 +- 对话失败 token:`dialogue rejected:<reason>` → 文案「对话失败:<reason>」; + 无可用对话 → 「当前没有可用对话」;无效选项 → 「对话选项无效」;附近无目标 → 「附近没有可互动目标」。 +- 交易失败:`PlayerTrade` 返回 `Succeeded = false` 且 `Message` 为中文原因—— + 「未选择职业」「找不到对方」「数量必须大于零」「库存不足」「无法报价」「对方资金不足」/ + 「背包里没有可卖物品」;对话交易侧失败名为「找不到目标 / 目标不在范围内 / 目标暂时无法互动 / + 找不到买家 / 找不到卖家 / 买家和卖家不能是同一人 / 数量必须大于零 / 库存不足 / 资金不足」。 +- 未知状态 token:`statusLabel` 兜底「状态已更新」。 + +## 算法与性能取舍 + +- `apply` 是纯状态转换:命令 → 校验(`commandAllowed`)→ 执行 → 生成 `Status` token; + 面板文本在帧内按需拼接,无额外缓存。 +- `PlayerTrade` 报价复用 `Sim.quotePrice`(先经 `Occupation.biasedQuote`;无职业恒等 1.0), + 通过「报价世界副本」把玩家镜像成 `Npc`,副本不写回真实世界——避免给 `Avatar` 引入 Inventory 字段。 +- 面板文案统一走既有 CJK 图集,不新增字体/外部素材。 diff --git a/docs/api-reference/map-and-mapgen.md b/docs/api-reference/map-and-mapgen.md new file mode 100644 index 0000000..5943967 --- /dev/null +++ b/docs/api-reference/map-and-mapgen.md @@ -0,0 +1,64 @@ +# 地图与生成(MapGen / ProceduralMap / MapSnapshot) + +源码:`src/LivingVillage.Desktop/MapGen.fs`、`src/LivingVillage.Desktop/ProceduralMap.fs`、 +`src/LivingVillage.Desktop/MapSnapshot.fs`。 + +## 职责 + +确定性生成可玩地图(河道 / 桥 / 石板路 / 民居 / 农田 / 主路),并把生成结果离线渲染为整图 PNG。 +`MapGen` 是默认可玩世界(256×192)与大图(512×384)的生成器;`ProceduralMap` 是遗留 +`LV_MAP_SCALE` 路径;`MapSnapshot` 只做纯 CPU 像素渲染,不参与游戏帧循环。 + +## 对外 API 概览 + +- `MapGen.defaultParams seed`:默认 64×48、`RiverCount = 2`、`RiverWidth = 3`、`CoreSide = 14`、 + `SpawnColumns = 5`、`SpawnRows = 6`。 +- `MapGen.paramsForSize width height seed`:尺寸自适应;`large = width > 256 || height > 192`。 + 大图追加 `ExtraRiversideHouses = 30`、`RiverDecorations = true`、`ClusterSeed = seed ^^^ 0xC1A57E2UL`, + 并关闭 `DecorativeStones`;非大图这些字段保持默认(不消费额外 RNG)。 +- `MapGen.generate params` / `MapGen.generateWithSize width height seed` → `MapGen.Result`。 +- `MapGen.serialize map`:确定性文本(同 seed 逐字节一致),用于回归/跨机核对。 +- `MapGen.tileAt map x y`、`MapGen.isWalkable map x y`、`MapGen.floodFill map x y`、 + `MapGen.visibleTileRange`、`MapGen.visibleTileCount`。 +- `ProceduralMap.generate`:遗留大世界地形(512×384 路径)。 +- `MapSnapshot.renderRgb map scale` / `MapSnapshot.renderPng map scale` / `MapSnapshot.save path map scale`; + `defaultScale = 8`。 + +## 单位与量纲 + +- 地图尺寸以**瓦片**计(`Result.Width` / `Result.Height`);内存瓦片码 `Tiles: int array`(行优先)。 +- `GroundTile`:`Grass=0 / Water=1 / Stone=2 / Peat=3 / PaddyField=4 / VegetablePlot=5`。 +- `River = { CenterY; Width }`(每列一条);`Building = { Left; Top; Width; Height; DoorX; DoorY }`(瓦片)。 +- `MainRoadCenter: int array`(长度 = 宽,逐列主路中心 y);`Paths: (int*int) list`、`Bridges`。 +- `MapSnapshot`:输出像素 = `Width*scale × Height*scale`;8-bit RGB PNG(无 alpha)。 + +## 不变量 + +1. **种子确定性**:同 `(width, height, seed)` 两次 `generateWithSize` 的 `Tiles`、 + `Rivers/Bridges/Paths/Buildings/Farmhouses/Groves/MainRoadCenter` 与 `serialize` 逐字节一致。 +2. **既有尺寸逐字节不变**:64×48 / 256×192 不进入大图分支、不消费额外 RNG(默认 stones 开启、 + decorations 关闭、ClusterSeed = 0)。 +3. **河道连续**:每条河每列恰好一段,宽度 ≥ `RiverWidth`;桥面仍标 `Water` 但 `isWalkable` 为真。 +4. **可达性**:`Result.ReachableTiles / ReachabilityOk / BridgeCrossingsOk` 由 `floodFill` 判定; + 民居/农舍门贴可达地面。 +5. **大图农田**:`PaddyField`/`VegetablePlot` 只在陆地上、避开路径与核心矩形。 +6. **渲染纯函数**:`MapSnapshot` 不依赖图形设备与时钟;同输入 PNG 逐字节一致。 + +## 失败分支 + +- `paramsForSize` 对任意正整数尺寸不抛异常(`large` 判定决定分支)。 +- `MapSnapshot.renderRgb/renderPng` 对越界瓦片写入静默跳过(`fillTile` 有 `tx/ty` 范围检查); + `scale` 经 `max 1` 保护;`save` 的 IO 异常由调用方处理。 +- `MapGen.isWalkable` 对越界坐标返回 `false`。 + +## 算法与性能取舍 + +- 生成原语:`splitmix64`(无外部依赖、无时钟)+ `valueNoise` 多八度 + 泊松布点; + 大图再加聚落组团(`ClusterSeed`)与主路缓弯(相邻列步长 ≤1)。 +- 渲染:`MapSnapshot` 用扁平色块 + 简化几何(屋顶矩形、门、主路/小径)而非纹理, + 目的是**离线、可复跑、无 GPU 依赖**;PNG 用 BCL `ZLibStream` 压缩。 +- 实测(`docs/evidence/p68-analysis.txt`,512×384 / seed=4242 / scale=8 → 4096×3072): + 河 388,608px、水田 742,080px、菜畦 824,128px、主路 32,768px、小径 34,112px、屋顶 19,328px 均 > 0; + 无 24×24 纯黑块 / 64×64 纯白块;`missing_glyph_slots = 0`;确定性 sha256 = `DD23CC2D…0FBF`。 +- 复跑:`LV_P68_MAPSHOT=1 LV_RECORD_DIR=<dir> dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll`, + 再 `python3 scripts/analyze-p68.py <dir> docs/evidence/p68-analysis.txt`。 diff --git a/docs/api-reference/occupation.md b/docs/api-reference/occupation.md new file mode 100644 index 0000000..071b337 --- /dev/null +++ b/docs/api-reference/occupation.md @@ -0,0 +1,60 @@ +# 职业系统(Occupation) + +源码:`src/LivingVillage.Kernel/Occupation.fs`(`module Occupation`)。 +设计依据:`docs/design/occupation-system-design.md`、`docs/design-professions.md`。 + +## 职责 + +玩家侧职业系统纯函数:职业档案、每日任务、任务完成判定与刷新、轻剧情线、背包侧车与存档 token。 +**不进入 `Sim.step` 数值路径**,也不给 NPC 加职业;职业状态由 Desktop `WorldBootstrap` 在开局注入。 + +## 对外 API 概览 + +- `type Kind = Farmer | Fisher | Peddler | Scholar`;`nameOf kind`(农夫/渔夫/货郎/书生)、 + `saveToken kind`(`farmer`/`fisher`/`peddler`/`scholar`)、`all`。 +- `profileOf kind : Profile = { Kind; InitialMoney; InitialEnergy; InitialInventory }`。 +- 每日任务:`taskPoolOf kind`、`taskNameOf template`、`dailyTaskOf seed occupationSeed dayIndex kind`、 + `dailyTaskOfWith targets seed occupationSeed dayIndex kind`(带定点)、 + `acceptTask`、`applySignal`、`expireAt`、`satisfiesCompletion`、 + `refreshState`、`refreshStateWith`、`refreshToday`、`refreshTodayWith`、`dayIndexOf tick`、 + `taskHash seed occupationSeed dayIndex`。 +- 定点:`type TaskTargets = { Npcs: Sim.NpcId list; Tiles: (int*int) list }`、 + `targetNpcOf`、`targetTileOf`。 +- 背包/报酬:`stateOf kind`、`backpackQuantity`、`grantItem`、`rewardOf template`、`itemNameOf item`。 +- 剧情线:`module Story`(`lineNameOf kind`、`stageNameOf stage` 等)。 +- `type State = { Profile; TaskToken: int; Today: DailyTask option; StoryStage: int; + Backpack: (Sim.ItemKind*int) list; Streak: int }`。 + +## 单位与量纲 + +- `dayIndex = tick / ticksPerDay`(模拟日);`OfferedTick = dayIndex * ticksPerDay`, + `DueTick = OfferedTick + ticksPerDay - 1`(当日有效)。 +- `TaskState = Offered | Active | Done | Failed`;`TaskToken` 为 v3 前缀占位扩展位(当前恒 0)。 +- `Backpack`:`(ItemKind*int) list`,数量 `int`;`Streak`:连续完成天数 `int >= 0`。 +- 随机源:`taskHash = splitmix(seed ^^^ occupationSeed, dayIndex+1)`,与 `Rng.fs` 同源,禁用 `System.Random`。 + +## 不变量 + +1. **无职业零影响**:`dailyTaskOf` / `refreshToday` / `refreshState`(空候选)与历史行为逐字节一致, + 目标恒 `None`;`Occupation` 不改变 `Sim` 状态结构。 +2. **状态机单向**:`acceptTask` 仅 `Offered → Active`;`applySignal` 仅 `Active` 命中才 `Done`; + `expireAt` 只把非终态在逾期后置 `Failed`;终态不可复活。 +3. **目标只影响读取它的模板**:`HelpWork` 读 `TargetNpc`、`TillSoil` 读 `TargetTile`; + 其余模板目标保持 `None`(不新增完成语义)。定点在候选集合内确定性选取。 +4. **候选注入**:Kernel 不感知地图/世界,候选由调用方(`WorldBootstrap.reachableTargets`)注入; + 空候选 → `None`(通配),不臆造目标。 +5. **序列化稳定**:`Backpack` 保持声明顺序;`Streak` 仅在 `> 0` 时写尾段(旧 v3 读回默认 0)。 + +## 失败分支 + +- 空任务池 / 空候选集合:`dailyTaskOf*` 返回 `None`、`targetNpcOf/targetTileOf` 返回 `None`(不抛异常)。 +- 未 `accept` 的任务不因信号完成;`Done/Failed` 终态对 `applySignal/expireAt/acceptTask` 幂等返回原值。 +- `grantItem` 对 `quantity <= 0` 原样返回;`backpackQuantity` 无物品返回 0。 +- `rewardOf` 仅两处有设计口径物品(夜捕 `Fish×2`、送粮 `Food×1`),其余返回 `None`(不发明奖励)。 + +## 算法与性能取舍 + +- 每日任务选择 = `taskHash seed occupationSeed dayIndex % pool.Length`;定点用同源另一段流 + (`targetNpcSalt` / `targetTileSalt` 异或后再 hash),候选先排序以消除输入顺序影响。 +- 全部为纯函数、无分配敏感热路径(不在 `Sim.step` 内),成本相对 `Sim.step` 可忽略。 +- 存档 token:kind 用 `saveToken` 小写英文;任务模板/状态/物品各有稳定 token(见 `world-save.md`)。 diff --git a/docs/api-reference/rendering-desktop.md b/docs/api-reference/rendering-desktop.md new file mode 100644 index 0000000..ca3a841 --- /dev/null +++ b/docs/api-reference/rendering-desktop.md @@ -0,0 +1,65 @@ +# 桌面渲染(Game / VillageArt / 字形图集) + +源码:`src/LivingVillage.Desktop/Game.fs`、`src/LivingVillage.Desktop/VillageArt.fs`、 +`src/LivingVillage.Desktop/CjkGlyphAtlas.fs`、`src/LivingVillage.Desktop/ChineseText.fs`、 +`src/LivingVillage.Desktop/HudLayout.fs`。 + +## 职责 + +MonoGame 渲染与主循环:把 `World` 画成江南水乡画面(地面/河道/建筑/人物/夜景光晕/室内), +并绘制 HUD、对话浮层与各类面板。不负责模拟推进(`Sim.step` 在 Kernel)、不负责地图生成算法。 + +## 对外 API 概览 + +- `LivingVillageGame`(`Game` 子类):`override Update(gameTime)` / `override Draw(gameTime)`。 + 默认 `IsFixedTimeStep = true`、`TargetElapsedTime = 1/60s`、垂直同步开启; + `LV_UNLOCK_FPS=1` 时放开两者以测渲染上限。 +- `VillageArt.loadTextures device` → `ArtTextures`;`VillageArt.drawWorld`、 + `VillageArt.drawMapViewport` / `drawMapFitted` / `drawMapRegion`、`VillageArt.drawCharacter`、 + `VillageArt.drawInterior` / `drawInteriorCharacter`、`VillageArt.drawNightGlows`、 + `VillageArt.drawCc0BoatOverlay`、`VillageArt.drawGroundShadow`。 +- 动画:`VillageArt.characterFrame isMoving tick`、`npcSpriteSpec*`、`avatarSpriteSpec`、 + `waterFrameTickAt tick x y`、`smokeFrameTick tick`。 +- 预乘与光晕:`VillageArt.premultiply r g b a`、`VillageArt.lanternGlowLayers`、 + `VillageArt.ellipseRuns`。 +- 中文字形:`CjkGlyphAtlas.columns = 16`、`cellPixels = 24`、`contains char`、 + `sourceRectangle char`、`load device`;文案清单 `ChineseText.requiredUiLabels`; + HUD 几何 `HudLayout.barHeight = 36`、`textScale = 2`。 + +## 单位与量纲 + +- 视口默认 1280×720;世界像素 = 瓦片 × `Sim.tilePixels`(32)。 +- 字形:每字一格 24×24 px,绘制时按 `PixelText` 的 `scale` 放大(HUD 常用 `scale = 2` → 每字 16px; + 图集源格固定 24px)。ASCII 走 `PixelText.glyphs` 位图,空格宽 `4*scale`,其余 ASCII 宽 `6*scale`。 +- 动画:字符格 32×48;`animationFrame = (tick/4) % 4`、`idleFrame = (tick/24) % 2`。 + +## 不变量 + +1. **预乘 alpha**:任何 `alpha < 255` 的前景绘制必须用 `VillageArt.premultiply`, + 否则预乘混合会把 rgb 直接加进帧缓冲导致纯白块(见 `docs/维护说明.md` §预乘 alpha)。 +2. **无缺字**:`PixelText.isRenderable` 要求每个字符 ∈ ASCII 位图 ∪ `CjkGlyphAtlas.contains`; + 不可渲染字符按设计**留空**,绝不画假字。 +3. **图集几何**:`CjkGlyphAtlas` 表保持有序稳定,`pixelWidth = 16*24 = 384`、 + `pixelHeight = rowCount*24`;`load` 校验尺寸否则 `invalidOp`。 +4. **帧选择纯函数**:动画/水波/炊烟只由 `tick` 与「是否移动」决定,同 tick 必得同帧。 +5. **绘制分层**:`drawWorld` 末尾单独绘制炊烟;夜间光晕只在真实灯笼实体处产生。 + +## 失败分支 + +- `CjkGlyphAtlas.load`:文件缺失 → `FileNotFoundException`;尺寸不符 → `invalidOp` 并释放纹理。 +- 文本:`PixelText.draw` 对不支持字符静默跳过(推进光标),不抛异常。 +- 纹理加载:`VillageArt.loadTexture` 校验期望宽高,资产缺失/尺寸不符会失败(构建期资产必在)。 + +## 算法与性能取舍 + +- 绘制分层(`Game.DrawWorldView`):地面 → 结构/民居 → 夜间光晕 → 水面波纹/船 → + 角色阴影与角色 → 黄金时刻暖幕 → 月光幕 → 面板/浮层(`DrawM5Overlay`)。 +- 只绘制可见瓦片(`isVisible` 视口裁剪);光晕用预乘同心圆 pixel run 近似径向衰减。 +- 帧循环结构:`Update`(键盘/菜单/证据钩子/`Sim` 推进)→ `Draw`(世界或菜单/证据帧); + 证据钩子(`LV_*_SHOT`)在 Playing 帧后落盘并退出。 +- 实测(`docs/evidence/p70-analysis.txt`,1280×720 / Xvfb 软件渲染 / 解锁 vsync): + 60 秒 8,788 帧、`measured_fps ≈ 139.3`;帧间隔 mean 7.179 / max 33.919 / p95 9.847 ms; + 掉帧 > 25ms = 4(0.05%)、> 33.3ms = 2(0.02%);每帧分配 mean ≈ 231 KB / p95 297 KB。 + 帧率随机器与软件渲染波动,只列本次实测,不外推。 +- 复跑:`P70_SECONDS=60 bash scripts/bench-p70.sh /tmp/opencode/lv-p70` + (逐帧日志经 `LV_FRAME_LOG=1`,仅日志、不改渲染逻辑)。 diff --git a/docs/api-reference/sim-kernel.md b/docs/api-reference/sim-kernel.md new file mode 100644 index 0000000..8fd4d34 --- /dev/null +++ b/docs/api-reference/sim-kernel.md @@ -0,0 +1,82 @@ +# Sim(F# 模拟核心) + +源码:`src/LivingVillage.Kernel/Sim.fs`(`module Sim`,命名空间 `LivingVillage.Kernel`)。 + +## 职责 + +纯确定性模拟核心:维护 `World` 状态、按固定 `tick` 推进(`Sim.step`)、做对话/交易/谣言/需求决策。 +不负责渲染、输入采集、存档文本(后者在 `WorldSave.fs`)、职业档案(后者在 `Occupation.fs`)。 + +## 对外 API 概览 + +- 时间与量纲:`ticksPerSecond = 60L`、`secondsPerDay = 86400L`、`ticksPerDay = 5_184_000L`、 + `dtSeconds = 1/60`、`dtSecondsF`。 +- 世界尺寸:`mapWidthTiles` / `mapHeightTiles`(默认 64×48)、`configureBounds width height`、 + `tilePixels = 32`、`avatarSpeed = 160.0f`、`npcSpeed = 80.0f`、`arriveEpsilon = 2.0f`。 +- 构造:`initialWorld seed`、`initialWorldN seed count`(NPC 网格 6 列、间距 64px)。 +- 主推进:`step (ts: TimeStep) (world: World) : World`;`TimeStep = { Input: Input }`, + `Input = { MoveX; MoveY }`(分量建议在 `[-1, 1]`)。 +- 对话:`openDialogueMenu world`、`responseFor intent personality`、 + `chooseDialogueWith respond target intent world`、`chooseDialogue target intent world` + (后者 = `chooseDialogueWith responseFor`,历史入口)。 +- 聊天/谣言:`chat { Narrator; Receiver } world`、`rumorStrengthAt now rumor`、 + `trimRumors now rumors`、`rumorWorkingSetStats rumors`、`rumorPath world target`、 + `rumorTraceText world`、`findChatPartner self pos npcs`、`chatableForChat npc`。 +- 交易/定价:`quotePrice request world`、`trade request world`。 +- 观察/年鉴:`observeVisible bounds world`、`annalText world`、`appendAnnal entry world`、 + `needsPanel world`、`relationMatrix world`、`relationCounts matrix`。 +- 常量:`dialogueOptions = [SmallTalk; AskHelp; OfferTrade; Joke; Apologize; Provoke]`、 + `chatRangePx = 96.0f`、`chatTicks = 300L`、`rumorCapacity = 16384`、`rumorRetentionDays = 3L`、 + `rumorFreshnessTicks = 3 * ticksPerDay`、`rumorMinimumStrength = 0.125f`、 + `relationHalfLifeTicks = ticksPerDay`、`playerId = NpcId -1`、`maxAnnalEntries = 128`。 + +## 单位与量纲 + +- `Tick: int64`(模拟 tick,从 0 起单调 +1);`Time: float`(= `tick * dtSeconds` 秒)。 +- `Vec2`:像素坐标,左上原点 `(0,0)`,右下 `(mapWidthTiles*32 - 32, mapHeightTiles*32 - 32)`。 +- `Needs`:四维均为 `float32`,语义取值域经 `needsClamp` 限制在 `[0, 100]`(Money 亦为 0..100 的口径)。 +- `Personality`:五维 `float32`,由 `personalityOfSeed` 生成,域 `[0, 1]`。 +- 价格 `unitPrice: float32`,货币单位与 `Needs.Money` 同尺度;`quotePrice` 结果被 clamp 到 `[0.01, 1000]`。 +- 谣言 `Strength: float32`(初始 1.0)、`Depth: int`(0 起)、`DayIndex = tick / ticksPerDay`。 + +## 不变量 + +1. **数组快照**:`World.Npcs` 在每个动作中要么原样引用、要么 `Array.copy` 后写新数组; + `stepNpc` 读取 `oldNpcs` 同时写 `newNpcs`,保证同一 tick 内每个 NPC 看到一致快照。 +2. **确定性**:`step` 每 tick 只推进一步 `Rng.nextUInt64`(`rngOut` 当前被 `ignore`, + 仅推进状态);所有派生(性格、谣言、动画)都是 `seed`/`tick` 的纯函数。 +3. **谣言行序**:工作集 `World.Rumors` 约定**最新在前**、`Tick` 非递增、头节点 `Id` 最大; + `nextRumorId` 依赖该头不变量做 O(1) 取号;裁剪只丢尾部旧条目。 +4. **派生镜像**:`RumorCount` / `RumorOldestDay` 是列表的派生量,不参与存档序列化,读档由列表重建。 +5. **年鉴上限**:`Annals` 经 `appendAnnal` 裁剪到 `maxAnnalEntries = 128`。 +6. **记忆双配额**:`recordMemory` 容量 64,且交互类记忆(`Chatted`/`Dialogue`)优先保留。 +7. **边界 clamp**:Avatar 位置在 `step` 中 clamp 到地图内;`Needs` 经 `needsClamp`。 + +## 失败分支 + +- **对话**:`chooseDialogueWith` 返回 `DialogueResult`—— + `DialogueRejected(DialogueTargetNotFound, world)`(目标不在 `Npcs`)、 + `DialogueRejected(DialogueTargetOutOfRange, world)`(距离平方 > `chatRangeSq`)、 + `DialogueRejected(DialogueTargetUnavailable, world)`(目标在 `Sleep`/`Chat`); + 成功为 `DialogueSucceeded(outcome, world)`。 +- **聊天**:`chat` 返回 `ChatResult`——`ChatRejected(NarratorNotFound | ReceiverNotFound, world)`、 + `ChatRejected(ChatSameParticipant, world)`、`ChatRejected(ParticipantNotChatable, world)`; + 重复谣言(同 narrator/receiver/parent 且新鲜可用)返回 `ChatSucceeded(None, world)`(只写记忆,不新增谣言)。 +- **交易**:`trade` 返回 `TradeResult`——`TradeRejected(BuyerNotFound | SellerNotFound | SameParticipant | + InvalidQuantity | OutOfStock | InsufficientFunds, world)`;`quotePrice` 对非法参与者/同人返回 `None`。 +- **异常**:`configureBounds` 本身不校验;调用方不得在推进世界期间改边界(边界每 tick 读取)。 + 非法数值不会抛出,而是被 clamp。`WorldSave` 的解析错误在 `WorldSave` 侧处理。 + +## 算法与性能取舍 + +- NPC 决策:`scoreAction` 线性加权(需求缺口 × 性格因子),`decideAction` 取 `maxBy`; + 夜间 `Sleep` 乘 `nightSleepMultiplier = 3.0`。 +- 移动:`moveToward` 向量归一化 + `arriveEpsilon` 到达容差;无寻路,直线目标点。 +- 谣言扫描去装箱:`NpcId`/`RumorId` 是 `[<Struct>]` 单例 DU,直接用 `=` 会比较时装箱; + 热路径改用 `npcIdValue`/`rumorIdValue` 解构比较(语义等价、去掉逐条分配)。 +- 谣言裁剪:`appendRumor` 用派生镜像把「每次追加全表扫描」降为 O(1) 判定, + 仅在超容量/超保留窗口时 `trimRumors`(`takeWhile + truncate`)。 +- 实测(`docs/evidence/p70-kernel-baseline.txt`,seed=42 / npc=4 / warmup=120 / measure=6000 ×3): + `ticks_per_second` mean ≈ 466,679(min 278,557 / max 646,089);`allocated_bytes` ≈ 3,032,184; + GC `gen0/gen1/gen2 = 0/0/0`;`final_digest = 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`、`performance_determinism = PASS`。 +- 复跑:`bash scripts/bench-p70.sh`;护栏:Kernel.Tests `P70PerformanceBaselineTests` 钉住 digest。 diff --git a/docs/api-reference/world-save.md b/docs/api-reference/world-save.md new file mode 100644 index 0000000..a8f474f --- /dev/null +++ b/docs/api-reference/world-save.md @@ -0,0 +1,57 @@ +# 存读档(WorldSave) + +源码:`src/LivingVillage.Kernel/WorldSave.fs`(`module WorldSave`)。 + +## 职责 + +把 `Sim.World`(可选附 `Occupation.State`)序列化为稳定的 `|` 分隔文本并解析回来, +兼容 v1/v2/v3 三种版式;不负责文件路径策略与 UI(由 Desktop 调用方决定)。 + +## 对外 API 概览 + +- `save world : string` = `saveWith None world`(无职业,产 **v2**,与历史格式逐字节一致)。 +- `saveWith occupation world : string`:`Some state` → **v3**(含职业段与可选尾段)。 +- `load text : Result<World, string>`、`loadFromFile path : Result<World, string>`。 +- `loadFromFileWith path : Result<World * Occupation.State option, string>`(读 v3 职业伴随数据)。 +- `saveToFile path world` / `saveToFileWith occupation path world`(UTF-8 无 BOM)。 +- 内部 token 读写:`writeDailyTask`(`task-today` 段)、`writeBackpack`(`backpack` 段)、 + `writeAnnal`(年鉴,含 `story`)。 + +## 单位与量纲 + +- 版式头:`LV_WORLD_SAVE_V1`(无宽高,读回默认 64×48)、`LV_WORLD_SAVE_V2`(带 `width height`)、 + `LV_WORLD_SAVE_V3`(带职业段)。文本以 `|` 分隔。 +- v3 职业段:`occupation.kind`(`farmer|fisher|peddler|scholar` 之一,未知 → `None`)、 + `occupation.task`(int)、`occupation.stage`(int)。 +- 尾段(annals 之后,按需出现):`task-today`(模板/状态/`OfferedTick`/`DueTick`/ + `npc-none|npc-some (+id)`/`tile-none|tile-some (+x,y)`)、`backpack`(`count` + 逐项 + `item quantity`)、`streak`(仅 `> 0` 时)。 +- 任务模板 token:`deliver-grain/help-work/till-soil/night-catch/sell-fish/market-inquiry/ + buy-goods/resell/observe-notes/reason-debate`;状态 token:`offered/active/done/failed`。 + +## 不变量 + +1. **v2 逐字节稳定**:`save world` 的输出在相同 `World` 下不变;`saveWith None world` 与 `save world` + 完全一致。 +2. **无职业 = 旧行为**:不带职业时 v2 序列化不产生任何 v3 专有段。 +3. **v1 向后兼容**:读到 `LV_WORLD_SAVE_V1` 时回退地图边界 64×48。 +4. **派生量不入档**:`RumorCount` / `RumorOldestDay` 不写;读档用 `rumorWorkingSetStats` 重建。 +5. **尾段可缺省**:旧 v3 无 `task-today`/`backpack`/`streak` 时,读回 `Today = None`、 + `Backpack` 用 `profileOf` 默认值、`Streak = 0`。 +6. **完整消费**:解析后 `reader.Remaining = 0`,否则视为 `trailing save data`。 + +## 失败分支 + +- 头未知 → `Error "unsupported save format"`。 +- token 非法/缺字段/多余尾数据 → `SaveParseError`,归一为 `Error`。 +- 数值越界/格式错误 → 捕获 `FormatException` / `OverflowException` / `ArgumentException`, + 返回 `Error "invalid save: <message>"`;`null` 文本 → `Error "save text is null"`。 +- 文件读取失败 → `Error "could not read save: <message>"`(捕获 `IOException`)。 +- 背包数量 `< 0` → 解析失败(`invalid backpack[i].quantity`)。 + +## 算法与性能取舍 + +- 手写 token 序列化(`ResizeArray<string>` 拼接),无反射/无 JSON 依赖;浮点用 + `CultureInfo.InvariantCulture` 格式以保证跨机一致。 +- 版本化采用「头 + 可选段」而非新格式文件:读端按头分派,v2 路径零额外开销。 +- 存档不是热路径;解析失败一律以 `Result` 返回,不抛出到调用方。 diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index a404797..a736a83 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -42,6 +42,7 @@ <Compile Include="P67TaskTargetUiTests.fs" /> <Compile Include="P68MapSnapshotTests.fs" /> <Compile Include="P69CopyAuditTests.fs" /> + <Compile Include="P71ApiDocsTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P71ApiDocsTests.fs b/src/LivingVillage.Desktop.Tests/P71ApiDocsTests.fs new file mode 100644 index 0000000..8aa3c71 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P71ApiDocsTests.fs @@ -0,0 +1,93 @@ +namespace LivingVillage.Desktop.Tests + +open System.IO +open Microsoft.VisualStudio.TestTools.UnitTesting + +/// P71(中文 API/模块说明,docs-only):锁定 docs/api-reference/ 的存在、分节与关键口径, +/// 防止说明文档被误删或章节漂移。 +[<TestClass>] +type P71ApiDocsTests () = + + let repoRoot () : string = + let mutable dir = System.AppContext.BaseDirectory + let mutable found = None + while found.IsNone && not (System.String.IsNullOrEmpty dir) do + if File.Exists(Path.Combine(dir, "LivingVillage.sln")) then found <- Some dir + else + let parent = Path.GetDirectoryName dir + if parent = dir then dir <- "" else dir <- parent + match found with + | Some d -> d + | None -> failwith "找不到仓库根(LivingVillage.sln)" + + let apiDir () = Path.Combine(repoRoot (), "docs", "api-reference") + + let expectedFiles = + [ "README.md" + "sim-kernel.md" + "map-and-mapgen.md" + "rendering-desktop.md" + "interaction-and-trade.md" + "occupation.md" + "world-save.md" ] + + let requiredSections = + [ "## 职责" + "## 对外 API 概览" + "## 单位与量纲" + "## 不变量" + "## 失败分支" + "## 算法与性能取舍" ] + + let allDocs () : string = + expectedFiles + |> List.filter (fun name -> File.Exists(Path.Combine(apiDir (), name))) + |> List.map (fun name -> File.ReadAllText(Path.Combine(apiDir (), name))) + |> String.concat "\n" + + [<TestMethod>] + member _.ApiReferenceDirectoryHasAllModuleFiles () = + Assert.IsTrue(Directory.Exists(apiDir ()), sprintf "缺少目录 %s" (apiDir ())) + let missing = expectedFiles |> List.filter (fun name -> not (File.Exists(Path.Combine(apiDir (), name)))) + CollectionAssert.AreEqual( + [||], + missing |> List.toArray, + sprintf "docs/api-reference 缺少文件: %s" (String.concat ", " missing)) + + [<TestMethod>] + member _.EveryModuleSectionHasTheRequiredHeadings () = + let docs = allDocs () + let missing = requiredSections |> List.filter (fun section -> not (docs.Contains section)) + CollectionAssert.AreEqual( + [||], + missing |> List.toArray, + sprintf "api-reference 缺少规定章节: %s" (String.concat ", " missing)) + // 六个模块文件各一个「## 职责」,确保每个模块都有齐六个小节。 + let responsibilities = requiredSections.[0] + let count = + docs.Split('\n') + |> Array.filter (fun line -> line.Trim() = responsibilities) + |> Array.length + Assert.IsTrue(count >= 6, sprintf "「## 职责」出现 %d 次,应 >= 6(每模块一节)" count) + + [<TestMethod>] + member _.ApiReferencePinsKeyInterfacesAndUnits () = + let docs = allDocs () + let anchors = + [ "ticksPerDay" + "configureBounds" + "953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C" + "WorldSave.save" + "LV_WORLD_SAVE_V2" + "LV_WORLD_SAVE_V3" + "MapGen.paramsForSize" + "MapSnapshot.renderPng" + "CjkGlyphAtlas" + "M5Command" + "PlayerTrade.sell" + "Occupation.saveToken" ] + let missing = anchors |> List.filter (fun anchor -> not (docs.Contains anchor)) + CollectionAssert.AreEqual( + [||], + missing |> List.toArray, + sprintf "api-reference 未钉住关键接口/单位: %s" (String.concat ", " missing)) |
