summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop/Interaction.fs
blob: da78b2e9b224db120768bdad72de1b780748c8d1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
namespace LivingVillage.Desktop

open System
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim

type M5Panel =
    | WorldPanel
    | NeedsPanel
    | ObservationPanel
    | DialoguePanel
    | ChroniclePanel

type M5Command =
    | Interact
    | Intent1
    | Intent2
    | Intent3
    | Intent4
    | Intent5
    | Intent6
    | Observe
    | ToggleNeeds
    | ShowChronicle
    | ClosePanel

type M5View =
    { Panel: M5Panel
      Menu: DialogueMenu option
      Needs: NeedsPanel option
      Observations: Observation list
      Chronicle: string
      Status: string }

module M5Interaction =

    let initial : M5View =
        { Panel = WorldPanel
          Menu = None
          Needs = None
          Observations = []
          Chronicle = ""
          Status = "ready" }

    let private observationBounds (world: World) : VisibleBounds =
        let radius = Sim.chatRangePx
        { Min = { X = world.Avatar.Pos.X - radius; Y = world.Avatar.Pos.Y - radius }
          Max = { X = world.Avatar.Pos.X + radius; Y = world.Avatar.Pos.Y + radius } }

    let private menuStatus (menu: DialogueMenu) : string =
        sprintf "dialogue target=%A options=%d; press 1-6" menu.Target menu.Options.Length

    let private intentName (intent: DialogueIntent) : string =
        match intent with
        | SmallTalk -> "闲聊"
        | AskHelp -> "求助"
        | OfferTrade -> "提议交易"
        | Joke -> "讲笑话"
        | Apologize -> "道歉"
        | Provoke -> "挑衅"

    let private needName (kind: NeedKind) : string =
        match kind with
        | HungerNeed -> "饥饿"
        | EnergyNeed -> "精力"
        | SocialNeed -> "社交"
        | MoneyNeed -> "金钱"

    let private itemName (item: ItemKind) : string =
        match item with
        | Food -> "食物"

    let private responseName (response: DialogueResponse) : string =
        match response with
        | Friendly -> "友好"
        | Helpful -> "乐于助人"
        | Bargaining -> "讨价还价"
        | Amused -> "开心"
        | Forgiving -> "原谅"
        | Hostile -> "敌意"
        | Reserved -> "保留"
        | Refused -> "拒绝"
        | Offended -> "恼怒"

    let private dialogueFailureName (failure: DialogueFailure) : string =
        match failure with
        | DialogueTargetNotFound -> "找不到目标"
        | DialogueTargetOutOfRange -> "目标不在范围内"
        | DialogueTargetUnavailable -> "目标暂时无法互动"

    let private tradeFailureName (failure: TradeFailure) : string =
        match failure with
        | BuyerNotFound -> "找不到买家"
        | SellerNotFound -> "找不到卖家"
        | SameParticipant -> "买家和卖家不能是同一人"
        | InvalidQuantity -> "数量必须大于零"
        | OutOfStock -> "库存不足"
        | InsufficientFunds -> "资金不足"

    let private npcNumber (NpcId id) = id

    let private chooseIntent (index: int) (world: World) (view: M5View) : World * M5View =
        match view.Menu with
        | None -> world, { view with Status = "no dialogue menu; press E near an NPC" }
        | Some menu ->
            match List.tryItem index menu.Options with
            | None -> world, { view with Status = "invalid dialogue option" }
            | Some intent ->
                match Sim.chooseDialogue menu.Target intent world with
                | DialogueSucceeded(outcome, next) ->
                    next,
                    { view with
                        Panel = DialoguePanel
                        Menu = None
                        Chronicle = Sim.annalText next
                        Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response }
                | DialogueRejected(failure, next) ->
                    next, { view with Status = sprintf "dialogue rejected: %s" (dialogueFailureName failure) }

    let apply (command: M5Command) (world: World) (view: M5View) : World * M5View =
        match command with
        | Interact ->
            match Sim.openDialogueMenu world with
            | Some menu ->
                world,
                { view with
                    Panel = DialoguePanel
                    Menu = Some menu
                    Status = menuStatus menu }
            | None -> world, { view with Panel = WorldPanel; Menu = None; Status = "no nearby NPC available" }
        | Intent1 -> chooseIntent 0 world view
        | Intent2 -> chooseIntent 1 world view
        | Intent3 -> chooseIntent 2 world view
        | Intent4 -> chooseIntent 3 world view
        | Intent5 -> chooseIntent 4 world view
        | Intent6 -> chooseIntent 5 world view
        | Observe ->
            let observations = Sim.observeVisible (observationBounds world) world
            world,
            { view with
                Panel = ObservationPanel
                Observations = observations
                Status = sprintf "observation visible_npcs=%d" observations.Length }
        | ToggleNeeds ->
            if view.Panel = NeedsPanel then
                world, { view with Panel = WorldPanel; Needs = None; Status = "needs panel closed" }
            else
                world,
                { view with
                    Panel = NeedsPanel
                    Needs = Some(Sim.needsPanel world)
                    Status = "needs panel open" }
        | ShowChronicle ->
            world,
            { view with
                Panel = ChroniclePanel
                Chronicle = Sim.annalText world
                Status = sprintf "chronicle entries=%d" world.Annals.Length }
        | ClosePanel -> world, { view with Panel = WorldPanel; Menu = None; Status = "panel closed" }

    let tradeResultText (request: TradeRequest) (quote: float32 option) (result: TradeResult) : string =
        match result with
        | TradeSucceeded _ ->
            match quote with
            | Some unitPrice ->
                sprintf "交易成功:%s ×%d,报价 %.2f,总价 %.2f"
                    (itemName request.Item)
                    request.Quantity
                    unitPrice
                    (unitPrice * float32 request.Quantity)
            | None -> sprintf "交易成功:%s ×%d" (itemName request.Item) request.Quantity
        | TradeRejected(failure, _) -> sprintf "交易失败:%s" (tradeFailureName failure)

    let panelName (view: M5View) : string =
        match view.Panel with
        | WorldPanel -> "world"
        | NeedsPanel -> "needs"
        | ObservationPanel -> "observation"
        | DialoguePanel -> "dialogue"
        | ChroniclePanel -> "chronicle"

    let titleText (view: M5View) : string =
        match view.Panel with
        | WorldPanel -> "E 互动 | Q 观察 | TAB 需求 | C 年鉴 | L 关系"
        | NeedsPanel -> "需求 | TAB 关闭 | Q 观察 | C 年鉴"
        | ObservationPanel -> "观察 | TAB 需求 | C 年鉴 | ESC 关闭"
        | DialoguePanel ->
            match view.Menu with
            | Some menu ->
                let options =
                    menu.Options
                    |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (intentName intent))
                    |> String.concat " | "
                sprintf "互动:村民 %d | %s | ESC 关闭" (npcNumber menu.Target) options
            | None -> "互动已记录 | E 互动 | ESC 关闭"
        | ChroniclePanel -> "年鉴 | TAB 需求 | Q 观察 | ESC 关闭"

    let private statusLabel (status: string) : string =
        match status with
        | "ready" -> "准备就绪"
        | "needs panel open" -> "需求面板已打开"
        | "needs panel closed" -> "需求面板已关闭"
        | "panel closed" -> "面板已关闭"
        | value when value.StartsWith("dialogue target=") -> "请选择对话方式"
        | value when value.StartsWith("dialogue intent=") -> "对话完成"
        | value when value.StartsWith("dialogue rejected:") ->
            let reason = value.Substring("dialogue rejected:".Length).Trim()
            if String.IsNullOrWhiteSpace reason then "对话失败" else sprintf "对话失败:%s" reason
        | value when value.StartsWith("observation") -> "观察完成"
        | value when value.StartsWith("chronicle") -> "年鉴已打开"
        | value when value.StartsWith("no dialogue") -> "当前没有可用对话"
        | value when value.StartsWith("invalid dialogue") -> "对话选项无效"
        | value when value.StartsWith("no nearby") -> "附近没有可互动村民"
        | _ -> "状态已更新"

    let private annalLine (entry: AnnalEntry) : string =
        match entry.Kind with
        | DialogueAnnal outcome ->
            sprintf "对话:村民 %d 对村民 %d 使用%s,回应%s"
                (npcNumber outcome.Actor)
                (npcNumber outcome.Target)
                (intentName outcome.Intent)
                (responseName outcome.Response)
        | RumorAnnal rumor ->
            sprintf "传闻:村民 %d 传给村民 %d,传播 %d 层"
                (npcNumber rumor.Narrator)
                (npcNumber rumor.Receiver)
                rumor.Depth
        | TradeAnnal event ->
            match event.Kind with
            | TradeEvent(buyer, seller, item, quantity, unitPrice) ->
                sprintf "交易:村民 %d 从村民 %d 获得%s ×%d,单价 %.2f"
                    (npcNumber buyer)
                    (npcNumber seller)
                    (itemName item)
                    quantity
                    unitPrice
            | _ -> "交易记录"

    let private chronicleLines (world: World) : string list =
        world.Annals
        |> List.rev
        |> List.truncate 10
        |> List.map annalLine

    let panelLines (world: World) (view: M5View) : string list =
        let status = if String.IsNullOrWhiteSpace view.Status then "准备就绪" else statusLabel view.Status
        match view.Panel with
        | WorldPanel ->
            [ "村庄"
              "E 互动"
              "Q 观察"
              "TAB 需求"
              "C 年鉴"
              "L 关系"
              status ]
        | NeedsPanel ->
            match view.Needs with
            | None -> [ "需求"; "暂无数据"; status ]
            | Some panel ->
                let topNeed =
                    panel.Ranked
                    |> List.tryHead
                    |> Option.map (fun score -> sprintf "最紧迫:%s" (needName score.Kind))
                    |> Option.defaultValue "暂无紧迫需求"
                [ "需求"
                  sprintf "饥饿 %.0f" panel.Needs.Hunger
                  sprintf "精力 %.0f" panel.Needs.Energy
                  sprintf "社交 %.0f" panel.Needs.Social
                  sprintf "金钱 %.0f" panel.Needs.Money
                  topNeed
                  status ]
        | ObservationPanel ->
            let rows =
                view.Observations
                |> List.truncate 8
                |> List.collect (fun observation ->
                    let urgency =
                        observation.Ranked
                        |> List.tryHead
                        |> Option.map (fun score -> score.Urgency)
                        |> Option.defaultValue 0.0f
                    [ sprintf "村民 %d 紧迫度 %.0f" (npcNumber observation.Id) urgency
                      sprintf "饥饿 %.0f 精力 %.0f 社交 %.0f 金钱 %.0f"
                          observation.Needs.Hunger
                          observation.Needs.Energy
                          observation.Needs.Social
                          observation.Needs.Money ])
            "观察" :: (if rows.IsEmpty then [ "范围内暂无村民" ] else rows) @ [ status ]
        | DialoguePanel ->
            match view.Menu with
            | None -> [ "互动已记录"; status ]
            | Some menu ->
                let options =
                    menu.Options
                    |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (intentName intent))
                [ sprintf "互动:村民 %d" (npcNumber menu.Target) ] @ options @ [ "ESC 关闭"; status ]
        | ChroniclePanel ->
            let entries = chronicleLines world
            "年鉴" :: (if entries.IsEmpty then [ "暂无记录" ] else entries) @ [ status ]