Start repo, claude & kimi still vibing tho
This commit is contained in:
178
lib/odinsea/channel/client.ex
Normal file
178
lib/odinsea/channel/client.ex
Normal file
@@ -0,0 +1,178 @@
|
||||
defmodule Odinsea.Channel.Client do
|
||||
@moduledoc """
|
||||
Client connection handler for game channel servers.
|
||||
Manages the game session state.
|
||||
"""
|
||||
|
||||
use GenServer, restart: :temporary
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Channel.Handler
|
||||
|
||||
defstruct [:socket, :ip, :channel_id, :state, :character_id]
|
||||
|
||||
def start_link({socket, channel_id}) do
|
||||
GenServer.start_link(__MODULE__, {socket, channel_id})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init({socket, channel_id}) do
|
||||
{:ok, {ip, _port}} = :inet.peername(socket)
|
||||
ip_string = format_ip(ip)
|
||||
|
||||
Logger.info("Channel #{channel_id} client connected from #{ip_string}")
|
||||
|
||||
state = %__MODULE__{
|
||||
socket: socket,
|
||||
ip: ip_string,
|
||||
channel_id: channel_id,
|
||||
state: :connected,
|
||||
character_id: nil
|
||||
}
|
||||
|
||||
send(self(), :receive)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:receive, %{socket: socket} = state) do
|
||||
case :gen_tcp.recv(socket, 0, 30_000) do
|
||||
{:ok, data} ->
|
||||
new_state = handle_packet(data, state)
|
||||
send(self(), :receive)
|
||||
{:noreply, new_state}
|
||||
|
||||
{:error, :closed} ->
|
||||
Logger.info("Channel #{state.channel_id} client disconnected: #{state.ip}")
|
||||
{:stop, :normal, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Channel client error: #{inspect(reason)}")
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, state) do
|
||||
if state.socket do
|
||||
:gen_tcp.close(state.socket)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_packet(data, state) do
|
||||
packet = In.new(data)
|
||||
|
||||
case In.decode_short(packet) do
|
||||
{opcode, packet} ->
|
||||
Logger.debug("Channel #{state.channel_id} packet: opcode=0x#{Integer.to_string(opcode, 16)}")
|
||||
dispatch_packet(opcode, packet, state)
|
||||
|
||||
:error ->
|
||||
Logger.warning("Failed to read packet opcode")
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch_packet(opcode, packet, state) do
|
||||
# Define opcodes for matching
|
||||
cp_general_chat = Opcodes.cp_general_chat()
|
||||
cp_party_chat = Opcodes.cp_party_chat()
|
||||
cp_whisper = Opcodes.cp_whisper()
|
||||
cp_move_player = Opcodes.cp_move_player()
|
||||
cp_change_map = Opcodes.cp_change_map()
|
||||
cp_change_keymap = Opcodes.cp_change_keymap()
|
||||
cp_skill_macro = Opcodes.cp_skill_macro()
|
||||
cp_close_range_attack = Opcodes.cp_close_range_attack()
|
||||
cp_ranged_attack = Opcodes.cp_ranged_attack()
|
||||
cp_magic_attack = Opcodes.cp_magic_attack()
|
||||
cp_take_damage = Opcodes.cp_take_damage()
|
||||
|
||||
case opcode do
|
||||
# Chat handlers
|
||||
^cp_general_chat ->
|
||||
case Handler.Chat.handle_general_chat(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_party_chat ->
|
||||
case Handler.Chat.handle_party_chat(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_whisper ->
|
||||
case Handler.Chat.handle_whisper(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
# Player movement and actions
|
||||
^cp_move_player ->
|
||||
case Handler.Player.handle_move_player(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_change_map ->
|
||||
case Handler.Player.handle_change_map(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_change_keymap ->
|
||||
case Handler.Player.handle_change_keymap(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_skill_macro ->
|
||||
case Handler.Player.handle_change_skill_macro(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
# Combat handlers (stubs for now)
|
||||
^cp_close_range_attack ->
|
||||
case Handler.Player.handle_close_range_attack(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_ranged_attack ->
|
||||
case Handler.Player.handle_ranged_attack(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_magic_attack ->
|
||||
case Handler.Player.handle_magic_attack(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_take_damage ->
|
||||
case Handler.Player.handle_take_damage(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.debug("Unhandled channel opcode: 0x#{Integer.to_string(opcode, 16)}")
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp format_ip({a, b, c, d}) do
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
|
||||
defp format_ip({a, b, c, d, e, f, g, h}) do
|
||||
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
|
||||
end
|
||||
end
|
||||
266
lib/odinsea/channel/handler/chat.ex
Normal file
266
lib/odinsea/channel/handler/chat.ex
Normal file
@@ -0,0 +1,266 @@
|
||||
defmodule Odinsea.Channel.Handler.Chat do
|
||||
@moduledoc """
|
||||
Handles chat packets (general, party, whisper, messenger).
|
||||
Ported from src/handling/channel/handler/ChatHandler.java
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Channel.Packets
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
@max_chat_length 80
|
||||
@max_staff_chat_length 512
|
||||
|
||||
@doc """
|
||||
Handles general chat (CP_USER_CHAT).
|
||||
Ported from ChatHandler.GeneralChat()
|
||||
"""
|
||||
def handle_general_chat(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid),
|
||||
{:ok, map_pid} <- get_map_pid(character.map_id, client_state.channel_id) do
|
||||
# Decode packet
|
||||
{tick, packet} = In.decode_int(packet)
|
||||
{message, packet} = In.decode_string(packet)
|
||||
{only_balloon, _packet} = In.decode_byte(packet)
|
||||
|
||||
# Validate message
|
||||
cond do
|
||||
String.length(message) == 0 ->
|
||||
{:ok, client_state}
|
||||
|
||||
String.length(message) >= @max_chat_length ->
|
||||
Logger.warning("Chat message too long from character #{character.id}")
|
||||
{:ok, client_state}
|
||||
|
||||
true ->
|
||||
# TODO: Process commands (CommandProcessor.processCommand)
|
||||
# TODO: Check if muted
|
||||
# TODO: Anti-spam checks
|
||||
|
||||
# Broadcast chat to map
|
||||
chat_packet = Packets.user_chat(character.id, message, false, only_balloon == 1)
|
||||
|
||||
Odinsea.Game.Map.broadcast(map_pid, chat_packet)
|
||||
|
||||
# Log chat
|
||||
Logger.info(
|
||||
"Chat [#{character.name}] (Map #{character.map_id}): #{message}"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("General chat failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles party chat (CP_PARTY_CHAT).
|
||||
Ported from ChatHandler.PartyChat()
|
||||
|
||||
Chat types:
|
||||
- 0: Buddy
|
||||
- 1: Party
|
||||
- 2: Guild
|
||||
- 3: Alliance
|
||||
- 4: Expedition
|
||||
"""
|
||||
def handle_party_chat(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode packet
|
||||
{chat_type, packet} = In.decode_byte(packet)
|
||||
{num_recipients, packet} = In.decode_byte(packet)
|
||||
|
||||
# Validate recipients count
|
||||
if num_recipients < 1 or num_recipients > 6 do
|
||||
{:ok, client_state}
|
||||
else
|
||||
# Read recipient IDs
|
||||
{recipients, packet} = decode_recipients(packet, num_recipients, [])
|
||||
{message, _packet} = In.decode_string(packet)
|
||||
|
||||
# Validate message
|
||||
if String.length(message) == 0 do
|
||||
{:ok, client_state}
|
||||
else
|
||||
# TODO: Process commands
|
||||
# TODO: Check if muted
|
||||
|
||||
# Route based on chat type
|
||||
route_party_chat(chat_type, character, recipients, message)
|
||||
|
||||
# Log chat
|
||||
chat_type_name = get_chat_type_name(chat_type)
|
||||
|
||||
Logger.info(
|
||||
"Chat [#{character.name}] (#{chat_type_name}): #{message}"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Party chat failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles whisper/find commands (CP_WHISPER).
|
||||
Ported from ChatHandler.WhisperFind()
|
||||
"""
|
||||
def handle_whisper(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode packet
|
||||
{mode, packet} = In.decode_byte(packet)
|
||||
{_tick, packet} = In.decode_int(packet)
|
||||
|
||||
case mode do
|
||||
# Find player (mode 5 or 68)
|
||||
mode when mode in [5, 68] ->
|
||||
{recipient, _packet} = In.decode_string(packet)
|
||||
handle_find_player(recipient, character, client_state)
|
||||
|
||||
# Whisper (mode 6)
|
||||
6 ->
|
||||
{recipient, packet} = In.decode_string(packet)
|
||||
{message, _packet} = In.decode_string(packet)
|
||||
handle_whisper_message(recipient, message, character, client_state)
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unknown whisper mode: #{mode}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Whisper failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_character(client_state) do
|
||||
case client_state.character_id do
|
||||
nil ->
|
||||
{:error, :no_character}
|
||||
|
||||
character_id ->
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :character_not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_map_pid(map_id, channel_id) do
|
||||
case Registry.lookup(Odinsea.MapRegistry, {map_id, channel_id}) do
|
||||
[{pid, _}] ->
|
||||
{:ok, pid}
|
||||
|
||||
[] ->
|
||||
# Map not loaded yet - load it
|
||||
case DynamicSupervisor.start_child(
|
||||
Odinsea.MapSupervisor,
|
||||
{Odinsea.Game.Map, {map_id, channel_id}}
|
||||
) do
|
||||
{:ok, pid} -> {:ok, pid}
|
||||
{:error, {:already_started, pid}} -> {:ok, pid}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_recipients(packet, 0, acc), do: {Enum.reverse(acc), packet}
|
||||
|
||||
defp decode_recipients(packet, count, acc) do
|
||||
{recipient_id, packet} = In.decode_int(packet)
|
||||
decode_recipients(packet, count - 1, [recipient_id | acc])
|
||||
end
|
||||
|
||||
defp route_party_chat(chat_type, character, recipients, message) do
|
||||
case chat_type do
|
||||
0 ->
|
||||
# Buddy chat
|
||||
Logger.debug("Buddy chat from #{character.name} to #{inspect(recipients)}: #{message}")
|
||||
# TODO: Implement World.Buddy.buddyChat
|
||||
|
||||
1 ->
|
||||
# Party chat
|
||||
Logger.debug("Party chat from #{character.name}: #{message}")
|
||||
# TODO: Implement World.Party.partyChat
|
||||
|
||||
2 ->
|
||||
# Guild chat
|
||||
Logger.debug("Guild chat from #{character.name}: #{message}")
|
||||
# TODO: Implement World.Guild.guildChat
|
||||
|
||||
3 ->
|
||||
# Alliance chat
|
||||
Logger.debug("Alliance chat from #{character.name}: #{message}")
|
||||
# TODO: Implement World.Alliance.allianceChat
|
||||
|
||||
4 ->
|
||||
# Expedition chat
|
||||
Logger.debug("Expedition chat from #{character.name}: #{message}")
|
||||
# TODO: Implement World.Party.expedChat
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unknown party chat type: #{chat_type}")
|
||||
end
|
||||
end
|
||||
|
||||
defp get_chat_type_name(0), do: "Buddy"
|
||||
defp get_chat_type_name(1), do: "Party"
|
||||
defp get_chat_type_name(2), do: "Guild"
|
||||
defp get_chat_type_name(3), do: "Alliance"
|
||||
defp get_chat_type_name(4), do: "Expedition"
|
||||
defp get_chat_type_name(_), do: "Unknown"
|
||||
|
||||
defp handle_find_player(recipient, character, client_state) do
|
||||
# TODO: Implement player search across channels
|
||||
# For now, just search locally
|
||||
case Odinsea.Channel.Players.find_by_name(client_state.channel_id, recipient) do
|
||||
{:ok, _target_character} ->
|
||||
# Send find reply with map
|
||||
# TODO: Send packet
|
||||
Logger.debug("Found player #{recipient} on current channel")
|
||||
{:ok, client_state}
|
||||
|
||||
{:error, :not_found} ->
|
||||
# Search other channels
|
||||
Logger.debug("Player #{recipient} not found")
|
||||
# TODO: Send "player not found" packet
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_whisper_message(recipient, message, character, client_state) do
|
||||
# TODO: Check if muted
|
||||
# TODO: Check blacklist
|
||||
|
||||
# Validate message
|
||||
if String.length(message) == 0 do
|
||||
{:ok, client_state}
|
||||
else
|
||||
# TODO: Find recipient across channels and send whisper
|
||||
Logger.info("Whisper [#{character.name} -> #{recipient}]: #{message}")
|
||||
|
||||
# For now, just log
|
||||
# TODO: Send whisper packet to recipient
|
||||
# TODO: Send whisper reply packet to sender
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
221
lib/odinsea/channel/handler/inter_server.ex
Normal file
221
lib/odinsea/channel/handler/inter_server.ex
Normal file
@@ -0,0 +1,221 @@
|
||||
defmodule Odinsea.Channel.Handler.InterServer do
|
||||
@moduledoc """
|
||||
Inter-server migration handler.
|
||||
Handles players migrating into the channel server from login or other channels.
|
||||
|
||||
Ported from Java handling.channel.handler.InterServerHandler.MigrateIn
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Database.Context
|
||||
alias Odinsea.World.Migration
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
@doc """
|
||||
Handles character migration into the channel server.
|
||||
|
||||
## Parameters
|
||||
- character_id: The character ID migrating in
|
||||
- client_state: The client connection state
|
||||
|
||||
## Returns
|
||||
- {:ok, new_state} on success
|
||||
- {:error, reason, state} on failure
|
||||
- {:disconnect, reason} on critical failure
|
||||
"""
|
||||
def migrate_in(character_id, %{socket: socket, ip: ip} = state) do
|
||||
Logger.info("Migrate in: character_id=#{character_id} from #{ip}")
|
||||
|
||||
# Check if character is already online in this channel
|
||||
if Odinsea.Channel.Players.is_online?(character_id) do
|
||||
Logger.error("Character #{character_id} already online, disconnecting")
|
||||
{:disconnect, :already_online}
|
||||
else
|
||||
# Check for pending migration token
|
||||
token = Migration.get_pending_character(character_id)
|
||||
|
||||
if token do
|
||||
# Validate the token
|
||||
case Migration.validate_migration_token(token.id, character_id, :channel) do
|
||||
{:ok, valid_token} ->
|
||||
# Use transfer data if available
|
||||
do_migrate_in(character_id, valid_token.account_id, state, valid_token.character_data)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Migration token validation failed: #{inspect(reason)}")
|
||||
# Fall back to database load
|
||||
do_migrate_in(character_id, nil, state, %{})
|
||||
end
|
||||
else
|
||||
# No token, load directly from database (direct login)
|
||||
do_migrate_in(character_id, nil, state, %{})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles channel change request from client.
|
||||
|
||||
## Parameters
|
||||
- target_channel: The target channel (1-20)
|
||||
- state: Client state
|
||||
|
||||
## Returns
|
||||
- {:ok, new_state} - Will disconnect client for migration
|
||||
"""
|
||||
def change_channel(target_channel, %{character_id: char_id, account_id: acc_id} = state) do
|
||||
Logger.info("Change channel: character=#{char_id} to channel #{target_channel}")
|
||||
|
||||
# Check server capacity
|
||||
if Migration.pending_count() >= 10 do
|
||||
Logger.warning("Server busy, rejecting channel change")
|
||||
response = Packets.server_blocked(2)
|
||||
send_packet(state, response)
|
||||
send_packet(state, Packets.enable_actions())
|
||||
{:ok, state}
|
||||
else
|
||||
# TODO: Check if player has blocked inventory, is in event, etc.
|
||||
|
||||
# Save character to database
|
||||
Context.update_character_position(char_id, state.map_id, state.spawn_point)
|
||||
|
||||
# Create migration token
|
||||
character_data = %{
|
||||
map_id: state.map_id,
|
||||
hp: state.hp,
|
||||
mp: state.mp,
|
||||
buffs: state.buffs || []
|
||||
}
|
||||
|
||||
case Migration.create_migration_token(char_id, acc_id, :channel, target_channel, character_data) do
|
||||
{:ok, token_id} ->
|
||||
# Get channel IP and port
|
||||
channel_ip = get_channel_ip(target_channel)
|
||||
channel_port = get_channel_port(target_channel)
|
||||
|
||||
# Send migration command
|
||||
response = Packets.get_channel_change(channel_ip, channel_port, char_id)
|
||||
send_packet(state, response)
|
||||
|
||||
# Update login state
|
||||
Context.update_login_state(acc_id, 3, state.ip) # CHANGE_CHANNEL
|
||||
|
||||
# Remove player from current channel storage
|
||||
Odinsea.Channel.Players.remove_player(char_id)
|
||||
|
||||
# Disconnect will happen after packet is sent
|
||||
{:disconnect, :changing_channel}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create migration token: #{inspect(reason)}")
|
||||
send_packet(state, Packets.server_blocked(2))
|
||||
send_packet(state, Packets.enable_actions())
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Private Functions
|
||||
# ==================================================================================================
|
||||
|
||||
defp do_migrate_in(character_id, account_id, state, transfer_data) do
|
||||
# Load character from database
|
||||
character = Context.load_character(character_id)
|
||||
|
||||
if is_nil(character) do
|
||||
Logger.error("Character #{character_id} not found in database")
|
||||
{:disconnect, :character_not_found}
|
||||
else
|
||||
# Verify account ownership if account_id provided
|
||||
if account_id && character.accountid != account_id do
|
||||
Logger.error("Character account mismatch: expected #{account_id}, got #{character.accountid}")
|
||||
{:disconnect, :account_mismatch}
|
||||
else
|
||||
# Check login state
|
||||
login_state = Context.get_login_state(character.accountid)
|
||||
|
||||
allow_login =
|
||||
login_state in [0, 1, 3] # NOTLOGGEDIN, SERVER_TRANSITION, or CHANGE_CHANNEL
|
||||
# TODO: Check if character is already connected on another account's session
|
||||
|
||||
if allow_login do
|
||||
complete_migration(character, state, transfer_data)
|
||||
else
|
||||
Logger.warning("Character #{character_id} already logged in elsewhere")
|
||||
{:disconnect, :already_logged_in}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp complete_migration(character, state, transfer_data) do
|
||||
# Update login state to logged in
|
||||
Context.update_login_state(character.accountid, 2, state.ip)
|
||||
|
||||
# Add to channel player storage
|
||||
:ok = Odinsea.Channel.Players.add_player(character.id, %{
|
||||
character_id: character.id,
|
||||
account_id: character.accountid,
|
||||
name: character.name,
|
||||
map_id: character.map,
|
||||
level: character.level,
|
||||
job: character.job,
|
||||
socket: state.socket
|
||||
})
|
||||
|
||||
# Restore buffs/cooldowns from transfer data or storage
|
||||
restored_buffs = transfer_data[:buffs] || []
|
||||
|
||||
# Send character info packet
|
||||
char_info = Packets.get_char_info(character, restored_buffs)
|
||||
send_packet(state, char_info)
|
||||
|
||||
# Send cash shop enable packet
|
||||
send_packet(state, Packets.enable_cash_shop())
|
||||
|
||||
# TODO: Send buddy list, guild info, etc.
|
||||
|
||||
new_state =
|
||||
state
|
||||
|> Map.put(:character_id, character.id)
|
||||
|> Map.put(:account_id, character.accountid)
|
||||
|> Map.put(:character_name, character.name)
|
||||
|> Map.put(:map_id, character.map)
|
||||
|> Map.put(:hp, character.hp)
|
||||
|> Map.put(:mp, character.mp)
|
||||
|> Map.put(:level, character.level)
|
||||
|> Map.put(:job, character.job)
|
||||
|> Map.put(:logged_in, true)
|
||||
|
||||
Logger.info("Character #{character.name} (#{character.id}) successfully migrated in")
|
||||
|
||||
{:ok, new_state}
|
||||
end
|
||||
|
||||
defp send_packet(%{socket: socket}, packet_data) do
|
||||
packet_length = byte_size(packet_data)
|
||||
header = <<packet_length::little-size(16)>>
|
||||
|
||||
case :gen_tcp.send(socket, header <> packet_data) do
|
||||
:ok -> :ok
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to send packet: #{inspect(reason)}")
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp send_packet(_, _), do: :error
|
||||
|
||||
defp get_channel_ip(channel_id) do
|
||||
# TODO: Get from configuration
|
||||
Application.get_env(:odinsea, :channel_ip, "127.0.0.1")
|
||||
end
|
||||
|
||||
defp get_channel_port(channel_id) do
|
||||
# TODO: Get from configuration
|
||||
base_port = Application.get_env(:odinsea, :channel_base_port, 8585)
|
||||
base_port + channel_id - 1
|
||||
end
|
||||
end
|
||||
447
lib/odinsea/channel/handler/npc.ex
Normal file
447
lib/odinsea/channel/handler/npc.ex
Normal file
@@ -0,0 +1,447 @@
|
||||
defmodule Odinsea.Channel.Handler.NPC do
|
||||
@moduledoc """
|
||||
Handles NPC interaction packets: talk, shop, storage, quests.
|
||||
Ported from src/handling/channel/handler/NPCHandler.java
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias Odinsea.Net.Packet.{In, Out}
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Constants.Game
|
||||
|
||||
@doc """
|
||||
Handles NPC movement/talk animations.
|
||||
Forwards NPC movement/animation packets to other players on the map.
|
||||
"""
|
||||
def handle_npc_move(%In{} = packet, client_pid) do
|
||||
with {:ok, chr_pid} <- get_character(client_pid),
|
||||
{:ok, map_pid} <- get_map(chr_pid),
|
||||
{:ok, change_time} <- get_change_time(chr_pid) do
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Anti-spam: prevent rapid NPC interactions
|
||||
if change_time > 0 and now - change_time < 7000 do
|
||||
:ok
|
||||
else
|
||||
handle_npc_move_packet(packet, client_pid, map_pid)
|
||||
end
|
||||
else
|
||||
_error -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_npc_move_packet(packet, _client_pid, _map_pid) do
|
||||
packet_length = In.remaining(packet)
|
||||
|
||||
cond do
|
||||
# NPC Talk (10 bytes for GMS, 6 for KMS)
|
||||
packet_length == 10 ->
|
||||
oid = In.decode_int(packet)
|
||||
byte1 = In.decode_byte(packet)
|
||||
unk = In.decode_byte(packet)
|
||||
|
||||
if unk == -1 do
|
||||
:ok
|
||||
else
|
||||
unk2 = In.decode_int(packet)
|
||||
# TODO: Validate NPC exists on map
|
||||
# TODO: Broadcast NPC action to other players
|
||||
Logger.debug("NPC talk: oid=#{oid}, byte1=#{byte1}, unk=#{unk}, unk2=#{unk2}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# NPC Move (more than 10 bytes)
|
||||
packet_length > 10 ->
|
||||
movement_data = In.decode_buffer(packet, packet_length - 9)
|
||||
# TODO: Broadcast NPC movement to other players
|
||||
Logger.debug("NPC move: #{byte_size(movement_data)} bytes of movement data")
|
||||
:ok
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles NPC shop actions: buy, sell, recharge.
|
||||
"""
|
||||
def handle_npc_shop(%In{} = packet, client_pid) do
|
||||
mode = In.decode_byte(packet)
|
||||
|
||||
case mode do
|
||||
# Buy item from shop
|
||||
0 ->
|
||||
In.skip(packet, 2)
|
||||
item_id = In.decode_int(packet)
|
||||
quantity = In.decode_short(packet)
|
||||
handle_shop_buy(client_pid, item_id, quantity)
|
||||
|
||||
# Sell item to shop
|
||||
1 ->
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
quantity = In.decode_short(packet)
|
||||
handle_shop_sell(client_pid, slot, item_id, quantity)
|
||||
|
||||
# Recharge item (stars/bullets)
|
||||
2 ->
|
||||
slot = In.decode_short(packet)
|
||||
handle_shop_recharge(client_pid, slot)
|
||||
|
||||
# Close shop
|
||||
_ ->
|
||||
handle_shop_close(client_pid)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_shop_buy(_client_pid, item_id, quantity) do
|
||||
# TODO: Implement shop buy
|
||||
# 1. Get character's current shop
|
||||
# 2. Validate item exists in shop
|
||||
# 3. Check mesos
|
||||
# 4. Check inventory space
|
||||
# 5. Deduct mesos and add item
|
||||
Logger.debug("Shop buy: item=#{item_id}, qty=#{quantity} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_shop_sell(_client_pid, slot, item_id, quantity) do
|
||||
# TODO: Implement shop sell
|
||||
# 1. Get character's current shop
|
||||
# 2. Validate item in inventory
|
||||
# 3. Calculate sell price
|
||||
# 4. Remove item and add mesos
|
||||
Logger.debug("Shop sell: slot=#{slot}, item=#{item_id}, qty=#{quantity} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_shop_recharge(_client_pid, slot) do
|
||||
# TODO: Implement recharge
|
||||
# 1. Get character's current shop
|
||||
# 2. Validate item is rechargeable (stars/bullets)
|
||||
# 3. Calculate recharge cost
|
||||
# 4. Recharge to full quantity
|
||||
Logger.debug("Shop recharge: slot=#{slot} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_shop_close(client_pid) do
|
||||
# TODO: Clear character's shop reference
|
||||
Logger.debug("Shop close for client #{inspect(client_pid)} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles NPC talk initiation.
|
||||
Opens NPC shop or starts NPC script dialog.
|
||||
"""
|
||||
def handle_npc_talk(%In{} = packet, client_pid) do
|
||||
with {:ok, chr_pid} <- get_character(client_pid),
|
||||
{:ok, map_pid} <- get_map(chr_pid),
|
||||
{:ok, last_select_time} <- get_last_select_npc_time(chr_pid) do
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Anti-spam: minimum 500ms between NPC interactions
|
||||
if last_select_time == 0 or now - last_select_time >= 500 do
|
||||
oid = In.decode_int(packet)
|
||||
_tick = In.decode_int(packet)
|
||||
|
||||
# TODO: Update last select NPC time
|
||||
# TODO: Get NPC from map by OID
|
||||
# TODO: Check if NPC has shop
|
||||
# TODO: If shop, open shop; else start script
|
||||
Logger.debug("NPC talk: oid=#{oid} (STUB - needs script/shop system)")
|
||||
:ok
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
_error -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles quest actions: start, complete, forfeit, restore item.
|
||||
"""
|
||||
def handle_quest_action(%In{} = packet, client_pid) do
|
||||
action = In.decode_byte(packet)
|
||||
quest_id = In.decode_ushort(packet)
|
||||
|
||||
case action do
|
||||
# Restore lost item
|
||||
0 ->
|
||||
_tick = In.decode_int(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
handle_quest_restore_item(client_pid, quest_id, item_id)
|
||||
|
||||
# Start quest
|
||||
1 ->
|
||||
npc_id = In.decode_int(packet)
|
||||
handle_quest_start(client_pid, quest_id, npc_id)
|
||||
|
||||
# Complete quest
|
||||
2 ->
|
||||
npc_id = In.decode_int(packet)
|
||||
_tick = In.decode_int(packet)
|
||||
|
||||
selection =
|
||||
if In.remaining(packet) >= 4 do
|
||||
In.decode_int(packet)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
handle_quest_complete(client_pid, quest_id, npc_id, selection)
|
||||
|
||||
# Forfeit quest
|
||||
3 ->
|
||||
handle_quest_forfeit(client_pid, quest_id)
|
||||
|
||||
# Scripted start quest
|
||||
4 ->
|
||||
npc_id = In.decode_int(packet)
|
||||
handle_quest_start_scripted(client_pid, quest_id, npc_id)
|
||||
|
||||
# Scripted end quest
|
||||
5 ->
|
||||
npc_id = In.decode_int(packet)
|
||||
handle_quest_end_scripted(client_pid, quest_id, npc_id)
|
||||
|
||||
_ ->
|
||||
Logger.warn("Unknown quest action: #{action}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_quest_restore_item(_client_pid, quest_id, item_id) do
|
||||
Logger.debug("Quest restore item: quest=#{quest_id}, item=#{item_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_quest_start(_client_pid, quest_id, npc_id) do
|
||||
# TODO: Load quest, check requirements, start quest
|
||||
Logger.debug("Quest start: quest=#{quest_id}, npc=#{npc_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_quest_complete(_client_pid, quest_id, npc_id, selection) do
|
||||
# TODO: Load quest, check completion, give rewards
|
||||
Logger.debug(
|
||||
"Quest complete: quest=#{quest_id}, npc=#{npc_id}, selection=#{inspect(selection)} (STUB)"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_quest_forfeit(_client_pid, quest_id) do
|
||||
# TODO: Check if quest can be forfeited, remove from character
|
||||
Logger.debug("Quest forfeit: quest=#{quest_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_quest_start_scripted(_client_pid, quest_id, npc_id) do
|
||||
# TODO: Start quest script via script manager
|
||||
Logger.debug("Quest start scripted: quest=#{quest_id}, npc=#{npc_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_quest_end_scripted(_client_pid, quest_id, npc_id) do
|
||||
# TODO: End quest script via script manager
|
||||
# TODO: Broadcast quest completion effect
|
||||
Logger.debug("Quest end scripted: quest=#{quest_id}, npc=#{npc_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles storage actions: take out, store, arrange, mesos.
|
||||
"""
|
||||
def handle_storage(%In{} = packet, client_pid) do
|
||||
mode = In.decode_byte(packet)
|
||||
|
||||
case mode do
|
||||
# Take out item
|
||||
4 ->
|
||||
type = In.decode_byte(packet)
|
||||
slot = In.decode_byte(packet)
|
||||
handle_storage_take_out(client_pid, type, slot)
|
||||
|
||||
# Store item
|
||||
5 ->
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
quantity = In.decode_short(packet)
|
||||
handle_storage_store(client_pid, slot, item_id, quantity)
|
||||
|
||||
# Arrange storage
|
||||
6 ->
|
||||
handle_storage_arrange(client_pid)
|
||||
|
||||
# Meso deposit/withdraw
|
||||
7 ->
|
||||
meso = In.decode_int(packet)
|
||||
handle_storage_meso(client_pid, meso)
|
||||
|
||||
# Close storage
|
||||
8 ->
|
||||
handle_storage_close(client_pid)
|
||||
|
||||
_ ->
|
||||
Logger.warn("Unknown storage mode: #{mode}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_storage_take_out(_client_pid, type, slot) do
|
||||
# TODO: Get storage, validate slot, check inventory space, move item
|
||||
Logger.debug("Storage take out: type=#{type}, slot=#{slot} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_storage_store(_client_pid, slot, item_id, quantity) do
|
||||
# TODO: Validate item, check storage space, charge fee, move item
|
||||
Logger.debug("Storage store: slot=#{slot}, item=#{item_id}, qty=#{quantity} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_storage_arrange(_client_pid) do
|
||||
# TODO: Sort storage items
|
||||
Logger.debug("Storage arrange (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_storage_meso(_client_pid, meso) do
|
||||
# TODO: Transfer mesos between character and storage
|
||||
Logger.debug("Storage meso: #{meso} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_storage_close(_client_pid) do
|
||||
# TODO: Close storage, clear reference
|
||||
Logger.debug("Storage close (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles NPC dialog continuation (script responses).
|
||||
"""
|
||||
def handle_npc_more_talk(%In{} = packet, client_pid) do
|
||||
last_msg = In.decode_byte(packet)
|
||||
action = In.decode_byte(packet)
|
||||
|
||||
cond do
|
||||
# Text input response
|
||||
last_msg == 3 ->
|
||||
if action != 0 do
|
||||
text = In.decode_string(packet)
|
||||
# TODO: Pass text to script manager
|
||||
Logger.debug("NPC more talk (text): #{text} (STUB)")
|
||||
end
|
||||
|
||||
# Selection response
|
||||
true ->
|
||||
selection =
|
||||
cond do
|
||||
In.remaining(packet) >= 4 -> In.decode_int(packet)
|
||||
In.remaining(packet) > 0 -> In.decode_byte(packet)
|
||||
true -> -1
|
||||
end
|
||||
|
||||
# TODO: Pass selection to script manager
|
||||
Logger.debug("NPC more talk (selection): #{selection}, action=#{action} (STUB)")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles equipment repair (single item).
|
||||
"""
|
||||
def handle_repair(%In{} = packet, client_pid) do
|
||||
if In.remaining(packet) < 4 do
|
||||
:ok
|
||||
else
|
||||
position = In.decode_int(packet)
|
||||
# TODO: Validate map, check durability, calculate cost, repair item
|
||||
Logger.debug("Repair: position=#{position} (STUB)")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles equipment repair (all items).
|
||||
"""
|
||||
def handle_repair_all(client_pid) do
|
||||
# TODO: Find all damaged items, calculate total cost, repair all
|
||||
Logger.debug("Repair all (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles quest info update.
|
||||
"""
|
||||
def handle_update_quest(%In{} = packet, client_pid) do
|
||||
quest_id = In.decode_short(packet)
|
||||
# TODO: Update quest progress/info
|
||||
Logger.debug("Update quest: #{quest_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles using quest items.
|
||||
"""
|
||||
def handle_use_item_quest(%In{} = packet, client_pid) do
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
quest_id = In.decode_int(packet)
|
||||
new_data = In.decode_int(packet)
|
||||
|
||||
# TODO: Validate quest item, update quest data, consume item
|
||||
Logger.debug(
|
||||
"Use item quest: slot=#{slot}, item=#{item_id}, quest=#{quest_id}, data=#{new_data} (STUB)"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles opening public NPCs (from UI, not on map).
|
||||
"""
|
||||
def handle_public_npc(%In{} = packet, client_pid) do
|
||||
npc_id = In.decode_int(packet)
|
||||
# TODO: Validate NPC in public NPC list, start script
|
||||
Logger.debug("Public NPC: #{npc_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles using scripted NPC items.
|
||||
"""
|
||||
def handle_use_scripted_npc_item(%In{} = packet, client_pid) do
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
# TODO: Validate item, run NPC script for item
|
||||
Logger.debug("Use scripted NPC item: slot=#{slot}, item=#{item_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Helper functions to get character/map info
|
||||
defp get_character(client_pid) do
|
||||
# TODO: Get character PID from client state
|
||||
{:ok, nil}
|
||||
end
|
||||
|
||||
defp get_map(_chr_pid) do
|
||||
# TODO: Get map PID from character state
|
||||
{:ok, nil}
|
||||
end
|
||||
|
||||
defp get_change_time(_chr_pid) do
|
||||
# TODO: Get last map change time from character
|
||||
{:ok, 0}
|
||||
end
|
||||
|
||||
defp get_last_select_npc_time(_chr_pid) do
|
||||
# TODO: Get last NPC select time from character
|
||||
{:ok, 0}
|
||||
end
|
||||
end
|
||||
322
lib/odinsea/channel/handler/player.ex
Normal file
322
lib/odinsea/channel/handler/player.ex
Normal file
@@ -0,0 +1,322 @@
|
||||
defmodule Odinsea.Channel.Handler.Player do
|
||||
@moduledoc """
|
||||
Handles player action packets (movement, attacks, map changes).
|
||||
Ported from src/handling/channel/handler/PlayerHandler.java
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.{In, Out}
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Channel.Packets
|
||||
alias Odinsea.Game.{Character, Movement, Map}
|
||||
|
||||
@doc """
|
||||
Handles player movement (CP_MOVE_PLAYER).
|
||||
Ported from PlayerHandler.MovePlayer()
|
||||
"""
|
||||
def handle_move_player(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid),
|
||||
{:ok, map_pid} <- get_map_pid(character.map_id, client_state.channel_id) do
|
||||
# Decode movement header
|
||||
{_dr0, packet} = In.decode_int(packet)
|
||||
{_dr1, packet} = In.decode_int(packet)
|
||||
# TODO: Check field key
|
||||
{_dr2, packet} = In.decode_int(packet)
|
||||
{_dr3, packet} = In.decode_int(packet)
|
||||
# Skip 20 bytes
|
||||
{_, packet} = In.skip(packet, 20)
|
||||
|
||||
# Store original position
|
||||
original_pos = character.position
|
||||
|
||||
# Parse movement
|
||||
case Movement.parse_movement(packet) do
|
||||
{:ok, movement_data, final_pos} ->
|
||||
# Update character position
|
||||
Character.update_position(character_pid, final_pos)
|
||||
|
||||
# Broadcast movement to other players
|
||||
move_packet =
|
||||
Out.new(Opcodes.lp_move_player())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_bytes(movement_data)
|
||||
|> Out.to_data()
|
||||
|
||||
Map.broadcast_except(
|
||||
character.map_id,
|
||||
client_state.channel_id,
|
||||
character.id,
|
||||
move_packet
|
||||
)
|
||||
|
||||
Logger.debug(
|
||||
"Player #{character.name} moved to (#{final_pos.x}, #{final_pos.y})"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Movement parsing failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Move player failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles map change via portal (CP_CHANGE_MAP).
|
||||
Ported from PlayerHandler.ChangeMap()
|
||||
"""
|
||||
def handle_change_map(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# TODO: Check field key
|
||||
|
||||
{target_id, packet} = In.decode_int(packet)
|
||||
# Skip GMS-specific field
|
||||
{_, packet} = In.decode_int(packet)
|
||||
{portal_name, packet} = In.decode_string(packet)
|
||||
|
||||
Logger.info(
|
||||
"Character #{character.name} changing map: target=#{target_id}, portal=#{portal_name}"
|
||||
)
|
||||
|
||||
# Handle different map change scenarios
|
||||
cond do
|
||||
# Death respawn
|
||||
target_id == -1 and not character.alive? ->
|
||||
# Respawn at return map
|
||||
# TODO: Implement death respawn logic
|
||||
Logger.info("Player #{character.name} respawning")
|
||||
{:ok, client_state}
|
||||
|
||||
# GM warp to specific map
|
||||
target_id != -1 and character.gm? ->
|
||||
# TODO: Implement GM warp
|
||||
Logger.info("GM #{character.name} warping to map #{target_id}")
|
||||
{:ok, client_state}
|
||||
|
||||
# Portal-based map change
|
||||
true ->
|
||||
# TODO: Load portal data and handle map transition
|
||||
# For now, just log the request
|
||||
Logger.info(
|
||||
"Portal map change: #{character.name} using portal '#{portal_name}'"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Change map failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles keymap changes (CP_CHANGE_KEYMAP).
|
||||
Ported from PlayerHandler.ChangeKeymap()
|
||||
"""
|
||||
def handle_change_keymap(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, _character} <- Character.get_state(character_pid) do
|
||||
# Skip mode
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
{num_changes, packet} = In.decode_int(packet)
|
||||
|
||||
# Parse keybinding changes
|
||||
keybindings = parse_keybindings(packet, num_changes, [])
|
||||
|
||||
# TODO: Store keybindings in character state / database
|
||||
Logger.debug("Keybindings updated: #{num_changes} changes")
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Change keymap failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles skill macro changes (CP_CHANGE_SKILL_MACRO).
|
||||
Ported from PlayerHandler.ChangeSkillMacro()
|
||||
"""
|
||||
def handle_change_skill_macro(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, _character} <- Character.get_state(character_pid) do
|
||||
{num_macros, packet} = In.decode_byte(packet)
|
||||
|
||||
# Parse macros
|
||||
macros = parse_macros(packet, num_macros, [])
|
||||
|
||||
# TODO: Store macros in character state / database
|
||||
Logger.debug("Skill macros updated: #{num_macros} macros")
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Change skill macro failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles close-range attack (CP_CLOSE_RANGE_ATTACK).
|
||||
Ported from PlayerHandler.closeRangeAttack() - STUB for now
|
||||
"""
|
||||
def handle_close_range_attack(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
Logger.debug("Close range attack from #{character.name} (stub)")
|
||||
# TODO: Implement attack logic
|
||||
# - Parse attack info
|
||||
# - Validate attack
|
||||
# - Calculate damage
|
||||
# - Apply damage to mobs
|
||||
# - Broadcast attack packet
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Close range attack failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles ranged attack (CP_RANGED_ATTACK).
|
||||
Ported from PlayerHandler.rangedAttack() - STUB for now
|
||||
"""
|
||||
def handle_ranged_attack(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
Logger.debug("Ranged attack from #{character.name} (stub)")
|
||||
# TODO: Implement ranged attack logic
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Ranged attack failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles magic attack (CP_MAGIC_ATTACK).
|
||||
Ported from PlayerHandler.MagicDamage() - STUB for now
|
||||
"""
|
||||
def handle_magic_attack(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
Logger.debug("Magic attack from #{character.name} (stub)")
|
||||
# TODO: Implement magic attack logic
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Magic attack failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles taking damage (CP_TAKE_DAMAGE).
|
||||
Ported from PlayerHandler.TakeDamage() - STUB for now
|
||||
"""
|
||||
def handle_take_damage(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode damage packet
|
||||
{_tick, packet} = In.decode_int(packet)
|
||||
{damage_type, packet} = In.decode_byte(packet)
|
||||
{element, packet} = In.decode_byte(packet)
|
||||
{damage, packet} = In.decode_int(packet)
|
||||
|
||||
Logger.debug(
|
||||
"Character #{character.name} took #{damage} damage (type=#{damage_type}, element=#{element})"
|
||||
)
|
||||
|
||||
# TODO: Apply damage to character
|
||||
# TODO: Check for death
|
||||
# TODO: Broadcast damage packet
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Take damage failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_character(client_state) do
|
||||
case client_state.character_id do
|
||||
nil ->
|
||||
{:error, :no_character}
|
||||
|
||||
character_id ->
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :character_not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_map_pid(map_id, channel_id) do
|
||||
case Registry.lookup(Odinsea.MapRegistry, {map_id, channel_id}) do
|
||||
[{pid, _}] ->
|
||||
{:ok, pid}
|
||||
|
||||
[] ->
|
||||
# Map not loaded yet - load it
|
||||
case DynamicSupervisor.start_child(
|
||||
Odinsea.MapSupervisor,
|
||||
{Map, {map_id, channel_id}}
|
||||
) do
|
||||
{:ok, pid} -> {:ok, pid}
|
||||
{:error, {:already_started, pid}} -> {:ok, pid}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_keybindings(packet, 0, acc), do: Enum.reverse(acc)
|
||||
|
||||
defp parse_keybindings(packet, count, acc) do
|
||||
{key, packet} = In.decode_int(packet)
|
||||
{key_type, packet} = In.decode_byte(packet)
|
||||
{action, packet} = In.decode_int(packet)
|
||||
|
||||
binding = %{key: key, type: key_type, action: action}
|
||||
parse_keybindings(packet, count - 1, [binding | acc])
|
||||
end
|
||||
|
||||
defp parse_macros(packet, 0, acc), do: Enum.reverse(acc)
|
||||
|
||||
defp parse_macros(packet, count, acc) do
|
||||
{name, packet} = In.decode_string(packet)
|
||||
{shout, packet} = In.decode_byte(packet)
|
||||
{skill1, packet} = In.decode_int(packet)
|
||||
{skill2, packet} = In.decode_int(packet)
|
||||
{skill3, packet} = In.decode_int(packet)
|
||||
|
||||
macro = %{
|
||||
name: name,
|
||||
shout: shout,
|
||||
skill1: skill1,
|
||||
skill2: skill2,
|
||||
skill3: skill3
|
||||
}
|
||||
|
||||
parse_macros(packet, count - 1, [macro | acc])
|
||||
end
|
||||
end
|
||||
301
lib/odinsea/channel/packets.ex
Normal file
301
lib/odinsea/channel/packets.ex
Normal file
@@ -0,0 +1,301 @@
|
||||
defmodule Odinsea.Channel.Packets do
|
||||
@moduledoc """
|
||||
Channel server packet builders.
|
||||
Ported from Java tools.packet.MaplePacketCreator (relevant parts)
|
||||
"""
|
||||
|
||||
alias Odinsea.Net.Packet.Out
|
||||
alias Odinsea.Net.Opcodes
|
||||
|
||||
@doc """
|
||||
Sends character information on login.
|
||||
"""
|
||||
def get_char_info(character, restored_buffs \\ []) do
|
||||
# TODO: Full character encoding
|
||||
# For now, send minimal info
|
||||
Out.new(Opcodes.lp_set_field())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_byte(0) # Channel
|
||||
|> Out.encode_byte(1) # Admin byte
|
||||
|> Out.encode_byte(1) # Enabled
|
||||
|> Out.encode_int(character.map)
|
||||
|> Out.encode_byte(character.spawnpoint)
|
||||
|> Out.encode_int(character.hp)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enables cash shop access.
|
||||
"""
|
||||
def enable_cash_shop do
|
||||
Out.new(Opcodes.lp_set_cash_shop_opened())
|
||||
|> Out.encode_byte(1)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Server blocked message.
|
||||
"""
|
||||
def server_blocked(reason) do
|
||||
Out.new(Opcodes.lp_server_blocked())
|
||||
|> Out.encode_byte(reason)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enable client actions.
|
||||
"""
|
||||
def enable_actions do
|
||||
Out.new(Opcodes.lp_enable_action())
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Channel change command.
|
||||
"""
|
||||
def get_channel_change(ip, port, character_id) do
|
||||
ip_parts = parse_ip(ip)
|
||||
|
||||
Out.new(Opcodes.lp_migrate_command())
|
||||
|> Out.encode_short(0) # Not cash shop
|
||||
|> encode_ip(ip_parts)
|
||||
|> Out.encode_short(port)
|
||||
|> Out.encode_int(character_id)
|
||||
|> Out.encode_bytes(<<0, 0>>)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp parse_ip(host) when is_binary(host) do
|
||||
case String.split(host, ".") do
|
||||
[a, b, c, d] ->
|
||||
{
|
||||
String.to_integer(a),
|
||||
String.to_integer(b),
|
||||
String.to_integer(c),
|
||||
String.to_integer(d)
|
||||
}
|
||||
|
||||
_ ->
|
||||
{127, 0, 0, 1}
|
||||
end
|
||||
end
|
||||
|
||||
defp encode_ip(packet, {a, b, c, d}) do
|
||||
packet
|
||||
|> Out.encode_byte(a)
|
||||
|> Out.encode_byte(b)
|
||||
|> Out.encode_byte(c)
|
||||
|> Out.encode_byte(d)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns a player on the map.
|
||||
Minimal implementation - will expand with equipment, buffs, etc.
|
||||
"""
|
||||
def spawn_player(oid, character_state) do
|
||||
# Reference: MaplePacketCreator.spawnPlayerMapobject()
|
||||
# This is a minimal implementation - full version needs equipment, buffs, etc.
|
||||
|
||||
Out.new(Opcodes.lp_spawn_player())
|
||||
|> Out.encode_int(oid)
|
||||
# Damage skin (custom client feature)
|
||||
# |> Out.encode_int(0)
|
||||
|> Out.encode_byte(character_state.level)
|
||||
|> Out.encode_string(character_state.name)
|
||||
# Ultimate Explorer name (empty for now)
|
||||
|> Out.encode_string("")
|
||||
# Guild info (no guild for now)
|
||||
|> Out.encode_int(0)
|
||||
|> Out.encode_int(0)
|
||||
# Buff mask (no buffs for now - TODO: implement buffs)
|
||||
# For now, send minimal buff data
|
||||
|> encode_buff_mask()
|
||||
# Foreign buff end
|
||||
|> Out.encode_short(0)
|
||||
# ITEM_EFFECT
|
||||
|> Out.encode_int(0)
|
||||
# CHAIR
|
||||
|> Out.encode_int(0)
|
||||
# Position
|
||||
|> Out.encode_short(character_state.position.x)
|
||||
|> Out.encode_short(character_state.position.y)
|
||||
|> Out.encode_byte(character_state.position.stance)
|
||||
# Foothold
|
||||
|> Out.encode_short(character_state.position.foothold)
|
||||
# Appearance (gender, skin, face, hair)
|
||||
|> Out.encode_byte(character_state.gender)
|
||||
|> Out.encode_byte(character_state.skin_color)
|
||||
|> Out.encode_int(character_state.face)
|
||||
# Mega - shown in rankings
|
||||
|> Out.encode_byte(0)
|
||||
# Equipment (TODO: implement proper equipment encoding)
|
||||
|> encode_appearance_minimal(character_state)
|
||||
# Driver ID / passenger ID (for mounts)
|
||||
|> Out.encode_int(0)
|
||||
# Chalkboard text
|
||||
|> Out.encode_string("")
|
||||
# Ring info (3 ring slots)
|
||||
|> Out.encode_int(0)
|
||||
|> Out.encode_int(0)
|
||||
|> Out.encode_int(0)
|
||||
# Marriage ring
|
||||
|> Out.encode_int(0)
|
||||
# Mount info (no mount for now)
|
||||
|> encode_mount_minimal()
|
||||
# Player shop (none for now)
|
||||
|> Out.encode_byte(0)
|
||||
# Admin byte
|
||||
|> Out.encode_byte(0)
|
||||
# Pet info (no pets for now)
|
||||
|> encode_pets_minimal()
|
||||
# Taming mob (none)
|
||||
|> Out.encode_int(0)
|
||||
# Mini game info
|
||||
|> Out.encode_byte(0)
|
||||
# Chalkboard
|
||||
|> Out.encode_byte(0)
|
||||
# New year cards
|
||||
|> Out.encode_byte(0)
|
||||
# Berserk
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a player from the map.
|
||||
"""
|
||||
def remove_player(oid) do
|
||||
Out.new(Opcodes.lp_remove_player_from_map())
|
||||
|> Out.encode_int(oid)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions for Spawn Encoding
|
||||
# ============================================================================
|
||||
|
||||
defp encode_buff_mask(packet) do
|
||||
# Buff mask is an array of integers representing active buffs
|
||||
# For GMS v342, this is typically 14-16 integers (56-64 bytes)
|
||||
# For now, send all zeros (no buffs)
|
||||
packet
|
||||
|> Out.encode_bytes(<<0::size(14 * 32)-little>>)
|
||||
end
|
||||
|
||||
defp encode_appearance_minimal(packet, character) do
|
||||
# Equipment encoding:
|
||||
# Map of slot -> item_id
|
||||
# For minimal implementation, just show hair
|
||||
packet
|
||||
# Equipped items map (empty for now)
|
||||
|> Out.encode_byte(0)
|
||||
# Masked items map (empty for now)
|
||||
|> Out.encode_byte(0)
|
||||
# Weapon (cash weapon)
|
||||
|> Out.encode_int(0)
|
||||
# Hair
|
||||
|> Out.encode_int(character.hair)
|
||||
# Ears (12 bit encoding for multiple items)
|
||||
|> Out.encode_int(0)
|
||||
end
|
||||
|
||||
defp encode_mount_minimal(packet) do
|
||||
packet
|
||||
|> Out.encode_byte(0)
|
||||
# Mount level
|
||||
|> Out.encode_byte(1)
|
||||
# Mount exp
|
||||
|> Out.encode_int(0)
|
||||
# Mount fatigue
|
||||
|> Out.encode_int(0)
|
||||
end
|
||||
|
||||
defp encode_pets_minimal(packet) do
|
||||
# 3 pet slots
|
||||
packet
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_byte(0)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Chat Packets
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
User chat packet.
|
||||
Ported from LocalePacket.UserChat()
|
||||
Reference: src/tools/packet/LocalePacket.java
|
||||
"""
|
||||
def user_chat(character_id, message, is_admin \\ false, only_balloon \\ false) do
|
||||
Out.new(Opcodes.lp_chattext())
|
||||
|> Out.encode_int(character_id)
|
||||
|> Out.encode_byte(if is_admin, do: 1, else: 0)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.encode_byte(if only_balloon, do: 1, else: 0)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Whisper chat packet (received whisper).
|
||||
Ported from LocalePacket.WhisperChat()
|
||||
"""
|
||||
def whisper_received(sender_name, channel, message) do
|
||||
Out.new(Opcodes.lp_whisper())
|
||||
|> Out.encode_byte(0x12)
|
||||
|> Out.encode_string(sender_name)
|
||||
|> Out.encode_short(channel - 1)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Whisper reply packet (sent whisper status).
|
||||
Mode: 1 = success, 0 = failure
|
||||
"""
|
||||
def whisper_reply(recipient_name, mode) do
|
||||
Out.new(Opcodes.lp_whisper())
|
||||
|> Out.encode_byte(0x0A)
|
||||
|> Out.encode_string(recipient_name)
|
||||
|> Out.encode_byte(mode)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find player reply (player found on channel).
|
||||
"""
|
||||
def find_player_reply(target_name, channel, is_buddy \\ false) do
|
||||
Out.new(Opcodes.lp_whisper())
|
||||
|> Out.encode_byte(0x09)
|
||||
|> Out.encode_string(target_name)
|
||||
|> Out.encode_byte(if is_buddy, do: 0x48, else: 0x01)
|
||||
|> Out.encode_byte(channel)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find player reply with map (player found on same channel).
|
||||
"""
|
||||
def find_player_with_map(target_name, map_id, is_buddy \\ false) do
|
||||
Out.new(Opcodes.lp_whisper())
|
||||
|> Out.encode_byte(0x09)
|
||||
|> Out.encode_string(target_name)
|
||||
|> Out.encode_byte(if is_buddy, do: 0x48, else: 0x01)
|
||||
|> Out.encode_int(map_id)
|
||||
|> Out.encode_bytes(<<0, 0, 0>>)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Party chat packet.
|
||||
Type: 0 = buddy, 1 = party, 2 = guild, 3 = alliance, 4 = expedition
|
||||
"""
|
||||
def multi_chat(sender_name, message, chat_type) do
|
||||
Out.new(Opcodes.lp_multi_chat())
|
||||
|> Out.encode_byte(chat_type)
|
||||
|> Out.encode_string(sender_name)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.to_data()
|
||||
end
|
||||
end
|
||||
112
lib/odinsea/channel/players.ex
Normal file
112
lib/odinsea/channel/players.ex
Normal file
@@ -0,0 +1,112 @@
|
||||
defmodule Odinsea.Channel.Players do
|
||||
@moduledoc """
|
||||
Player storage for channel server.
|
||||
Manages online player state and lookups.
|
||||
|
||||
Ported from Java handling.channel.PlayerStorage
|
||||
|
||||
Uses ETS for fast in-memory lookups.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@table :channel_players
|
||||
|
||||
@doc """
|
||||
Starts the player storage (creates ETS table).
|
||||
"""
|
||||
def start_link do
|
||||
:ets.new(@table, [
|
||||
:set,
|
||||
:public,
|
||||
:named_table,
|
||||
read_concurrency: true,
|
||||
write_concurrency: true
|
||||
])
|
||||
|
||||
{:ok, self()}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a player to the channel storage.
|
||||
"""
|
||||
def add_player(character_id, player_data) do
|
||||
:ets.insert(@table, {character_id, player_data})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a player from the channel storage.
|
||||
"""
|
||||
def remove_player(character_id) do
|
||||
:ets.delete(@table, character_id)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets player data by character ID.
|
||||
Returns nil if not found.
|
||||
"""
|
||||
def get_player(character_id) do
|
||||
case :ets.lookup(@table, character_id) do
|
||||
[{^character_id, data}] -> data
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a character is online in this channel.
|
||||
"""
|
||||
def is_online?(character_id) do
|
||||
:ets.member(@table, character_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all online players.
|
||||
"""
|
||||
def get_all_players do
|
||||
:ets.tab2list(@table)
|
||||
|> Enum.map(fn {_id, data} -> data end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets player count.
|
||||
"""
|
||||
def count do
|
||||
:ets.info(@table, :size)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a player by name.
|
||||
"""
|
||||
def get_player_by_name(name) do
|
||||
@table
|
||||
|> :ets.tab2list()
|
||||
|> Enum.find(fn {_id, data} -> data.name == name end)
|
||||
|> case do
|
||||
nil -> nil
|
||||
{_, data} -> data
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates player data.
|
||||
"""
|
||||
def update_player(character_id, updates) do
|
||||
case get_player(character_id) do
|
||||
nil -> :error
|
||||
data ->
|
||||
updated = Map.merge(data, updates)
|
||||
:ets.insert(@table, {character_id, updated})
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears all players (e.g., during shutdown).
|
||||
"""
|
||||
def clear do
|
||||
:ets.delete_all_objects(@table)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
73
lib/odinsea/channel/server.ex
Normal file
73
lib/odinsea/channel/server.ex
Normal file
@@ -0,0 +1,73 @@
|
||||
defmodule Odinsea.Channel.Server do
|
||||
@moduledoc """
|
||||
A single game channel server.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
defstruct [:channel_id, :socket, :port, :clients]
|
||||
|
||||
def start_link(channel_id) do
|
||||
GenServer.start_link(__MODULE__, channel_id, name: via_tuple(channel_id))
|
||||
end
|
||||
|
||||
def via_tuple(channel_id) do
|
||||
{:via, Registry, {Odinsea.Channel.Registry, channel_id}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(channel_id) do
|
||||
ports = Application.get_env(:odinsea, :game, [])[:channel_ports] || %{}
|
||||
port = Map.get(ports, channel_id, 8584 + channel_id)
|
||||
|
||||
case :gen_tcp.listen(port, tcp_options()) do
|
||||
{:ok, socket} ->
|
||||
Logger.info("Channel #{channel_id} listening on port #{port}")
|
||||
send(self(), :accept)
|
||||
|
||||
{:ok,
|
||||
%__MODULE__{
|
||||
channel_id: channel_id,
|
||||
socket: socket,
|
||||
port: port,
|
||||
clients: %{}
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to start channel #{channel_id} on port #{port}: #{inspect(reason)}")
|
||||
{:stop, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:accept, %{socket: socket, channel_id: channel_id} = state) do
|
||||
case :gen_tcp.accept(socket) do
|
||||
{:ok, client_socket} ->
|
||||
{:ok, _pid} =
|
||||
DynamicSupervisor.start_child(
|
||||
Odinsea.ClientSupervisor,
|
||||
{Odinsea.Channel.Client, {client_socket, channel_id}}
|
||||
)
|
||||
|
||||
send(self(), :accept)
|
||||
{:noreply, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Channel #{channel_id} accept error: #{inspect(reason)}")
|
||||
send(self(), :accept)
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp tcp_options do
|
||||
[
|
||||
:binary,
|
||||
packet: :raw,
|
||||
active: false,
|
||||
reuseaddr: true,
|
||||
backlog: 100
|
||||
]
|
||||
end
|
||||
end
|
||||
28
lib/odinsea/channel/supervisor.ex
Normal file
28
lib/odinsea/channel/supervisor.ex
Normal file
@@ -0,0 +1,28 @@
|
||||
defmodule Odinsea.Channel.Supervisor do
|
||||
@moduledoc """
|
||||
Supervisor for game channel servers.
|
||||
Each channel is a separate TCP listener.
|
||||
"""
|
||||
|
||||
use Supervisor
|
||||
|
||||
require Logger
|
||||
|
||||
def start_link(channel_count) do
|
||||
Supervisor.start_link(__MODULE__, channel_count, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(channel_count) do
|
||||
children =
|
||||
for i <- 1..channel_count do
|
||||
%{
|
||||
id: {Odinsea.Channel.Server, i},
|
||||
start: {Odinsea.Channel.Server, :start_link, [i]}
|
||||
}
|
||||
end
|
||||
|
||||
Logger.info("Starting #{channel_count} game channels")
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user