439 lines
12 KiB
Elixir
439 lines
12 KiB
Elixir
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
|