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

281 lines
8.5 KiB
Elixir

defmodule Odinsea.Game.DropSystem do
@moduledoc """
Drop creation and management system.
Ported from Java server.maps.MapleMap.spawnMobDrop()
This module handles:
- Creating drops when monsters die
- Calculating drop positions
- Managing drop ownership
- Drop expiration
"""
alias Odinsea.Game.{Drop, DropTable, Item, ItemInfo}
alias Odinsea.Game.LifeFactory
require Logger
# Default drop rate multiplier
@default_drop_rate 1.0
# Drop type constants
@drop_type_owner_timeout 0 # Timeout for non-owner
@drop_type_party_timeout 1 # Timeout for non-owner's party
@drop_type_ffa 2 # Free for all
@drop_type_explosive 3 # Explosive (instant FFA)
@doc """
Creates drops for a killed monster.
Returns a list of Drop structs.
"""
@spec create_monster_drops(integer(), integer(), map(), integer(), float()) :: [Drop.t()]
def create_monster_drops(mob_id, owner_id, position, next_oid, drop_rate_multiplier \\ @default_drop_rate) do
# Get monster stats
stats = LifeFactory.get_monster_stats(mob_id)
if stats == nil do
Logger.warning("Cannot create drops - monster stats not found for mob_id #{mob_id}")
[]
else
# Calculate what should drop
calculated_drops = DropTable.calculate_drops(mob_id, drop_rate_multiplier)
# Create Drop structs
{drops, _next_oid} =
Enum.reduce(calculated_drops, {[], next_oid}, fn drop_data, {acc, oid} ->
case create_drop(drop_data, owner_id, position, oid, stats) do
nil -> {acc, oid}
drop -> {[drop | acc], oid + 1}
end
end)
Enum.reverse(drops)
end
end
@doc """
Creates a single drop from calculated drop data.
"""
@spec create_drop({atom(), integer(), integer()}, integer(), map(), integer(), map()) :: Drop.t() | nil
def create_drop({:meso, amount, _}, owner_id, position, oid, stats) do
# Calculate drop position (small random offset from monster)
drop_position = calculate_drop_position(position)
# Determine drop type based on monster
drop_type =
cond do
stats.boss and not stats.party_bonus -> @drop_type_explosive
stats.party_bonus -> @drop_type_party_timeout
true -> @drop_type_ffa
end
Drop.new_meso_drop(oid, amount, owner_id, drop_position,
drop_type: drop_type,
dropper_oid: nil,
source_position: position
)
end
def create_drop({:item, item_id, quantity}, owner_id, position, oid, stats) do
# Validate item exists
if ItemInfo.item_exists?(item_id) do
# Calculate drop position
drop_position = calculate_drop_position(position)
# Determine drop type
drop_type =
cond do
stats.boss and not stats.party_bonus -> @drop_type_explosive
stats.party_bonus -> @drop_type_party_timeout
true -> @drop_type_ffa
end
Drop.new_item_drop(oid, item_id, quantity, owner_id, drop_position,
drop_type: drop_type,
dropper_oid: nil,
source_position: position
)
else
Logger.debug("Item #{item_id} not found, skipping drop")
nil
end
end
@doc """
Creates an item drop from a player's inventory.
Used when a player drops an item.
"""
@spec create_player_drop(Item.t(), integer(), map(), integer()) :: Drop.t()
def create_player_drop(item, owner_id, position, oid) do
drop_position = calculate_drop_position(position)
Drop.new_item_drop(oid, item.item_id, item.quantity, owner_id, drop_position,
drop_type: @drop_type_ffa, # Player drops are always FFA
player_drop: true
)
end
@doc """
Creates an equipment drop with randomized stats.
"""
@spec create_equipment_drop(integer(), integer(), map(), integer(), float()) :: Drop.t() | nil
def create_equipment_drop(item_id, owner_id, position, oid, _drop_rate_multiplier \\ 1.0) do
# Check if item is equipment
case ItemInfo.get_inventory_type(item_id) do
:equip ->
# Get equipment stats
equip = ItemInfo.create_equip(item_id)
if equip do
drop_position = calculate_drop_position(position)
Drop.new_item_drop(oid, item_id, 1, owner_id, drop_position,
drop_type: @drop_type_ffa
)
else
nil
end
_ ->
# Not equipment, create regular item drop
create_drop({:item, item_id, 1}, owner_id, position, oid, %{})
end
end
@doc """
Calculates a drop position near the source position.
"""
@spec calculate_drop_position(map()) :: %{x: integer(), y: integer()}
def calculate_drop_position(%{x: x, y: y}) do
# Random offset within range
offset_x = :rand.uniform(80) - 40 # -40 to +40
offset_y = :rand.uniform(20) - 10 # -10 to +10
%{
x: x + offset_x,
y: y + offset_y
}
end
@doc """
Checks and handles drop expiration.
Returns updated drops list with expired ones marked.
"""
@spec check_expiration([Drop.t()], integer()) :: [Drop.t()]
def check_expiration(drops, now) do
Enum.map(drops, fn drop ->
if Drop.should_expire?(drop, now) do
Drop.mark_picked_up(drop)
else
drop
end
end)
end
@doc """
Filters out expired and picked up drops.
"""
@spec cleanup_drops([Drop.t()]) :: [Drop.t()]
def cleanup_drops(drops) do
Enum.reject(drops, fn drop ->
drop.picked_up
end)
end
@doc """
Attempts to pick up a drop.
Returns {:ok, drop} if successful, {:error, reason} if not.
"""
@spec pickup_drop(Drop.t(), integer(), integer()) :: {:ok, Drop.t()} | {:error, atom()}
def pickup_drop(drop, character_id, now) do
cond do
drop.picked_up ->
{:error, :already_picked_up}
not Drop.can_loot?(drop, character_id, now) ->
{:error, :not_owner}
true ->
{:ok, Drop.mark_picked_up(drop)}
end
end
@doc """
Gets all visible drops for a character.
"""
@spec get_visible_drops([Drop.t()], integer(), map()) :: [Drop.t()]
def get_visible_drops(drops, character_id, quest_status) do
Enum.filter(drops, fn drop ->
Drop.visible_to?(drop, character_id, quest_status)
end)
end
@doc """
Determines drop ownership type based on damage contribution.
"""
@spec determine_drop_type([{integer(), integer()}], integer()) :: integer()
def determine_drop_type(attackers, _killer_id) do
# If only one attacker, owner-only
# If multiple attackers, party/FFA based on damage distribution
case length(attackers) do
1 -> @drop_type_owner_timeout
_ -> @drop_type_party_timeout
end
end
@doc """
Creates drops with specific ownership rules.
Used for special drops like event rewards.
"""
@spec create_special_drop(integer(), integer(), integer(), map(), integer(), keyword()) :: Drop.t()
def create_special_drop(item_id, quantity, owner_id, position, oid, opts \\ []) do
drop_type = Keyword.get(opts, :drop_type, @drop_type_explosive)
quest_id = Keyword.get(opts, :quest_id, -1)
individual = Keyword.get(opts, :individual_reward, false)
Drop.new_item_drop(oid, item_id, quantity, owner_id, position,
drop_type: drop_type,
quest_id: quest_id,
individual_reward: individual
)
end
# ============================================================================
# Global Drop System (Global Drops apply to all monsters)
# ============================================================================
@doc """
Creates global drops for a monster kill.
These are additional drops that can drop from any monster.
"""
@spec create_global_drops(integer(), map(), integer(), float()) :: [Drop.t()]
def create_global_drops(owner_id, position, next_oid, drop_rate_multiplier \\ @default_drop_rate) do
global_entries = DropTable.get_global_drops()
{drops, _next_oid} =
Enum.reduce(global_entries, {[], next_oid}, fn entry, {acc, oid} ->
case DropTable.roll_drop(entry, drop_rate_multiplier) do
nil ->
{acc, oid}
{item_id, quantity} ->
if ItemInfo.item_exists?(item_id) do
drop_position = calculate_drop_position(position)
drop = Drop.new_item_drop(oid, item_id, quantity, owner_id, drop_position,
drop_type: entry.drop_type,
quest_id: entry.questid
)
{[drop | acc], oid + 1}
else
{acc, oid}
end
end
end)
Enum.reverse(drops)
end
end