Files
odinsea-elixir/lib/odinsea/game/map.ex
2026-02-25 12:26:26 -07:00

1188 lines
35 KiB
Elixir

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 """
Gets all monsters on the map (default channel).
"""
def get_monsters(map_id) do
get_monsters(map_id, 1)
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 """
Damages a monster (default channel).
"""
def damage_monster(map_id, oid, damage, character_id) do
damage_monster(map_id, 1, 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