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 | |
| 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')
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/DeterminismTests.fs | 19 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/M5Tests.fs | 141 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/RelationTests.fs | 30 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/RumorTests.fs | 15 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/TradeTests.fs | 19 |
6 files changed, 211 insertions, 14 deletions
diff --git a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs index cc1944f..52cae89 100644 --- a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs +++ b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs @@ -233,10 +233,21 @@ type DeterminismTests () = | Rest -> 0.4f | Pay -> 0.2f | Hungry -> -0.4f - | Chatted _ -> Sim.chatValence - | Rumor _ -> 0.1f - | Bought _ -> 0.3f - | Sold _ -> 0.3f + | Chatted _ -> Sim.chatValence + | Rumor _ -> 0.1f + | Bought _ -> 0.3f + | Sold _ -> 0.3f + | Dialogue (_, _, response) -> + match response with + | Friendly -> 0.4f + | Helpful -> 0.5f + | Bargaining -> 0.2f + | Amused -> 0.35f + | Forgiving -> 0.45f + | Hostile -> -0.5f + | Reserved -> 0.0f + | Refused -> -0.1f + | Offended -> -0.4f if e.Valence <> expected then Assert.Fail($"valence/kind mismatch: {e.Kind} -> {e.Valence}") if e.Valence < -1.0f || e.Valence > 1.0f then diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj index f65200b..2a3ba3c 100644 --- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj +++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj @@ -10,6 +10,7 @@ <Compile Include="RelationTests.fs" /> <Compile Include="RumorTests.fs" /> <Compile Include="TradeTests.fs" /> + <Compile Include="M5Tests.fs" /> </ItemGroup> <ItemGroup> 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) diff --git a/src/LivingVillage.Kernel.Tests/RelationTests.fs b/src/LivingVillage.Kernel.Tests/RelationTests.fs index 20331ce..b98252d 100644 --- a/src/LivingVillage.Kernel.Tests/RelationTests.fs +++ b/src/LivingVillage.Kernel.Tests/RelationTests.fs @@ -32,11 +32,22 @@ module RelationHarness = { Tick = tick Time = 0.0 Rng = Rng.ofSeed 1UL - Avatar = { Pos = { X = 0.0f; Y = 0.0f } } + Avatar = + { Pos = { X = 0.0f; Y = 0.0f } + Mind = + { Needs = needs + Personality = personalityOfSeed 1UL + Action = Wander + Target = actionTarget Wander + ActionAge = 0L + EffectDone = false + HungerFlagged = false + Memory = [] } } NoHost = { Reserved = 0UL } Npcs = [| mkNpc 0 mem0; mkNpc 1 mem1 |] Events = [] - Rumors = [] } + Rumors = [] + Annals = [] } let chatted (tick: int64) (partner: int) : MemoryEvent = { Tick = tick; Kind = Chatted(NpcId partner); Valence = Sim.chatValence } @@ -182,11 +193,22 @@ type RelationTests () = { Tick = 1L Time = 0.0 Rng = Rng.ofSeed 55UL - Avatar = { Pos = plaza } + Avatar = + { Pos = plaza + Mind = + { Needs = needs + Personality = personality + Action = Wander + Target = actionTarget Wander + ActionAge = 0L + EffectDone = false + HungerFlagged = false + Memory = [] } } NoHost = { Reserved = 0UL } Npcs = [| self; nearest; alternate |] Events = [] - Rumors = [] } + Rumors = [] + Annals = [] } 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 index 3c9b267..f52fca9 100644 --- a/src/LivingVillage.Kernel.Tests/RumorTests.fs +++ b/src/LivingVillage.Kernel.Tests/RumorTests.fs @@ -37,11 +37,22 @@ module private RumorHarness = { Tick = tick Time = float tick * dtSeconds Rng = Rng.ofSeed 11UL - Avatar = { Pos = { X = 0.0f; Y = 0.0f } } + Avatar = + { Pos = { X = 0.0f; Y = 0.0f } + Mind = + { Needs = needs + Personality = personality + Action = Wander + Target = actionTarget Wander + ActionAge = 0L + EffectDone = false + HungerFlagged = false + Memory = [] } } NoHost = { Reserved = 0UL } Npcs = Array.init count npc Events = [] - Rumors = [] } + Rumors = [] + Annals = [] } let readyForChat (world: World) : World = { world with diff --git a/src/LivingVillage.Kernel.Tests/TradeTests.fs b/src/LivingVillage.Kernel.Tests/TradeTests.fs index 5a0e724..77e5658 100644 --- a/src/LivingVillage.Kernel.Tests/TradeTests.fs +++ b/src/LivingVillage.Kernel.Tests/TradeTests.fs @@ -39,13 +39,24 @@ module private TradeHarness = { Tick = 10L Time = 0.0 Rng = Rng.ofSeed 7UL - Avatar = { Pos = { X = 0.0f; Y = 0.0f } } + Avatar = + { Pos = { X = 0.0f; Y = 0.0f } + Mind = + { Needs = needs 50.0f 100.0f + Personality = personality + Action = Wander + Target = actionTarget Wander + ActionAge = 0L + EffectDone = false + HungerFlagged = false + Memory = [] } } NoHost = { Reserved = 0UL } Npcs = - [| npc 0 buyerMoney buyerHunger buyerFood [] None - npc 1 sellerMoney sellerHunger sellerFood sellerMemory None |] + [| npc 0 buyerMoney buyerHunger buyerFood [] None + npc 1 sellerMoney sellerHunger sellerFood sellerMemory None |] Events = [] - Rumors = [] } + Rumors = [] + Annals = [] } let request quantity = { Buyer = NpcId 0 |
