Start repo, claude & kimi still vibing tho

This commit is contained in:
ra
2026-02-14 17:04:21 -07:00
commit f5b8aeb39d
54 changed files with 9466 additions and 0 deletions

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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