kimi gone wild
This commit is contained in:
413
lib/odinsea/scripting/manager.ex
Normal file
413
lib/odinsea/scripting/manager.ex
Normal file
@@ -0,0 +1,413 @@
|
||||
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
|
||||
Reference in New Issue
Block a user