547 lines
16 KiB
Elixir
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
|