kimi gone wild
This commit is contained in:
546
lib/odinsea/scripting/npc_manager.ex
Normal file
546
lib/odinsea/scripting/npc_manager.ex
Normal file
@@ -0,0 +1,546 @@
|
||||
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
|
||||
Reference in New Issue
Block a user