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