522 lines
14 KiB
Elixir
522 lines
14 KiB
Elixir
defmodule Odinsea.Game.Inventory do
|
|
@moduledoc """
|
|
Manages a single inventory type (equip, use, setup, etc, cash, equipped).
|
|
Provides operations for adding, removing, moving, and querying items.
|
|
"""
|
|
|
|
alias Odinsea.Game.{Item, Equip, InventoryType}
|
|
|
|
defstruct [
|
|
:type,
|
|
:slot_limit,
|
|
items: %{}
|
|
]
|
|
|
|
@type t :: %__MODULE__{
|
|
type: InventoryType.t(),
|
|
slot_limit: integer(),
|
|
items: map()
|
|
}
|
|
|
|
@doc """
|
|
Creates a new inventory of the specified type.
|
|
"""
|
|
def new(type, slot_limit \\ nil) do
|
|
limit = slot_limit || InventoryType.default_slot_limit(type)
|
|
|
|
%__MODULE__{
|
|
type: type,
|
|
slot_limit: limit,
|
|
items: %{}
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Gets the slot limit of the inventory.
|
|
"""
|
|
def slot_limit(%__MODULE__{} = inv), do: inv.slot_limit
|
|
|
|
@doc """
|
|
Sets the slot limit (max 96).
|
|
"""
|
|
def set_slot_limit(%__MODULE__{} = inv, limit) when limit > 96 do
|
|
%{inv | slot_limit: 96}
|
|
end
|
|
|
|
def set_slot_limit(%__MODULE__{} = inv, limit) do
|
|
%{inv | slot_limit: limit}
|
|
end
|
|
|
|
@doc """
|
|
Adds slots to the inventory limit.
|
|
"""
|
|
def add_slots(%__MODULE__{} = inv, slots) do
|
|
new_limit = min(inv.slot_limit + slots, 96)
|
|
%{inv | slot_limit: new_limit}
|
|
end
|
|
|
|
@doc """
|
|
Finds an item by its item_id.
|
|
Returns the first matching item or nil.
|
|
"""
|
|
def find_by_id(%__MODULE__{} = inv, item_id) do
|
|
inv.items
|
|
|> Map.values()
|
|
|> Enum.find(fn item -> item.item_id == item_id end)
|
|
end
|
|
|
|
@doc """
|
|
Finds an item by its unique_id.
|
|
"""
|
|
def find_by_unique_id(%__MODULE__{} = inv, unique_id) do
|
|
inv.items
|
|
|> Map.values()
|
|
|> Enum.find(fn item -> item.unique_id == unique_id end)
|
|
end
|
|
|
|
@doc """
|
|
Finds an item by both inventory_id and item_id.
|
|
"""
|
|
def find_by_inventory_id(%__MODULE__{} = inv, inventory_id, item_id) do
|
|
inv.items
|
|
|> Map.values()
|
|
|> Enum.find(fn item ->
|
|
item.inventory_id == inventory_id && item.item_id == item_id
|
|
end) || find_by_id(inv, item_id)
|
|
end
|
|
|
|
@doc """
|
|
Gets the total count of an item by item_id across all slots.
|
|
"""
|
|
def count_by_id(%__MODULE__{} = inv, item_id) do
|
|
inv.items
|
|
|> Map.values()
|
|
|> Enum.filter(fn item -> item.item_id == item_id end)
|
|
|> Enum.map(fn item -> item.quantity end)
|
|
|> Enum.sum()
|
|
end
|
|
|
|
@doc """
|
|
Lists all items with the given item_id.
|
|
"""
|
|
def list_by_id(%__MODULE__{} = inv, item_id) do
|
|
items =
|
|
inv.items
|
|
|> Map.values()
|
|
|> Enum.filter(fn item -> item.item_id == item_id end)
|
|
|> Enum.sort(&Item.compare/2)
|
|
|
|
items
|
|
end
|
|
|
|
@doc """
|
|
Lists all items in the inventory.
|
|
"""
|
|
def list(%__MODULE__{} = inv) do
|
|
Map.values(inv.items)
|
|
end
|
|
|
|
@doc """
|
|
Lists all unique item IDs in the inventory.
|
|
"""
|
|
def list_ids(%__MODULE__{} = inv) do
|
|
inv.items
|
|
|> Map.values()
|
|
|> Enum.map(fn item -> item.item_id end)
|
|
|> Enum.uniq()
|
|
|> Enum.sort()
|
|
end
|
|
|
|
@doc """
|
|
Gets an item at a specific position.
|
|
"""
|
|
def get_item(%__MODULE__{} = inv, position) do
|
|
Map.get(inv.items, position)
|
|
end
|
|
|
|
@doc """
|
|
Checks if the inventory is full.
|
|
"""
|
|
def full?(%__MODULE__{} = inv) do
|
|
map_size(inv.items) >= inv.slot_limit
|
|
end
|
|
|
|
@doc """
|
|
Checks if the inventory would be full with additional items.
|
|
"""
|
|
def full?(%__MODULE__{} = inv, margin) do
|
|
map_size(inv.items) + margin >= inv.slot_limit
|
|
end
|
|
|
|
@doc """
|
|
Gets the number of free slots.
|
|
"""
|
|
def free_slots(%__MODULE__{} = inv) do
|
|
inv.slot_limit - map_size(inv.items)
|
|
end
|
|
|
|
@doc """
|
|
Gets the next available slot number.
|
|
Returns -1 if the inventory is full.
|
|
"""
|
|
def next_free_slot(%__MODULE__{} = inv) do
|
|
if full?(inv) do
|
|
-1
|
|
else
|
|
find_next_slot(inv.items, inv.slot_limit, 1)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets the next available slot number.
|
|
Returns nil if the inventory is full (Elixir-style).
|
|
"""
|
|
def get_next_free_slot(%__MODULE__{} = inv) do
|
|
case next_free_slot(inv) do
|
|
-1 -> nil
|
|
slot -> slot
|
|
end
|
|
end
|
|
|
|
defp find_next_slot(items, limit, slot) when slot > limit, do: -1
|
|
|
|
defp find_next_slot(items, limit, slot) do
|
|
if Map.has_key?(items, slot) do
|
|
find_next_slot(items, limit, slot + 1)
|
|
else
|
|
slot
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Adds an item to the inventory.
|
|
Returns {:ok, position, inventory} or {:error, :inventory_full}.
|
|
"""
|
|
def add_item(%__MODULE__{} = inv, %Item{} = item) do
|
|
slot = next_free_slot(inv)
|
|
|
|
if slot < 0 do
|
|
{:error, :inventory_full}
|
|
else
|
|
item = %{item | position: slot}
|
|
new_items = Map.put(inv.items, slot, item)
|
|
{:ok, slot, %{inv | items: new_items}}
|
|
end
|
|
end
|
|
|
|
def add_item(%__MODULE__{} = inv, %Equip{} = equip) do
|
|
slot = next_free_slot(inv)
|
|
|
|
if slot < 0 do
|
|
{:error, :inventory_full}
|
|
else
|
|
equip = %{equip | position: slot}
|
|
new_items = Map.put(inv.items, slot, equip)
|
|
{:ok, slot, %{inv | items: new_items}}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Adds a plain map item to the inventory (used for drops).
|
|
Returns {:ok, new_inventory, assigned_item} or {:error, :inventory_full}.
|
|
"""
|
|
def add_item(%__MODULE__{} = inv, %{} = item_map) when not is_struct(item_map) do
|
|
slot = next_free_slot(inv)
|
|
|
|
if slot < 0 do
|
|
{:error, :inventory_full}
|
|
else
|
|
# Convert map to item with assigned position
|
|
assigned_item = Map.put(item_map, :position, slot)
|
|
new_items = Map.put(inv.items, slot, assigned_item)
|
|
{:ok, %{inv | items: new_items}, assigned_item}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Adds an item from the database (preserves position).
|
|
"""
|
|
def add_from_db(%__MODULE__{} = inv, %{position: position} = item) do
|
|
# Validate position for equipped vs unequipped
|
|
valid_position = validate_position(inv.type, position)
|
|
|
|
if valid_position do
|
|
new_items = Map.put(inv.items, position, item)
|
|
%{inv | items: new_items}
|
|
else
|
|
inv
|
|
end
|
|
end
|
|
|
|
defp validate_position(:equipped, position) when position < 0, do: true
|
|
defp validate_position(:equipped, _position), do: false
|
|
defp validate_position(_type, position) when position > 0, do: true
|
|
defp validate_position(_type, _position), do: false
|
|
|
|
@doc """
|
|
Removes an item from a specific slot.
|
|
"""
|
|
def remove_slot(%__MODULE__{} = inv, position) do
|
|
new_items = Map.delete(inv.items, position)
|
|
%{inv | items: new_items}
|
|
end
|
|
|
|
@doc """
|
|
Removes a quantity from an item.
|
|
If quantity reaches 0, removes the item entirely (unless allow_zero is true).
|
|
"""
|
|
def remove_item(%__MODULE__{} = inv, position, quantity \\ 1, allow_zero \\ false) do
|
|
case Map.get(inv.items, position) do
|
|
nil ->
|
|
inv
|
|
|
|
item ->
|
|
new_qty = item.quantity - quantity
|
|
new_qty = if new_qty < 0, do: 0, else: new_qty
|
|
|
|
if new_qty == 0 and not allow_zero do
|
|
remove_slot(inv, position)
|
|
else
|
|
new_item = %{item | quantity: new_qty}
|
|
new_items = Map.put(inv.items, position, new_item)
|
|
%{inv | items: new_items}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Moves an item from one slot to another.
|
|
Handles equipping/unequipping and stacking.
|
|
"""
|
|
def move(%__MODULE__{} = inv, src_slot, dst_slot, slot_max \\ 100) do
|
|
source = Map.get(inv.items, src_slot)
|
|
target = Map.get(inv.items, dst_slot)
|
|
|
|
cond do
|
|
is_nil(source) ->
|
|
{:error, :empty_source}
|
|
|
|
is_nil(target) ->
|
|
# Simple move to empty slot
|
|
if valid_position?(inv.type, dst_slot) do
|
|
new_items =
|
|
inv.items
|
|
|> Map.delete(src_slot)
|
|
|> Map.put(dst_slot, %{source | position: dst_slot})
|
|
|
|
{:ok, %{inv | items: new_items}}
|
|
else
|
|
{:error, :invalid_position}
|
|
end
|
|
|
|
can_stack?(source, target, inv.type) ->
|
|
# Stack items
|
|
stack_items(inv, src_slot, dst_slot, source, target, slot_max)
|
|
|
|
true ->
|
|
# Swap items
|
|
{:ok, swap_items(inv, src_slot, dst_slot, source, target)}
|
|
end
|
|
end
|
|
|
|
defp valid_position?(:equipped, position) when position < 0, do: true
|
|
defp valid_position?(:equipped, _), do: false
|
|
defp valid_position?(_, position) when position > 0, do: true
|
|
defp valid_position?(_, _), do: false
|
|
|
|
defp can_stack?(source, target, type) do
|
|
# Can't stack equipment or cash items
|
|
not InventoryType.equipment?(type) and
|
|
type != :cash and
|
|
source.item_id == target.item_id and
|
|
source.owner == target.owner and
|
|
source.expiration == target.expiration and
|
|
source.flag == target.flag
|
|
end
|
|
|
|
defp stack_items(inv, src_slot, dst_slot, source, target, slot_max) do
|
|
total_qty = source.quantity + target.quantity
|
|
|
|
if total_qty > slot_max do
|
|
# Partial stack
|
|
new_source = %{source | quantity: total_qty - slot_max}
|
|
new_target = %{target | quantity: slot_max}
|
|
|
|
new_items =
|
|
inv.items
|
|
|> Map.put(src_slot, new_source)
|
|
|> Map.put(dst_slot, new_target)
|
|
|
|
{:ok, %{inv | items: new_items}}
|
|
else
|
|
# Full stack - remove source
|
|
new_target = %{target | quantity: total_qty}
|
|
|
|
new_items =
|
|
inv.items
|
|
|> Map.delete(src_slot)
|
|
|> Map.put(dst_slot, new_target)
|
|
|
|
{:ok, %{inv | items: new_items}}
|
|
end
|
|
end
|
|
|
|
defp swap_items(inv, src_slot, dst_slot, source, target) do
|
|
new_source = %{source | position: dst_slot}
|
|
new_target = %{target | position: src_slot}
|
|
|
|
inv.items
|
|
|> Map.put(dst_slot, new_source)
|
|
|> Map.put(src_slot, new_target)
|
|
end
|
|
|
|
@doc """
|
|
Drops an item from the inventory.
|
|
Returns {:ok, dropped_item, updated_inventory} or {:error, reason}.
|
|
"""
|
|
def drop(%__MODULE__{} = inv, position, quantity \\ 1) do
|
|
case Map.get(inv.items, position) do
|
|
nil ->
|
|
{:error, :item_not_found}
|
|
|
|
item ->
|
|
if quantity >= item.quantity do
|
|
# Drop entire stack
|
|
new_items = Map.delete(inv.items, position)
|
|
{:ok, item, %{inv | items: new_items}}
|
|
else
|
|
# Drop partial stack
|
|
new_item = %{item | quantity: item.quantity - quantity}
|
|
dropped_item = %{item | quantity: quantity}
|
|
new_items = Map.put(inv.items, position, new_item)
|
|
{:ok, dropped_item, %{inv | items: new_items}}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Updates an item at a specific position.
|
|
"""
|
|
def update_item(%__MODULE__{} = inv, position, updater_fn) when is_function(updater_fn, 1) do
|
|
case Map.get(inv.items, position) do
|
|
nil ->
|
|
inv
|
|
|
|
item ->
|
|
new_item = updater_fn.(item)
|
|
new_items = Map.put(inv.items, position, new_item)
|
|
%{inv | items: new_items}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets all equipped items.
|
|
"""
|
|
def equipped_items(%__MODULE__{type: :equipped} = inv) do
|
|
inv.items
|
|
|> Map.values()
|
|
|> Enum.sort_by(fn item -> abs(item.position) end)
|
|
end
|
|
|
|
def equipped_items(%__MODULE__{}), do: []
|
|
|
|
@doc """
|
|
Gets the inventory type based on item ID.
|
|
"""
|
|
def get_type_by_item_id(item_id) do
|
|
InventoryType.from_item_id(item_id)
|
|
end
|
|
|
|
@doc """
|
|
Checks if inventory has at least the specified quantity of an item.
|
|
"""
|
|
def has_item_count(%__MODULE__{} = inv, item_id, quantity) do
|
|
count_by_id(inv, item_id) >= quantity
|
|
end
|
|
|
|
@doc """
|
|
Checks if there's at least one free slot in the inventory.
|
|
"""
|
|
def has_free_slot(%__MODULE__{} = inv) do
|
|
next_free_slot(inv) >= 0
|
|
end
|
|
|
|
@doc """
|
|
Checks if inventory can hold the specified quantity of an item.
|
|
For stackable items, checks if there's room to stack or a free slot.
|
|
"""
|
|
def can_hold_quantity(%__MODULE__{} = inv, item_id, quantity) do
|
|
# Find existing item to check stack space
|
|
existing = find_by_id(inv, item_id)
|
|
slot_max = InventoryType.slot_limit(inv.type)
|
|
|
|
if existing do
|
|
# Check if we can stack
|
|
space_in_stack = slot_max - existing.quantity
|
|
remaining = quantity - space_in_stack
|
|
|
|
if remaining <= 0 do
|
|
true
|
|
else
|
|
# Need additional slots
|
|
free_slots = count_free_slots(inv)
|
|
slots_needed = div(remaining, slot_max) + if rem(remaining, slot_max) > 0, do: 1, else: 0
|
|
free_slots >= slots_needed
|
|
end
|
|
else
|
|
# Need new slot(s)
|
|
free_slots = count_free_slots(inv)
|
|
slots_needed = div(quantity, slot_max) + if rem(quantity, slot_max) > 0, do: 1, else: 0
|
|
free_slots >= slots_needed
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Removes items by item ID.
|
|
Returns {:ok, new_inventory, removed_count} or {:error, reason}.
|
|
"""
|
|
def remove_by_id(%__MODULE__{} = inv, item_id, quantity) do
|
|
items_with_id =
|
|
inv.items
|
|
|> Map.values()
|
|
|> Enum.filter(fn item -> item.item_id == item_id end)
|
|
|> Enum.sort_by(fn item -> item.position end)
|
|
|
|
total_available = Enum.map(items_with_id, fn i -> i.quantity end) |> Enum.sum()
|
|
|
|
if total_available < quantity do
|
|
{:error, :insufficient_quantity}
|
|
else
|
|
{new_items, removed} = do_remove_by_id(inv.items, items_with_id, quantity, 0)
|
|
{:ok, %{inv | items: new_items}, removed}
|
|
end
|
|
end
|
|
|
|
defp do_remove_by_id(items, _items_to_remove, 0, removed), do: {items, removed}
|
|
defp do_remove_by_id(items, [], _quantity, removed), do: {items, removed}
|
|
defp do_remove_by_id(items, [item | rest], quantity, removed) do
|
|
if quantity <= 0 do
|
|
{items, removed}
|
|
else
|
|
to_remove = min(item.quantity, quantity)
|
|
new_quantity = item.quantity - to_remove
|
|
|
|
new_items = if new_quantity <= 0 do
|
|
Map.delete(items, item.position)
|
|
else
|
|
Map.put(items, item.position, %{item | quantity: new_quantity})
|
|
end
|
|
|
|
do_remove_by_id(new_items, rest, quantity - to_remove, removed + to_remove)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Counts free slots in the inventory.
|
|
"""
|
|
def count_free_slots(%__MODULE__{} = inv) do
|
|
used_slots = map_size(inv.items)
|
|
inv.slot_limit - used_slots
|
|
end
|
|
end
|