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
|
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 -> "SMALLTALK"
| AskHelp -> "ASK HELP"
| OfferTrade -> "OFFER TRADE"
| Joke -> "JOKE"
| Apologize -> "APOLOGIZE"
| Provoke -> "PROVOKE"
let private needName (kind: NeedKind) : string =
match kind with
| HungerNeed -> "HUNGER"
| EnergyNeed -> "ENERGY"
| SocialNeed -> "SOCIAL"
| MoneyNeed -> "MONEY"
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: %A" 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 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 INTERACT | Q OBSERVE | TAB NEEDS | C CHRONICLE | L RELATIONS"
| NeedsPanel -> "NEEDS | TAB CLOSE | Q OBSERVE | C CHRONICLE"
| ObservationPanel -> "OBSERVATION | TAB NEEDS | C CHRONICLE | ESC CLOSE"
| 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 "DIALOGUE TARGET=%A | %s | ESC CLOSE" menu.Target options
| None -> "DIALOGUE RECORDED | E INTERACT | ESC CLOSE"
| ChroniclePanel -> "CHRONICLE | TAB NEEDS | Q OBSERVE | ESC CLOSE"
let private chronicleLines (text: string) : string list =
text.Split([| '\n' |], System.StringSplitOptions.RemoveEmptyEntries) |> Array.toList
let panelLines (world: World) (view: M5View) : string list =
let status = if String.IsNullOrWhiteSpace view.Status then "READY" else view.Status.ToUpperInvariant()
match view.Panel with
| WorldPanel ->
[ "M5 VILLAGE"
"E INTERACT"
"Q OBSERVE"
"TAB NEEDS"
"C CHRONICLE"
"L RELATIONS"
status ]
| NeedsPanel ->
match view.Needs with
| None -> [ "NEEDS"; "NO DATA"; status ]
| Some panel ->
let topNeed =
panel.Ranked
|> List.tryHead
|> Option.map (fun score -> sprintf "TOP %s" (needName score.Kind))
|> Option.defaultValue "TOP NONE"
[ "NEEDS"
sprintf "HUNGER %.0f" panel.Needs.Hunger
sprintf "ENERGY %.0f" panel.Needs.Energy
sprintf "SOCIAL %.0f" panel.Needs.Social
sprintf "MONEY %.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 "NPC %d U %.0f" (npcNumber observation.Id) urgency
sprintf "H %.0f E %.0f S %.0f M %.0f"
observation.Needs.Hunger
observation.Needs.Energy
observation.Needs.Social
observation.Needs.Money ])
"OBSERVATION" :: (if rows.IsEmpty then [ "NO NPC IN RANGE" ] else rows) @ [ status ]
| DialoguePanel ->
match view.Menu with
| None -> [ "DIALOGUE RECORDED"; status ]
| Some menu ->
let options =
menu.Options
|> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (intentName intent))
[ sprintf "DIALOGUE NPC %d" (npcNumber menu.Target) ] @ options @ [ "ESC CLOSE"; status ]
| ChroniclePanel ->
let entries = chronicleLines view.Chronicle |> List.truncate 10
"CHRONICLE" :: (if entries.IsEmpty then [ "NO ENTRIES" ] else entries) @ [ status ]
|