414 lines
12 KiB
Elixir
414 lines
12 KiB
Elixir
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
|