diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-20 14:20:54 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-20 14:20:54 +0800 |
| commit | 7e197babfbb68d7f7d1866d6a0ca47cf17d3af23 (patch) | |
| tree | abf951825edce69139f099a73dfb6f2575ff0259 /src/LivingVillage.Kernel.Tests/M5Tests.fs | |
| parent | 4f8aca922e6cf47dce95aab79541065d487d5272 (diff) | |
| download | living-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.fs | 141 |
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) |
