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