This commit is contained in:
ra
2026-02-14 19:36:59 -07:00
parent f5b8aeb39d
commit bbd205ecbe
19 changed files with 5191 additions and 554 deletions

View 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