summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop/Interaction.fs
blob: 6f175d623a684b416253146835502b27ae88c0bb (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
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 ]