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