summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel.Tests
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
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')
-rw-r--r--src/LivingVillage.Kernel.Tests/DeterminismTests.fs1
-rw-r--r--src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj1
-rw-r--r--src/LivingVillage.Kernel.Tests/RelationTests.fs6
-rw-r--r--src/LivingVillage.Kernel.Tests/RumorTests.fs179
-rw-r--r--src/LivingVillage.Kernel.Tests/TradeTests.fs3
5 files changed, 187 insertions, 3 deletions
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 @@
<ItemGroup>
<Compile Include="DeterminismTests.fs" />
<Compile Include="RelationTests.fs" />
+ <Compile Include="RumorTests.fs" />
<Compile Include="TradeTests.fs" />
</ItemGroup>
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<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)
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