defmodule Odinsea.Game.SkillFactory do @moduledoc """ Skill Factory - loads and caches skill data. Ported from Java: client/SkillFactory.java This module loads skill metadata from cached JSON files. The JSON files should be exported from the Java server's WZ data providers. Skill data is cached in ETS for fast lookups. """ use GenServer require Logger alias Odinsea.Game.{Skill, StatEffect} # ETS table names @skill_cache :odinsea_skill_cache @skill_names :odinsea_skill_names @skills_by_job :odinsea_skills_by_job @summon_skills :odinsea_summon_skills # Data file paths (relative to priv directory) @skill_data_file "data/skills.json" @skill_strings_file "data/skill_strings.json" defmodule SummonSkillEntry do @moduledoc "Summon skill attack data" @type t :: %__MODULE__{ skill_id: integer(), type: integer(), mob_count: integer(), attack_count: integer(), lt: {integer(), integer()}, rb: {integer(), integer()}, delay: integer() } defstruct [ :skill_id, :type, :mob_count, :attack_count, :lt, :rb, :delay ] end ## Public API @doc "Starts the SkillFactory GenServer" def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Gets a skill by ID. Returns nil if not found. """ @spec get_skill(integer()) :: Skill.t() | nil def get_skill(skill_id) do case :ets.lookup(@skill_cache, skill_id) do [{^skill_id, skill}] -> skill [] -> nil end end @doc """ Gets skill name by ID. """ @spec get_skill_name(integer()) :: String.t() def get_skill_name(skill_id) do case :ets.lookup(@skill_names, skill_id) do [{^skill_id, name}] -> name [] -> "UNKNOWN" end end @doc """ Gets all skills for a specific job. """ @spec get_skills_by_job(integer()) :: [integer()] def get_skills_by_job(job_id) do case :ets.lookup(@skills_by_job, job_id) do [{^job_id, skills}] -> skills [] -> [] end end @doc """ Gets summon skill entry for a skill ID. """ @spec get_summon_data(integer()) :: SummonSkillEntry.t() | nil def get_summon_data(skill_id) do case :ets.lookup(@summon_skills, skill_id) do [{^skill_id, entry}] -> entry [] -> nil end end @doc """ Checks if a skill exists. """ @spec skill_exists?(integer()) :: boolean() def skill_exists?(skill_id) do :ets.member(@skill_cache, skill_id) end @doc """ Gets all loaded skill IDs. """ @spec get_all_skill_ids() :: [integer()] def get_all_skill_ids do :ets.select(@skill_cache, [{{:"$1", :_}, [], [:"$1"]}]) end @doc """ Gets skill effect for a specific level. Convenience function that combines get_skill and Skill.get_effect. """ @spec get_effect(integer(), integer()) :: StatEffect.t() | nil def get_effect(skill_id, level) do case get_skill(skill_id) do nil -> nil skill -> Skill.get_effect(skill, level) end end @doc """ Checks if a skill is a beginner skill. """ @spec is_beginner_skill?(integer()) :: boolean() def is_beginner_skill?(skill_id) do job_id = div(skill_id, 10000) job_id in [0, 1000, 2000, 2001, 2002, 3000, 3001, 1] end @doc """ Gets the job ID for a skill. """ @spec get_skill_job(integer()) :: integer() def get_skill_job(skill_id) do div(skill_id, 10000) end @doc """ Reloads skill 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(@skill_cache, [:set, :public, :named_table, read_concurrency: true]) :ets.new(@skill_names, [:set, :public, :named_table, read_concurrency: true]) :ets.new(@skills_by_job, [:set, :public, :named_table, read_concurrency: true]) :ets.new(@summon_skills, [:set, :public, :named_table, read_concurrency: true]) # Load data load_skill_data() {:ok, %{}} end @impl true def handle_call(:reload, _from, state) do Logger.info("Reloading skill data...") load_skill_data() {:reply, :ok, state} end ## Private Functions defp load_skill_data do priv_dir = :code.priv_dir(:odinsea) |> to_string() # Try to load from JSON files load_skill_strings(Path.join(priv_dir, @skill_strings_file)) load_skills(Path.join(priv_dir, @skill_data_file)) skill_count = :ets.info(@skill_cache, :size) Logger.info("Loaded #{skill_count} skills") end defp load_skill_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 {skill_id, ""} -> :ets.insert(@skill_names, {skill_id, name}) _ -> :ok end end) {:error, reason} -> Logger.warn("Failed to parse skill strings JSON: #{inspect(reason)}") create_fallback_strings() end {:error, :enoent} -> Logger.warn("Skill strings file not found: #{file_path}, using fallback data") create_fallback_strings() {:error, reason} -> Logger.error("Failed to read skill strings: #{inspect(reason)}") create_fallback_strings() end end defp load_skills(file_path) do case File.read(file_path) do {:ok, content} -> case Jason.decode(content, keys: :atoms) do {:ok, skills} when is_list(skills) -> Enum.each(skills, fn skill_data -> skill = build_skill(skill_data) :ets.insert(@skill_cache, {skill.id, skill}) # Index by job job_id = div(skill.id, 10000) existing = case :ets.lookup(@skills_by_job, job_id) do [{^job_id, list}] -> list [] -> [] end :ets.insert(@skills_by_job, {job_id, [skill.id | existing]}) # Check for summon data if skill_data[:summon] do entry = build_summon_entry(skill.id, skill_data[:summon]) :ets.insert(@summon_skills, {skill.id, entry}) end end) {:error, reason} -> Logger.warn("Failed to parse skills JSON: #{inspect(reason)}") create_fallback_skills() end {:error, :enoent} -> Logger.warn("Skills file not found: #{file_path}, using fallback data") create_fallback_skills() {:error, reason} -> Logger.error("Failed to read skills: #{inspect(reason)}") create_fallback_skills() end end defp build_skill(data) do effects = (data[:effects] || []) |> Enum.map(&build_stat_effect/1) pvp_effects = if data[:pvp_effects] do Enum.map(data[:pvp_effects], &build_stat_effect/1) else nil end %Skill{ id: data[:id] || data[:skill_id] || 0, name: data[:name] || "", element: parse_element(data[:element]), max_level: data[:max_level] || 0, true_max: data[:true_max] || data[:max_level] || 0, master_level: data[:master_level] || 0, effects: effects, pvp_effects: pvp_effects, required_skills: data[:required_skills] || [], skill_type: data[:skill_type] || 0, animation: data[:animation], animation_time: data[:animation_time] || 0, delay: data[:delay] || 0, invisible: data[:invisible] || false, time_limited: data[:time_limited] || false, combat_orders: data[:combat_orders] || false, charge_skill: data[:charge_skill] || false, magic: data[:magic] || false, caster_move: data[:caster_move] || false, push_target: data[:push_target] || false, pull_target: data[:pull_target] || false, not_removed: data[:not_removed] || false, pvp_disabled: data[:pvp_disabled] || false, event_taming_mob: data[:event_taming_mob] || 0 } end defp build_stat_effect(data) do %StatEffect{ source_id: data[:source_id] || 0, level: data[:level] || 1, is_skill: data[:is_skill] || true, duration: data[:duration] || -1, over_time: data[:over_time] || false, hp: data[:hp] || 0, mp: data[:mp] || 0, hp_r: data[:hp_r] || 0.0, mp_r: data[:mp_r] || 0.0, mhp_r: data[:mhp_r] || 0, mmp_r: data[:mmp_r] || 0, watk: data[:watk] || data[:pad] || 0, wdef: data[:wdef] || data[:pdd] || 0, matk: data[:matk] || data[:mad] || 0, mdef: data[:mdef] || data[:mdd] || 0, acc: data[:acc] || 0, avoid: data[:avoid] || data[:eva] || 0, hands: data[:hands] || 0, speed: data[:speed] || 0, jump: data[:jump] || 0, mastery: data[:mastery] || 0, damage: data[:damage] || 100, pdd_r: data[:pdd_r] || 0, mdd_r: data[:mdd_r] || 0, dam_r: data[:dam_r] || 0, bd_r: data[:bd_r] || 0, ignore_mob: data[:ignore_mob] || 0, critical_damage_min: data[:critical_damage_min] || 0, critical_damage_max: data[:critical_damage_max] || 0, asr_r: data[:asr_r] || 0, er: data[:er] || 0, prop: data[:prop] || 100, mob_count: data[:mob_count] || 1, attack_count: data[:attack_count] || 1, bullet_count: data[:bullet_count] || 1, cooldown: data[:cooldown] || data[:cooltime] || 0, interval: data[:interval] || 0, mp_con: data[:mp_con] || 0, hp_con: data[:hp_con] || 0, force_con: data[:force_con] || 0, mp_con_reduce: data[:mp_con_reduce] || 0, move_to: data[:move_to] || -1, morph_id: data[:morph] || data[:morph_id] || 0, summon_movement_type: parse_summon_movement(data[:summon_movement]), dot: data[:dot] || 0, dot_time: data[:dot_time] || 0, thaw: data[:thaw] || 0, self_destruction: data[:self_destruction] || 0, pvp_damage: data[:pvp_damage] || 0, inc_pvp_damage: data[:inc_pvp_damage] || 0, indie_pad: data[:indie_pad] || 0, indie_mad: data[:indie_mad] || 0, indie_mhp: data[:indie_mhp] || 0, indie_mmp: data[:indie_mmp] || 0, indie_speed: data[:indie_speed] || 0, indie_jump: data[:indie_jump] || 0, indie_acc: data[:indie_acc] || 0, indie_eva: data[:indie_eva] || 0, indie_pdd: data[:indie_pdd] || 0, indie_mdd: data[:indie_mdd] || 0, indie_all_stat: data[:indie_all_stat] || 0, str: data[:str] || 0, dex: data[:dex] || 0, int: data[:int] || 0, luk: data[:luk] || 0, str_x: data[:str_x] || 0, dex_x: data[:dex_x] || 0, int_x: data[:int_x] || 0, luk_x: data[:luk_x] || 0, x: data[:x] || 0, y: data[:y] || 0, z: data[:z] || 0, stat_ups: data[:stat_ups] || %{}, monster_status: data[:monster_status] || %{} } end defp build_summon_entry(skill_id, summon_data) do %SummonSkillEntry{ skill_id: skill_id, type: summon_data[:type] || 0, mob_count: summon_data[:mob_count] || 1, attack_count: summon_data[:attack_count] || 1, lt: parse_point(summon_data[:lt]) || {-100, -100}, rb: parse_point(summon_data[:rb]) || {100, 100}, delay: summon_data[:delay] || 0 } end defp parse_element(nil), do: :neutral defp parse_element("f"), do: :fire defp parse_element("i"), do: :ice defp parse_element("l"), do: :lightning defp parse_element("p"), do: :poison defp parse_element("h"), do: :holy defp parse_element("d"), do: :dark defp parse_element("s"), do: :physical defp parse_element(atom) when is_atom(atom), do: atom defp parse_element(_), do: :neutral defp parse_summon_movement(nil), do: nil defp parse_summon_movement("follow"), do: :follow defp parse_summon_movement("stationary"), do: :stationary defp parse_summon_movement("circle_follow"), do: :circle_follow defp parse_summon_movement(atom) when is_atom(atom), do: atom defp parse_summon_movement(_), do: nil defp parse_point(nil), do: nil defp parse_point({x, y}), do: {x, y} defp parse_point([x, y]), do: {x, y} defp parse_point(%{x: x, y: y}), do: {x, y} defp parse_point(_), do: nil # Fallback data for basic testing without WZ exports defp create_fallback_strings do fallback_names = %{ # Beginner skills 1_000 => "Three Snails", 1_001 => "Recovery", 1_002 => "Nimble Feet", 1_003 => "Monster Rider", 1_004 => "Echo of Hero", # Warrior 1st job 100_000 => "Power Strike", 100_001 => "Slash Blast", 100_002 => "Iron Body", 100_003 => "Iron Body", 100_004 => "Power Strike", 100_005 => "Slash Blast", 100_006 => "Iron Body", 100_007 => "Power Strike", 100_008 => "Slash Blast", 100_009 => "Iron Body", 100_010 => "Power Strike", 100_100 => "Power Strike", 100_101 => "Slash Blast", 100_102 => "Iron Body", # Magician 1st job 200_000 => "Magic Claw", 200_001 => "Teleport", 200_002 => "Magic Guard", 200_003 => "Magic Armor", 200_004 => "Energy Bolt", 200_005 => "Magic Claw", 200_006 => "Teleport", 200_007 => "Magic Guard", 200_008 => "Magic Armor", 200_009 => "Energy Bolt", 200_100 => "Magic Claw", 200_101 => "Teleport", 200_102 => "Magic Guard", 200_103 => "Magic Armor", 200_104 => "Energy Bolt", # Bowman 1st job 300_000 => "Arrow Blow", 300_001 => "Double Shot", 300_002 => "Critical Shot", 300_003 => "The Eye of Amazon", 300_004 => "Focus", 300_100 => "Arrow Blow", 300_101 => "Double Shot", 300_102 => "Critical Shot", 300_103 => "The Eye of Amazon", 300_104 => "Focus", # Thief 1st job 400_000 => "Lucky Seven", 400_001 => "Double Stab", 400_002 => "Disorder", 400_003 => "Dark Sight", 400_004 => "Lucky Seven", 400_005 => "Double Stab", 400_100 => "Lucky Seven", 400_101 => "Double Stab", 400_102 => "Disorder", 400_103 => "Dark Sight", 400_104 => "Lucky Seven", 400_105 => "Double Stab", # Pirate 1st job 500_000 => "Somersault Kick", 500_001 => "Double Fire", 500_002 => "Dash", 500_003 => "Shadow Heart", 500_004 => "Somersault Kick", 500_005 => "Double Fire", 500_100 => "Somersault Kick", 500_101 => "Double Fire", 500_102 => "Dash", 500_103 => "Shadow Heart", 500_104 => "Somersault Kick", 500_105 => "Double Fire", # GM skills 9_001_000 => "Haste", 9_001_001 => "Dragon Roar", 9_001_002 => "Holy Symbol", 9_001_003 => "Heal", 9_001_004 => "Hide", 9_001_005 => "Resurrection", 9_001_006 => "Hyper Body", 9_001_007 => "Holy Shield", 9_001_008 => "Holy Shield", # 4th job common 1_122_004 => "Hero's Will", 1_222_004 => "Hero's Will", 1_322_004 => "Hero's Will", 2_122_004 => "Hero's Will", 2_222_004 => "Hero's Will", 2_322_004 => "Hero's Will", 3_122_004 => "Hero's Will", 4_122_004 => "Hero's Will", 4_222_004 => "Hero's Will", 5_122_004 => "Hero's Will", 5_222_004 => "Hero's Will", # Maple Warrior (all 4th jobs) 1_121_000 => "Maple Warrior", 1_221_000 => "Maple Warrior", 1_321_000 => "Maple Warrior", 2_121_000 => "Maple Warrior", 2_221_000 => "Maple Warrior", 2_321_000 => "Maple Warrior", 3_121_000 => "Maple Warrior", 3_221_000 => "Maple Warrior", 4_121_000 => "Maple Warrior", 4_221_000 => "Maple Warrior", 5_121_000 => "Maple Warrior", 5_221_000 => "Maple Warrior" } Enum.each(fallback_names, fn {skill_id, name} -> :ets.insert(@skill_names, {skill_id, name}) end) end defp create_fallback_skills do # Create some basic beginner skills as fallback fallback_skills = [ %{ id: 1_000, name: "Three Snails", element: :physical, max_level: 3, true_max: 3, effects: [ %{level: 1, damage: 150, mp_con: 10, mob_count: 1, x: 15}, %{level: 2, damage: 200, mp_con: 15, mob_count: 1, x: 30}, %{level: 3, damage: 250, mp_con: 20, mob_count: 1, x: 45} ] }, %{ id: 1_001, name: "Recovery", element: :neutral, max_level: 3, true_max: 3, effects: [ %{level: 1, duration: 30000, hp: 10, interval: 2000, x: 10}, %{level: 2, duration: 30000, hp: 20, interval: 1900, x: 20}, %{level: 3, duration: 30000, hp: 30, interval: 1800, x: 30} ] }, %{ id: 1_002, name: "Nimble Feet", element: :neutral, max_level: 3, true_max: 3, effects: [ %{level: 1, duration: 4000, speed: 10, x: 10}, %{level: 2, duration: 8000, speed: 15, x: 15}, %{level: 3, duration: 12000, speed: 20, x: 20} ] }, %{ id: 1_004, name: "Echo of Hero", element: :neutral, max_level: 1, true_max: 1, effects: [ %{level: 1, duration: 1200000, watk: 4, wdef: 4, matk: 4, mdef: 4, x: 4} ] }, %{ id: 100_000, name: "Power Strike", element: :physical, max_level: 20, true_max: 20, skill_type: 1, effects: [ %{level: 1, damage: 145, mp_con: 8, mob_count: 1, attack_count: 1}, %{level: 10, damage: 190, mp_con: 16, mob_count: 1, attack_count: 1}, %{level: 20, damage: 245, mp_con: 24, mob_count: 1, attack_count: 1} ] }, %{ id: 100_001, name: "Slash Blast", element: :physical, max_level: 20, true_max: 20, skill_type: 1, effects: [ %{level: 1, damage: 85, mp_con: 8, mob_count: 3, attack_count: 1}, %{level: 10, damage: 115, mp_con: 16, mob_count: 4, attack_count: 1}, %{level: 20, damage: 150, mp_con: 24, mob_count: 6, attack_count: 1} ] }, %{ id: 200_000, name: "Magic Claw", element: :neutral, max_level: 20, true_max: 20, magic: true, skill_type: 1, effects: [ %{level: 1, damage: 132, mp_con: 12, mob_count: 2, attack_count: 1, x: 22}, %{level: 10, damage: 156, mp_con: 24, mob_count: 2, attack_count: 1, x: 26}, %{level: 20, damage: 182, mp_con: 36, mob_count: 2, attack_count: 1, x: 30} ] }, %{ id: 200_001, name: "Teleport", element: :neutral, max_level: 20, true_max: 20, skill_type: 2, effects: [ %{level: 1, mp_con: 40, x: 70}, %{level: 10, mp_con: 35, x: 115}, %{level: 20, mp_con: 30, x: 160} ] }, %{ id: 200_002, name: "Magic Guard", element: :neutral, max_level: 20, true_max: 20, skill_type: 2, effects: [ %{level: 1, x: 15}, %{level: 10, x: 42}, %{level: 20, x: 70} ] } ] Enum.each(fallback_skills, fn skill_data -> skill = build_skill(skill_data) :ets.insert(@skill_cache, {skill.id, skill}) job_id = div(skill.id, 10000) existing = case :ets.lookup(@skills_by_job, job_id) do [{^job_id, list}] -> list [] -> [] end :ets.insert(@skills_by_job, {job_id, [skill.id | existing]}) end) end end