kimi gone wild

This commit is contained in:
ra
2026-02-14 23:12:33 -07:00
parent bbd205ecbe
commit 0222be36c5
98 changed files with 39726 additions and 309 deletions

View 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