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.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, # 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(), 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 # ============================================================================ # 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_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 %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, 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: %{}, skills: %{}, buffs: [], pets: [], created_at: db_char.inserted_at, updated_at: DateTime.utc_now() } 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 } Odinsea.Database.Context.update_character(state.character_id, attrs) end end