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, # 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(), 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 # ============================================================================ # 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_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 @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, 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 end