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

500 lines
14 KiB
Elixir

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