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