summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel.Tests/TradeTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-20 11:29:11 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-20 11:29:11 +0800
commit23a9235f29e92adb51c3bf1a71f92cd8434e0939 (patch)
tree0b8ff21509d55aacebe47c1d655816a1f148653f /src/LivingVillage.Kernel.Tests/TradeTests.fs
parent2f8ce45de7e4d9fb1aef1e35ef9d025b72f07b58 (diff)
downloadliving-village-23a9235f29e92adb51c3bf1a71f92cd8434e0939.tar.gz
feat(m4a): 增加交易库存金钱与动态定价
[变更性质] - 本提交新增 M4a 交易能力,不扩大到 M4b、M5 或 M6。 [新增功能] - 支持 Food 库存、买卖双方金钱转移、原子成功与拒绝结果。 - 支持基于需求、库存、性格和近期成交记忆的动态定价。 [实现方案] - 在 Sim 中增加 TradeRequest、TradeResult、交易失败类型、交易记忆与 TradeEvent。 - 以不可变 World 更新和确定性定价保持库存、金钱及事件守恒。 - 增加交易测试,并补齐既有 NPC fixture 与 MemoryKind 穷举匹配。 [影响范围] - 仅影响 LivingVillage.Kernel 交易逻辑及五个 M4a 相关测试项目文件。
Diffstat (limited to 'src/LivingVillage.Kernel.Tests/TradeTests.fs')
-rw-r--r--src/LivingVillage.Kernel.Tests/TradeTests.fs154
1 files changed, 154 insertions, 0 deletions
diff --git a/src/LivingVillage.Kernel.Tests/TradeTests.fs b/src/LivingVillage.Kernel.Tests/TradeTests.fs
new file mode 100644
index 0000000..c7c8ef4
--- /dev/null
+++ b/src/LivingVillage.Kernel.Tests/TradeTests.fs
@@ -0,0 +1,154 @@
+namespace LivingVillage.Kernel.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+module private TradeHarness =
+
+ let private personality =
+ { Drive = 0.5f
+ Aggression = 0.0f
+ Extraversion = 0.5f
+ Honesty = 0.5f
+ Greed = 0.5f }
+
+ let private needs money hunger =
+ { Hunger = hunger
+ Energy = 100.0f
+ Social = 100.0f
+ Money = money }
+
+ let private inventory food = Map.ofList [ Food, food ]
+
+ let private npc id money hunger food memory personalityOverride =
+ { Id = NpcId id
+ Pos = { X = float32 (id * 10); Y = 0.0f }
+ Inventory = inventory food
+ Mind =
+ { Needs = needs money hunger
+ Personality = defaultArg personalityOverride personality
+ Action = Wander
+ Target = actionTarget Wander
+ ActionAge = 0L
+ EffectDone = false
+ HungerFlagged = false
+ Memory = memory } }
+
+ let world buyerMoney buyerHunger buyerFood sellerMoney sellerHunger sellerFood sellerMemory =
+ { Tick = 10L
+ Time = 0.0
+ Rng = Rng.ofSeed 7UL
+ Avatar = { Pos = { X = 0.0f; Y = 0.0f } }
+ NoHost = { Reserved = 0UL }
+ Npcs =
+ [| npc 0 buyerMoney buyerHunger buyerFood [] None
+ npc 1 sellerMoney sellerHunger sellerFood sellerMemory None |]
+ Events = [] }
+
+ let request quantity =
+ { Buyer = NpcId 0
+ Seller = NpcId 1
+ Item = Food
+ Quantity = quantity }
+
+ let withSellerGreed greed world =
+ let seller = world.Npcs.[1]
+ { world with
+ Npcs =
+ [| world.Npcs.[0]
+ { seller with
+ Mind =
+ { seller.Mind with
+ Personality = { seller.Mind.Personality with Greed = greed } } } |] }
+
+ let succeeded result =
+ match result with
+ | TradeSucceeded world -> world
+ | TradeRejected (failure, _) -> Assert.Fail($"expected success, got {failure}"); Unchecked.defaultof<World>
+
+[<TestClass>]
+type TradeTests () =
+
+ [<TestMethod>]
+ member _.SuccessfulTradeTransfersItemMoneyAndMemoryAtomically () =
+ let before = TradeHarness.world 50.0f 30.0f 0 10.0f 80.0f 3 []
+ let request = TradeHarness.request 2
+ let unitPrice = Sim.quotePrice request before |> Option.get
+ let after = Sim.trade request before |> TradeHarness.succeeded
+ let total = unitPrice * 2.0f
+
+ Assert.IsTrue(after.Npcs.[0].Inventory.[Food] = 2)
+ Assert.IsTrue(after.Npcs.[1].Inventory.[Food] = 1)
+ Assert.IsTrue(abs (after.Npcs.[0].Mind.Needs.Money - (50.0f - total)) < 1e-5f)
+ Assert.IsTrue(abs (after.Npcs.[1].Mind.Needs.Money - (10.0f + total)) < 1e-5f)
+ Assert.IsTrue(abs ((after.Npcs.[0].Mind.Needs.Money + after.Npcs.[1].Mind.Needs.Money) - 60.0f) < 1e-5f)
+ Assert.IsTrue(after.Npcs.[0].Mind.Memory |> List.exists (fun e ->
+ match e.Kind with
+ | Bought (NpcId seller, Food, quantity, price) -> seller = 1 && quantity = 2 && price = unitPrice
+ | _ -> false))
+ Assert.IsTrue(after.Npcs.[1].Mind.Memory |> List.exists (fun e ->
+ match e.Kind with
+ | Sold (NpcId buyer, Food, quantity, price) -> buyer = 0 && quantity = 2 && price = unitPrice
+ | _ -> false))
+ Assert.IsTrue(after.Events |> List.exists (fun event ->
+ match event.Kind with
+ | TradeEvent (NpcId buyer, NpcId seller, Food, quantity, price) ->
+ buyer = 0 && seller = 1 && quantity = 2 && price = unitPrice
+ | _ -> false))
+ Assert.IsTrue(before.Npcs.[0].Inventory.[Food] = 0, "trade must not mutate the input world")
+ Assert.IsTrue(before.Npcs.[1].Inventory.[Food] = 3, "trade must not mutate the input world")
+
+ [<TestMethod>]
+ member _.RejectedTradesLeaveWorldUnchanged () =
+ let before = TradeHarness.world 50.0f 30.0f 0 10.0f 80.0f 3 []
+ let cases =
+ [ TradeHarness.request 0, InvalidQuantity
+ TradeHarness.request 4, OutOfStock
+ { TradeHarness.request 1 with Seller = NpcId 0 }, SameParticipant
+ TradeHarness.request 1, InsufficientFunds ]
+ for request, expected in cases do
+ let candidate =
+ if expected = InsufficientFunds then
+ TradeHarness.world 1.0f 30.0f 0 10.0f 80.0f 3 []
+ else before
+ match Sim.trade request candidate with
+ | TradeRejected (actual, unchanged) ->
+ if actual <> expected then Assert.Fail($"expected {expected}, got {actual}")
+ if unchanged <> candidate then Assert.Fail("rejected trade changed the world")
+ | TradeSucceeded _ -> Assert.Fail($"expected rejection {expected}")
+
+ [<TestMethod>]
+ member _.UnknownParticipantsAreRejectedWithoutChangingWorld () =
+ let before = TradeHarness.world 50.0f 30.0f 0 10.0f 80.0f 3 []
+ let missingBuyer = { TradeHarness.request 1 with Buyer = NpcId 9 }
+ let missingSeller = { TradeHarness.request 1 with Seller = NpcId 9 }
+ match Sim.trade missingBuyer before with
+ | TradeRejected (BuyerNotFound, unchanged) ->
+ if unchanged <> before then Assert.Fail("missing buyer changed the world")
+ | other -> Assert.Fail($"expected BuyerNotFound, got {other}")
+ match Sim.trade missingSeller before with
+ | TradeRejected (SellerNotFound, unchanged) ->
+ if unchanged <> before then Assert.Fail("missing seller changed the world")
+ | other -> Assert.Fail($"expected SellerNotFound, got {other}")
+
+ [<TestMethod>]
+ member _.QuoteIsDeterministicAndRespondsToNeedStockPersonalityAndRecentTrades () =
+ let request = TradeHarness.request 1
+ let baseWorld = TradeHarness.world 50.0f 100.0f 0 50.0f 100.0f 20 []
+ let urgentWorld = TradeHarness.world 50.0f 0.0f 0 50.0f 100.0f 20 []
+ let scarceWorld = TradeHarness.world 50.0f 100.0f 0 50.0f 100.0f 1 []
+ let greedySeller = TradeHarness.withSellerGreed 1.0f baseWorld
+ let recentMemory = [ { Tick = 9L; Kind = Sold(NpcId 0, Food, 1, 40.0f); Valence = 0.2f } ]
+ let recentWorld = TradeHarness.world 50.0f 100.0f 0 50.0f 100.0f 20 recentMemory
+ let p0 = Sim.quotePrice request baseWorld |> Option.get
+ let p1 = Sim.quotePrice request baseWorld |> Option.get
+ let urgent = Sim.quotePrice request urgentWorld |> Option.get
+ let scarce = Sim.quotePrice request scarceWorld |> Option.get
+ let greedy = Sim.quotePrice request greedySeller |> Option.get
+ let recent = Sim.quotePrice request recentWorld |> Option.get
+ if p0 <> p1 then Assert.Fail("same world and request must produce the same quote")
+ if urgent <= p0 then Assert.Fail("urgent buyer need must raise the quote")
+ if scarce <= p0 then Assert.Fail("scarce seller stock must raise the quote")
+ if greedy <= p0 then Assert.Fail("seller greed must affect the quote")
+ if recent <= p0 then Assert.Fail("recent transaction prices must affect the quote")