kimi gone wild

This commit is contained in:
ra
2026-02-14 23:12:33 -07:00
parent bbd205ecbe
commit 0222be36c5
98 changed files with 39726 additions and 309 deletions

View 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

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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