Files
odinsea-elixir/lib/odinsea/scripting/npc_manager.ex
2026-02-14 23:12:33 -07:00

547 lines
16 KiB
Elixir

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