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