Files
odinsea-elixir/lib/odinsea/game/life_factory.ex
2026-02-14 19:36:59 -07:00

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