From 4f8aca922e6cf47dce95aab79541065d487d5272 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Sun, 20 Sep 2026 12:18:38 +0800 Subject: feat(m4b): 增加可追踪谣言传播与回放 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [变更性质] - 本提交新增 M4b 谣言传播与确定性验收能力,不是单纯缺陷修复。 [新增功能] - 支持带来源、父节点、深度、强度和路径的谣言传播、去重、衰减与过期处理。 - Headless 支持稳定谣言 dump、replay,以及与 batch 模式的互斥校验。 [实现方案] - 通过不可变 World 更新和失败原子返回保持模拟确定性。 - 新增多跳传播、重复转述、过期父节点、非法参与者和稳定 trace 测试。 [影响范围] - 影响 Kernel、Headless 和 Kernel 测试;不包含 M5/M6 或无关配置与文档改动。 --- src/LivingVillage.Kernel.Tests/DeterminismTests.fs | 1 + .../LivingVillage.Kernel.Tests.fsproj | 1 + src/LivingVillage.Kernel.Tests/RelationTests.fs | 6 +- src/LivingVillage.Kernel.Tests/RumorTests.fs | 179 +++++++++++++++++++++ src/LivingVillage.Kernel.Tests/TradeTests.fs | 3 +- 5 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 src/LivingVillage.Kernel.Tests/RumorTests.fs (limited to 'src/LivingVillage.Kernel.Tests') diff --git a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs index c37be67..cc1944f 100644 --- a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs +++ b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs @@ -234,6 +234,7 @@ type DeterminismTests () = | Pay -> 0.2f | Hungry -> -0.4f | Chatted _ -> Sim.chatValence + | Rumor _ -> 0.1f | Bought _ -> 0.3f | Sold _ -> 0.3f if e.Valence <> expected then diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj index d805d73..f65200b 100644 --- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj +++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj @@ -8,6 +8,7 @@ + diff --git a/src/LivingVillage.Kernel.Tests/RelationTests.fs b/src/LivingVillage.Kernel.Tests/RelationTests.fs index dc433a7..20331ce 100644 --- a/src/LivingVillage.Kernel.Tests/RelationTests.fs +++ b/src/LivingVillage.Kernel.Tests/RelationTests.fs @@ -35,7 +35,8 @@ module RelationHarness = Avatar = { Pos = { X = 0.0f; Y = 0.0f } } NoHost = { Reserved = 0UL } Npcs = [| mkNpc 0 mem0; mkNpc 1 mem1 |] - Events = [] } + Events = [] + Rumors = [] } let chatted (tick: int64) (partner: int) : MemoryEvent = { Tick = tick; Kind = Chatted(NpcId partner); Valence = Sim.chatValence } @@ -184,7 +185,8 @@ type RelationTests () = Avatar = { Pos = plaza } NoHost = { Reserved = 0UL } Npcs = [| self; nearest; alternate |] - Events = [] } + Events = [] + Rumors = [] } let next = Sim.step RelationHarness.zeroInput world match List.tryHead next.Npcs.[0].Mind.Memory with | Some entry -> diff --git a/src/LivingVillage.Kernel.Tests/RumorTests.fs b/src/LivingVillage.Kernel.Tests/RumorTests.fs new file mode 100644 index 0000000..3c9b267 --- /dev/null +++ b/src/LivingVillage.Kernel.Tests/RumorTests.fs @@ -0,0 +1,179 @@ +namespace LivingVillage.Kernel.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +module private RumorHarness = + + let private needs = + { Hunger = 100.0f + Energy = 100.0f + Social = 100.0f + Money = 50.0f } + + let private personality = + { Drive = 0.5f + Aggression = 0.0f + Extraversion = 0.8f + Honesty = 0.5f + Greed = 0.5f } + + let private npc id = + { Id = NpcId id + Pos = { X = float32 (id * 10); Y = 0.0f } + Inventory = Map.empty + Mind = + { Needs = needs + Personality = personality + Action = Wander + Target = actionTarget Wander + ActionAge = minActionTicks + EffectDone = true + HungerFlagged = false + Memory = [] } } + + let world tick count = + { Tick = tick + Time = float tick * dtSeconds + Rng = Rng.ofSeed 11UL + Avatar = { Pos = { X = 0.0f; Y = 0.0f } } + NoHost = { Reserved = 0UL } + Npcs = Array.init count npc + Events = [] + Rumors = [] } + + let readyForChat (world: World) : World = + { world with + Npcs = + world.Npcs + |> Array.map (fun npc -> + { npc with + Mind = + { npc.Mind with + Action = Wander + Target = actionTarget Wander + ActionAge = minActionTicks + EffectDone = true } }) } + + let chat narrator receiver world = + match Sim.chat { Narrator = NpcId narrator; Receiver = NpcId receiver } world with + | ChatSucceeded (_, next) -> next + | ChatRejected (failure, _) -> Assert.Fail($"chat rejected: {failure}"); Unchecked.defaultof + + let rumorId (RumorId id) = id + + let orderedRumors (world: World) = + world.Rumors |> List.sortBy (fun rumor -> rumorId rumor.Id) + + let traceText (world: World) = + orderedRumors world + |> List.map (fun rumor -> + let parent = rumor.Parent |> Option.map rumorId |> Option.map string |> Option.defaultValue "-" + sprintf "%d|%d|%d|%d|%d|%s|%d" + (rumorId rumor.Id) + rumor.Tick + (match rumor.Source with NpcId id -> id) + (match rumor.Narrator with NpcId id -> id) + (match rumor.Receiver with NpcId id -> id) + parent + rumor.Depth) + |> String.concat "\n" + +[] +type RumorTests () = + + [] + member _.IdleChatsCreateThreeHopTraceableRumorPathWithinTwoDays () = + let initial = RumorHarness.world 0L 4 + let first = RumorHarness.chat 0 1 initial + let second = RumorHarness.chat 1 2 (RumorHarness.readyForChat { first with Tick = ticksPerDay }) + let third = RumorHarness.chat 2 3 (RumorHarness.readyForChat { second with Tick = 2L * ticksPerDay }) + let ordered = RumorHarness.orderedRumors third + + Assert.AreEqual(3, third.Rumors.Length) + Assert.AreEqual(3, ordered.Length) + let root = ordered.[0] + let retelling = ordered.[1] + let final = ordered.[2] + Assert.AreEqual(NpcId 0, root.Source) + Assert.AreEqual(NpcId 0, root.Narrator) + Assert.AreEqual(NpcId 1, root.Receiver) + Assert.IsTrue(root.Parent.IsNone) + Assert.AreEqual(0, root.Depth) + Assert.AreEqual(root.Id, retelling.Parent |> Option.get) + Assert.AreEqual(root.Source, retelling.Source) + Assert.AreEqual(NpcId 1, retelling.Narrator) + Assert.AreEqual(NpcId 2, retelling.Receiver) + Assert.AreEqual(1, retelling.Depth) + Assert.AreEqual(retelling.Id, final.Parent |> Option.get) + Assert.AreEqual(NpcId 2, final.Narrator) + Assert.AreEqual(NpcId 3, final.Receiver) + Assert.AreEqual(2, final.Depth) + + let path = Sim.rumorPath third final.Id + CollectionAssert.AreEqual([| root; retelling; final |], List.toArray path) + + [] + member _.DuplicateRetellingDoesNotCreateAnotherRumorNode () = + let first = RumorHarness.chat 0 1 (RumorHarness.world 0L 3) + let second = RumorHarness.chat 1 2 (RumorHarness.readyForChat first) + let duplicate = RumorHarness.chat 1 2 (RumorHarness.readyForChat second) + + Assert.AreEqual(2, second.Rumors.Length) + Assert.AreEqual(2, duplicate.Rumors.Length) + Assert.AreEqual(RumorHarness.traceText second, RumorHarness.traceText duplicate) + + [] + member _.StaleRumorIsNotUsedAsParentForAReTelling () = + let first = RumorHarness.chat 0 1 (RumorHarness.world 0L 3) + let stale = + first + |> RumorHarness.readyForChat + |> fun world -> { world with Tick = rumorFreshnessTicks + 1L } + let next = RumorHarness.chat 1 2 stale + let latest = RumorHarness.orderedRumors next |> List.last + + Assert.IsTrue(latest.Parent.IsNone, "stale memory must not be reused as a parent") + Assert.IsTrue(latest.Source = NpcId 1, "a stale rumor starts a new local source") + Assert.IsTrue(Sim.rumorStrengthAt next.Tick (RumorHarness.orderedRumors next |> List.head) < rumorMinimumStrength) + + [] + member _.InvalidParticipantsLeaveWorldUnchanged () = + let before = RumorHarness.world 10L 2 + let requests = + [ { Narrator = NpcId 9; Receiver = NpcId 1 } + { Narrator = NpcId 0; Receiver = NpcId 9 } + { Narrator = NpcId 0; Receiver = NpcId 0 } ] + + for request in requests do + match Sim.chat request before with + | ChatRejected (_, unchanged) -> Assert.AreEqual(before, unchanged) + | ChatSucceeded _ -> Assert.Fail($"expected invalid chat to be rejected: {request}") + + [] + member _.SameSeedAndChatOperationsProduceIdenticalRumorTrace () = + let run seed = + let initial = { RumorHarness.world 0L 4 with Rng = Rng.ofSeed seed } + let first = RumorHarness.chat 0 1 initial + let second = RumorHarness.chat 1 2 (RumorHarness.readyForChat { first with Tick = ticksPerDay }) + let third = RumorHarness.chat 2 3 (RumorHarness.readyForChat { second with Tick = 2L * ticksPerDay }) + RumorHarness.traceText third + + Assert.AreEqual(run 4242UL, run 4242UL) + + [] + member _.RumorTraceTextContainsStableMetadataAndReplayablePaths () = + let initial = RumorHarness.world 0L 4 + let first = RumorHarness.chat 0 1 initial + let second = RumorHarness.chat 1 2 (RumorHarness.readyForChat { first with Tick = ticksPerDay }) + let third = RumorHarness.chat 2 3 (RumorHarness.readyForChat { second with Tick = 2L * ticksPerDay }) + + let expected = + String.concat "\n" + [ sprintf "rumor_trace tick=%d count=3" (2L * ticksPerDay) + "rumor id=0 tick=0 origin_tick=0 source=0 narrator=0 receiver=1 parent=- depth=0 strength=1.000000 path=0" + sprintf "rumor id=1 tick=%d origin_tick=0 source=0 narrator=1 receiver=2 parent=0 depth=1 strength=0.500000 path=0,1" ticksPerDay + sprintf "rumor id=2 tick=%d origin_tick=0 source=0 narrator=2 receiver=3 parent=1 depth=2 strength=0.250000 path=0,1,2" (2L * ticksPerDay) ] + + Assert.AreEqual(expected, Sim.rumorTraceText third) diff --git a/src/LivingVillage.Kernel.Tests/TradeTests.fs b/src/LivingVillage.Kernel.Tests/TradeTests.fs index c7c8ef4..5a0e724 100644 --- a/src/LivingVillage.Kernel.Tests/TradeTests.fs +++ b/src/LivingVillage.Kernel.Tests/TradeTests.fs @@ -44,7 +44,8 @@ module private TradeHarness = Npcs = [| npc 0 buyerMoney buyerHunger buyerFood [] None npc 1 sellerMoney sellerHunger sellerFood sellerMemory None |] - Events = [] } + Events = [] + Rumors = [] } let request quantity = { Buyer = NpcId 0 -- cgit v1.2.3