defmodule Odinsea.Game.Skill do @moduledoc """ Skill struct and functions for MapleStory skills. Ported from Java: client/Skill.java Skills are abilities that characters can learn and use. Each skill has: - Multiple levels with increasing effects - Requirements (job, level, other skills) - Effects (buffs, damage, healing, etc.) - Animation data - Cooldowns and durations """ alias Odinsea.Game.StatEffect defstruct [ :id, :name, :element, :max_level, :true_max, :master_level, :effects, :pvp_effects, :required_skills, :skill_type, :animation, :animation_time, :delay, :invisible, :time_limited, :combat_orders, :charge_skill, :magic, :caster_move, :push_target, :pull_target, :not_removed, :pvp_disabled, :event_taming_mob ] @type element :: :neutral | :fire | :ice | :lightning | :poison | :holy | :dark | :physical @type t :: %__MODULE__{ id: integer(), name: String.t(), element: element(), max_level: integer(), true_max: integer(), master_level: integer(), effects: [StatEffect.t()], pvp_effects: [StatEffect.t()] | nil, required_skills: [{integer(), integer()}], skill_type: integer(), animation: [{String.t(), integer()}] | nil, animation_time: integer(), delay: integer(), invisible: boolean(), time_limited: boolean(), combat_orders: boolean(), charge_skill: boolean(), magic: boolean(), caster_move: boolean(), push_target: boolean(), pull_target: boolean(), not_removed: boolean(), pvp_disabled: boolean(), event_taming_mob: integer() } @doc """ Creates a new skill with the given ID and default values. """ @spec new(integer()) :: t() def new(id) do %__MODULE__{ id: id, name: "", element: :neutral, max_level: 0, true_max: 0, master_level: 0, effects: [], pvp_effects: nil, required_skills: [], skill_type: 0, animation: nil, animation_time: 0, delay: 0, invisible: false, time_limited: false, combat_orders: false, charge_skill: false, magic: false, caster_move: false, push_target: false, pull_target: false, not_removed: false, pvp_disabled: false, event_taming_mob: 0 } end @doc """ Gets the effect for a specific skill level. Returns the last effect if level exceeds max, or first effect if level <= 0. """ @spec get_effect(t(), integer()) :: StatEffect.t() | nil def get_effect(skill, level) do effects = skill.effects cond do length(effects) == 0 -> nil level <= 0 -> List.first(effects) level > length(effects) -> List.last(effects) true -> Enum.at(effects, level - 1) end end @doc """ Gets the PVP effect for a specific skill level. Falls back to regular effects if PVP effects not defined. """ @spec get_pvp_effect(t(), integer()) :: StatEffect.t() | nil def get_pvp_effect(skill, level) do if skill.pvp_effects do cond do level <= 0 -> List.first(skill.pvp_effects) level > length(skill.pvp_effects) -> List.last(skill.pvp_effects) true -> Enum.at(skill.pvp_effects, level - 1) end else get_effect(skill, level) end end @doc """ Checks if this skill can be learned by a specific job. """ @spec can_be_learned_by?(t(), integer()) :: boolean() def can_be_learned_by?(skill, job_id) do skill_job = div(skill.id, 10000) # Special job exceptions cond do # Evan beginner skills skill_job == 2001 -> is_evan_job?(job_id) # Regular beginner skills (adventurer) skill_job == 0 -> is_adventurer_job?(job_id) # Cygnus beginner skills skill_job == 1000 -> is_cygnus_job?(job_id) # Aran beginner skills skill_job == 2000 -> is_aran_job?(job_id) # Resistance beginner skills skill_job == 3000 -> is_resistance_job?(job_id) # Cannon shooter beginner skill_job == 1 -> is_cannon_job?(job_id) # Demon beginner skill_job == 3001 -> is_demon_job?(job_id) # Mercedes beginner skill_job == 2002 -> is_mercedes_job?(job_id) # Wrong job category div(job_id, 100) != div(skill_job, 100) -> false div(job_id, 1000) != div(skill_job, 1000) -> false # Class-specific restrictions is_cannon_job?(skill_job) and not is_cannon_job?(job_id) -> false is_demon_job?(skill_job) and not is_demon_job?(job_id) -> false is_adventurer_job?(skill_job) and not is_adventurer_job?(job_id) -> false is_cygnus_job?(skill_job) and not is_cygnus_job?(job_id) -> false is_aran_job?(skill_job) and not is_aran_job?(job_id) -> false is_evan_job?(skill_job) and not is_evan_job?(job_id) -> false is_mercedes_job?(skill_job) and not is_mercedes_job?(job_id) -> false is_resistance_job?(skill_job) and not is_resistance_job?(job_id) -> false # Wrong 2nd job rem(div(job_id, 10), 10) == 0 and rem(div(skill_job, 10), 10) > rem(div(job_id, 10), 10) -> false rem(div(skill_job, 10), 10) != 0 and rem(div(skill_job, 10), 10) != rem(div(job_id, 10), 10) -> false # Wrong 3rd/4th job rem(skill_job, 10) > rem(job_id, 10) -> false true -> true end end @doc """ Checks if this is a fourth job skill. """ @spec is_fourth_job?(t()) :: boolean() def is_fourth_job?(skill) do job_id = div(skill.id, 10000) cond do # All 10 skills for 2312 (Phantom) job_id == 2312 -> true # Skills with max level <= 15 and no master level skill.max_level <= 15 and not skill.invisible and skill.master_level <= 0 -> false # Specific exceptions skill.id in [3_220_010, 3_120_011, 33_120_010, 32_120_009, 5_321_006, 21_120_011, 22_181_004, 4_340_010] -> false # Evan skills job_id >= 2212 and job_id < 3000 -> rem(job_id, 10) >= 7 # Dual Blade skills job_id >= 430 and job_id <= 434 -> rem(job_id, 10) == 4 or skill.master_level > 0 # Standard 4th job detection rem(job_id, 10) == 2 and skill.id < 90_000_000 and not is_beginner_skill?(skill) -> true true -> false end end @doc """ Checks if this is a beginner skill. """ @spec is_beginner_skill?(t()) :: boolean() def is_beginner_skill?(skill) do job_id = div(skill.id, 10000) job_id in [0, 1000, 2000, 2001, 2002, 3000, 3001, 1] end @doc """ Checks if skill has required skills that must be learned first. """ @spec has_required_skill?(t()) :: boolean() def has_required_skill?(skill) do length(skill.required_skills) > 0 end @doc """ Gets the default skill expiration time for time-limited skills. Returns -1 for permanent skills, or 30 days from now for time-limited. """ @spec get_default_expiry(t()) :: integer() def get_default_expiry(skill) do if skill.time_limited do # 30 days in milliseconds System.system_time(:millisecond) + 30 * 24 * 60 * 60 * 1000 else -1 end end @doc """ Checks if this is a special skill (GM, admin, etc). """ @spec is_special_skill?(t()) :: boolean() def is_special_skill?(skill) do job_id = div(skill.id, 10000) job_id in [900, 800, 9000, 9200, 9201, 9202, 9203, 9204] end @doc """ Gets a random animation from the skill's animation list. """ @spec get_animation(t()) :: integer() | nil def get_animation(skill) do if skill.animation && length(skill.animation) > 0 do {_, delay} = Enum.random(skill.animation) delay else nil end end # Job type checks defp is_evan_job?(job_id), do: div(job_id, 100) == 22 or job_id == 2001 defp is_adventurer_job?(job_id), do: div(job_id, 1000) == 0 and job_id not in [1] defp is_cygnus_job?(job_id), do: div(job_id, 1000) == 1 defp is_aran_job?(job_id), do: div(job_id, 100) == 21 or job_id == 2000 defp is_resistance_job?(job_id), do: div(job_id, 1000) == 3 defp is_cannon_job?(job_id), do: div(job_id, 100) == 53 or job_id == 1 defp is_demon_job?(job_id), do: div(job_id, 100) == 31 or job_id == 3001 defp is_mercedes_job?(job_id), do: div(job_id, 100) == 23 or job_id == 2002 end