defmodule Odinsea.Scripting.NPCManager do @moduledoc """ NPC Script Manager for handling NPC conversations. Manages the lifecycle of NPC interactions including: - Starting conversations with NPCs - Handling player responses (yes/no, menu selections, text input) - Quest start/end conversations - Multiple concurrent conversations per player ## Conversation State Each active conversation tracks: - Player/Client reference - NPC ID - Quest ID (for quest conversations) - Conversation type (:npc, :quest_start, :quest_end) - Last message type (for input validation) - Pending disposal flag ## Script Interface NPC scripts receive a `cm` (conversation manager) object with methods: - `send_ok/1` - Show OK dialog - `send_yes_no/1` - Show Yes/No dialog - `send_simple/1` - Show menu selection - `send_get_text/1` - Request text input - `send_get_number/4` - Request number input - `send_style/2` - Show style selection - `warp/2` - Warp player to map - `gain_item/2` - Give player items - `dispose/0` - End conversation ## Example Script (Elixir) defmodule Odinsea.Scripting.NPC.Script_1002001 do @behaviour Odinsea.Scripting.Behavior alias Odinsea.Scripting.PlayerAPI @impl true def start(cm) do PlayerAPI.send_ok(cm, "Hello! I'm Cody. Nice to meet you!") end @impl true def action(cm, _mode, _type, _selection) do PlayerAPI.dispose(cm) end end ## JavaScript Compatibility For JavaScript scripts, the following globals are available: - `cm` - Conversation manager API - `status` - Conversation status variable (for legacy scripts) Entry points: - `function start()` - Called when conversation starts - `function action(mode, type, selection)` - Called on player response """ use GenServer require Logger alias Odinsea.Scripting.{Manager, PlayerAPI} # Conversation types @type conv_type :: :npc | :quest_start | :quest_end # Conversation state defmodule Conversation do @moduledoc "Represents an active NPC conversation." defstruct [ :client_pid, # Player's client process :character_id, # Character ID :npc_id, # NPC template ID :quest_id, # Quest ID (for quest conversations) :type, # :npc, :quest_start, :quest_end :script_module, # Compiled script module :last_msg, # Last message type sent (-1 = none) :pending_disposal, # Flag to dispose on next action :script_name # Custom script name override ] @type t :: %__MODULE__{ client_pid: pid(), character_id: integer(), npc_id: integer(), quest_id: integer() | nil, type: Odinsea.Scripting.NPCManager.conv_type(), script_module: module() | nil, last_msg: integer(), pending_disposal: boolean(), script_name: String.t() | nil } end # ============================================================================ # Client API # ============================================================================ @doc """ Starts the NPC script manager. """ @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Starts an NPC conversation with a player. ## Parameters - `client_pid` - The player's client process - `character_id` - The character ID - `npc_id` - The NPC template ID - `opts` - Options: - `:script_name` - Override script name (default: npc_id) ## Returns - `:ok` - Conversation started - `{:error, :already_talking}` - Player already in conversation - `{:error, :script_not_found}` - NPC script not found """ @spec start_conversation(pid(), integer(), integer(), keyword()) :: :ok | {:error, term()} def start_conversation(client_pid, character_id, npc_id, opts \\ []) do GenServer.call(__MODULE__, { :start_conversation, client_pid, character_id, npc_id, :npc, nil, opts }) end @doc """ Starts a quest conversation (start quest). ## Parameters - `client_pid` - The player's client process - `character_id` - The character ID - `npc_id` - The NPC template ID - `quest_id` - The quest ID ## Returns - `:ok` - Conversation started - `{:error, reason}` - Failed to start """ @spec start_quest(pid(), integer(), integer(), integer()) :: :ok | {:error, term()} def start_quest(client_pid, character_id, npc_id, quest_id) do GenServer.call(__MODULE__, { :start_conversation, client_pid, character_id, npc_id, :quest_start, quest_id, [] }) end @doc """ Ends a quest conversation (complete quest). ## Parameters - `client_pid` - The player's client process - `character_id` - The character ID - `npc_id` - The NPC template ID - `quest_id` - The quest ID ## Returns - `:ok` - Conversation started - `{:error, reason}` - Failed to start """ @spec end_quest(pid(), integer(), integer(), integer()) :: :ok | {:error, term()} def end_quest(client_pid, character_id, npc_id, quest_id) do GenServer.call(__MODULE__, { :start_conversation, client_pid, character_id, npc_id, :quest_end, quest_id, [] }) end @doc """ Handles a player action in an ongoing conversation. ## Parameters - `client_pid` - The player's client process - `character_id` - The character ID - `mode` - Action mode (0 = end, 1 = yes/next) - `type` - Action type (usually 0) - `selection` - Menu selection index ## Returns - `:ok` - Action handled - `{:error, :no_conversation}` - No active conversation """ @spec handle_action(pid(), integer(), integer(), integer(), integer()) :: :ok | {:error, term()} def handle_action(client_pid, character_id, mode, type, selection) do GenServer.call(__MODULE__, { :handle_action, client_pid, character_id, mode, type, selection }) end @doc """ Disposes (ends) a conversation. ## Parameters - `client_pid` - The player's client process - `character_id` - The character ID ## Returns - `:ok` - Conversation disposed """ @spec dispose(pid(), integer()) :: :ok def dispose(client_pid, character_id) do GenServer.call(__MODULE__, {:dispose, client_pid, character_id}) end @doc """ Safely disposes a conversation on the next action. ## Parameters - `client_pid` - The player's client process - `character_id` - The character ID ## Returns - `:ok` - Pending disposal set - `{:error, :no_conversation}` - No active conversation """ @spec safe_dispose(pid(), integer()) :: :ok | {:error, term()} def safe_dispose(client_pid, character_id) do GenServer.call(__MODULE__, {:safe_dispose, client_pid, character_id}) end @doc """ Gets the conversation manager for a player. ## Parameters - `client_pid` - The player's client process - `character_id` - The character ID ## Returns - `{:ok, cm}` - Conversation manager - `{:error, :no_conversation}` - No active conversation """ @spec get_cm(pid(), integer()) :: {:ok, PlayerAPI.t()} | {:error, term()} def get_cm(client_pid, character_id) do GenServer.call(__MODULE__, {:get_cm, client_pid, character_id}) end @doc """ Checks if a player is currently in a conversation. ## Parameters - `client_pid` - The player's client process - `character_id` - The character ID ## Returns - `true` - Player is in conversation - `false` - Player is not in conversation """ @spec in_conversation?(pid(), integer()) :: boolean() def in_conversation?(client_pid, character_id) do case get_cm(client_pid, character_id) do {:ok, _} -> true {:error, _} -> false end end @doc """ Sets the last message type for input validation. ## Parameters - `client_pid` - The player's client process - `character_id` - The character ID - `msg_type` - Message type code """ @spec set_last_msg(pid(), integer(), integer()) :: :ok def set_last_msg(client_pid, character_id, msg_type) do GenServer.call(__MODULE__, {:set_last_msg, client_pid, character_id, msg_type}) end # ============================================================================ # Server Callbacks # ============================================================================ @impl true def init(_opts) do # ETS table for active conversations: {{client_pid, character_id}, conversation} :ets.new(:npc_conversations, [:named_table, :set, :public, read_concurrency: true, write_concurrency: true]) Logger.info("NPC Script Manager initialized") {:ok, %{}} end @impl true def handle_call({:start_conversation, client_pid, character_id, npc_id, type, quest_id, opts}, _from, state) do key = {client_pid, character_id} # Check if already in conversation case :ets.lookup(:npc_conversations, key) do [{_, _existing}] -> {:reply, {:error, :already_talking}, state} [] -> # Determine script name script_name = opts[:script_name] || to_string(npc_id) # Load script based on type script_result = case type do :npc -> Manager.get_script(:npc, script_name) :quest_start -> Manager.get_script(:quest, to_string(quest_id)) :quest_end -> Manager.get_script(:quest, to_string(quest_id)) end case script_result do {:ok, script_module} -> # Create conversation record conv = %Conversation{ client_pid: client_pid, character_id: character_id, npc_id: npc_id, quest_id: quest_id, type: type, script_module: script_module, last_msg: -1, pending_disposal: false, script_name: script_name } # Store conversation :ets.insert(:npc_conversations, {key, conv}) # Create conversation manager API cm = PlayerAPI.new(client_pid, character_id, npc_id, quest_id, self()) # Call script start function Task.start(fn -> try do # Set the script engine in client state if needed # For now, directly call the behavior callback case type do :quest_start -> if function_exported?(script_module, :quest_start, 4) do script_module.quest_start(cm, 1, 0, 0) else script_module.action(cm, 1, 0, 0) end :quest_end -> if function_exported?(script_module, :quest_end, 4) do script_module.quest_end(cm, 1, 0, 0) else script_module.action(cm, 1, 0, 0) end _ -> if function_exported?(script_module, :start, 1) do script_module.start(cm) else # Try action as fallback script_module.action(cm, 1, 0, 0) end end rescue e -> Logger.error("NPC script error: #{inspect(e)}") dispose(client_pid, character_id) end end) {:reply, :ok, state} {:error, :enoent} -> # Script not found - use default "notcoded" script case Manager.get_script(:npc, "notcoded") do {:ok, script_module} -> conv = %Conversation{ client_pid: client_pid, character_id: character_id, npc_id: npc_id, quest_id: quest_id, type: type, script_module: script_module, last_msg: -1, pending_disposal: false, script_name: "notcoded" } :ets.insert(:npc_conversations, {key, conv}) cm = PlayerAPI.new(client_pid, character_id, npc_id, quest_id, self()) Task.start(fn -> try do script_module.start(cm) rescue _ -> dispose(client_pid, character_id) end end) {:reply, :ok, state} {:error, _} -> {:reply, {:error, :script_not_found}, state} end {:error, reason} -> {:reply, {:error, reason}, state} end end end @impl true def handle_call({:handle_action, client_pid, character_id, mode, type, selection}, _from, state) do key = {client_pid, character_id} case :ets.lookup(:npc_conversations, key) do [{_, conv}] when conv.pending_disposal -> # Dispose and reply :ets.delete(:npc_conversations, key) {:reply, :ok, state} [{_, conv}] when conv.last_msg > -1 -> # Already sent a message, ignore {:reply, :ok, state} [{_, conv}] -> if mode == -1 do # Cancel/end :ets.delete(:npc_conversations, key) {:reply, :ok, state} else cm = PlayerAPI.new(client_pid, character_id, conv.npc_id, conv.quest_id, self()) Task.start(fn -> try do case conv.type do :quest_start -> if function_exported?(conv.script_module, :quest_start, 4) do conv.script_module.quest_start(cm, mode, type, selection) else conv.script_module.action(cm, mode, type, selection) end :quest_end -> if function_exported?(conv.script_module, :quest_end, 4) do conv.script_module.quest_end(cm, mode, type, selection) else conv.script_module.action(cm, mode, type, selection) end _ -> conv.script_module.action(cm, mode, type, selection) end rescue e -> Logger.error("NPC action error: #{inspect(e)}") dispose(client_pid, character_id) end end) {:reply, :ok, state} end [] -> {:reply, {:error, :no_conversation}, state} end end @impl true def handle_call({:dispose, client_pid, character_id}, _from, state) do key = {client_pid, character_id} :ets.delete(:npc_conversations, key) {:reply, :ok, state} end @impl true def handle_call({:safe_dispose, client_pid, character_id}, _from, state) do key = {client_pid, character_id} case :ets.lookup(:npc_conversations, key) do [{_, conv}] -> updated = %{conv | pending_disposal: true} :ets.insert(:npc_conversations, {key, updated}) {:reply, :ok, state} [] -> {:reply, {:error, :no_conversation}, state} end end @impl true def handle_call({:get_cm, client_pid, character_id}, _from, state) do key = {client_pid, character_id} case :ets.lookup(:npc_conversations, key) do [{_, conv}] -> cm = PlayerAPI.new(client_pid, character_id, conv.npc_id, conv.quest_id, self()) {:reply, {:ok, cm}, state} [] -> {:reply, {:error, :no_conversation}, state} end end @impl true def handle_call({:set_last_msg, client_pid, character_id, msg_type}, _from, state) do key = {client_pid, character_id} case :ets.lookup(:npc_conversations, key) do [{_, conv}] -> updated = %{conv | last_msg: msg_type} :ets.insert(:npc_conversations, {key, updated}) {:reply, :ok, state} [] -> {:reply, {:error, :no_conversation}, state} end end end