Files
odinsea-elixir/lib/odinsea/game/skill.ex
2026-02-14 23:12:33 -07:00

272 lines
8.2 KiB
Elixir

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