summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop/Interaction.fs
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Desktop/Interaction.fs')
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs211
1 files changed, 211 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs
new file mode 100644
index 0000000..6f175d6
--- /dev/null
+++ b/src/LivingVillage.Desktop/Interaction.fs
@@ -0,0 +1,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 ]