update
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user