diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 23:06:20 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 23:06:20 +0800 |
| commit | 07f2e072a14cc74133b187bb8715758790cdfe67 (patch) | |
| tree | 715efbba87d3be156e927ed5c3ed857a274ea1ae /src/LivingVillage.Desktop/PlayerTrade.fs | |
| parent | a26ba18e9710dcdac57d942d2d7ac1ce1e47ca1a (diff) | |
| download | living-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.fs | 230 |
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 |
