summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel.Tests/RumorTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-20 12:18:38 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-20 12:18:38 +0800
commit4f8aca922e6cf47dce95aab79541065d487d5272 (patch)
tree3d0e259440912ccb7ab62b910c576e928326fa96 /src/LivingVillage.Kernel.Tests/RumorTests.fs
parent23a9235f29e92adb51c3bf1a71f92cd8434e0939 (diff)
downloadliving-village-4f8aca922e6cf47dce95aab79541065d487d5272.tar.gz
feat(m4b): 增加可追踪谣言传播与回放
[变更性质] - 本提交新增 M4b 谣言传播与确定性验收能力,不是单纯缺陷修复。 [新增功能] - 支持带来源、父节点、深度、强度和路径的谣言传播、去重、衰减与过期处理。 - Headless 支持稳定谣言 dump、replay,以及与 batch 模式的互斥校验。 [实现方案] - 通过不可变 World 更新和失败原子返回保持模拟确定性。 - 新增多跳传播、重复转述、过期父节点、非法参与者和稳定 trace 测试。 [影响范围] - 影响 Kernel、Headless 和 Kernel 测试;不包含 M5/M6 或无关配置与文档改动。
Diffstat (limited to 'src/LivingVillage.Kernel.Tests/RumorTests.fs')
-rw-r--r--src/LivingVillage.Kernel.Tests/RumorTests.fs179
1 files changed, 179 insertions, 0 deletions
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<World>
+
+ 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"
+
+[<TestClass>]
+type RumorTests () =
+
+ [<TestMethod>]
+ 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<int>(3, third.Rumors.Length)
+ Assert.AreEqual<int>(3, ordered.Length)
+ let root = ordered.[0]
+ let retelling = ordered.[1]
+ let final = ordered.[2]
+ Assert.AreEqual<NpcId>(NpcId 0, root.Source)
+ Assert.AreEqual<NpcId>(NpcId 0, root.Narrator)
+ Assert.AreEqual<NpcId>(NpcId 1, root.Receiver)
+ Assert.IsTrue(root.Parent.IsNone)
+ Assert.AreEqual<int>(0, root.Depth)
+ Assert.AreEqual<RumorId>(root.Id, retelling.Parent |> Option.get)
+ Assert.AreEqual<NpcId>(root.Source, retelling.Source)
+ Assert.AreEqual<NpcId>(NpcId 1, retelling.Narrator)
+ Assert.AreEqual<NpcId>(NpcId 2, retelling.Receiver)
+ Assert.AreEqual<int>(1, retelling.Depth)
+ Assert.AreEqual<RumorId>(retelling.Id, final.Parent |> Option.get)
+ Assert.AreEqual<NpcId>(NpcId 2, final.Narrator)
+ Assert.AreEqual<NpcId>(NpcId 3, final.Receiver)
+ Assert.AreEqual<int>(2, final.Depth)
+
+ let path = Sim.rumorPath third final.Id
+ CollectionAssert.AreEqual([| root; retelling; final |], List.toArray path)
+
+ [<TestMethod>]
+ 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<int>(2, second.Rumors.Length)
+ Assert.AreEqual<int>(2, duplicate.Rumors.Length)
+ Assert.AreEqual<string>(RumorHarness.traceText second, RumorHarness.traceText duplicate)
+
+ [<TestMethod>]
+ 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)
+
+ [<TestMethod>]
+ 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<World>(before, unchanged)
+ | ChatSucceeded _ -> Assert.Fail($"expected invalid chat to be rejected: {request}")
+
+ [<TestMethod>]
+ 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<string>(run 4242UL, run 4242UL)
+
+ [<TestMethod>]
+ 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<string>(expected, Sim.rumorTraceText third)