550 lines
15 KiB
Elixir
550 lines
15 KiB
Elixir
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
|