defmodule Odinsea.Game.Map do @moduledoc """ Represents a game map instance. Each map is a GenServer that manages all objects on the map: - Players - Monsters (mobs) - NPCs - Items (drops) - Reactors - Portals Maps are registered by map_id and belong to a specific channel. """ use GenServer require Logger alias Odinsea.Game.Character alias Odinsea.Game.{Drop, MapFactory, LifeFactory, Monster, Reactor, ReactorFactory} alias Odinsea.Channel.Packets, as: ChannelPackets # ============================================================================ # Data Structures # ============================================================================ defmodule SpawnPoint do @moduledoc "Represents a monster spawn point on the map" defstruct [ :id, # Unique spawn point ID :mob_id, # Monster ID to spawn :x, # Spawn position X :y, # Spawn position Y :fh, # Foothold :cy, # CY value :f, # Facing direction (0 = left, 1 = right) :mob_time, # Respawn time in milliseconds :spawned_oid, # OID of currently spawned monster (nil if not spawned) :last_spawn_time, # Last time monster was spawned :respawn_timer_ref # Timer reference for respawn ] @type t :: %__MODULE__{ id: integer(), mob_id: integer(), x: integer(), y: integer(), fh: integer(), cy: integer(), f: integer(), mob_time: integer(), spawned_oid: integer() | nil, last_spawn_time: DateTime.t() | nil, respawn_timer_ref: reference() | nil } end defmodule State do @moduledoc "Map instance state" defstruct [ # Map identity :map_id, :channel_id, # Objects on map (by type) :players, # Map stores character_id => %{oid: integer(), character: Character.State} :monsters, # Map stores oid => Monster.t() :npcs, # Map stores oid => NPC :items, # Map stores oid => Item :reactors, # Map stores oid => Reactor :spawn_points, # Map stores spawn_id => SpawnPoint.t() # Object ID counter :next_oid, # Map properties (loaded from MapFactory) :return_map, :forced_return, :time_limit, :field_limit, :mob_rate, :drop_rate, :map_name, :street_name, # Timestamps :created_at ] @type t :: %__MODULE__{ map_id: non_neg_integer(), channel_id: byte(), players: %{pos_integer() => map()}, monsters: %{pos_integer() => Monster.t()}, npcs: %{pos_integer() => any()}, items: %{pos_integer() => any()}, reactors: %{pos_integer() => any()}, spawn_points: %{integer() => SpawnPoint.t()}, next_oid: pos_integer(), return_map: non_neg_integer() | nil, forced_return: non_neg_integer() | nil, time_limit: non_neg_integer() | nil, field_limit: non_neg_integer() | nil, mob_rate: float(), drop_rate: float(), map_name: String.t() | nil, street_name: String.t() | nil, created_at: DateTime.t() } end # ============================================================================ # Client API # ============================================================================ @doc """ Starts a map GenServer. """ def start_link(opts) do map_id = Keyword.fetch!(opts, :map_id) channel_id = Keyword.fetch!(opts, :channel_id) GenServer.start_link(__MODULE__, opts, name: via_tuple(map_id, channel_id)) end @doc """ Ensures a map is loaded for the given channel. """ def ensure_map(map_id, channel_id) do case Registry.lookup(Odinsea.MapRegistry, {map_id, channel_id}) do [{pid, _}] -> {:ok, pid} [] -> # Start map via DynamicSupervisor spec = {__MODULE__, map_id: map_id, channel_id: channel_id} case DynamicSupervisor.start_child(Odinsea.MapSupervisor, spec) do {:ok, pid} -> {:ok, pid} {:error, {:already_started, pid}} -> {:ok, pid} error -> error end end end @doc """ Adds a player to the map. """ def add_player(map_id, character_id) do # TODO: Get channel_id from somewhere channel_id = 1 {:ok, _pid} = ensure_map(map_id, channel_id) GenServer.call(via_tuple(map_id, channel_id), {:add_player, character_id}) end @doc """ Removes a player from the map. """ def remove_player(map_id, character_id) do # TODO: Get channel_id from somewhere channel_id = 1 case Registry.lookup(Odinsea.MapRegistry, {map_id, channel_id}) do [{_pid, _}] -> GenServer.call(via_tuple(map_id, channel_id), {:remove_player, character_id}) [] -> :ok end end @doc """ Broadcasts a packet to all players on the map. """ def broadcast(map_id, channel_id, packet) do GenServer.cast(via_tuple(map_id, channel_id), {:broadcast, packet}) end @doc """ Broadcasts a packet to all players except the specified character. """ def broadcast_except(map_id, channel_id, except_character_id, packet) do GenServer.cast(via_tuple(map_id, channel_id), {:broadcast_except, except_character_id, packet}) end @doc """ Gets all players on the map. """ def get_players(map_id, channel_id) do GenServer.call(via_tuple(map_id, channel_id), :get_players) end @doc """ Gets all monsters on the map. """ def get_monsters(map_id, channel_id) do GenServer.call(via_tuple(map_id, channel_id), :get_monsters) end @doc """ Spawns a monster at the specified spawn point. """ def spawn_monster(map_id, channel_id, spawn_id) do GenServer.cast(via_tuple(map_id, channel_id), {:spawn_monster, spawn_id}) end @doc """ Handles monster death and initiates respawn. """ def monster_killed(map_id, channel_id, oid, killer_id \\ nil) do GenServer.cast(via_tuple(map_id, channel_id), {:monster_killed, oid, killer_id}) end @doc """ Damages a monster. """ def damage_monster(map_id, channel_id, oid, damage, character_id) do GenServer.call(via_tuple(map_id, channel_id), {:damage_monster, oid, damage, character_id}) end @doc """ Hits a reactor, advancing its state and triggering effects. """ def hit_reactor(map_id, channel_id, oid, character_id, stance \\ 0) do GenServer.call(via_tuple(map_id, channel_id), {:hit_reactor, oid, character_id, stance}) end @doc """ Destroys a reactor (e.g., after final state). """ def destroy_reactor(map_id, channel_id, oid) do GenServer.call(via_tuple(map_id, channel_id), {:destroy_reactor, oid}) end @doc """ Gets a reactor by OID. """ def get_reactor(map_id, channel_id, oid) do GenServer.call(via_tuple(map_id, channel_id), {:get_reactor, oid}) end @doc """ Gets all reactors on the map. """ def get_reactors(map_id, channel_id) do GenServer.call(via_tuple(map_id, channel_id), :get_reactors) end @doc """ Respawns a destroyed reactor after its delay. """ def respawn_reactor(map_id, channel_id, original_reactor) do GenServer.cast(via_tuple(map_id, channel_id), {:respawn_reactor, original_reactor}) end # ============================================================================ # GenServer Callbacks # ============================================================================ @impl true def init(opts) do map_id = Keyword.fetch!(opts, :map_id) channel_id = Keyword.fetch!(opts, :channel_id) # Load map template from MapFactory template = MapFactory.get_template(map_id) spawn_points = if template do load_spawn_points(template) else %{} end # Load reactor spawns from template and create reactors reactors = if template do load_reactors(template, 500_000) else {%{}, 500_000} end {reactor_map, next_oid} = reactors state = %State{ map_id: map_id, channel_id: channel_id, players: %{}, monsters: %{}, npcs: %{}, items: %{}, reactors: reactor_map, spawn_points: spawn_points, next_oid: next_oid, return_map: if(template, do: template.return_map, else: nil), forced_return: if(template, do: template.forced_return, else: nil), time_limit: if(template, do: template.time_limit, else: nil), field_limit: if(template, do: template.field_limit, else: 0), mob_rate: if(template, do: template.mob_rate, else: 1.0), drop_rate: 1.0, map_name: if(template, do: template.map_name, else: "Unknown"), street_name: if(template, do: template.street_name, else: ""), created_at: DateTime.utc_now() } Logger.debug("Map loaded: #{map_id} (channel #{channel_id}) - #{map_size(spawn_points)} spawn points") # Schedule initial monster spawning if map_size(spawn_points) > 0 do Process.send_after(self(), :spawn_initial_monsters, 100) end {:ok, state} end @impl true def handle_call({:add_player, character_id}, _from, state) do # Allocate OID for this player oid = state.next_oid # Get character state case Character.get_state(character_id) do %Character.State{} = char_state -> # Add player to map player_entry = %{ oid: oid, character: char_state } new_players = Map.put(state.players, character_id, player_entry) # Broadcast spawn packet to other players spawn_packet = ChannelPackets.spawn_player(oid, char_state) broadcast_to_players(new_players, spawn_packet, except: character_id) # Send existing players to new player client_pid = char_state.client_pid if client_pid do send_existing_players(client_pid, new_players, except: character_id) send_existing_monsters(client_pid, state.monsters) send_existing_reactors(client_pid, state.reactors) end new_state = %{ state | players: new_players, next_oid: oid + 1 } {:reply, {:ok, oid}, new_state} nil -> {:reply, {:error, :character_not_found}, state} end end @impl true def handle_call({:remove_player, character_id}, _from, state) do case Map.get(state.players, character_id) do nil -> {:reply, :ok, state} player_entry -> # Broadcast despawn packet despawn_packet = ChannelPackets.remove_player(player_entry.oid) broadcast_to_players(state.players, despawn_packet, except: character_id) # Remove from map new_players = Map.delete(state.players, character_id) new_state = %{state | players: new_players} {:reply, :ok, new_state} end end @impl true def handle_call(:get_players, _from, state) do {:reply, state.players, state} end @impl true def handle_call(:get_monsters, _from, state) do {:reply, state.monsters, state} end @impl true def handle_call({:damage_monster, oid, damage_amount, character_id}, _from, state) do case Map.get(state.monsters, oid) do nil -> {:reply, {:error, :monster_not_found}, state} monster -> # Apply damage to monster case Monster.damage(monster, damage_amount, character_id) do {:dead, updated_monster, actual_damage} -> # Monster died Logger.debug("Monster #{oid} killed on map #{state.map_id}") # Remove monster from map new_monsters = Map.delete(state.monsters, oid) # Find spawn point spawn_point_id = find_spawn_point_by_oid(state.spawn_points, oid) # Update spawn point to clear spawned monster new_spawn_points = if spawn_point_id do update_spawn_point(state.spawn_points, spawn_point_id, fn sp -> %{sp | spawned_oid: nil, last_spawn_time: DateTime.utc_now()} end) else state.spawn_points end # Schedule respawn if spawn_point_id do spawn_point = Map.get(new_spawn_points, spawn_point_id) schedule_respawn(spawn_point_id, spawn_point.mob_time) end # Broadcast monster death packet kill_packet = ChannelPackets.kill_monster(updated_monster, 1) broadcast_to_players(state.players, kill_packet) Logger.debug("Monster killed: OID #{oid} on map #{state.map_id}") # Calculate and distribute EXP distribute_exp(updated_monster, state.players, character_id) # Create drops new_state = if not Monster.drops_disabled?(updated_monster) do create_monster_drops(updated_monster, character_id, state) else %{state | monsters: new_monsters, spawn_points: new_spawn_points} end {:reply, {:ok, :killed}, new_state} {:ok, updated_monster, actual_damage} -> # Monster still alive new_monsters = Map.put(state.monsters, oid, updated_monster) new_state = %{state | monsters: new_monsters} # Broadcast damage packet damage_packet = ChannelPackets.damage_monster(oid, actual_damage) broadcast_to_players(state.players, damage_packet) {:reply, {:ok, :damaged}, new_state} end end end @impl true def handle_cast({:broadcast, packet}, state) do broadcast_to_players(state.players, packet) {:noreply, state} end @impl true def handle_cast({:broadcast_except, except_character_id, packet}, state) do broadcast_to_players(state.players, packet, except: except_character_id) {:noreply, state} end # ============================================================================ # Reactor Callbacks # ============================================================================ @impl true def handle_call({:hit_reactor, oid, _character_id, stance}, _from, state) do case Map.get(state.reactors, oid) do nil -> {:reply, {:error, :reactor_not_found}, state} reactor -> if not reactor.alive do {:reply, {:error, :reactor_not_alive}, state} else # Advance reactor state old_state = reactor.state new_reactor = Reactor.advance_state(reactor) # Check if reactor should be destroyed if Reactor.should_destroy?(new_reactor) do # Destroy reactor destroy_packet = ChannelPackets.destroy_reactor(new_reactor) broadcast_to_players(state.players, destroy_packet) new_reactor = Reactor.set_alive(new_reactor, false) new_reactors = Map.put(state.reactors, oid, new_reactor) # Schedule respawn if delay is set if new_reactor.delay > 0 do schedule_reactor_respawn(oid, new_reactor.delay) end {:reply, {:ok, :destroyed}, %{state | reactors: new_reactors}} else # Trigger state change trigger_packet = ChannelPackets.trigger_reactor(new_reactor, stance) broadcast_to_players(state.players, trigger_packet) # Check for timeout and schedule if needed timeout = Reactor.get_timeout(new_reactor) new_reactor = if timeout > 0 do Reactor.set_timer_active(new_reactor, true) else new_reactor end new_reactors = Map.put(state.reactors, oid, new_reactor) # If state changed, this might trigger a script script_trigger = old_state != new_reactor.state {:reply, {:ok, %{state_changed: true, script_trigger: script_trigger}}, %{state | reactors: new_reactors}} end end end end @impl true def handle_call({:destroy_reactor, oid}, _from, state) do case Map.get(state.reactors, oid) do nil -> {:reply, {:error, :reactor_not_found}, state} reactor -> # Broadcast destroy destroy_packet = ChannelPackets.destroy_reactor(reactor) broadcast_to_players(state.players, destroy_packet) new_reactor = reactor |> Reactor.set_alive(false) |> Reactor.set_timer_active(false) new_reactors = Map.put(state.reactors, oid, new_reactor) # Schedule respawn if delay is set if reactor.delay > 0 do schedule_reactor_respawn(oid, reactor.delay) end {:reply, :ok, %{state | reactors: new_reactors}} end end @impl true def handle_call({:get_reactor, oid}, _from, state) do {:reply, Map.get(state.reactors, oid), state} end @impl true def handle_call(:get_reactors, _from, state) do {:reply, state.reactors, state} end @impl true def handle_cast({:respawn_reactor, original_reactor}, state) do # Create a fresh copy of the reactor respawned = original_reactor |> Reactor.copy() |> Reactor.set_oid(state.next_oid) new_reactors = Map.put(state.reactors, state.next_oid, respawned) # Broadcast spawn spawn_packet = ChannelPackets.spawn_reactor(respawned) broadcast_to_players(state.players, spawn_packet) Logger.debug("Reactor #{respawned.reactor_id} respawned on map #{state.map_id} with OID #{state.next_oid}") {:noreply, %{state | reactors: new_reactors, next_oid: state.next_oid + 1}} end # ============================================================================ # Monster Callbacks # ============================================================================ @impl true def handle_cast({:spawn_monster, spawn_id}, state) do case Map.get(state.spawn_points, spawn_id) do nil -> Logger.warn("Spawn point #{spawn_id} not found on map #{state.map_id}") {:noreply, state} spawn_point -> if spawn_point.spawned_oid do # Already spawned {:noreply, state} else # Spawn new monster {new_state, _oid} = do_spawn_monster(state, spawn_point, spawn_id) {:noreply, new_state} end end end @impl true def handle_cast({:monster_killed, oid, killer_id}, state) do # Handle monster death (called externally) case Map.get(state.monsters, oid) do nil -> {:noreply, state} monster -> # Remove monster new_monsters = Map.delete(state.monsters, oid) # Find and update spawn point spawn_point_id = find_spawn_point_by_oid(state.spawn_points, oid) new_spawn_points = if spawn_point_id do update_spawn_point(state.spawn_points, spawn_point_id, fn sp -> %{sp | spawned_oid: nil, last_spawn_time: DateTime.utc_now()} end) else state.spawn_points end # Schedule respawn if spawn_point_id do spawn_point = Map.get(new_spawn_points, spawn_point_id) schedule_respawn(spawn_point_id, spawn_point.mob_time) end # Create drops if killer_id is provided new_state = if killer_id && not Monster.drops_disabled?(monster) do monster_with_stats = %{monster | attackers: %{}} # Reset attackers since this is external create_monster_drops(monster, killer_id, %{state | monsters: new_monsters, spawn_points: new_spawn_points }) else %{state | monsters: new_monsters, spawn_points: new_spawn_points} end {:noreply, new_state} end end @impl true def handle_info(:spawn_initial_monsters, state) do Logger.debug("Spawning initial monsters on map #{state.map_id}") # Spawn all monsters at their spawn points new_state = Enum.reduce(state.spawn_points, state, fn {spawn_id, spawn_point}, acc_state -> {updated_state, _oid} = do_spawn_monster(acc_state, spawn_point, spawn_id) updated_state end) {:noreply, new_state} end @impl true def handle_info({:respawn_monster, spawn_id}, state) do # Respawn monster at spawn point case Map.get(state.spawn_points, spawn_id) do nil -> {:noreply, state} spawn_point -> if spawn_point.spawned_oid do # Already spawned (shouldn't happen) {:noreply, state} else {new_state, _oid} = do_spawn_monster(state, spawn_point, spawn_id) {:noreply, new_state} end end end @impl true def handle_info({:respawn_reactor, oid}, state) do # Respawn a destroyed reactor case Map.get(state.reactors, oid) do nil -> {:noreply, state} original_reactor -> if original_reactor.alive do # Already alive (shouldn't happen) {:noreply, state} else # Create a fresh copy respawned = original_reactor |> Reactor.copy() |> Reactor.set_oid(state.next_oid) new_reactors = state.reactors |> Map.delete(oid) # Remove old destroyed reactor |> Map.put(state.next_oid, respawned) # Broadcast spawn to players spawn_packet = ChannelPackets.spawn_reactor(respawned) broadcast_to_players(state.players, spawn_packet) Logger.debug("Reactor #{respawned.reactor_id} respawned on map #{state.map_id} with OID #{state.next_oid}") {:noreply, %{state | reactors: new_reactors, next_oid: state.next_oid + 1}} end end end # ============================================================================ # Helper Functions # ============================================================================ defp via_tuple(map_id, channel_id) do {:via, Registry, {Odinsea.MapRegistry, {map_id, channel_id}}} end defp broadcast_to_players(players, packet, opts \\ []) do except_char_id = Keyword.get(opts, :except) Enum.each(players, fn {char_id, %{character: char_state}} when char_id != except_char_id -> if char_state.client_pid do send_packet(char_state.client_pid, packet) end _ -> :ok end) end defp send_existing_players(client_pid, players, opts) do except_char_id = Keyword.get(opts, :except) Enum.each(players, fn {char_id, %{oid: oid, character: char_state}} when char_id != except_char_id -> spawn_packet = ChannelPackets.spawn_player(oid, char_state) send_packet(client_pid, spawn_packet) _ -> :ok end) end defp send_existing_monsters(client_pid, monsters) do Enum.each(monsters, fn {_oid, monster} -> spawn_packet = ChannelPackets.spawn_monster(monster, -1, 0) send_packet(client_pid, spawn_packet) end) end defp send_existing_reactors(client_pid, reactors) do Enum.each(reactors, fn {_oid, reactor} -> spawn_packet = ChannelPackets.spawn_reactor(reactor) send_packet(client_pid, spawn_packet) end) end defp send_packet(client_pid, packet) do send(client_pid, {:send_packet, packet}) end # ============================================================================ # Monster Spawning Helpers # ============================================================================ defp load_spawn_points(template) do # Load spawn points from template template.spawn_points |> Enum.with_index() |> Enum.map(fn {sp, idx} -> spawn_point = %SpawnPoint{ id: idx, mob_id: sp.mob_id, x: sp.x, y: sp.y, fh: sp.fh, cy: sp.cy, f: sp.f || 0, mob_time: sp.mob_time || 10_000, # Default 10 seconds spawned_oid: nil, last_spawn_time: nil, respawn_timer_ref: nil } {idx, spawn_point} end) |> Map.new() rescue _e -> Logger.warn("Failed to load spawn points for map, using empty spawn list") %{} end defp load_reactors(template, starting_oid) do # Load reactors from template reactor spawns {reactor_map, next_oid} = template.reactor_spawns |> Enum.with_index(starting_oid) |> Enum.reduce({%{}, starting_oid}, fn {rs, oid}, {acc_map, _acc_oid} -> case ReactorFactory.create_reactor( rs.reactor_id, rs.x, rs.y, rs.facing_direction, rs.name, rs.delay ) do nil -> # Reactor stats not found, skip {acc_map, oid} reactor -> # Assign OID to reactor reactor = Reactor.set_oid(reactor, oid) {Map.put(acc_map, oid, reactor), oid + 1} end end) count = map_size(reactor_map) if count > 0 do Logger.debug("Loaded #{count} reactors on map #{template.map_id}") end {reactor_map, next_oid} rescue _e -> Logger.warn("Failed to load reactors for map, using empty reactor list") {%{}, starting_oid} end defp do_spawn_monster(state, spawn_point, spawn_id) do # Get monster stats from LifeFactory case LifeFactory.get_monster_stats(spawn_point.mob_id) do nil -> Logger.warn("Monster stats not found for mob_id #{spawn_point.mob_id}") {state, nil} stats -> # Allocate OID oid = state.next_oid # Create monster instance position = %{x: spawn_point.x, y: spawn_point.y, fh: spawn_point.fh} monster = %Monster{ oid: oid, mob_id: spawn_point.mob_id, stats: stats, hp: stats.hp, mp: stats.mp, max_hp: stats.hp, max_mp: stats.mp, position: position, stance: 5, # Default stance controller_id: nil, controller_has_aggro: false, spawn_effect: 0, team: -1, fake: false, link_oid: 0, status_effects: %{}, poisons: [], attackers: %{}, last_attack: System.system_time(:millisecond), last_move: System.system_time(:millisecond), last_skill_use: 0, killed: false, drops_disabled: false, create_time: System.system_time(:millisecond) } # Add to monsters map new_monsters = Map.put(state.monsters, oid, monster) # Update spawn point new_spawn_points = update_spawn_point(state.spawn_points, spawn_id, fn sp -> %{sp | spawned_oid: oid, last_spawn_time: DateTime.utc_now()} end) # Broadcast monster spawn packet to all players spawn_packet = ChannelPackets.spawn_monster(monster, -1, 0) broadcast_to_players(state.players, spawn_packet) Logger.debug("Spawned monster #{monster.mob_id} (OID: #{oid}) on map #{state.map_id}") new_state = %{ state | monsters: new_monsters, spawn_points: new_spawn_points, next_oid: oid + 1 } Logger.debug("Spawned monster #{spawn_point.mob_id} (OID: #{oid}) on map #{state.map_id}") {new_state, oid} end end defp find_spawn_point_by_oid(spawn_points, oid) do Enum.find_value(spawn_points, fn {spawn_id, sp} -> if sp.spawned_oid == oid, do: spawn_id, else: nil end) end defp update_spawn_point(spawn_points, spawn_id, update_fn) do case Map.get(spawn_points, spawn_id) do nil -> spawn_points sp -> Map.put(spawn_points, spawn_id, update_fn.(sp)) end end defp schedule_respawn(spawn_id, mob_time) do # Schedule respawn message Process.send_after(self(), {:respawn_monster, spawn_id}, mob_time) end defp schedule_reactor_respawn(oid, delay) do # Schedule reactor respawn message Process.send_after(self(), {:respawn_reactor, oid}, delay) end # ============================================================================ # EXP Distribution # ============================================================================ defp distribute_exp(monster, players, _killer_id) do # Calculate base EXP from monster base_exp = calculate_monster_exp(monster) if base_exp > 0 do # Find highest damage dealer {highest_attacker_id, _highest_damage} = Enum.max_by( monster.attackers, fn {_id, entry} -> entry.damage end, fn -> {nil, 0} end ) # Distribute EXP to all attackers Enum.each(monster.attackers, fn {attacker_id, attacker_data} -> # Calculate EXP share based on damage dealt damage_ratio = attacker_data.damage / max(1, monster.max_hp) attacker_exp = trunc(base_exp * min(1.0, damage_ratio)) is_highest = attacker_id == highest_attacker_id # Find character and give EXP case find_character_pid(attacker_id) do {:ok, character_pid} -> give_exp_to_character(character_pid, attacker_exp, is_highest, monster) {:error, _} -> Logger.debug("Character #{attacker_id} not found for EXP distribution") end end) end end defp calculate_monster_exp(monster) do # Base EXP from monster stats base = monster.stats.exp # Apply any multipliers # TODO: Add event multipliers, premium account bonuses, etc. base end defp give_exp_to_character(character_pid, exp_amount, is_highest, monster) do # TODO: Apply EXP buffs (Holy Symbol, exp cards, etc.) # TODO: Apply level difference penalties # TODO: Apply server rates final_exp = exp_amount # Give EXP to character case Character.gain_exp(character_pid, final_exp, is_highest) do :ok -> Logger.debug("Gave #{final_exp} EXP to character (highest: #{is_highest})") {:error, reason} -> Logger.warning("Failed to give EXP to character: #{inspect(reason)}") end end defp find_character_pid(character_id) do case Registry.lookup(Odinsea.CharacterRegistry, character_id) do [{pid, _}] -> {:ok, pid} [] -> {:error, :not_found} end end # ============================================================================ # Drop System # ============================================================================ defp create_monster_drops(monster, killer_id, state) do # Get monster position position = monster.position # Calculate drop rate multiplier (from map/server rates) drop_rate_multiplier = state.drop_rate # Create drops drops = DropSystem.create_monster_drops( monster.mob_id, killer_id, position, state.next_oid, drop_rate_multiplier ) # Also create global drops global_drops = DropSystem.create_global_drops( killer_id, position, state.next_oid + length(drops), drop_rate_multiplier ) all_drops = drops ++ global_drops if length(all_drops) > 0 do # Add drops to map state new_items = Enum.reduce(all_drops, state.items, fn drop, items -> Map.put(items, drop.oid, drop) end) # Broadcast drop spawn packets Enum.each(all_drops, fn drop -> spawn_packet = ChannelPackets.spawn_drop(drop, position, 1) broadcast_to_players(state.players, spawn_packet) end) # Update next OID next_oid = state.next_oid + length(all_drops) Logger.debug("Created #{length(all_drops)} drops on map #{state.map_id}") %{state | items: new_items, next_oid: next_oid} else state end end @doc """ Gets all drops on the map. """ def get_drops(map_id, channel_id) do GenServer.call(via_tuple(map_id, channel_id), :get_drops) end @doc """ Attempts to pick up a drop. Returns {:ok, drop} if successful, {:error, reason} if not. """ def pickup_drop(map_id, channel_id, drop_oid, character_id) do GenServer.call(via_tuple(map_id, channel_id), {:pickup_drop, drop_oid, character_id}) end @doc """ Checks if a drop is visible to a character (for quest items, individual rewards). """ def drop_visible_to?(map_id, channel_id, drop_oid, character_id, quest_status \\ %{}) do GenServer.call(via_tuple(map_id, channel_id), {:drop_visible_to, drop_oid, character_id, quest_status}) end @impl true def handle_call(:get_drops, _from, state) do {:reply, state.items, state} end @impl true def handle_call({:pickup_drop, drop_oid, character_id}, _from, state) do case Map.get(state.items, drop_oid) do nil -> {:reply, {:error, :drop_not_found}, state} drop -> now = System.system_time(:millisecond) # Validate ownership using Drop.can_loot? if not Drop.can_loot?(drop, character_id, now) do {:reply, {:error, :not_owner}, state} else case DropSystem.pickup_drop(drop, character_id, now) do {:ok, updated_drop} -> # Broadcast pickup animation to all players remove_packet = ChannelPackets.remove_drop(drop_oid, 2, character_id) broadcast_to_players(state.players, remove_packet) # Remove from map new_items = Map.delete(state.items, drop_oid) # Return drop info for inventory addition {:reply, {:ok, updated_drop}, %{state | items: new_items}} {:error, reason} -> {:reply, {:error, reason}, state} end end end end @impl true def handle_call({:drop_visible_to, drop_oid, character_id, quest_status}, _from, state) do case Map.get(state.items, drop_oid) do nil -> {:reply, false, state} drop -> visible = Drop.visible_to?(drop, character_id, quest_status) {:reply, visible, state} end end @impl true def handle_info(:check_drop_expiration, state) do now = System.system_time(:millisecond) # Check for expired drops {expired_drops, valid_drops} = Enum.split_with(state.items, fn {_oid, drop} -> Drop.should_expire?(drop, now) end) # Broadcast expiration for expired drops Enum.each(expired_drops, fn {oid, _drop} -> expire_packet = ChannelPackets.remove_drop(oid, 0, 0) broadcast_to_players(state.players, expire_packet) end) # Convert valid drops back to map new_items = Map.new(valid_drops) # Schedule next check if there are drops remaining if map_size(new_items) > 0 do Process.send_after(self(), :check_drop_expiration, 10_000) end {:noreply, %{state | items: new_items}} end defp send_existing_items(client_pid, items) do Enum.each(items, fn {_oid, drop} -> if not drop.picked_up do packet = ChannelPackets.spawn_drop(drop, nil, 2) send_packet(client_pid, packet) end end) end end