kimi gone wild
This commit is contained in:
379
lib/odinsea/scripting/behavior.ex
Normal file
379
lib/odinsea/scripting/behavior.ex
Normal file
@@ -0,0 +1,379 @@
|
||||
defmodule Odinsea.Scripting.Behavior do
|
||||
@moduledoc """
|
||||
Behavior module defining callbacks for Odinsea game scripts.
|
||||
|
||||
This behavior is implemented by script modules that are dynamically
|
||||
compiled from script files or created manually as Elixir modules.
|
||||
|
||||
The scripting system supports the following script types:
|
||||
- NPC scripts (conversation/dialogue) - uses `cm` (conversation manager)
|
||||
- Quest scripts - uses `qm` (quest manager)
|
||||
- Portal scripts - uses `pi` (portal interaction)
|
||||
- Reactor scripts - uses `rm` (reactor manager)
|
||||
- Event scripts - uses `em` (event manager)
|
||||
|
||||
## Script Globals
|
||||
|
||||
When scripts are executed, they have access to these globals:
|
||||
|
||||
| Variable | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `cm` | `Odinsea.Scripting.PlayerAPI` | NPC conversation manager |
|
||||
| `qm` | `Odinsea.Scripting.PlayerAPI` | Quest conversation manager |
|
||||
| `pi` | `Odinsea.Scripting.PlayerAPI` | Portal interaction |
|
||||
| `rm` | `Odinsea.Scripting.PlayerAPI` | Reactor actions |
|
||||
| `em` | `Odinsea.Scripting.EventManager` | Event management |
|
||||
| `eim` | `Odinsea.Scripting.EventInstance` | Event instance (for events) |
|
||||
|
||||
## Script Examples
|
||||
|
||||
### NPC Script (Elixir module)
|
||||
|
||||
defmodule Odinsea.Scripting.NPC.Script_1002001 do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
@impl true
|
||||
def start(cm) do
|
||||
Odinsea.Scripting.PlayerAPI.send_ok(cm, "Hello! I'm Cody. Nice to meet you!")
|
||||
end
|
||||
|
||||
@impl true
|
||||
def action(cm, _mode, _type, _selection) do
|
||||
Odinsea.Scripting.PlayerAPI.dispose(cm)
|
||||
end
|
||||
end
|
||||
|
||||
### Portal Script (Elixir module)
|
||||
|
||||
defmodule Odinsea.Scripting.Portal.Script_08_xmas_st do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
@impl true
|
||||
def enter(pi) do
|
||||
# Portal logic here
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
### Event Script (Elixir module)
|
||||
|
||||
defmodule Odinsea.Scripting.Event.Boats do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
@impl true
|
||||
def init(em) do
|
||||
# Initialize event
|
||||
schedule_new(em)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def schedule(em, method_name, delay) do
|
||||
# Handle scheduled callback
|
||||
end
|
||||
|
||||
@impl true
|
||||
def player_entry(eim, player) do
|
||||
# Handle player entering event
|
||||
end
|
||||
end
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# NPC/Quest Script Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when an NPC conversation starts.
|
||||
|
||||
## Parameters
|
||||
- `api` - The conversation manager (`cm` for NPC, `qm` for quest)
|
||||
"""
|
||||
@callback start(api :: Odinsea.Scripting.PlayerAPI.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player responds to an NPC dialogue.
|
||||
|
||||
## Parameters
|
||||
- `api` - The conversation manager
|
||||
- `mode` - The mode byte (0 = cancel/end, 1 = next/yes)
|
||||
- `type` - The type byte (usually 0)
|
||||
- `selection` - The player's selection (for menus)
|
||||
"""
|
||||
@callback action(api :: Odinsea.Scripting.PlayerAPI.t(),
|
||||
mode :: integer(),
|
||||
type :: integer(),
|
||||
selection :: integer()) :: any()
|
||||
|
||||
# ============================================================================
|
||||
# Quest Script Callbacks (alternative to action for quest scripts)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when a quest starts (alternative to `action` for quests).
|
||||
"""
|
||||
@callback quest_start(api :: Odinsea.Scripting.PlayerAPI.t(),
|
||||
mode :: integer(),
|
||||
type :: integer(),
|
||||
selection :: integer()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a quest ends/completes (alternative to `action` for quests).
|
||||
"""
|
||||
@callback quest_end(api :: Odinsea.Scripting.PlayerAPI.t(),
|
||||
mode :: integer(),
|
||||
type :: integer(),
|
||||
selection :: integer()) :: any()
|
||||
|
||||
# ============================================================================
|
||||
# Portal Script Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when a player enters a scripted portal.
|
||||
|
||||
## Parameters
|
||||
- `api` - The portal interaction manager
|
||||
|
||||
## Returns
|
||||
- `:ok` - Portal handling successful
|
||||
- `{:error, reason}` - Portal handling failed
|
||||
"""
|
||||
@callback enter(api :: Odinsea.Scripting.PlayerAPI.t()) :: :ok | {:error, term()}
|
||||
|
||||
# ============================================================================
|
||||
# Reactor Script Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when a reactor is activated/hit.
|
||||
|
||||
## Parameters
|
||||
- `api` - The reactor action manager
|
||||
"""
|
||||
@callback act(api :: Odinsea.Scripting.PlayerAPI.t()) :: any()
|
||||
|
||||
# ============================================================================
|
||||
# Event Script Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when an event is initialized (after ChannelServer loads).
|
||||
|
||||
## Parameters
|
||||
- `em` - The event manager
|
||||
"""
|
||||
@callback init(em :: Odinsea.Scripting.EventManager.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called to set up an event instance.
|
||||
|
||||
## Parameters
|
||||
- `em` - The event manager (or eim for some setups)
|
||||
- `args` - Variable arguments depending on event type
|
||||
"""
|
||||
@callback setup(em :: Odinsea.Scripting.EventManager.t(), args :: term()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player enters an event instance.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
"""
|
||||
@callback player_entry(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player changes maps within an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
- `map_id` - The new map ID
|
||||
"""
|
||||
@callback changed_map(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t(),
|
||||
map_id :: integer()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when an event times out.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
"""
|
||||
@callback scheduled_timeout(eim :: Odinsea.Scripting.EventInstance.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when all monsters in an event are killed.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
"""
|
||||
@callback all_monsters_dead(eim :: Odinsea.Scripting.EventInstance.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player dies in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
"""
|
||||
@callback player_dead(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player is revived in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
|
||||
## Returns
|
||||
- `true` - Allow revive
|
||||
- `false` - Deny revive
|
||||
"""
|
||||
@callback player_revive(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: boolean()
|
||||
|
||||
@doc """
|
||||
Called when a player disconnects from an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
|
||||
## Returns
|
||||
- `0` - Deregister normally, dispose if no players left
|
||||
- `x > 0` - Deregister, dispose if less than x players
|
||||
- `x < 0` - Deregister, dispose if less than |x| players, boot all if leader
|
||||
"""
|
||||
@callback player_disconnected(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: integer()
|
||||
|
||||
@doc """
|
||||
Called when a monster is killed in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `mob_id` - The monster ID
|
||||
|
||||
## Returns
|
||||
- Points value for this monster kill
|
||||
"""
|
||||
@callback monster_value(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
mob_id :: integer()) :: integer()
|
||||
|
||||
@doc """
|
||||
Called when a player leaves the party in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
"""
|
||||
@callback left_party(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a party is disbanded in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
"""
|
||||
@callback disband_party(eim :: Odinsea.Scripting.EventInstance.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a party quest is cleared.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
"""
|
||||
@callback clear_pq(eim :: Odinsea.Scripting.EventInstance.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player is removed from an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
"""
|
||||
@callback player_exit(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called for scheduled event methods.
|
||||
|
||||
## Parameters
|
||||
- `em` - The event manager
|
||||
- `method_name` - The name of the method to call
|
||||
- `delay` - The delay in milliseconds
|
||||
"""
|
||||
@callback schedule(em :: Odinsea.Scripting.EventManager.t(),
|
||||
method_name :: String.t(),
|
||||
delay :: integer()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when an event's schedule is cancelled.
|
||||
|
||||
## Parameters
|
||||
- `em` - The event manager
|
||||
"""
|
||||
@callback cancel_schedule(em :: Odinsea.Scripting.EventManager.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a carnival party is registered.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `carnival_party` - The carnival party data
|
||||
"""
|
||||
@callback register_carnival_party(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
carnival_party :: term()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player loads a map in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
"""
|
||||
@callback on_map_load(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: any()
|
||||
|
||||
# ============================================================================
|
||||
# Optional Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@optional_callbacks [
|
||||
# NPC/Quest callbacks
|
||||
start: 1,
|
||||
action: 4,
|
||||
quest_start: 4,
|
||||
quest_end: 4,
|
||||
|
||||
# Portal callbacks
|
||||
enter: 1,
|
||||
|
||||
# Reactor callbacks
|
||||
act: 1,
|
||||
|
||||
# Event callbacks
|
||||
init: 1,
|
||||
setup: 2,
|
||||
player_entry: 2,
|
||||
changed_map: 3,
|
||||
scheduled_timeout: 1,
|
||||
all_monsters_dead: 1,
|
||||
player_dead: 2,
|
||||
player_revive: 2,
|
||||
player_disconnected: 2,
|
||||
monster_value: 2,
|
||||
left_party: 2,
|
||||
disband_party: 1,
|
||||
clear_pq: 1,
|
||||
player_exit: 2,
|
||||
schedule: 3,
|
||||
cancel_schedule: 1,
|
||||
register_carnival_party: 2,
|
||||
on_map_load: 2
|
||||
]
|
||||
end
|
||||
787
lib/odinsea/scripting/event_instance.ex
Normal file
787
lib/odinsea/scripting/event_instance.ex
Normal file
@@ -0,0 +1,787 @@
|
||||
defmodule Odinsea.Scripting.EventInstance do
|
||||
@moduledoc """
|
||||
Event Instance Manager for individual event/quest instances.
|
||||
|
||||
Each event instance represents a running copy of a party quest or event,
|
||||
with its own state, players, maps, and timers.
|
||||
|
||||
## State
|
||||
|
||||
Event instances track:
|
||||
- Players registered to the event
|
||||
- Monsters spawned
|
||||
- Kill counts per player
|
||||
- Map instances (cloned maps for PQ)
|
||||
- Custom properties
|
||||
- Event timer
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. EventManager creates instance via `new/3`
|
||||
2. Script `setup/2` is called
|
||||
3. Players register via `register_player/2`
|
||||
4. Event callbacks fire as things happen
|
||||
5. Instance disposes when complete or empty
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
event_manager: Odinsea.Scripting.EventManager.t() | nil,
|
||||
name: String.t(),
|
||||
channel: integer(),
|
||||
players: [integer()],
|
||||
disconnected: [integer()],
|
||||
monsters: [integer()],
|
||||
kill_count: %{integer() => integer()},
|
||||
map_ids: [integer()],
|
||||
is_instanced: [boolean()],
|
||||
properties: %{String.t() => String.t()},
|
||||
timer_started: boolean(),
|
||||
time_started: integer() | nil,
|
||||
event_time: integer() | nil,
|
||||
disposed: boolean()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:event_manager,
|
||||
:name,
|
||||
:channel,
|
||||
players: [],
|
||||
disconnected: [],
|
||||
monsters: [],
|
||||
kill_count: %{},
|
||||
map_ids: [],
|
||||
is_instanced: [],
|
||||
properties: %{},
|
||||
timer_started: false,
|
||||
time_started: nil,
|
||||
event_time: nil,
|
||||
disposed: false
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constructor
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new event instance.
|
||||
|
||||
## Parameters
|
||||
- `event_manager` - Parent EventManager
|
||||
- `name` - Unique instance name
|
||||
- `channel` - Channel number
|
||||
"""
|
||||
@spec new(Odinsea.Scripting.EventManager.t() | nil, String.t(), integer()) :: t()
|
||||
def new(event_manager, name, channel) do
|
||||
%__MODULE__{
|
||||
event_manager: event_manager,
|
||||
name: name,
|
||||
channel: channel
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Player Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Registers a player to this event instance.
|
||||
|
||||
## Parameters
|
||||
- `eim` - Event instance
|
||||
- `player` - Character struct or ID
|
||||
"""
|
||||
@spec register_player(t(), Character.t() | integer()) :: t()
|
||||
def register_player(%{disposed: true} = eim, _), do: eim
|
||||
def register_player(eim, player) do
|
||||
char_id = case player do
|
||||
%{id: id} -> id
|
||||
id when is_integer(id) -> id
|
||||
end
|
||||
|
||||
# Add to player list
|
||||
players = [char_id | eim.players] |> Enum.uniq()
|
||||
|
||||
%{eim | players: players}
|
||||
|> call_callback(:player_entry, [player])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unregisters a player from this event instance.
|
||||
"""
|
||||
@spec unregister_player(t(), Character.t() | integer()) :: t()
|
||||
def unregister_player(%{disposed: true} = eim, _), do: eim
|
||||
def unregister_player(eim, player) do
|
||||
char_id = case player do
|
||||
%{id: id} -> id
|
||||
id when is_integer(id) -> id
|
||||
end
|
||||
|
||||
players = List.delete(eim.players, char_id)
|
||||
|
||||
%{eim | players: players}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles player changing maps.
|
||||
"""
|
||||
@spec changed_map(t(), Character.t() | integer(), integer()) :: t()
|
||||
def changed_map(%{disposed: true} = eim, _, _), do: eim
|
||||
def changed_map(eim, player, map_id) do
|
||||
call_callback(eim, :changed_map, [player, map_id])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles player death.
|
||||
"""
|
||||
@spec player_killed(t(), Character.t() | integer()) :: t()
|
||||
def player_killed(%{disposed: true} = eim, _), do: eim
|
||||
def player_killed(eim, player) do
|
||||
call_callback(eim, :player_dead, [player])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles player revive request.
|
||||
|
||||
## Returns
|
||||
- `{allow_revive :: boolean(), updated_eim}`
|
||||
"""
|
||||
@spec revive_player(t(), Character.t() | integer()) :: {boolean(), t()}
|
||||
def revive_player(%{disposed: true} = eim, _), do: {true, eim}
|
||||
def revive_player(eim, player) do
|
||||
result = call_callback_result(eim, :player_revive, [player])
|
||||
allow = if is_boolean(result), do: result, else: true
|
||||
{allow, eim}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles player disconnection.
|
||||
|
||||
## Returns
|
||||
- `{:dispose, eim}` - Dispose instance
|
||||
- `{:continue, eim}` - Continue running
|
||||
"""
|
||||
@spec player_disconnected(t(), Character.t() | integer()) :: {:dispose | :continue, t()}
|
||||
def player_disconnected(%{disposed: true} = eim, _), do: {:dispose, eim}
|
||||
def player_disconnected(eim, player) do
|
||||
char_id = case player do
|
||||
%{id: id} -> id
|
||||
id when is_integer(id) -> id
|
||||
end
|
||||
|
||||
# Add to disconnected list
|
||||
disconnected = [char_id | eim.disconnected]
|
||||
eim = %{eim | disconnected: disconnected}
|
||||
|
||||
# Remove from players
|
||||
eim = unregister_player(eim, player)
|
||||
|
||||
# Call callback to determine behavior
|
||||
result = call_callback_result(eim, :player_disconnected, [player])
|
||||
|
||||
action = case result do
|
||||
0 ->
|
||||
# Dispose if no players
|
||||
if length(eim.players) == 0, do: :dispose, else: :continue
|
||||
|
||||
x when x > 0 ->
|
||||
# Dispose if less than x players
|
||||
if length(eim.players) < x, do: :dispose, else: :continue
|
||||
|
||||
x when x < 0 ->
|
||||
# Dispose if less than |x| players, or if leader disconnected
|
||||
threshold = abs(x)
|
||||
if length(eim.players) < threshold do
|
||||
:dispose
|
||||
else
|
||||
# TODO: Check if leader disconnected
|
||||
:continue
|
||||
end
|
||||
|
||||
_ ->
|
||||
:continue
|
||||
end
|
||||
|
||||
{action, eim}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes disconnected player ID from tracking.
|
||||
"""
|
||||
@spec remove_disconnected(t(), integer()) :: t()
|
||||
def remove_disconnected(eim, char_id) do
|
||||
disconnected = List.delete(eim.disconnected, char_id)
|
||||
%{eim | disconnected: disconnected}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is disconnected.
|
||||
"""
|
||||
@spec is_disconnected?(t(), integer()) :: boolean()
|
||||
def is_disconnected?(eim, char_id) do
|
||||
char_id in eim.disconnected
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Party Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Registers an entire party to the event.
|
||||
"""
|
||||
@spec register_party(t(), term(), integer()) :: t()
|
||||
def register_party(%{disposed: true} = eim, _, _), do: eim
|
||||
def register_party(eim, party, map_id) do
|
||||
# TODO: Get party members and register each
|
||||
eim
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a squad (expedition) to the event.
|
||||
"""
|
||||
@spec register_squad(t(), term(), integer(), integer()) :: t()
|
||||
def register_squad(%{disposed: true} = eim, _, _, _), do: eim
|
||||
def register_squad(eim, squad, map_id, quest_id) do
|
||||
# TODO: Register squad members
|
||||
eim
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles player leaving party.
|
||||
"""
|
||||
@spec left_party(t(), Character.t() | integer()) :: t()
|
||||
def left_party(%{disposed: true} = eim, _), do: eim
|
||||
def left_party(eim, player) do
|
||||
call_callback(eim, :left_party, [player])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles party disbanding.
|
||||
"""
|
||||
@spec disband_party(t()) :: t()
|
||||
def disband_party(%{disposed: true} = eim), do: eim
|
||||
def disband_party(eim) do
|
||||
call_callback(eim, :disband_party, [])
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Monster Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Registers a monster to this event.
|
||||
"""
|
||||
@spec register_monster(t(), integer()) :: t()
|
||||
def register_monster(%{disposed: true} = eim, _), do: eim
|
||||
def register_monster(eim, mob_id) do
|
||||
monsters = [mob_id | eim.monsters]
|
||||
%{eim | monsters: monsters}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unregisters a monster when killed.
|
||||
"""
|
||||
@spec unregister_monster(t(), integer()) :: t()
|
||||
def unregister_monster(%{disposed: true} = eim, _), do: eim
|
||||
def unregister_monster(eim, mob_id) do
|
||||
monsters = List.delete(eim.monsters, mob_id)
|
||||
eim = %{eim | monsters: monsters}
|
||||
|
||||
# If no monsters left, call allMonstersDead
|
||||
if length(monsters) == 0 do
|
||||
call_callback(eim, :all_monsters_dead, [])
|
||||
else
|
||||
eim
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Records monster kill and distributes points.
|
||||
"""
|
||||
@spec monster_killed(t(), Character.t() | integer(), integer()) :: t()
|
||||
def monster_killed(%{disposed: true} = eim, _, _), do: eim
|
||||
def monster_killed(eim, player, mob_id) do
|
||||
# Get monster value from script
|
||||
inc = call_callback_result(eim, :monster_value, [mob_id])
|
||||
inc = if is_integer(inc), do: inc, else: 0
|
||||
|
||||
# Update kill count
|
||||
char_id = case player do
|
||||
%{id: id} -> id
|
||||
id when is_integer(id) -> id
|
||||
end
|
||||
|
||||
current = Map.get(eim.kill_count, char_id, 0)
|
||||
kill_count = Map.put(eim.kill_count, char_id, current + inc)
|
||||
|
||||
%{eim | kill_count: kill_count}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets kill count for a player.
|
||||
"""
|
||||
@spec get_kill_count(t(), integer()) :: integer()
|
||||
def get_kill_count(eim, char_id) do
|
||||
Map.get(eim.kill_count, char_id, 0)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Timer Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts/restarts the event timer.
|
||||
|
||||
## Parameters
|
||||
- `eim` - Event instance
|
||||
- `time_ms` - Time in milliseconds
|
||||
"""
|
||||
@spec start_event_timer(t(), integer()) :: t()
|
||||
def start_event_timer(eim, time_ms) do
|
||||
restart_event_timer(eim, time_ms)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Restarts the event timer.
|
||||
"""
|
||||
@spec restart_event_timer(t(), integer()) :: t()
|
||||
def restart_event_timer(%{disposed: true} = eim, _), do: eim
|
||||
def restart_event_timer(eim, time_ms) do
|
||||
# Send clock packet to all players
|
||||
time_seconds = div(time_ms, 1000)
|
||||
broadcast_packet(eim, {:clock, time_seconds})
|
||||
|
||||
# Schedule timeout
|
||||
if eim.event_manager do
|
||||
Odinsea.Scripting.EventManager.schedule(
|
||||
eim,
|
||||
"scheduledTimeout",
|
||||
time_ms
|
||||
)
|
||||
end
|
||||
|
||||
%{eim |
|
||||
timer_started: true,
|
||||
time_started: System.system_time(:millisecond),
|
||||
event_time: time_ms
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the event timer.
|
||||
"""
|
||||
@spec stop_event_timer(t()) :: t()
|
||||
def stop_event_timer(eim) do
|
||||
%{eim |
|
||||
timer_started: false,
|
||||
time_started: nil,
|
||||
event_time: nil
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if timer is started.
|
||||
"""
|
||||
@spec is_timer_started?(t()) :: boolean()
|
||||
def is_timer_started?(eim) do
|
||||
eim.timer_started && eim.time_started != nil
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets time remaining in milliseconds.
|
||||
"""
|
||||
@spec get_time_left(t()) :: integer()
|
||||
def get_time_left(eim) do
|
||||
if is_timer_started?(eim) do
|
||||
elapsed = System.system_time(:millisecond) - eim.time_started
|
||||
max(0, eim.event_time - elapsed)
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules a custom method callback.
|
||||
"""
|
||||
@spec schedule(t(), String.t(), integer()) :: reference()
|
||||
def schedule(eim, method_name, delay_ms) do
|
||||
if eim.event_manager do
|
||||
Odinsea.Scripting.EventManager.schedule(eim, method_name, delay_ms)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Map Instance Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates an instanced map (clone for PQ).
|
||||
|
||||
## Returns
|
||||
- `{map_instance_id, updated_eim}`
|
||||
"""
|
||||
@spec create_instance_map(t(), integer()) :: {integer(), t()}
|
||||
def create_instance_map(%{disposed: true} = eim, _), do: {0, eim}
|
||||
def create_instance_map(eim, map_id) do
|
||||
assigned_id = get_new_instance_map_id()
|
||||
|
||||
# TODO: Create actual map instance
|
||||
# For now, just track the ID
|
||||
eim = %{eim |
|
||||
map_ids: [assigned_id | eim.map_ids],
|
||||
is_instanced: [true | eim.is_instanced]
|
||||
}
|
||||
|
||||
{assigned_id, eim}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates an instanced map with simplified settings.
|
||||
"""
|
||||
@spec create_instance_map_s(t(), integer()) :: {integer(), t()}
|
||||
def create_instance_map_s(eim, map_id) do
|
||||
create_instance_map(eim, map_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets an existing map as part of this event.
|
||||
"""
|
||||
@spec set_instance_map(t(), integer()) :: t()
|
||||
def set_instance_map(%{disposed: true} = eim, _), do: eim
|
||||
def set_instance_map(eim, map_id) do
|
||||
%{eim |
|
||||
map_ids: [map_id | eim.map_ids],
|
||||
is_instanced: [false | eim.is_instanced]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a map instance by index.
|
||||
"""
|
||||
@spec get_map_instance(t(), integer()) :: term()
|
||||
def get_map_instance(eim, index) when index < length(eim.map_ids) do
|
||||
map_id = Enum.at(eim.map_ids, index)
|
||||
is_instanced = Enum.at(eim.is_instanced, index)
|
||||
|
||||
# TODO: Return actual map
|
||||
%{id: map_id, instanced: is_instanced}
|
||||
end
|
||||
def get_map_instance(eim, map_id) when is_integer(map_id) do
|
||||
# Assume it's a real map ID
|
||||
%{id: map_id, instanced: false}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Properties
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Sets a property on this instance.
|
||||
"""
|
||||
@spec set_property(t(), String.t(), String.t()) :: t()
|
||||
def set_property(%{disposed: true} = eim, _, _), do: eim
|
||||
def set_property(eim, key, value) do
|
||||
properties = Map.put(eim.properties, key, value)
|
||||
%{eim | properties: properties}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a property value.
|
||||
"""
|
||||
@spec get_property(t(), String.t()) :: String.t() | nil
|
||||
def get_property(eim, key) do
|
||||
Map.get(eim.properties, key)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Player Actions
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Removes a player from the event (warp out).
|
||||
"""
|
||||
@spec remove_player(t(), Character.t() | integer()) :: t()
|
||||
def remove_player(%{disposed: true} = eim, _), do: eim
|
||||
def remove_player(eim, player) do
|
||||
call_callback(eim, :player_exit, [player])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finishes the party quest.
|
||||
"""
|
||||
@spec finish_pq(t()) :: t()
|
||||
def finish_pq(%{disposed: true} = eim), do: eim
|
||||
def finish_pq(eim) do
|
||||
call_callback(eim, :clear_pq, [])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Awards achievement to all players.
|
||||
"""
|
||||
@spec give_achievement(t(), integer()) :: :ok
|
||||
def give_achievement(eim, type) do
|
||||
broadcast_to_players(eim, {:achievement, type})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a message to all players in the event.
|
||||
"""
|
||||
@spec broadcast_player_msg(t(), integer(), String.t()) :: :ok
|
||||
def broadcast_player_msg(eim, type, message) do
|
||||
broadcast_to_players(eim, {:message, type, message})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a raw packet to all players.
|
||||
"""
|
||||
@spec broadcast_packet(t(), term()) :: :ok
|
||||
def broadcast_packet(eim, packet) do
|
||||
Enum.each(eim.players, fn char_id ->
|
||||
# TODO: Send packet to player
|
||||
:ok
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts packet to team members.
|
||||
"""
|
||||
@spec broadcast_team_packet(t(), term(), integer()) :: :ok
|
||||
def broadcast_team_packet(eim, packet, team) do
|
||||
# TODO: Filter by team and send
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies buff to a player.
|
||||
"""
|
||||
@spec apply_buff(t(), Character.t() | integer(), integer()) :: :ok
|
||||
def apply_buff(eim, player, buff_id) do
|
||||
# TODO: Apply item effect
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies skill to a player.
|
||||
"""
|
||||
@spec apply_skill(t(), Character.t() | integer(), integer()) :: :ok
|
||||
def apply_skill(eim, player, skill_id) do
|
||||
# TODO: Apply skill effect
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Carnival Party (CPQ)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Registers a carnival party.
|
||||
"""
|
||||
@spec register_carnival_party(t(), term()) :: t()
|
||||
def register_carnival_party(%{disposed: true} = eim, _), do: eim
|
||||
def register_carnival_party(eim, carnival_party) do
|
||||
call_callback(eim, :register_carnival_party, [carnival_party])
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Disposal
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Disposes the event instance if player count is at or below threshold.
|
||||
|
||||
## Returns
|
||||
- `{true, eim}` - Instance was disposed
|
||||
- `{false, eim}` - Instance not disposed
|
||||
"""
|
||||
@spec dispose_if_player_below(t(), integer(), integer()) :: {boolean(), t()}
|
||||
def dispose_if_player_below(%{disposed: true} = eim, _, _), do: {true, eim}
|
||||
def dispose_if_player_below(eim, size, warp_map_id) do
|
||||
if length(eim.players) <= size do
|
||||
# Warp players if map specified
|
||||
if warp_map_id > 0 do
|
||||
# TODO: Warp all players
|
||||
end
|
||||
|
||||
{true, dispose(eim)}
|
||||
else
|
||||
{false, eim}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disposes the event instance.
|
||||
"""
|
||||
@spec dispose(t()) :: t()
|
||||
def dispose(%{disposed: true} = eim), do: eim
|
||||
def dispose(eim) do
|
||||
# Clear player event instances
|
||||
Enum.each(eim.players, fn char_id ->
|
||||
# TODO: Clear player's event instance reference
|
||||
:ok
|
||||
end)
|
||||
|
||||
# Remove instanced maps
|
||||
Enum.zip(eim.map_ids, eim.is_instanced)
|
||||
|> Enum.each(fn {map_id, instanced} ->
|
||||
if instanced do
|
||||
# TODO: Remove instance map
|
||||
:ok
|
||||
end
|
||||
end)
|
||||
|
||||
# Notify event manager
|
||||
if eim.event_manager do
|
||||
Odinsea.Scripting.EventManager.dispose_instance(eim.name)
|
||||
end
|
||||
|
||||
%{eim |
|
||||
disposed: true,
|
||||
players: [],
|
||||
monsters: [],
|
||||
kill_count: %{},
|
||||
map_ids: [],
|
||||
is_instanced: []
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Utility
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Checks if player is the leader.
|
||||
"""
|
||||
@spec is_leader?(t(), Character.t() | integer()) :: boolean()
|
||||
def is_leader?(_eim, _player) do
|
||||
# TODO: Check party leadership
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets player count.
|
||||
"""
|
||||
@spec get_player_count(t()) :: integer()
|
||||
def get_player_count(%{disposed: true}), do: 0
|
||||
def get_player_count(eim), do: length(eim.players)
|
||||
|
||||
@doc """
|
||||
Gets the list of players.
|
||||
"""
|
||||
@spec get_players(t()) :: [integer()]
|
||||
def get_players(%{disposed: true}), do: []
|
||||
def get_players(eim), do: eim.players
|
||||
|
||||
@doc """
|
||||
Gets the list of monsters.
|
||||
"""
|
||||
@spec get_mobs(t()) :: [integer()]
|
||||
def get_mobs(eim), do: eim.monsters
|
||||
|
||||
@doc """
|
||||
Handles map load event.
|
||||
"""
|
||||
@spec on_map_load(t(), Character.t() | integer()) :: t()
|
||||
def on_map_load(%{disposed: true} = eim, _), do: eim
|
||||
def on_map_load(eim, player) do
|
||||
call_callback(eim, :on_map_load, [player])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new pair list (utility for scripts).
|
||||
"""
|
||||
@spec new_pair() :: list()
|
||||
def new_pair(), do: []
|
||||
|
||||
@doc """
|
||||
Adds to a pair list.
|
||||
"""
|
||||
@spec add_to_pair(list(), term(), term()) :: list()
|
||||
def add_to_pair(list, key, value) do
|
||||
[{key, value} | list]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new character pair list.
|
||||
"""
|
||||
@spec new_pair_chr() :: list()
|
||||
def new_pair_chr(), do: []
|
||||
|
||||
@doc """
|
||||
Adds to a character pair list.
|
||||
"""
|
||||
@spec add_to_pair_chr(list(), term(), term()) :: list()
|
||||
def add_to_pair_chr(list, key, value) do
|
||||
[{key, value} | list]
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp call_callback(%{disposed: true} = eim, _method, _args), do: eim
|
||||
defp call_callback(eim, method, args) do
|
||||
if eim.event_manager && eim.event_manager.script_module do
|
||||
mod = eim.event_manager.script_module
|
||||
|
||||
if function_exported?(mod, method, length(args) + 1) do
|
||||
try do
|
||||
apply(mod, method, [eim | args])
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Event callback #{method} error: #{inspect(e)}")
|
||||
eim
|
||||
end
|
||||
else
|
||||
eim
|
||||
end
|
||||
else
|
||||
eim
|
||||
end
|
||||
end
|
||||
|
||||
defp call_callback_result(eim, method, args) do
|
||||
if eim.event_manager && eim.event_manager.script_module do
|
||||
mod = eim.event_manager.script_module
|
||||
|
||||
if function_exported?(mod, method, length(args) + 1) do
|
||||
try do
|
||||
apply(mod, method, [eim | args])
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Event callback #{method} error: #{inspect(e)}")
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp broadcast_to_players(_eim, _message) do
|
||||
# TODO: Implement broadcasting
|
||||
:ok
|
||||
end
|
||||
|
||||
# Global counter for instance map IDs
|
||||
defp get_new_instance_map_id() do
|
||||
# Use persistent_term or similar for atomic increment
|
||||
:counters.add(:instance_counter, 1, 1)
|
||||
rescue
|
||||
_ ->
|
||||
# Fallback if counter doesn't exist
|
||||
System.unique_integer([:positive])
|
||||
end
|
||||
end
|
||||
475
lib/odinsea/scripting/event_manager.ex
Normal file
475
lib/odinsea/scripting/event_manager.ex
Normal file
@@ -0,0 +1,475 @@
|
||||
defmodule Odinsea.Scripting.EventManager do
|
||||
@moduledoc """
|
||||
Event Script Manager for handling game events and party quests.
|
||||
|
||||
Event scripts run continuously on channel servers and manage instances
|
||||
of party quests, special events, and scheduled activities.
|
||||
|
||||
## Script Interface
|
||||
|
||||
Event scripts receive an `em` (event manager) object with these callbacks:
|
||||
|
||||
- `init/1` - Called when event is loaded
|
||||
- `schedule/3` - Schedule a method callback after delay
|
||||
- `setup/2` - Called to create a new event instance
|
||||
- `player_entry/2` - Player enters instance
|
||||
- `player_dead/2` - Player dies
|
||||
- `player_revive/2` - Player revives
|
||||
- `player_disconnected/2` - Player disconnects
|
||||
- `monster_value/2` - Monster killed, returns points
|
||||
- `all_monsters_dead/1` - All monsters killed
|
||||
- `scheduled_timeout/1` - Event timer expired
|
||||
- `left_party/2` - Player left party
|
||||
- `disband_party/1` - Party disbanded
|
||||
- `clear_pq/1` - Party quest cleared
|
||||
- `player_exit/2` - Player exits event
|
||||
- `cancel_schedule/1` - Event cancelled
|
||||
|
||||
## Example Event Script
|
||||
|
||||
defmodule Odinsea.Scripting.Event.Boats do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
alias Odinsea.Scripting.EventManager
|
||||
|
||||
@impl true
|
||||
def init(em) do
|
||||
schedule_new(em)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def schedule(em, "stopentry", _delay) do
|
||||
set_property(em, "entry", "false")
|
||||
end
|
||||
|
||||
def schedule(em, "takeoff", _delay) do
|
||||
warp_all_player(em, 200000112, 200090000)
|
||||
schedule(em, "arrived", 420_000)
|
||||
end
|
||||
|
||||
def schedule(em, "arrived", _delay) do
|
||||
warp_all_player(em, 200090000, 101000300)
|
||||
schedule_new(em)
|
||||
end
|
||||
|
||||
defp schedule_new(em) do
|
||||
set_property(em, "docked", "true")
|
||||
set_property(em, "entry", "true")
|
||||
schedule(em, "stopentry", 240_000)
|
||||
schedule(em, "takeoff", 300_000)
|
||||
end
|
||||
end
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Scripting.{Manager, EventInstance}
|
||||
|
||||
# ETS tables
|
||||
@event_scripts :event_scripts
|
||||
@event_instances :event_instances
|
||||
@event_properties :event_properties
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
name: String.t(),
|
||||
channel: integer(),
|
||||
script_module: module() | nil,
|
||||
properties: map()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:name,
|
||||
:channel,
|
||||
:script_module,
|
||||
:properties
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the event script manager.
|
||||
"""
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads and initializes event scripts.
|
||||
|
||||
## Parameters
|
||||
- `scripts` - List of event script names (without .js extension)
|
||||
- `channel` - Channel server number
|
||||
|
||||
## Returns
|
||||
- `{:ok, count}` - Number of events loaded
|
||||
"""
|
||||
@spec load_events([String.t()], integer()) :: {:ok, integer()} | {:error, term()}
|
||||
def load_events(scripts, channel) do
|
||||
GenServer.call(__MODULE__, {:load_events, scripts, channel})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an event manager for a specific event.
|
||||
|
||||
## Parameters
|
||||
- `event_name` - Name of the event
|
||||
|
||||
## Returns
|
||||
- `{:ok, em}` - Event manager struct
|
||||
- `{:error, :not_found}` - Event not loaded
|
||||
"""
|
||||
@spec get_event(String.t()) :: {:ok, t()} | {:error, term()}
|
||||
def get_event(event_name) do
|
||||
case :ets.lookup(@event_scripts, event_name) do
|
||||
[{^event_name, em}] -> {:ok, em}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new event instance.
|
||||
|
||||
## Parameters
|
||||
- `event_name` - Name of the event
|
||||
- `instance_name` - Unique name for this instance
|
||||
- `args` - Arguments to pass to setup
|
||||
|
||||
## Returns
|
||||
- `{:ok, eim}` - Event instance created
|
||||
- `{:error, reason}` - Failed to create
|
||||
"""
|
||||
@spec new_instance(String.t(), String.t(), term()) ::
|
||||
{:ok, EventInstance.t()} | {:error, term()}
|
||||
def new_instance(event_name, instance_name, args \\ nil) do
|
||||
GenServer.call(__MODULE__, {:new_instance, event_name, instance_name, args})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an existing event instance.
|
||||
"""
|
||||
@spec get_instance(String.t()) :: {:ok, EventInstance.t()} | {:error, term()}
|
||||
def get_instance(instance_name) do
|
||||
case :ets.lookup(@event_instances, instance_name) do
|
||||
[{^instance_name, eim}] -> {:ok, eim}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disposes of an event instance.
|
||||
"""
|
||||
@spec dispose_instance(String.t()) :: :ok
|
||||
def dispose_instance(instance_name) do
|
||||
GenServer.call(__MODULE__, {:dispose_instance, instance_name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules a method callback on an event.
|
||||
|
||||
## Parameters
|
||||
- `em` - Event manager or instance
|
||||
- `method_name` - Name of the method to call
|
||||
- `delay_ms` - Delay in milliseconds
|
||||
"""
|
||||
@spec schedule(t() | EventInstance.t(), String.t(), integer()) :: reference()
|
||||
def schedule(em_or_eim, method_name, delay_ms) do
|
||||
GenServer.call(__MODULE__, {:schedule, em_or_eim, method_name, delay_ms})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels all scheduled tasks for an event.
|
||||
"""
|
||||
@spec cancel(t()) :: :ok
|
||||
def cancel(em) do
|
||||
GenServer.call(__MODULE__, {:cancel, em.name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a property on an event manager.
|
||||
"""
|
||||
@spec set_property(t(), String.t(), String.t()) :: :ok
|
||||
def set_property(em, key, value) do
|
||||
GenServer.call(__MODULE__, {:set_property, em.name, key, value})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a property from an event manager.
|
||||
"""
|
||||
@spec get_property(t(), String.t()) :: String.t() | nil
|
||||
def get_property(em, key) do
|
||||
case :ets.lookup(@event_properties, {em.name, key}) do
|
||||
[{_, value}] -> value
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Warps all players from one map to another.
|
||||
|
||||
## Parameters
|
||||
- `em` - Event manager
|
||||
- `from_map` - Source map ID
|
||||
- `to_map` - Destination map ID
|
||||
"""
|
||||
@spec warp_all_player(t(), integer(), integer()) :: :ok
|
||||
def warp_all_player(em, from_map, to_map) do
|
||||
Logger.debug("Event #{em.name}: Warp all from #{from_map} to #{to_map}")
|
||||
# TODO: Implement warp all
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a ship effect to a map.
|
||||
"""
|
||||
@spec broadcast_ship(t(), integer(), integer()) :: :ok
|
||||
def broadcast_ship(em, map_id, effect) do
|
||||
Logger.debug("Event #{em.name}: Broadcast ship effect #{effect} to map #{map_id}")
|
||||
# TODO: Send boat packet
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a yellow message to the channel.
|
||||
"""
|
||||
@spec broadcast_yellow_msg(t(), String.t()) :: :ok
|
||||
def broadcast_yellow_msg(em, message) do
|
||||
Logger.debug("Event #{em.name}: Yellow message: #{message}")
|
||||
# TODO: Broadcast to channel
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a server message.
|
||||
"""
|
||||
@spec broadcast_server_msg(t(), integer(), String.t(), boolean()) :: :ok
|
||||
def broadcast_server_msg(em, type, message, weather \\ false) do
|
||||
Logger.debug("Event #{em.name}: Server message (#{type}): #{message}")
|
||||
# TODO: Broadcast
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the map factory for creating map instances.
|
||||
"""
|
||||
@spec get_map_factory(t()) :: term()
|
||||
def get_map_factory(_em) do
|
||||
# TODO: Return map factory
|
||||
nil
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a monster by ID.
|
||||
"""
|
||||
@spec get_monster(t(), integer()) :: term()
|
||||
def get_monster(_em, mob_id) do
|
||||
# TODO: Return monster data
|
||||
%{id: mob_id}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a reactor by ID.
|
||||
"""
|
||||
@spec get_reactor(t(), integer()) :: term()
|
||||
def get_reactor(_em, reactor_id) do
|
||||
# TODO: Return reactor data
|
||||
%{id: reactor_id}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates new monster stats for overriding.
|
||||
"""
|
||||
@spec new_monster_stats() :: map()
|
||||
def new_monster_stats() do
|
||||
%{}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new character list.
|
||||
"""
|
||||
@spec new_char_list() :: list()
|
||||
def new_char_list() do
|
||||
[]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the EXP rate for the channel.
|
||||
"""
|
||||
@spec get_exp_rate(t()) :: integer()
|
||||
def get_exp_rate(_em) do
|
||||
# TODO: Get from channel config
|
||||
1
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the channel server.
|
||||
"""
|
||||
@spec get_channel_server(t()) :: term()
|
||||
def get_channel_server(em) do
|
||||
# TODO: Return channel server
|
||||
%{channel: em.channel}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the channel number.
|
||||
"""
|
||||
@spec get_channel(t()) :: integer()
|
||||
def get_channel(em) do
|
||||
em.channel
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(@event_scripts, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
:ets.new(@event_instances, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
:ets.new(@event_properties, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
|
||||
Logger.info("Event Script Manager initialized")
|
||||
|
||||
{:ok, %{timers: %{}}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:load_events, scripts, channel}, _from, state) do
|
||||
count = Enum.count(scripts, fn script_name ->
|
||||
case Manager.get_script(:event, script_name) do
|
||||
{:ok, module} ->
|
||||
em = %__MODULE__{
|
||||
name: script_name,
|
||||
channel: channel,
|
||||
script_module: module,
|
||||
properties: %{}
|
||||
}
|
||||
|
||||
:ets.insert(@event_scripts, {script_name, em})
|
||||
|
||||
# Call init if available
|
||||
if function_exported?(module, :init, 1) do
|
||||
Task.start(fn ->
|
||||
try do
|
||||
module.init(em)
|
||||
rescue
|
||||
e -> Logger.error("Event #{script_name} init error: #{inspect(e)}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
true
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to load event #{script_name}: #{inspect(reason)}")
|
||||
false
|
||||
end
|
||||
end)
|
||||
|
||||
{:reply, {:ok, count}, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:new_instance, event_name, instance_name, args}, _from, state) do
|
||||
case :ets.lookup(@event_scripts, event_name) do
|
||||
[{^event_name, em}] ->
|
||||
# Create event instance
|
||||
eim = EventInstance.new(em, instance_name, em.channel)
|
||||
:ets.insert(@event_instances, {instance_name, eim})
|
||||
|
||||
# Call setup
|
||||
if em.script_module && function_exported?(em.script_module, :setup, 2) do
|
||||
Task.start(fn ->
|
||||
try do
|
||||
em.script_module.setup(eim, args)
|
||||
rescue
|
||||
e -> Logger.error("Event #{event_name} setup error: #{inspect(e)}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
{:reply, {:ok, eim}, state}
|
||||
|
||||
[] ->
|
||||
{:reply, {:error, :event_not_found}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:dispose_instance, instance_name}, _from, state) do
|
||||
:ets.delete(@event_instances, instance_name)
|
||||
|
||||
# Cancel any timers for this instance
|
||||
timers = Map.get(state.timers, instance_name, [])
|
||||
Enum.each(timers, fn ref ->
|
||||
Process.cancel_timer(ref)
|
||||
end)
|
||||
|
||||
new_timers = Map.delete(state.timers, instance_name)
|
||||
{:reply, :ok, %{state | timers: new_timers}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:schedule, em_or_eim, method_name, delay_ms}, _from, state) do
|
||||
instance_name = case em_or_eim do
|
||||
%{name: name} -> name
|
||||
_ -> "unknown"
|
||||
end
|
||||
|
||||
ref = Process.send_after(self(), {:scheduled, em_or_eim, method_name}, delay_ms)
|
||||
|
||||
timers = Map.update(state.timers, instance_name, [ref], &[ref | &1])
|
||||
|
||||
{:reply, ref, %{state | timers: timers}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:cancel, event_name}, _from, state) do
|
||||
timers = Map.get(state.timers, event_name, [])
|
||||
Enum.each(timers, fn ref ->
|
||||
Process.cancel_timer(ref)
|
||||
end)
|
||||
|
||||
new_timers = Map.delete(state.timers, event_name)
|
||||
{:reply, :ok, %{state | timers: new_timers}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_property, event_name, key, value}, _from, state) do
|
||||
:ets.insert(@event_properties, {{event_name, key}, value})
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:scheduled, em_or_eim, method_name}, state) do
|
||||
# Find script module
|
||||
script_module = case em_or_eim do
|
||||
%{__struct__: Odinsea.Scripting.EventManager, script_module: mod} -> mod
|
||||
%{__struct__: Odinsea.Scripting.EventInstance, event_manager: em} -> em.script_module
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
if script_module && function_exported?(script_module, :schedule, 3) do
|
||||
Task.start(fn ->
|
||||
try do
|
||||
script_module.schedule(em_or_eim, method_name, 0)
|
||||
rescue
|
||||
e -> Logger.error("Scheduled event error: #{inspect(e)}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
413
lib/odinsea/scripting/manager.ex
Normal file
413
lib/odinsea/scripting/manager.ex
Normal file
@@ -0,0 +1,413 @@
|
||||
defmodule Odinsea.Scripting.Manager do
|
||||
@moduledoc """
|
||||
Base script manager for loading and caching game scripts.
|
||||
|
||||
This module provides functionality for:
|
||||
- Loading script files from disk
|
||||
- Caching compiled scripts in ETS
|
||||
- Hot-reloading scripts without server restart
|
||||
- Resolving script modules by name
|
||||
|
||||
## Script Loading
|
||||
|
||||
Scripts are loaded from the `scripts/` directory with the following structure:
|
||||
- `scripts/npc/` - NPC conversation scripts (857 files)
|
||||
- `scripts/portal/` - Portal scripts (700 files)
|
||||
- `scripts/event/` - Event scripts (95 files)
|
||||
- `scripts/quest/` - Quest scripts (445 files)
|
||||
- `scripts/reactor/` - Reactor scripts (272 files)
|
||||
|
||||
## Hot Reload
|
||||
|
||||
When `script_reload` is enabled in configuration, scripts are reloaded
|
||||
from disk on each invocation (useful for development).
|
||||
|
||||
## Script Compilation
|
||||
|
||||
Scripts can be implemented as:
|
||||
1. Elixir modules compiled at build time
|
||||
2. Elixir modules compiled dynamically at runtime (Code.eval_string)
|
||||
3. JavaScript executed via QuickJS (future enhancement)
|
||||
4. Lua executed via luerl (future enhancement)
|
||||
|
||||
## Configuration
|
||||
|
||||
config :odinsea, Odinsea.Scripting,
|
||||
script_reload: true, # Enable hot-reload in development
|
||||
scripts_path: "priv/scripts" # Path to script files
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Scripting.{Behavior, PlayerAPI}
|
||||
|
||||
# ETS table names for caching
|
||||
@script_cache :script_cache
|
||||
@script_timestamps :script_timestamps
|
||||
|
||||
# Script types
|
||||
@script_types [:npc, :portal, :event, :quest, :reactor]
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@type script_type :: :npc | :portal | :event | :quest | :reactor
|
||||
@type script_module :: module()
|
||||
@type script_result :: {:ok, script_module()} | {:error, term()}
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the script manager.
|
||||
"""
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads all scripts from the scripts directory.
|
||||
|
||||
## Parameters
|
||||
- `type` - Optional script type to load (nil loads all)
|
||||
|
||||
## Returns
|
||||
- `{:ok, count}` - Number of scripts loaded
|
||||
- `{:error, reason}` - Loading failed
|
||||
"""
|
||||
@spec load_all(script_type() | nil) :: {:ok, integer()} | {:error, term()}
|
||||
def load_all(type \\ nil) do
|
||||
GenServer.call(__MODULE__, {:load_all, type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads a single script file.
|
||||
|
||||
## Parameters
|
||||
- `path` - Relative path within scripts directory (e.g., "npc/1002001.js")
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Script loaded successfully
|
||||
- `{:error, reason}` - Loading failed
|
||||
"""
|
||||
@spec load_script(String.t()) :: script_result()
|
||||
def load_script(path) do
|
||||
GenServer.call(__MODULE__, {:load_script, path})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a cached script module.
|
||||
|
||||
## Parameters
|
||||
- `type` - Script type (:npc, :portal, etc.)
|
||||
- `name` - Script name (e.g., "1002001", "08_xmas_st")
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Script found
|
||||
- `{:error, :not_found}` - Script not found
|
||||
"""
|
||||
@spec get_script(script_type(), String.t()) :: script_result()
|
||||
def get_script(type, name) do
|
||||
case :ets.lookup(@script_cache, {type, name}) do
|
||||
[{_, module}] ->
|
||||
if script_reload?() do
|
||||
# Reload if hot-reload is enabled
|
||||
reload_script(type, name)
|
||||
else
|
||||
{:ok, module}
|
||||
end
|
||||
[] ->
|
||||
# Try to load from file
|
||||
load_and_cache(type, name)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reloads a script from disk.
|
||||
|
||||
## Parameters
|
||||
- `type` - Script type
|
||||
- `name` - Script name
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Script reloaded successfully
|
||||
- `{:error, reason}` - Reload failed
|
||||
"""
|
||||
@spec reload_script(script_type(), String.t()) :: script_result()
|
||||
def reload_script(type, name) do
|
||||
GenServer.call(__MODULE__, {:reload_script, type, name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears all cached scripts.
|
||||
"""
|
||||
@spec clear_cache() :: :ok
|
||||
def clear_cache() do
|
||||
GenServer.call(__MODULE__, :clear_cache)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the file path for a script.
|
||||
|
||||
## Parameters
|
||||
- `type` - Script type
|
||||
- `name` - Script name
|
||||
|
||||
## Returns
|
||||
- File path as string
|
||||
"""
|
||||
@spec script_path(script_type(), String.t()) :: String.t()
|
||||
def script_path(type, name) do
|
||||
base = scripts_path()
|
||||
ext = script_extension()
|
||||
Path.join([base, to_string(type), "#{name}#{ext}"])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a script file exists.
|
||||
|
||||
## Parameters
|
||||
- `type` - Script type
|
||||
- `name` - Script name
|
||||
|
||||
## Returns
|
||||
- `true` - Script exists
|
||||
- `false` - Script does not exist
|
||||
"""
|
||||
@spec script_exists?(script_type(), String.t()) :: boolean()
|
||||
def script_exists?(type, name) do
|
||||
script_path(type, name)
|
||||
|> File.exists?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all available scripts of a given type.
|
||||
|
||||
## Parameters
|
||||
- `type` - Script type
|
||||
|
||||
## Returns
|
||||
- List of script names
|
||||
"""
|
||||
@spec list_scripts(script_type()) :: [String.t()]
|
||||
def list_scripts(type) do
|
||||
base = Path.join(scripts_path(), to_string(type))
|
||||
ext = script_extension()
|
||||
|
||||
case File.ls(base) do
|
||||
{:ok, files} ->
|
||||
files
|
||||
|> Enum.filter(&String.ends_with?(&1, ext))
|
||||
|> Enum.map(&String.replace_suffix(&1, ext, ""))
|
||||
{:error, _} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Compiles a script file into an Elixir module.
|
||||
|
||||
This is a stub implementation that can be extended to support:
|
||||
- JavaScript via QuickJS
|
||||
- Lua via luerl
|
||||
- Direct Elixir modules
|
||||
|
||||
## Parameters
|
||||
- `source` - Script source code
|
||||
- `module_name` - Name for the compiled module
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Compilation successful
|
||||
- `{:error, reason}` - Compilation failed
|
||||
"""
|
||||
@spec compile_script(String.t(), module()) :: script_result()
|
||||
def compile_script(source, module_name) do
|
||||
# Stub implementation - creates a minimal module
|
||||
# In production, this would parse JavaScript/Lua and generate Elixir code
|
||||
# or compile to bytecode for a JS/Lua runtime
|
||||
|
||||
try do
|
||||
# For now, create a stub module
|
||||
# This would be replaced with actual JS/Lua compilation
|
||||
ast = quote do
|
||||
defmodule unquote(module_name) do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
# Stub implementations
|
||||
def start(_api), do: :ok
|
||||
def action(_api, _mode, _type, _selection), do: :ok
|
||||
def enter(_api), do: :ok
|
||||
def act(_api), do: :ok
|
||||
def init(_em), do: :ok
|
||||
def setup(_em, _args), do: :ok
|
||||
end
|
||||
end
|
||||
|
||||
Code.eval_quoted(ast)
|
||||
{:ok, module_name}
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Script compilation failed: #{inspect(e)}")
|
||||
{:error, :compilation_failed}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Configuration Helpers
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Returns the base path for scripts.
|
||||
"""
|
||||
@spec scripts_path() :: String.t()
|
||||
def scripts_path() do
|
||||
Application.get_env(:odinsea, __MODULE__, [])
|
||||
|> Keyword.get(:scripts_path, "priv/scripts")
|
||||
|> Path.expand()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns whether hot-reload is enabled.
|
||||
"""
|
||||
@spec script_reload?() :: boolean()
|
||||
def script_reload?() do
|
||||
Application.get_env(:odinsea, __MODULE__, [])
|
||||
|> Keyword.get(:script_reload, false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the script file extension.
|
||||
"""
|
||||
@spec script_extension() :: String.t()
|
||||
def script_extension() do
|
||||
# Could be .js for JavaScript, .lua for Lua, .ex for Elixir
|
||||
Application.get_env(:odinsea, __MODULE__, [])
|
||||
|> Keyword.get(:script_extension, ".ex")
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables for caching
|
||||
:ets.new(@script_cache, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
:ets.new(@script_timestamps, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
|
||||
Logger.info("Script Manager initialized")
|
||||
|
||||
{:ok, %{loaded: 0}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:load_all, type}, _from, state) do
|
||||
types = if type, do: [type], else: @script_types
|
||||
|
||||
count = Enum.reduce(types, 0, fn script_type, acc ->
|
||||
scripts = list_scripts(script_type)
|
||||
loaded = Enum.count(scripts, fn name ->
|
||||
case load_and_cache(script_type, name) do
|
||||
{:ok, _} -> true
|
||||
{:error, _} -> false
|
||||
end
|
||||
end)
|
||||
acc + loaded
|
||||
end)
|
||||
|
||||
Logger.info("Loaded #{count} scripts")
|
||||
{:reply, {:ok, count}, %{state | loaded: count}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:load_script, path}, _from, state) do
|
||||
result = do_load_script(path)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:reload_script, type, name}, _from, state) do
|
||||
# Remove from cache
|
||||
:ets.delete(@script_cache, {type, name})
|
||||
:ets.delete(@script_timestamps, {type, name})
|
||||
|
||||
# Reload
|
||||
result = load_and_cache(type, name)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:clear_cache, _from, state) do
|
||||
:ets.delete_all_objects(@script_cache)
|
||||
:ets.delete_all_objects(@script_timestamps)
|
||||
Logger.info("Script cache cleared")
|
||||
{:reply, :ok, %{state | loaded: 0}}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp load_and_cache(type, name) do
|
||||
path = script_path(type, name)
|
||||
|
||||
case File.read(path) do
|
||||
{:ok, source} ->
|
||||
module_name = module_name_for(type, name)
|
||||
|
||||
case compile_script(source, module_name) do
|
||||
{:ok, module} ->
|
||||
:ets.insert(@script_cache, {{type, name}, module})
|
||||
:ets.insert(@script_timestamps, {{type, name}, File.stat!(path).mtime})
|
||||
{:ok, module}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to compile script #{path}: #{inspect(reason)}")
|
||||
{:error, :compilation_failed}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug("Script not found: #{path}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_load_script(path) do
|
||||
full_path = Path.join(scripts_path(), path)
|
||||
|
||||
case File.read(full_path) do
|
||||
{:ok, source} ->
|
||||
# Determine type and name from path
|
||||
[type_str, filename] = Path.split(path)
|
||||
name = Path.rootname(filename)
|
||||
type = String.to_existing_atom(type_str)
|
||||
module_name = module_name_for(type, name)
|
||||
|
||||
compile_script(source, module_name)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp module_name_for(type, name) do
|
||||
# Generate a valid Elixir module name
|
||||
# e.g., Odinsea.Scripting.NPC.Script_1002001
|
||||
type_module = type |> to_string() |> Macro.camelize()
|
||||
safe_name = sanitize_module_name(name)
|
||||
Module.concat(["Odinsea", "Scripting", type_module, "Script_#{safe_name}"])
|
||||
end
|
||||
|
||||
defp sanitize_module_name(name) do
|
||||
# Convert script name to valid module name
|
||||
name
|
||||
|> String.replace(~r/[^a-zA-Z0-9_]/, "_")
|
||||
|> String.replace_prefix("", "Script_")
|
||||
end
|
||||
end
|
||||
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
|
||||
1493
lib/odinsea/scripting/player_api.ex
Normal file
1493
lib/odinsea/scripting/player_api.ex
Normal file
File diff suppressed because it is too large
Load Diff
345
lib/odinsea/scripting/portal_manager.ex
Normal file
345
lib/odinsea/scripting/portal_manager.ex
Normal file
@@ -0,0 +1,345 @@
|
||||
defmodule Odinsea.Scripting.PortalManager do
|
||||
@moduledoc """
|
||||
Portal Script Manager for handling scripted portals.
|
||||
|
||||
Portal scripts are triggered when a player enters a portal with a script name.
|
||||
They receive a `pi` (portal interaction) API object that extends PlayerAPI
|
||||
with portal-specific functionality.
|
||||
|
||||
## Script Interface
|
||||
|
||||
Portal scripts must implement the `enter/1` callback:
|
||||
|
||||
defmodule Odinsea.Scripting.Portal.Script_08_xmas_st do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
alias Odinsea.Scripting.PlayerAPI
|
||||
|
||||
@impl true
|
||||
def enter(pi) do
|
||||
# Portal logic here
|
||||
if PlayerAPI.get_player_stat(pi, "LVL") >= 10 do
|
||||
PlayerAPI.warp(pi, 100000000)
|
||||
:ok
|
||||
else
|
||||
PlayerAPI.player_message(pi, "You must be level 10 to enter.")
|
||||
{:error, :level_too_low}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
## JavaScript Compatibility
|
||||
|
||||
For JavaScript scripts:
|
||||
- `pi` - Portal interaction API
|
||||
- `function enter(pi)` - Entry point
|
||||
|
||||
## Portal API Extensions
|
||||
|
||||
The portal API (`pi`) includes all PlayerAPI functions plus:
|
||||
- `get_portal/0` - Get portal data
|
||||
- `in_free_market/0` - Warp to free market
|
||||
- `in_ardentmill/0` - Warp to crafting town
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Scripting.{Manager, PlayerAPI}
|
||||
|
||||
# ETS table for caching compiled portal scripts
|
||||
@portal_cache :portal_scripts
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@type portal_script :: module()
|
||||
@type portal_result :: :ok | {:error, term()}
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the portal script manager.
|
||||
"""
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Executes a portal script when a player enters a scripted portal.
|
||||
|
||||
## Parameters
|
||||
- `script_name` - Name of the portal script (e.g., "08_xmas_st")
|
||||
- `client_pid` - Player's client process
|
||||
- `character_id` - Character ID
|
||||
- `portal_data` - Portal information (position, target map, etc.)
|
||||
|
||||
## Returns
|
||||
- `:ok` - Script executed successfully
|
||||
- `{:error, reason}` - Script execution failed or script not found
|
||||
"""
|
||||
@spec execute(String.t(), pid(), integer(), map()) :: portal_result()
|
||||
def execute(script_name, client_pid, character_id, portal_data) do
|
||||
GenServer.call(__MODULE__, {
|
||||
:execute,
|
||||
script_name,
|
||||
client_pid,
|
||||
character_id,
|
||||
portal_data
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads a portal script into the cache.
|
||||
|
||||
## Parameters
|
||||
- `script_name` - Name of the script
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Script loaded
|
||||
- `{:error, reason}` - Failed to load
|
||||
"""
|
||||
@spec load_script(String.t()) :: {:ok, module()} | {:error, term()}
|
||||
def load_script(script_name) do
|
||||
GenServer.call(__MODULE__, {:load_script, script_name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a cached portal script.
|
||||
|
||||
## Parameters
|
||||
- `script_name` - Name of the script
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Script found
|
||||
- `{:error, :not_found}` - Script not cached
|
||||
"""
|
||||
@spec get_script(String.t()) :: {:ok, module()} | {:error, term()}
|
||||
def get_script(script_name) do
|
||||
case :ets.lookup(@portal_cache, script_name) do
|
||||
[{^script_name, module}] -> {:ok, module}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears all cached portal scripts.
|
||||
"""
|
||||
@spec clear_cache() :: :ok
|
||||
def clear_cache() do
|
||||
GenServer.call(__MODULE__, :clear_cache)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all available portal scripts.
|
||||
|
||||
## Returns
|
||||
- List of script names
|
||||
"""
|
||||
@spec list_scripts() :: [String.t()]
|
||||
def list_scripts() do
|
||||
Manager.list_scripts(:portal)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a portal script exists.
|
||||
|
||||
## Parameters
|
||||
- `script_name` - Name of the script
|
||||
|
||||
## Returns
|
||||
- `true` - Script exists
|
||||
- `false` - Script does not exist
|
||||
"""
|
||||
@spec script_exists?(String.t()) :: boolean()
|
||||
def script_exists?(script_name) do
|
||||
Manager.script_exists?(:portal, script_name)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS table for caching portal scripts
|
||||
:ets.new(@portal_cache, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
|
||||
Logger.info("Portal Script Manager initialized")
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:execute, script_name, client_pid, character_id, portal_data}, _from, state) do
|
||||
# Get or load the script
|
||||
script_result = case get_script(script_name) do
|
||||
{:ok, module} -> {:ok, module}
|
||||
{:error, :not_found} -> do_load_script(script_name)
|
||||
end
|
||||
|
||||
case script_result do
|
||||
{:ok, script_module} ->
|
||||
# Create portal interaction API
|
||||
pi = create_portal_api(client_pid, character_id, portal_data)
|
||||
|
||||
# Execute the script's enter function
|
||||
result = try do
|
||||
if function_exported?(script_module, :enter, 1) do
|
||||
script_module.enter(pi)
|
||||
else
|
||||
Logger.warning("Portal script #{script_name} missing enter/1 function")
|
||||
{:error, :invalid_script}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Portal script #{script_name} error: #{inspect(e)}")
|
||||
{:error, :script_error}
|
||||
catch
|
||||
kind, reason ->
|
||||
Logger.error("Portal script #{script_name} crashed: #{kind} #{inspect(reason)}")
|
||||
{:error, :script_crash}
|
||||
end
|
||||
|
||||
{:reply, result, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Unhandled portal script #{script_name}: #{inspect(reason)}")
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:load_script, script_name}, _from, state) do
|
||||
result = do_load_script(script_name)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:clear_cache, _from, state) do
|
||||
:ets.delete_all_objects(@portal_cache)
|
||||
Logger.info("Portal script cache cleared")
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp do_load_script(script_name) do
|
||||
case Manager.get_script(:portal, script_name) do
|
||||
{:ok, module} ->
|
||||
:ets.insert(@portal_cache, {script_name, module})
|
||||
{:ok, module}
|
||||
|
||||
{:error, reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp create_portal_api(client_pid, character_id, portal_data) do
|
||||
# Create extended PlayerAPI with portal-specific functions
|
||||
base_api = PlayerAPI.new(client_pid, character_id, portal_data.id, nil, nil)
|
||||
|
||||
# Add portal-specific data
|
||||
Map.put(base_api, :__portal_data__, portal_data)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Portal API Extensions (for use in scripts)
|
||||
# ============================================================================
|
||||
|
||||
defmodule PortalAPI do
|
||||
@moduledoc """
|
||||
Portal-specific API extensions.
|
||||
|
||||
These functions are available on the `pi` object passed to portal scripts.
|
||||
"""
|
||||
|
||||
alias Odinsea.Scripting.PlayerAPI
|
||||
|
||||
@doc """
|
||||
Gets the portal data.
|
||||
|
||||
## Parameters
|
||||
- `pi` - Portal API struct
|
||||
|
||||
## Returns
|
||||
- Portal data map
|
||||
"""
|
||||
@spec get_portal(PlayerAPI.t()) :: map()
|
||||
def get_portal(%{__portal_data__: data}), do: data
|
||||
def get_portal(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Gets portal position.
|
||||
"""
|
||||
@spec get_position(PlayerAPI.t()) :: {integer(), integer()}
|
||||
def get_position(pi) do
|
||||
case get_portal(pi) do
|
||||
%{x: x, y: y} -> {x, y}
|
||||
_ -> {0, 0}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Warps to Free Market if level >= 15.
|
||||
"""
|
||||
@spec in_free_market(PlayerAPI.t()) :: :ok
|
||||
def in_free_market(pi) do
|
||||
level = PlayerAPI.get_player_stat(pi, "LVL")
|
||||
|
||||
if level >= 15 do
|
||||
# Save return location
|
||||
PlayerAPI.save_location(pi, "FREE_MARKET")
|
||||
PlayerAPI.play_portal_se(pi)
|
||||
PlayerAPI.warp_portal(pi, 910000000, "st00")
|
||||
else
|
||||
PlayerAPI.player_message_type(pi, 5, "You must be level 15 to enter the Free Market.")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Warps to Ardentmill (crafting town) if level >= 10.
|
||||
"""
|
||||
@spec in_ardentmill(PlayerAPI.t()) :: :ok
|
||||
def in_ardentmill(pi) do
|
||||
level = PlayerAPI.get_player_stat(pi, "LVL")
|
||||
|
||||
if level >= 10 do
|
||||
PlayerAPI.save_location(pi, "ARDENTMILL")
|
||||
PlayerAPI.play_portal_se(pi)
|
||||
PlayerAPI.warp_portal(pi, 910001000, "st00")
|
||||
else
|
||||
PlayerAPI.player_message_type(pi, 5, "You must be level 10 to enter the Crafting Town.")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns monster at portal position.
|
||||
"""
|
||||
@spec spawn_monster(PlayerAPI.t(), integer()) :: :ok
|
||||
def spawn_monster(pi, mob_id) do
|
||||
{x, y} = get_position(pi)
|
||||
PlayerAPI.spawn_monster_pos(pi, mob_id, 1, x, y)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns multiple monsters at portal position.
|
||||
"""
|
||||
@spec spawn_monsters(PlayerAPI.t(), integer(), integer()) :: :ok
|
||||
def spawn_monsters(pi, mob_id, qty) do
|
||||
{x, y} = get_position(pi)
|
||||
PlayerAPI.spawn_monster_pos(pi, mob_id, qty, x, y)
|
||||
end
|
||||
end
|
||||
end
|
||||
499
lib/odinsea/scripting/reactor_manager.ex
Normal file
499
lib/odinsea/scripting/reactor_manager.ex
Normal file
@@ -0,0 +1,499 @@
|
||||
defmodule Odinsea.Scripting.ReactorManager do
|
||||
@moduledoc """
|
||||
Reactor Script Manager for handling reactor (map object) interactions.
|
||||
|
||||
Reactor scripts are triggered when a player hits/activates a reactor.
|
||||
They receive an `rm` (reactor manager) API object that extends PlayerAPI
|
||||
with reactor-specific functionality like dropping items.
|
||||
|
||||
## Script Interface
|
||||
|
||||
Reactor scripts must implement the `act/1` callback:
|
||||
|
||||
defmodule Odinsea.Scripting.Reactor.Script_1002001 do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
alias Odinsea.Scripting.PlayerAPI
|
||||
alias Odinsea.Scripting.ReactorManager.ReactorAPI
|
||||
|
||||
@impl true
|
||||
def act(rm) do
|
||||
# Drop items at reactor position
|
||||
ReactorAPI.drop_items(rm, true, 1, 100, 500)
|
||||
|
||||
# Or drop a single item
|
||||
ReactorAPI.drop_single_item(rm, 4000000)
|
||||
end
|
||||
end
|
||||
|
||||
## JavaScript Compatibility
|
||||
|
||||
For JavaScript scripts:
|
||||
- `rm` - Reactor action manager API
|
||||
- `function act()` - Entry point
|
||||
|
||||
## Reactor API Extensions
|
||||
|
||||
The reactor API (`rm`) includes all PlayerAPI functions plus:
|
||||
- `drop_items/5` - Drop items/meso at reactor position
|
||||
- `drop_single_item/2` - Drop a single item
|
||||
- `get_position/1` - Get reactor position
|
||||
- `spawn_zakum/1` - Spawn Zakum boss
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Scripting.{Manager, PlayerAPI}
|
||||
|
||||
# ETS table for caching reactor scripts
|
||||
@reactor_cache :reactor_scripts
|
||||
|
||||
# ETS table for reactor drops
|
||||
@reactor_drops :reactor_drops
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@type reactor_script :: module()
|
||||
@type reactor_result :: :ok | {:error, term()}
|
||||
|
||||
defmodule DropEntry do
|
||||
@moduledoc "Represents a reactor drop entry."
|
||||
|
||||
defstruct [
|
||||
:item_id, # Item ID (0 = meso)
|
||||
:chance, # Drop chance (1 in N)
|
||||
:quest_id # Required quest (-1 = none)
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
item_id: integer(),
|
||||
chance: integer(),
|
||||
quest_id: integer()
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the reactor script manager.
|
||||
"""
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Executes a reactor script when a player activates a reactor.
|
||||
|
||||
## Parameters
|
||||
- `reactor_id` - Reactor template ID
|
||||
- `client_pid` - Player's client process
|
||||
- `character_id` - Character ID
|
||||
- `reactor_instance` - Reactor instance data
|
||||
|
||||
## Returns
|
||||
- `:ok` - Script executed successfully
|
||||
- `{:error, reason}` - Script execution failed
|
||||
"""
|
||||
@spec act(integer(), pid(), integer(), map()) :: reactor_result()
|
||||
def act(reactor_id, client_pid, character_id, reactor_instance) do
|
||||
GenServer.call(__MODULE__, {
|
||||
:act,
|
||||
reactor_id,
|
||||
client_pid,
|
||||
character_id,
|
||||
reactor_instance
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets drops for a reactor.
|
||||
|
||||
## Parameters
|
||||
- `reactor_id` - Reactor template ID
|
||||
|
||||
## Returns
|
||||
- List of DropEntry structs
|
||||
"""
|
||||
@spec get_drops(integer()) :: [DropEntry.t()]
|
||||
def get_drops(reactor_id) do
|
||||
case :ets.lookup(@reactor_drops, reactor_id) do
|
||||
[{^reactor_id, drops}] -> drops
|
||||
[] -> load_drops(reactor_id)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears all cached reactor drops.
|
||||
"""
|
||||
@spec clear_drops() :: :ok
|
||||
def clear_drops() do
|
||||
GenServer.call(__MODULE__, :clear_drops)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads a reactor script into the cache.
|
||||
"""
|
||||
@spec load_script(integer()) :: {:ok, module()} | {:error, term()}
|
||||
def load_script(reactor_id) do
|
||||
GenServer.call(__MODULE__, {:load_script, reactor_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all available reactor scripts.
|
||||
"""
|
||||
@spec list_scripts() :: [String.t()]
|
||||
def list_scripts() do
|
||||
Manager.list_scripts(:reactor)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(@reactor_cache, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
:ets.new(@reactor_drops, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
|
||||
Logger.info("Reactor Script Manager initialized")
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:act, reactor_id, client_pid, character_id, reactor_instance}, _from, state) do
|
||||
# Get or load the script
|
||||
script_name = to_string(reactor_id)
|
||||
|
||||
script_result = case :ets.lookup(@reactor_cache, reactor_id) do
|
||||
[{^reactor_id, module}] -> {:ok, module}
|
||||
[] -> do_load_script(reactor_id)
|
||||
end
|
||||
|
||||
case script_result do
|
||||
{:ok, script_module} ->
|
||||
# Create reactor action API
|
||||
rm = create_reactor_api(client_pid, character_id, reactor_id, reactor_instance)
|
||||
|
||||
# Execute the script's act function
|
||||
result = try do
|
||||
if function_exported?(script_module, :act, 1) do
|
||||
script_module.act(rm)
|
||||
else
|
||||
Logger.warning("Reactor script #{reactor_id} missing act/1 function")
|
||||
# Execute default drop behavior
|
||||
ReactorAPI.drop_items(rm, false, 0, 0, 0)
|
||||
:ok
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Reactor script #{reactor_id} error: #{inspect(e)}")
|
||||
:ok # Don't error on reactor scripts, just log
|
||||
catch
|
||||
kind, reason ->
|
||||
Logger.error("Reactor script #{reactor_id} crashed: #{kind} #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
{:reply, result, state}
|
||||
|
||||
{:error, _reason} ->
|
||||
# No script found - execute default drop behavior
|
||||
rm = create_reactor_api(client_pid, character_id, reactor_id, reactor_instance)
|
||||
ReactorAPI.drop_items(rm, false, 0, 0, 0)
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:load_script, reactor_id}, _from, state) do
|
||||
result = do_load_script(reactor_id)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:clear_drops, _from, state) do
|
||||
:ets.delete_all_objects(@reactor_drops)
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp do_load_script(reactor_id) do
|
||||
script_name = to_string(reactor_id)
|
||||
|
||||
case Manager.get_script(:reactor, script_name) do
|
||||
{:ok, module} ->
|
||||
:ets.insert(@reactor_cache, {reactor_id, module})
|
||||
{:ok, module}
|
||||
|
||||
{:error, reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp load_drops(reactor_id) do
|
||||
# TODO: Load from database
|
||||
# For now, return empty list
|
||||
drops = []
|
||||
:ets.insert(@reactor_drops, {reactor_id, drops})
|
||||
drops
|
||||
end
|
||||
|
||||
defp create_reactor_api(client_pid, character_id, reactor_id, reactor_instance) do
|
||||
base_api = PlayerAPI.new(client_pid, character_id, reactor_id, nil, nil)
|
||||
|
||||
Map.merge(base_api, %{
|
||||
__reactor_instance__: reactor_instance,
|
||||
__reactor_id__: reactor_id
|
||||
})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Reactor API Extensions (for use in scripts)
|
||||
# ============================================================================
|
||||
|
||||
defmodule ReactorAPI do
|
||||
@moduledoc """
|
||||
Reactor-specific API extensions.
|
||||
|
||||
These functions are available on the `rm` object passed to reactor scripts.
|
||||
"""
|
||||
|
||||
alias Odinsea.Scripting.PlayerAPI
|
||||
alias Odinsea.Scripting.ReactorManager.DropEntry
|
||||
|
||||
@doc """
|
||||
Gets the reactor instance data.
|
||||
"""
|
||||
@spec get_reactor(PlayerAPI.t()) :: map()
|
||||
def get_reactor(%{__reactor_instance__: data}), do: data
|
||||
def get_reactor(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Gets reactor position.
|
||||
"""
|
||||
@spec get_position(PlayerAPI.t()) :: {integer(), integer()}
|
||||
def get_position(rm) do
|
||||
case get_reactor(rm) do
|
||||
%{x: x, y: y} -> {x, y - 10} # Slightly above for drops
|
||||
_ -> {0, 0}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets reactor ID.
|
||||
"""
|
||||
@spec get_reactor_id(PlayerAPI.t()) :: integer()
|
||||
def get_reactor_id(%{__reactor_id__: id}), do: id
|
||||
def get_reactor_id(_), do: 0
|
||||
|
||||
@doc """
|
||||
Drops items from reactor.
|
||||
|
||||
## Parameters
|
||||
- `rm` - Reactor API
|
||||
- `meso` - Whether to drop meso
|
||||
- `meso_chance` - Chance for meso (1 in N)
|
||||
- `min_meso` - Minimum meso amount
|
||||
- `max_meso` - Maximum meso amount
|
||||
- `min_items` - Minimum items to drop
|
||||
"""
|
||||
@spec drop_items(PlayerAPI.t(), boolean(), integer(), integer(), integer(), integer()) :: :ok
|
||||
def drop_items(rm, meso \\ false, meso_chance \\ 0, min_meso \\ 0, max_meso \\ 0, min_items \\ 0) do
|
||||
reactor_id = get_reactor_id(rm)
|
||||
chances = Odinsea.Scripting.ReactorManager.get_drops(reactor_id)
|
||||
|
||||
# Filter drops by chance
|
||||
items = filter_drops(chances, rm)
|
||||
|
||||
# Add meso if enabled
|
||||
items = if meso && :rand.uniform(meso_chance) == 1 do
|
||||
[%DropEntry{item_id: 0, chance: meso_chance, quest_id: -1} | items]
|
||||
else
|
||||
items
|
||||
end
|
||||
|
||||
# Pad with meso if needed
|
||||
items = if length(items) < min_items do
|
||||
pad_items(items, min_items, meso_chance)
|
||||
else
|
||||
items
|
||||
end
|
||||
|
||||
# Calculate drop position
|
||||
{base_x, y} = get_position(rm)
|
||||
count = length(items)
|
||||
start_x = base_x - (12 * count)
|
||||
|
||||
# Drop items
|
||||
Enum.each(Enum.with_index(items), fn {drop, idx} ->
|
||||
x = start_x + (idx * 25)
|
||||
|
||||
if drop.item_id == 0 do
|
||||
# Meso drop
|
||||
amount = :rand.uniform(max_meso - min_meso) + min_meso
|
||||
drop_meso(rm, amount, {x, y})
|
||||
else
|
||||
# Item drop
|
||||
drop_item(rm, drop.item_id, {x, y}, drop.quest_id)
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Drops a single item at reactor position.
|
||||
"""
|
||||
@spec drop_single_item(PlayerAPI.t(), integer()) :: :ok
|
||||
def drop_single_item(rm, item_id) do
|
||||
pos = get_position(rm)
|
||||
drop_item(rm, item_id, pos, -1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns Zakum at reactor position.
|
||||
"""
|
||||
@spec spawn_zakum(PlayerAPI.t()) :: :ok
|
||||
def spawn_zakum(rm) do
|
||||
{x, y} = get_position(rm)
|
||||
Logger.debug("Spawn Zakum at (#{x}, #{y})")
|
||||
# TODO: Spawn Zakum
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns a fake (non-aggro) monster at reactor position.
|
||||
"""
|
||||
@spec spawn_fake_monster(PlayerAPI.t(), integer()) :: :ok
|
||||
def spawn_fake_monster(rm, mob_id) do
|
||||
spawn_fake_monster_qty(rm, mob_id, 1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns multiple fake monsters at reactor position.
|
||||
"""
|
||||
@spec spawn_fake_monster_qty(PlayerAPI.t(), integer(), integer()) :: :ok
|
||||
def spawn_fake_monster_qty(rm, mob_id, qty) do
|
||||
{x, y} = get_position(rm)
|
||||
Logger.debug("Spawn fake monster #{mob_id} x#{qty} at (#{x}, #{y})")
|
||||
# TODO: Spawn fake monsters
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns NPC at reactor position.
|
||||
"""
|
||||
@spec spawn_npc(PlayerAPI.t(), integer()) :: :ok
|
||||
def spawn_npc(rm, npc_id) do
|
||||
{x, y} = get_position(rm)
|
||||
PlayerAPI.spawn_npc_pos(rm, npc_id, x, y)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Kills all monsters on the map.
|
||||
"""
|
||||
@spec kill_all(PlayerAPI.t()) :: :ok
|
||||
def kill_all(rm) do
|
||||
PlayerAPI.kill_all_mob(rm)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Kills a specific monster.
|
||||
"""
|
||||
@spec kill_monster(PlayerAPI.t(), integer()) :: :ok
|
||||
def kill_monster(rm, mob_id) do
|
||||
PlayerAPI.kill_mob(rm, mob_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Dispels all monsters (CPQ guardian effect).
|
||||
"""
|
||||
@spec dispel_all_monsters(PlayerAPI.t(), integer()) :: :ok
|
||||
def dispel_all_monsters(rm, _num) do
|
||||
# TODO: Dispel monsters
|
||||
Logger.debug("Dispel all monsters")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Performs harvesting (profession gathering).
|
||||
"""
|
||||
@spec do_harvest(PlayerAPI.t()) :: :ok
|
||||
def do_harvest(rm) do
|
||||
# TODO: Implement harvesting logic
|
||||
Logger.debug("Harvesting at reactor")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels harvesting.
|
||||
"""
|
||||
@spec cancel_harvest(PlayerAPI.t(), boolean()) :: :ok
|
||||
def cancel_harvest(_rm, _success) do
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp filter_drops(chances, rm) do
|
||||
Enum.filter(chances, fn drop ->
|
||||
passed_chance = :rand.uniform(drop.chance) == 1
|
||||
passed_quest = should_drop_quest_item(drop.quest_id, rm)
|
||||
passed_chance && passed_quest
|
||||
end)
|
||||
end
|
||||
|
||||
defp should_drop_quest_item(quest_id, _rm) when quest_id <= 0, do: true
|
||||
defp should_drop_quest_item(quest_id, rm) do
|
||||
# TODO: Check if any player on map has quest active
|
||||
# For now, return true
|
||||
true
|
||||
end
|
||||
|
||||
defp pad_items(items, min_items, meso_chance) when length(items) < min_items do
|
||||
pad_items(
|
||||
[%DropEntry{item_id: 0, chance: meso_chance, quest_id: -1} | items],
|
||||
min_items,
|
||||
meso_chance
|
||||
)
|
||||
end
|
||||
defp pad_items(items, _min, _chance), do: items
|
||||
|
||||
defp drop_meso(rm, amount, position) do
|
||||
Logger.debug("Drop #{amount} meso at #{inspect(position)}")
|
||||
# TODO: Spawn meso drop
|
||||
:ok
|
||||
end
|
||||
|
||||
defp drop_item(rm, item_id, position, quest_id) do
|
||||
owner = get_drop_owner(quest_id, rm)
|
||||
Logger.debug("Drop item #{item_id} at #{inspect(position)}, owner: #{inspect(owner)}")
|
||||
# TODO: Spawn item drop
|
||||
:ok
|
||||
end
|
||||
|
||||
defp get_drop_owner(quest_id, rm) when quest_id <= 0 do
|
||||
# Return triggering player
|
||||
rm.character_id
|
||||
end
|
||||
defp get_drop_owner(_quest_id, rm) do
|
||||
# TODO: Find player who needs quest item
|
||||
rm.character_id
|
||||
end
|
||||
end
|
||||
end
|
||||
44
lib/odinsea/scripting/supervisor.ex
Normal file
44
lib/odinsea/scripting/supervisor.ex
Normal file
@@ -0,0 +1,44 @@
|
||||
defmodule Odinsea.Scripting.Supervisor do
|
||||
@moduledoc """
|
||||
Supervisor for the Scripting system.
|
||||
|
||||
Manages all scripting-related processes:
|
||||
- Script Manager (base script loading and caching)
|
||||
- NPC Script Manager (NPC conversations)
|
||||
- Portal Script Manager (portal scripts)
|
||||
- Reactor Script Manager (reactor scripts)
|
||||
- Event Script Manager (event/party quest scripts)
|
||||
"""
|
||||
|
||||
use Supervisor
|
||||
|
||||
@doc """
|
||||
Starts the scripting supervisor.
|
||||
"""
|
||||
@spec start_link(keyword()) :: Supervisor.on_start()
|
||||
def start_link(init_arg) do
|
||||
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
children = [
|
||||
# Base script manager - handles loading and caching
|
||||
Odinsea.Scripting.Manager,
|
||||
|
||||
# NPC Script Manager - handles NPC conversations
|
||||
Odinsea.Scripting.NPCManager,
|
||||
|
||||
# Portal Script Manager - handles scripted portals
|
||||
Odinsea.Scripting.PortalManager,
|
||||
|
||||
# Reactor Script Manager - handles reactor interactions
|
||||
Odinsea.Scripting.ReactorManager,
|
||||
|
||||
# Event Script Manager - handles events and party quests
|
||||
Odinsea.Scripting.EventManager
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user