diff options
| -rw-r--r-- | docs/维护说明.md | 55 | ||||
| -rw-r--r-- | src/LivingVillage.Headless/Program.fs | 26 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/RelationTests.fs | 4 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/RumorTests.fs | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/TradeTests.fs | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/RumorBench.fs | 109 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 73 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 3 |
9 files changed, 260 insertions, 15 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md index cc3a698..dbbf2ac 100644 --- a/docs/维护说明.md +++ b/docs/维护说明.md @@ -459,6 +459,57 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll 各出一格,相邻民居极近时挑檐可能视觉叠压,可在布点时加 1 格间隔;③ 巡游 40s 档的 `visible>=8` 占比仅 13%(P25 150s 档为 74%),属取证时长差异,非可见性回归。 +## P27:Rumor 长局性能修复(工作集扫描去装箱 + 裁剪判定) + +- **现状复核(更正 M3-1 前提)**:M3-1 记录的「`World.Rumors` 不裁剪、逐次聊天全表扫描」已由 M6a + (`d94ec2d` 工作集容量上界+按天淘汰、`cc850bf` 窗口化早停扫描)修复:`RumorEvent.DayIndex` 派生字段 + + `trimRumors` 把工作集按「最新在前 + 3 天保留窗口」收敛。实测单日成本已平稳(本机 `--cost-probe`: + 1 天 18.7s/18.9GB、5 天 90.5s/95.7GB、10 天 198.2s/191.9GB,≈19GB/天常数),M3 记录的 20 天 + ≈45 s/天的超线性项已消失。故 P27 不采用「砍 depth/strength 传播」这类会改变模拟语义的方案。 +- **剩余 Rumor 成本定位**:工作集有界后每次聊天仍有两条 O(N) 扫描 `latestRumorFor` / `rumorIsDuplicate`; + 且此前用 `=` 比较 `[<Struct>]` 的 `NpcId`/`RumorId`,会走泛型结构相等,对每一条被扫描到的谣言 + 装箱(实测 ≈24 B/条、单次聊天随 N 线性增长——`Sim.chat` 单独测得 N=16000 时 393,736 B/次); + 这是 Rumor 逐条分配的主因,而非列表 cons 本身。 +- **Kernel 长跑基准**:新增 `src/LivingVillage.Kernel/RumorBench.fs` 与 headless + `--rumor-bench [days npcs chats_per_day]`,把「聊天→生成/追溯谣言」路径从 `Sim.step` 的 NPC 快照 + 分配解耦,按模拟日推进 tick、固定节奏注入聊天,测工作集分配与耗时;同 `Config` 的 + `retained_id_checksum` 可证明确定性。 + 修复前(30 NPC,`--rumor-bench D 30 2000`): + + | days | chat_calls | rumors_retained | allocated_bytes | elapsed_ms | + |---|---|---|---|---| + | 3 | 6000 | 6000 | 930,716,456 | 1626.7 | + | 30 | 60000 | 8000 | 13,419,045,864 | 12182.5 | + | 60 | 120000 | 8000 | 27,297,668,608 | 23769.7 | + + 60 天工作集稳定在 8000(M6a 的 3 天保留窗口 + 2000/天),不再随年龄增长。 +- **修复策略(两条,均不改保留集合与查询结果)**: + 1. **扫描去装箱**:`latestRumorFor` / `rumorIsDuplicate` 原来用 `=` 比较 `[<Struct>]` 的 + `NpcId`/`RumorId`(含 `RumorId option`),走泛型结构相等、逐条装箱。改为先解构底层 + `int`/`int64` 再比较(`Parent` 用 `int64 option` 比较);语义完全等价,扫描不再逐条分配。 + 2. **裁剪判定 O(1)**:`World` 新增派生镜像 `RumorCount`/`RumorOldestDay`(不参与存档,读档由 + `Sim.rumorWorkingSetStats` 从列表重建)。`appendRumor` 用「`count+1 > capacity` 或 + `min(oldest, day) < cutoff`」O(1) 判定;旧 `consRumor` 每次追加都 `rumorsNeedTrim` 全表扫一遍。 + 判定条件与 `rumorsNeedTrim` 逐字等价,仅真需裁剪时才走 `trimRumors`,保留集合逐条相同。 +- **修复后基准(同机、同 `--rumor-bench D 30 2000`)**: + + | days | 修复前 alloc / ms | 修复后 alloc / ms | alloc 降幅 | + |---|---|---|---| + | 3 | 930,716,456 / 1626.7 | 482,716,192 / 1511.3 | −48% | + | 30 | 13,419,045,864 / 12182.5 | 4,884,067,640 / 8879.6 | −64% | + | 60 | 27,297,668,608 / 23769.7 | 9,774,660,360 / 17078.2 | −64% | + + 60 天分配 −64%、耗时 −28%;`retained_id_checksum=927964000`(3/30/60 天分别为 + 17997000 / 447996000 / 927964000)与修复前逐位一致,证明保留集合未变。 +- **等价性与门槛**:5 天 2000 聊天/天连跑两遍 `WorldSave.save` 逐字节相同; + `RumorCount`/`RumorOldestDay` 与对列表重算一致(count=8000、oldest_day=1)。 + `final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` + 三次一致、`performance_determinism=PASS`;Kernel 92 / Desktop 143 全绿。 +- **全模拟口径(`--cost-probe`)**:1 天 18.7s/18.8GB、5 天 90.4s/94.6GB,与修复前基本持平—— + 全模拟分配由逐 tick 的 `Npc[30]` 快照(`Sim.step` 的 `Array.copy`,≈120 B/NPC/tick)主导, + 谣言子系统占比很小;P27 的收益集中在 Rumor 工作集路径,符合「隔离测量」的基准口径。 + 逐 tick NPC 快照分配属另一个课题(会触及不可变快照不变量),留给后续单。 + ## 已验证命令 ```bash @@ -477,6 +528,10 @@ LV_BATCH_WORKERS=4 dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVi # 长局成本阶梯(只读,真实测量) dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --cost-probe 1,5,10 +# P27 Rumor 长跑基准(隔离谣言子系统;默认 30 NPC×60 天×2000 聊天/天) +dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --rumor-bench +dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --rumor-bench 3 30 2000 + # 素材再生成(确定性,输出 byte-identical) python3 scripts/make-jiangnan-art.py && python3 scripts/make-cc0-art.py diff --git a/src/LivingVillage.Headless/Program.fs b/src/LivingVillage.Headless/Program.fs index 89f4f26..7f5dcf5 100644 --- a/src/LivingVillage.Headless/Program.fs +++ b/src/LivingVillage.Headless/Program.fs @@ -518,6 +518,28 @@ let runCostProbe (ladder: int64 list) : int = Console.Out.Flush() 0 +/// `--rumor-bench [days] [npcs] [chats_per_day]`:Kernel 层 Rumor 长跑基准 +/// (默认 30 NPC × 60 模拟日 × 每日 2000 次聊天)。只读、不改模拟语义。 +let runRumorBench (argv: string[]) : int = + let tryInt64 (index: int) = + if argv.Length > index then + match Int64.TryParse argv.[index] with + | true, value -> Some value + | _ -> None + else None + let days = tryInt64 1 |> Option.defaultValue RumorBench.defaultConfig.Days + let npcs = tryInt64 2 |> Option.defaultValue (int64 RumorBench.defaultConfig.NpcCount) |> int + let chatsPerDay = tryInt64 3 |> Option.defaultValue (int64 RumorBench.defaultConfig.ChatsPerDay) |> int + let config = + { RumorBench.defaultConfig with + Days = days + NpcCount = npcs + ChatsPerDay = chatsPerDay } + let result = RumorBench.run config + printfn "%s" (RumorBench.format result) + Console.Out.Flush() + 0 + let runExisting (argv: string[]) = let rec parse (i: int) @@ -581,7 +603,7 @@ let runExisting (argv: string[]) = if stats.NonFinite > 0L || stats.OutOfBounds > 0L || not replayOk then 1 else 0 | Error msg -> eprintfn $"headless: {msg}" - eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S [--npc N] [--dump-relations] [--dump-rumors] [--replay-rumors] | --m5-smoke | --m6a-smoke | --batch K D | --performance-baseline | --cost-probe [D1,D2,...]" + eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S [--npc N] [--dump-relations] [--dump-rumors] [--replay-rumors] | --m5-smoke | --m6a-smoke | --batch K D | --performance-baseline | --cost-probe [D1,D2,...] | --rumor-bench [days npcs chats_per_day]" 2 let private parseCostLadder (argv: string[]) : int64 list = @@ -608,5 +630,7 @@ let main argv = LivingVillage.Headless.PerformanceProbe.runDefault () elif argv.Length >= 1 && argv.[0] = "--cost-probe" then runCostProbe (parseCostLadder argv) + elif argv.Length >= 1 && argv.[0] = "--rumor-bench" then + runRumorBench argv else runExisting argv diff --git a/src/LivingVillage.Kernel.Tests/RelationTests.fs b/src/LivingVillage.Kernel.Tests/RelationTests.fs index b98252d..1a6d178 100644 --- a/src/LivingVillage.Kernel.Tests/RelationTests.fs +++ b/src/LivingVillage.Kernel.Tests/RelationTests.fs @@ -47,6 +47,8 @@ module RelationHarness = Npcs = [| mkNpc 0 mem0; mkNpc 1 mem1 |] Events = [] Rumors = [] + RumorCount = 0 + RumorOldestDay = System.Int64.MaxValue Annals = [] } let chatted (tick: int64) (partner: int) : MemoryEvent = @@ -208,6 +210,8 @@ type RelationTests () = Npcs = [| self; nearest; alternate |] Events = [] Rumors = [] + RumorCount = 0 + RumorOldestDay = System.Int64.MaxValue Annals = [] } let next = Sim.step RelationHarness.zeroInput world match List.tryHead next.Npcs.[0].Mind.Memory with diff --git a/src/LivingVillage.Kernel.Tests/RumorTests.fs b/src/LivingVillage.Kernel.Tests/RumorTests.fs index 2cbb189..e8cf07a 100644 --- a/src/LivingVillage.Kernel.Tests/RumorTests.fs +++ b/src/LivingVillage.Kernel.Tests/RumorTests.fs @@ -52,6 +52,8 @@ module private RumorHarness = Npcs = Array.init count npc Events = [] Rumors = [] + RumorCount = 0 + RumorOldestDay = System.Int64.MaxValue Annals = [] } let readyForChat (world: World) : World = diff --git a/src/LivingVillage.Kernel.Tests/TradeTests.fs b/src/LivingVillage.Kernel.Tests/TradeTests.fs index 77e5658..fa40ac3 100644 --- a/src/LivingVillage.Kernel.Tests/TradeTests.fs +++ b/src/LivingVillage.Kernel.Tests/TradeTests.fs @@ -56,6 +56,8 @@ module private TradeHarness = npc 1 sellerMoney sellerHunger sellerFood sellerMemory None |] Events = [] Rumors = [] + RumorCount = 0 + RumorOldestDay = System.Int64.MaxValue Annals = [] } let request quantity = diff --git a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj index c6b3bf9..72e5def 100644 --- a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj +++ b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj @@ -7,6 +7,7 @@ <ItemGroup> <Compile Include="Rng.fs" /> <Compile Include="Sim.fs" /> + <Compile Include="RumorBench.fs" /> <Compile Include="Occupation.fs" /> <Compile Include="SimulationControl.fs" /> <Compile Include="WorldSave.fs" /> diff --git a/src/LivingVillage.Kernel/RumorBench.fs b/src/LivingVillage.Kernel/RumorBench.fs new file mode 100644 index 0000000..145f92c --- /dev/null +++ b/src/LivingVillage.Kernel/RumorBench.fs @@ -0,0 +1,109 @@ +namespace LivingVillage.Kernel + +open System +open System.Diagnostics +open LivingVillage.Kernel.Sim + +/// Kernel 层 Rumor 长跑基准:把「聊天 → 生成/追溯传闻」这条路径单独拎出来,按 +/// 模拟日推进 tick 并注入固定节奏的聊天,长期测量 Rumor 工作集随世界年龄的 +/// 分配与耗时量级(与 `Sim.step` 的 NPC 快照分配解耦,便于隔离谣言子系统)。 +/// +/// 该基准只读、不改变任何模拟语义;所有输入由 `Config` 决定,无墙钟/`System.Random`, +/// 同一 `Config` 两次运行产生相同的工作集校验和。 +module RumorBench = + + type Config = + { Seed: uint64 + NpcCount: int + Days: int64 + /// 每个模拟日注入的聊天次数(决定谣言产生速率)。 + ChatsPerDay: int } + + type Result = + { Seed: uint64 + NpcCount: int + Days: int64 + ChatsPerDay: int + ChatCalls: int64 + RumorsCreated: int64 + RumorsRetained: int + /// 保留工作集内各条目 Id 之和,用于证明同一 Config 的确定性。 + RetainedIdChecksum: int64 + AllocatedBytes: int64 + ElapsedMs: float } + + /// 任务默认口径:30 NPC × 60 模拟日 × 每日 2000 次聊天。 + let defaultConfig : Config = + { Seed = 42UL + NpcCount = 30 + Days = 60L + ChatsPerDay = 2000 } + + /// 测试/快速核验口径(同样是 30 NPC,但只跑 3 天)。 + let quickConfig : Config = + { defaultConfig with Days = 3L } + + let private resetParticipants (ids: NpcId list) (world: World) : World = + let npcs = Array.copy world.Npcs + for id in ids do + let (NpcId index) = id + if index >= 0 && index < npcs.Length then + npcs.[index] <- + { npcs.[index] with + Mind = { npcs.[index].Mind with Action = Wander } } + { world with Npcs = npcs } + + let private retainedChecksum (rumors: RumorEvent list) : int64 = + rumors |> List.sumBy (fun rumor -> let (RumorId id) = rumor.Id in id) + + let run (config: Config) : Result = + if config.NpcCount < 2 then invalidArg (nameof config) "NpcCount must be at least 2" + if config.Days <= 0L then invalidArg (nameof config) "Days must be positive" + if config.ChatsPerDay <= 0 then invalidArg (nameof config) "ChatsPerDay must be positive" + if config.ChatsPerDay > 1000000 then invalidArg (nameof config) "ChatsPerDay is too large" + let callsPerDay = int64 config.ChatsPerDay + let ticksPerCall = max 1L (ticksPerDay / callsPerDay) + let totalCalls = config.Days * callsPerDay + let mutable world = Sim.initialWorldN config.Seed config.NpcCount + let mutable created = 0L + let mutable calls = 0L + GC.Collect(2, GCCollectionMode.Forced, true, true) + GC.WaitForPendingFinalizers() + GC.Collect(2, GCCollectionMode.Forced, true, true) + let allocatedBefore = GC.GetAllocatedBytesForCurrentThread() + let stopwatch = Stopwatch.StartNew() + for i in 0L .. totalCalls - 1L do + let dayIndex = i / callsPerDay + let within = i % callsPerDay + let tick = dayIndex * ticksPerDay + within * ticksPerCall + let narratorIndex = int (i % int64 config.NpcCount) + let receiverIndex = + int ((i + 1L + (i / int64 config.NpcCount) % int64 (config.NpcCount - 1)) % int64 config.NpcCount) + let narrator = NpcId narratorIndex + let receiver = NpcId receiverIndex + world <- + resetParticipants [ narrator; receiver ] { world with Tick = tick } + match Sim.chat { Narrator = narrator; Receiver = receiver } world with + | ChatSucceeded (rumor, next) -> + world <- next + calls <- calls + 1L + if rumor.IsSome then created <- created + 1L + | ChatRejected _ -> () + stopwatch.Stop() + let allocatedAfter = GC.GetAllocatedBytesForCurrentThread() + { Seed = config.Seed + NpcCount = config.NpcCount + Days = config.Days + ChatsPerDay = config.ChatsPerDay + ChatCalls = calls + RumorsCreated = created + RumorsRetained = world.Rumors.Length + RetainedIdChecksum = retainedChecksum world.Rumors + AllocatedBytes = allocatedAfter - allocatedBefore + ElapsedMs = stopwatch.Elapsed.TotalMilliseconds } + + let format (result: Result) : string = + sprintf + "rumor_bench seed=%d npcs=%d days=%d chats_per_day=%d chat_calls=%d rumors_created=%d rumors_retained=%d retained_id_checksum=%d allocated_bytes=%d elapsed_ms=%.3f" + result.Seed result.NpcCount result.Days result.ChatsPerDay result.ChatCalls result.RumorsCreated + result.RumorsRetained result.RetainedIdChecksum result.AllocatedBytes result.ElapsedMs diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs index cedadfa..5c4e107 100644 --- a/src/LivingVillage.Kernel/Sim.fs +++ b/src/LivingVillage.Kernel/Sim.fs @@ -192,6 +192,12 @@ module Sim = Npcs: Npc[] Events: InteractionEvent list Rumors: RumorEvent list + /// 派生镜像:工作集条数(= Rumors.Length)。不参与存档序列化,读档由列表重建; + /// 用于把每追加一条都要全表扫描的裁剪判定降为 O(1)。 + RumorCount: int + /// 派生镜像:工作集内最小 DayIndex(空表为 Int64.MaxValue)。语义等价于 + /// `rumorsNeedTrim` 的「存在早于保留窗口的条目」判定。 + RumorOldestDay: int64 Annals: AnnalEntry list } type DialogueFailure = @@ -481,6 +487,8 @@ module Sim = Npcs = [| npc |] Events = [] Rumors = [] + RumorCount = 0 + RumorOldestDay = System.Int64.MaxValue Annals = [] } let initialWorldN (seed: uint64) (count: int) : World = @@ -680,19 +688,49 @@ module Sim = else rumors - /// 追加一条谣言并顺手裁剪工作集;容量内且新鲜时等价于 `rumor :: rumors`。 - let private consRumor (nowTick: int64) (rumor: RumorEvent) (rumors: RumorEvent list) : RumorEvent list = - trimRumors nowTick (rumor :: rumors) + /// 工作集派生统计:(条数, 最小 DayIndex;空表为 Int64.MaxValue)。仅裁剪/读档等非热路径调用。 + let rumorWorkingSetStats (rumors: RumorEvent list) : int * int64 = + rumors.Length, + (rumors |> List.fold (fun acc rumor -> min acc rumor.DayIndex) System.Int64.MaxValue) + + let private oldestRumorDay (rumors: RumorEvent list) : int64 = snd (rumorWorkingSetStats rumors) + + /// 追加一条谣言并维护派生元数据。容量内且新鲜时等价于 `rumor :: rumors`,且**不再全表扫描** + /// 判断是否需要裁剪(旧 `consRumor` 每次追加都会 `rumorsNeedTrim` 走一遍工作集); + /// 仅当条数超容量或存在早于保留窗口的条目时才走 `trimRumors`,保留集合与旧实现逐条相同。 + let private appendRumor (nowTick: int64) (rumor: RumorEvent) (world: World) : World = + let cutoffDay = max 0L (rumorDayIndex nowTick - rumorRetentionDays) + let nextCount = world.RumorCount + 1 + let nextOldest = min world.RumorOldestDay rumor.DayIndex + if nextCount > rumorCapacity || nextOldest < cutoffDay then + let trimmed = trimRumors nowTick (rumor :: world.Rumors) + { world with + Rumors = trimmed + RumorCount = trimmed.Length + RumorOldestDay = oldestRumorDay trimmed } + else + { world with + Rumors = rumor :: world.Rumors + RumorCount = nextCount + RumorOldestDay = nextOldest } + + /// `NpcId`/`RumorId` 是 `[<Struct>]` 单例判别联合:直接用 `=` 比较会走泛型结构相等并 + /// 在每次比较时为两侧装箱(实测 ~24 B/次)。在按 tick 全表扫描的热路径里逐条比较, + /// 这是 Rumor 分配的主要来源。这里改为先解构出底层 int/int64 再比较(语义完全等价, + /// 仅去掉装箱),扫描不再产生逐条分配。 + let inline private npcIdValue (NpcId id) : int = id + let inline private rumorIdValue (RumorId id) : int64 = id let private latestRumorFor (receiver: NpcId) (world: World) : RumorEvent option = // 工作集最新在前且 Tick 非递增:第一条命中的即“Tick 最大、Id 最大”,与原实现 // (filter 后 sortByDescending 取头)等价;窗口外无分配提前返回。 + let receiverId = npcIdValue receiver let rec loop (rest: RumorEvent list) : RumorEvent option = match rest with | [] -> None | rumor :: tail -> if world.Tick - rumor.Tick > rumorFreshnessTicks then None - elif rumor.Receiver = receiver + elif npcIdValue rumor.Receiver = receiverId && rumor.Tick <= world.Tick && rumorStrengthAt world.Tick rumor >= rumorMinimumStrength then Some rumor else loop tail @@ -783,11 +821,13 @@ module Sim = let avatarMemory = recordMemory world.Tick (Dialogue(target, intent, response)) world.Avatar.Mind.Memory let event = { Tick = world.Tick; Kind = DialogueEvent outcome } let updated = - { world with - Avatar = { world.Avatar with Mind = { world.Avatar.Mind with Memory = avatarMemory } } - Npcs = newNpcs - Events = world.Events @ [ event ] - Rumors = consRumor world.Tick rumor world.Rumors } + appendRumor + world.Tick + rumor + { world with + Avatar = { world.Avatar with Mind = { world.Avatar.Mind with Memory = avatarMemory } } + Npcs = newNpcs + Events = world.Events @ [ event ] } let withRumorAnnal = appendAnnal { Tick = world.Tick @@ -826,16 +866,21 @@ module Sim = RecentMemory = List.truncate 8 npc.Mind.Memory }) let private rumorIsDuplicate (request: ChatRequest) (parent: RumorEvent option) (world: World) : bool = - let parentId = parent |> Option.map (fun rumor -> rumor.Id) + let parentId = parent |> Option.map (fun rumor -> rumorIdValue rumor.Id) + let narratorId = npcIdValue request.Narrator + let receiverId = npcIdValue request.Receiver // 窗口外条目强度必 < rumorMinimumStrength,与原全表 exists 等价;命中或越窗即无分配返回。 + // `Parent` 用底层 int64 比较而非 `RumorId option` 结构相等,避免逐条装箱。 let rec loop (rest: RumorEvent list) : bool = match rest with | [] -> false | rumor :: tail -> if world.Tick - rumor.Tick > rumorFreshnessTicks then false - elif rumor.Narrator = request.Narrator - && rumor.Receiver = request.Receiver - && rumor.Parent = parentId + elif npcIdValue rumor.Narrator = narratorId + && npcIdValue rumor.Receiver = receiverId + && (match rumor.Parent with + | Some parentRumor -> parentId = Some(rumorIdValue parentRumor) + | None -> parentId.IsNone) && rumor.Tick <= world.Tick && rumorStrengthAt world.Tick rumor >= rumorMinimumStrength then true else loop tail @@ -936,7 +981,7 @@ module Sim = { npc with Mind = { npc.Mind with Memory = recordMemory world.Tick (Rumor rumor.Id) npc.Mind.Memory } } { chatted with Npcs = newNpcs } - let updated = { withReceiverRumor with Rumors = consRumor world.Tick rumor world.Rumors } + let updated = appendRumor world.Tick rumor withReceiverRumor ChatSucceeded( Some rumor, appendAnnal diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs index f1b0be3..b3bdfa9 100644 --- a/src/LivingVillage.Kernel/WorldSave.fs +++ b/src/LivingVillage.Kernel/WorldSave.fs @@ -645,6 +645,7 @@ module WorldSave = let events = List.init eventCount (fun index -> readInteraction reader (sprintf "event[%d]" index)) let rumorCount = readCount reader "rumors" let rumors = List.init rumorCount (fun index -> readRumor reader (sprintf "rumor[%d]" index)) + let rumorStats = rumorWorkingSetStats rumors let annalCount = readCount reader "annals" let annals = List.init annalCount (fun index -> readAnnal reader (sprintf "annal[%d]" index)) let todayTask = @@ -667,6 +668,8 @@ module WorldSave = Npcs = npcs Events = events Rumors = rumors + RumorCount = fst rumorStats + RumorOldestDay = snd rumorStats Annals = annals }, occupation) with |
