update
This commit is contained in:
@@ -11,6 +11,7 @@ defmodule Odinsea.Game.Character do
|
||||
|
||||
alias Odinsea.Database.Schema.Character, as: CharacterDB
|
||||
alias Odinsea.Game.Map, as: GameMap
|
||||
alias Odinsea.Game.{Inventory, InventoryType}
|
||||
alias Odinsea.Net.Packet.Out
|
||||
|
||||
# ============================================================================
|
||||
@@ -231,6 +232,52 @@ defmodule Odinsea.Game.Character do
|
||||
GenServer.stop(via_tuple(character_id), :normal)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Inventory API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Gets a specific inventory.
|
||||
"""
|
||||
def get_inventory(character_id, inv_type) do
|
||||
GenServer.call(via_tuple(character_id), {:get_inventory, inv_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an item from a specific inventory slot.
|
||||
"""
|
||||
def get_item(character_id, inv_type, position) do
|
||||
GenServer.call(via_tuple(character_id), {:get_item, inv_type, position})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Moves an item within or between inventories.
|
||||
"""
|
||||
def move_item(character_id, inv_type, src_slot, dst_slot, slot_max \\ 100) do
|
||||
GenServer.call(via_tuple(character_id), {:move_item, inv_type, src_slot, dst_slot, slot_max})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Equips an item (moves from EQUIP inventory to EQUIPPED).
|
||||
"""
|
||||
def equip_item(character_id, src_slot, dst_slot) do
|
||||
GenServer.call(via_tuple(character_id), {:equip_item, src_slot, dst_slot})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unequips an item (moves from EQUIPPED to EQUIP inventory).
|
||||
"""
|
||||
def unequip_item(character_id, src_slot, dst_slot) do
|
||||
GenServer.call(via_tuple(character_id), {:unequip_item, src_slot, dst_slot})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Drops an item from inventory.
|
||||
"""
|
||||
def drop_item(character_id, inv_type, position, quantity \\ 1) do
|
||||
GenServer.call(via_tuple(character_id), {:drop_item, inv_type, position, quantity})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
@@ -286,6 +333,93 @@ defmodule Odinsea.Game.Character do
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_inventory, inv_type}, _from, state) do
|
||||
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
|
||||
{:reply, inventory, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_item, inv_type, position}, _from, state) do
|
||||
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
|
||||
item = Inventory.get_item(inventory, position)
|
||||
{:reply, item, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:move_item, inv_type, src_slot, dst_slot, slot_max}, _from, state) do
|
||||
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
|
||||
|
||||
case Inventory.move(inventory, src_slot, dst_slot, slot_max) do
|
||||
{:ok, new_inventory} ->
|
||||
new_inventories = Map.put(state.inventories, inv_type, new_inventory)
|
||||
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
||||
{:reply, :ok, new_state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:equip_item, src_slot, dst_slot}, _from, state) do
|
||||
# Move from EQUIP to EQUIPPED
|
||||
equip_inv = Map.get(state.inventories, :equip, Inventory.new(:equip))
|
||||
equipped_inv = Map.get(state.inventories, :equipped, Inventory.new(:equipped))
|
||||
|
||||
case Inventory.move(equip_inv, src_slot, dst_slot, 1) do
|
||||
{:ok, new_equip_inv} ->
|
||||
new_inventories =
|
||||
state.inventories
|
||||
|> Map.put(:equip, new_equip_inv)
|
||||
|> Map.put(:equipped, equipped_inv)
|
||||
|
||||
# TODO: Recalculate stats based on new equipment
|
||||
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
||||
{:reply, :ok, new_state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:unequip_item, src_slot, dst_slot}, _from, state) do
|
||||
# Move from EQUIPPED to EQUIP
|
||||
equipped_inv = Map.get(state.inventories, :equipped, Inventory.new(:equipped))
|
||||
equip_inv = Map.get(state.inventories, :equip, Inventory.new(:equip))
|
||||
|
||||
case Inventory.move(equipped_inv, src_slot, dst_slot, 1) do
|
||||
{:ok, new_equipped_inv} ->
|
||||
new_inventories =
|
||||
state.inventories
|
||||
|> Map.put(:equipped, new_equipped_inv)
|
||||
|> Map.put(:equip, equip_inv)
|
||||
|
||||
# TODO: Recalculate stats based on removed equipment
|
||||
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
||||
{:reply, :ok, new_state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:drop_item, inv_type, position, quantity}, _from, state) do
|
||||
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
|
||||
|
||||
case Inventory.drop(inventory, position, quantity) do
|
||||
{:ok, dropped_item, new_inventory} ->
|
||||
new_inventories = Map.put(state.inventories, inv_type, new_inventory)
|
||||
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
||||
{:reply, {:ok, dropped_item}, new_state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:update_position, position}, state) do
|
||||
new_state = %{
|
||||
@@ -361,6 +495,9 @@ defmodule Odinsea.Game.Character do
|
||||
sp_list when is_list(sp_list) -> sp_list
|
||||
end
|
||||
|
||||
# Load inventories from database
|
||||
inventories = load_inventories(db_char.id)
|
||||
|
||||
%State{
|
||||
character_id: db_char.id,
|
||||
account_id: db_char.account_id,
|
||||
@@ -383,7 +520,7 @@ defmodule Odinsea.Game.Character do
|
||||
remaining_ap: db_char.remaining_ap,
|
||||
remaining_sp: remaining_sp,
|
||||
client_pid: client_pid,
|
||||
inventories: %{},
|
||||
inventories: inventories,
|
||||
skills: %{},
|
||||
buffs: [],
|
||||
pets: [],
|
||||
@@ -392,6 +529,30 @@ defmodule Odinsea.Game.Character do
|
||||
}
|
||||
end
|
||||
|
||||
defp load_inventories(character_id) do
|
||||
# Initialize empty inventories for all types
|
||||
base_inventories =
|
||||
InventoryType.all_types()
|
||||
|> Map.new(fn type -> {type, Inventory.new(type)} end)
|
||||
|
||||
# Load items from database
|
||||
case Odinsea.Database.Context.load_character_inventory(character_id) do
|
||||
nil ->
|
||||
base_inventories
|
||||
|
||||
items_by_type ->
|
||||
# Add items to appropriate inventories
|
||||
Enum.reduce(items_by_type, base_inventories, fn {type, items}, acc ->
|
||||
inventory =
|
||||
Enum.reduce(items, Inventory.new(type), fn item, inv ->
|
||||
Inventory.add_from_db(inv, item)
|
||||
end)
|
||||
|
||||
Map.put(acc, type, inventory)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_sp_string(sp_str) do
|
||||
sp_str
|
||||
|> String.split(",")
|
||||
@@ -429,6 +590,12 @@ defmodule Odinsea.Game.Character do
|
||||
remaining_sp: sp_string
|
||||
}
|
||||
|
||||
Odinsea.Database.Context.update_character(state.character_id, attrs)
|
||||
# Save character base data
|
||||
result = Odinsea.Database.Context.update_character(state.character_id, attrs)
|
||||
|
||||
# Save inventories
|
||||
Odinsea.Database.Context.save_character_inventory(state.character_id, state.inventories)
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
394
lib/odinsea/game/inventory.ex
Normal file
394
lib/odinsea/game/inventory.ex
Normal file
@@ -0,0 +1,394 @@
|
||||
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
|
||||
|
||||
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 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: []
|
||||
end
|
||||
98
lib/odinsea/game/inventory_type.ex
Normal file
98
lib/odinsea/game/inventory_type.ex
Normal file
@@ -0,0 +1,98 @@
|
||||
defmodule Odinsea.Game.InventoryType do
|
||||
@moduledoc """
|
||||
Inventory type definitions for MapleStory.
|
||||
Each inventory type corresponds to a specific tab in the player's inventory.
|
||||
"""
|
||||
|
||||
@type t :: :equip | :use | :setup | :etc | :cash | :equipped | :undefined
|
||||
|
||||
# Type values matching Java implementation
|
||||
@undefined 0
|
||||
@equip 1
|
||||
@use 2
|
||||
@setup 3
|
||||
@etc 4
|
||||
@cash 5
|
||||
@equipped -1
|
||||
|
||||
@doc """
|
||||
Gets the byte type value for an inventory type.
|
||||
"""
|
||||
def type_value(:equip), do: @equip
|
||||
def type_value(:use), do: @use
|
||||
def type_value(:setup), do: @setup
|
||||
def type_value(:etc), do: @etc
|
||||
def type_value(:cash), do: @cash
|
||||
def type_value(:equipped), do: @equipped
|
||||
def type_value(:undefined), do: @undefined
|
||||
def type_value(_), do: @undefined
|
||||
|
||||
@doc """
|
||||
Gets the inventory type atom from a byte value.
|
||||
"""
|
||||
def from_type(@equip), do: :equip
|
||||
def from_type(@use), do: :use
|
||||
def from_type(@setup), do: :setup
|
||||
def from_type(@etc), do: :etc
|
||||
def from_type(@cash), do: :cash
|
||||
def from_type(@equipped), do: :equipped
|
||||
def from_type(@undefined), do: :undefined
|
||||
def from_type(_), do: :undefined
|
||||
|
||||
@doc """
|
||||
Gets the bitfield encoding for an inventory type.
|
||||
Used in packet encoding.
|
||||
"""
|
||||
def bitfield_encoding(:equip), do: 2
|
||||
def bitfield_encoding(:use), do: 4
|
||||
def bitfield_encoding(:setup), do: 8
|
||||
def bitfield_encoding(:etc), do: 16
|
||||
def bitfield_encoding(:cash), do: 32
|
||||
def bitfield_encoding(:equipped), do: 2
|
||||
def bitfield_encoding(:undefined), do: 0
|
||||
def bitfield_encoding(type), do: bitfield_encoding(from_type(type))
|
||||
|
||||
@doc """
|
||||
Gets the inventory type from a WZ data name.
|
||||
"""
|
||||
def from_wz_name("Install"), do: :setup
|
||||
def from_wz_name("Consume"), do: :use
|
||||
def from_wz_name("Etc"), do: :etc
|
||||
def from_wz_name("Eqp"), do: :equip
|
||||
def from_wz_name("Cash"), do: :cash
|
||||
def from_wz_name("Pet"), do: :cash
|
||||
def from_wz_name(_), do: :undefined
|
||||
|
||||
@doc """
|
||||
Gets the default slot limit for an inventory type.
|
||||
"""
|
||||
def default_slot_limit(:equip), do: 24
|
||||
def default_slot_limit(:use), do: 80
|
||||
def default_slot_limit(:setup), do: 80
|
||||
def default_slot_limit(:etc), do: 80
|
||||
def default_slot_limit(:cash), do: 40
|
||||
def default_slot_limit(:equipped), do: 128
|
||||
def default_slot_limit(_), do: 24
|
||||
|
||||
@doc """
|
||||
Gets the maximum slot limit for an inventory type.
|
||||
"""
|
||||
def max_slot_limit(_type), do: 96
|
||||
|
||||
@doc """
|
||||
Checks if the inventory type is for equipment.
|
||||
"""
|
||||
def equipment?(:equip), do: true
|
||||
def equipment?(:equipped), do: true
|
||||
def equipment?(_), do: false
|
||||
|
||||
@doc """
|
||||
Lists all inventory types that can be modified by the player.
|
||||
"""
|
||||
def player_types, do: [:equip, :use, :setup, :etc, :cash]
|
||||
|
||||
@doc """
|
||||
Lists all inventory types including equipped.
|
||||
"""
|
||||
def all_types, do: [:equip, :use, :setup, :etc, :cash, :equipped]
|
||||
end
|
||||
321
lib/odinsea/game/item.ex
Normal file
321
lib/odinsea/game/item.ex
Normal file
@@ -0,0 +1,321 @@
|
||||
defmodule Odinsea.Game.Item do
|
||||
@moduledoc """
|
||||
Represents an item in the game.
|
||||
Items can be regular stackable items or equipment with stats.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
item_id: integer(),
|
||||
position: integer(),
|
||||
quantity: integer(),
|
||||
flag: integer(),
|
||||
unique_id: integer(),
|
||||
expiration: integer(),
|
||||
owner: String.t(),
|
||||
gift_from: String.t(),
|
||||
gm_log: String.t(),
|
||||
inventory_id: integer(),
|
||||
pet: map() | nil
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:id,
|
||||
:item_id,
|
||||
:position,
|
||||
:quantity,
|
||||
:flag,
|
||||
:unique_id,
|
||||
:expiration,
|
||||
:owner,
|
||||
:gift_from,
|
||||
:gm_log,
|
||||
:inventory_id,
|
||||
:pet
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a new item.
|
||||
"""
|
||||
def new(item_id, position, quantity, flag \\ 0, unique_id \\ -1) do
|
||||
%__MODULE__{
|
||||
id: nil,
|
||||
item_id: item_id,
|
||||
position: position,
|
||||
quantity: quantity,
|
||||
flag: flag,
|
||||
unique_id: unique_id,
|
||||
expiration: -1,
|
||||
owner: "",
|
||||
gift_from: "",
|
||||
gm_log: "",
|
||||
inventory_id: 0,
|
||||
pet: nil
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a copy of an item.
|
||||
"""
|
||||
def copy(%__MODULE__{} = item) do
|
||||
%{item | id: nil}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the type of item (2 = regular item).
|
||||
"""
|
||||
def type(%__MODULE__{}), do: 2
|
||||
|
||||
@doc """
|
||||
Checks if two items can be stacked (same item_id, owner, expiration).
|
||||
"""
|
||||
def stackable?(%__MODULE__{} = item1, %__MODULE__{} = item2) do
|
||||
item1.item_id == item2.item_id &&
|
||||
item1.owner == item2.owner &&
|
||||
item1.expiration == item2.expiration &&
|
||||
item1.flag == item2.flag
|
||||
end
|
||||
|
||||
@doc """
|
||||
Compares items by position for sorting.
|
||||
"""
|
||||
def compare(%__MODULE__{} = item1, %__MODULE__{} = item2) do
|
||||
pos1 = abs(item1.position)
|
||||
pos2 = abs(item2.position)
|
||||
|
||||
cond do
|
||||
pos1 < pos2 -> :lt
|
||||
pos1 == pos2 -> :eq
|
||||
true -> :gt
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Odinsea.Game.Equip do
|
||||
@moduledoc """
|
||||
Represents an equipment item with stats.
|
||||
Extends the base Item with equipment-specific fields.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
# Base item fields
|
||||
id: integer(),
|
||||
item_id: integer(),
|
||||
position: integer(),
|
||||
quantity: integer(),
|
||||
flag: integer(),
|
||||
unique_id: integer(),
|
||||
expiration: integer(),
|
||||
owner: String.t(),
|
||||
gift_from: String.t(),
|
||||
gm_log: String.t(),
|
||||
inventory_id: integer(),
|
||||
pet: map() | nil,
|
||||
# Equipment-specific fields
|
||||
upgrade_slots: integer(),
|
||||
level: integer(),
|
||||
vicious_hammer: integer(),
|
||||
enhance: integer(),
|
||||
str: integer(),
|
||||
dex: integer(),
|
||||
int: integer(),
|
||||
luk: integer(),
|
||||
hp: integer(),
|
||||
mp: integer(),
|
||||
watk: integer(),
|
||||
matk: integer(),
|
||||
wdef: integer(),
|
||||
mdef: integer(),
|
||||
acc: integer(),
|
||||
avoid: integer(),
|
||||
hands: integer(),
|
||||
speed: integer(),
|
||||
jump: integer(),
|
||||
hp_r: integer(),
|
||||
mp_r: integer(),
|
||||
charm_exp: integer(),
|
||||
pvp_damage: integer(),
|
||||
item_exp: integer(),
|
||||
durability: integer(),
|
||||
inc_skill: integer(),
|
||||
potential1: integer(),
|
||||
potential2: integer(),
|
||||
potential3: integer(),
|
||||
ring: map() | nil,
|
||||
android: map() | nil
|
||||
}
|
||||
|
||||
defstruct [
|
||||
# Base item fields
|
||||
:id,
|
||||
:item_id,
|
||||
:position,
|
||||
:quantity,
|
||||
:flag,
|
||||
:unique_id,
|
||||
:expiration,
|
||||
:owner,
|
||||
:gift_from,
|
||||
:gm_log,
|
||||
:inventory_id,
|
||||
:pet,
|
||||
# Equipment-specific fields
|
||||
:upgrade_slots,
|
||||
:level,
|
||||
:vicious_hammer,
|
||||
:enhance,
|
||||
:str,
|
||||
:dex,
|
||||
:int,
|
||||
:luk,
|
||||
:hp,
|
||||
:mp,
|
||||
:watk,
|
||||
:matk,
|
||||
:wdef,
|
||||
:mdef,
|
||||
:acc,
|
||||
:avoid,
|
||||
:hands,
|
||||
:speed,
|
||||
:jump,
|
||||
:hp_r,
|
||||
:mp_r,
|
||||
:charm_exp,
|
||||
:pvp_damage,
|
||||
:item_exp,
|
||||
:durability,
|
||||
:inc_skill,
|
||||
:potential1,
|
||||
:potential2,
|
||||
:potential3,
|
||||
:ring,
|
||||
:android
|
||||
]
|
||||
|
||||
@armor_ratio 350_000
|
||||
@weapon_ratio 700_000
|
||||
|
||||
@doc """
|
||||
Creates a new equipment item.
|
||||
"""
|
||||
def new(item_id, position, flag \\ 0, unique_id \\ -1) do
|
||||
%__MODULE__{
|
||||
id: nil,
|
||||
item_id: item_id,
|
||||
position: position,
|
||||
quantity: 1,
|
||||
flag: flag,
|
||||
unique_id: unique_id,
|
||||
expiration: -1,
|
||||
owner: "",
|
||||
gift_from: "",
|
||||
gm_log: "",
|
||||
inventory_id: 0,
|
||||
pet: nil,
|
||||
upgrade_slots: 0,
|
||||
level: 0,
|
||||
vicious_hammer: 0,
|
||||
enhance: 0,
|
||||
str: 0,
|
||||
dex: 0,
|
||||
int: 0,
|
||||
luk: 0,
|
||||
hp: 0,
|
||||
mp: 0,
|
||||
watk: 0,
|
||||
matk: 0,
|
||||
wdef: 0,
|
||||
mdef: 0,
|
||||
acc: 0,
|
||||
avoid: 0,
|
||||
hands: 0,
|
||||
speed: 0,
|
||||
jump: 0,
|
||||
hp_r: 0,
|
||||
mp_r: 0,
|
||||
charm_exp: -1,
|
||||
pvp_damage: 0,
|
||||
item_exp: 0,
|
||||
durability: -1,
|
||||
inc_skill: -1,
|
||||
potential1: 0,
|
||||
potential2: 0,
|
||||
potential3: 0,
|
||||
ring: nil,
|
||||
android: nil
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the type of item (1 = equipment).
|
||||
"""
|
||||
def type(%__MODULE__{}), do: 1
|
||||
|
||||
@doc """
|
||||
Gets the potential state of the equipment.
|
||||
Returns: 0=none, 5=rare, 6=epic, 7=unique, 8=legendary
|
||||
"""
|
||||
def potential_state(%__MODULE__{} = equip) do
|
||||
pots = equip.potential1 + equip.potential2 + equip.potential3
|
||||
|
||||
cond do
|
||||
equip.potential1 >= 40_000 or equip.potential2 >= 40_000 or equip.potential3 >= 40_000 -> 8
|
||||
equip.potential1 >= 30_000 or equip.potential2 >= 30_000 or equip.potential3 >= 30_000 -> 7
|
||||
equip.potential1 >= 20_000 or equip.potential2 >= 20_000 or equip.potential3 >= 20_000 -> 6
|
||||
pots >= 1 -> 5
|
||||
pots < 0 -> 1
|
||||
true -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the equipment level based on item EXP.
|
||||
"""
|
||||
def equip_level(%__MODULE__{} = equip) do
|
||||
if equip.item_exp <= 0 do
|
||||
base_level(equip)
|
||||
else
|
||||
calculate_equip_level(equip, base_level(equip), equip_exp(equip))
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_equip_level(equip, level, exp) do
|
||||
# Simplified - would need item data to check max level
|
||||
max_level = 10
|
||||
|
||||
if level >= max_level or exp <= 0 do
|
||||
level
|
||||
else
|
||||
# Simplified exp calculation
|
||||
needed_exp = level * 100
|
||||
|
||||
if exp >= needed_exp do
|
||||
calculate_equip_level(equip, level + 1, exp - needed_exp)
|
||||
else
|
||||
level
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp base_level(_equip), do: 1
|
||||
|
||||
@doc """
|
||||
Gets the equipment EXP value.
|
||||
"""
|
||||
def equip_exp(%__MODULE__{} = equip) do
|
||||
if equip.item_exp <= 0 do
|
||||
0
|
||||
else
|
||||
# Simplified ratio
|
||||
div(equip.item_exp, @armor_ratio)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a copy of an equipment.
|
||||
"""
|
||||
def copy(%__MODULE__{} = equip) do
|
||||
%{equip | id: nil}
|
||||
end
|
||||
end
|
||||
549
lib/odinsea/game/item_info.ex
Normal file
549
lib/odinsea/game/item_info.ex
Normal file
@@ -0,0 +1,549 @@
|
||||
defmodule Odinsea.Game.ItemInfo do
|
||||
@moduledoc """
|
||||
Item Information Provider - loads and caches item data.
|
||||
|
||||
This module loads item metadata (stats, prices, requirements, etc.) from cached JSON files.
|
||||
The JSON files should be exported from the Java server's WZ data providers.
|
||||
|
||||
Data is cached in ETS for fast lookups.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{Item, Equip}
|
||||
|
||||
# ETS table names
|
||||
@item_cache :odinsea_item_cache
|
||||
@equip_stats_cache :odinsea_equip_stats_cache
|
||||
@item_names :odinsea_item_names
|
||||
@set_items :odinsea_set_items
|
||||
|
||||
# Data file paths (relative to priv directory)
|
||||
@item_data_file "data/items.json"
|
||||
@equip_data_file "data/equips.json"
|
||||
@string_data_file "data/item_strings.json"
|
||||
@set_data_file "data/set_items.json"
|
||||
|
||||
defmodule ItemInformation do
|
||||
@moduledoc "Complete item information structure"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
item_id: integer(),
|
||||
name: String.t(),
|
||||
desc: String.t(),
|
||||
slot_max: integer(),
|
||||
price: float(),
|
||||
whole_price: integer(),
|
||||
req_level: integer(),
|
||||
req_job: integer(),
|
||||
req_str: integer(),
|
||||
req_dex: integer(),
|
||||
req_int: integer(),
|
||||
req_luk: integer(),
|
||||
req_pop: integer(),
|
||||
cash: boolean(),
|
||||
tradeable: boolean(),
|
||||
quest: boolean(),
|
||||
time_limited: boolean(),
|
||||
expire_on_logout: boolean(),
|
||||
pickup_block: boolean(),
|
||||
only_one: boolean(),
|
||||
account_shareable: boolean(),
|
||||
mob_id: integer(),
|
||||
mob_hp: integer(),
|
||||
success_rate: integer(),
|
||||
cursed: integer(),
|
||||
karma: integer(),
|
||||
recover_hp: integer(),
|
||||
recover_mp: integer(),
|
||||
buff_time: integer(),
|
||||
meso: integer(),
|
||||
monster_book: boolean(),
|
||||
reward_id: integer(),
|
||||
state_change_item: integer(),
|
||||
create_item: integer(),
|
||||
bag_type: integer(),
|
||||
effect: map() | nil,
|
||||
equip_stats: map() | nil
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:item_id,
|
||||
:name,
|
||||
:desc,
|
||||
:slot_max,
|
||||
:price,
|
||||
:whole_price,
|
||||
:req_level,
|
||||
:req_job,
|
||||
:req_str,
|
||||
:req_dex,
|
||||
:req_int,
|
||||
:req_luk,
|
||||
:req_pop,
|
||||
:cash,
|
||||
:tradeable,
|
||||
:quest,
|
||||
:time_limited,
|
||||
:expire_on_logout,
|
||||
:pickup_block,
|
||||
:only_one,
|
||||
:account_shareable,
|
||||
:mob_id,
|
||||
:mob_hp,
|
||||
:success_rate,
|
||||
:cursed,
|
||||
:karma,
|
||||
:recover_hp,
|
||||
:recover_mp,
|
||||
:buff_time,
|
||||
:meso,
|
||||
:monster_book,
|
||||
:reward_id,
|
||||
:state_change_item,
|
||||
:create_item,
|
||||
:bag_type,
|
||||
:effect,
|
||||
:equip_stats
|
||||
]
|
||||
end
|
||||
|
||||
defmodule EquipStats do
|
||||
@moduledoc "Equipment base stats"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
str: integer(),
|
||||
dex: integer(),
|
||||
int: integer(),
|
||||
luk: integer(),
|
||||
hp: integer(),
|
||||
mp: integer(),
|
||||
watk: integer(),
|
||||
matk: integer(),
|
||||
wdef: integer(),
|
||||
mdef: integer(),
|
||||
acc: integer(),
|
||||
avoid: integer(),
|
||||
hands: integer(),
|
||||
speed: integer(),
|
||||
jump: integer(),
|
||||
slots: integer(),
|
||||
vicious_hammer: integer(),
|
||||
item_level: integer(),
|
||||
durability: integer(),
|
||||
inc_str: integer(),
|
||||
inc_dex: integer(),
|
||||
inc_int: integer(),
|
||||
inc_luk: integer(),
|
||||
inc_mhp: integer(),
|
||||
inc_mmp: integer(),
|
||||
inc_speed: integer(),
|
||||
inc_jump: integer(),
|
||||
tuc: integer(),
|
||||
only_equip: boolean(),
|
||||
trade_block: boolean(),
|
||||
equip_on_level_up: boolean(),
|
||||
boss_drop: boolean(),
|
||||
boss_reward: boolean()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:str,
|
||||
:dex,
|
||||
:int,
|
||||
:luk,
|
||||
:hp,
|
||||
:mp,
|
||||
:watk,
|
||||
:matk,
|
||||
:wdef,
|
||||
:mdef,
|
||||
:acc,
|
||||
:avoid,
|
||||
:hands,
|
||||
:speed,
|
||||
:jump,
|
||||
:slots,
|
||||
:vicious_hammer,
|
||||
:item_level,
|
||||
:durability,
|
||||
:inc_str,
|
||||
:inc_dex,
|
||||
:inc_int,
|
||||
:inc_luk,
|
||||
:inc_mhp,
|
||||
:inc_mmp,
|
||||
:inc_speed,
|
||||
:inc_jump,
|
||||
:tuc,
|
||||
:only_equip,
|
||||
:trade_block,
|
||||
:equip_on_level_up,
|
||||
:boss_drop,
|
||||
:boss_reward
|
||||
]
|
||||
end
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Starts the ItemInfo GenServer"
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc "Gets item information by item ID"
|
||||
@spec get_item_info(integer()) :: ItemInformation.t() | nil
|
||||
def get_item_info(item_id) do
|
||||
case :ets.lookup(@item_cache, item_id) do
|
||||
[{^item_id, info}] -> info
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets item name by item ID"
|
||||
@spec get_name(integer()) :: String.t() | nil
|
||||
def get_name(item_id) do
|
||||
case :ets.lookup(@item_names, item_id) do
|
||||
[{^item_id, name}] -> name
|
||||
[] -> "UNKNOWN"
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets item price by item ID"
|
||||
@spec get_price(integer()) :: float()
|
||||
def get_price(item_id) do
|
||||
case get_item_info(item_id) do
|
||||
nil -> 0.0
|
||||
info -> info.price || 0.0
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets maximum stack size for item"
|
||||
@spec get_slot_max(integer()) :: integer()
|
||||
def get_slot_max(item_id) do
|
||||
case get_item_info(item_id) do
|
||||
nil -> 1
|
||||
info -> info.slot_max || 1
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets required level for item"
|
||||
@spec get_req_level(integer()) :: integer()
|
||||
def get_req_level(item_id) do
|
||||
case get_item_info(item_id) do
|
||||
nil -> 0
|
||||
info -> info.req_level || 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if item is cash item"
|
||||
@spec is_cash?(integer()) :: boolean()
|
||||
def is_cash?(item_id) do
|
||||
case get_item_info(item_id) do
|
||||
nil -> false
|
||||
info -> info.cash || false
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if item is tradeable"
|
||||
@spec is_tradeable?(integer()) :: boolean()
|
||||
def is_tradeable?(item_id) do
|
||||
case get_item_info(item_id) do
|
||||
nil -> true
|
||||
info -> if info.tradeable == nil, do: true, else: info.tradeable
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if item is quest item"
|
||||
@spec is_quest?(integer()) :: boolean()
|
||||
def is_quest?(item_id) do
|
||||
case get_item_info(item_id) do
|
||||
nil -> false
|
||||
info -> info.quest || false
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets equipment base stats"
|
||||
@spec get_equip_stats(integer()) :: EquipStats.t() | nil
|
||||
def get_equip_stats(item_id) do
|
||||
case :ets.lookup(@equip_stats_cache, item_id) do
|
||||
[{^item_id, stats}] -> stats
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new equipment item with randomized stats.
|
||||
Returns an Equip struct with base stats.
|
||||
"""
|
||||
@spec create_equip(integer(), integer()) :: Equip.t() | nil
|
||||
def create_equip(item_id, position \\ -1) do
|
||||
case get_equip_stats(item_id) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
stats ->
|
||||
%Equip{
|
||||
id: nil,
|
||||
item_id: item_id,
|
||||
position: position,
|
||||
quantity: 1,
|
||||
flag: 0,
|
||||
unique_id: -1,
|
||||
expiration: -1,
|
||||
owner: "",
|
||||
gift_from: "",
|
||||
gm_log: "",
|
||||
inventory_id: 0,
|
||||
# Base stats from item definition
|
||||
str: stats.str || 0,
|
||||
dex: stats.dex || 0,
|
||||
int: stats.int || 0,
|
||||
luk: stats.luk || 0,
|
||||
hp: stats.hp || 0,
|
||||
mp: stats.mp || 0,
|
||||
watk: stats.watk || 0,
|
||||
matk: stats.matk || 0,
|
||||
wdef: stats.wdef || 0,
|
||||
mdef: stats.mdef || 0,
|
||||
acc: stats.acc || 0,
|
||||
avoid: stats.avoid || 0,
|
||||
hands: stats.hands || 0,
|
||||
speed: stats.speed || 0,
|
||||
jump: stats.jump || 0,
|
||||
upgrade_slots: stats.tuc || 0,
|
||||
level: stats.item_level || 0,
|
||||
item_exp: 0,
|
||||
vicious_hammer: stats.vicious_hammer || 0,
|
||||
durability: stats.durability || -1,
|
||||
enhance: 0,
|
||||
potential1: 0,
|
||||
potential2: 0,
|
||||
potential3: 0,
|
||||
hp_r: 0,
|
||||
mp_r: 0,
|
||||
charm_exp: 0,
|
||||
pvp_damage: 0,
|
||||
inc_skill: 0,
|
||||
ring: nil,
|
||||
android: nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if item exists in cache"
|
||||
@spec item_exists?(integer()) :: boolean()
|
||||
def item_exists?(item_id) do
|
||||
:ets.member(@item_cache, item_id)
|
||||
end
|
||||
|
||||
@doc "Gets all loaded item IDs"
|
||||
@spec get_all_item_ids() :: [integer()]
|
||||
def get_all_item_ids do
|
||||
:ets.select(@item_cache, [{{:"$1", :_}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc "Reloads item data from files"
|
||||
def reload do
|
||||
GenServer.call(__MODULE__, :reload, :infinity)
|
||||
end
|
||||
|
||||
## GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(@item_cache, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@equip_stats_cache, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@item_names, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@set_items, [:set, :public, :named_table, read_concurrency: true])
|
||||
|
||||
# Load data
|
||||
load_item_data()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reload, _from, state) do
|
||||
Logger.info("Reloading item data...")
|
||||
load_item_data()
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp load_item_data do
|
||||
priv_dir = :code.priv_dir(:odinsea) |> to_string()
|
||||
|
||||
# Try to load from JSON files
|
||||
# If files don't exist, create minimal fallback data
|
||||
load_item_strings(Path.join(priv_dir, @string_data_file))
|
||||
load_items(Path.join(priv_dir, @item_data_file))
|
||||
load_equips(Path.join(priv_dir, @equip_data_file))
|
||||
load_set_items(Path.join(priv_dir, @set_data_file))
|
||||
|
||||
item_count = :ets.info(@item_cache, :size)
|
||||
equip_count = :ets.info(@equip_stats_cache, :size)
|
||||
Logger.info("Loaded #{item_count} items and #{equip_count} equipment definitions")
|
||||
end
|
||||
|
||||
defp load_item_strings(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content) do
|
||||
{:ok, data} when is_map(data) ->
|
||||
Enum.each(data, fn {id_str, name} ->
|
||||
case Integer.parse(id_str) do
|
||||
{item_id, ""} -> :ets.insert(@item_names, {item_id, name})
|
||||
_ -> :ok
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse item strings JSON: #{inspect(reason)}")
|
||||
create_fallback_strings()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Item strings file not found: #{file_path}, using fallback data")
|
||||
create_fallback_strings()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read item strings: #{inspect(reason)}")
|
||||
create_fallback_strings()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_items(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, items} when is_list(items) ->
|
||||
Enum.each(items, fn item_data ->
|
||||
info = struct(ItemInformation, item_data)
|
||||
:ets.insert(@item_cache, {info.item_id, info})
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse items JSON: #{inspect(reason)}")
|
||||
create_fallback_items()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Items file not found: #{file_path}, using fallback data")
|
||||
create_fallback_items()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read items: #{inspect(reason)}")
|
||||
create_fallback_items()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_equips(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, equips} when is_list(equips) ->
|
||||
Enum.each(equips, fn equip_data ->
|
||||
stats = struct(EquipStats, equip_data)
|
||||
# Also ensure equip is in item cache
|
||||
item_id = Map.get(equip_data, :item_id)
|
||||
|
||||
if item_id do
|
||||
:ets.insert(@equip_stats_cache, {item_id, stats})
|
||||
|
||||
# Create basic item info if not exists
|
||||
unless :ets.member(@item_cache, item_id) do
|
||||
info = %ItemInformation{
|
||||
item_id: item_id,
|
||||
name: get_name(item_id),
|
||||
slot_max: 1,
|
||||
price: 0.0,
|
||||
tradeable: true,
|
||||
equip_stats: stats
|
||||
}
|
||||
|
||||
:ets.insert(@item_cache, {item_id, info})
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse equips JSON: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Equips file not found: #{file_path}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read equips: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp load_set_items(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, sets} when is_list(sets) ->
|
||||
Enum.each(sets, fn set_data ->
|
||||
set_id = set_data[:set_id]
|
||||
:ets.insert(@set_items, {set_id, set_data})
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse set items JSON: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.debug("Set items file not found: #{file_path}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read set items: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# Fallback data for basic testing without WZ exports
|
||||
defp create_fallback_strings do
|
||||
# Common item names
|
||||
fallback_names = %{
|
||||
# Potions
|
||||
2_000_000 => "Red Potion",
|
||||
2_000_001 => "Orange Potion",
|
||||
2_000_002 => "White Potion",
|
||||
2_000_003 => "Blue Potion",
|
||||
2_000_006 => "Mana Elixir",
|
||||
# Equips
|
||||
1_002_000 => "Blue Bandana",
|
||||
1_040_000 => "Green T-Shirt",
|
||||
1_060_000 => "Blue Jean Shorts",
|
||||
# Weapons
|
||||
1_302_000 => "Sword",
|
||||
1_322_005 => "Wooden Club",
|
||||
# Etc
|
||||
4_000_000 => "Blue Snail Shell",
|
||||
4_000_001 => "Red Snail Shell"
|
||||
}
|
||||
|
||||
Enum.each(fallback_names, fn {item_id, name} ->
|
||||
:ets.insert(@item_names, {item_id, name})
|
||||
end)
|
||||
end
|
||||
|
||||
defp create_fallback_items do
|
||||
# Basic consumables
|
||||
potions = [
|
||||
%{item_id: 2_000_000, slot_max: 100, price: 50.0, recover_hp: 50},
|
||||
%{item_id: 2_000_001, slot_max: 100, price: 100.0, recover_hp: 100},
|
||||
%{item_id: 2_000_002, slot_max: 100, price: 300.0, recover_hp: 300},
|
||||
%{item_id: 2_000_003, slot_max: 100, price: 100.0, recover_mp: 100},
|
||||
%{item_id: 2_000_006, slot_max: 100, price: 500.0, recover_mp: 300}
|
||||
]
|
||||
|
||||
Enum.each(potions, fn potion_data ->
|
||||
info = struct(ItemInformation, Map.merge(potion_data, %{name: get_name(potion_data.item_id), tradeable: true}))
|
||||
:ets.insert(@item_cache, {info.item_id, info})
|
||||
end)
|
||||
end
|
||||
end
|
||||
438
lib/odinsea/game/life_factory.ex
Normal file
438
lib/odinsea/game/life_factory.ex
Normal file
@@ -0,0 +1,438 @@
|
||||
defmodule Odinsea.Game.LifeFactory do
|
||||
@moduledoc """
|
||||
Life Factory - loads and caches monster and NPC data.
|
||||
|
||||
This module loads life metadata (monsters, NPCs, stats, skills) from cached JSON files.
|
||||
The JSON files should be exported from the Java server's WZ data providers.
|
||||
|
||||
Life data is cached in ETS for fast lookups.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
# ETS table names
|
||||
@monster_stats :odinsea_monster_stats
|
||||
@npc_data :odinsea_npc_data
|
||||
@mob_skills :odinsea_mob_skills
|
||||
|
||||
# Data file paths
|
||||
@monster_data_file "data/monsters.json"
|
||||
@npc_data_file "data/npcs.json"
|
||||
@mob_skill_file "data/mob_skills.json"
|
||||
|
||||
defmodule MonsterStats do
|
||||
@moduledoc "Monster statistics and properties"
|
||||
|
||||
@type element :: :physical | :ice | :fire | :poison | :lightning | :holy | :dark
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
mob_id: integer(),
|
||||
name: String.t(),
|
||||
level: integer(),
|
||||
hp: integer(),
|
||||
mp: integer(),
|
||||
exp: integer(),
|
||||
physical_attack: integer(),
|
||||
magic_attack: integer(),
|
||||
physical_defense: integer(),
|
||||
magic_defense: integer(),
|
||||
accuracy: integer(),
|
||||
evasion: integer(),
|
||||
speed: integer(),
|
||||
chase_speed: integer(),
|
||||
boss: boolean(),
|
||||
undead: boolean(),
|
||||
flying: boolean(),
|
||||
friendly: boolean(),
|
||||
public_reward: boolean(),
|
||||
explosive_reward: boolean(),
|
||||
invincible: boolean(),
|
||||
first_attack: boolean(),
|
||||
kb_recovery: integer(),
|
||||
fixed_damage: integer(),
|
||||
only_normal_attack: boolean(),
|
||||
self_destruction_hp: integer(),
|
||||
self_destruction_action: integer(),
|
||||
remove_after: integer(),
|
||||
tag_color: integer(),
|
||||
tag_bg_color: integer(),
|
||||
skills: [integer()],
|
||||
revives: [integer()],
|
||||
drop_item_period: integer(),
|
||||
elemental_attributes: %{element() => atom()}
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:mob_id,
|
||||
:name,
|
||||
:level,
|
||||
:hp,
|
||||
:mp,
|
||||
:exp,
|
||||
:physical_attack,
|
||||
:magic_attack,
|
||||
:physical_defense,
|
||||
:magic_defense,
|
||||
:accuracy,
|
||||
:evasion,
|
||||
:speed,
|
||||
:chase_speed,
|
||||
:boss,
|
||||
:undead,
|
||||
:flying,
|
||||
:friendly,
|
||||
:public_reward,
|
||||
:explosive_reward,
|
||||
:invincible,
|
||||
:first_attack,
|
||||
:kb_recovery,
|
||||
:fixed_damage,
|
||||
:only_normal_attack,
|
||||
:self_destruction_hp,
|
||||
:self_destruction_action,
|
||||
:remove_after,
|
||||
:tag_color,
|
||||
:tag_bg_color,
|
||||
:skills,
|
||||
:revives,
|
||||
:drop_item_period,
|
||||
:elemental_attributes
|
||||
]
|
||||
end
|
||||
|
||||
defmodule NPC do
|
||||
@moduledoc "NPC data"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
npc_id: integer(),
|
||||
name: String.t(),
|
||||
has_shop: boolean(),
|
||||
shop_id: integer() | nil,
|
||||
script: String.t() | nil
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:npc_id,
|
||||
:name,
|
||||
:has_shop,
|
||||
:shop_id,
|
||||
:script
|
||||
]
|
||||
end
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Starts the LifeFactory GenServer"
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc "Gets monster stats by mob ID"
|
||||
@spec get_monster_stats(integer()) :: MonsterStats.t() | nil
|
||||
def get_monster_stats(mob_id) do
|
||||
case :ets.lookup(@monster_stats, mob_id) do
|
||||
[{^mob_id, stats}] -> stats
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets NPC data by NPC ID"
|
||||
@spec get_npc(integer()) :: NPC.t() | nil
|
||||
def get_npc(npc_id) do
|
||||
case :ets.lookup(@npc_data, npc_id) do
|
||||
[{^npc_id, npc}] -> npc
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets monster name by mob ID"
|
||||
@spec get_monster_name(integer()) :: String.t()
|
||||
def get_monster_name(mob_id) do
|
||||
case get_monster_stats(mob_id) do
|
||||
nil -> "UNKNOWN"
|
||||
stats -> stats.name || "UNKNOWN"
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets NPC name by NPC ID"
|
||||
@spec get_npc_name(integer()) :: String.t()
|
||||
def get_npc_name(npc_id) do
|
||||
case get_npc(npc_id) do
|
||||
nil -> "UNKNOWN"
|
||||
npc -> npc.name || "UNKNOWN"
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if monster exists"
|
||||
@spec monster_exists?(integer()) :: boolean()
|
||||
def monster_exists?(mob_id) do
|
||||
:ets.member(@monster_stats, mob_id)
|
||||
end
|
||||
|
||||
@doc "Checks if NPC exists"
|
||||
@spec npc_exists?(integer()) :: boolean()
|
||||
def npc_exists?(npc_id) do
|
||||
:ets.member(@npc_data, npc_id)
|
||||
end
|
||||
|
||||
@doc "Gets all loaded monster IDs"
|
||||
@spec get_all_monster_ids() :: [integer()]
|
||||
def get_all_monster_ids do
|
||||
:ets.select(@monster_stats, [{{:"$1", :_}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc "Gets all loaded NPC IDs"
|
||||
@spec get_all_npc_ids() :: [integer()]
|
||||
def get_all_npc_ids do
|
||||
:ets.select(@npc_data, [{{:"$1", :_}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc "Reloads life data from files"
|
||||
def reload do
|
||||
GenServer.call(__MODULE__, :reload, :infinity)
|
||||
end
|
||||
|
||||
## GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(@monster_stats, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@npc_data, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@mob_skills, [:set, :public, :named_table, read_concurrency: true])
|
||||
|
||||
# Load data
|
||||
load_life_data()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reload, _from, state) do
|
||||
Logger.info("Reloading life data...")
|
||||
load_life_data()
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp load_life_data do
|
||||
priv_dir = :code.priv_dir(:odinsea) |> to_string()
|
||||
|
||||
# Load monsters and NPCs
|
||||
load_monsters(Path.join(priv_dir, @monster_data_file))
|
||||
load_npcs(Path.join(priv_dir, @npc_data_file))
|
||||
|
||||
monster_count = :ets.info(@monster_stats, :size)
|
||||
npc_count = :ets.info(@npc_data, :size)
|
||||
Logger.info("Loaded #{monster_count} monsters and #{npc_count} NPCs")
|
||||
end
|
||||
|
||||
defp load_monsters(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, monsters} when is_list(monsters) ->
|
||||
Enum.each(monsters, fn monster_data ->
|
||||
stats = build_monster_stats(monster_data)
|
||||
:ets.insert(@monster_stats, {stats.mob_id, stats})
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse monsters JSON: #{inspect(reason)}")
|
||||
create_fallback_monsters()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Monsters file not found: #{file_path}, using fallback data")
|
||||
create_fallback_monsters()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read monsters: #{inspect(reason)}")
|
||||
create_fallback_monsters()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_npcs(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, npcs} when is_list(npcs) ->
|
||||
Enum.each(npcs, fn npc_data ->
|
||||
npc = build_npc(npc_data)
|
||||
:ets.insert(@npc_data, {npc.npc_id, npc})
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse NPCs JSON: #{inspect(reason)}")
|
||||
create_fallback_npcs()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("NPCs file not found: #{file_path}, using fallback data")
|
||||
create_fallback_npcs()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read NPCs: #{inspect(reason)}")
|
||||
create_fallback_npcs()
|
||||
end
|
||||
end
|
||||
|
||||
defp build_monster_stats(monster_data) do
|
||||
%MonsterStats{
|
||||
mob_id: monster_data[:mob_id] || monster_data[:id],
|
||||
name: monster_data[:name] || "UNKNOWN",
|
||||
level: monster_data[:level] || 1,
|
||||
hp: monster_data[:hp] || 100,
|
||||
mp: monster_data[:mp] || 0,
|
||||
exp: monster_data[:exp] || 0,
|
||||
physical_attack: monster_data[:physical_attack] || monster_data[:watk] || 10,
|
||||
magic_attack: monster_data[:magic_attack] || monster_data[:matk] || 10,
|
||||
physical_defense: monster_data[:physical_defense] || monster_data[:wdef] || 0,
|
||||
magic_defense: monster_data[:magic_defense] || monster_data[:mdef] || 0,
|
||||
accuracy: monster_data[:accuracy] || monster_data[:acc] || 10,
|
||||
evasion: monster_data[:evasion] || monster_data[:eva] || 5,
|
||||
speed: monster_data[:speed] || 100,
|
||||
chase_speed: monster_data[:chase_speed] || 100,
|
||||
boss: monster_data[:boss] || false,
|
||||
undead: monster_data[:undead] || false,
|
||||
flying: monster_data[:flying] || monster_data[:fly] || false,
|
||||
friendly: monster_data[:friendly] || false,
|
||||
public_reward: monster_data[:public_reward] || false,
|
||||
explosive_reward: monster_data[:explosive_reward] || false,
|
||||
invincible: monster_data[:invincible] || false,
|
||||
first_attack: monster_data[:first_attack] || false,
|
||||
kb_recovery: monster_data[:kb_recovery] || monster_data[:pushed] || 1,
|
||||
fixed_damage: monster_data[:fixed_damage] || 0,
|
||||
only_normal_attack: monster_data[:only_normal_attack] || false,
|
||||
self_destruction_hp: monster_data[:self_destruction_hp] || 0,
|
||||
self_destruction_action: monster_data[:self_destruction_action] || 0,
|
||||
remove_after: monster_data[:remove_after] || -1,
|
||||
tag_color: monster_data[:tag_color] || 0,
|
||||
tag_bg_color: monster_data[:tag_bg_color] || 0,
|
||||
skills: monster_data[:skills] || [],
|
||||
revives: monster_data[:revives] || [],
|
||||
drop_item_period: monster_data[:drop_item_period] || 0,
|
||||
elemental_attributes: monster_data[:elemental_attributes] || %{}
|
||||
}
|
||||
end
|
||||
|
||||
defp build_npc(npc_data) do
|
||||
%NPC{
|
||||
npc_id: npc_data[:npc_id] || npc_data[:id],
|
||||
name: npc_data[:name] || "UNKNOWN",
|
||||
has_shop: npc_data[:has_shop] || false,
|
||||
shop_id: npc_data[:shop_id],
|
||||
script: npc_data[:script]
|
||||
}
|
||||
end
|
||||
|
||||
# Fallback data for basic testing
|
||||
defp create_fallback_monsters do
|
||||
# Common beginner monsters
|
||||
fallback_monsters = [
|
||||
# Blue Snail
|
||||
%{
|
||||
mob_id: 100100,
|
||||
name: "Blue Snail",
|
||||
level: 1,
|
||||
hp: 50,
|
||||
mp: 0,
|
||||
exp: 3,
|
||||
physical_attack: 8,
|
||||
magic_attack: 8,
|
||||
physical_defense: 10,
|
||||
magic_defense: 10,
|
||||
accuracy: 5,
|
||||
evasion: 3,
|
||||
speed: 50,
|
||||
boss: false,
|
||||
undead: false,
|
||||
flying: false
|
||||
},
|
||||
# Red Snail
|
||||
%{
|
||||
mob_id: 130101,
|
||||
name: "Red Snail",
|
||||
level: 3,
|
||||
hp: 80,
|
||||
mp: 0,
|
||||
exp: 5,
|
||||
physical_attack: 12,
|
||||
magic_attack: 12,
|
||||
physical_defense: 15,
|
||||
magic_defense: 15,
|
||||
accuracy: 8,
|
||||
evasion: 5,
|
||||
speed: 50,
|
||||
boss: false,
|
||||
undead: false,
|
||||
flying: false
|
||||
},
|
||||
# Green Mushroom
|
||||
%{
|
||||
mob_id: 1110100,
|
||||
name: "Green Mushroom",
|
||||
level: 7,
|
||||
hp: 200,
|
||||
mp: 0,
|
||||
exp: 12,
|
||||
physical_attack: 30,
|
||||
magic_attack: 30,
|
||||
physical_defense: 30,
|
||||
magic_defense: 30,
|
||||
accuracy: 20,
|
||||
evasion: 10,
|
||||
speed: 80,
|
||||
boss: false,
|
||||
undead: false,
|
||||
flying: false
|
||||
},
|
||||
# Orange Mushroom
|
||||
%{
|
||||
mob_id: 1210100,
|
||||
name: "Orange Mushroom",
|
||||
level: 10,
|
||||
hp: 300,
|
||||
mp: 0,
|
||||
exp: 20,
|
||||
physical_attack: 45,
|
||||
magic_attack: 45,
|
||||
physical_defense: 40,
|
||||
magic_defense: 40,
|
||||
accuracy: 25,
|
||||
evasion: 12,
|
||||
speed: 100,
|
||||
boss: false,
|
||||
undead: false,
|
||||
flying: false
|
||||
}
|
||||
]
|
||||
|
||||
Enum.each(fallback_monsters, fn monster_data ->
|
||||
stats = build_monster_stats(monster_data)
|
||||
:ets.insert(@monster_stats, {stats.mob_id, stats})
|
||||
end)
|
||||
end
|
||||
|
||||
defp create_fallback_npcs do
|
||||
# Common NPCs
|
||||
fallback_npcs = [
|
||||
# Henesys NPCs
|
||||
%{npc_id: 1012000, name: "Athena Pierce", has_shop: false},
|
||||
%{npc_id: 1012001, name: "Robin", has_shop: false},
|
||||
%{npc_id: 1012002, name: "Maya", has_shop: false},
|
||||
# General Shop
|
||||
%{npc_id: 9201045, name: "General Store", has_shop: true, shop_id: 1000},
|
||||
# Beginner instructors
|
||||
%{npc_id: 1002000, name: "Sera", has_shop: false},
|
||||
%{npc_id: 2007, name: "Peter", has_shop: false}
|
||||
]
|
||||
|
||||
Enum.each(fallback_npcs, fn npc_data ->
|
||||
npc = build_npc(npc_data)
|
||||
:ets.insert(@npc_data, {npc.npc_id, npc})
|
||||
end)
|
||||
end
|
||||
end
|
||||
473
lib/odinsea/game/map_factory.ex
Normal file
473
lib/odinsea/game/map_factory.ex
Normal file
@@ -0,0 +1,473 @@
|
||||
defmodule Odinsea.Game.MapFactory do
|
||||
@moduledoc """
|
||||
Map Factory - loads and caches map templates and data.
|
||||
|
||||
This module loads map metadata (portals, footholds, spawns, properties) from cached JSON files.
|
||||
The JSON files should be exported from the Java server's WZ data providers.
|
||||
|
||||
Map templates are cached in ETS for fast lookups.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
# ETS table names
|
||||
@map_templates :odinsea_map_templates
|
||||
@portal_cache :odinsea_portal_cache
|
||||
@foothold_cache :odinsea_foothold_cache
|
||||
|
||||
# Data file paths
|
||||
@map_data_file "data/maps.json"
|
||||
@portal_data_file "data/portals.json"
|
||||
@foothold_data_file "data/footholds.json"
|
||||
|
||||
defmodule Portal do
|
||||
@moduledoc "Represents a portal on a map"
|
||||
|
||||
@type portal_type ::
|
||||
:spawn
|
||||
| :invisible
|
||||
| :visible
|
||||
| :collision
|
||||
| :changeable
|
||||
| :changeable_invisible
|
||||
| :town_portal_point
|
||||
| :script
|
||||
| :sp
|
||||
| :pi
|
||||
| :pv
|
||||
| :tp
|
||||
| :ps
|
||||
| :psi
|
||||
| :hidden
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
name: String.t(),
|
||||
type: portal_type(),
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
target_map: integer(),
|
||||
target_portal: String.t(),
|
||||
script: String.t() | nil
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:id,
|
||||
:name,
|
||||
:type,
|
||||
:x,
|
||||
:y,
|
||||
:target_map,
|
||||
:target_portal,
|
||||
:script
|
||||
]
|
||||
|
||||
@doc "Converts portal type integer to atom"
|
||||
def type_from_int(type_int) do
|
||||
case type_int do
|
||||
0 -> :spawn
|
||||
1 -> :invisible
|
||||
2 -> :visible
|
||||
3 -> :collision
|
||||
4 -> :changeable
|
||||
5 -> :changeable_invisible
|
||||
6 -> :town_portal_point
|
||||
7 -> :script
|
||||
8 -> :sp
|
||||
9 -> :pi
|
||||
10 -> :pv
|
||||
11 -> :tp
|
||||
12 -> :ps
|
||||
13 -> :psi
|
||||
14 -> :hidden
|
||||
_ -> :invisible
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Converts portal type string to atom"
|
||||
def type_from_string(type_str) do
|
||||
case type_str do
|
||||
"sp" -> :spawn
|
||||
"pi" -> :invisible
|
||||
"pv" -> :visible
|
||||
"pc" -> :collision
|
||||
"pg" -> :changeable
|
||||
"pgi" -> :changeable_invisible
|
||||
"tp" -> :town_portal_point
|
||||
"ps" -> :script
|
||||
"psi" -> :script
|
||||
"hidden" -> :hidden
|
||||
_ -> :invisible
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Foothold do
|
||||
@moduledoc "Represents a foothold (platform) on a map"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
x1: integer(),
|
||||
y1: integer(),
|
||||
x2: integer(),
|
||||
y2: integer(),
|
||||
prev: integer(),
|
||||
next: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:id,
|
||||
:x1,
|
||||
:y1,
|
||||
:x2,
|
||||
:y2,
|
||||
:prev,
|
||||
:next
|
||||
]
|
||||
end
|
||||
|
||||
defmodule FieldTemplate do
|
||||
@moduledoc "Map field template containing all map data"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
map_id: integer(),
|
||||
map_name: String.t(),
|
||||
street_name: String.t(),
|
||||
return_map: integer(),
|
||||
forced_return: integer(),
|
||||
mob_rate: float(),
|
||||
field_limit: integer(),
|
||||
time_limit: integer(),
|
||||
dec_hp: integer(),
|
||||
dec_hp_interval: integer(),
|
||||
portal_map: %{String.t() => Portal.t()},
|
||||
portals: [Portal.t()],
|
||||
spawn_points: [Portal.t()],
|
||||
footholds: [Foothold.t()],
|
||||
top: integer(),
|
||||
bottom: integer(),
|
||||
left: integer(),
|
||||
right: integer(),
|
||||
bgm: String.t(),
|
||||
first_user_enter: String.t(),
|
||||
user_enter: String.t(),
|
||||
clock: boolean(),
|
||||
everlast: boolean(),
|
||||
town: boolean(),
|
||||
mount_allowed: boolean(),
|
||||
recovery_rate: float(),
|
||||
create_mob_interval: integer(),
|
||||
fixed_mob_capacity: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:map_id,
|
||||
:map_name,
|
||||
:street_name,
|
||||
:return_map,
|
||||
:forced_return,
|
||||
:mob_rate,
|
||||
:field_limit,
|
||||
:time_limit,
|
||||
:dec_hp,
|
||||
:dec_hp_interval,
|
||||
:portal_map,
|
||||
:portals,
|
||||
:spawn_points,
|
||||
:footholds,
|
||||
:top,
|
||||
:bottom,
|
||||
:left,
|
||||
:right,
|
||||
:bgm,
|
||||
:first_user_enter,
|
||||
:user_enter,
|
||||
:clock,
|
||||
:everlast,
|
||||
:town,
|
||||
:mount_allowed,
|
||||
:recovery_rate,
|
||||
:create_mob_interval,
|
||||
:fixed_mob_capacity
|
||||
]
|
||||
end
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Starts the MapFactory GenServer"
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc "Gets map template by map ID"
|
||||
@spec get_map_template(integer()) :: FieldTemplate.t() | nil
|
||||
def get_map_template(map_id) do
|
||||
case :ets.lookup(@map_templates, map_id) do
|
||||
[{^map_id, template}] -> template
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets portal by name on a map"
|
||||
@spec get_portal(integer(), String.t()) :: Portal.t() | nil
|
||||
def get_portal(map_id, portal_name) do
|
||||
case get_map_template(map_id) do
|
||||
nil -> nil
|
||||
template -> Map.get(template.portal_map, portal_name)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets random spawn portal on a map"
|
||||
@spec get_random_spawn_portal(integer()) :: Portal.t() | nil
|
||||
def get_random_spawn_portal(map_id) do
|
||||
case get_map_template(map_id) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
template ->
|
||||
spawn_points = template.spawn_points
|
||||
|
||||
if Enum.empty?(spawn_points) do
|
||||
nil
|
||||
else
|
||||
Enum.random(spawn_points)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets map name"
|
||||
@spec get_map_name(integer()) :: String.t()
|
||||
def get_map_name(map_id) do
|
||||
case get_map_template(map_id) do
|
||||
nil -> "UNKNOWN"
|
||||
template -> template.map_name || "UNKNOWN"
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets return map ID"
|
||||
@spec get_return_map(integer()) :: integer()
|
||||
def get_return_map(map_id) do
|
||||
case get_map_template(map_id) do
|
||||
nil -> 999_999_999
|
||||
template -> template.return_map || 999_999_999
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if map exists"
|
||||
@spec map_exists?(integer()) :: boolean()
|
||||
def map_exists?(map_id) do
|
||||
:ets.member(@map_templates, map_id)
|
||||
end
|
||||
|
||||
@doc "Gets all loaded map IDs"
|
||||
@spec get_all_map_ids() :: [integer()]
|
||||
def get_all_map_ids do
|
||||
:ets.select(@map_templates, [{{:"$1", :_}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc "Reloads map data from files"
|
||||
def reload do
|
||||
GenServer.call(__MODULE__, :reload, :infinity)
|
||||
end
|
||||
|
||||
## GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(@map_templates, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@portal_cache, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@foothold_cache, [:set, :public, :named_table, read_concurrency: true])
|
||||
|
||||
# Load data
|
||||
load_map_data()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reload, _from, state) do
|
||||
Logger.info("Reloading map data...")
|
||||
load_map_data()
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp load_map_data do
|
||||
priv_dir = :code.priv_dir(:odinsea) |> to_string()
|
||||
|
||||
# Load maps
|
||||
load_maps(Path.join(priv_dir, @map_data_file))
|
||||
|
||||
map_count = :ets.info(@map_templates, :size)
|
||||
Logger.info("Loaded #{map_count} map templates")
|
||||
end
|
||||
|
||||
defp load_maps(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, maps} when is_list(maps) ->
|
||||
Enum.each(maps, fn map_data ->
|
||||
template = build_field_template(map_data)
|
||||
:ets.insert(@map_templates, {template.map_id, template})
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse maps JSON: #{inspect(reason)}")
|
||||
create_fallback_maps()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Maps file not found: #{file_path}, using fallback data")
|
||||
create_fallback_maps()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read maps: #{inspect(reason)}")
|
||||
create_fallback_maps()
|
||||
end
|
||||
end
|
||||
|
||||
defp build_field_template(map_data) do
|
||||
# Parse portals
|
||||
portals =
|
||||
(map_data[:portals] || [])
|
||||
|> Enum.map(&build_portal/1)
|
||||
|
||||
portal_map =
|
||||
portals
|
||||
|> Enum.map(fn portal -> {portal.name, portal} end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
spawn_points =
|
||||
Enum.filter(portals, fn portal ->
|
||||
portal.type == :spawn || portal.name == "sp"
|
||||
end)
|
||||
|
||||
# Parse footholds
|
||||
footholds =
|
||||
(map_data[:footholds] || [])
|
||||
|> Enum.map(&build_foothold/1)
|
||||
|
||||
%FieldTemplate{
|
||||
map_id: map_data[:map_id],
|
||||
map_name: map_data[:map_name] || "",
|
||||
street_name: map_data[:street_name] || "",
|
||||
return_map: map_data[:return_map] || 999_999_999,
|
||||
forced_return: map_data[:forced_return] || 999_999_999,
|
||||
mob_rate: map_data[:mob_rate] || 1.0,
|
||||
field_limit: map_data[:field_limit] || 0,
|
||||
time_limit: map_data[:time_limit] || -1,
|
||||
dec_hp: map_data[:dec_hp] || 0,
|
||||
dec_hp_interval: map_data[:dec_hp_interval] || 10000,
|
||||
portal_map: portal_map,
|
||||
portals: portals,
|
||||
spawn_points: spawn_points,
|
||||
footholds: footholds,
|
||||
top: map_data[:top] || 0,
|
||||
bottom: map_data[:bottom] || 0,
|
||||
left: map_data[:left] || 0,
|
||||
right: map_data[:right] || 0,
|
||||
bgm: map_data[:bgm] || "",
|
||||
first_user_enter: map_data[:first_user_enter] || "",
|
||||
user_enter: map_data[:user_enter] || "",
|
||||
clock: map_data[:clock] || false,
|
||||
everlast: map_data[:everlast] || false,
|
||||
town: map_data[:town] || false,
|
||||
mount_allowed: map_data[:mount_allowed] || true,
|
||||
recovery_rate: map_data[:recovery_rate] || 1.0,
|
||||
create_mob_interval: map_data[:create_mob_interval] || 4000,
|
||||
fixed_mob_capacity: map_data[:fixed_mob_capacity] || 0
|
||||
}
|
||||
end
|
||||
|
||||
defp build_portal(portal_data) do
|
||||
type =
|
||||
cond do
|
||||
is_integer(portal_data[:type]) -> Portal.type_from_int(portal_data[:type])
|
||||
is_binary(portal_data[:type]) -> Portal.type_from_string(portal_data[:type])
|
||||
true -> :invisible
|
||||
end
|
||||
|
||||
%Portal{
|
||||
id: portal_data[:id] || 0,
|
||||
name: portal_data[:name] || "sp",
|
||||
type: type,
|
||||
x: portal_data[:x] || 0,
|
||||
y: portal_data[:y] || 0,
|
||||
target_map: portal_data[:target_map] || 999_999_999,
|
||||
target_portal: portal_data[:target_portal] || "",
|
||||
script: portal_data[:script]
|
||||
}
|
||||
end
|
||||
|
||||
defp build_foothold(foothold_data) do
|
||||
%Foothold{
|
||||
id: foothold_data[:id] || 0,
|
||||
x1: foothold_data[:x1] || 0,
|
||||
y1: foothold_data[:y1] || 0,
|
||||
x2: foothold_data[:x2] || 0,
|
||||
y2: foothold_data[:y2] || 0,
|
||||
prev: foothold_data[:prev] || 0,
|
||||
next: foothold_data[:next] || 0
|
||||
}
|
||||
end
|
||||
|
||||
# Fallback data for basic testing
|
||||
defp create_fallback_maps do
|
||||
# Common beginner maps
|
||||
fallback_maps = [
|
||||
# Maple Island - Southperry
|
||||
%{
|
||||
map_id: 60000,
|
||||
map_name: "Southperry",
|
||||
street_name: "Maple Island",
|
||||
return_map: 60000,
|
||||
forced_return: 60000,
|
||||
portals: [
|
||||
%{id: 0, name: "sp", type: "sp", x: 0, y: 0, target_map: 60000, target_portal: ""}
|
||||
]
|
||||
},
|
||||
# Victoria Island - Henesys
|
||||
%{
|
||||
map_id: 100000000,
|
||||
map_name: "Henesys",
|
||||
street_name: "Victoria Island",
|
||||
return_map: 100000000,
|
||||
forced_return: 100000000,
|
||||
portals: [
|
||||
%{id: 0, name: "sp", type: "sp", x: -1283, y: 86, target_map: 100000000, target_portal: ""}
|
||||
]
|
||||
},
|
||||
# Henesys Hunting Ground I
|
||||
%{
|
||||
map_id: 100010000,
|
||||
map_name: "Henesys Hunting Ground I",
|
||||
street_name: "Victoria Island",
|
||||
return_map: 100000000,
|
||||
forced_return: 100000000,
|
||||
portals: [
|
||||
%{id: 0, name: "sp", type: "sp", x: 0, y: 0, target_map: 100010000, target_portal: ""}
|
||||
]
|
||||
},
|
||||
# Hidden Street - FM Entrance
|
||||
%{
|
||||
map_id: 910000000,
|
||||
map_name: "Free Market Entrance",
|
||||
street_name: "Free Market",
|
||||
return_map: 100000000,
|
||||
forced_return: 100000000,
|
||||
portals: [
|
||||
%{id: 0, name: "sp", type: "sp", x: 0, y: 0, target_map: 910000000, target_portal: ""}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Enum.each(fallback_maps, fn map_data ->
|
||||
template = build_field_template(map_data)
|
||||
:ets.insert(@map_templates, {template.map_id, template})
|
||||
end)
|
||||
end
|
||||
end
|
||||
254
lib/odinsea/game/monster.ex
Normal file
254
lib/odinsea/game/monster.ex
Normal file
@@ -0,0 +1,254 @@
|
||||
defmodule Odinsea.Game.Monster do
|
||||
@moduledoc """
|
||||
Represents a monster (mob) instance on a map.
|
||||
|
||||
Monsters are managed by the Map GenServer, not as separate processes.
|
||||
Each monster has stats, position, HP tracking, and controller assignment.
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.LifeFactory
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
oid: integer(),
|
||||
mob_id: integer(),
|
||||
stats: LifeFactory.MonsterStats.t(),
|
||||
hp: integer(),
|
||||
mp: integer(),
|
||||
max_hp: integer(),
|
||||
max_mp: integer(),
|
||||
position: %{x: integer(), y: integer(), fh: integer()},
|
||||
stance: integer(),
|
||||
controller_id: integer() | nil,
|
||||
controller_has_aggro: boolean(),
|
||||
spawn_effect: integer(),
|
||||
team: integer(),
|
||||
fake: boolean(),
|
||||
link_oid: integer(),
|
||||
status_effects: %{atom() => any()},
|
||||
poisons: [any()],
|
||||
attackers: %{integer() => %{damage: integer(), last_hit: integer()}},
|
||||
last_attack: integer(),
|
||||
last_move: integer(),
|
||||
last_skill_use: integer(),
|
||||
killed: boolean(),
|
||||
drops_disabled: boolean(),
|
||||
create_time: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:oid,
|
||||
:mob_id,
|
||||
:stats,
|
||||
:hp,
|
||||
:mp,
|
||||
:max_hp,
|
||||
:max_mp,
|
||||
:position,
|
||||
:stance,
|
||||
:controller_id,
|
||||
:controller_has_aggro,
|
||||
:spawn_effect,
|
||||
:team,
|
||||
:fake,
|
||||
:link_oid,
|
||||
:status_effects,
|
||||
:poisons,
|
||||
:attackers,
|
||||
:last_attack,
|
||||
:last_move,
|
||||
:last_skill_use,
|
||||
:killed,
|
||||
:drops_disabled,
|
||||
:create_time
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a new monster instance.
|
||||
"""
|
||||
def new(mob_id, oid, position) do
|
||||
stats = LifeFactory.get_monster_stats(mob_id)
|
||||
|
||||
if stats do
|
||||
%__MODULE__{
|
||||
oid: oid,
|
||||
mob_id: mob_id,
|
||||
stats: stats,
|
||||
hp: stats.hp,
|
||||
mp: stats.mp,
|
||||
max_hp: stats.hp,
|
||||
max_mp: stats.mp,
|
||||
position: position,
|
||||
stance: 5,
|
||||
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)
|
||||
}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Damages the monster.
|
||||
Returns {:ok, monster, damage_dealt} or {:dead, monster, damage_dealt}
|
||||
"""
|
||||
def damage(%__MODULE__{} = monster, damage_amount, attacker_id) do
|
||||
# Track attacker
|
||||
attacker_entry = Map.get(monster.attackers, attacker_id, %{damage: 0, last_hit: 0})
|
||||
|
||||
new_attacker_entry = %{
|
||||
attacker_entry
|
||||
| damage: attacker_entry.damage + damage_amount,
|
||||
last_hit: System.system_time(:millisecond)
|
||||
}
|
||||
|
||||
new_attackers = Map.put(monster.attackers, attacker_id, new_attacker_entry)
|
||||
|
||||
# Apply damage
|
||||
new_hp = max(0, monster.hp - damage_amount)
|
||||
new_monster = %{monster | hp: new_hp, attackers: new_attackers}
|
||||
|
||||
if new_hp <= 0 do
|
||||
{:dead, %{new_monster | killed: true}, damage_amount}
|
||||
else
|
||||
{:ok, new_monster, damage_amount}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Heals the monster.
|
||||
"""
|
||||
def heal(%__MODULE__{} = monster, heal_amount) do
|
||||
new_hp = min(monster.max_hp, monster.hp + heal_amount)
|
||||
%{monster | hp: new_hp}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the monster's controller.
|
||||
"""
|
||||
def set_controller(%__MODULE__{} = monster, controller_id) do
|
||||
%{monster | controller_id: controller_id, controller_has_aggro: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes the monster's controller.
|
||||
"""
|
||||
def clear_controller(%__MODULE__{} = monster) do
|
||||
%{monster | controller_id: nil, controller_has_aggro: false}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the monster's position.
|
||||
"""
|
||||
def update_position(%__MODULE__{} = monster, position) do
|
||||
%{monster | position: position, last_move: System.system_time(:millisecond)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the monster is boss.
|
||||
"""
|
||||
def boss?(%__MODULE__{} = monster) do
|
||||
monster.stats.boss
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the monster is dead.
|
||||
"""
|
||||
def dead?(%__MODULE__{} = monster) do
|
||||
monster.hp <= 0 || monster.killed
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the monster's name.
|
||||
"""
|
||||
def name(%__MODULE__{} = monster) do
|
||||
monster.stats.name
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the monster's level.
|
||||
"""
|
||||
def level(%__MODULE__{} = monster) do
|
||||
monster.stats.level
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates EXP drop for this monster.
|
||||
"""
|
||||
def calculate_exp(%__MODULE__{} = monster, exp_rate \\ 1.0) do
|
||||
base_exp = monster.stats.exp
|
||||
trunc(base_exp * exp_rate)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the top attacker (highest damage dealer).
|
||||
"""
|
||||
def get_top_attacker(%__MODULE__{} = monster) do
|
||||
if Enum.empty?(monster.attackers) do
|
||||
nil
|
||||
else
|
||||
{attacker_id, _entry} =
|
||||
Enum.max_by(monster.attackers, fn {_id, entry} -> entry.damage end)
|
||||
|
||||
attacker_id
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all attackers sorted by damage (descending).
|
||||
"""
|
||||
def get_attackers_sorted(%__MODULE__{} = monster) do
|
||||
monster.attackers
|
||||
|> Enum.sort_by(fn {_id, entry} -> entry.damage end, :desc)
|
||||
|> Enum.map(fn {id, entry} -> {id, entry.damage} end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies a status effect to the monster.
|
||||
"""
|
||||
def apply_status_effect(%__MODULE__{} = monster, effect_name, effect_data) do
|
||||
new_effects = Map.put(monster.status_effects, effect_name, effect_data)
|
||||
%{monster | status_effects: new_effects}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a status effect from the monster.
|
||||
"""
|
||||
def remove_status_effect(%__MODULE__{} = monster, effect_name) do
|
||||
new_effects = Map.delete(monster.status_effects, effect_name)
|
||||
%{monster | status_effects: new_effects}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if monster has a status effect.
|
||||
"""
|
||||
def has_status_effect?(%__MODULE__{} = monster, effect_name) do
|
||||
Map.has_key?(monster.status_effects, effect_name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disables drops for this monster.
|
||||
"""
|
||||
def disable_drops(%__MODULE__{} = monster) do
|
||||
%{monster | drops_disabled: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if drops are disabled.
|
||||
"""
|
||||
def drops_disabled?(%__MODULE__{} = monster) do
|
||||
monster.drops_disabled
|
||||
end
|
||||
end
|
||||
190
lib/odinsea/game/shop.ex
Normal file
190
lib/odinsea/game/shop.ex
Normal file
@@ -0,0 +1,190 @@
|
||||
defmodule Odinsea.Game.Shop do
|
||||
@moduledoc """
|
||||
Manages NPC shops: buying, selling, and recharging items.
|
||||
Ported from src/server/MapleShop.java
|
||||
|
||||
TODO: Full implementation requires:
|
||||
- Shop item database loading
|
||||
- Inventory system integration
|
||||
- Item information provider
|
||||
- Price calculations
|
||||
- Buyback system
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
# Shop item structure
|
||||
defmodule ShopItem do
|
||||
@moduledoc """
|
||||
Represents an item in a shop.
|
||||
"""
|
||||
defstruct [
|
||||
:item_id,
|
||||
:price,
|
||||
:pitch,
|
||||
:quantity,
|
||||
:max_per_slot,
|
||||
:req_item,
|
||||
:req_item_q,
|
||||
:category,
|
||||
:rank
|
||||
]
|
||||
end
|
||||
|
||||
# Rechargeable items (throwing stars and bullets)
|
||||
@rechargeable_items MapSet.new([
|
||||
# Throwing stars
|
||||
2_070_000,
|
||||
2_070_001,
|
||||
2_070_002,
|
||||
2_070_003,
|
||||
2_070_004,
|
||||
2_070_005,
|
||||
2_070_006,
|
||||
2_070_007,
|
||||
2_070_008,
|
||||
2_070_009,
|
||||
2_070_010,
|
||||
2_070_011,
|
||||
2_070_012,
|
||||
2_070_013,
|
||||
2_070_016,
|
||||
2_070_018,
|
||||
2_070_019,
|
||||
2_070_023,
|
||||
2_070_024,
|
||||
# Bullets
|
||||
2_330_000,
|
||||
2_330_001,
|
||||
2_330_002,
|
||||
2_330_003,
|
||||
2_330_004,
|
||||
2_330_005,
|
||||
2_330_007,
|
||||
2_330_008,
|
||||
2_331_000,
|
||||
2_332_000
|
||||
])
|
||||
|
||||
# GenServer client API
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Load a shop by NPC ID.
|
||||
"""
|
||||
def load_shop(npc_id) do
|
||||
# TODO: Load shop from database
|
||||
# For now, return a stub shop
|
||||
{:ok, %{id: npc_id, npc_id: npc_id, items: []}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Send shop to client.
|
||||
"""
|
||||
def send_shop(client_pid, shop, npc_id) do
|
||||
# TODO: Send shop packet to client
|
||||
# Should encode shop items and send OPEN_NPC_SHOP packet
|
||||
Logger.debug("Sending shop #{shop.id} to client #{inspect(client_pid)} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle buying an item from shop.
|
||||
"""
|
||||
def buy_item(client_pid, shop, item_id, quantity) do
|
||||
# TODO: Full buy implementation:
|
||||
# 1. Find shop item by item_id
|
||||
# 2. Validate mount item for job (if mount)
|
||||
# 3. Check inventory space
|
||||
# 4. Check mesos or required item
|
||||
# 5. Deduct cost and give item
|
||||
# 6. Send confirmation packet
|
||||
|
||||
Logger.debug("Shop buy: item=#{item_id}, qty=#{quantity} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle selling an item to shop.
|
||||
"""
|
||||
def sell_item(client_pid, shop, inv_type, slot, quantity) do
|
||||
# TODO: Full sell implementation:
|
||||
# 1. Get item from inventory
|
||||
# 2. Validate item can be sold (not pet, not expiring, etc.)
|
||||
# 3. Add to buyback (if applicable)
|
||||
# 4. Remove item from inventory
|
||||
# 5. Calculate sell price
|
||||
# 6. Give mesos to player
|
||||
# 7. Send confirmation packet
|
||||
|
||||
Logger.debug("Shop sell: type=#{inv_type}, slot=#{slot}, qty=#{quantity} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle recharging throwing stars or bullets.
|
||||
"""
|
||||
def recharge_item(client_pid, shop, slot) do
|
||||
# TODO: Full recharge implementation:
|
||||
# 1. Get item from USE inventory
|
||||
# 2. Validate item is rechargeable (stars/bullets)
|
||||
# 3. Validate shop sells this item
|
||||
# 4. Calculate recharge cost
|
||||
# 5. Check mesos
|
||||
# 6. Recharge to full quantity
|
||||
# 7. Send confirmation packet
|
||||
|
||||
Logger.debug("Shop recharge: slot=#{slot} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Check if an item is rechargeable.
|
||||
"""
|
||||
def rechargeable?(item_id) do
|
||||
MapSet.member?(@rechargeable_items, item_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find a shop item by item ID.
|
||||
"""
|
||||
def find_shop_item(shop, item_id) do
|
||||
Enum.find(shop.items, fn item -> item.item_id == item_id end)
|
||||
end
|
||||
|
||||
# GenServer callbacks
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
shop_id = Keyword.fetch!(opts, :shop_id)
|
||||
npc_id = Keyword.get(opts, :npc_id, shop_id)
|
||||
|
||||
state = %{
|
||||
id: shop_id,
|
||||
npc_id: npc_id,
|
||||
items: []
|
||||
}
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_item, shop_item}, _from, state) do
|
||||
new_items = [shop_item | state.items]
|
||||
{:reply, :ok, %{state | items: new_items}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_items, _from, state) do
|
||||
{:reply, state.items, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_npc_id, _from, state) do
|
||||
{:reply, state.npc_id, state}
|
||||
end
|
||||
end
|
||||
183
lib/odinsea/game/storage.ex
Normal file
183
lib/odinsea/game/storage.ex
Normal file
@@ -0,0 +1,183 @@
|
||||
defmodule Odinsea.Game.Storage do
|
||||
@moduledoc """
|
||||
Manages personal storage (bank) for characters.
|
||||
Ported from src/server/MapleStorage.java
|
||||
|
||||
TODO: Full implementation requires:
|
||||
- Database persistence
|
||||
- Inventory system integration
|
||||
- Item serialization
|
||||
- Slot management
|
||||
- Meso storage
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
@default_slots 4
|
||||
@max_slots 48
|
||||
@storage_fee 100
|
||||
|
||||
defstruct [
|
||||
:character_id,
|
||||
:account_id,
|
||||
:slots,
|
||||
:meso,
|
||||
items: %{}
|
||||
]
|
||||
|
||||
# Client API
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Load storage for a character.
|
||||
"""
|
||||
def load_storage(account_id) do
|
||||
# TODO: Load from database
|
||||
{:ok, %__MODULE__{account_id: account_id, slots: @default_slots, meso: 0, items: %{}}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Send storage to client.
|
||||
"""
|
||||
def send_storage(client_pid, storage) do
|
||||
# TODO: Send OPEN_STORAGE packet
|
||||
Logger.debug("Sending storage to client #{inspect(client_pid)} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Take out an item from storage.
|
||||
"""
|
||||
def take_out_item(storage, slot) do
|
||||
# TODO: Full implementation:
|
||||
# 1. Validate slot
|
||||
# 2. Get item from storage
|
||||
# 3. Check inventory space
|
||||
# 4. Remove from storage
|
||||
# 5. Add to inventory
|
||||
# 6. Send update packet
|
||||
|
||||
Logger.debug("Storage take out: slot=#{slot} (STUB)")
|
||||
{:ok, storage}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Store an item in storage.
|
||||
"""
|
||||
def store_item(storage, item, slot) do
|
||||
# TODO: Full implementation:
|
||||
# 1. Check storage is not full
|
||||
# 2. Validate item (not pet, not trade-restricted)
|
||||
# 3. Charge storage fee (100 mesos)
|
||||
# 4. Remove karma flags if applicable
|
||||
# 5. Remove from inventory
|
||||
# 6. Add to storage
|
||||
# 7. Send update packet
|
||||
|
||||
Logger.debug("Storage store: item=#{inspect(item)}, slot=#{slot} (STUB)")
|
||||
{:ok, storage}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Arrange/sort storage items.
|
||||
"""
|
||||
def arrange(storage) do
|
||||
# TODO: Sort items by type/ID
|
||||
Logger.debug("Storage arrange (STUB)")
|
||||
{:ok, storage}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deposit or withdraw mesos.
|
||||
"""
|
||||
def transfer_meso(storage, amount) do
|
||||
# TODO: Full implementation:
|
||||
# 1. Validate amount (positive = withdraw, negative = deposit)
|
||||
# 2. Check character has mesos (if depositing)
|
||||
# 3. Check storage has mesos (if withdrawing)
|
||||
# 4. Handle overflow protection
|
||||
# 5. Transfer mesos
|
||||
# 6. Send update packet
|
||||
|
||||
Logger.debug("Storage meso transfer: #{amount} (STUB)")
|
||||
{:ok, storage}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Close storage.
|
||||
"""
|
||||
def close(storage) do
|
||||
# TODO: Save to database
|
||||
Logger.debug("Storage close (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Check if storage is full.
|
||||
"""
|
||||
def full?(storage) do
|
||||
map_size(storage.items) >= storage.slots
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get next available slot.
|
||||
"""
|
||||
def next_slot(storage) do
|
||||
Enum.find(1..storage.slots, fn slot -> !Map.has_key?(storage.items, slot) end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find item by ID in storage.
|
||||
"""
|
||||
def find_by_id(storage, item_id) do
|
||||
Enum.find(storage.items, fn {_slot, item} -> item.item_id == item_id end)
|
||||
end
|
||||
|
||||
# GenServer callbacks
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
account_id = Keyword.fetch!(opts, :account_id)
|
||||
|
||||
state = %__MODULE__{
|
||||
account_id: account_id,
|
||||
slots: @default_slots,
|
||||
meso: 0,
|
||||
items: %{}
|
||||
}
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_item, slot, item}, _from, state) do
|
||||
if slot <= state.slots and !Map.has_key?(state.items, slot) do
|
||||
new_items = Map.put(state.items, slot, item)
|
||||
{:reply, :ok, %{state | items: new_items}}
|
||||
else
|
||||
{:reply, {:error, :invalid_slot}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_item, slot}, _from, state) do
|
||||
case Map.pop(state.items, slot) do
|
||||
{nil, _} -> {:reply, {:error, :no_item}, state}
|
||||
{item, new_items} -> {:reply, {:ok, item}, %{state | items: new_items}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_meso, meso}, _from, state) do
|
||||
{:reply, :ok, %{state | meso: meso}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_meso, _from, state) do
|
||||
{:reply, state.meso, state}
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user