summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel.Tests/M5Tests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-20 14:20:54 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-20 14:20:54 +0800
commit7e197babfbb68d7f7d1866d6a0ca47cf17d3af23 (patch)
treeabf951825edce69139f099a73dfb6f2575ff0259 /src/LivingVillage.Kernel.Tests/M5Tests.fs
parent4f8aca922e6cf47dce95aab79541065d487d5272 (diff)
downloadliving-village-7e197babfbb68d7f7d1866d6a0ca47cf17d3af23.tar.gz
feat(m5): 增加可交互对话、需求观察与编年史
[变更性质] - 本提交新增 M5 用户能力,不是缺陷修复。 [新增功能] - 增加玩家六意图对话、需求面板、可见 NPC 观察、编年史与因果谣言冒烟验收。 [实现方案] - 在 Kernel 中扩展 Avatar Mind、对话结果、观察数据、Annals 和确定性谣言链。 - 在 Desktop 中加入键盘交互、像素文本面板和运行时状态展示,并补充 Headless 与测试覆盖。 [影响范围] - 覆盖 Kernel、Headless、Desktop 及测试工程;保留既有 M3/M4 确定性与交易、关系、谣言行为。
Diffstat (limited to 'src/LivingVillage.Kernel.Tests/M5Tests.fs')
-rw-r--r--src/LivingVillage.Kernel.Tests/M5Tests.fs141
1 files changed, 141 insertions, 0 deletions
diff --git a/src/LivingVillage.Kernel.Tests/M5Tests.fs b/src/LivingVillage.Kernel.Tests/M5Tests.fs
new file mode 100644
index 0000000..5eb1501
--- /dev/null
+++ b/src/LivingVillage.Kernel.Tests/M5Tests.fs
@@ -0,0 +1,141 @@
+namespace LivingVillage.Kernel.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+module private M5Harness =
+
+ let zeroInput = { Input = { MoveX = 0.0f; MoveY = 0.0f } }
+
+ let atNpc (id: int) (world: World) : World =
+ let npc = world.Npcs.[id]
+ { world with Avatar = { world.Avatar with Pos = npc.Pos } }
+
+ 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 atTick (tick: int64) (world: World) : World =
+ { world with
+ Tick = tick
+ Time = float tick * dtSeconds }
+
+ let chat (narrator: int) (receiver: int) (world: World) : 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>
+
+[<TestClass>]
+type M5Tests () =
+
+ [<TestMethod>]
+ member _.AvatarNeedsDecayAndNeedsPanelUsesTheSameValues () =
+ let before = Sim.initialWorld 42UL
+ let after = Sim.step M5Harness.zeroInput before
+ Assert.IsTrue(after.Avatar.Mind.Needs.Hunger < before.Avatar.Mind.Needs.Hunger)
+ Assert.IsTrue(after.Avatar.Mind.Needs.Energy < before.Avatar.Mind.Needs.Energy)
+ Assert.IsTrue(after.Avatar.Mind.Needs.Social < before.Avatar.Mind.Needs.Social)
+ Assert.IsTrue(after.Avatar.Mind.Needs.Money < before.Avatar.Mind.Needs.Money)
+ let panel = Sim.needsPanel after
+ Assert.AreEqual<Needs>(after.Avatar.Mind.Needs, panel.Needs)
+ Assert.IsFalse(panel.Ranked.IsEmpty)
+
+ [<TestMethod>]
+ member _.NearbyNpcOpensSixIntentDialogueMenu () =
+ let world = Sim.initialWorldN 42UL 4 |> M5Harness.atNpc 0
+ match Sim.openDialogueMenu world with
+ | Some menu ->
+ Assert.AreEqual<NpcId>(NpcId 0, menu.Target)
+ Assert.AreEqual<int>(6, menu.Options.Length)
+ Assert.IsTrue(menu.Options |> List.contains SmallTalk)
+ Assert.IsTrue(menu.Options |> List.contains AskHelp)
+ Assert.IsTrue(menu.Options |> List.contains OfferTrade)
+ | None -> Assert.Fail("nearby NPC should open a dialogue menu")
+
+ [<TestMethod>]
+ member _.SelectedIntentCreatesBilateralMemoryEventAndAnnal () =
+ let world = Sim.initialWorldN 42UL 4 |> M5Harness.atNpc 0
+ match Sim.chooseDialogue (NpcId 0) SmallTalk world with
+ | DialogueSucceeded (outcome, next) ->
+ Assert.AreEqual<NpcId>(playerId, outcome.Actor)
+ Assert.AreEqual<NpcId>(NpcId 0, outcome.Target)
+ Assert.IsTrue(next.Avatar.Mind.Memory |> List.exists (fun e ->
+ match e.Kind with
+ | Dialogue (NpcId target, SmallTalk, response) -> target = 0 && response = outcome.Response
+ | _ -> false))
+ Assert.IsTrue(next.Npcs.[0].Mind.Memory |> List.exists (fun e ->
+ match e.Kind with
+ | Dialogue (NpcId actor, SmallTalk, response) -> actor = -1 && response = outcome.Response
+ | _ -> false))
+ Assert.IsTrue(next.Events |> List.exists (fun event ->
+ match event.Kind with
+ | DialogueEvent recorded -> recorded = outcome
+ | _ -> false))
+ Assert.IsTrue(next.Annals |> List.exists (fun entry ->
+ match entry.Kind with
+ | DialogueAnnal recorded -> recorded = outcome
+ | _ -> false))
+ Assert.IsTrue(Sim.annalText next |> fun text -> text.Contains("dialogue"))
+ | DialogueRejected (failure, _) -> Assert.Fail($"dialogue rejected: {failure}")
+
+ [<TestMethod>]
+ member _.PlayerSmallTalkPassesThroughThreeIntermediariesAndReturnsToPlayer () =
+ let initial = Sim.initialWorldN 7UL 4 |> M5Harness.atNpc 0 |> M5Harness.readyForChat
+ let afterPlayer =
+ match Sim.chooseDialogue (NpcId 0) SmallTalk initial with
+ | DialogueSucceeded (_, next) -> next
+ | DialogueRejected (failure, _) -> Assert.Fail($"dialogue rejected: {failure}"); Unchecked.defaultof<World>
+ let first =
+ afterPlayer
+ |> M5Harness.atTick Sim.ticksPerDay
+ |> M5Harness.readyForChat
+ |> M5Harness.chat 0 1
+ let second =
+ first
+ |> M5Harness.atTick (2L * Sim.ticksPerDay)
+ |> M5Harness.readyForChat
+ |> M5Harness.chat 1 2
+ let third =
+ second
+ |> M5Harness.atTick (3L * Sim.ticksPerDay)
+ |> M5Harness.readyForChat
+ |> M5Harness.chat 2 -1
+ let finalRumor = third.Rumors |> List.sortBy (fun rumor -> rumor.Id) |> List.last
+ let path = Sim.rumorPath third finalRumor.Id
+ Assert.AreEqual<NpcId>(playerId, finalRumor.Source)
+ Assert.AreEqual<NpcId>(playerId, finalRumor.Receiver)
+ Assert.AreEqual<NpcId>(NpcId 2, finalRumor.Narrator)
+ Assert.AreEqual<int>(3, finalRumor.Depth)
+ Assert.AreEqual<int>(4, path.Length)
+ Assert.AreEqual<NpcId>(playerId, path.Head.Source)
+ Assert.AreEqual<NpcId>(playerId, path |> List.last |> fun rumor -> rumor.Receiver)
+
+ [<TestMethod>]
+ member _.ObservationOnlyIncludesVisibleNpcsAndRanksUrgentNeeds () =
+ let baseWorld = Sim.initialWorldN 42UL 2
+ let visibleNpc =
+ { baseWorld.Npcs.[0] with
+ Pos = { X = 10.0f; Y = 10.0f }
+ Mind =
+ { baseWorld.Npcs.[0].Mind with
+ Needs = { baseWorld.Npcs.[0].Mind.Needs with Hunger = 5.0f; Money = 90.0f } } }
+ let hiddenNpc = { baseWorld.Npcs.[1] with Pos = { X = 200.0f; Y = 200.0f } }
+ let world = { baseWorld with Npcs = [| visibleNpc; hiddenNpc |] }
+ let observations =
+ Sim.observeVisible
+ { Min = { X = 0.0f; Y = 0.0f }
+ Max = { X = 100.0f; Y = 100.0f } }
+ world
+ Assert.AreEqual<int>(1, observations.Length)
+ Assert.AreEqual<NpcId>(NpcId 0, observations.Head.Id)
+ Assert.AreEqual<NeedKind>(HungerNeed, observations.Head.Ranked.Head.Kind)