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