1048 lines
30 KiB
Elixir
1048 lines
30 KiB
Elixir
defmodule Odinsea.Game.Character do
|
|
@moduledoc """
|
|
Represents an in-game character (player) with stats, position, inventory, skills, etc.
|
|
This is a GenServer that manages character state while the player is logged into a channel.
|
|
|
|
Unlike the database schema (Odinsea.Database.Schema.Character), this module represents
|
|
the live, mutable game state including position, buffs, equipment effects, etc.
|
|
"""
|
|
use GenServer
|
|
require Logger
|
|
|
|
alias Odinsea.Database.Schema.Character, as: CharacterDB
|
|
alias Odinsea.Game.Map, as: GameMap
|
|
alias Odinsea.Game.{Inventory, InventoryType, Pet}
|
|
alias Odinsea.Net.Packet.Out
|
|
|
|
# ============================================================================
|
|
# Data Structures
|
|
# ============================================================================
|
|
|
|
defmodule Stats do
|
|
@moduledoc "Character stats (base + equipped + buffed)"
|
|
defstruct [
|
|
# Base stats
|
|
:str,
|
|
:dex,
|
|
:int,
|
|
:luk,
|
|
:hp,
|
|
:max_hp,
|
|
:mp,
|
|
:max_mp,
|
|
# Computed stats (from equipment + buffs)
|
|
:weapon_attack,
|
|
:magic_attack,
|
|
:weapon_defense,
|
|
:magic_defense,
|
|
:accuracy,
|
|
:avoidability,
|
|
:speed,
|
|
:jump
|
|
]
|
|
|
|
@type t :: %__MODULE__{
|
|
str: non_neg_integer(),
|
|
dex: non_neg_integer(),
|
|
int: non_neg_integer(),
|
|
luk: non_neg_integer(),
|
|
hp: non_neg_integer(),
|
|
max_hp: non_neg_integer(),
|
|
mp: non_neg_integer(),
|
|
max_mp: non_neg_integer(),
|
|
weapon_attack: non_neg_integer(),
|
|
magic_attack: non_neg_integer(),
|
|
weapon_defense: non_neg_integer(),
|
|
magic_defense: non_neg_integer(),
|
|
accuracy: non_neg_integer(),
|
|
avoidability: non_neg_integer(),
|
|
speed: non_neg_integer(),
|
|
jump: non_neg_integer()
|
|
}
|
|
end
|
|
|
|
defmodule Position do
|
|
@moduledoc "Character position and stance"
|
|
defstruct [:x, :y, :foothold, :stance]
|
|
|
|
@type t :: %__MODULE__{
|
|
x: integer(),
|
|
y: integer(),
|
|
foothold: non_neg_integer(),
|
|
stance: byte()
|
|
}
|
|
end
|
|
|
|
defmodule State do
|
|
@moduledoc "In-game character state"
|
|
defstruct [
|
|
# Identity
|
|
:character_id,
|
|
:account_id,
|
|
:name,
|
|
:world_id,
|
|
:channel_id,
|
|
# Character data
|
|
:level,
|
|
:job,
|
|
:exp,
|
|
:meso,
|
|
:fame,
|
|
:gender,
|
|
:skin_color,
|
|
:hair,
|
|
:face,
|
|
# GM Level (0 = normal player, >0 = GM)
|
|
:gm,
|
|
# Guild
|
|
:guild_id,
|
|
:guild_rank,
|
|
:alliance_rank,
|
|
# Stats
|
|
:stats,
|
|
# Position & Map
|
|
:map_id,
|
|
:position,
|
|
:spawn_point,
|
|
# AP/SP
|
|
:remaining_ap,
|
|
:remaining_sp,
|
|
# Client connection
|
|
:client_pid,
|
|
# Inventory (TODO)
|
|
:inventories,
|
|
# Skills (TODO)
|
|
:skills,
|
|
# Buffs (TODO)
|
|
:buffs,
|
|
# Pets (TODO)
|
|
:pets,
|
|
# Timestamps
|
|
:created_at,
|
|
:updated_at
|
|
]
|
|
|
|
@type t :: %__MODULE__{
|
|
character_id: pos_integer(),
|
|
account_id: pos_integer(),
|
|
name: String.t(),
|
|
world_id: byte(),
|
|
channel_id: byte(),
|
|
level: non_neg_integer(),
|
|
job: non_neg_integer(),
|
|
exp: non_neg_integer(),
|
|
meso: non_neg_integer(),
|
|
fame: integer(),
|
|
gender: byte(),
|
|
skin_color: byte(),
|
|
hair: non_neg_integer(),
|
|
face: non_neg_integer(),
|
|
gm: non_neg_integer(),
|
|
guild_id: non_neg_integer(),
|
|
guild_rank: non_neg_integer(),
|
|
alliance_rank: non_neg_integer(),
|
|
stats: Stats.t(),
|
|
map_id: non_neg_integer(),
|
|
position: Position.t(),
|
|
spawn_point: byte(),
|
|
remaining_ap: non_neg_integer(),
|
|
remaining_sp: list(non_neg_integer()),
|
|
client_pid: pid() | nil,
|
|
inventories: map(),
|
|
skills: map(),
|
|
buffs: list(),
|
|
pets: list(),
|
|
created_at: DateTime.t(),
|
|
updated_at: DateTime.t()
|
|
}
|
|
end
|
|
|
|
# ============================================================================
|
|
# Client API
|
|
# ============================================================================
|
|
|
|
@doc """
|
|
Starts a character GenServer for an in-game player.
|
|
"""
|
|
def start_link(opts) do
|
|
character_id = Keyword.fetch!(opts, :character_id)
|
|
GenServer.start_link(__MODULE__, opts, name: via_tuple(character_id))
|
|
end
|
|
|
|
@doc """
|
|
Loads a character from the database and starts their GenServer.
|
|
"""
|
|
def load(character_id, account_id, world_id, channel_id, client_pid) do
|
|
case Odinsea.Database.Context.get_character(character_id) do
|
|
nil ->
|
|
{:error, :character_not_found}
|
|
|
|
db_char ->
|
|
# Verify ownership
|
|
if db_char.account_id != account_id do
|
|
{:error, :unauthorized}
|
|
else
|
|
state = from_database(db_char, world_id, channel_id, client_pid)
|
|
|
|
case start_link(character_id: character_id, state: state) do
|
|
{:ok, pid} -> {:ok, pid, state}
|
|
{:error, {:already_started, pid}} -> {:ok, pid, state}
|
|
error -> error
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets the current character state.
|
|
"""
|
|
def get_state(character_id) do
|
|
GenServer.call(via_tuple(character_id), :get_state)
|
|
end
|
|
|
|
@doc """
|
|
Updates character position (from movement packet).
|
|
"""
|
|
def update_position(character_id, position) do
|
|
GenServer.cast(via_tuple(character_id), {:update_position, position})
|
|
end
|
|
|
|
@doc """
|
|
Changes the character's map.
|
|
"""
|
|
def change_map(character_id, new_map_id, spawn_point \\ 0) do
|
|
GenServer.call(via_tuple(character_id), {:change_map, new_map_id, spawn_point})
|
|
end
|
|
|
|
@doc """
|
|
Gets the character's current map ID.
|
|
"""
|
|
def get_map_id(character_id) do
|
|
GenServer.call(via_tuple(character_id), :get_map_id)
|
|
end
|
|
|
|
@doc """
|
|
Gets the character's client PID.
|
|
"""
|
|
def get_client_pid(character_id) do
|
|
GenServer.call(via_tuple(character_id), :get_client_pid)
|
|
end
|
|
|
|
@doc """
|
|
Saves character to database.
|
|
"""
|
|
def save(character_id) do
|
|
GenServer.call(via_tuple(character_id), :save)
|
|
end
|
|
|
|
@doc """
|
|
Stops the character GenServer (logout).
|
|
"""
|
|
def logout(character_id) do
|
|
GenServer.stop(via_tuple(character_id), :normal)
|
|
end
|
|
|
|
# ============================================================================
|
|
# Inventory API
|
|
# ============================================================================
|
|
|
|
@doc """
|
|
Gets a specific inventory.
|
|
"""
|
|
def get_inventory(character_id, inv_type) do
|
|
GenServer.call(via_tuple(character_id), {:get_inventory, inv_type})
|
|
end
|
|
|
|
@doc """
|
|
Gets an item from a specific inventory slot.
|
|
"""
|
|
def get_item(character_id, inv_type, position) do
|
|
GenServer.call(via_tuple(character_id), {:get_item, inv_type, position})
|
|
end
|
|
|
|
@doc """
|
|
Moves an item within or between inventories.
|
|
"""
|
|
def move_item(character_id, inv_type, src_slot, dst_slot, slot_max \\ 100) do
|
|
GenServer.call(via_tuple(character_id), {:move_item, inv_type, src_slot, dst_slot, slot_max})
|
|
end
|
|
|
|
@doc """
|
|
Equips an item (moves from EQUIP inventory to EQUIPPED).
|
|
"""
|
|
def equip_item(character_id, src_slot, dst_slot) do
|
|
GenServer.call(via_tuple(character_id), {:equip_item, src_slot, dst_slot})
|
|
end
|
|
|
|
@doc """
|
|
Unequips an item (moves from EQUIPPED to EQUIP inventory).
|
|
"""
|
|
def unequip_item(character_id, src_slot, dst_slot) do
|
|
GenServer.call(via_tuple(character_id), {:unequip_item, src_slot, dst_slot})
|
|
end
|
|
|
|
@doc """
|
|
Drops an item from inventory.
|
|
"""
|
|
def drop_item(character_id, inv_type, position, quantity \\ 1) do
|
|
GenServer.call(via_tuple(character_id), {:drop_item, inv_type, position, quantity})
|
|
end
|
|
|
|
@doc """
|
|
Adds meso to the character.
|
|
Returns {:ok, new_meso} on success, {:error, reason} on failure.
|
|
"""
|
|
def gain_meso(character_id, amount, show_in_chat \\ false) do
|
|
GenServer.call(via_tuple(character_id), {:gain_meso, amount, show_in_chat})
|
|
end
|
|
|
|
@doc """
|
|
Checks if the character has inventory space for an item.
|
|
Returns {:ok, slot} with the next free slot, or {:error, :inventory_full}.
|
|
"""
|
|
def check_inventory_space(character_id, inv_type, quantity \\ 1) do
|
|
GenServer.call(via_tuple(character_id), {:check_inventory_space, inv_type, quantity})
|
|
end
|
|
|
|
@doc """
|
|
Adds an item to the character's inventory (from a drop).
|
|
Returns {:ok, item} on success, {:error, reason} on failure.
|
|
"""
|
|
def add_item_from_drop(character_id, item) do
|
|
GenServer.call(via_tuple(character_id), {:add_item_from_drop, item})
|
|
end
|
|
|
|
# ============================================================================
|
|
# GenServer Callbacks
|
|
# ============================================================================
|
|
|
|
@impl true
|
|
def init(opts) do
|
|
state = Keyword.fetch!(opts, :state)
|
|
Logger.debug("Character loaded: #{state.name} (ID: #{state.character_id})")
|
|
{:ok, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:get_state, _from, state) do
|
|
{:reply, state, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:get_map_id, _from, state) do
|
|
{:reply, state.map_id, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:get_client_pid, _from, state) do
|
|
{:reply, state.client_pid, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:change_map, new_map_id, spawn_point}, _from, state) do
|
|
old_map_id = state.map_id
|
|
|
|
# Remove from old map
|
|
if old_map_id do
|
|
GameMap.remove_player(old_map_id, state.character_id)
|
|
end
|
|
|
|
# Update state
|
|
new_state = %{
|
|
state
|
|
| map_id: new_map_id,
|
|
spawn_point: spawn_point,
|
|
updated_at: DateTime.utc_now()
|
|
}
|
|
|
|
# Add to new map
|
|
GameMap.add_player(new_map_id, state.character_id)
|
|
|
|
{:reply, :ok, new_state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:save, _from, state) do
|
|
result = save_to_database(state)
|
|
{:reply, result, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:get_inventory, inv_type}, _from, state) do
|
|
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
|
|
{:reply, inventory, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:get_item, inv_type, position}, _from, state) do
|
|
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
|
|
item = Inventory.get_item(inventory, position)
|
|
{:reply, item, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:move_item, inv_type, src_slot, dst_slot, slot_max}, _from, state) do
|
|
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
|
|
|
|
case Inventory.move(inventory, src_slot, dst_slot, slot_max) do
|
|
{:ok, new_inventory} ->
|
|
new_inventories = Map.put(state.inventories, inv_type, new_inventory)
|
|
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
|
{:reply, :ok, new_state}
|
|
|
|
{:error, reason} ->
|
|
{:reply, {:error, reason}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:equip_item, src_slot, dst_slot}, _from, state) do
|
|
# Move from EQUIP to EQUIPPED
|
|
equip_inv = Map.get(state.inventories, :equip, Inventory.new(:equip))
|
|
equipped_inv = Map.get(state.inventories, :equipped, Inventory.new(:equipped))
|
|
|
|
case Inventory.move(equip_inv, src_slot, dst_slot, 1) do
|
|
{:ok, new_equip_inv} ->
|
|
new_inventories =
|
|
state.inventories
|
|
|> Map.put(:equip, new_equip_inv)
|
|
|> Map.put(:equipped, equipped_inv)
|
|
|
|
# TODO: Recalculate stats based on new equipment
|
|
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
|
{:reply, :ok, new_state}
|
|
|
|
{:error, reason} ->
|
|
{:reply, {:error, reason}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:unequip_item, src_slot, dst_slot}, _from, state) do
|
|
# Move from EQUIPPED to EQUIP
|
|
equipped_inv = Map.get(state.inventories, :equipped, Inventory.new(:equipped))
|
|
equip_inv = Map.get(state.inventories, :equip, Inventory.new(:equip))
|
|
|
|
case Inventory.move(equipped_inv, src_slot, dst_slot, 1) do
|
|
{:ok, new_equipped_inv} ->
|
|
new_inventories =
|
|
state.inventories
|
|
|> Map.put(:equipped, new_equipped_inv)
|
|
|> Map.put(:equip, equip_inv)
|
|
|
|
# TODO: Recalculate stats based on removed equipment
|
|
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
|
{:reply, :ok, new_state}
|
|
|
|
{:error, reason} ->
|
|
{:reply, {:error, reason}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:drop_item, inv_type, position, quantity}, _from, state) do
|
|
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
|
|
|
|
case Inventory.drop(inventory, position, quantity) do
|
|
{:ok, dropped_item, new_inventory} ->
|
|
new_inventories = Map.put(state.inventories, inv_type, new_inventory)
|
|
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
|
{:reply, {:ok, dropped_item}, new_state}
|
|
|
|
{:error, reason} ->
|
|
{:reply, {:error, reason}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:gain_meso, amount, show_in_chat}, _from, state) do
|
|
# Cap meso at 9,999,999,999 (MapleStory max)
|
|
max_meso = 9_999_999_999
|
|
new_meso = min(state.meso + amount, max_meso)
|
|
|
|
new_state = %{state | meso: new_meso, updated_at: DateTime.utc_now()}
|
|
|
|
# TODO: Send meso gain packet to client if show_in_chat is true
|
|
|
|
{:reply, {:ok, new_meso}, new_state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:check_inventory_space, inv_type, _quantity}, _from, state) do
|
|
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
|
|
|
|
case Inventory.get_next_free_slot(inventory) do
|
|
nil -> {:reply, {:error, :inventory_full}, state}
|
|
slot -> {:reply, {:ok, slot}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:add_item_from_drop, item}, _from, state) do
|
|
inv_type = get_inventory_type_from_item_id(item.item_id)
|
|
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
|
|
|
|
case Inventory.add_item(inventory, item) do
|
|
{:ok, new_inventory, assigned_item} ->
|
|
new_inventories = Map.put(state.inventories, inv_type, new_inventory)
|
|
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
|
{:reply, {:ok, assigned_item}, new_state}
|
|
|
|
{:error, reason} ->
|
|
{:reply, {:error, reason}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:update_position, position}, state) do
|
|
new_state = %{
|
|
state
|
|
| position: position,
|
|
updated_at: DateTime.utc_now()
|
|
}
|
|
|
|
{:noreply, new_state}
|
|
end
|
|
|
|
@impl true
|
|
def terminate(reason, state) do
|
|
Logger.debug(
|
|
"Character logout: #{state.name} (ID: #{state.character_id}), reason: #{inspect(reason)}"
|
|
)
|
|
|
|
# Remove from map
|
|
if state.map_id do
|
|
GameMap.remove_player(state.map_id, state.character_id)
|
|
end
|
|
|
|
# Save to database
|
|
save_to_database(state)
|
|
|
|
:ok
|
|
end
|
|
|
|
# ============================================================================
|
|
# Helper Functions
|
|
# ============================================================================
|
|
|
|
defp via_tuple(character_id) do
|
|
{:via, Registry, {Odinsea.CharacterRegistry, character_id}}
|
|
end
|
|
|
|
defp get_inventory_type_from_item_id(item_id) do
|
|
type_prefix = div(item_id, 1_000_000)
|
|
|
|
case type_prefix do
|
|
1 -> :equip
|
|
2 -> :use
|
|
3 -> :setup
|
|
4 -> :etc
|
|
5 -> :cash
|
|
_ -> :etc
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Converts database character to in-game state.
|
|
"""
|
|
def from_database(%CharacterDB{} = db_char, world_id, channel_id, client_pid) do
|
|
stats = %Stats{
|
|
str: db_char.str,
|
|
dex: db_char.dex,
|
|
int: db_char.int,
|
|
luk: db_char.luk,
|
|
hp: db_char.hp,
|
|
max_hp: db_char.max_hp,
|
|
mp: db_char.mp,
|
|
max_mp: db_char.max_mp,
|
|
# Computed stats - TODO: calculate from equipment
|
|
weapon_attack: 0,
|
|
magic_attack: 0,
|
|
weapon_defense: 0,
|
|
magic_defense: 0,
|
|
accuracy: 0,
|
|
avoidability: 0,
|
|
speed: 100,
|
|
jump: 100
|
|
}
|
|
|
|
position = %Position{
|
|
x: 0,
|
|
y: 0,
|
|
foothold: 0,
|
|
stance: 0
|
|
}
|
|
|
|
# Parse remaining_sp (stored as comma-separated string in Java version)
|
|
remaining_sp =
|
|
case db_char.remaining_sp do
|
|
nil -> [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
|
sp_str when is_binary(sp_str) -> parse_sp_string(sp_str)
|
|
sp_list when is_list(sp_list) -> sp_list
|
|
end
|
|
|
|
# Load inventories from database
|
|
inventories = load_inventories(db_char.id)
|
|
|
|
%State{
|
|
character_id: db_char.id,
|
|
account_id: db_char.account_id,
|
|
name: db_char.name,
|
|
world_id: world_id,
|
|
channel_id: channel_id,
|
|
level: db_char.level,
|
|
job: db_char.job,
|
|
exp: db_char.exp,
|
|
meso: db_char.meso,
|
|
fame: db_char.fame,
|
|
gender: db_char.gender,
|
|
skin_color: db_char.skin_color,
|
|
hair: db_char.hair,
|
|
face: db_char.face,
|
|
gm: db_char.gm,
|
|
guild_id: db_char.guild_id || 0,
|
|
guild_rank: db_char.guild_rank || 0,
|
|
alliance_rank: db_char.alliance_rank || 0,
|
|
stats: stats,
|
|
map_id: db_char.map_id,
|
|
position: position,
|
|
spawn_point: db_char.spawn_point,
|
|
remaining_ap: db_char.remaining_ap,
|
|
remaining_sp: remaining_sp,
|
|
client_pid: client_pid,
|
|
inventories: inventories,
|
|
skills: %{},
|
|
buffs: [],
|
|
pets: [],
|
|
created_at: db_char.inserted_at,
|
|
updated_at: DateTime.utc_now()
|
|
}
|
|
end
|
|
|
|
defp load_inventories(character_id) do
|
|
# Initialize empty inventories for all types
|
|
base_inventories =
|
|
InventoryType.all_types()
|
|
|> Map.new(fn type -> {type, Inventory.new(type)} end)
|
|
|
|
# Load items from database
|
|
case Odinsea.Database.Context.load_character_inventory(character_id) do
|
|
nil ->
|
|
base_inventories
|
|
|
|
items_by_type ->
|
|
# Add items to appropriate inventories
|
|
Enum.reduce(items_by_type, base_inventories, fn {type, items}, acc ->
|
|
inventory =
|
|
Enum.reduce(items, Inventory.new(type), fn item, inv ->
|
|
Inventory.add_from_db(inv, item)
|
|
end)
|
|
|
|
Map.put(acc, type, inventory)
|
|
end)
|
|
end
|
|
end
|
|
|
|
defp parse_sp_string(sp_str) do
|
|
sp_str
|
|
|> String.split(",")
|
|
|> Enum.map(&String.to_integer/1)
|
|
|> Kernel.++(List.duplicate(0, 10))
|
|
|> Enum.take(10)
|
|
rescue
|
|
_ -> [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
|
end
|
|
|
|
@doc """
|
|
Saves character state to database.
|
|
"""
|
|
def save_to_database(%State{} = state) do
|
|
# Convert remaining_sp list to comma-separated string for database
|
|
sp_string = Enum.join(state.remaining_sp, ",")
|
|
|
|
attrs = %{
|
|
level: state.level,
|
|
job: state.job,
|
|
exp: state.exp,
|
|
str: state.stats.str,
|
|
dex: state.stats.dex,
|
|
int: state.stats.int,
|
|
luk: state.stats.luk,
|
|
hp: state.stats.hp,
|
|
max_hp: state.stats.max_hp,
|
|
mp: state.stats.mp,
|
|
max_mp: state.stats.max_mp,
|
|
meso: state.meso,
|
|
fame: state.fame,
|
|
map_id: state.map_id,
|
|
spawn_point: state.spawn_point,
|
|
remaining_ap: state.remaining_ap,
|
|
remaining_sp: sp_string
|
|
}
|
|
|
|
# Save character base data
|
|
result = Odinsea.Database.Context.update_character(state.character_id, attrs)
|
|
|
|
# Save inventories
|
|
Odinsea.Database.Context.save_character_inventory(state.character_id, state.inventories)
|
|
|
|
result
|
|
end
|
|
|
|
@doc """
|
|
Gives EXP to the character.
|
|
Handles level-up, EXP calculation, and packet broadcasting.
|
|
"""
|
|
def gain_exp(character_pid, exp_amount, is_highest_damage \\ false) when is_pid(character_pid) do
|
|
GenServer.cast(character_pid, {:gain_exp, exp_amount, is_highest_damage})
|
|
end
|
|
|
|
# ============================================================================
|
|
# Pet API
|
|
# ============================================================================
|
|
|
|
@doc """
|
|
Gets a pet by slot index (0-2 for the 3 pet slots).
|
|
"""
|
|
def get_pet(character_id, slot) do
|
|
GenServer.call(via_tuple(character_id), {:get_pet, slot})
|
|
end
|
|
|
|
@doc """
|
|
Gets all summoned pets.
|
|
"""
|
|
def get_summoned_pets(character_id) do
|
|
GenServer.call(via_tuple(character_id), :get_summoned_pets)
|
|
end
|
|
|
|
@doc """
|
|
Spawns a pet from inventory to the map.
|
|
"""
|
|
def spawn_pet(character_id, inventory_slot, lead \\ false) do
|
|
GenServer.call(via_tuple(character_id), {:spawn_pet, inventory_slot, lead})
|
|
end
|
|
|
|
@doc """
|
|
Despawns a pet from the map.
|
|
"""
|
|
def despawn_pet(character_id, slot) do
|
|
GenServer.call(via_tuple(character_id), {:despawn_pet, slot})
|
|
end
|
|
|
|
@doc """
|
|
Updates a pet's data (after feeding, command, etc.).
|
|
"""
|
|
def update_pet(character_id, pet) do
|
|
GenServer.cast(via_tuple(character_id), {:update_pet, pet})
|
|
end
|
|
|
|
@doc """
|
|
Updates a pet's position.
|
|
"""
|
|
def update_pet_position(character_id, slot, position) do
|
|
GenServer.cast(via_tuple(character_id), {:update_pet_position, slot, position})
|
|
end
|
|
|
|
def gain_exp(character_id, exp_amount, is_highest_damage) when is_integer(character_id) do
|
|
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
|
[{pid, _}] -> gain_exp(pid, exp_amount, is_highest_damage)
|
|
[] -> {:error, :character_not_found}
|
|
end
|
|
end
|
|
|
|
# ============================================================================
|
|
# GenServer Callbacks - EXP Gain
|
|
# ============================================================================
|
|
|
|
@impl true
|
|
def handle_cast({:gain_exp, exp_amount, is_highest_damage}, state) do
|
|
# Calculate EXP needed for next level
|
|
exp_needed = calculate_exp_needed(state.level)
|
|
|
|
# Add EXP
|
|
new_exp = state.exp + exp_amount
|
|
|
|
# Check for level up
|
|
{new_state, leveled_up} =
|
|
if new_exp >= exp_needed and state.level < 200 do
|
|
# Level up!
|
|
new_level = state.level + 1
|
|
|
|
# Calculate stat gains (simple formula for now)
|
|
# TODO: Use job-specific stat gain formulas
|
|
hp_gain = 10 + div(state.stats.str, 10)
|
|
mp_gain = 5 + div(state.stats.int, 10)
|
|
|
|
new_stats = %{
|
|
state.stats
|
|
| max_hp: state.stats.max_hp + hp_gain,
|
|
max_mp: state.stats.max_mp + mp_gain,
|
|
hp: state.stats.max_hp + hp_gain,
|
|
mp: state.stats.max_mp + mp_gain
|
|
}
|
|
|
|
updated_state = %{
|
|
state
|
|
| level: new_level,
|
|
exp: new_exp - exp_needed,
|
|
stats: new_stats,
|
|
remaining_ap: state.remaining_ap + 5
|
|
}
|
|
|
|
Logger.info("Character #{state.name} leveled up to #{new_level}!")
|
|
|
|
# TODO: Send level-up packet to client
|
|
# TODO: Broadcast level-up effect to map
|
|
|
|
{updated_state, true}
|
|
else
|
|
{%{state | exp: new_exp}, false}
|
|
end
|
|
|
|
# TODO: Send EXP gain packet to client
|
|
# TODO: If highest damage, send bonus message
|
|
|
|
Logger.debug(
|
|
"Character #{state.name} gained #{exp_amount} EXP (total: #{new_state.exp}, level: #{new_state.level})"
|
|
)
|
|
|
|
{:noreply, new_state}
|
|
end
|
|
|
|
# ============================================================================
|
|
# GenServer Callbacks - Pet Functions
|
|
# ============================================================================
|
|
|
|
@impl true
|
|
def handle_call({:get_pet, slot}, _from, state) do
|
|
# Find pet by slot (1, 2, or 3)
|
|
pet = Enum.find(state.pets, fn p -> p.summoned == slot end)
|
|
|
|
if pet do
|
|
{:reply, {:ok, pet}, state}
|
|
else
|
|
{:reply, {:error, :pet_not_found}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:get_summoned_pets, _from, state) do
|
|
# Return list of {slot, pet} tuples for all summoned pets
|
|
summoned = state.pets
|
|
|> Enum.filter(fn p -> p.summoned > 0 end)
|
|
|> Enum.map(fn p -> {p.summoned, p} end)
|
|
|
|
{:reply, summoned, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:spawn_pet, inventory_slot, lead}, _from, state) do
|
|
# Get pet from cash inventory
|
|
cash_inv = Map.get(state.inventories, :cash, Inventory.new(:cash))
|
|
item = Inventory.get_item(cash_inv, inventory_slot)
|
|
|
|
cond do
|
|
is_nil(item) ->
|
|
{:reply, {:error, :item_not_found}, state}
|
|
|
|
is_nil(item.pet) ->
|
|
{:reply, {:error, :not_a_pet}, state}
|
|
|
|
true ->
|
|
# Find available slot (1, 2, or 3)
|
|
used_slots = state.pets |> Enum.map(& &1.summoned) |> Enum.filter(& &1 > 0)
|
|
available_slots = [1, 2, 3] -- used_slots
|
|
|
|
if available_slots == [] do
|
|
{:reply, {:error, :no_slots_available}, state}
|
|
else
|
|
slot = if lead, do: 1, else: List.first(available_slots)
|
|
|
|
# Update pet with summoned slot and position
|
|
pet = item.pet
|
|
|> Pet.set_summoned(slot)
|
|
|> Pet.set_inventory_position(inventory_slot)
|
|
|> Pet.update_position(state.position.x, state.position.y)
|
|
|
|
# Add or update pet in state
|
|
existing_index = Enum.find_index(state.pets, fn p -> p.unique_id == pet.unique_id end)
|
|
|
|
new_pets = if existing_index do
|
|
List.replace_at(state.pets, existing_index, pet)
|
|
else
|
|
[pet | state.pets]
|
|
end
|
|
|
|
new_state = %{state | pets: new_pets, updated_at: DateTime.utc_now()}
|
|
{:reply, {:ok, pet}, new_state}
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:despawn_pet, slot}, _from, state) do
|
|
case Enum.find(state.pets, fn p -> p.summoned == slot end) do
|
|
nil ->
|
|
{:reply, {:error, :pet_not_found}, state}
|
|
|
|
pet ->
|
|
# Set summoned to 0
|
|
updated_pet = Pet.set_summoned(pet, 0)
|
|
|
|
# Update in state
|
|
pet_index = Enum.find_index(state.pets, fn p -> p.unique_id == pet.unique_id end)
|
|
new_pets = List.replace_at(state.pets, pet_index, updated_pet)
|
|
|
|
new_state = %{state | pets: new_pets, updated_at: DateTime.utc_now()}
|
|
{:reply, {:ok, updated_pet}, new_state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:update_pet, pet}, state) do
|
|
# Find and update pet
|
|
pet_index = Enum.find_index(state.pets, fn p -> p.unique_id == pet.unique_id end)
|
|
|
|
new_pets = if pet_index do
|
|
List.replace_at(state.pets, pet_index, pet)
|
|
else
|
|
[pet | state.pets]
|
|
end
|
|
|
|
{:noreply, %{state | pets: new_pets, updated_at: DateTime.utc_now()}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:update_pet_position, slot, position}, state) do
|
|
# Find pet by slot and update position
|
|
case Enum.find_index(state.pets, fn p -> p.summoned == slot end) do
|
|
nil ->
|
|
{:noreply, state}
|
|
|
|
index ->
|
|
pet = Enum.at(state.pets, index)
|
|
updated_pet = Pet.update_position(pet, position.x, position.y, position.fh, position.stance)
|
|
new_pets = List.replace_at(state.pets, index, updated_pet)
|
|
|
|
{:noreply, %{state | pets: new_pets}}
|
|
end
|
|
end
|
|
|
|
# Calculate EXP needed to reach next level
|
|
defp calculate_exp_needed(level) when level >= 200, do: 0
|
|
|
|
defp calculate_exp_needed(level) do
|
|
# Simple formula: level^3 + 100 * level
|
|
# TODO: Use actual MapleStory EXP table
|
|
level * level * level + 100 * level
|
|
end
|
|
|
|
# ============================================================================
|
|
# Scripting API Helper Functions
|
|
# ============================================================================
|
|
|
|
@doc """
|
|
Gets the character's channel ID.
|
|
"""
|
|
def get_channel(character_id) do
|
|
case get_state(character_id) do
|
|
nil -> {:error, :character_not_found}
|
|
%State{channel_id: channel_id} -> {:ok, channel_id}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Updates the character's meso.
|
|
"""
|
|
def update_meso(character_id, new_meso) do
|
|
GenServer.cast(via_tuple(character_id), {:update_meso, new_meso})
|
|
end
|
|
|
|
@doc """
|
|
Updates the character's job.
|
|
"""
|
|
def update_job(character_id, new_job) do
|
|
GenServer.cast(via_tuple(character_id), {:update_job, new_job})
|
|
end
|
|
|
|
@doc """
|
|
Updates a skill level.
|
|
"""
|
|
def update_skill(character_id, skill_id, level, master_level) do
|
|
GenServer.cast(via_tuple(character_id), {:update_skill, skill_id, level, master_level})
|
|
end
|
|
|
|
@doc """
|
|
Adds an item to inventory.
|
|
"""
|
|
def add_item(character_id, inventory_type, item) do
|
|
GenServer.call(via_tuple(character_id), {:add_item, inventory_type, item})
|
|
end
|
|
|
|
@doc """
|
|
Removes items by item ID.
|
|
"""
|
|
def remove_item_by_id(character_id, item_id, quantity) do
|
|
GenServer.call(via_tuple(character_id), {:remove_item_by_id, item_id, quantity})
|
|
end
|
|
|
|
# ============================================================================
|
|
# GenServer Callbacks - Scripting Operations
|
|
# ============================================================================
|
|
|
|
@impl true
|
|
def handle_cast({:update_meso, new_meso}, state) do
|
|
new_state = %{state | meso: new_meso, updated_at: DateTime.utc_now()}
|
|
{:noreply, new_state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:update_job, new_job}, state) do
|
|
new_state = %{state | job: new_job, updated_at: DateTime.utc_now()}
|
|
{:noreply, new_state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:update_skill, skill_id, level, master_level}, state) do
|
|
skill_entry = %{
|
|
level: level,
|
|
master_level: master_level,
|
|
expiration: -1
|
|
}
|
|
new_skills = Map.put(state.skills, skill_id, skill_entry)
|
|
new_state = %{state | skills: new_skills, updated_at: DateTime.utc_now()}
|
|
{:noreply, new_state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:add_item, inventory_type, item}, _from, state) do
|
|
inventory = Map.get(state.inventories, inventory_type, Inventory.new(inventory_type))
|
|
|
|
case Inventory.add_item(inventory, item) do
|
|
{:ok, new_inventory} ->
|
|
new_inventories = Map.put(state.inventories, inventory_type, new_inventory)
|
|
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
|
{:reply, :ok, new_state}
|
|
|
|
{:error, reason} ->
|
|
{:reply, {:error, reason}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:remove_item_by_id, item_id, quantity}, _from, state) do
|
|
inventory_type = Inventory.get_type_by_item_id(item_id)
|
|
inventory = Map.get(state.inventories, inventory_type, Inventory.new(inventory_type))
|
|
|
|
case Inventory.remove_by_id(inventory, item_id, quantity) do
|
|
{:ok, new_inventory, _removed} ->
|
|
new_inventories = Map.put(state.inventories, inventory_type, new_inventory)
|
|
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
|
|
{:reply, :ok, new_state}
|
|
|
|
{:error, reason} ->
|
|
{:reply, {:error, reason}, state}
|
|
end
|
|
end
|
|
end
|