Start repo, claude & kimi still vibing tho
This commit is contained in:
538
lib/odinsea/login/handler.ex
Normal file
538
lib/odinsea/login/handler.ex
Normal file
@@ -0,0 +1,538 @@
|
||||
defmodule Odinsea.Login.Handler do
|
||||
@moduledoc """
|
||||
Login server packet handlers.
|
||||
Ported from Java CharLoginHandler.java
|
||||
|
||||
Handles all login-related operations:
|
||||
- Authentication (login/password)
|
||||
- World/server selection
|
||||
- Character list
|
||||
- Character creation/deletion
|
||||
- Character selection (migration to channel)
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.{In, Out}
|
||||
alias Odinsea.Net.Cipher.LoginCrypto
|
||||
alias Odinsea.Login.Packets
|
||||
alias Odinsea.Constants.Server
|
||||
alias Odinsea.Database.Context
|
||||
|
||||
# ==================================================================================================
|
||||
# Permission Request (Client Hello / Version Check)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles permission request (initial client hello).
|
||||
Validates client version, locale, and patch.
|
||||
|
||||
Packet structure:
|
||||
- byte: locale
|
||||
- short: major version
|
||||
- short: minor version (patch)
|
||||
"""
|
||||
def on_permission_request(packet, state) do
|
||||
{locale, packet} = In.decode_byte(packet)
|
||||
{major, packet} = In.decode_short(packet)
|
||||
{minor, _packet} = In.decode_short(packet)
|
||||
|
||||
# Validate version
|
||||
if locale != Server.maple_locale() or
|
||||
major != Server.maple_version() or
|
||||
Integer.to_string(minor) != Server.maple_patch() do
|
||||
Logger.warning("Invalid client version: locale=#{locale}, major=#{major}, minor=#{minor}")
|
||||
{:disconnect, :invalid_version}
|
||||
else
|
||||
Logger.debug("Permission request validated")
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Password Check (Authentication)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles password check (login authentication).
|
||||
|
||||
Packet structure:
|
||||
- string: username
|
||||
- string: password (RSA encrypted if enabled)
|
||||
"""
|
||||
def on_check_password(packet, state) do
|
||||
{username, packet} = In.decode_string(packet)
|
||||
{password_encrypted, _packet} = In.decode_string(packet)
|
||||
|
||||
# Decrypt password if encryption is enabled
|
||||
password =
|
||||
if Application.get_env(:odinsea, :encrypt_passwords, false) do
|
||||
case LoginCrypto.decrypt_rsa(password_encrypted) do
|
||||
{:ok, decrypted} -> decrypted
|
||||
{:error, _} -> password_encrypted
|
||||
end
|
||||
else
|
||||
password_encrypted
|
||||
end
|
||||
|
||||
Logger.info("Login attempt: username=#{username} from #{state.ip}")
|
||||
|
||||
# Check if IP/MAC is banned
|
||||
# TODO: Implement IP/MAC ban checking
|
||||
is_banned = false
|
||||
|
||||
# Authenticate with database
|
||||
case Context.authenticate_user(username, password, state.ip) do
|
||||
{:ok, account_info} ->
|
||||
# TODO: Check if account is banned or temp banned
|
||||
# TODO: Check if already logged in (kick other session)
|
||||
|
||||
# Send success response
|
||||
response = Packets.get_auth_success(
|
||||
account_info.account_id,
|
||||
account_info.username,
|
||||
account_info.gender,
|
||||
account_info.is_gm,
|
||||
account_info.second_password
|
||||
)
|
||||
|
||||
new_state =
|
||||
state
|
||||
|> Map.put(:logged_in, true)
|
||||
|> Map.put(:account_id, account_info.account_id)
|
||||
|> Map.put(:account_name, account_info.username)
|
||||
|> Map.put(:gender, account_info.gender)
|
||||
|> Map.put(:is_gm, account_info.is_gm)
|
||||
|> Map.put(:second_password, account_info.second_password)
|
||||
|> Map.put(:login_attempts, 0)
|
||||
|
||||
send_packet(state, response)
|
||||
{:ok, new_state}
|
||||
|
||||
{:error, :invalid_credentials} ->
|
||||
# Increment login attempts
|
||||
login_attempts = Map.get(state, :login_attempts, 0) + 1
|
||||
|
||||
if login_attempts > 5 do
|
||||
Logger.warning("Too many login attempts from #{state.ip}")
|
||||
{:disconnect, :too_many_attempts}
|
||||
else
|
||||
# Send login failed (reason 4 = incorrect password)
|
||||
response = Packets.get_login_failed(4)
|
||||
send_packet(state, response)
|
||||
|
||||
new_state = Map.put(state, :login_attempts, login_attempts)
|
||||
{:ok, new_state}
|
||||
end
|
||||
|
||||
{:error, :account_not_found} ->
|
||||
# Send login failed (reason 5 = not registered ID)
|
||||
response = Packets.get_login_failed(5)
|
||||
send_packet(state, response)
|
||||
{:ok, state}
|
||||
|
||||
{:error, :already_logged_in} ->
|
||||
# Send login failed (reason 7 = already logged in)
|
||||
response = Packets.get_login_failed(7)
|
||||
send_packet(state, response)
|
||||
{:ok, state}
|
||||
|
||||
{:error, :banned} ->
|
||||
# TODO: Check temp ban vs perm ban
|
||||
response = Packets.get_perm_ban(0)
|
||||
send_packet(state, response)
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# World Info Request (Server List)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles world info request.
|
||||
Sends the list of available worlds/servers and channels.
|
||||
"""
|
||||
def on_world_info_request(state) do
|
||||
# TODO: Get actual channel load from World server
|
||||
# For now, send a stub response
|
||||
channel_load = get_channel_load()
|
||||
|
||||
# Send server list
|
||||
server_list = Packets.get_server_list(
|
||||
0, # server_id
|
||||
"Odinsea", # world_name
|
||||
0, # flag (0=normal, 1=event, 2=new, 3=hot)
|
||||
"Welcome to Odinsea!", # event_message
|
||||
channel_load
|
||||
)
|
||||
|
||||
send_packet(state, server_list)
|
||||
|
||||
# Send end of server list
|
||||
end_list = Packets.get_end_of_server_list()
|
||||
send_packet(state, end_list)
|
||||
|
||||
# Send latest connected world
|
||||
latest_world = Packets.get_latest_connected_world(0)
|
||||
send_packet(state, latest_world)
|
||||
|
||||
# Send recommended world message
|
||||
recommend = Packets.get_recommend_world_message(0, "Join now!")
|
||||
send_packet(state, recommend)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Check User Limit (Server Capacity)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles check user limit request.
|
||||
Returns server population status.
|
||||
"""
|
||||
def on_check_user_limit(state) do
|
||||
# TODO: Get actual user count from World server
|
||||
{users_online, user_limit} = get_user_count()
|
||||
|
||||
status =
|
||||
cond do
|
||||
users_online >= user_limit -> 2 # Full
|
||||
users_online * 2 >= user_limit -> 1 # Highly populated
|
||||
true -> 0 # Normal
|
||||
end
|
||||
|
||||
response = Packets.get_server_status(status)
|
||||
send_packet(state, response)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Select World (Load Character List)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles world selection.
|
||||
Validates world/channel and sends character list.
|
||||
|
||||
Packet structure:
|
||||
- byte: world_id
|
||||
- byte: channel_id (0-based, add 1 for actual channel)
|
||||
"""
|
||||
def on_select_world(packet, state) do
|
||||
if not Map.get(state, :logged_in, false) do
|
||||
Logger.warning("Select world: not logged in")
|
||||
{:disconnect, :not_logged_in}
|
||||
else
|
||||
{world_id, packet} = In.decode_byte(packet)
|
||||
{channel_id, _packet} = In.decode_byte(packet)
|
||||
|
||||
actual_channel = channel_id + 1
|
||||
|
||||
# Validate world
|
||||
if world_id != 0 do
|
||||
Logger.warning("Invalid world ID: #{world_id}")
|
||||
response = Packets.get_login_failed(10)
|
||||
send_packet(state, response)
|
||||
{:ok, state}
|
||||
else
|
||||
# TODO: Check if channel is available
|
||||
# if not World.is_channel_available(actual_channel) do
|
||||
# response = Packets.get_login_failed(10)
|
||||
# send_packet(state, response)
|
||||
# {:ok, state}
|
||||
# else
|
||||
|
||||
# TODO: Load character list from database
|
||||
characters = load_characters(state.account_id, world_id)
|
||||
|
||||
response = Packets.get_char_list(
|
||||
characters,
|
||||
state.second_password,
|
||||
3 # character slots
|
||||
)
|
||||
|
||||
send_packet(state, response)
|
||||
|
||||
new_state =
|
||||
state
|
||||
|> Map.put(:world, world_id)
|
||||
|> Map.put(:channel, actual_channel)
|
||||
|
||||
{:ok, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Check Duplicated ID (Character Name Availability)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles character name availability check.
|
||||
|
||||
Packet structure:
|
||||
- string: character_name
|
||||
"""
|
||||
def on_check_duplicated_id(packet, state) do
|
||||
if not Map.get(state, :logged_in, false) do
|
||||
{:disconnect, :not_logged_in}
|
||||
else
|
||||
{char_name, _packet} = In.decode_string(packet)
|
||||
|
||||
# TODO: Check if name is forbidden or already exists
|
||||
name_used = check_name_used(char_name, state)
|
||||
|
||||
response = Packets.get_char_name_response(char_name, name_used)
|
||||
send_packet(state, response)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Create New Character
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles character creation.
|
||||
|
||||
Packet structure:
|
||||
- string: name
|
||||
- int: job_type (0=Resistance, 1=Adventurer, 2=Cygnus, 3=Aran, 4=Evan)
|
||||
- short: dual_blade (1=DB, 0=Adventurer)
|
||||
- byte: gender (GMS only)
|
||||
- int: face
|
||||
- int: hair
|
||||
- int: hair_color
|
||||
- int: skin_color
|
||||
- int: top
|
||||
- int: bottom
|
||||
- int: shoes
|
||||
- int: weapon
|
||||
"""
|
||||
def on_create_new_character(packet, state) do
|
||||
if not Map.get(state, :logged_in, false) do
|
||||
{:disconnect, :not_logged_in}
|
||||
else
|
||||
{name, packet} = In.decode_string(packet)
|
||||
{job_type, packet} = In.decode_int(packet)
|
||||
{_dual_blade, packet} = In.decode_short(packet)
|
||||
{gender, packet} = In.decode_byte(packet)
|
||||
{face, packet} = In.decode_int(packet)
|
||||
{hair, packet} = In.decode_int(packet)
|
||||
{hair_color, packet} = In.decode_int(packet)
|
||||
{skin_color, packet} = In.decode_int(packet)
|
||||
{top, packet} = In.decode_int(packet)
|
||||
{bottom, packet} = In.decode_int(packet)
|
||||
{shoes, packet} = In.decode_int(packet)
|
||||
{weapon, _packet} = In.decode_int(packet)
|
||||
|
||||
Logger.info("Create character: name=#{name}, job_type=#{job_type}")
|
||||
|
||||
# TODO: Validate appearance items
|
||||
# TODO: Create character in database
|
||||
# TODO: Add default items and quests
|
||||
|
||||
# For now, send success stub
|
||||
response = Packets.get_add_new_char_entry(%{}, false) # TODO: Pass actual character
|
||||
send_packet(state, response)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Create Ultimate (Cygnus Knight → Ultimate Adventurer)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles ultimate adventurer creation (Cygnus Knight transformation).
|
||||
"""
|
||||
def on_create_ultimate(_packet, state) do
|
||||
# TODO: Implement ultimate creation
|
||||
# Requires level 120 Cygnus Knight in Ereve
|
||||
Logger.debug("Create ultimate request (not implemented)")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Delete Character
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles character deletion.
|
||||
|
||||
Packet structure:
|
||||
- string: second_password (if enabled)
|
||||
- int: character_id
|
||||
"""
|
||||
def on_delete_character(packet, state) do
|
||||
if not Map.get(state, :logged_in, false) do
|
||||
{:disconnect, :not_logged_in}
|
||||
else
|
||||
# TODO: Read second password if enabled
|
||||
{_spw, packet} = In.decode_string(packet)
|
||||
{character_id, _packet} = In.decode_int(packet)
|
||||
|
||||
Logger.info("Delete character: character_id=#{character_id}")
|
||||
|
||||
# TODO: Validate second password
|
||||
# TODO: Check if character belongs to account
|
||||
# TODO: Delete character from database
|
||||
|
||||
# For now, send success stub
|
||||
response = Packets.get_delete_char_response(character_id, 0)
|
||||
send_packet(state, response)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Select Character (Enter Game / Migrate to Channel)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles character selection (enter game).
|
||||
Initiates migration to the selected channel.
|
||||
|
||||
Packet structure:
|
||||
- int: character_id
|
||||
"""
|
||||
def on_select_character(packet, state) do
|
||||
if not Map.get(state, :logged_in, false) do
|
||||
{:disconnect, :not_logged_in}
|
||||
else
|
||||
{character_id, _packet} = In.decode_int(packet)
|
||||
|
||||
Logger.info("Select character: character_id=#{character_id}, channel=#{state.channel}")
|
||||
|
||||
# TODO: Validate character belongs to account
|
||||
# TODO: Load character data
|
||||
# TODO: Register migration token with channel server
|
||||
|
||||
# Send migration command to connect to channel
|
||||
# TODO: Get actual channel IP/port
|
||||
channel_ip = "127.0.0.1"
|
||||
channel_port = 8585 + (state.channel - 1)
|
||||
|
||||
response = Packets.get_server_ip(false, channel_ip, channel_port, character_id)
|
||||
send_packet(state, response)
|
||||
|
||||
new_state = Map.put(state, :character_id, character_id)
|
||||
{:ok, new_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Check Second Password
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles second password verification (SPW / PIC).
|
||||
|
||||
Packet structure:
|
||||
- string: second_password
|
||||
"""
|
||||
def on_check_spw_request(packet, state) do
|
||||
{spw, _packet} = In.decode_string(packet)
|
||||
|
||||
# TODO: Validate second password
|
||||
stored_spw = Map.get(state, :second_password)
|
||||
|
||||
if stored_spw == nil or stored_spw == spw do
|
||||
# Success - continue with operation
|
||||
{:ok, state}
|
||||
else
|
||||
# Failure - send error
|
||||
response = Packets.get_second_pw_error(15) # Incorrect SPW
|
||||
send_packet(state, response)
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# RSA Key Request
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles RSA key request.
|
||||
Sends RSA public key and login background.
|
||||
"""
|
||||
def on_rsa_key(_packet, state) do
|
||||
# TODO: Check if custom client is enabled
|
||||
# TODO: Send damage cap packet if custom client
|
||||
|
||||
# Send login background
|
||||
background = Application.get_env(:odinsea, :login_background, "MapLogin")
|
||||
bg_response = Packets.get_login_background(background)
|
||||
send_packet(state, bg_response)
|
||||
|
||||
# Send RSA public key
|
||||
pub_key = Application.get_env(:odinsea, :rsa_public_key, "")
|
||||
key_response = Packets.get_rsa_key(pub_key)
|
||||
send_packet(state, key_response)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Helper Functions
|
||||
# ==================================================================================================
|
||||
|
||||
defp send_packet(%{socket: socket} = state, packet_data) do
|
||||
# Add header (2 bytes: packet length)
|
||||
packet_length = byte_size(packet_data)
|
||||
header = <<packet_length::little-size(16)>>
|
||||
full_packet = header <> packet_data
|
||||
|
||||
case :gen_tcp.send(socket, full_packet) do
|
||||
:ok ->
|
||||
Logger.debug("Sent packet: #{packet_length} bytes")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to send packet: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp send_packet(_state, _packet_data) do
|
||||
# Socket not available in state
|
||||
Logger.error("Cannot send packet: socket not in state")
|
||||
:error
|
||||
end
|
||||
|
||||
defp authenticate_user(username, password, _state) do
|
||||
# Delegated to Context.authenticate_user/3
|
||||
Context.authenticate_user(username, password)
|
||||
end
|
||||
|
||||
defp get_channel_load do
|
||||
# TODO: Get actual channel load from World server
|
||||
# For now, return stub data
|
||||
%{
|
||||
1 => 100,
|
||||
2 => 200,
|
||||
3 => 150
|
||||
}
|
||||
end
|
||||
|
||||
defp get_user_count do
|
||||
# TODO: Get actual user count from World server
|
||||
{50, 1000}
|
||||
end
|
||||
|
||||
defp load_characters(account_id, world_id) do
|
||||
Context.load_character_entries(account_id, world_id)
|
||||
end
|
||||
|
||||
defp check_name_used(char_name, _state) do
|
||||
# Check if name is forbidden or already exists
|
||||
Context.forbidden_name?(char_name) or
|
||||
Context.character_name_exists?(char_name)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user