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

346 lines
9.6 KiB
Elixir

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