summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop/PlayerTrade.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 23:06:20 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 23:06:20 +0800
commit07f2e072a14cc74133b187bb8715758790cdfe67 (patch)
tree715efbba87d3be156e927ed5c3ed857a274ea1ae /src/LivingVillage.Desktop/PlayerTrade.fs
parenta26ba18e9710dcdac57d942d2d7ac1ce1e47ca1a (diff)
downloadliving-village-07f2e072a14cc74133b187bb8715758790cdfe67.tar.gz
p52: 玩家交易通道——买/卖经 Sim.quotePrice→Occupation.biasedQuote,背包侧车/钱财增减 + v3 round-trip + HUD 短反馈(design §2.4-2)
Diffstat (limited to 'src/LivingVillage.Desktop/PlayerTrade.fs')
-rw-r--r--src/LivingVillage.Desktop/PlayerTrade.fs230
1 files changed, 230 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop/PlayerTrade.fs b/src/LivingVillage.Desktop/PlayerTrade.fs
new file mode 100644
index 0000000..e1ea53e
--- /dev/null
+++ b/src/LivingVillage.Desktop/PlayerTrade.fs
@@ -0,0 +1,230 @@
+namespace LivingVillage.Desktop
+
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+/// P52 职业切片二:把 P51 落盘的职业背包侧车变成玩家在世界里可用的物品
+/// (docs/design-professions.md §2.4-2)。
+///
+/// 口径:
+/// - 定价仍走既有 `Sim.quotePrice`,再经 `Occupation.biasedQuote`;无职业恒等 1.0。
+/// - 钱写 `world.Avatar.Mind.Needs.Money`,物品写 `Occupation.State.Backpack`(侧车)。
+/// - 交易像 `Sim.trade` 一样追加 `TradeEvent` + `TradeAnnal` 并写双方记忆,
+/// 但**不动** `Sim.step` / 地图 / seed,也不给 Avatar 加 Inventory 字段。
+///
+/// 玩家不是 `world.Npcs` 成员(`playerId = NpcId -1`),而 `Sim.quotePrice` 只认
+/// `Npcs`。这里把玩家侧(Avatar 需求/人格 + 背包)镜像成一个临时 `Npc` 追加进
+/// 「报价世界副本」再调用 `Sim.quotePrice`;副本只用于报价,不写回真实世界。
+module PlayerTrade =
+
+ type Outcome =
+ { World: World
+ Occupation: Occupation.State option
+ Succeeded: bool
+ /// 成交时的职业偏置后单位价(失败为 None)。
+ UnitPrice: float32 option
+ /// 面向 HUD 的短中文反馈。
+ Message: string }
+
+ let private itemName (item: ItemKind) : string =
+ match item with
+ | Food -> "食物"
+ | Fish -> "鱼"
+ | Spice -> "香料"
+ | Scroll -> "卷轴"
+
+ let private quantityOf (item: ItemKind) (inventory: Map<ItemKind, int>) : int =
+ inventory |> Map.tryFind item |> Option.defaultValue 0
+
+ let private changeItem (item: ItemKind) (delta: int) (inventory: Map<ItemKind, int>) : Map<ItemKind, int> =
+ let next = quantityOf item inventory + delta
+ if next <= 0 then Map.remove item inventory else Map.add item next inventory
+
+ let private backpackMap (backpack: (ItemKind * int) list) : Map<ItemKind, int> =
+ backpack
+ |> List.fold (fun acc (item, quantity) -> Map.add item (quantityOf item acc + quantity) acc) Map.empty
+
+ /// 背包增减,保持声明顺序;归零移除,新物品追加到尾部。
+ let private changeBackpack (item: ItemKind) (delta: int) (backpack: (ItemKind * int) list) : (ItemKind * int) list =
+ let mutable found = false
+ let updated =
+ backpack
+ |> List.choose (fun (kind, quantity) ->
+ if kind = item then
+ found <- true
+ let next = quantity + delta
+ if next <= 0 then None else Some(kind, next)
+ else
+ Some(kind, quantity))
+ if not found && delta > 0 then updated @ [ item, delta ] else updated
+
+ let private quoteWorld (world: World) (state: Occupation.State option) : World =
+ let backpack = state |> Option.map (fun s -> s.Backpack) |> Option.defaultValue []
+ let playerNpc =
+ { Id = playerId
+ Pos = world.Avatar.Pos
+ Inventory = backpackMap backpack
+ Mind = world.Avatar.Mind }
+ { world with Npcs = Array.append world.Npcs [| playerNpc |] }
+
+ let private requestFor (playerIsBuyer: bool) (counterparty: NpcId) (item: ItemKind) (quantity: int) : TradeRequest =
+ if playerIsBuyer then
+ { Buyer = playerId; Seller = counterparty; Item = item; Quantity = quantity }
+ else
+ { Buyer = counterparty; Seller = playerId; Item = item; Quantity = quantity }
+
+ /// 未偏置的 `Sim.quotePrice` 结果(玩家侧镜像为临时参与者)。
+ let quoteBase
+ (state: Occupation.State option)
+ (playerIsBuyer: bool)
+ (counterparty: NpcId)
+ (item: ItemKind)
+ (quantity: int)
+ (world: World)
+ : float32 option =
+ Sim.quotePrice (requestFor playerIsBuyer counterparty item quantity) (quoteWorld world state)
+
+ /// 最终成交单价 = `Sim.quotePrice` → `Occupation.biasedQuote`。
+ let quote
+ (state: Occupation.State option)
+ (playerIsBuyer: bool)
+ (counterparty: NpcId)
+ (item: ItemKind)
+ (quantity: int)
+ (world: World)
+ : float32 option =
+ let kind = state |> Option.map (fun s -> s.Profile.Kind)
+ quoteBase state playerIsBuyer counterparty item quantity world
+ |> Occupation.biasedQuote kind playerIsBuyer
+
+ let private replaceNpc (npc: Npc) (world: World) : World =
+ { world with Npcs = world.Npcs |> Array.map (fun current -> if current.Id = npc.Id then npc else current) }
+
+ let private recordTrade
+ (buyer: NpcId)
+ (seller: NpcId)
+ (item: ItemKind)
+ (quantity: int)
+ (unitPrice: float32)
+ (world: World)
+ : World =
+ let event =
+ { Tick = world.Tick
+ Kind = TradeEvent(buyer, seller, item, quantity, unitPrice) }
+ let entry =
+ { Tick = world.Tick
+ Kind = TradeAnnal event
+ Summary =
+ sprintf "trade tick=%d buyer=%A seller=%A item=%A quantity=%d price=%.2f"
+ world.Tick buyer seller item quantity unitPrice }
+ Sim.appendAnnal entry { world with Events = world.Events @ [ event ] }
+
+ let private failure (state: Occupation.State option) (world: World) (message: string) : Outcome =
+ { World = world; Occupation = state; Succeeded = false; UnitPrice = None; Message = message }
+
+ /// 玩家向 `counterparty` 买入 `quantity` 件 `item`。
+ let buy
+ (state: Occupation.State option)
+ (counterparty: NpcId)
+ (item: ItemKind)
+ (quantity: int)
+ (world: World)
+ : Outcome =
+ match state with
+ | None -> failure state world "未选择职业"
+ | Some occupation ->
+ match world.Npcs |> Array.tryFind (fun npc -> npc.Id = counterparty) with
+ | None -> failure state world "找不到对方"
+ | Some seller when quantity <= 0 -> failure state world "数量必须大于零"
+ | Some seller ->
+ if quantityOf item seller.Inventory < quantity then
+ failure state world "库存不足"
+ else
+ match quote (Some occupation) true counterparty item quantity world with
+ | None -> failure state world "无法报价"
+ | Some unitPrice ->
+ let total = unitPrice * float32 quantity
+ if world.Avatar.Mind.Needs.Money < total then
+ failure state world "资金不足"
+ else
+ let avatarMind =
+ { world.Avatar.Mind with
+ Needs = { world.Avatar.Mind.Needs with Money = world.Avatar.Mind.Needs.Money - total }
+ Memory = recordMemory world.Tick (Bought(counterparty, item, quantity, unitPrice)) world.Avatar.Mind.Memory }
+ let updatedSeller =
+ { seller with
+ Inventory = changeItem item -quantity seller.Inventory
+ Mind =
+ { seller.Mind with
+ Needs = { seller.Mind.Needs with Money = seller.Mind.Needs.Money + total }
+ Memory = recordMemory world.Tick (Sold(playerId, item, quantity, unitPrice)) seller.Mind.Memory } }
+ let nextWorld =
+ world
+ |> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } })
+ |> replaceNpc updatedSeller
+ |> recordTrade playerId counterparty item quantity unitPrice
+ { World = nextWorld
+ Occupation = Some { occupation with Backpack = changeBackpack item quantity occupation.Backpack }
+ Succeeded = true
+ UnitPrice = Some unitPrice
+ Message = sprintf "买入%s ×%d,单价 %.2f" (itemName item) quantity unitPrice }
+
+ /// 玩家向 `counterparty` 卖出 `quantity` 件 `item`(须在背包内)。
+ let sell
+ (state: Occupation.State option)
+ (counterparty: NpcId)
+ (item: ItemKind)
+ (quantity: int)
+ (world: World)
+ : Outcome =
+ match state with
+ | None -> failure state world "未选择职业"
+ | Some occupation ->
+ match world.Npcs |> Array.tryFind (fun npc -> npc.Id = counterparty) with
+ | None -> failure state world "找不到对方"
+ | Some buyer when quantity <= 0 -> failure state world "数量必须大于零"
+ | Some buyer ->
+ if Occupation.backpackQuantity item occupation < quantity then
+ failure state world "背包不足"
+ else
+ match quote (Some occupation) false counterparty item quantity world with
+ | None -> failure state world "无法报价"
+ | Some unitPrice ->
+ let total = unitPrice * float32 quantity
+ if buyer.Mind.Needs.Money < total then
+ failure state world "对方资金不足"
+ else
+ let avatarMind =
+ { world.Avatar.Mind with
+ Needs = { world.Avatar.Mind.Needs with Money = world.Avatar.Mind.Needs.Money + total }
+ Memory = recordMemory world.Tick (Sold(counterparty, item, quantity, unitPrice)) world.Avatar.Mind.Memory }
+ let updatedBuyer =
+ { buyer with
+ Inventory = changeItem item quantity buyer.Inventory
+ Mind =
+ { buyer.Mind with
+ Needs = { buyer.Mind.Needs with Money = buyer.Mind.Needs.Money - total }
+ Memory = recordMemory world.Tick (Bought(playerId, item, quantity, unitPrice)) buyer.Mind.Memory } }
+ let nextWorld =
+ world
+ |> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } })
+ |> replaceNpc updatedBuyer
+ |> recordTrade counterparty playerId item quantity unitPrice
+ { World = nextWorld
+ Occupation = Some { occupation with Backpack = changeBackpack item -quantity occupation.Backpack }
+ Succeeded = true
+ UnitPrice = Some unitPrice
+ Message = sprintf "卖出%s ×%d,单价 %.2f" (itemName item) quantity unitPrice }
+
+ /// HUD 入口:向 `counterparty` 买 1 件食物。
+ let buyFood (state: Occupation.State option) (counterparty: NpcId) (world: World) : Outcome =
+ buy state counterparty Food 1 world
+
+ /// HUD 入口:卖出背包里第一件有货的物品 1 件;背包为空则失败。
+ let sellFirst (state: Occupation.State option) (counterparty: NpcId) (world: World) : Outcome =
+ match state with
+ | None -> failure state world "未选择职业"
+ | Some occupation ->
+ match occupation.Backpack |> List.tryFind (fun (_, quantity) -> quantity > 0) with
+ | None -> failure state world "背包里没有可卖物品"
+ | Some(item, quantity) -> sell state counterparty item (min 1 quantity) world