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 ]