kimi gone wild

This commit is contained in:
ra
2026-02-14 23:12:33 -07:00
parent bbd205ecbe
commit 0222be36c5
98 changed files with 39726 additions and 309 deletions

View File

@@ -0,0 +1,675 @@
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