diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-20 12:18:38 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-20 12:18:38 +0800 |
| commit | 4f8aca922e6cf47dce95aab79541065d487d5272 (patch) | |
| tree | 3d0e259440912ccb7ab62b910c576e928326fa96 /src/LivingVillage.Kernel.Tests | |
| parent | 23a9235f29e92adb51c3bf1a71f92cd8434e0939 (diff) | |
| download | living-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')
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 |
