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

788 lines
21 KiB
Elixir

defmodule Odinsea.Scripting.EventInstance do
@moduledoc """
Event Instance Manager for individual event/quest instances.
Each event instance represents a running copy of a party quest or event,
with its own state, players, maps, and timers.
## State
Event instances track:
- Players registered to the event
- Monsters spawned
- Kill counts per player
- Map instances (cloned maps for PQ)
- Custom properties
- Event timer
## Lifecycle
1. EventManager creates instance via `new/3`
2. Script `setup/2` is called
3. Players register via `register_player/2`
4. Event callbacks fire as things happen
5. Instance disposes when complete or empty
"""
require Logger
alias Odinsea.Game.Character
# ============================================================================
# Types
# ============================================================================
@type t :: %__MODULE__{
event_manager: Odinsea.Scripting.EventManager.t() | nil,
name: String.t(),
channel: integer(),
players: [integer()],
disconnected: [integer()],
monsters: [integer()],
kill_count: %{integer() => integer()},
map_ids: [integer()],
is_instanced: [boolean()],
properties: %{String.t() => String.t()},
timer_started: boolean(),
time_started: integer() | nil,
event_time: integer() | nil,
disposed: boolean()
}
defstruct [
:event_manager,
:name,
:channel,
players: [],
disconnected: [],
monsters: [],
kill_count: %{},
map_ids: [],
is_instanced: [],
properties: %{},
timer_started: false,
time_started: nil,
event_time: nil,
disposed: false
]
# ============================================================================
# Constructor
# ============================================================================
@doc """
Creates a new event instance.
## Parameters
- `event_manager` - Parent EventManager
- `name` - Unique instance name
- `channel` - Channel number
"""
@spec new(Odinsea.Scripting.EventManager.t() | nil, String.t(), integer()) :: t()
def new(event_manager, name, channel) do
%__MODULE__{
event_manager: event_manager,
name: name,
channel: channel
}
end
# ============================================================================
# Player Management
# ============================================================================
@doc """
Registers a player to this event instance.
## Parameters
- `eim` - Event instance
- `player` - Character struct or ID
"""
@spec register_player(t(), Character.t() | integer()) :: t()
def register_player(%{disposed: true} = eim, _), do: eim
def register_player(eim, player) do
char_id = case player do
%{id: id} -> id
id when is_integer(id) -> id
end
# Add to player list
players = [char_id | eim.players] |> Enum.uniq()
%{eim | players: players}
|> call_callback(:player_entry, [player])
end
@doc """
Unregisters a player from this event instance.
"""
@spec unregister_player(t(), Character.t() | integer()) :: t()
def unregister_player(%{disposed: true} = eim, _), do: eim
def unregister_player(eim, player) do
char_id = case player do
%{id: id} -> id
id when is_integer(id) -> id
end
players = List.delete(eim.players, char_id)
%{eim | players: players}
end
@doc """
Handles player changing maps.
"""
@spec changed_map(t(), Character.t() | integer(), integer()) :: t()
def changed_map(%{disposed: true} = eim, _, _), do: eim
def changed_map(eim, player, map_id) do
call_callback(eim, :changed_map, [player, map_id])
end
@doc """
Handles player death.
"""
@spec player_killed(t(), Character.t() | integer()) :: t()
def player_killed(%{disposed: true} = eim, _), do: eim
def player_killed(eim, player) do
call_callback(eim, :player_dead, [player])
end
@doc """
Handles player revive request.
## Returns
- `{allow_revive :: boolean(), updated_eim}`
"""
@spec revive_player(t(), Character.t() | integer()) :: {boolean(), t()}
def revive_player(%{disposed: true} = eim, _), do: {true, eim}
def revive_player(eim, player) do
result = call_callback_result(eim, :player_revive, [player])
allow = if is_boolean(result), do: result, else: true
{allow, eim}
end
@doc """
Handles player disconnection.
## Returns
- `{:dispose, eim}` - Dispose instance
- `{:continue, eim}` - Continue running
"""
@spec player_disconnected(t(), Character.t() | integer()) :: {:dispose | :continue, t()}
def player_disconnected(%{disposed: true} = eim, _), do: {:dispose, eim}
def player_disconnected(eim, player) do
char_id = case player do
%{id: id} -> id
id when is_integer(id) -> id
end
# Add to disconnected list
disconnected = [char_id | eim.disconnected]
eim = %{eim | disconnected: disconnected}
# Remove from players
eim = unregister_player(eim, player)
# Call callback to determine behavior
result = call_callback_result(eim, :player_disconnected, [player])
action = case result do
0 ->
# Dispose if no players
if length(eim.players) == 0, do: :dispose, else: :continue
x when x > 0 ->
# Dispose if less than x players
if length(eim.players) < x, do: :dispose, else: :continue
x when x < 0 ->
# Dispose if less than |x| players, or if leader disconnected
threshold = abs(x)
if length(eim.players) < threshold do
:dispose
else
# TODO: Check if leader disconnected
:continue
end
_ ->
:continue
end
{action, eim}
end
@doc """
Removes disconnected player ID from tracking.
"""
@spec remove_disconnected(t(), integer()) :: t()
def remove_disconnected(eim, char_id) do
disconnected = List.delete(eim.disconnected, char_id)
%{eim | disconnected: disconnected}
end
@doc """
Checks if a player is disconnected.
"""
@spec is_disconnected?(t(), integer()) :: boolean()
def is_disconnected?(eim, char_id) do
char_id in eim.disconnected
end
# ============================================================================
# Party Management
# ============================================================================
@doc """
Registers an entire party to the event.
"""
@spec register_party(t(), term(), integer()) :: t()
def register_party(%{disposed: true} = eim, _, _), do: eim
def register_party(eim, party, map_id) do
# TODO: Get party members and register each
eim
end
@doc """
Registers a squad (expedition) to the event.
"""
@spec register_squad(t(), term(), integer(), integer()) :: t()
def register_squad(%{disposed: true} = eim, _, _, _), do: eim
def register_squad(eim, squad, map_id, quest_id) do
# TODO: Register squad members
eim
end
@doc """
Handles player leaving party.
"""
@spec left_party(t(), Character.t() | integer()) :: t()
def left_party(%{disposed: true} = eim, _), do: eim
def left_party(eim, player) do
call_callback(eim, :left_party, [player])
end
@doc """
Handles party disbanding.
"""
@spec disband_party(t()) :: t()
def disband_party(%{disposed: true} = eim), do: eim
def disband_party(eim) do
call_callback(eim, :disband_party, [])
end
# ============================================================================
# Monster Management
# ============================================================================
@doc """
Registers a monster to this event.
"""
@spec register_monster(t(), integer()) :: t()
def register_monster(%{disposed: true} = eim, _), do: eim
def register_monster(eim, mob_id) do
monsters = [mob_id | eim.monsters]
%{eim | monsters: monsters}
end
@doc """
Unregisters a monster when killed.
"""
@spec unregister_monster(t(), integer()) :: t()
def unregister_monster(%{disposed: true} = eim, _), do: eim
def unregister_monster(eim, mob_id) do
monsters = List.delete(eim.monsters, mob_id)
eim = %{eim | monsters: monsters}
# If no monsters left, call allMonstersDead
if length(monsters) == 0 do
call_callback(eim, :all_monsters_dead, [])
else
eim
end
end
@doc """
Records monster kill and distributes points.
"""
@spec monster_killed(t(), Character.t() | integer(), integer()) :: t()
def monster_killed(%{disposed: true} = eim, _, _), do: eim
def monster_killed(eim, player, mob_id) do
# Get monster value from script
inc = call_callback_result(eim, :monster_value, [mob_id])
inc = if is_integer(inc), do: inc, else: 0
# Update kill count
char_id = case player do
%{id: id} -> id
id when is_integer(id) -> id
end
current = Map.get(eim.kill_count, char_id, 0)
kill_count = Map.put(eim.kill_count, char_id, current + inc)
%{eim | kill_count: kill_count}
end
@doc """
Gets kill count for a player.
"""
@spec get_kill_count(t(), integer()) :: integer()
def get_kill_count(eim, char_id) do
Map.get(eim.kill_count, char_id, 0)
end
# ============================================================================
# Timer Management
# ============================================================================
@doc """
Starts/restarts the event timer.
## Parameters
- `eim` - Event instance
- `time_ms` - Time in milliseconds
"""
@spec start_event_timer(t(), integer()) :: t()
def start_event_timer(eim, time_ms) do
restart_event_timer(eim, time_ms)
end
@doc """
Restarts the event timer.
"""
@spec restart_event_timer(t(), integer()) :: t()
def restart_event_timer(%{disposed: true} = eim, _), do: eim
def restart_event_timer(eim, time_ms) do
# Send clock packet to all players
time_seconds = div(time_ms, 1000)
broadcast_packet(eim, {:clock, time_seconds})
# Schedule timeout
if eim.event_manager do
Odinsea.Scripting.EventManager.schedule(
eim,
"scheduledTimeout",
time_ms
)
end
%{eim |
timer_started: true,
time_started: System.system_time(:millisecond),
event_time: time_ms
}
end
@doc """
Stops the event timer.
"""
@spec stop_event_timer(t()) :: t()
def stop_event_timer(eim) do
%{eim |
timer_started: false,
time_started: nil,
event_time: nil
}
end
@doc """
Checks if timer is started.
"""
@spec is_timer_started?(t()) :: boolean()
def is_timer_started?(eim) do
eim.timer_started && eim.time_started != nil
end
@doc """
Gets time remaining in milliseconds.
"""
@spec get_time_left(t()) :: integer()
def get_time_left(eim) do
if is_timer_started?(eim) do
elapsed = System.system_time(:millisecond) - eim.time_started
max(0, eim.event_time - elapsed)
else
0
end
end
@doc """
Schedules a custom method callback.
"""
@spec schedule(t(), String.t(), integer()) :: reference()
def schedule(eim, method_name, delay_ms) do
if eim.event_manager do
Odinsea.Scripting.EventManager.schedule(eim, method_name, delay_ms)
else
nil
end
end
# ============================================================================
# Map Instance Management
# ============================================================================
@doc """
Creates an instanced map (clone for PQ).
## Returns
- `{map_instance_id, updated_eim}`
"""
@spec create_instance_map(t(), integer()) :: {integer(), t()}
def create_instance_map(%{disposed: true} = eim, _), do: {0, eim}
def create_instance_map(eim, map_id) do
assigned_id = get_new_instance_map_id()
# TODO: Create actual map instance
# For now, just track the ID
eim = %{eim |
map_ids: [assigned_id | eim.map_ids],
is_instanced: [true | eim.is_instanced]
}
{assigned_id, eim}
end
@doc """
Creates an instanced map with simplified settings.
"""
@spec create_instance_map_s(t(), integer()) :: {integer(), t()}
def create_instance_map_s(eim, map_id) do
create_instance_map(eim, map_id)
end
@doc """
Sets an existing map as part of this event.
"""
@spec set_instance_map(t(), integer()) :: t()
def set_instance_map(%{disposed: true} = eim, _), do: eim
def set_instance_map(eim, map_id) do
%{eim |
map_ids: [map_id | eim.map_ids],
is_instanced: [false | eim.is_instanced]
}
end
@doc """
Gets a map instance by index.
"""
@spec get_map_instance(t(), integer()) :: term()
def get_map_instance(eim, index) when index < length(eim.map_ids) do
map_id = Enum.at(eim.map_ids, index)
is_instanced = Enum.at(eim.is_instanced, index)
# TODO: Return actual map
%{id: map_id, instanced: is_instanced}
end
def get_map_instance(eim, map_id) when is_integer(map_id) do
# Assume it's a real map ID
%{id: map_id, instanced: false}
end
# ============================================================================
# Properties
# ============================================================================
@doc """
Sets a property on this instance.
"""
@spec set_property(t(), String.t(), String.t()) :: t()
def set_property(%{disposed: true} = eim, _, _), do: eim
def set_property(eim, key, value) do
properties = Map.put(eim.properties, key, value)
%{eim | properties: properties}
end
@doc """
Gets a property value.
"""
@spec get_property(t(), String.t()) :: String.t() | nil
def get_property(eim, key) do
Map.get(eim.properties, key)
end
# ============================================================================
# Player Actions
# ============================================================================
@doc """
Removes a player from the event (warp out).
"""
@spec remove_player(t(), Character.t() | integer()) :: t()
def remove_player(%{disposed: true} = eim, _), do: eim
def remove_player(eim, player) do
call_callback(eim, :player_exit, [player])
end
@doc """
Finishes the party quest.
"""
@spec finish_pq(t()) :: t()
def finish_pq(%{disposed: true} = eim), do: eim
def finish_pq(eim) do
call_callback(eim, :clear_pq, [])
end
@doc """
Awards achievement to all players.
"""
@spec give_achievement(t(), integer()) :: :ok
def give_achievement(eim, type) do
broadcast_to_players(eim, {:achievement, type})
:ok
end
@doc """
Broadcasts a message to all players in the event.
"""
@spec broadcast_player_msg(t(), integer(), String.t()) :: :ok
def broadcast_player_msg(eim, type, message) do
broadcast_to_players(eim, {:message, type, message})
:ok
end
@doc """
Broadcasts a raw packet to all players.
"""
@spec broadcast_packet(t(), term()) :: :ok
def broadcast_packet(eim, packet) do
Enum.each(eim.players, fn char_id ->
# TODO: Send packet to player
:ok
end)
end
@doc """
Broadcasts packet to team members.
"""
@spec broadcast_team_packet(t(), term(), integer()) :: :ok
def broadcast_team_packet(eim, packet, team) do
# TODO: Filter by team and send
:ok
end
@doc """
Applies buff to a player.
"""
@spec apply_buff(t(), Character.t() | integer(), integer()) :: :ok
def apply_buff(eim, player, buff_id) do
# TODO: Apply item effect
:ok
end
@doc """
Applies skill to a player.
"""
@spec apply_skill(t(), Character.t() | integer(), integer()) :: :ok
def apply_skill(eim, player, skill_id) do
# TODO: Apply skill effect
:ok
end
# ============================================================================
# Carnival Party (CPQ)
# ============================================================================
@doc """
Registers a carnival party.
"""
@spec register_carnival_party(t(), term()) :: t()
def register_carnival_party(%{disposed: true} = eim, _), do: eim
def register_carnival_party(eim, carnival_party) do
call_callback(eim, :register_carnival_party, [carnival_party])
end
# ============================================================================
# Disposal
# ============================================================================
@doc """
Disposes the event instance if player count is at or below threshold.
## Returns
- `{true, eim}` - Instance was disposed
- `{false, eim}` - Instance not disposed
"""
@spec dispose_if_player_below(t(), integer(), integer()) :: {boolean(), t()}
def dispose_if_player_below(%{disposed: true} = eim, _, _), do: {true, eim}
def dispose_if_player_below(eim, size, warp_map_id) do
if length(eim.players) <= size do
# Warp players if map specified
if warp_map_id > 0 do
# TODO: Warp all players
end
{true, dispose(eim)}
else
{false, eim}
end
end
@doc """
Disposes the event instance.
"""
@spec dispose(t()) :: t()
def dispose(%{disposed: true} = eim), do: eim
def dispose(eim) do
# Clear player event instances
Enum.each(eim.players, fn char_id ->
# TODO: Clear player's event instance reference
:ok
end)
# Remove instanced maps
Enum.zip(eim.map_ids, eim.is_instanced)
|> Enum.each(fn {map_id, instanced} ->
if instanced do
# TODO: Remove instance map
:ok
end
end)
# Notify event manager
if eim.event_manager do
Odinsea.Scripting.EventManager.dispose_instance(eim.name)
end
%{eim |
disposed: true,
players: [],
monsters: [],
kill_count: %{},
map_ids: [],
is_instanced: []
}
end
# ============================================================================
# Utility
# ============================================================================
@doc """
Checks if player is the leader.
"""
@spec is_leader?(t(), Character.t() | integer()) :: boolean()
def is_leader?(_eim, _player) do
# TODO: Check party leadership
false
end
@doc """
Gets player count.
"""
@spec get_player_count(t()) :: integer()
def get_player_count(%{disposed: true}), do: 0
def get_player_count(eim), do: length(eim.players)
@doc """
Gets the list of players.
"""
@spec get_players(t()) :: [integer()]
def get_players(%{disposed: true}), do: []
def get_players(eim), do: eim.players
@doc """
Gets the list of monsters.
"""
@spec get_mobs(t()) :: [integer()]
def get_mobs(eim), do: eim.monsters
@doc """
Handles map load event.
"""
@spec on_map_load(t(), Character.t() | integer()) :: t()
def on_map_load(%{disposed: true} = eim, _), do: eim
def on_map_load(eim, player) do
call_callback(eim, :on_map_load, [player])
end
@doc """
Creates a new pair list (utility for scripts).
"""
@spec new_pair() :: list()
def new_pair(), do: []
@doc """
Adds to a pair list.
"""
@spec add_to_pair(list(), term(), term()) :: list()
def add_to_pair(list, key, value) do
[{key, value} | list]
end
@doc """
Creates a new character pair list.
"""
@spec new_pair_chr() :: list()
def new_pair_chr(), do: []
@doc """
Adds to a character pair list.
"""
@spec add_to_pair_chr(list(), term(), term()) :: list()
def add_to_pair_chr(list, key, value) do
[{key, value} | list]
end
# ============================================================================
# Private Functions
# ============================================================================
defp call_callback(%{disposed: true} = eim, _method, _args), do: eim
defp call_callback(eim, method, args) do
if eim.event_manager && eim.event_manager.script_module do
mod = eim.event_manager.script_module
if function_exported?(mod, method, length(args) + 1) do
try do
apply(mod, method, [eim | args])
rescue
e ->
Logger.error("Event callback #{method} error: #{inspect(e)}")
eim
end
else
eim
end
else
eim
end
end
defp call_callback_result(eim, method, args) do
if eim.event_manager && eim.event_manager.script_module do
mod = eim.event_manager.script_module
if function_exported?(mod, method, length(args) + 1) do
try do
apply(mod, method, [eim | args])
rescue
e ->
Logger.error("Event callback #{method} error: #{inspect(e)}")
nil
end
else
nil
end
else
nil
end
end
defp broadcast_to_players(_eim, _message) do
# TODO: Implement broadcasting
:ok
end
# Global counter for instance map IDs
defp get_new_instance_map_id() do
# Use persistent_term or similar for atomic increment
:counters.add(:instance_counter, 1, 1)
rescue
_ ->
# Fallback if counter doesn't exist
System.unique_integer([:positive])
end
end