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