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 ]
|