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) : int = inventory |> Map.tryFind item |> Option.defaultValue 0 let private changeItem (item: ItemKind) (delta: int) (inventory: Map) : Map = 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 = 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 (event: InteractionEvent) (world: World) : World = let entry = { Tick = world.Tick Kind = TradeAnnal event Summary = match event.Kind with | TradeEvent(buyer, seller, item, quantity, unitPrice) -> sprintf "trade tick=%d buyer=%A seller=%A item=%A quantity=%d price=%.2f" world.Tick buyer seller item quantity unitPrice | _ -> sprintf "trade tick=%d" world.Tick } Sim.appendAnnal entry { world with Events = world.Events @ [ event ] } /// P54:交易事件同时推进当日任务(Offered 须先接受;判定走 Kernel 纯函数)。 let private advanceOccupation (event: InteractionEvent) (occupation: Occupation.State) : Occupation.State = Occupation.advanceTask event occupation 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 tradeEvent = { Tick = world.Tick Kind = TradeEvent(playerId, counterparty, item, quantity, unitPrice) } let nextWorld = world |> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } }) |> replaceNpc updatedSeller |> recordTrade tradeEvent let nextOccupation = { occupation with Backpack = changeBackpack item quantity occupation.Backpack } |> advanceOccupation tradeEvent { World = nextWorld Occupation = Some nextOccupation 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 tradeEvent = { Tick = world.Tick Kind = TradeEvent(counterparty, playerId, item, quantity, unitPrice) } let nextWorld = world |> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } }) |> replaceNpc updatedBuyer |> recordTrade tradeEvent let nextOccupation = { occupation with Backpack = changeBackpack item -quantity occupation.Backpack } |> advanceOccupation tradeEvent { World = nextWorld Occupation = Some nextOccupation 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