kimi gone wild

This commit is contained in:
ra
2026-02-14 23:12:33 -07:00
parent bbd205ecbe
commit 0222be36c5
98 changed files with 39726 additions and 309 deletions

View File

@@ -115,6 +115,75 @@ defmodule Odinsea.Channel.Client do
cp_public_npc = Opcodes.cp_public_npc()
cp_use_scripted_npc_item = Opcodes.cp_use_scripted_npc_item()
# Mob opcodes
cp_move_life = Opcodes.cp_move_life()
cp_auto_aggro = Opcodes.cp_auto_aggro()
cp_mob_skill_delay_end = Opcodes.cp_mob_skill_delay_end()
cp_mob_bomb = Opcodes.cp_mob_bomb()
# Summon opcodes
cp_move_summon = Opcodes.cp_move_summon()
cp_summon_attack = Opcodes.cp_summon_attack()
cp_damage_summon = Opcodes.cp_damage_summon()
cp_sub_summon = Opcodes.cp_sub_summon()
cp_remove_summon = Opcodes.cp_remove_summon()
cp_move_dragon = Opcodes.cp_move_dragon()
# Player operations
cp_note_action = Opcodes.cp_note_action()
cp_give_fame = Opcodes.cp_give_fame()
cp_use_door = Opcodes.cp_use_door()
cp_use_mech_door = Opcodes.cp_use_mech_door()
cp_transform_player = Opcodes.cp_transform_player()
cp_damage_reactor = Opcodes.cp_damage_reactor()
cp_touch_reactor = Opcodes.cp_touch_reactor()
cp_coconut = Opcodes.cp_coconut()
cp_follow_request = Opcodes.cp_follow_request()
cp_follow_reply = Opcodes.cp_follow_reply()
cp_ring_action = Opcodes.cp_ring_action()
cp_solomon = Opcodes.cp_solomon()
cp_gach_exp = Opcodes.cp_gach_exp()
cp_report = Opcodes.cp_report()
cp_enter_pvp = Opcodes.cp_enter_pvp()
cp_leave_pvp = Opcodes.cp_leave_pvp()
cp_pvp_respawn = Opcodes.cp_pvp_respawn()
cp_pvp_attack = Opcodes.cp_pvp_attack()
# UI opcodes
cp_cygnus_summon = Opcodes.cp_cygnus_summon()
cp_game_poll = Opcodes.cp_game_poll()
cp_ship_object = Opcodes.cp_ship_object()
# BBS
cp_bbs_operation = Opcodes.cp_bbs_operation()
# Duey
cp_duey_action = Opcodes.cp_duey_action()
# Monster Carnival
cp_monster_carnival = Opcodes.cp_monster_carnival()
# Alliance
cp_alliance_operation = Opcodes.cp_alliance_operation()
cp_deny_alliance_request = Opcodes.cp_deny_alliance_request()
# Item Maker / Crafting
cp_item_maker = Opcodes.cp_item_maker()
cp_use_recipe = Opcodes.cp_use_recipe()
cp_make_extractor = Opcodes.cp_make_extractor()
cp_use_bag = Opcodes.cp_use_bag()
cp_start_harvest = Opcodes.cp_start_harvest()
cp_stop_harvest = Opcodes.cp_stop_harvest()
cp_profession_info = Opcodes.cp_profession_info()
cp_craft_effect = Opcodes.cp_craft_effect()
cp_craft_make = Opcodes.cp_craft_make()
cp_craft_done = Opcodes.cp_craft_done()
cp_use_pot = Opcodes.cp_use_pot()
cp_clear_pot = Opcodes.cp_clear_pot()
cp_feed_pot = Opcodes.cp_feed_pot()
cp_cure_pot = Opcodes.cp_cure_pot()
cp_reward_pot = Opcodes.cp_reward_pot()
case opcode do
# Chat handlers
^cp_general_chat ->
@@ -277,6 +346,219 @@ defmodule Odinsea.Channel.Client do
Handler.NPC.handle_use_scripted_npc_item(packet, self())
state
# Mob handlers
^cp_move_life ->
Handler.Mob.handle_mob_move(packet, self())
state
^cp_auto_aggro ->
Handler.Mob.handle_auto_aggro(packet, self())
state
^cp_mob_skill_delay_end ->
Handler.Mob.handle_mob_skill_delay_end(packet, self())
state
^cp_mob_bomb ->
Handler.Mob.handle_mob_bomb(packet, self())
state
# Summon handlers
^cp_move_summon ->
Handler.Summon.handle_move_summon(packet, self())
state
^cp_summon_attack ->
Handler.Summon.handle_summon_attack(packet, self())
state
^cp_damage_summon ->
Handler.Summon.handle_damage_summon(packet, self())
state
^cp_sub_summon ->
Handler.Summon.handle_sub_summon(packet, self())
state
^cp_remove_summon ->
Handler.Summon.handle_remove_summon(packet, self())
state
^cp_move_dragon ->
Handler.Summon.handle_move_dragon(packet, self())
state
# Player handlers
^cp_note_action ->
Handler.Players.handle_note(packet, self())
state
^cp_give_fame ->
Handler.Players.handle_give_fame(packet, self())
state
^cp_use_door ->
Handler.Players.handle_use_door(packet, self())
state
^cp_use_mech_door ->
Handler.Players.handle_use_mech_door(packet, self())
state
^cp_transform_player ->
Handler.Players.handle_transform_player(packet, self())
state
^cp_damage_reactor ->
Handler.Players.handle_hit_reactor(packet, self())
state
^cp_touch_reactor ->
Handler.Players.handle_touch_reactor(packet, self())
state
^cp_coconut ->
Handler.Players.handle_hit_coconut(packet, self())
state
^cp_follow_request ->
Handler.Players.handle_follow_request(packet, self())
state
^cp_follow_reply ->
Handler.Players.handle_follow_reply(packet, self())
state
^cp_ring_action ->
Handler.Players.handle_ring_action(packet, self())
state
^cp_solomon ->
Handler.Players.handle_solomon(packet, self())
state
^cp_gach_exp ->
Handler.Players.handle_gach_exp(packet, self())
state
^cp_report ->
Handler.Players.handle_report(packet, self())
state
^cp_enter_pvp ->
Handler.Players.handle_enter_pvp(packet, self())
state
^cp_leave_pvp ->
Handler.Players.handle_leave_pvp(packet, self())
state
^cp_pvp_respawn ->
Handler.Players.handle_respawn_pvp(packet, self())
state
^cp_pvp_attack ->
Handler.Players.handle_attack_pvp(packet, self())
state
# UI handlers
^cp_cygnus_summon ->
Handler.UI.handle_cygnus_summon(packet, self())
state
^cp_game_poll ->
Handler.UI.handle_game_poll(packet, self())
state
^cp_ship_object ->
Handler.UI.handle_ship_object(packet, self())
state
# BBS handler
^cp_bbs_operation ->
Handler.BBS.handle_bbs_operation(packet, self())
state
# Duey handler
^cp_duey_action ->
Handler.Duey.handle_duey_operation(packet, self())
state
# Monster Carnival handler
^cp_monster_carnival ->
Handler.MonsterCarnival.handle_monster_carnival(packet, self())
state
# Alliance handlers
^cp_alliance_operation ->
Handler.Alliance.handle_alliance(packet, self())
state
^cp_deny_alliance_request ->
Handler.Alliance.handle_deny_invite(packet, self())
state
# Item Maker handlers
^cp_item_maker ->
Handler.ItemMaker.handle_item_maker(packet, self())
state
^cp_use_recipe ->
Handler.ItemMaker.handle_use_recipe(packet, self())
state
^cp_make_extractor ->
Handler.ItemMaker.handle_make_extractor(packet, self())
state
^cp_use_bag ->
Handler.ItemMaker.handle_use_bag(packet, self())
state
^cp_start_harvest ->
Handler.ItemMaker.handle_start_harvest(packet, self())
state
^cp_stop_harvest ->
Handler.ItemMaker.handle_stop_harvest(packet, self())
state
^cp_profession_info ->
Handler.ItemMaker.handle_profession_info(packet, self())
state
^cp_craft_effect ->
Handler.ItemMaker.handle_craft_effect(packet, self())
state
^cp_craft_make ->
Handler.ItemMaker.handle_craft_make(packet, self())
state
^cp_craft_done ->
Handler.ItemMaker.handle_craft_complete(packet, self())
state
^cp_use_pot ->
Handler.ItemMaker.handle_use_pot(packet, self())
state
^cp_clear_pot ->
Handler.ItemMaker.handle_clear_pot(packet, self())
state
^cp_feed_pot ->
Handler.ItemMaker.handle_feed_pot(packet, self())
state
^cp_cure_pot ->
Handler.ItemMaker.handle_cure_pot(packet, self())
state
^cp_reward_pot ->
Handler.ItemMaker.handle_reward_pot(packet, self())
state
_ ->
Logger.debug("Unhandled channel opcode: 0x#{Integer.to_string(opcode, 16)}")
state

View File

@@ -0,0 +1,282 @@
defmodule Odinsea.Channel.Handler.Alliance do
@moduledoc """
Handles Guild Alliance operations.
Ported from: src/handling/channel/handler/AllianceHandler.java
Guild alliances allow multiple guilds to:
- Share a common chat channel
- Display alliance information
- Coordinate activities
## Operations
- 1: Load alliance info
- 2: Leave alliance
- 3: Invite guild to alliance
- 4: Accept alliance invitation
- 6: Expel guild from alliance
- 7: Change alliance leader
- 8: Update alliance titles (ranks)
- 9: Change member guild rank
- 10: Update alliance notice
- 22: Deny alliance invitation
## Main Handlers
- handle_alliance/2 - All alliance operations
- handle_deny_invite/2 - Deny alliance invitation
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Game.Character
alias Odinsea.World.Guild
alias Odinsea.Channel.Packets
# ============================================================================
# Alliance Operations
# ============================================================================
@doc """
Handles all alliance operations (CP_ALLIANCE_OPERATION / 0xBA).
Reference: AllianceHandler.HandleAlliance()
"""
def handle_alliance(packet, client_pid, denied \\ false) do
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
guild_id = char_state.guild_id
# Check if in guild
if guild_id <= 0 do
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
else
# Get guild info
# guild = World.Guild.get_guild(guild_id)
op = In.decode_byte(packet)
# Handle deny separately
if op == 22 do
handle_deny_invite(client_pid, character_id, char_state, guild_id)
else
handle_alliance_op(op, packet, client_pid, character_id, char_state, guild_id)
end
end
{:error, reason} ->
Logger.warn("Failed to handle alliance: #{inspect(reason)}")
end
end
# ============================================================================
# Individual Operations
# ============================================================================
# Operation 1: Load alliance info
defp handle_alliance_op(1, _packet, client_pid, character_id, char_state, guild_id) do
alliance_id = char_state.alliance_id
if alliance_id > 0 do
# TODO: Get alliance info from World.Alliance
# packets = World.Alliance.get_alliance_info(alliance_id, false)
# Enum.each(packets, fn packet -> send(client_pid, {:send_packet, packet}) end)
Logger.debug("Alliance load: alliance #{alliance_id}, character #{character_id}")
end
:ok
end
# Operation 2: Leave alliance / Operation 6: Expel guild
defp handle_alliance_op(op, packet, client_pid, character_id, char_state, guild_id)
when op in [2, 6] do
alliance_id = char_state.alliance_id
guild_rank = char_state.guild_rank
# Get target guild ID
target_guild_id = if op == 6 and byte_size(packet.data) >= 4 do
In.decode_int(packet)
else
guild_id
end
# Permission check: alliance rank <= 2, or own guild
if guild_rank <= 2 or target_guild_id == guild_id do
# TODO: Remove guild from alliance
# World.Alliance.remove_guild_from_alliance(alliance_id, target_guild_id, target_guild_id != guild_id)
Logger.debug("Alliance remove: guild #{target_guild_id} from alliance #{alliance_id}, character #{character_id}")
end
:ok
end
# Operation 3: Invite guild to alliance
defp handle_alliance_op(3, packet, client_pid, character_id, char_state, guild_id) do
alliance_id = char_state.alliance_id
alliance_rank = char_state.alliance_rank
# Get guild leader name to invite
target_leader_name = In.decode_string(packet)
# Only alliance leader (rank 1) can invite
if alliance_rank == 1 do
# TODO: Get target guild leader ID
# target_leader_id = World.Guild.get_guild_leader(target_leader_name)
# TODO: Get target character
# target_char = ChannelServer.get_player_storage().get_character_by_id(target_leader_id)
# TODO: Check if can invite
# if World.Alliance.can_invite(alliance_id) do
# # Send invite
# target_char.client.send_packet(Packets.alliance_invite(alliance_name, character_id))
# World.Guild.set_invited_id(target_char.guild_id, alliance_id)
# end
Logger.debug("Alliance invite: to leader #{target_leader_name}, alliance #{alliance_id}, character #{character_id}")
end
:ok
end
# Operation 4: Accept alliance invitation
defp handle_alliance_op(4, _packet, client_pid, character_id, char_state, guild_id) do
# Get invited alliance ID
# invited_alliance_id = World.Guild.get_invited_id(guild_id)
# if invited_alliance_id > 0 do
# # Add guild to alliance
# success = World.Alliance.add_guild_to_alliance(invited_alliance_id, guild_id)
# if not success do
# # Send error message
# end
#
# # Clear invited ID
# World.Guild.set_invited_id(guild_id, 0)
# end
Logger.debug("Alliance accept: guild #{guild_id}, character #{character_id}")
:ok
end
# Operation 7: Change alliance leader
defp handle_alliance_op(7, packet, client_pid, character_id, char_state, guild_id) do
alliance_id = char_state.alliance_id
alliance_rank = char_state.alliance_rank
# Only alliance leader can change leader
if alliance_rank == 1 do
new_leader_id = In.decode_int(packet)
# TODO: Change alliance leader
# World.Alliance.change_alliance_leader(alliance_id, new_leader_id)
Logger.debug("Alliance leader change: to #{new_leader_id}, alliance #{alliance_id}, character #{character_id}")
end
:ok
end
# Operation 8: Update alliance titles/ranks
defp handle_alliance_op(8, packet, client_pid, character_id, char_state, guild_id) do
alliance_id = char_state.alliance_id
alliance_rank = char_state.alliance_rank
# Only alliance leader can update titles
if alliance_rank == 1 do
# Read 5 rank titles
ranks = Enum.map(1..5, fn _ -> In.decode_string(packet) end)
# TODO: Update alliance ranks
# World.Alliance.update_alliance_ranks(alliance_id, ranks)
Logger.debug("Alliance ranks update: alliance #{alliance_id}, character #{character_id}")
end
:ok
end
# Operation 9: Change member guild rank
defp handle_alliance_op(9, packet, client_pid, character_id, char_state, guild_id) do
alliance_id = char_state.alliance_id
alliance_rank = char_state.alliance_rank
# Alliance rank <= 2 can change ranks
if alliance_rank <= 2 do
target_guild_id = In.decode_int(packet)
new_rank = In.decode_byte(packet)
# TODO: Change guild rank in alliance
# World.Alliance.change_alliance_rank(alliance_id, target_guild_id, new_rank)
Logger.debug("Alliance rank change: guild #{target_guild_id} to rank #{new_rank}, alliance #{alliance_id}, character #{character_id}")
end
:ok
end
# Operation 10: Update alliance notice
defp handle_alliance_op(10, packet, client_pid, character_id, char_state, guild_id) do
alliance_id = char_state.alliance_id
alliance_rank = char_state.alliance_rank
# Alliance rank <= 2 can update notice
if alliance_rank <= 2 do
notice = In.decode_string(packet)
# Check notice length (max 100)
if String.length(notice) <= 100 do
# TODO: Update alliance notice
# World.Alliance.update_alliance_notice(alliance_id, notice)
Logger.debug("Alliance notice update: alliance #{alliance_id}, character #{character_id}")
end
end
:ok
end
# Unknown operation
defp handle_alliance_op(op, _packet, _client_pid, character_id, _char_state, guild_id) do
Logger.warning("Unknown alliance operation #{op} from character #{character_id}, guild #{guild_id}")
:ok
end
# ============================================================================
# Deny Invite Handler
# ============================================================================
@doc """
Handles deny alliance invitation (CP_DENY_ALLIANCE_REQUEST / 0xBB).
Also called when op == 22 in alliance operation.
Reference: AllianceHandler.DenyInvite()
"""
def handle_deny_invite(client_pid, character_id, char_state, guild_id) do
# Get invited alliance ID
# invited_alliance_id = World.Guild.get_invited_id(guild_id)
# if invited_alliance_id > 0 do
# # Get alliance leader
# leader_id = World.Alliance.get_alliance_leader(invited_alliance_id)
#
# if leader_id > 0 do
# # Notify leader of rejection
# leader = ChannelServer.get_player_storage().get_character_by_id(leader_id)
# if leader do
# leader.drop_message(5, "#{guild.name} Guild has rejected the Guild Union invitation.")
# end
# end
#
# # Clear invited ID
# World.Guild.set_invited_id(guild_id, 0)
# end
Logger.debug("Alliance invite denied: guild #{guild_id}, character #{character_id}")
:ok
end
end

View File

@@ -0,0 +1,254 @@
defmodule Odinsea.Channel.Handler.BBS do
@moduledoc """
Handles Guild BBS (Bulletin Board System) operations.
Ported from: src/handling/channel/handler/BBSHandler.java
The Guild BBS allows guild members to:
- Create and edit threads/posts
- Reply to threads
- Delete threads and replies (with permission checks)
- List threads with pagination
- View individual threads with replies
## Main Handlers
- handle_bbs_operation/2 - All BBS operations (CRUD)
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Game.Character
alias Odinsea.World.Guild
alias Odinsea.Channel.Packets
# ============================================================================
# BBS Operations
# ============================================================================
@doc """
Handles all BBS operations (CP_BBS_OPERATION / 0xCD).
Actions:
- 0: Create new post / Edit existing post
- 1: Delete a thread
- 2: List threads (pagination)
- 3: Display thread with replies
- 4: Add reply to thread
- 5: Delete reply from thread
Reference: BBSHandler.BBSOperation()
"""
def handle_bbs_operation(packet, client_pid) do
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
guild_id = char_state.guild_id
if guild_id <= 0 do
Logger.debug("BBS operation rejected: character #{character_id} not in guild")
:ok
else
action = In.decode_byte(packet)
handle_bbs_action(action, packet, client_pid, character_id, char_state, guild_id)
end
{:error, reason} ->
Logger.warn("Failed to handle BBS operation: #{inspect(reason)}")
end
end
# ============================================================================
# Individual Actions
# ============================================================================
# Action 0: Create or edit post
defp handle_bbs_action(0, packet, client_pid, character_id, char_state, guild_id) do
is_edit = In.decode_byte(packet) > 0
local_thread_id = if is_edit do
In.decode_int(packet)
else
0
end
is_notice = In.decode_byte(packet) > 0
title = In.decode_string(packet) |> correct_length(25)
text = In.decode_string(packet) |> correct_length(600)
icon = In.decode_int(packet)
# Validate icon
valid_icon = validate_icon(icon, character_id)
if valid_icon do
if is_edit do
# Edit existing thread
edit_thread(guild_id, local_thread_id, title, text, icon, character_id, char_state.guild_rank)
else
# Create new thread
create_thread(guild_id, title, text, icon, is_notice, character_id)
end
# Send updated thread list
list_threads(client_pid, guild_id, 0)
end
Logger.debug("BBS create/edit: guild #{guild_id}, edit=#{is_edit}, notice=#{is_notice}, icon=#{icon}, character #{character_id}")
:ok
end
# Action 1: Delete thread
defp handle_bbs_action(1, packet, client_pid, character_id, char_state, guild_id) do
local_thread_id = In.decode_int(packet)
delete_thread(guild_id, local_thread_id, character_id, char_state.guild_rank)
Logger.debug("BBS delete thread: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
:ok
end
# Action 2: List threads (pagination)
defp handle_bbs_action(2, packet, client_pid, character_id, _char_state, guild_id) do
start = In.decode_int(packet)
list_threads(client_pid, guild_id, start * 10)
Logger.debug("BBS list threads: guild #{guild_id}, start #{start}, character #{character_id}")
:ok
end
# Action 3: Display thread
defp handle_bbs_action(3, packet, client_pid, character_id, _char_state, guild_id) do
local_thread_id = In.decode_int(packet)
display_thread(client_pid, guild_id, local_thread_id)
Logger.debug("BBS display thread: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
:ok
end
# Action 4: Add reply
defp handle_bbs_action(4, packet, client_pid, character_id, _char_state, guild_id) do
# Check rate limit (60 seconds between replies)
# TODO: Implement rate limiting via CheatTracker
local_thread_id = In.decode_int(packet)
text = In.decode_string(packet) |> correct_length(25)
add_reply(guild_id, local_thread_id, text, character_id)
# Refresh thread display
display_thread(client_pid, guild_id, local_thread_id)
Logger.debug("BBS add reply: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
:ok
end
# Action 5: Delete reply
defp handle_bbs_action(5, packet, client_pid, character_id, char_state, guild_id) do
local_thread_id = In.decode_int(packet)
reply_id = In.decode_int(packet)
delete_reply(guild_id, local_thread_id, reply_id, character_id, char_state.guild_rank)
# Refresh thread display
display_thread(client_pid, guild_id, local_thread_id)
Logger.debug("BBS delete reply: guild #{guild_id}, thread #{local_thread_id}, reply #{reply_id}, character #{character_id}")
:ok
end
# Unknown action
defp handle_bbs_action(action, _packet, _client_pid, character_id, _char_state, guild_id) do
Logger.warning("Unknown BBS action #{action} from character #{character_id}, guild #{guild_id}")
:ok
end
# ============================================================================
# BBS Backend Operations
# ============================================================================
defp create_thread(guild_id, title, text, icon, is_notice, character_id) do
# TODO: Call World.Guild.addBBSThread
# Returns: local_thread_id
Logger.debug("Create BBS thread: guild #{guild_id}, title '#{title}', character #{character_id}")
:ok
end
defp edit_thread(guild_id, local_thread_id, title, text, icon, character_id, guild_rank) do
# TODO: Call World.Guild.editBBSThread
# Permission: thread owner OR guild rank <= 2
Logger.debug("Edit BBS thread: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
:ok
end
defp delete_thread(guild_id, local_thread_id, character_id, guild_rank) do
# TODO: Call World.Guild.deleteBBSThread
# Permission: thread owner OR guild rank <= 2 (masters/jr masters)
Logger.debug("Delete BBS thread: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
:ok
end
defp list_threads(client_pid, guild_id, start) do
# TODO: Get threads from World.Guild.getBBS
# TODO: Build thread list packet
# packet = Packets.bbs_thread_list(threads, start)
# send(client_pid, {:send_packet, packet})
:ok
end
defp display_thread(client_pid, guild_id, local_thread_id) do
# TODO: Get thread from World.Guild.getBBS
# TODO: Find thread by local_thread_id
# TODO: Build show thread packet
# packet = Packets.show_thread(thread)
# send(client_pid, {:send_packet, packet})
:ok
end
defp add_reply(guild_id, local_thread_id, text, character_id) do
# TODO: Call World.Guild.addBBSReply
Logger.debug("Add BBS reply: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
:ok
end
defp delete_reply(guild_id, local_thread_id, reply_id, character_id, guild_rank) do
# TODO: Call World.Guild.deleteBBSReply
# Permission: reply owner OR guild rank <= 2
Logger.debug("Delete BBS reply: guild #{guild_id}, thread #{local_thread_id}, reply #{reply_id}, character #{character_id}")
:ok
end
# ============================================================================
# Helper Functions
# ============================================================================
# Truncates string to max length if needed
defp correct_length(string, max_size) when is_binary(string) do
if String.length(string) > max_size do
String.slice(string, 0, max_size)
else
string
end
end
# Validates icon selection
# Icons 0x64-0x6A (100-106) require NX items (5290000-5290006)
defp validate_icon(icon, character_id) do
cond do
# NX icons - require specific items
icon >= 0x64 and icon <= 0x6A ->
# TODO: Check if player has item 5290000 + (icon - 0x64)
# For now, allow all
true
# Standard icons (0-2)
icon >= 0 and icon <= 2 ->
true
# Invalid icon
true ->
Logger.warning("Invalid BBS icon #{icon} from character #{character_id}")
false
end
end
end

View File

@@ -0,0 +1,418 @@
defmodule Odinsea.Channel.Handler.Buddy do
@moduledoc """
Handles buddy list operations.
Ported from src/handling/channel/handler/BuddyListHandler.java
Manages buddy list add, remove, and accept operations.
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Channel.Packets
alias Odinsea.Game.Character
alias Odinsea.Database.Context
@max_buddy_list 100
@default_capacity 20
@doc """
Handles buddy list operations (CP_BUDDYLIST_MODIFY).
Ported from BuddyListHandler.BuddyOperation()
Mode:
- 1: Add buddy
- 2: Accept buddy
- 3: Delete buddy
"""
def handle_buddy_operation(packet, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
{mode, packet} = In.decode_byte(packet)
case mode do
1 -> handle_add_buddy(packet, character, client_state)
2 -> handle_accept_buddy(packet, character, client_state)
3 -> handle_delete_buddy(packet, character, client_state)
_ ->
Logger.warning("Unknown buddy operation mode: #{mode}")
{:ok, client_state}
end
else
{:error, reason} ->
Logger.warning("Buddy operation failed: #{inspect(reason)}")
{:ok, client_state}
end
end
# ============================================================================
# Add Buddy Handler
# ============================================================================
defp handle_add_buddy(packet, character, client_state) do
{add_name, packet} = In.decode_string(packet)
{group_name, _packet} = In.decode_string(packet)
# Validate inputs
if String.length(add_name) > 13 || String.length(group_name) > 16 do
{:ok, client_state}
else
# Check if already in buddy list
existing = find_buddy(character.buddies, add_name)
cond do
existing && existing.group == group_name ->
# Already in list with same group
send_buddy_message(client_state, 11)
existing && !existing.pending ->
# Update group
updated_buddies = update_buddy_group(character.buddies, add_name, group_name)
Character.update_buddies(character.id, updated_buddies)
# Send updated buddy list
buddy_list_packet = Packets.update_buddylist(updated_buddies, 10)
send_packet(client_state, buddy_list_packet)
length(character.buddies) >= @max_buddy_list ->
# Buddy list full
send_buddy_message(client_state, 11)
true ->
# Try to find and add buddy
add_buddy_to_list(add_name, group_name, character, client_state)
end
{:ok, client_state}
end
end
defp add_buddy_to_list(add_name, group_name, character, client_state) do
# Try to find character on current channel
case Odinsea.Channel.Players.find_by_name(client_state.channel_id, add_name) do
{:ok, target_character} ->
# Check if can add (not GM hiding, not blacklisted)
if can_add_buddy?(character, target_character) do
# Check target's buddy list capacity
if length(target_character.buddies) >= @default_capacity do
send_buddy_message(client_state, 12)
else
# Send buddy request to target
send_buddy_request(target_character, character)
# Add pending buddy to our list
buddy = create_buddy_entry(target_character, group_name, -1, true)
updated_buddies = character.buddies ++ [buddy]
Character.update_buddies(character.id, updated_buddies)
# Send updated buddy list
buddy_list_packet = Packets.update_buddylist(updated_buddies, 10)
send_packet(client_state, buddy_list_packet)
end
else
send_buddy_message(client_state, 15)
end
{:error, :not_found} ->
# Try to find in database
case Context.get_character_by_name(add_name) do
nil ->
send_buddy_message(client_state, 15)
target_db ->
# Check if target can accept buddy
if target_db.gm_level < 3 do
# Check buddy capacity in database
case get_buddy_count_from_db(target_db.id) do
{:ok, count} when count >= @default_capacity ->
send_buddy_message(client_state, 12)
_ ->
# Add pending to database
insert_pending_buddy(target_db.id, character.id, group_name)
# Add pending buddy to our list
buddy = create_buddy_entry_from_db(target_db, group_name, true)
updated_buddies = character.buddies ++ [buddy]
Character.update_buddies(character.id, updated_buddies)
# Send updated buddy list
buddy_list_packet = Packets.update_buddylist(updated_buddies, 10)
send_packet(client_state, buddy_list_packet)
end
else
send_buddy_message(client_state, 15)
end
end
end
end
# ============================================================================
# Accept Buddy Handler
# ============================================================================
defp handle_accept_buddy(packet, character, client_state) do
{other_cid, _packet} = In.decode_int(packet)
# Find pending buddy
buddy = Enum.find(character.buddies, fn b ->
b.character_id == other_cid && b.pending
end)
if buddy && length(character.buddies) < @max_buddy_list do
# Accept the buddy
updated_buddy = %{buddy | pending: false, visible: true, group: "ETC"}
# Update buddy in list
updated_buddies = Enum.map(character.buddies, fn b ->
if b.character_id == other_cid do
updated_buddy
else
b
end
end)
Character.update_buddies(character.id, updated_buddies)
# Try to find channel
channel = find_buddy_channel(other_cid)
# Send updated buddy list
buddy_list_packet = Packets.update_buddylist(updated_buddies, 10)
send_packet(client_state, buddy_list_packet)
# Notify other player if online
if channel > 0 do
notify_buddy_added(other_cid, character, "ETC")
end
# Update in database
accept_buddy_in_db(character.id, other_cid)
else
send_buddy_message(client_state, 11)
end
{:ok, client_state}
end
# ============================================================================
# Delete Buddy Handler
# ============================================================================
defp handle_delete_buddy(packet, character, client_state) do
{other_cid, _packet} = In.decode_int(packet)
# Find buddy
buddy = Enum.find(character.buddies, fn b -> b.character_id == other_cid end)
if buddy do
# Notify other player if online and visible
if buddy.visible do
channel = find_buddy_channel(other_cid)
if channel > 0 do
notify_buddy_removed(other_cid, character.id)
end
end
# Remove from our list
updated_buddies = Enum.reject(character.buddies, fn b ->
b.character_id == other_cid
end)
Character.update_buddies(character.id, updated_buddies)
# Send updated buddy list
buddy_list_packet = Packets.update_buddylist(updated_buddies, 18)
send_packet(client_state, buddy_list_packet)
# Remove from database
remove_buddy_from_db(character.id, other_cid)
end
{:ok, client_state}
end
# ============================================================================
# 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 find_buddy(buddies, name) do
name_lower = String.downcase(name)
Enum.find(buddies, fn b ->
String.downcase(b.name) == name_lower
end)
end
defp update_buddy_group(buddies, name, group_name) do
Enum.map(buddies, fn b ->
if String.downcase(b.name) == String.downcase(name) do
%{b | group: group_name}
else
b
end
end)
end
defp can_add_buddy?(character, target) do
# Check if target is GM hiding
if target.gm? && !character.gm? do
false
else
# Check blacklist
target_character =
case Registry.lookup(Odinsea.CharacterRegistry, target.id) do
[{pid, _}] ->
case Character.get_state(pid) do
{:ok, state} -> state
_ -> nil
end
[] -> nil
end
if target_character do
not Enum.member?(target_character.blacklist, String.downcase(character.name))
else
true
end
end
end
defp create_buddy_entry(character, group, channel, pending) do
%{
character_id: character.id,
name: character.name,
group: group,
channel: channel,
visible: !pending,
pending: pending,
level: character.level,
job: character.job
}
end
defp create_buddy_entry_from_db(character, group, pending) do
%{
character_id: character.id,
name: character.name,
group: group,
channel: -1,
visible: !pending,
pending: pending,
level: character.level,
job: character.job
}
end
defp send_buddy_request(target_character, from_character) do
case Registry.lookup(Odinsea.CharacterRegistry, target_character.id) do
[{pid, _}] ->
request_packet = Packets.request_buddylist_add(
from_character.id,
from_character.name,
from_character.level,
from_character.job
)
send(pid, {:send_packet, request_packet})
[] -> :ok
end
end
defp notify_buddy_added(target_id, from_character, group) do
case Registry.lookup(Odinsea.CharacterRegistry, target_id) do
[{pid, _}] ->
# Add buddy entry for target
buddy_entry = create_buddy_entry(from_character, group, 1, false)
# Update target's buddies
case Character.get_state(pid) do
{:ok, target_state} ->
updated_buddies = target_state.buddies ++ [buddy_entry]
Character.update_buddies(target_id, updated_buddies)
# Send update packet
buddy_list_packet = Packets.update_buddylist(updated_buddies, 10)
send(pid, {:send_packet, buddy_list_packet})
_ -> :ok
end
[] -> :ok
end
end
defp notify_buddy_removed(target_id, remover_id) do
case Registry.lookup(Odinsea.CharacterRegistry, target_id) do
[{pid, _}] ->
case Character.get_state(pid) do
{:ok, target_state} ->
updated_buddies = Enum.reject(target_state.buddies, fn b ->
b.character_id == remover_id
end)
Character.update_buddies(target_id, updated_buddies)
buddy_list_packet = Packets.update_buddylist(updated_buddies, 18)
send(pid, {:send_packet, buddy_list_packet})
_ -> :ok
end
[] -> :ok
end
end
defp find_buddy_channel(character_id) do
# Try to find character on any channel
# For now, just check current channel's registry
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
[{_pid, _}] -> 1 # Found, return channel
[] -> -1 # Not found
end
end
defp send_buddy_message(client_state, code) do
packet = Packets.buddylist_message(code)
send_packet(client_state, packet)
end
defp send_packet(client_state, packet) do
if client_state.socket do
:gen_tcp.send(client_state.socket, packet)
end
end
# ============================================================================
# Database Functions (Stubs)
# ============================================================================
defp get_buddy_count_from_db(character_id) do
# TODO: Query buddies table for count
{:ok, 0}
end
defp insert_pending_buddy(target_id, character_id, group_name) do
# TODO: Insert pending buddy into database
Logger.debug("Insert pending buddy: target=#{target_id}, from=#{character_id}, group=#{group_name}")
:ok
end
defp accept_buddy_in_db(character_id, other_id) do
# TODO: Update buddy status in database
Logger.debug("Accept buddy in DB: #{character_id} <-> #{other_id}")
:ok
end
defp remove_buddy_from_db(character_id, other_id) do
# TODO: Remove buddy from database
Logger.debug("Remove buddy from DB: #{character_id} X #{other_id}")
:ok
end
end

View File

@@ -9,6 +9,7 @@ defmodule Odinsea.Channel.Handler.Chat do
alias Odinsea.Net.Packet.In
alias Odinsea.Channel.Packets
alias Odinsea.Game.Character
alias Odinsea.Admin.Handler, as: AdminHandler
@max_chat_length 80
@max_staff_chat_length 512
@@ -36,21 +37,25 @@ defmodule Odinsea.Channel.Handler.Chat do
{:ok, client_state}
true ->
# TODO: Process commands (CommandProcessor.processCommand)
# TODO: Check if muted
# TODO: Anti-spam checks
# Check if this is an admin command
if AdminHandler.admin_command?(message) do
handle_admin_command(message, client_state)
else
# TODO: Check if muted
# TODO: Anti-spam checks
# Broadcast chat to map
chat_packet = Packets.user_chat(character.id, message, false, only_balloon == 1)
# Broadcast chat to map
chat_packet = Packets.user_chat(character.id, message, false, only_balloon == 1)
Odinsea.Game.Map.broadcast(map_pid, chat_packet)
Odinsea.Game.Map.broadcast(map_pid, chat_packet)
# Log chat
Logger.info(
"Chat [#{character.name}] (Map #{character.map_id}): #{message}"
)
# Log chat
Logger.info(
"Chat [#{character.name}] (Map #{character.map_id}): #{message}"
)
{:ok, client_state}
{:ok, client_state}
end
end
else
{:error, reason} ->
@@ -263,4 +268,28 @@ defmodule Odinsea.Channel.Handler.Chat do
{:ok, client_state}
end
end
# ============================================================================
# Admin Command Handling
# ============================================================================
defp handle_admin_command(message, client_state) do
command_name = AdminHandler.extract_command_name(message)
Logger.info("Admin command detected: #{command_name} from character #{client_state.character_id}")
case AdminHandler.handle_command(message, client_state) do
{:ok, result} ->
Logger.info("Admin command succeeded: #{command_name} - #{result}")
{:ok, client_state}
{:error, reason} ->
Logger.warning("Admin command failed: #{command_name} - #{reason}")
{:ok, client_state}
:not_command ->
# Shouldn't happen since we checked, but handle gracefully
{:ok, client_state}
end
end
end

View File

@@ -0,0 +1,224 @@
defmodule Odinsea.Channel.Handler.Duey do
@moduledoc """
Handles Duey (parcel delivery) system operations.
Ported from: src/handling/channel/handler/DueyHandler.java
Duey allows players to:
- Send items and mesos to other players
- Receive packages from other players
- Remove/delete packages
## Status Codes
- 19 = Successful
- 18 = One-of-a-kind item already in receiver's delivery
- 17 = Character unable to receive parcel
- 15 = Same account
- 14 = Name does not exist
- 16 = Not enough space
- 12 = Not enough mesos
## Main Handlers
- handle_duey_operation/2 - All Duey operations (send, receive, remove)
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Game.Character
alias Odinsea.Channel.Packets
# ============================================================================
# Duey Operations
# ============================================================================
@doc """
Handles all Duey operations (CP_DUEY_ACTION / 0x48).
Operations:
- 1: Start Duey (load packages)
- 3: Send item/mesos
- 5: Receive package
- 6: Remove package
- 8: Close Duey
Note: The original Java handler is mostly commented out.
This is a stub implementation for future development.
Reference: DueyHandler.DueyOperation()
"""
def handle_duey_operation(packet, client_pid) do
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# Check conversation state (should be 2 for Duey)
# For now, allow without strict check since this is a stub
operation = In.decode_byte(packet)
handle_duey_op(operation, packet, client_pid, character_id, char_state)
{:error, reason} ->
Logger.warn("Failed to handle Duey operation: #{inspect(reason)}")
end
end
# ============================================================================
# Individual Operations
# ============================================================================
# Operation 1: Start Duey - Load packages
defp handle_duey_op(1, packet, client_pid, character_id, _char_state) do
# AS13Digit = packet.decodeString() # 13 digit AS code (unused)
# TODO: Load packages from database
# packages = load_items(character_id)
# TODO: Send package list to client
# packet = Packets.send_duey(10, packages)
# send(client_pid, {:send_packet, packet})
Logger.debug("Duey start: character #{character_id}")
:ok
end
# Operation 3: Send item/mesos
defp handle_duey_op(3, packet, client_pid, character_id, char_state) do
inventory_id = In.decode_byte(packet)
item_pos = In.decode_short(packet)
amount = In.decode_short(packet)
mesos = In.decode_int(packet)
recipient = In.decode_string(packet)
quick_delivery = In.decode_byte(packet) > 0
# Calculate cost
# tax = GameConstants.getTaxAmount(mesos)
# final_cost = mesos + tax + (if quick_delivery, do: 0, else: 5000)
# TODO: Validate recipient exists
# TODO: Validate recipient is not same account
# TODO: Validate sender has enough mesos
# TODO: Validate item exists if sending item
# TODO: Check receiver has space
# TODO: Add to database
# TODO: Send success/failure packet
Logger.debug("Duey send: #{mesos} mesos (quick=#{quick_delivery}) to #{recipient}, item inv=#{inventory_id}, pos=#{item_pos}, amount=#{amount}, character #{character_id}")
# Send failure for now (not implemented)
send(client_pid, {:send_packet, duey_error(17)})
:ok
end
# Operation 5: Receive package
defp handle_duey_op(5, packet, client_pid, character_id, _char_state) do
package_id = In.decode_int(packet)
# TODO: Load package from database
# package = load_single_item(package_id, character_id)
# TODO: Validate package exists
# TODO: Check inventory space
# TODO: Add item/mesos to character
# TODO: Remove from database
# TODO: Send remove packet
Logger.debug("Duey receive: package #{package_id}, character #{character_id}")
# Send failure for now
send(client_pid, {:send_packet, duey_error(17)})
:ok
end
# Operation 6: Remove package
defp handle_duey_op(6, packet, client_pid, character_id, _char_state) do
package_id = In.decode_int(packet)
# TODO: Remove from database
# remove_item_from_db(package_id, character_id)
# TODO: Send remove confirmation
# packet = Packets.remove_item_from_duey(true, package_id)
# send(client_pid, {:send_packet, packet})
Logger.debug("Duey remove: package #{package_id}, character #{character_id}")
:ok
end
# Operation 8: Close Duey
defp handle_duey_op(8, _packet, client_pid, character_id, _char_state) do
# TODO: Set conversation state to 0
Logger.debug("Duey close: character #{character_id}")
:ok
end
# Unknown operation
defp handle_duey_op(operation, _packet, _client_pid, character_id, _char_state) do
Logger.warning("Unknown Duey operation #{operation} from character #{character_id}")
:ok
end
# ============================================================================
# Database Operations (Stubs)
# ============================================================================
@doc """
Loads all packages for a character.
"""
def load_items(character_id) do
# TODO: Query dueypackages table
# SELECT * FROM dueypackages WHERE RecieverId = ?
[]
end
@doc """
Loads a single package by ID.
"""
def load_single_item(package_id, character_id) do
# TODO: Query dueypackages table
# SELECT * FROM dueypackages WHERE PackageId = ? and RecieverId = ?
nil
end
@doc """
Adds mesos to database.
"""
def add_meso_to_db(mesos, sender_name, recipient_id, is_online) do
# TODO: INSERT INTO dueypackages (RecieverId, SenderName, Mesos, TimeStamp, Checked, Type)
# VALUES (?, ?, ?, ?, ?, 3)
false
end
@doc """
Adds item to database.
"""
def add_item_to_db(item, quantity, mesos, sender_name, recipient_id, is_online) do
# TODO: INSERT INTO dueypackages with item data
# Use ItemLoader.DUEY.saveItems for item serialization
false
end
@doc """
Removes item from database.
"""
def remove_item_from_db(package_id, character_id) do
# TODO: DELETE FROM dueypackages WHERE PackageId = ? and RecieverId = ?
:ok
end
@doc """
Marks messages as received (updates Checked flag).
"""
def receive_msg(character_id) do
# TODO: UPDATE dueypackages SET Checked = 0 WHERE RecieverId = ?
:ok
end
# ============================================================================
# Helper Functions
# ============================================================================
defp duey_error(code) do
# TODO: Implement proper Duey error packet
# Packets.send_duey(code, nil)
<<>>
end
end

View File

@@ -0,0 +1,566 @@
defmodule Odinsea.Channel.Handler.Guild do
@moduledoc """
Handles guild operations.
Ported from src/handling/channel/handler/GuildHandler.java
Manages guild create, join, leave, ranks, skills, and alliance.
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Channel.Packets
alias Odinsea.Game.Character
alias Odinsea.World.Guild
# Guild creation location (Henesys Guild Headquarters)
@guild_creation_map_id 200_000_301
@guild_create_cost 500_000
@emblem_change_cost 1_500_000
# Invited list: {name => {guild_id, expiration_time}}
@invited_table :guild_invited
@doc """
Initializes the guild handler ETS table.
"""
def init do
:ets.new(@invited_table, [:set, :public, :named_table])
:ok
end
@doc """
Handles guild operations (CP_GUILD_OPERATION).
Ported from GuildHandler.Guild()
Operation:
- 0x02: Create guild
- 0x05: Invite player
- 0x06: Accept invitation
- 0x07: Leave guild
- 0x08: Expel member
- 0x0E: Change rank titles
- 0x0F: Change member rank
- 0x10: Change emblem
- 0x11: Change notice
- 0x1D: Purchase skill
- 0x1E: Activate skill
- 0x1F: Change leader
"""
def handle_guild_operation(packet, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
# Prune expired invites periodically
prune_expired_invites()
{operation, packet} = In.decode_byte(packet)
Logger.debug("Guild operation: #{operation} from #{character.name}")
case operation do
0x02 -> handle_create_guild(packet, character, client_state)
0x05 -> handle_invite_player(packet, character, client_state)
0x06 -> handle_accept_invitation(packet, character, client_state)
0x07 -> handle_leave_guild(character, client_state)
0x08 -> handle_expel_member(packet, character, client_state)
0x0E -> handle_change_rank_titles(packet, character, client_state)
0x0F -> handle_change_rank(packet, character, client_state)
0x10 -> handle_change_emblem(packet, character, client_state)
0x11 -> handle_change_notice(packet, character, client_state)
0x1D -> handle_purchase_skill(packet, character, client_state)
0x1E -> handle_activate_skill(packet, character, client_state)
0x1F -> handle_change_leader(packet, character, client_state)
_ ->
Logger.warning("Unknown guild operation: #{operation}")
{:ok, client_state}
end
else
{:error, reason} ->
Logger.warning("Guild operation failed: #{inspect(reason)}")
{:ok, client_state}
end
end
@doc """
Handles guild request denial (CP_DENY_GUILD_REQUEST).
Ported from GuildHandler.DenyGuildRequest()
"""
def handle_deny_guild_request(packet, client_state) do
with {:ok, _character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(client_state.character_id) do
{from_name, _packet} = In.decode_string(packet)
from_name = String.downcase(from_name)
# Remove from invited list
case :ets.lookup(@invited_table, from_name) do
[{^from_name, {guild_id, _expires}}] ->
:ets.delete(@invited_table, from_name)
# Notify inviter
notify_guild_denied(from_name, character.name)
[] ->
:ok
end
else
_ -> :ok
end
{:ok, client_state}
end
# ============================================================================
# Guild Operation Handlers
# ============================================================================
defp handle_create_guild(packet, character, client_state) do
cond do
character.guild_id && character.guild_id > 0 ->
Character.send_message(character.id, "You cannot create a new Guild while in one.", 1)
character.map_id != @guild_creation_map_id ->
Character.send_message(character.id, "You cannot create a new Guild while in one.", 1)
character.meso < @guild_create_cost ->
Character.send_message(character.id, "You do not have enough mesos to create a Guild.", 1)
true ->
{guild_name, _packet} = In.decode_string(packet)
if valid_guild_name?(guild_name) do
case Guild.create_guild(character.id, guild_name) do
{:ok, guild_id} ->
# Deduct mesos
Character.gain_meso(character.id, -@guild_create_cost, true, true)
# Set guild info
Character.set_guild(character.id, guild_id, 1)
Character.save_guild_status(character.id)
# TODO: Finish achievement 35
# Set online in guild
Guild.set_online(guild_id, character.id, true, client_state.channel_id)
# Send guild info
# TODO: Implement showGuildInfo packet
# Gain GP for creation
Guild.gain_gp(guild_id, 500, character.id)
# Respawn player (update guild tag)
respawn_player(character.id)
Character.send_message(character.id, "You have successfully created a Guild.", 1)
Logger.info("Guild '#{guild_name}' (ID: #{guild_id}) created by #{character.name}")
{:error, reason} ->
Character.send_message(character.id, "Please try again.", 1)
Logger.error("Failed to create guild: #{inspect(reason)}")
end
else
Character.send_message(character.id, "The Guild name you have chosen is not accepted.", 1)
end
end
{:ok, client_state}
end
defp handle_invite_player(packet, character, client_state) do
# Check if in guild and has invite permission (rank <= 2)
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
{target_name, _packet} = In.decode_string(packet)
target_name_lower = String.downcase(target_name)
# Check if already handling invitation
case :ets.lookup(@invited_table, target_name_lower) do
[{_, _}] ->
Character.send_message(character.id, "The player is currently handling an invitation.", 5)
[] ->
# Try to find target
case Odinsea.Channel.Players.find_by_name(client_state.channel_id, target_name_lower) do
{:ok, target} ->
# Check if can invite
if target.guild_id == nil || target.guild_id == 0 do
# Send invite
send_guild_invite(target, character)
# Add to invited list (expires in 20 minutes)
expiration = System.system_time(:millisecond) + 20 * 60 * 1000
:ets.insert(@invited_table, {target_name_lower, {character.guild_id, expiration}})
else
# TODO: Send appropriate error packet
:ok
end
{:error, :not_found} ->
# TODO: Send error packet
:ok
end
end
end
{:ok, client_state}
end
defp handle_accept_invitation(packet, character, client_state) do
if character.guild_id && character.guild_id > 0 do
# Already in guild
{:ok, client_state}
else
{guild_id, packet} = In.decode_int(packet)
{cid, _packet} = In.decode_int(packet)
# Verify character ID matches
if cid == character.id do
target_name = String.downcase(character.name)
case :ets.lookup(@invited_table, target_name) do
[{^target_name, {^guild_id, _expires}}] ->
# Remove from invited
:ets.delete(@invited_table, target_name)
# Join guild
case Guild.add_member(guild_id, character) do
{:ok, _member} ->
# Set guild info
Character.set_guild(character.id, guild_id, 5)
Character.save_guild_status(character.id)
# Send guild info
# TODO: Implement showGuildInfo packet
# Send alliance info if applicable
guild = Guild.get_guild(guild_id)
if guild && guild.alliance_id > 0 do
# TODO: Send alliance info
:ok
end
# Respawn player
respawn_player(character.id)
{:error, :guild_full} ->
Character.send_message(character.id, "The Guild you are trying to join is already full.", 1)
{:error, reason} ->
Logger.error("Failed to add guild member: #{inspect(reason)}")
end
[] ->
# No pending invitation
:ok
end
end
{:ok, client_state}
end
end
defp handle_leave_guild(character, client_state) do
if character.guild_id && character.guild_id > 0 do
case Guild.leave_guild(character.guild_id, character.id) do
:ok ->
# Clear guild info
Character.set_guild(character.id, 0, 5)
Character.save_guild_status(character.id)
# Send empty guild info
# TODO: Implement showGuildInfo with null
Logger.info("#{character.name} left guild #{character.guild_id}")
{:error, reason} ->
Logger.error("Failed to leave guild: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_expel_member(packet, character, client_state) do
{target_id, packet} = In.decode_int(packet)
{target_name, _packet} = In.decode_string(packet)
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
case Guild.expel_member(character.guild_id, character.id, target_id, target_name) do
:ok ->
# Update expelled character if online
case Registry.lookup(Odinsea.CharacterRegistry, target_id) do
[{pid, _}] ->
Character.set_guild(target_id, 0, 5)
# TODO: Send guild info update
[] ->
# Send note to offline character
send_note(target_name, character.name, "You have been expelled from the guild.")
end
Logger.info("#{target_name} expelled from guild by #{character.name}")
{:error, reason} ->
Logger.error("Failed to expel member: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_change_rank_titles(packet, character, client_state) do
if character.guild_id && character.guild_id > 0 && character.guild_rank == 1 do
# Read 5 rank titles
titles = for _i <- 1..5 do
{title, remaining} = In.decode_string(packet)
packet = remaining
title
end
case Guild.change_rank_titles(character.guild_id, titles, character.id) do
:ok ->
Logger.info("Guild #{character.guild_id} rank titles changed")
{:error, reason} ->
Logger.error("Failed to change rank titles: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_change_rank(packet, character, client_state) do
{target_id, packet} = In.decode_byte(packet)
{new_rank, _packet} = In.decode_byte(packet)
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
# Validate rank
if new_rank > 1 && new_rank <= 5 && (new_rank > 2 || character.guild_rank == 1) do
case Guild.change_rank(character.guild_id, target_id, new_rank, character.id) do
:ok ->
# Update target's rank if online
case Registry.lookup(Odinsea.CharacterRegistry, target_id) do
[{pid, _}] ->
Character.set_guild_rank(target_id, new_rank)
[] ->
:ok
end
{:error, reason} ->
Logger.error("Failed to change rank: #{inspect(reason)}")
end
end
end
{:ok, client_state}
end
defp handle_change_emblem(packet, character, client_state) do
cond do
character.guild_id == nil || character.guild_id == 0 ->
{:ok, client_state}
character.guild_rank != 1 ->
{:ok, client_state}
character.map_id != @guild_creation_map_id ->
{:ok, client_state}
character.meso < @emblem_change_cost ->
Character.send_message(character.id, "You do not have enough mesos to create an emblem.", 1)
{:ok, client_state}
true ->
{bg, packet} = In.decode_short(packet)
{bg_color, packet} = In.decode_byte(packet)
{logo, packet} = In.decode_short(packet)
{logo_color, _packet} = In.decode_byte(packet)
case Guild.set_emblem(character.guild_id, bg, bg_color, logo, logo_color, character.id) do
:ok ->
# Deduct mesos
Character.gain_meso(character.id, -@emblem_change_cost, true, true)
# Respawn all members to update emblem
respawn_all_guild_members(character.guild_id)
{:error, reason} ->
Logger.error("Failed to change emblem: #{inspect(reason)}")
end
{:ok, client_state}
end
end
defp handle_change_notice(packet, character, client_state) do
{notice, _packet} = In.decode_string(packet)
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
if String.length(notice) <= 100 do
case Guild.set_notice(character.guild_id, notice, character.id) do
:ok ->
Logger.info("Guild #{character.guild_id} notice changed")
{:error, reason} ->
Logger.error("Failed to change notice: #{inspect(reason)}")
end
end
end
{:ok, client_state}
end
defp handle_purchase_skill(packet, character, client_state) do
{skill_id, _packet} = In.decode_int(packet)
if character.guild_id && character.guild_id > 0 do
# TODO: Validate skill and level
# TODO: Check if character has enough mesos
case Guild.purchase_skill(character.guild_id, skill_id, character.name, character.id) do
{:ok, _level} ->
# Deduct mesos
# TODO: Get skill price
:ok
{:error, reason} ->
Logger.error("Failed to purchase guild skill: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_activate_skill(packet, character, client_state) do
{skill_id, _packet} = In.decode_int(packet)
if character.guild_id && character.guild_id > 0 do
# TODO: Check if skill is purchased and not expired
# TODO: Check if character has enough mesos for extension
case Guild.activate_skill(character.guild_id, skill_id, character.name) do
:ok ->
# Deduct mesos
:ok
{:error, reason} ->
Logger.error("Failed to activate guild skill: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_change_leader(packet, character, client_state) do
{new_leader_id, _packet} = In.decode_int(packet)
if character.guild_id && character.guild_id > 0 && character.guild_rank == 1 do
# Get current leader
guild = Guild.get_guild(character.guild_id)
if guild && guild.leader_id != new_leader_id do
case Guild.change_leader(character.guild_id, new_leader_id, character.id) do
:ok ->
# Update ranks
Character.set_guild_rank(character.id, 2)
Character.set_guild_rank(new_leader_id, 1)
{:error, reason} ->
Character.send_message(character.id, "This user is already the guild leader.", 1)
Logger.error("Failed to change leader: #{inspect(reason)}")
end
else
Character.send_message(character.id, "This user is already the guild leader.", 1)
end
end
{:ok, client_state}
end
# ============================================================================
# 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 valid_guild_name?(name) do
cond do
String.length(name) < 3 -> false
String.length(name) > 12 -> false
true -> Regex.match?(~r/^[a-zA-Z]+$/, name)
end
end
defp send_guild_invite(target, inviter) do
case Registry.lookup(Odinsea.CharacterRegistry, target.id) do
[{pid, _}] ->
invite_packet = Packets.guild_invite(inviter)
send(pid, {:send_packet, invite_packet})
[] -> :ok
end
end
defp notify_guild_denied(inviter_name, denier_name) do
# Find inviter and send denial
case Odinsea.Channel.Players.find_by_name(1, inviter_name) do
{:ok, inviter} ->
case Registry.lookup(Odinsea.CharacterRegistry, inviter.id) do
[{pid, _}] ->
packet = Packets.deny_guild_invitation(denier_name)
send(pid, {:send_packet, packet})
[] -> :ok
end
{:error, _} -> :ok
end
end
defp send_note(to_name, from_name, message) do
# TODO: Implement note sending via database
Logger.debug("Note to #{to_name} from #{from_name}: #{message}")
:ok
end
defp respawn_player(character_id) do
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
[{pid, _}] ->
case Character.get_state(pid) do
{:ok, character} ->
# Broadcast guild name and icon update
# TODO: Implement proper respawn
:ok
_ -> :ok
end
[] -> :ok
end
end
defp respawn_all_guild_members(guild_id) do
case Guild.get_guild(guild_id) do
nil -> :ok
guild ->
Enum.each(guild.members, fn member ->
if member.online do
respawn_player(member.id)
end
end)
end
end
defp prune_expired_invites do
now = System.system_time(:millisecond)
:ets.select_delete(@invited_table, [
{{:_, {:_, :"$1"}}, [{:<, :"$1", now}], [true]}
])
end
end

View File

@@ -0,0 +1,641 @@
defmodule Odinsea.Channel.Handler.ItemMaker do
@moduledoc """
Handles item crafting/making operations.
Ported from: src/handling/channel/handler/ItemMakerHandler.java
## Maker Types
- 1: Create items/gems/equipment
- 3: Make crystals from etc items
- 4: Disassemble equipment
## Profession Skills
- 92000000: Herbalism
- 92010000: Mining
- 92020000: Smithing
- 92030000: Accessory Crafting
- 92040000: Alchemy
## Main Handlers
- handle_item_maker/2 - Item crafting
- handle_use_recipe/2 - Recipe usage
- handle_make_extractor/2 - Extractor creation
- handle_use_bag/2 - Herb/Mining bag usage
- handle_start_harvest/2 - Start harvesting
- handle_stop_harvest/2 - Stop harvesting
- handle_profession_info/2 - Profession info request
- handle_craft_effect/2 - Crafting animation effect
- handle_craft_make/2 - Crafting make animation
- handle_craft_complete/2 - Crafting completion
- handle_use_pot/2 - Item pot usage
- handle_clear_pot/2 - Clear item pot
- handle_feed_pot/2 - Feed item pot
- handle_cure_pot/2 - Cure item pot
- handle_reward_pot/2 - Reward from item pot
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Game.{Character, Inventory}
alias Odinsea.Channel.Packets
# Crafting effect mapping
@crafting_effects %{
"Effect/BasicEff.img/professions/herbalism" => 92000000,
"Effect/BasicEff.img/professions/mining" => 92010000,
"Effect/BasicEff.img/professions/herbalismExtract" => 92000000,
"Effect/BasicEff.img/professions/miningExtract" => 92010000,
"Effect/BasicEff.img/professions/equip_product" => 92020000,
"Effect/BasicEff.img/professions/acc_product" => 92030000,
"Effect/BasicEff.img/professions/alchemy" => 92040000
}
# ============================================================================
# Item Making
# ============================================================================
@doc """
Handles item maker operations (CP_ITEM_MAKER / 0x87).
Maker types:
- 1: Gem creation, other gem creation, or equipment making
- 3: Crystal making from etc items
- 4: Equipment disassembly
Reference: ItemMakerHandler.ItemMaker()
"""
def handle_item_maker(packet, client_pid) do
maker_type = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
case maker_type do
1 -> handle_make_item(packet, client_pid, character_id, char_state)
3 -> handle_make_crystal(packet, client_pid, character_id, char_state)
4 -> handle_disassemble(packet, client_pid, character_id, char_state)
_ ->
Logger.warning("Unknown maker type: #{maker_type}, character #{character_id}")
:ok
end
{:error, reason} ->
Logger.warn("Failed to handle item maker: #{inspect(reason)}")
end
end
# Handle type 1: Make item/gem/equipment
defp handle_make_item(packet, client_pid, character_id, char_state) do
to_create = In.decode_int(packet)
# Check what type of creation this is
cond do
is_gem?(to_create) ->
# Gem creation with random reward
handle_gem_creation(packet, client_pid, character_id, char_state, to_create)
is_other_gem?(to_create) ->
# Non-gem items created with gem recipe
handle_other_gem_creation(packet, client_pid, character_id, char_state, to_create)
true ->
# Equipment creation
handle_equip_creation(packet, client_pid, character_id, char_state, to_create)
end
end
defp handle_gem_creation(packet, client_pid, character_id, char_state, item_id) do
# TODO: Get gem info from ItemMakerFactory
# gem = ItemMakerFactory.get_gem_info(item_id)
# TODO: Check skill level
# TODO: Check meso cost
# TODO: Check inventory space
# TODO: Remove required items
# TODO: Give random gem reward
Logger.debug("Gem creation: item #{item_id}, character #{character_id}")
# Send success packet
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
end
defp handle_other_gem_creation(packet, client_pid, character_id, char_state, item_id) do
# TODO: Similar to gem creation but with fixed reward
Logger.debug("Other gem creation: item #{item_id}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
end
defp handle_equip_creation(packet, client_pid, character_id, char_state, item_id) do
stimulator = In.decode_byte(packet) > 0
num_enchanter = In.decode_int(packet)
# TODO: Get creation info from ItemMakerFactory
# create = ItemMakerFactory.get_create_info(item_id)
# TODO: Validate enchanter count <= TUC
# TODO: Check skill level
# TODO: Check meso cost
# TODO: Check inventory space
# TODO: Remove required items
# TODO: Create equipment with optional stimulator/enchanters
Logger.debug("Equip creation: item #{item_id}, stimulator=#{stimulator}, enchanters=#{num_enchanter}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
end
# Handle type 3: Make crystals
defp handle_make_crystal(packet, client_pid, character_id, char_state) do
etc_id = In.decode_int(packet)
# TODO: Validate player has 100 of the etc item
# TODO: Get crystal ID based on item level
# crystal_id = get_create_crystal(etc_id)
# TODO: Add crystal to inventory
# TODO: Remove etc items
Logger.debug("Crystal creation: etc #{etc_id}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
end
# Handle type 4: Disassemble equipment
defp handle_disassemble(packet, client_pid, character_id, char_state) do
item_id = In.decode_int(packet)
tick = In.decode_int(packet)
slot = In.decode_int(packet)
# TODO: Validate item exists in equip inventory
# TODO: Get item level
# TODO: Calculate crystal reward
# TODO: Add crystals to inventory
# TODO: Remove equipment
Logger.debug("Disassemble: item #{item_id} at slot #{slot}, tick #{tick}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
end
# ============================================================================
# Recipe Usage
# ============================================================================
@doc """
Handles recipe usage (CP_USE_RECIPE / 0x5A).
Recipes are items that teach crafting skills.
Reference: ItemMakerHandler.UseRecipe()
"""
def handle_use_recipe(packet, client_pid) do
tick = In.decode_int(packet)
slot = In.decode_short(packet)
item_id = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate item is recipe (item_id / 10000 == 251)
# TODO: Apply recipe effect (learn skill)
# TODO: Remove recipe item
Logger.debug("Use recipe: item #{item_id} at slot #{slot}, tick #{tick}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
{:error, reason} ->
Logger.warn("Failed to use recipe: #{inspect(reason)}")
end
end
# ============================================================================
# Extractor
# ============================================================================
@doc """
Handles extractor creation (CP_MAKE_EXTRACTOR / 0x114).
Extractors allow other players to use your profession skills.
Reference: ItemMakerHandler.MakeExtractor()
"""
def handle_make_extractor(packet, client_pid) do
item_id = In.decode_int(packet)
fee = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# Handle removing extractor (negative item_id)
if item_id < 0 do
# TODO: Remove extractor
Logger.debug("Remove extractor: character #{character_id}")
else
# TODO: Validate item is extractor (item_id / 10000 == 304)
# TODO: Validate fee > 0
# TODO: Validate in town
# TODO: Create extractor on map
Logger.debug("Make extractor: item #{item_id}, fee #{fee}, character #{character_id}")
end
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
{:error, reason} ->
Logger.warn("Failed to make extractor: #{inspect(reason)}")
end
end
# ============================================================================
# Bags
# ============================================================================
@doc """
Handles bag usage (CP_USE_BAG / 0x68).
Herb bags and mining bags extend inventory.
Reference: ItemMakerHandler.UseBag()
"""
def handle_use_bag(packet, client_pid) do
tick = In.decode_int(packet)
slot = In.decode_short(packet)
item_id = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate item is bag (item_id / 10000 == 433)
# TODO: Add to extended slots if first time
# TODO: Open bag UI
Logger.debug("Use bag: item #{item_id} at slot #{slot}, tick #{tick}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
{:error, reason} ->
Logger.warn("Failed to use bag: #{inspect(reason)}")
end
end
# ============================================================================
# Harvesting
# ============================================================================
@doc """
Handles start harvest (CP_START_HARVEST / 0x12E).
Reference: ItemMakerHandler.StartHarvest()
"""
def handle_start_harvest(packet, client_pid) do
reactor_oid = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Get reactor from map
# TODO: Validate reactor is valid for harvesting
# TODO: Check harvesting tool
# TODO: Check fatigue
# TODO: Check harvest cooldown
# TODO: Send harvest OK message
Logger.debug("Start harvest: reactor #{reactor_oid}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to start harvest: #{inspect(reason)}")
end
end
@doc """
Handles stop harvest (CP_STOP_HARVEST / 0x12F).
Reference: ItemMakerHandler.StopHarvest()
"""
def handle_stop_harvest(packet, client_pid) do
reactor_oid = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Process harvest completion
# TODO: Give items
# TODO: Destroy reactor
# TODO: Trigger reactor script
Logger.debug("Stop harvest: reactor #{reactor_oid}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to stop harvest: #{inspect(reason)}")
end
end
# ============================================================================
# Profession Info
# ============================================================================
@doc """
Handles profession info request (CP_PROFESSION_INFO / 0x97).
Reference: ItemMakerHandler.ProfessionInfo()
"""
def handle_profession_info(packet, client_pid) do
profession_str = In.decode_string(packet)
level1 = In.decode_int(packet)
level2 = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# Parse profession string to get skill ID
profession_id = String.to_integer(profession_str)
# Calculate progress percentage
# progress = max(0, 100 - ((level1 + 1) - profession_level) * 20)
# TODO: Send profession info packet
# packet = Packets.profession_info(profession_str, level1, level2, progress)
# send(client_pid, {:send_packet, packet})
Logger.debug("Profession info: #{profession_id}, levels #{level1}/#{level2}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to get profession info: #{inspect(reason)}")
end
end
# ============================================================================
# Crafting Animations
# ============================================================================
@doc """
Handles crafting effect (CP_CRAFT_EFFECT / 0xC9).
Shows crafting animation to player and others.
Reference: ItemMakerHandler.CraftEffect()
"""
def handle_craft_effect(packet, client_pid) do
effect = In.decode_string(packet)
time = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# Validate map (Ardentmill or has extractor)
valid_map = char_state.map == 910001000 #|| has_extractor_nearby?
if valid_map do
profession = Map.get(@crafting_effects, effect)
if profession do
# Clamp time to 3-6 seconds
time = max(3000, min(6000, time))
is_extract = String.ends_with?(effect, "Extract")
# TODO: Broadcast crafting effect
# packet = Packets.show_own_crafting_effect(effect, time, is_extract)
# send(client_pid, {:send_packet, packet})
# TODO: Broadcast to others
# packet = Packets.show_crafting_effect(character_id, effect, time, is_extract)
# Map.broadcast_packet(char_state.map, packet, exclude: character_id)
end
end
Logger.debug("Craft effect: #{effect}, time #{time}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle craft effect: #{inspect(reason)}")
end
end
@doc """
Handles craft make animation (CP_CRAFT_MAKE / 0xCA).
Broadcasts crafting animation to map.
Reference: ItemMakerHandler.CraftMake()
"""
def handle_craft_make(packet, client_pid) do
something = In.decode_int(packet)
time = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# Clamp time
time = max(3000, min(6000, time))
# TODO: Broadcast craft make animation
# packet = Packets.craft_make(character_id, something, time)
# Map.broadcast_packet(char_state.map, packet)
Logger.debug("Craft make: #{something}, time #{time}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle craft make: #{inspect(reason)}")
end
end
@doc """
Handles craft completion (CP_CRAFT_DONE / 0xC8).
Processes crafting results.
Reference: ItemMakerHandler.CraftComplete()
"""
def handle_craft_complete(packet, client_pid) do
craft_id = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Get crafting entry from SkillFactory
# ce = SkillFactory.get_craft(craft_id)
# TODO: Check profession level
# TODO: Check fatigue
# TODO: Process disassembly, fusing, or normal crafting
# TODO: Calculate success/failure
# TODO: Give items
# TODO: Add profession EXP
# TODO: Add fatigue
Logger.debug("Craft complete: #{craft_id}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
{:error, reason} ->
Logger.warn("Failed to complete craft: #{inspect(reason)}")
end
end
# ============================================================================
# Item Pot (Imps)
# ============================================================================
@doc """
Handles use item pot (CP_USE_POT / 0x98).
Summons an item pot (imp) pet.
Reference: ItemMakerHandler.UsePot()
"""
def handle_use_pot(packet, client_pid) do
item_id = In.decode_int(packet)
slot = In.decode_short(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate item is pot item (item_id / 10000 == 244)
# TODO: Check for empty imp slot
# TODO: Create imp
# TODO: Remove item
Logger.debug("Use pot: item #{item_id} at slot #{slot}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
{:error, reason} ->
Logger.warn("Failed to use pot: #{inspect(reason)}")
end
end
@doc """
Handles clear item pot (CP_CLEAR_POT / 0x99).
Removes an item pot.
Reference: ItemMakerHandler.ClearPot()
"""
def handle_clear_pot(packet, client_pid) do
index = In.decode_int(packet) - 1
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate index
# TODO: Remove imp
Logger.debug("Clear pot: index #{index}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
{:error, reason} ->
Logger.warn("Failed to clear pot: #{inspect(reason)}")
end
end
@doc """
Handles feed item pot (CP_FEED_POT / 0x9A).
Feeds item to imp to level it up.
Reference: ItemMakerHandler.FeedPot()
"""
def handle_feed_pot(packet, client_pid) do
item_id = In.decode_int(packet)
slot = In.decode_int(packet)
index = In.decode_int(packet) - 1
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate imp exists
# TODO: Validate item level range
# TODO: Add fullness/closeness
# TODO: Level up if full
# TODO: Remove item
Logger.debug("Feed pot: item #{item_id} at #{slot}, index #{index}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
{:error, reason} ->
Logger.warn("Failed to feed pot: #{inspect(reason)}")
end
end
@doc """
Handles cure item pot (CP_CURE_POT / 0x9B).
Cures a sick imp.
Reference: ItemMakerHandler.CurePot()
"""
def handle_cure_pot(packet, client_pid) do
item_id = In.decode_int(packet)
slot = In.decode_int(packet)
index = In.decode_int(packet) - 1
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate imp is sick
# TODO: Validate cure item (item_id / 10000 == 434)
# TODO: Cure imp
# TODO: Remove item
Logger.debug("Cure pot: item #{item_id} at #{slot}, index #{index}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
{:error, reason} ->
Logger.warn("Failed to cure pot: #{inspect(reason)}")
end
end
@doc """
Handles reward from item pot (CP_REWARD_POT / 0x9C).
Claims reward from fully grown imp.
Reference: ItemMakerHandler.RewardPot()
"""
def handle_reward_pot(packet, client_pid) do
index = In.decode_int(packet) - 1
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate imp is max level
# TODO: Calculate reward based on imp type and closeness
# TODO: Give reward item
# TODO: Remove imp
Logger.debug("Reward pot: index #{index}, character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
{:error, reason} ->
Logger.warn("Failed to reward pot: #{inspect(reason)}")
end
end
# ============================================================================
# Helper Functions
# ============================================================================
defp is_gem?(item_id) do
# Gems are in specific ID ranges
# TODO: Implement proper check from GameConstants
item_id >= 4000000 and item_id < 4001000
end
defp is_other_gem?(item_id) do
# Other items that use gem crafting
# TODO: Implement proper check from GameConstants
false
end
end

View File

@@ -0,0 +1,356 @@
defmodule Odinsea.Channel.Handler.Mob do
@moduledoc """
Handles all mob (monster) related packets from the client.
Ported from: src/handling/channel/handler/MobHandler.java
## Main Handlers
- handle_mob_move/2 - Monster movement from controller
- handle_auto_aggro/2 - Monster aggro request
- handle_mob_skill_delay_end/2 - Monster skill execution
- handle_mob_bomb/2 - Monster self-destruct
- handle_mob_hit_by_mob/2 - Mob to mob damage
"""
require Logger
use Bitwise
alias Odinsea.Net.Packet.In
alias Odinsea.Game.{Character, Map, Movement}
alias Odinsea.Game.Movement.Path
alias Odinsea.Channel.Packets
# ============================================================================
# Packet Handlers
# ============================================================================
@doc """
Handles monster movement from the controlling client (CP_MOVE_LIFE / 0xF3).
Flow:
1. Client sends mob movement when they control the mob
2. Server validates the movement
3. Server broadcasts movement to other players
Reference: MobHandler.onMobMove()
"""
def handle_mob_move(packet, client_pid) do
# Decode packet
mob_id = In.decode_int(packet)
mob_ctrl_sn = In.decode_short(packet)
mob_ctrl_state = In.decode_byte(packet)
next_attack_possible = (mob_ctrl_state &&& 0x0F) != 0
action = In.decode_byte(packet)
data = In.decode_int(packet)
# Multi-target for ball
multi_target_count = In.decode_int(packet)
packet = Enum.reduce(1..multi_target_count, packet, fn _, acc_packet ->
acc_packet
|> In.decode_int() # x
|> In.decode_int() # y
end)
# Rand time for area attack
rand_time_count = In.decode_int(packet)
packet = Enum.reduce(1..rand_time_count, packet, fn _, acc_packet ->
In.decode_int(acc_packet) # rand time
end)
# Movement validation fields
_is_cheat_mob_move_rand = In.decode_byte(packet)
_hacked_code = In.decode_int(packet)
_target_x = In.decode_int(packet)
_target_y = In.decode_int(packet)
_hacked_code_crc = In.decode_int(packet)
# Parse MovePath (newer mob movement system)
move_path = Path.decode(packet, false)
# Parse additional passive data
_b_chasing = In.decode_byte(packet)
_has_target = In.decode_byte(packet)
_target_b_chasing = In.decode_byte(packet)
_target_b_chasing_hack = In.decode_byte(packet)
_chase_duration = In.decode_int(packet)
# Get character state
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# Update monster position if path has elements
final_pos = Path.get_final_position(move_path)
final_action = Path.get_final_action(move_path)
final_foothold = Path.get_final_foothold(move_path)
# TODO: Validate monster controller
# TODO: Update monster in map
# TODO: Broadcast movement to other players
Logger.debug(
"Mob move: OID #{mob_id}, action #{action}, " <>
"pos (#{final_pos.x}, #{final_pos.y}), " <>
"elements #{length(move_path.elements)}, character #{character_id}"
)
# Send control ack back to client
ack_packet = Packets.mob_ctrl_ack(mob_id, mob_ctrl_sn, next_attack_possible, 100, 0, 0)
send(client_pid, {:send_packet, ack_packet})
# Broadcast movement to other players if path has elements
if length(move_path.elements) > 0 do
broadcast_mob_move(
char_state.map,
char_state.channel_id,
mob_id,
next_attack_possible,
action,
move_path,
character_id
)
end
:ok
{:error, reason} ->
Logger.warn("Failed to handle mob move: #{inspect(reason)}")
end
end
@doc """
Handles monster auto-aggro (CP_AUTO_AGGRO / 0xFC).
When a monster detects a player, the client sends this packet to request control.
Reference: MobHandler.AutoAggro()
"""
def handle_auto_aggro(packet, client_pid) do
monster_oid = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
map_id = char_state.map
channel_id = char_state.channel_id
# TODO: Implement controller assignment
# TODO: Check distance between player and monster
# TODO: Assign monster control to this player
Logger.debug("Auto aggro: Monster OID #{monster_oid}, character #{character_id}")
# For now, just acknowledge
:ok
{:error, reason} ->
Logger.warn("Failed to handle auto aggro: #{inspect(reason)}")
end
end
@doc """
Handles monster skill delay end (CP_MOB_SKILL_DELAY_END / 0xFE).
After a monster skill animation delay, the client sends this to execute the skill effect.
Reference: MobHandler.onMobSkillDelayEnd()
"""
def handle_mob_skill_delay_end(packet, client_pid) do
monster_oid = In.decode_int(packet)
skill_id = In.decode_int(packet)
skill_lv = In.decode_int(packet)
# _option = In.decode_int(packet) # Sometimes present
case Character.get_state_by_client(client_pid) do
{:ok, character_id, _char_state} ->
# TODO: Validate monster has this skill
# TODO: Execute mob skill effect (stun, poison, etc.)
# TODO: Apply skill to players in range
Logger.debug("Mob skill delay end: OID #{monster_oid}, skill #{skill_id} lv #{skill_lv}, character #{character_id}")
{:error, reason} ->
Logger.warn("Failed to handle mob skill delay end: #{inspect(reason)}")
end
end
@doc """
Handles monster bomb/self-destruct (CP_MOB_BOMB / 0xFF).
Some monsters explode when their timer runs out or when triggered.
Reference: MobHandler.MobBomb()
"""
def handle_mob_bomb(packet, client_pid) do
monster_oid = In.decode_int(packet)
_unknown = In.decode_short(packet) # 9E 07 or similar
_damage = In.decode_int(packet) # -204 or similar
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
map_id = char_state.map
channel_id = char_state.channel_id
# TODO: Check if monster has TimeBomb buff
# TODO: Execute bomb explosion
# TODO: Damage players in range
# TODO: Kill monster
Logger.debug("Mob bomb: OID #{monster_oid}, character #{character_id}")
{:error, reason} ->
Logger.warn("Failed to handle mob bomb: #{inspect(reason)}")
end
end
@doc """
Handles mob-to-mob damage (when monsters attack each other).
Used for friendly mobs like Shammos escort quests.
Reference: MobHandler.OnMobHitByMob(), MobHandler.OnMobAttackMob()
"""
def handle_mob_hit_by_mob(packet, client_pid) do
mob_from_oid = In.decode_int(packet)
_player_id = In.decode_int(packet)
mob_to_oid = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
map_id = char_state.map
channel_id = char_state.channel_id
# TODO: Validate both monsters exist
# TODO: Check if target monster is friendly
# TODO: Calculate and apply damage
# TODO: Check for special escort quest logic (Shammos)
Logger.debug("Mob hit by mob: From OID #{mob_from_oid}, to OID #{mob_to_oid}, character #{character_id}")
{:error, reason} ->
Logger.warn("Failed to handle mob hit by mob: #{inspect(reason)}")
end
end
@doc """
Handles mob-to-mob attack damage packet (CP_MOB_ATTACK_MOB).
Similar to handle_mob_hit_by_mob but with more damage information.
Reference: MobHandler.OnMobAttackMob()
"""
def handle_mob_attack_mob(packet, client_pid) do
mob_from_oid = In.decode_int(packet)
_player_id = In.decode_int(packet)
mob_to_oid = In.decode_int(packet)
_skill_or_bump = In.decode_byte(packet) # -1 = bump, otherwise skill ID
damage = In.decode_int(packet)
# Damage cap check
if damage > 30_000 do
Logger.warn("Suspicious mob-to-mob damage: #{damage}")
:ok
else
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
map_id = char_state.map
channel_id = char_state.channel_id
# TODO: Validate both monsters exist
# TODO: Check if target monster is friendly
# TODO: Apply damage to target monster
# TODO: Broadcast damage packet
# TODO: Check for Shammos escort quest logic
Logger.debug("Mob attack mob: From OID #{mob_from_oid}, to OID #{mob_to_oid}, damage #{damage}, character #{character_id}")
{:error, reason} ->
Logger.warn("Failed to handle mob attack mob: #{inspect(reason)}")
end
end
end
@doc """
Handles monster escort collision (CP_MOB_ESCORT_COLLISION).
Used for escort quests where monsters follow a path with nodes.
Reference: MobHandler.OnMobEscrotCollision()
"""
def handle_mob_escort_collision(packet, client_pid) do
mob_oid = In.decode_int(packet)
new_node = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate monster is an escort type
# TODO: Update monster's current node
# TODO: Check if node triggers dialog
# TODO: Check if node is last node (quest complete)
Logger.debug("Mob escort collision: OID #{mob_oid}, node #{new_node}, character #{character_id}")
{:error, reason} ->
Logger.warn("Failed to handle mob escort collision: #{inspect(reason)}")
end
end
@doc """
Handles monster escort info request (CP_MOB_REQUEST_ESCORT_INFO).
Client requests path information for an escort monster.
Reference: MobHandler.OnMobRequestEscortInfo()
"""
def handle_mob_request_escort_info(packet, client_pid) do
mob_oid = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, _character_id, _char_state} ->
# TODO: Get monster from map
# TODO: Get map node properties
# TODO: Send node properties packet to client
Logger.debug("Mob escort info request: OID #{mob_oid}")
{:error, reason} ->
Logger.warn("Failed to handle mob escort info request: #{inspect(reason)}")
end
end
# ============================================================================
# Helper Functions
# ============================================================================
@doc false
def get_character_state(client_pid) do
Character.get_state_by_client(client_pid)
end
# Broadcast mob movement to other players in the map
defp broadcast_mob_move(
map_id,
channel_id,
mob_id,
next_attack_possible,
action,
move_path,
controller_id
) do
# Encode movement data
move_path_data = Path.encode(move_path, false)
# Build movement packet
# LP_MobMove packet structure:
# - mob_id (int)
# - byte (0)
# - byte (0)
# - next_attack_possible (bool)
# - action (byte)
# - skill_id (int)
# - multi_target (int, 0)
# - rand_time (int, 0)
# - move_path_data
# TODO: Build and broadcast actual packet via Map.broadcast_except
# For now just log
Logger.debug("Broadcasting mob #{mob_id} move to map #{map_id} (controller: #{controller_id})")
end
end

View File

@@ -0,0 +1,181 @@
defmodule Odinsea.Channel.Handler.MonsterCarnival do
@moduledoc """
Handles Monster Carnival (CPQ - Carnival Party Quest) operations.
Ported from: src/handling/channel/handler/MonsterCarnivalHandler.java
CPQ is a PvP-style party quest where two parties compete:
- Summon monsters to send to the opposing team
- Use debuff skills on the opposing team
- Deploy guardians for defense
## Tabs
- 0: Summon monsters
- 1: Use debuff skills
- 2: Summon guardians
## Main Handlers
- handle_monster_carnival/2 - All CPQ operations
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Game.Character
alias Odinsea.Channel.Packets
# ============================================================================
# CPQ Operations
# ============================================================================
@doc """
Handles all Monster Carnival operations (CP_MONSTER_CARNIVAL / 0x125).
Tabs:
- 0: Summon monsters (mob list index)
- 1: Use debuff skills (skill list index)
- 2: Summon guardians (guardian index)
Reference: MonsterCarnivalHandler.MonsterCarnival()
"""
def handle_monster_carnival(packet, client_pid) do
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# Check if in carnival party
if char_state.carnival_party == nil do
Logger.debug("Monster Carnival rejected: character #{character_id} not in carnival party")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
else
tab = In.decode_byte(packet)
num = In.decode_int(packet)
handle_carnival_tab(tab, num, client_pid, character_id, char_state)
end
{:error, reason} ->
Logger.warn("Failed to handle monster carnival: #{inspect(reason)}")
end
end
# ============================================================================
# Tab Handlers
# ============================================================================
# Tab 0: Summon monsters
defp handle_carnival_tab(0, num, client_pid, character_id, char_state) do
map_id = char_state.map
team = char_state.carnival_party.team
available_cp = char_state.carnival_party.available_cp
# TODO: Get mob list for map
# mobs = Map.get_mobs_to_spawn(map_id)
# TODO: Validate num is valid index
# TODO: Check available CP >= mob_cost
# If valid:
# - Spawn monster for opposing team
# - Deduct CP
# - Update CP displays
# - Broadcast summon message
Logger.debug("CPQ summon mob: index #{num}, team #{team}, map #{map_id}, character #{character_id}")
# Send enable actions (success or failure)
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
end
# Tab 1: Use debuff skills
defp handle_carnival_tab(1, num, client_pid, character_id, char_state) do
map_id = char_state.map
team = char_state.carnival_party.team
available_cp = char_state.carnival_party.available_cp
# TODO: Get skill list for map
# skills = Map.get_skill_ids(map_id)
# TODO: Validate num is valid index
# TODO: Get skill from MapleCarnivalFactory
# TODO: Check available CP >= skill.cp_loss
# If valid:
# - Apply debuff to opposing team
# - Deduct CP
# - Update CP displays
# - Broadcast skill usage
Logger.debug("CPQ debuff: index #{num}, team #{team}, map #{map_id}, character #{character_id}")
# Send enable actions (success or failure)
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
end
# Tab 2: Summon guardians
defp handle_carnival_tab(2, num, client_pid, character_id, char_state) do
map_id = char_state.map
team = char_state.carnival_party.team
available_cp = char_state.carnival_party.available_cp
# TODO: Get guardian skill from MapleCarnivalFactory
# skill = MapleCarnivalFactory.getGuardian(num)
# TODO: Check available CP >= skill.cp_loss
# If valid:
# - Spawn carnival reactor (guardian)
# - Deduct CP
# - Update CP displays
# - Broadcast summon message
Logger.debug("CPQ guardian: index #{num}, team #{team}, map #{map_id}, character #{character_id}")
# Send enable actions (success or failure)
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
end
# Unknown tab
defp handle_carnival_tab(tab, num, client_pid, character_id, _char_state) do
Logger.warning("Unknown CPQ tab #{tab} (num #{num}) from character #{character_id}")
send(client_pid, {:send_packet, Packets.enable_actions()})
:ok
end
# ============================================================================
# CP Management
# ============================================================================
@doc """
Updates CP display for a player.
party_cp: true = show party CP, false = show personal CP
"""
def update_cp(client_pid, available_cp, total_cp, team, party_cp \\ false) do
# TODO: Build CP update packet
# packet = Packets.cp_update(available_cp, total_cp, team, party_cp)
# send(client_pid, {:send_packet, packet})
:ok
end
@doc """
Broadcasts player summoned message to map.
"""
def broadcast_summon(map_id, player_name, tab, num) do
# TODO: Build summon broadcast packet
# packet = Packets.player_summoned(player_name, tab, num)
# Map.broadcast_packet(map_id, packet, exclude: player_id)
:ok
end
@doc """
Distributes CP to carnival party members.
"""
def distribute_cp(carnival_party, cp_amount) do
# TODO: Add CP to party total
# TODO: Update each member's display
:ok
end
end

View File

@@ -0,0 +1,510 @@
defmodule Odinsea.Channel.Handler.Party do
@moduledoc """
Handles party operations.
Ported from src/handling/channel/handler/PartyHandler.java
Manages party create, join, leave, expel, and leader change.
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Channel.Packets
alias Odinsea.Game.Character
alias Odinsea.World.Party
@party_invite_quest_id 1000 # TODO: Get actual quest ID
@party_request_quest_id 1001 # TODO: Get actual quest ID
@doc """
Handles party operations (CP_PARTY_OPERATION).
Ported from PartyHandler.PartyOperation()
Operation:
- 1: Create party
- 2: Leave party
- 3: Accept invitation
- 4: Invite player
- 5: Expel member
- 6: Change leader
- 7: Request to join party
- 8: Toggle party requests
"""
def handle_party_operation(packet, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
{operation, packet} = In.decode_byte(packet)
Logger.debug("Party operation: #{operation} from #{character.name}")
case operation do
1 -> handle_create_party(character, client_state)
2 -> handle_leave_party(character, client_state)
3 -> handle_accept_invitation(packet, character, client_state)
4 -> handle_invite_player(packet, character, client_state)
5 -> handle_expel_member(packet, character, client_state)
6 -> handle_change_leader(packet, character, client_state)
7 -> handle_request_join(packet, character, client_state)
8 -> handle_toggle_requests(packet, character, client_state)
_ ->
Logger.warning("Unknown party operation: #{operation}")
{:ok, client_state}
end
else
{:error, reason} ->
Logger.warning("Party operation failed: #{inspect(reason)}")
{:ok, client_state}
end
end
@doc """
Handles party request denial (CP_DENY_PARTY_REQUEST).
Ported from PartyHandler.DenyPartyRequest()
"""
def handle_deny_party_request(packet, client_state) do
with {:ok, _character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(client_state.character_id) do
{action, packet} = In.decode_byte(packet)
# Check for GMS-specific action
if action == 0x32 do
# TODO: GMS-specific party join
{:ok, client_state}
else
{party_id, _packet} = In.decode_int(packet)
if action == 0x1D || action == 0x1B do
# Accept - handled by PartyOperation(3)
{:ok, client_state}
else
# Deny - notify inviter
case Party.get_party(party_id) do
nil -> :ok
party ->
# Find leader and notify
notify_party_denied(party.leader_id, character.name)
end
{:ok, client_state}
end
end
else
_ -> {:ok, client_state}
end
end
@doc """
Handles party invite settings (CP_ALLOW_PARTY_INVITE).
Ported from PartyHandler.AllowPartyInvite()
"""
def handle_allow_party_invite(packet, character) do
{enabled, _packet} = In.decode_byte(packet)
# Update quest status for party invite blocking
if enabled > 0 do
Character.remove_quest(character.id, @party_invite_quest_id)
else
Character.start_quest(character.id, @party_invite_quest_id)
end
:ok
end
# ============================================================================
# Party Operation Handlers
# ============================================================================
defp handle_create_party(character, client_state) do
if character.party_id && character.party_id > 0 do
# Already in a party
case Party.get_party(character.party_id) do
nil ->
# Invalid party, create new
create_new_party(character, client_state)
party ->
# Check if leader of single-member party
if party.leader_id == character.id && length(party.members) == 1 do
# Re-send party created
party_created_packet = Packets.party_created(party.id)
send_packet(client_state, party_created_packet)
else
Character.send_message(character.id, "You can't create a party as you are already in one", 5)
end
end
else
create_new_party(character, client_state)
end
{:ok, client_state}
end
defp create_new_party(character, client_state) do
party_character = create_party_character(character, client_state.channel_id)
case Party.create_party(party_character) do
{:ok, party} ->
# Update character's party
Character.set_party(character.id, party.id)
# Send party created packet
party_created_packet = Packets.party_created(party.id)
send_packet(client_state, party_created_packet)
Logger.info("Party #{party.id} created by #{character.name}")
{:error, reason} ->
Logger.error("Failed to create party: #{inspect(reason)}")
Character.send_message(character.id, "Failed to create party", 5)
end
end
defp handle_leave_party(character, client_state) do
if character.party_id && character.party_id > 0 do
party_character = create_party_character(character, client_state.channel_id)
case Party.update_party(character.party_id, :leave, party_character) do
{:ok, _} ->
# Update character
Character.set_party(character.id, nil)
# If in Dojo or Pyramid, fail those
# TODO: Implement Dojo/Pyramid fail
# If in event instance, handle leave
# TODO: Implement event instance leftParty
Logger.info("#{character.name} left party #{character.party_id}")
{:error, reason} ->
Logger.error("Failed to leave party: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_accept_invitation(packet, character, client_state) do
{party_id, _packet} = In.decode_int(packet)
if character.party_id && character.party_id > 0 do
Character.send_message(character.id, "You can't join the party as you are already in one", 5)
else
# Check if accepting party invites
if Character.has_quest(character.id, @party_invite_quest_id) do
{:ok, client_state}
else
case Party.get_party(party_id) do
nil ->
Character.send_message(character.id, "The party you are trying to join does not exist", 5)
party ->
if length(party.members) >= 6 do
send_party_status_message(client_state, 17)
else
# Join party
party_character = create_party_character(character, client_state.channel_id)
case Party.update_party(party_id, :join, party_character) do
{:ok, _} ->
Character.set_party(character.id, party_id)
# Request party member HP updates
# TODO: Implement receivePartyMemberHP / updatePartyMemberHP
Logger.info("#{character.name} joined party #{party_id}")
{:error, :party_full} ->
send_party_status_message(client_state, 17)
{:error, reason} ->
Logger.error("Failed to join party: #{inspect(reason)}")
end
end
end
end
end
{:ok, client_state}
end
defp handle_invite_player(packet, character, client_state) do
# Create party if not in one
party = if not (character.party_id && character.party_id > 0) do
party_character = create_party_character(character, client_state.channel_id)
{:ok, new_party} = Party.create_party(party_character)
Character.set_party(character.id, new_party.id)
party_created_packet = Packets.party_created(new_party.id)
send_packet(client_state, party_created_packet)
new_party
else
Party.get_party(character.party_id)
end
{target_name, _packet} = In.decode_string(packet)
target_name = String.downcase(target_name)
cond do
party && party.expedition_id > 0 ->
Character.send_message(character.id, "You may not do party operations while in a raid.", 5)
party && length(party.members) >= 6 ->
send_party_status_message(client_state, 16)
true ->
# Find target character
case Odinsea.Channel.Players.find_by_name(client_state.channel_id, target_name) do
{:ok, target} ->
# Check if can invite
if can_invite_to_party?(character, target) do
# Send invite
send_party_invite(target, character)
send_party_status_message(client_state, 22, target.name)
Logger.info("#{character.name} invited #{target.name} to party")
else
send_party_status_message(client_state, 17)
end
{:error, :not_found} ->
send_party_status_message(client_state, 19)
end
end
{:ok, client_state}
end
defp handle_expel_member(packet, character, client_state) do
{target_id, _packet} = In.decode_int(packet)
if character.party_id && character.party_id > 0 do
case Party.get_party(character.party_id) do
nil ->
:ok
party ->
# Check if leader
if party.leader_id == character.id do
# Check expedition
if party.expedition_id > 0 do
Character.send_message(character.id, "You may not do party operations while in a raid.", 5)
else
# Find member to expel
target = Enum.find(party.members, fn m -> m.id == target_id end)
if target do
party_character = %{create_party_character(character, client_state.channel_id) | id: target_id}
case Party.update_party(character.party_id, :expel, party_character) do
{:ok, _} ->
# Update expelled character
Character.set_party(target_id, nil)
# Handle event instance
# TODO: disbandParty if leader wants to boot
Logger.info("#{target.name} expelled from party by #{character.name}")
{:error, reason} ->
Logger.error("Failed to expel member: #{inspect(reason)}")
end
end
end
end
end
end
{:ok, client_state}
end
defp handle_change_leader(packet, character, client_state) do
{new_leader_id, _packet} = In.decode_int(packet)
if character.party_id && character.party_id > 0 do
case Party.get_party(character.party_id) do
nil ->
:ok
party ->
# Check expedition
if party.expedition_id > 0 do
Character.send_message(character.id, "You may not do party operations while in a raid.", 5)
else
# Check if leader
if party.leader_id == character.id do
# Check if new leader is in party
if Enum.any?(party.members, fn m -> m.id == new_leader_id end) do
case Party.change_leader(character.party_id, new_leader_id, character.id) do
:ok ->
Logger.info("Party #{character.party_id} leader changed to #{new_leader_id}")
{:error, reason} ->
Logger.error("Failed to change leader: #{inspect(reason)}")
end
end
end
end
end
end
{:ok, client_state}
end
defp handle_request_join(packet, character, client_state) do
{party_id, _packet} = In.decode_int(packet)
# Leave current party if any
if character.party_id && character.party_id > 0 do
handle_leave_party(character, client_state)
end
# Request to join party
case Party.get_party(party_id) do
nil ->
:ok
party ->
# Check restrictions
# TODO: Check event instance, pyramid, dojo, expedition
# Find leader
case Registry.lookup(Odinsea.CharacterRegistry, party.leader_id) do
[{leader_pid, _}] ->
case Character.get_state(leader_pid) do
{:ok, leader} ->
# Check if leader accepts party requests
unless Character.has_quest(leader.id, @party_request_quest_id) do
# Check blacklist
unless Enum.member?(leader.blacklist, String.downcase(character.name)) do
# Send request to leader
send_party_request(leader, character)
send_party_status_message(client_state, 50, character.name)
else
Character.send_message(character.id, "Player was not found or player is not accepting party requests.", 5)
end
else
Character.send_message(character.id, "Player was not found or player is not accepting party requests.", 5)
end
_ ->
Character.send_message(character.id, "Player was not found or player is not accepting party requests.", 5)
end
[] ->
Character.send_message(character.id, "Player was not found or player is not accepting party requests.", 5)
end
end
{:ok, client_state}
end
defp handle_toggle_requests(packet, character, client_state) do
{enabled, _packet} = In.decode_byte(packet)
if enabled > 0 do
Character.remove_quest(character.id, @party_request_quest_id)
else
Character.start_quest(character.id, @party_request_quest_id)
end
{:ok, client_state}
end
# ============================================================================
# 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 create_party_character(character, channel_id) do
%{
id: character.id,
name: character.name,
level: character.level,
job: character.job,
channel_id: channel_id,
map_id: character.map_id,
# Door info (for mystic door skill)
door_town: 999999999,
door_target: 999999999,
door_skill: 0,
door_x: 0,
door_y: 0
}
end
defp can_invite_to_party?(inviter, target) do
cond do
# Target has blocked inventory
target.has_blocked_inventory ->
false
# Target already in party
target.party_id && target.party_id > 0 ->
false
# Target has blocked invites
Character.has_quest(target.id, @party_invite_quest_id) ->
false
# Target has inviter blacklisted
Enum.member?(target.blacklist, String.downcase(inviter.name)) ->
false
true ->
true
end
end
defp send_party_invite(target, inviter) do
case Registry.lookup(Odinsea.CharacterRegistry, target.id) do
[{pid, _}] ->
invite_packet = Packets.party_invite(inviter)
send(pid, {:send_packet, invite_packet})
[] -> :ok
end
end
defp send_party_request(leader, requester) do
case Registry.lookup(Odinsea.CharacterRegistry, leader.id) do
[{pid, _}] ->
request_packet = Packets.party_request(requester)
send(pid, {:send_packet, request_packet})
[] -> :ok
end
end
defp notify_party_denied(leader_id, denier_name) do
case Registry.lookup(Odinsea.CharacterRegistry, leader_id) do
[{pid, _}] ->
message_packet = Packets.party_status_message(23, denier_name)
send(pid, {:send_packet, message_packet})
[] -> :ok
end
end
defp send_party_status_message(client_state, code, name \\ "") do
packet = Packets.party_status_message(code, name)
send_packet(client_state, packet)
end
defp send_packet(client_state, packet) do
if client_state.socket do
:gen_tcp.send(client_state.socket, packet)
end
end
end

View File

@@ -0,0 +1,528 @@
defmodule Odinsea.Channel.Handler.Pet do
@moduledoc """
Handles pet-related packets.
Ported from src/handling/channel/handler/PetHandler.java
Handles:
- Pet spawning/despawning
- Pet movement
- Pet commands (tricks)
- Pet chat
- Pet food (feeding)
- Pet auto-potion
- Pet item looting
"""
require Logger
alias Odinsea.Net.Packet.{In, Out}
alias Odinsea.Net.Opcodes
alias Odinsea.Game.{Character, Pet, PetData}
alias Odinsea.Channel.Packets
# ============================================================================
# Pet Spawning
# ============================================================================
@doc """
Handles pet spawn request (CP_SpawnPet).
Ported from PetHandler.SpawnPet()
"""
def handle_spawn_pet(packet, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
# Decode packet
{_tick, packet} = In.decode_int(packet)
{slot, packet} = In.decode_byte(packet)
{lead, _packet} = In.decode_byte(packet)
Logger.info("Pet spawn request: character=#{character.name}, slot=#{slot}, lead=#{lead}")
# Get pet from inventory and spawn it
case Character.spawn_pet(character_pid, slot, lead > 0) do
{:ok, pet} ->
Logger.info("Pet spawned: #{pet.name} (level #{pet.level})")
# Broadcast pet spawn to map
spawn_packet = Packets.spawn_pet(character.id, pet, false, false)
broadcast_to_map(character.map_id, character.id, spawn_packet, client_state)
{:error, reason} ->
Logger.warning("Failed to spawn pet: #{inspect(reason)}")
end
{:ok, client_state}
else
{:error, reason} ->
Logger.warning("Spawn pet failed: #{inspect(reason)}")
send_enable_actions(client_state)
{:ok, client_state}
end
end
@doc """
Handles pet despawn.
"""
def handle_despawn_pet(pet_index, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
case Character.despawn_pet(character_pid, pet_index) do
{:ok, pet} ->
Logger.info("Pet despawned: #{pet.name}")
# Broadcast pet removal to map
remove_packet = Packets.remove_pet(character.id, pet_index)
broadcast_to_map(character.map_id, character.id, remove_packet, client_state)
{:error, reason} ->
Logger.warning("Failed to despawn pet: #{inspect(reason)}")
end
{:ok, client_state}
else
{:error, reason} ->
Logger.warning("Despawn pet failed: #{inspect(reason)}")
{:ok, client_state}
end
end
# ============================================================================
# Pet Movement
# ============================================================================
@doc """
Handles pet movement (CP_MovePet).
Ported from PetHandler.MovePet()
"""
def handle_move_pet(packet, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
# Decode pet ID/slot
{pet_id_or_slot, packet} = decode_pet_id(packet, Odinsea.Constants.Game.gms?())
# Skip field key check bytes
{_, packet} = In.skip(packet, 8)
# Get movement data (binary blob to forward to other clients)
# In full implementation, parse and validate movement
movement_data = packet
pet_slot = if Odinsea.Constants.Game.gms?(), do: pet_id_or_slot, else: pet_id_or_slot
# Get the pet
case Character.get_pet(character_pid, pet_slot) do
{:ok, pet} ->
# Update pet position (in full implementation)
# Character.update_pet_position(character_pid, pet_slot, new_position)
# Broadcast movement to other players
move_packet = Packets.move_pet(character.id, pet.unique_id, pet_slot, movement_data)
broadcast_to_map(character.map_id, character.id, move_packet, client_state)
# Check for item pickup if pet has pickup ability
if Pet.has_flag?(pet, PetData.PetFlag.item_pickup()) do
check_pet_loot(character, pet, pet_slot, client_state)
end
{:error, _reason} ->
:ok
end
{:ok, client_state}
else
{:error, reason} ->
Logger.warning("Move pet failed: #{inspect(reason)}")
{:ok, client_state}
end
end
# ============================================================================
# Pet Chat
# ============================================================================
@doc """
Handles pet chat (CP_PetChat).
Ported from PetHandler.PetChat()
"""
def handle_pet_chat(packet, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
# Decode packet
{pet_slot, packet} = decode_pet_slot(packet)
{chat_command, packet} = In.decode_short(packet)
{text, _packet} = In.decode_string(packet)
# Validate pet exists
case Character.get_pet(character_pid, pet_slot) do
{:ok, _pet} ->
Logger.debug("Pet chat: #{character.name}'s pet says: #{text}")
# Broadcast chat to map
chat_packet = Packets.pet_chat(character.id, pet_slot, chat_command, text)
broadcast_to_map(character.map_id, character.id, chat_packet, client_state)
{:error, reason} ->
Logger.warning("Pet chat failed - no pet at slot #{pet_slot}: #{inspect(reason)}")
end
{:ok, client_state}
else
{:error, reason} ->
Logger.warning("Pet chat failed: #{inspect(reason)}")
{:ok, client_state}
end
end
# ============================================================================
# Pet Commands (Tricks)
# ============================================================================
@doc """
Handles pet command/trick (CP_PetCommand).
Ported from PetHandler.PetCommand()
"""
def handle_pet_command(packet, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
# Decode packet
{pet_slot, packet} = decode_pet_slot(packet)
{command_id, _packet} = In.decode_byte(packet)
Logger.debug("Pet command: character=#{character.name}, slot=#{pet_slot}, cmd=#{command_id}")
case Character.get_pet(character_pid, pet_slot) do
{:ok, pet} ->
# Get command data
case PetData.get_pet_command(pet.pet_item_id, command_id) do
{probability, closeness_inc} ->
# Roll for success
success = :rand.uniform(100) <= probability
{_result, updated_pet} =
if success do
# Add closeness on success
case Pet.add_closeness(pet, closeness_inc) do
{:level_up, leveled_pet} ->
# Send level up packets
own_level_packet = Packets.show_own_pet_level_up(pet_slot)
send_packet(client_state, own_level_packet)
other_level_packet = Packets.show_pet_level_up(character.id, pet_slot)
broadcast_to_map(character.map_id, character.id, other_level_packet, client_state)
{:level_up, leveled_pet}
{:ok, updated} ->
{:ok, updated}
end
else
{:fail, pet}
end
# Save pet if changed
if updated_pet.changed do
Character.update_pet(character_pid, updated_pet)
# Send pet update packet
update_packet = Packets.update_pet(updated_pet)
send_packet(client_state, update_packet)
end
# Send command response
response_packet =
Packets.pet_command_response(
character.id,
pet_slot,
command_id,
success,
false
)
broadcast_to_map(character.map_id, character.id, response_packet, client_state)
nil ->
# Unknown command
Logger.warning("Unknown pet command #{command_id} for pet #{pet.pet_item_id}")
end
{:error, reason} ->
Logger.warning("Pet command failed - no pet at slot #{pet_slot}: #{inspect(reason)}")
end
send_enable_actions(client_state)
{:ok, client_state}
else
{:error, reason} ->
Logger.warning("Pet command failed: #{inspect(reason)}")
send_enable_actions(client_state)
{:ok, client_state}
end
end
# ============================================================================
# Pet Food
# ============================================================================
@doc """
Handles pet food (CP_PetFood).
Ported from PetHandler.PetFood()
"""
def handle_pet_food(packet, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
# Decode packet
{_tick, packet} = In.decode_int(packet)
{slot, packet} = In.decode_short(packet)
{item_id, _packet} = In.decode_int(packet)
Logger.debug("Pet food: item=#{item_id}, slot=#{slot}")
# Find the hungriest summoned pet
case find_hungriest_pet(character_pid) do
{:ok, {pet_slot, pet}} ->
# Validate food item
if PetData.pet_food?(item_id) do
# Calculate fullness gain
food_value = PetData.get_food_value(item_id)
# 50% chance to gain closeness when feeding
gain_closeness = :rand.uniform(100) <= 50
if pet.fullness < 100 do
# Pet was hungry, feed it
updated_pet = Pet.add_fullness(pet, food_value)
# Possibly add closeness
{_closeness_result, final_pet} =
if gain_closeness do
case Pet.add_closeness(updated_pet, 1) do
{:level_up, leveled_pet} ->
own_level_packet = Packets.show_own_pet_level_up(pet_slot)
send_packet(client_state, own_level_packet)
other_level_packet = Packets.show_pet_level_up(character.id, pet_slot)
broadcast_to_map(character.map_id, character.id, other_level_packet, client_state)
{:level_up, leveled_pet}
{:ok, updated} ->
{:ok, updated}
end
else
{:ok, updated_pet}
end
# Save pet
Character.update_pet(character_pid, final_pet)
# Send update packet
update_packet = Packets.update_pet(final_pet)
send_packet(client_state, update_packet)
# Send command response (food success)
response_packet =
Packets.pet_command_response(
character.id,
pet_slot,
1,
true,
true
)
broadcast_to_map(character.map_id, character.id, response_packet, client_state)
else
# Pet was full, may lose closeness
final_pet =
if gain_closeness do
case Pet.remove_closeness(pet, 1) do
{:level_down, downgraded_pet} ->
Character.update_pet(character_pid, downgraded_pet)
downgraded_pet
{:ok, updated} ->
Character.update_pet(character_pid, updated)
updated
end
else
pet
end
# Send update
update_packet = Packets.update_pet(final_pet)
send_packet(client_state, update_packet)
# Send failure response
response_packet =
Packets.pet_command_response(
character.id,
pet_slot,
1,
false,
true
)
broadcast_to_map(character.map_id, character.id, response_packet, client_state)
end
# Remove food from inventory
# Character.remove_item(character_pid, :use, slot, 1)
else
Logger.warning("Invalid pet food item: #{item_id}")
end
{:error, reason} ->
Logger.warning("Pet food failed: #{inspect(reason)}")
end
send_enable_actions(client_state)
{:ok, client_state}
else
{:error, reason} ->
Logger.warning("Pet food failed: #{inspect(reason)}")
send_enable_actions(client_state)
{:ok, client_state}
end
end
# ============================================================================
# Pet Auto-Potion
# ============================================================================
@doc """
Handles pet auto-potion (CP_PetAutoPot).
Ported from PetHandler.Pet_AutoPotion()
"""
def handle_pet_auto_potion(packet, client_state) do
# Skip field key bytes
{_, packet} = In.skip(packet, if(Odinsea.Constants.Game.gms?(), do: 9, else: 1))
# Decode packet
{_tick, packet} = In.decode_int(packet)
{slot, packet} = In.decode_short(packet)
{item_id, _packet} = In.decode_int(packet)
with {:ok, character_pid} <- get_character(client_state),
{:ok, _character} <- Character.get_state(character_pid) do
# TODO: Validate item and use potion
# This requires checking if HP/MP is below threshold and using the item
Logger.debug("Pet auto-potion: slot=#{slot}, item=#{item_id}")
{:ok, client_state}
else
{:error, reason} ->
Logger.warning("Pet auto-potion failed: #{inspect(reason)}")
send_enable_actions(client_state)
{:ok, client_state}
end
end
# ============================================================================
# Pet Loot
# ============================================================================
@doc """
Handles pet looting (CP_PetLoot).
"""
def handle_pet_loot(packet, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
# Decode packet
{pet_slot, _packet} = decode_pet_slot(packet)
case Character.get_pet(character_pid, pet_slot) do
{:ok, pet} ->
if Pet.has_flag?(pet, PetData.PetFlag.item_pickup()) do
# Attempt to loot nearby items
check_pet_loot(character, pet, pet_slot, client_state)
end
{:error, _reason} ->
:ok
end
{:ok, client_state}
else
{:error, reason} ->
Logger.warning("Pet loot failed: #{inspect(reason)}")
{:ok, client_state}
end
end
# ============================================================================
# Helper Functions
# ============================================================================
defp find_hungriest_pet(character_pid) do
# Get all summoned pets and find the one with lowest fullness
case Character.get_summoned_pets(character_pid) do
[] ->
{:error, :no_pets_summoned}
pets ->
{slot, pet} = Enum.min_by(pets, fn {_slot, p} -> p.fullness end)
{:ok, {slot, pet}}
end
end
defp check_pet_loot(_character, pet, pet_slot, _client_state) do
# Get items near the pet
# In full implementation, query map for items in range
# For now, this is a placeholder
Logger.debug("Checking pet loot for #{pet.name} at slot #{pet_slot}")
# Pickup range check
# If item is in range and pet has appropriate flags, pick it up
:ok
end
# Decodes pet ID/slot based on GMS mode
defp decode_pet_id(packet, true = _gms) do
In.decode_byte(packet)
end
defp decode_pet_id(packet, false = _gms) do
In.decode_int(packet)
end
# Decodes pet slot based on GMS mode
defp decode_pet_slot(packet) do
if Odinsea.Constants.Game.gms?() do
In.decode_byte(packet)
else
In.decode_int(packet)
end
end
# Gets character PID from client state
defp get_character(client_state) do
if client_state[:character_pid] do
{:ok, client_state.character_pid}
else
{:error, :no_character}
end
end
# Sends a packet to the client
defp send_packet(client_state, packet_data) do
if client_state[:transport] && client_state[:client_pid] do
send(client_state.client_pid, {:send_packet, packet_data})
end
end
# Broadcasts a packet to all players on the map except the sender
defp broadcast_to_map(map_id, character_id, packet_data, client_state) do
# In full implementation, get map PID and broadcast
# For now, placeholder
if client_state[:channel_id] do
Odinsea.Game.Map.broadcast_except(map_id, client_state.channel_id, character_id, packet_data)
end
rescue
_ -> :ok
end
# Sends enable actions packet to client
defp send_enable_actions(client_state) do
packet = Packets.enable_actions()
send_packet(client_state, packet)
end
end

View File

@@ -9,7 +9,7 @@ defmodule Odinsea.Channel.Handler.Player do
alias Odinsea.Net.Packet.{In, Out}
alias Odinsea.Net.Opcodes
alias Odinsea.Channel.Packets
alias Odinsea.Game.{Character, Movement, Map}
alias Odinsea.Game.{Character, Movement, Map, AttackInfo, DamageCalc}
@doc """
Handles player movement (CP_MOVE_PLAYER).
@@ -31,16 +31,22 @@ defmodule Odinsea.Channel.Handler.Player do
# Store original position
original_pos = character.position
# Parse movement
case Movement.parse_movement(packet) do
{:ok, movement_data, final_pos} ->
# Parse movement using the full movement system
case Movement.parse_player_movement(packet, original_pos) do
{:ok, movements, final_pos} ->
# Update character position
Character.update_position(character_pid, final_pos)
# Serialize movements for broadcast
movement_data = Movement.serialize_movements(movements)
# Broadcast movement to other players
move_packet =
Out.new(Opcodes.lp_move_player())
|> Out.encode_int(character.id)
|> Out.encode_short(original_pos.x)
|> Out.encode_short(original_pos.y)
|> Out.encode_int(0) # Unknown int
|> Out.encode_bytes(movement_data)
|> Out.to_data()
@@ -52,7 +58,7 @@ defmodule Odinsea.Channel.Handler.Player do
)
Logger.debug(
"Player #{character.name} moved to (#{final_pos.x}, #{final_pos.y})"
"Player #{character.name} moved from (#{original_pos.x}, #{original_pos.y}) to (#{final_pos.x}, #{final_pos.y}) with #{length(movements)} movements"
)
{:ok, client_state}
@@ -168,20 +174,39 @@ defmodule Odinsea.Channel.Handler.Player do
@doc """
Handles close-range attack (CP_CLOSE_RANGE_ATTACK).
Ported from PlayerHandler.closeRangeAttack() - STUB for now
Ported from PlayerHandler.closeRangeAttack() and DamageParse.parseDmgM()
"""
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, character} <- Character.get_state(character_pid),
{:ok, map_pid} <- get_map_pid(character.map_id, client_state.channel_id) do
# Parse attack packet
case AttackInfo.parse_melee_attack(packet) do
{:ok, attack_info} ->
Logger.debug(
"Close range attack from #{character.name}: skill=#{attack_info.skill}, targets=#{attack_info.targets}, hits=#{attack_info.hits}"
)
{:ok, client_state}
# Apply attack via DamageCalc
case DamageCalc.apply_attack(
attack_info,
character_pid,
map_pid,
client_state.channel_id
) do
{:ok, total_damage} ->
Logger.debug("Attack dealt #{total_damage} total damage")
{:ok, client_state}
{:error, reason} ->
Logger.warning("Attack failed: #{inspect(reason)}")
{:ok, client_state}
end
{:error, reason} ->
Logger.warning("Failed to parse melee attack: #{inspect(reason)}")
{:ok, client_state}
end
else
{:error, reason} ->
Logger.warning("Close range attack failed: #{inspect(reason)}")
@@ -191,15 +216,39 @@ defmodule Odinsea.Channel.Handler.Player do
@doc """
Handles ranged attack (CP_RANGED_ATTACK).
Ported from PlayerHandler.rangedAttack() - STUB for now
Ported from PlayerHandler.rangedAttack() and DamageParse.parseDmgR()
"""
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, character} <- Character.get_state(character_pid),
{:ok, map_pid} <- get_map_pid(character.map_id, client_state.channel_id) do
# Parse attack packet
case AttackInfo.parse_ranged_attack(packet) do
{:ok, attack_info} ->
Logger.debug(
"Ranged attack from #{character.name}: skill=#{attack_info.skill}, targets=#{attack_info.targets}, hits=#{attack_info.hits}"
)
{:ok, client_state}
# Apply attack via DamageCalc
case DamageCalc.apply_attack(
attack_info,
character_pid,
map_pid,
client_state.channel_id
) do
{:ok, total_damage} ->
Logger.debug("Attack dealt #{total_damage} total damage")
{:ok, client_state}
{:error, reason} ->
Logger.warning("Attack failed: #{inspect(reason)}")
{:ok, client_state}
end
{:error, reason} ->
Logger.warning("Failed to parse ranged attack: #{inspect(reason)}")
{:ok, client_state}
end
else
{:error, reason} ->
Logger.warning("Ranged attack failed: #{inspect(reason)}")
@@ -209,15 +258,39 @@ defmodule Odinsea.Channel.Handler.Player do
@doc """
Handles magic attack (CP_MAGIC_ATTACK).
Ported from PlayerHandler.MagicDamage() - STUB for now
Ported from PlayerHandler.MagicDamage() and DamageParse.parseDmgMa()
"""
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, character} <- Character.get_state(character_pid),
{:ok, map_pid} <- get_map_pid(character.map_id, client_state.channel_id) do
# Parse attack packet
case AttackInfo.parse_magic_attack(packet) do
{:ok, attack_info} ->
Logger.debug(
"Magic attack from #{character.name}: skill=#{attack_info.skill}, targets=#{attack_info.targets}, hits=#{attack_info.hits}"
)
{:ok, client_state}
# Apply attack via DamageCalc
case DamageCalc.apply_attack(
attack_info,
character_pid,
map_pid,
client_state.channel_id
) do
{:ok, total_damage} ->
Logger.debug("Attack dealt #{total_damage} total damage")
{:ok, client_state}
{:error, reason} ->
Logger.warning("Attack failed: #{inspect(reason)}")
{:ok, client_state}
end
{:error, reason} ->
Logger.warning("Failed to parse magic attack: #{inspect(reason)}")
{:ok, client_state}
end
else
{:error, reason} ->
Logger.warning("Magic attack failed: #{inspect(reason)}")

View File

@@ -0,0 +1,973 @@
defmodule Odinsea.Channel.Handler.PlayerShop do
@moduledoc """
Handles player shop and hired merchant packets.
Ported from:
- src/handling/channel/handler/PlayerInteractionHandler.java
- src/handling/channel/handler/HiredMerchantHandler.java
Handles:
- Creating player shops and mini games
- Visiting shops
- Buying/selling items
- Managing visitors
- Mini game operations (Omok, Match Card)
- Hired merchant operations
"""
require Logger
alias Odinsea.Net.Packet.{In, Out}
alias Odinsea.Net.Opcodes
alias Odinsea.Game.{PlayerShop, HiredMerchant, MiniGame, ShopItem, Item, Equip}
# Interaction action constants (from PlayerInteractionHandler.Interaction enum)
# GMS v342 values
@action_create 0x06
@action_invite_trade 0x11
@action_deny_trade 0x12
@action_visit 0x09
@action_chat 0x14
@action_exit 0x18
@action_open 0x16
@action_set_items 0x00
@action_set_meso 0x01
@action_confirm_trade 0x02
@action_player_shop_add_item 0x28
@action_buy_item_player_shop 0x22
@action_add_item 0x23
@action_buy_item_store 0x24
@action_buy_item_hired_merchant 0x26
@action_remove_item 0x28
@action_maintenance_off 0x29
@action_maintenance_organise 0x30
@action_close_merchant 0x31
@action_admin_store_namechange 0x35
@action_view_merchant_visitor 0x36
@action_view_merchant_blacklist 0x37
@action_merchant_blacklist_add 0x38
@action_merchant_blacklist_remove 0x39
@action_request_tie 0x51
@action_answer_tie 0x52
@action_give_up 0x53
@action_request_redo 0x55
@action_answer_redo 0x56
@action_exit_after_game 0x57
@action_cancel_exit 0x58
@action_ready 0x59
@action_un_ready 0x60
@action_expel 0x61
@action_start 0x62
@action_skip 0x64
@action_move_omok 0x65
@action_select_card 0x68
# Create type constants
@create_type_trade 3
@create_type_omok 1
@create_type_match_card 2
@create_type_player_shop 4
@create_type_hired_merchant 5
@doc """
Main handler for player interaction packets.
"""
def handle_interaction(packet, client_state) do
{action, packet} = In.decode_byte(packet)
case action do
@action_create -> handle_create(packet, client_state)
@action_visit -> handle_visit(packet, client_state)
@action_chat -> handle_chat(packet, client_state)
@action_exit -> handle_exit(packet, client_state)
@action_open -> handle_open(packet, client_state)
@action_player_shop_add_item -> handle_add_item(packet, client_state)
@action_add_item -> handle_add_item(packet, client_state)
@action_buy_item_player_shop -> handle_buy_item(packet, client_state)
@action_buy_item_store -> handle_buy_item(packet, client_state)
@action_buy_item_hired_merchant -> handle_buy_item(packet, client_state)
@action_remove_item -> handle_remove_item(packet, client_state)
@action_maintenance_off -> handle_maintenance_off(packet, client_state)
@action_maintenance_organise -> handle_maintenance_organise(packet, client_state)
@action_close_merchant -> handle_close_merchant(packet, client_state)
@action_view_merchant_visitor -> handle_view_visitors(packet, client_state)
@action_view_merchant_blacklist -> handle_view_blacklist(packet, client_state)
@action_merchant_blacklist_add -> handle_blacklist_add(packet, client_state)
@action_merchant_blacklist_remove -> handle_blacklist_remove(packet, client_state)
@action_ready -> handle_ready(packet, client_state)
@action_un_ready -> handle_ready(packet, client_state)
@action_start -> handle_start_game(packet, client_state)
@action_give_up -> handle_give_up(packet, client_state)
@action_request_tie -> handle_request_tie(packet, client_state)
@action_answer_tie -> handle_answer_tie(packet, client_state)
@action_skip -> handle_skip(packet, client_state)
@action_move_omok -> handle_move_omok(packet, client_state)
@action_select_card -> handle_select_card(packet, client_state)
@action_exit_after_game -> handle_exit_after_game(packet, client_state)
@action_cancel_exit -> handle_exit_after_game(packet, client_state)
_ ->
Logger.debug("Unhandled player interaction action: #{action}")
send_enable_actions(client_state)
{:ok, client_state}
end
end
@doc """
Handles hired merchant specific packets.
"""
def handle_hired_merchant(packet, client_state) do
{operation, packet} = In.decode_byte(packet)
case operation do
# Display Fredrick/Merchant item store
20 -> handle_display_merch(client_state)
# Open merch item store
25 -> handle_open_merch_store(client_state)
# Retrieve items
26 -> handle_retrieve_items(packet, client_state)
# Close dialog
27 ->
send_enable_actions(client_state)
{:ok, client_state}
_ ->
Logger.debug("Unhandled hired merchant operation: #{operation}")
send_enable_actions(client_state)
{:ok, client_state}
end
end
# ============================================================================
# Create Shop/Game Handlers
# ============================================================================
defp handle_create(packet, client_state) do
{create_type, packet} = In.decode_byte(packet)
{description, packet} = In.decode_string(packet)
{has_password, packet} = In.decode_byte(packet)
password =
if has_password > 0 do
{pwd, packet} = In.decode_string(packet)
pwd
else
""
end
case create_type do
@create_type_omok ->
{piece, _packet} = In.decode_byte(packet)
create_mini_game(client_state, description, password, MiniGame.game_type_omok(), piece)
@create_type_match_card ->
{piece, _packet} = In.decode_byte(packet)
create_mini_game(client_state, description, password, MiniGame.game_type_match_card(), piece)
@create_type_player_shop ->
# Skip slot and item ID validation for now
create_player_shop(client_state, description)
@create_type_hired_merchant ->
create_hired_merchant(client_state, description)
_ ->
send_enable_actions(client_state)
{:ok, client_state}
end
end
defp create_mini_game(client_state, description, password, game_type, piece_type) do
with {:ok, character} <- get_character(client_state) do
game_opts = %{
id: generate_id(),
owner_id: character.id,
owner_name: character.name,
description: description,
password: password,
game_type: game_type,
piece_type: piece_type,
map_id: character.map_id,
channel: client_state.channel
}
# Start the mini game GenServer
case DynamicSupervisor.start_child(
Odinsea.MiniGameSupervisor,
{MiniGame, game_opts}
) do
{:ok, _pid} ->
# Send mini game packet
packet = encode_mini_game(game_opts)
send_packet(client_state, packet)
send_enable_actions(client_state)
{:ok, %{client_state | player_shop: game_opts.id}}
{:error, reason} ->
Logger.error("Failed to create mini game: #{inspect(reason)}")
send_enable_actions(client_state)
{:ok, client_state}
end
else
_ ->
send_enable_actions(client_state)
{:ok, client_state}
end
end
defp create_player_shop(client_state, description) do
with {:ok, character} <- get_character(client_state) do
shop_opts = %{
id: generate_id(),
owner_id: character.id,
owner_account_id: character.account_id,
owner_name: character.name,
item_id: 5_040_000,
description: description,
map_id: character.map_id,
channel: client_state.channel
}
case DynamicSupervisor.start_child(
Odinsea.ShopSupervisor,
{PlayerShop, shop_opts}
) do
{:ok, _pid} ->
# Send player shop packet
packet = encode_player_shop(shop_opts, true)
send_packet(client_state, packet)
send_enable_actions(client_state)
{:ok, %{client_state | player_shop: shop_opts.id}}
{:error, reason} ->
Logger.error("Failed to create player shop: #{inspect(reason)}")
send_enable_actions(client_state)
{:ok, client_state}
end
else
_ ->
send_enable_actions(client_state)
{:ok, client_state}
end
end
defp create_hired_merchant(client_state, description) do
with {:ok, character} <- get_character(client_state) do
# Check if already has a merchant
# In full implementation, check world for existing merchant
merchant_opts = %{
id: generate_id(),
owner_id: character.id,
owner_account_id: character.account_id,
owner_name: character.name,
item_id: 5_030_000,
description: description,
map_id: character.map_id,
channel: client_state.channel
}
case DynamicSupervisor.start_child(
Odinsea.MerchantSupervisor,
{HiredMerchant, merchant_opts}
) do
{:ok, _pid} ->
# Send hired merchant packet
packet = encode_hired_merchant(merchant_opts, true)
send_packet(client_state, packet)
send_enable_actions(client_state)
{:ok, %{client_state | player_shop: merchant_opts.id}}
{:error, reason} ->
Logger.error("Failed to create hired merchant: #{inspect(reason)}")
send_enable_actions(client_state)
{:ok, client_state}
end
else
_ ->
send_enable_actions(client_state)
{:ok, client_state}
end
end
# ============================================================================
# Visit/Exit Handlers
# ============================================================================
defp handle_visit(packet, client_state) do
{object_id, packet} = In.decode_int(packet)
# Try to find shop by object ID
# This would need proper map object tracking
# For now, simplified version
Logger.debug("Visit shop: object_id=#{object_id}")
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_exit(_packet, client_state) do
# Close player shop or mini game
if client_state.player_shop do
# Clean up
{:ok, %{client_state | player_shop: nil}}
else
{:ok, client_state}
end
end
# ============================================================================
# Shop Management Handlers
# ============================================================================
defp handle_open(_packet, client_state) do
with {:ok, character} <- get_character(client_state),
shop_id <- client_state.player_shop,
true <- shop_id != nil do
# Try player shop first, then hired merchant
case PlayerShop.set_open(shop_id, true) do
:ok ->
PlayerShop.set_available(shop_id, true)
:ok
{:error, :not_found} ->
HiredMerchant.set_open(shop_id, true)
HiredMerchant.set_available(shop_id, true)
:ok
end
send_enable_actions(client_state)
{:ok, client_state}
else
_ ->
send_enable_actions(client_state)
{:ok, client_state}
end
end
defp handle_add_item(packet, client_state) do
{inv_type, packet} = In.decode_byte(packet)
{slot, packet} = In.decode_short(packet)
{bundles, packet} = In.decode_short(packet)
{per_bundle, packet} = In.decode_short(packet)
{price, _packet} = In.decode_int(packet)
with {:ok, character} <- get_character(client_state),
shop_id <- client_state.player_shop,
true <- shop_id != nil do
# Get item from inventory
# Create shop item
item = %ShopItem{
item: %Item{item_id: 400_0000, quantity: per_bundle},
bundles: bundles,
price: price
}
# Add to shop
case PlayerShop.add_item(shop_id, item) do
:ok ->
# Send item update packet
send_shop_item_update(client_state, shop_id)
_ ->
:ok
end
send_enable_actions(client_state)
{:ok, client_state}
else
_ ->
send_enable_actions(client_state)
{:ok, client_state}
end
end
defp handle_buy_item(packet, client_state) do
{item_slot, packet} = In.decode_byte(packet)
{quantity, _packet} = In.decode_short(packet)
with {:ok, character} <- get_character(client_state),
shop_id <- client_state.player_shop,
true <- shop_id != nil do
# Try player shop buy
case PlayerShop.buy_item(shop_id, item_slot, quantity, character.id, character.name) do
{:ok, item, price, status} ->
# Deduct meso and add item
# Send update packet
send_shop_item_update(client_state, shop_id)
if status == :close do
# Shop closed (all items sold)
send_shop_error_message(client_state, 10, 1)
end
{:error, reason} ->
Logger.debug("Buy item failed: #{reason}")
end
send_enable_actions(client_state)
{:ok, client_state}
else
_ ->
send_enable_actions(client_state)
{:ok, client_state}
end
end
defp handle_remove_item(packet, client_state) do
{slot, _packet} = In.decode_short(packet)
with {:ok, _character} <- get_character(client_state),
shop_id <- client_state.player_shop,
true <- shop_id != nil do
case PlayerShop.remove_item(shop_id, slot) do
{:ok, _item} ->
send_shop_item_update(client_state, shop_id)
_ ->
:ok
end
send_enable_actions(client_state)
{:ok, client_state}
else
_ ->
send_enable_actions(client_state)
{:ok, client_state}
end
end
# ============================================================================
# Hired Merchant Specific Handlers
# ============================================================================
defp handle_maintenance_off(_packet, client_state) do
with shop_id <- client_state.player_shop,
true <- shop_id != nil do
HiredMerchant.set_open(shop_id, true)
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_maintenance_organise(_packet, client_state) do
with shop_id <- client_state.player_shop,
true <- shop_id != nil do
# Clean up sold out items and give meso
# This is a simplified version
:ok
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_close_merchant(_packet, client_state) do
with shop_id <- client_state.player_shop,
true <- shop_id != nil do
HiredMerchant.close_merchant(shop_id, true, true)
# Send Fredrick message
send_drop_message(client_state, 1, "Please visit Fredrick for your items.")
end
send_enable_actions(client_state)
{:ok, %{client_state | player_shop: nil}}
end
defp handle_view_visitors(_packet, client_state) do
with shop_id <- client_state.player_shop,
true <- shop_id != nil,
visitors <- HiredMerchant.get_visitors(shop_id) do
packet = encode_visitor_view(visitors)
send_packet(client_state, packet)
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_view_blacklist(_packet, client_state) do
with shop_id <- client_state.player_shop,
true <- shop_id != nil,
blacklist <- HiredMerchant.get_blacklist(shop_id) do
packet = encode_blacklist_view(blacklist)
send_packet(client_state, packet)
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_blacklist_add(packet, client_state) do
{name, _packet} = In.decode_string(packet)
with shop_id <- client_state.player_shop,
true <- shop_id != nil do
HiredMerchant.add_to_blacklist(shop_id, name)
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_blacklist_remove(packet, client_state) do
{name, _packet} = In.decode_string(packet)
with shop_id <- client_state.player_shop,
true <- shop_id != nil do
HiredMerchant.remove_from_blacklist(shop_id, name)
end
send_enable_actions(client_state)
{:ok, client_state}
end
# ============================================================================
# Mini Game Handlers
# ============================================================================
defp handle_ready(_packet, client_state) do
with game_id <- client_state.player_shop,
true <- game_id != nil,
{:ok, character} <- get_character(client_state) do
MiniGame.set_ready(game_id, character.id)
# Send ready update
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_start_game(_packet, client_state) do
with game_id <- client_state.player_shop,
true <- game_id != nil do
case MiniGame.start_game(game_id) do
{:ok, loser} ->
# Send game start packet
send_game_start(client_state, loser)
{:error, :not_ready} ->
:ok
end
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_give_up(_packet, client_state) do
with game_id <- client_state.player_shop,
true <- game_id != nil,
{:ok, character} <- get_character(client_state) do
case MiniGame.give_up(game_id, character.id) do
{:give_up, winner} ->
# Send game result
send_game_result(client_state, 0, winner)
_ ->
:ok
end
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_request_tie(_packet, client_state) do
with game_id <- client_state.player_shop,
true <- game_id != nil,
{:ok, character} <- get_character(client_state) do
MiniGame.request_tie(game_id, character.id)
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_answer_tie(packet, client_state) do
{accept, _packet} = In.decode_byte(packet)
with game_id <- client_state.player_shop,
true <- game_id != nil,
{:ok, character} <- get_character(client_state) do
case MiniGame.answer_tie(game_id, character.id, accept > 0) do
{:tie, _} ->
send_game_result(client_state, 1, 0)
{:deny, _} ->
send_deny_tie(client_state)
_ ->
:ok
end
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_skip(_packet, client_state) do
with game_id <- client_state.player_shop,
true <- game_id != nil,
{:ok, character} <- get_character(client_state) do
MiniGame.skip_turn(game_id, character.id)
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_move_omok(packet, client_state) do
{x, packet} = In.decode_int(packet)
{y, packet} = In.decode_int(packet)
{piece_type, _packet} = In.decode_byte(packet)
with game_id <- client_state.player_shop,
true <- game_id != nil,
{:ok, character} <- get_character(client_state) do
case MiniGame.make_omok_move(game_id, character.id, x, y, piece_type) do
{:ok, _won} ->
# Broadcast move to all players
:ok
{:win, winner} ->
send_game_result(client_state, 2, winner)
{:error, reason} ->
Logger.debug("Omok move failed: #{reason}")
end
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_select_card(packet, client_state) do
{slot, _packet} = In.decode_byte(packet)
with game_id <- client_state.player_shop,
true <- game_id != nil,
{:ok, character} <- get_character(client_state) do
case MiniGame.select_card(game_id, character.id, slot) do
{:first_card, _} ->
:ok
{:match, _} ->
:ok
{:no_match, _} ->
:ok
{:game_win, winner} ->
send_game_result(client_state, 2, winner)
_ ->
:ok
end
end
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_exit_after_game(_packet, client_state) do
with game_id <- client_state.player_shop,
true <- game_id != nil,
{:ok, character} <- get_character(client_state) do
MiniGame.set_exit_after(game_id, character.id)
end
send_enable_actions(client_state)
{:ok, client_state}
end
# ============================================================================
# Chat Handler
# ============================================================================
defp handle_chat(packet, client_state) do
{_tick, packet} = In.decode_int(packet)
{message, _packet} = In.decode_string(packet)
with {:ok, character} <- get_character(client_state),
shop_id <- client_state.player_shop,
true <- shop_id != nil do
# Broadcast to all visitors
packet = encode_shop_chat(character.name, message)
PlayerShop.broadcast_to_visitors(shop_id, packet, true)
end
{:ok, client_state}
end
# ============================================================================
# Fredrick/Merch Store Handlers
# ============================================================================
defp handle_display_merch(client_state) do
# Check for stored items
# For now, return empty
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_open_merch_store(client_state) do
# Open the Fredrick item store dialog
packet = encode_merch_item_store()
send_packet(client_state, packet)
send_enable_actions(client_state)
{:ok, client_state}
end
defp handle_retrieve_items(_packet, client_state) do
# Retrieve items from Fredrick
# For now, just acknowledge
send_enable_actions(client_state)
{:ok, client_state}
end
# ============================================================================
# Packet Encoders
# ============================================================================
defp encode_player_shop(shop, is_owner) do
# Player shop packet
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x05) # Shop type
|> Out.encode_byte(PlayerShop.shop_type())
|> Out.encode_int(shop.id)
|> Out.encode_string(shop.owner_name)
|> Out.encode_string(shop.description)
|> Out.encode_byte(0) # Password flag
|> Out.encode_byte(length(shop.items))
|> encode_shop_items(shop.items)
|> Out.encode_byte(if is_owner, do: 0, else: 1)
|> Out.to_data()
end
defp encode_hired_merchant(merchant, is_owner) do
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x05)
|> Out.encode_byte(HiredMerchant.shop_type())
|> Out.encode_int(merchant.id)
|> Out.encode_string(merchant.owner_name)
|> Out.encode_string(merchant.description)
|> Out.encode_byte(0)
|> Out.encode_int(0) # Time remaining
|> Out.encode_byte(0) # Visitor count
|> Out.encode_byte(0) # Has items
|> Out.encode_byte(if is_owner, do: 0, else: 1)
|> Out.to_data()
end
defp encode_mini_game(game) do
game_type =
case game.game_type do
1 -> MiniGame.shop_type(%{game_type: 1})
2 -> MiniGame.shop_type(%{game_type: 2})
end
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x05)
|> Out.encode_byte(game_type)
|> Out.encode_int(game.id)
|> Out.encode_string(game.owner_name)
|> Out.encode_string(game.description)
|> Out.encode_byte(if game.password != "", do: 1, else: 0)
|> Out.encode_byte(0) # Piece type
|> Out.encode_byte(1) # Is owner
|> Out.encode_byte(0) # Loser
|> Out.encode_byte(0) # Turn
|> Out.to_data()
end
defp encode_shop_items(packet, items) do
Enum.reduce(items, packet, fn item, p ->
p
|> Out.encode_short(item.bundles)
|> Out.encode_short(item.item.quantity)
|> Out.encode_int(item.price)
|> encode_item(item.item)
end)
end
defp encode_item(packet, %Item{} = item) do
packet
|> Out.encode_byte(2) # Item type
|> Out.encode_int(item.item_id)
|> Out.encode_byte(0) # Has cash ID
|> Out.encode_long(0) # Expiration
|> Out.encode_short(item.quantity)
|> Out.encode_string(item.owner)
end
defp encode_item(packet, %Equip{} = equip) do
packet
|> Out.encode_byte(1) # Equip type
|> Out.encode_int(equip.item_id)
|> Out.encode_byte(0) # Has cash ID
|> Out.encode_long(0) # Expiration
# Equipment stats would go here
|> Out.encode_bytes(<<0::size(100)-unit(8)>>)
end
defp encode_shop_chat(name, message) do
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x06) # Chat
|> Out.encode_byte(0) # Slot
|> Out.encode_string("#{name} : #{message}")
|> Out.to_data()
end
defp encode_shop_item_update(shop) do
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x07) # Update
|> encode_shop_items(shop.items)
|> Out.to_data()
end
defp encode_visitor_view(visitors) do
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x0A) # Visitor view
|> Out.encode_byte(length(visitors))
|> encode_visitor_list(visitors)
|> Out.to_data()
end
defp encode_visitor_list(packet, visitors) do
Enum.reduce(visitors, packet, fn name, p ->
p
|> Out.encode_string(name)
|> Out.encode_long(0) # Visit time
end)
end
defp encode_blacklist_view(blacklist) do
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x0B) # Blacklist view
|> Out.encode_byte(length(blacklist))
|> encode_string_list(blacklist)
|> Out.to_data()
end
defp encode_string_list(packet, strings) do
Enum.reduce(strings, packet, fn str, p ->
Out.encode_string(p, str)
end)
end
defp encode_game_start(loser) do
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x0C) # Start
|> Out.encode_byte(loser)
|> Out.to_data()
end
defp encode_game_result(result_type, winner) do
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x0D) # Result
|> Out.encode_byte(result_type) # 0 = give up, 1 = tie, 2 = win
|> Out.encode_byte(winner)
|> Out.to_data()
end
defp encode_deny_tie do
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x0E) # Deny tie
|> Out.to_data()
end
defp encode_merch_item_store do
Out.new(Opcodes.lp_merch_item_store())
|> Out.encode_byte(0x24)
|> Out.to_data()
end
defp send_shop_item_update(client_state, shop_id) do
# Get shop state and send update
case PlayerShop.get_state(shop_id) do
{:error, _} ->
case HiredMerchant.get_state(shop_id) do
{:error, _} -> :ok
state -> send_packet(client_state, encode_shop_item_update(state))
end
state ->
send_packet(client_state, encode_shop_item_update(state))
end
end
defp send_game_start(client_state, loser) do
packet = encode_game_start(loser)
send_packet(client_state, packet)
end
defp send_game_result(client_state, result_type, winner) do
packet = encode_game_result(result_type, winner)
send_packet(client_state, packet)
end
defp send_deny_tie(client_state) do
packet = encode_deny_tie()
send_packet(client_state, packet)
end
defp send_shop_error_message(client_state, error, msg_type) do
Out.new(Opcodes.lp_player_interaction())
|> Out.encode_byte(0x0A) # Error
|> Out.encode_byte(error)
|> Out.encode_byte(msg_type)
|> Out.to_data()
|> then(&send_packet(client_state, &1))
end
defp send_drop_message(client_state, msg_type, message) do
Out.new(Opcodes.lp_blow_weather())
|> Out.encode_int(msg_type)
|> Out.encode_string(message)
|> Out.to_data()
|> then(&send_packet(client_state, &1))
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, _}] ->
case Odinsea.Game.Character.get_state(pid) do
{:ok, state} -> {:ok, state}
error -> error
end
[] ->
{:error, :character_not_found}
end
end
end
defp send_packet(client_state, data) do
if client_state.client_pid do
send(client_state.client_pid, {:send_packet, data})
end
end
defp send_enable_actions(client_state) do
packet = <<0x0D, 0x00, 0x00>>
send_packet(client_state, packet)
end
defp generate_id do
:erlang.unique_integer([:positive])
end
end

View File

@@ -0,0 +1,674 @@
defmodule Odinsea.Channel.Handler.Players do
@moduledoc """
Handles general player operation packets.
Ported from: src/handling/channel/handler/PlayersHandler.java
## Main Handlers
- handle_note/2 - Cash note system
- handle_give_fame/2 - Fame system
- handle_use_door/2 - Party door usage
- handle_use_mech_door/2 - Mechanic door usage
- handle_transform_player/2 - Transformation items
- handle_hit_reactor/2 - Reactor hit
- handle_touch_reactor/2 - Reactor touch
- handle_hit_coconut/2 - Coconut event
- handle_follow_request/2 - Follow request
- handle_follow_reply/2 - Follow reply
- handle_ring_action/2 - Marriage rings
- handle_solomon/2 - Solomon's books
- handle_gach_exp/2 - Gachapon EXP
- handle_report/2 - Player reporting
- handle_monster_book_info/2 - Monster book info
- handle_change_set/2 - Card set change
- handle_enter_pvp/2 - Enter PVP
- handle_respawn_pvp/2 - PVP respawn
- handle_leave_pvp/2 - Leave PVP
- handle_attack_pvp/2 - PVP attack
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Game.{Character, Map}
alias Odinsea.Channel.Packets
# ============================================================================
# Note System
# ============================================================================
@doc """
Handles cash note operations (CP_NOTE_ACTION / 0xAD).
Types:
- 0: Send note with item
- 1: Delete notes
Reference: PlayersHandler.Note()
"""
def handle_note(packet, client_pid) do
type = In.decode_byte(packet)
case type do
0 ->
# Send note
name = In.decode_string(packet)
msg = In.decode_string(packet)
fame = In.decode_byte(packet) > 0
_ = In.decode_int(packet) # unknown
cash_id = In.decode_long(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, _char_state} ->
# TODO: Validate item exists in cash inventory
# TODO: Send note to recipient
Logger.debug("Send note to #{name}: #{msg}, fame: #{fame}, cash_id: #{cash_id}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to send note: #{inspect(reason)}")
end
1 ->
# Delete notes
num = In.decode_byte(packet)
_ = In.decode_short(packet) # skip 2
notes_to_delete = Enum.map(1..num, fn _ ->
id = In.decode_int(packet)
fame_delete = In.decode_byte(packet) > 0
{id, fame_delete}
end)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, _char_state} ->
# TODO: Delete notes from database
Logger.debug("Delete notes: #{inspect(notes_to_delete)}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to delete notes: #{inspect(reason)}")
end
_ ->
Logger.warning("Unhandled note action: #{type}")
end
end
# ============================================================================
# Fame System
# ============================================================================
@doc """
Handles giving fame (CP_GIVE_FAME / 0x73).
Reference: PlayersHandler.GiveFame()
"""
def handle_give_fame(packet, client_pid) do
target_id = In.decode_int(packet)
mode = In.decode_byte(packet) # 0 = down, 1 = up
fame_change = if mode == 0, do: -1, else: 1
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate target exists on map
# TODO: Check target is not self
# TODO: Check character level >= 15
# TODO: Check fame cooldown
# TODO: Apply fame change
# TODO: Send response packets
Logger.debug("Give fame: #{fame_change} to #{target_id}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to give fame: #{inspect(reason)}")
end
end
# ============================================================================
# Door Handlers
# ============================================================================
@doc """
Handles door usage (CP_USE_DOOR / 0xAF).
Mystic Door (Priest skill) - warp to town or back.
Reference: PlayersHandler.UseDoor()
"""
def handle_use_door(packet, client_pid) do
oid = In.decode_int(packet)
mode = In.decode_byte(packet) == 0 # 0 = target to town, 1 = town to target
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Find door by owner ID
# TODO: Validate door is active
# TODO: Warp character to appropriate destination
Logger.debug("Use door: OID #{oid}, mode #{mode}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to use door: #{inspect(reason)}")
end
end
@doc """
Handles mechanic door usage (CP_USE_MECH_DOOR / 0xB0).
Mechanic teleport doors.
Reference: PlayersHandler.UseMechDoor()
"""
def handle_use_mech_door(packet, client_pid) do
oid = In.decode_int(packet)
pos_x = In.decode_short(packet)
pos_y = In.decode_short(packet)
mode = In.decode_byte(packet) # door ID
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# Send enable actions
send(client_pid, {:send_packet, Packets.enable_actions()})
# TODO: Find mechanic door by owner ID and door ID
# TODO: Move character to position
Logger.debug("Use mech door: OID #{oid}, pos (#{pos_x}, #{pos_y}), mode #{mode}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to use mech door: #{inspect(reason)}")
end
end
# ============================================================================
# Transformation
# ============================================================================
@doc """
Handles player transformation (CP_TRANSFORM_PLAYER / 0xD2).
Item-based transformations (e.g., 2212000 - prank item).
Reference: PlayersHandler.TransformPlayer()
"""
def handle_transform_player(packet, client_pid) do
tick = In.decode_int(packet)
slot = In.decode_short(packet)
item_id = In.decode_int(packet)
target_name = In.decode_string(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate item exists in inventory
# TODO: Find target by name
# TODO: Apply transformation effect
# TODO: Consume item
Logger.debug("Transform player: item #{item_id}, slot #{slot}, target #{target_name}, tick #{tick}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to transform player: #{inspect(reason)}")
end
end
# ============================================================================
# Reactor Handlers
# ============================================================================
@doc """
Handles reactor hit (CP_DAMAGE_REACTOR / 0x10F).
Reference: PlayersHandler.HitReactor()
"""
def handle_hit_reactor(packet, client_pid) do
oid = In.decode_int(packet)
char_pos = In.decode_int(packet)
stance = In.decode_short(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Get reactor from map
# TODO: Validate reactor is alive
# TODO: Hit reactor with damage
Logger.debug("Hit reactor: OID #{oid}, char_pos #{char_pos}, stance #{stance}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to hit reactor: #{inspect(reason)}")
end
end
@doc """
Handles reactor touch (CP_TOUCH_REACTOR / 0x110).
Reference: PlayersHandler.TouchReactor()
"""
def handle_touch_reactor(packet, client_pid) do
oid = In.decode_int(packet)
touched = if byte_size(packet.data) == 0, do: true, else: In.decode_byte(packet) > 0
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Get reactor from map
# TODO: Handle touch based on reactor type
# TODO: Check required items for certain reactors
Logger.debug("Touch reactor: OID #{oid}, touched #{touched}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to touch reactor: #{inspect(reason)}")
end
end
# ============================================================================
# Event Handlers
# ============================================================================
@doc """
Handles coconut hit (CP_COCONUT / 0x11B).
Coconut event / Coke Play event.
Reference: PlayersHandler.hitCoconut()
"""
def handle_hit_coconut(packet, client_pid) do
coconut_id = In.decode_short(packet)
# Unknown bytes follow
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Get coconut event for channel
# TODO: Validate coconut can be hit
# TODO: Process hit (falling, bomb, points)
Logger.debug("Hit coconut: ID #{coconut_id}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to hit coconut: #{inspect(reason)}")
end
end
# ============================================================================
# Follow System
# ============================================================================
@doc """
Handles follow request (CP_FOLLOW_REQUEST / 0x8E).
Reference: PlayersHandler.FollowRequest()
"""
def handle_follow_request(packet, client_pid) do
target_id = In.decode_int(packet)
follow_mode = In.decode_byte(packet) > 0
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Find target on map
# TODO: Check distance
# TODO: Send follow request
Logger.debug("Follow request: target #{target_id}, mode #{follow_mode}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle follow request: #{inspect(reason)}")
end
end
@doc """
Handles follow reply (CP_FOLLOW_REPLY / 0x91).
Reference: PlayersHandler.FollowReply()
"""
def handle_follow_reply(packet, client_pid) do
target_id = In.decode_int(packet)
accepted = In.decode_byte(packet) > 0
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate follow request exists
# TODO: Set follow state for both players
# TODO: Broadcast follow effect
Logger.debug("Follow reply: target #{target_id}, accepted #{accepted}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle follow reply: #{inspect(reason)}")
end
end
# ============================================================================
# Marriage System
# ============================================================================
@doc """
Handles ring/marriage actions (CP_RING_ACTION / 0xB5).
Modes:
- 0: Propose (DoRing)
- 1: Cancel proposal
- 2: Accept/Deny proposal
- 3: Drop ring (ETC only)
Reference: PlayersHandler.RingAction(), PlayersHandler.DoRing()
"""
def handle_ring_action(packet, client_pid) do
mode = In.decode_byte(packet)
case mode do
0 ->
# Propose
name = In.decode_string(packet)
item_id = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, _char_state} ->
# TODO: Validate character is not married
# TODO: Validate target exists
# TODO: Validate has ring box item
# TODO: Send proposal
Logger.debug("Marriage proposal to #{name} with item #{item_id}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to propose: #{inspect(reason)}")
end
1 ->
# Cancel proposal
case Character.get_state_by_client(client_pid) do
{:ok, character_id, _char_state} ->
# TODO: Cancel pending proposal
Logger.debug("Cancel marriage proposal, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to cancel proposal: #{inspect(reason)}")
end
2 ->
# Accept/Deny
accepted = In.decode_byte(packet) > 0
name = In.decode_string(packet)
id = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, _char_state} ->
# TODO: Validate proposal exists
# TODO: If accepted, create rings for both
# TODO: Update marriage IDs
Logger.debug("Marriage reply: #{accepted} to #{name} (#{id}), character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to reply to proposal: #{inspect(reason)}")
end
3 ->
# Drop ring
item_id = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, _char_state} ->
# TODO: Validate ring is ETC type
# TODO: Drop ring from inventory
Logger.debug("Drop ring #{item_id}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to drop ring: #{inspect(reason)}")
end
_ ->
Logger.warning("Unhandled ring action mode: #{mode}")
end
end
# ============================================================================
# Solomon/Gachapon Systems
# ============================================================================
@doc """
Handles Solomon's books (CP_SOLOMON / 0x8C).
EXP books for level 50 and below.
Reference: PlayersHandler.Solomon()
"""
def handle_solomon(packet, client_pid) do
tick = In.decode_int(packet)
slot = In.decode_short(packet)
item_id = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate character level <= 50
# TODO: Validate has gach exp available
# TODO: Get EXP from item
# TODO: Add gach EXP
# TODO: Remove item
# Send enable actions
send(client_pid, {:send_packet, Packets.enable_actions()})
Logger.debug("Solomon: item #{item_id}, slot #{slot}, tick #{tick}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to use Solomon book: #{inspect(reason)}")
end
end
@doc """
Handles Gachapon EXP claim (CP_GACH_EXP / 0x8D).
Reference: PlayersHandler.GachExp()
"""
def handle_gach_exp(packet, client_pid) do
tick = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Check gach EXP > 0
# TODO: Gain EXP with quest rate
# TODO: Reset gach EXP
# Send enable actions
send(client_pid, {:send_packet, Packets.enable_actions()})
Logger.debug("Gach EXP claim: tick #{tick}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to claim gach EXP: #{inspect(reason)}")
end
end
# ============================================================================
# Reporting
# ============================================================================
@doc """
Handles player report (CP_REPORT / 0x94).
Report types: BOT, HACK, AD, HARASS, etc.
Reference: PlayersHandler.Report()
"""
def handle_report(packet, client_pid) do
# Format varies by server type (GMS/non-GMS)
report_type = In.decode_byte(packet)
target_name = In.decode_string(packet)
# Additional data may follow
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate target exists
# TODO: Check report cooldown (2 hours)
# TODO: Log report
# TODO: Send to Discord if configured
Logger.debug("Report: type #{report_type}, target #{target_name}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle report: #{inspect(reason)}")
end
end
# ============================================================================
# Monster Book
# ============================================================================
@doc """
Handles monster book info request (CP_GET_BOOK_INFO / 0x7FFA).
Reference: PlayersHandler.MonsterBookInfoRequest()
"""
def handle_monster_book_info(packet, client_pid) do
_ = In.decode_int(packet) # unknown
target_id = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Find target player
# TODO: Get monster book info
# TODO: Send info packet
# Send enable actions
send(client_pid, {:send_packet, Packets.enable_actions()})
Logger.debug("Monster book info request: target #{target_id}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to get monster book info: #{inspect(reason)}")
end
end
@doc """
Handles card set change (CP_CHANGE_SET / 0x7FFE).
Reference: PlayersHandler.ChangeSet()
"""
def handle_change_set(packet, client_pid) do
set_id = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate set exists
# TODO: Change active card set
# TODO: Apply book effects
Logger.debug("Change card set: #{set_id}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to change card set: #{inspect(reason)}")
end
end
# ============================================================================
# PVP Handlers
# ============================================================================
@doc """
Handles enter PVP (CP_ENTER_PVP / 0x26).
Reference: PlayersHandler.EnterPVP()
"""
def handle_enter_pvp(packet, client_pid) do
tick = In.decode_int(packet)
_ = In.decode_byte(packet) # skip
type = In.decode_byte(packet)
level = In.decode_byte(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate not in party
# TODO: Validate level range
# TODO: Get PVP event manager
# TODO: Register player for PVP
Logger.debug("Enter PVP: type #{type}, level #{level}, tick #{tick}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to enter PVP: #{inspect(reason)}")
end
end
@doc """
Handles PVP respawn (CP_PVP_RESPAWN / 0x9D).
Reference: PlayersHandler.RespawnPVP()
"""
def handle_respawn_pvp(packet, client_pid) do
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Check player is dead and in PVP
# TODO: Heal player
# TODO: Clear cooldowns
# TODO: Warp to spawn point
# TODO: Send score packet
Logger.debug("PVP respawn: character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to respawn in PVP: #{inspect(reason)}")
end
end
@doc """
Handles leave PVP (CP_LEAVE_PVP / 0x29).
Reference: PlayersHandler.LeavePVP()
"""
def handle_leave_pvp(packet, client_pid) do
tick = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Calculate battle points/EXP
# TODO: Clear buffs
# TODO: Warp to lobby (960000000)
# TODO: Update stats
Logger.debug("Leave PVP: tick #{tick}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to leave PVP: #{inspect(reason)}")
end
end
@doc """
Handles PVP attack (CP_PVP_ATTACK / 0x35).
Reference: PlayersHandler.AttackPVP()
"""
def handle_attack_pvp(packet, client_pid) do
skill_id = In.decode_int(packet)
# Complex packet structure for attack data
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate in PVP and alive
# TODO: Parse attack data
# TODO: Calculate damage
# TODO: Apply damage to targets
# TODO: Update score
# TODO: Broadcast attack
Logger.debug("PVP attack: skill #{skill_id}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle PVP attack: #{inspect(reason)}")
end
end
end

View File

@@ -0,0 +1,263 @@
defmodule Odinsea.Channel.Handler.Summon do
@moduledoc """
Handles summon-related packets (puppet, dragon, summons).
Ported from: src/handling/channel/handler/SummonHandler.java
## Main Handlers
- handle_move_dragon/2 - Dragon movement
- handle_move_summon/2 - Summon movement
- handle_damage_summon/2 - Summon taking damage
- handle_summon_attack/2 - Summon attacking
- handle_remove_summon/2 - Remove summon
- handle_sub_summon/2 - Summon sub-skill (healing, etc.)
- handle_pvp_summon/2 - PVP summon attack
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Game.{Character, Map}
# ============================================================================
# Dragon Handlers
# ============================================================================
@doc """
Handles dragon movement (CP_MOVE_DRAGON / 0xE7).
Reference: SummonHandler.MoveDragon()
"""
def handle_move_dragon(packet, client_pid) do
# Skip 8 bytes (position data)
_ = In.decode_long(packet)
# Parse movement data
# TODO: Implement full movement parsing
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate dragon exists for character
# TODO: Update dragon position
# TODO: Broadcast movement to other players
Logger.debug("Dragon move: character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle dragon move: #{inspect(reason)}")
end
end
# ============================================================================
# Summon Handlers
# ============================================================================
@doc """
Handles summon movement (CP_MOVE_SUMMON / 0xDF).
Reference: SummonHandler.MoveSummon()
"""
def handle_move_summon(packet, client_pid) do
summon_oid = In.decode_int(packet)
# Skip 8 bytes (start position)
_ = In.decode_long(packet)
# Parse movement data
# TODO: Implement movement parsing
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate summon exists and belongs to character
# TODO: Check summon movement type (skip if STATIONARY)
# TODO: Update summon position
# TODO: Broadcast movement to other players
Logger.debug("Summon move: OID #{summon_oid}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle summon move: #{inspect(reason)}")
end
end
@doc """
Handles summon taking damage (CP_DAMAGE_SUMMON / 0xE1).
Puppet summons can take damage and be destroyed.
Reference: SummonHandler.DamageSummon()
"""
def handle_damage_summon(packet, client_pid) do
unk_byte = In.decode_byte(packet)
damage = In.decode_int(packet)
monster_id_from = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Find puppet summon for character
# TODO: Apply damage to summon HP
# TODO: Broadcast damage packet
# TODO: Remove summon if HP <= 0
Logger.debug("Summon damage: #{damage} from mob #{monster_id_from}, unk #{unk_byte}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle summon damage: #{inspect(reason)}")
end
end
@doc """
Handles summon attack (CP_SUMMON_ATTACK / 0xE0).
Summons attack monsters with their skills.
Reference: SummonHandler.SummonAttack()
"""
def handle_summon_attack(packet, client_pid) do
summon_oid = In.decode_int(packet)
# Skip bytes based on game version
_ = In.decode_long(packet) # tick or unknown
tick = In.decode_int(packet)
_ = In.decode_long(packet) # skip
animation = In.decode_byte(packet)
_ = In.decode_long(packet) # CRC32 skip
mob_count = In.decode_byte(packet)
# Parse attack targets
targets = parse_summon_targets(packet, mob_count)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate summon exists and belongs to character
# TODO: Check attack frequency (anti-cheat)
# TODO: Calculate damage for each target
# TODO: Apply damage to monsters
# TODO: Broadcast attack packet
# TODO: Remove summon if not multi-attack
Logger.debug("Summon attack: OID #{summon_oid}, tick #{tick}, anim #{animation}, targets #{length(targets)}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle summon attack: #{inspect(reason)}")
end
end
@doc """
Handles summon removal (CP_REMOVE_SUMMON / 0xE3).
Player manually removes their summon.
Reference: SummonHandler.RemoveSummon()
"""
def handle_remove_summon(packet, client_pid) do
summon_oid = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate summon exists and belongs to character
# TODO: Check if summon can be removed (not rock/shock)
# TODO: Remove summon from map
# TODO: Broadcast removal packet
# TODO: Cancel summon buff
Logger.debug("Remove summon: OID #{summon_oid}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle remove summon: #{inspect(reason)}")
end
end
@doc """
Handles summon sub-skill (CP_SUB_SUMMON / 0xE2).
Special summon abilities like:
- 35121009: Mech summon extension (spawn additional summons)
- 35111011: Healing
- 1321007: Beholder (heal/buff)
Reference: SummonHandler.SubSummon()
"""
def handle_sub_summon(packet, client_pid) do
summon_oid = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Get summon by OID
# TODO: Check summon cooldown
# TODO: Execute sub-skill based on summon skill ID
# TODO: Apply effects (heal, spawn, buff)
# TODO: Broadcast skill effect
Logger.debug("Sub summon: OID #{summon_oid}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle sub summon: #{inspect(reason)}")
end
end
@doc """
Handles PVP summon attack (CP_PVP_SUMMON / 0xE4).
Summon attacks in PVP mode.
Reference: SummonHandler.SummonPVP()
"""
def handle_pvp_summon(packet, client_pid) do
summon_oid = In.decode_int(packet)
# Parse attack data based on packet length
tick = if byte_size(packet.data) >= 27 do
packet
|> skip_bytes(23)
|> In.decode_int()
else
-1
end
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate player is in PVP
# TODO: Validate summon belongs to character
# TODO: Calculate PVP damage
# TODO: Apply damage to targets
# TODO: Update PVP score
# TODO: Broadcast attack packet
Logger.debug("PVP summon attack: OID #{summon_oid}, tick #{tick}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle PVP summon: #{inspect(reason)}")
end
end
# ============================================================================
# Helper Functions
# ============================================================================
defp parse_summon_targets(packet, count) do
Enum.reduce(1..count, [], fn _, acc ->
mob_oid = In.decode_int(packet)
_ = In.decode_bytes(packet, 18) # skip unknown
damage = In.decode_int(packet)
[{mob_oid, damage} | acc]
end)
|> Enum.reverse()
end
defp skip_bytes(packet, count) do
In.decode_bytes(packet, count)
packet
end
end

View File

@@ -0,0 +1,158 @@
defmodule Odinsea.Channel.Handler.UI do
@moduledoc """
Handles user interface interaction packets.
Ported from: src/handling/channel/handler/UserInterfaceHandler.java
## Main Handlers
- handle_cygnus_summon/2 - Cygnus/Aran first job advancement
- handle_game_poll/2 - In-game poll
- handle_ship_object/2 - Ship/boat object requests
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Game.{Character, Map}
alias Odinsea.Channel.Packets
# ============================================================================
# Job Advancement
# ============================================================================
@doc """
Handles Cygnus/Aran summon NPC request (CP_CYGNUS_SUMMON / 0xC5).
Opens the first job advancement NPC for Cygnus and Aran characters.
- Job 2000 (Aran) → NPC 1202000
- Job 1000 (Cygnus Knight) → NPC 1101008
Reference: UserInterfaceHandler.CygnusSummon_NPCRequest()
"""
def handle_cygnus_summon(_packet, client_pid) do
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
npc_id = case char_state.job do
2000 -> 1202000 # Aran
1000 -> 1101008 # Cygnus Knight
_ -> nil
end
if npc_id do
# TODO: Start NPC script
# NPCScriptManager.getInstance().start(c, npc_id)
Logger.debug("Cygnus/Aran summon NPC: #{npc_id} for character #{character_id}")
else
Logger.debug("Invalid job for Cygnus summon: #{char_state.job}, character #{character_id}")
end
:ok
{:error, reason} ->
Logger.warn("Failed to handle Cygnus summon: #{inspect(reason)}")
end
end
# ============================================================================
# Game Poll
# ============================================================================
@doc """
Handles in-game poll (CP_GAME_POLL / 0xD4).
Player submits response to server poll/questionnaire.
Reference: UserInterfaceHandler.InGame_Poll()
"""
def handle_game_poll(packet, client_pid) do
# tick = In.decode_int(packet)
selection = In.decode_int(packet)
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
# TODO: Validate poll is enabled
# TODO: Validate selection is valid
# TODO: Record poll response
# TODO: Send reply packet
Logger.debug("Game poll response: #{selection}, character #{character_id}")
:ok
{:error, reason} ->
Logger.warn("Failed to handle game poll: #{inspect(reason)}")
end
end
# ============================================================================
# Ship/Boat Objects
# ============================================================================
@doc """
Handles ship object request (CP_SHIP_OBJECT / 0x127).
Client requests ship/boat status for station maps.
Used for boats between continents (Ellinia-Orbis, etc.)
Packet format varies by map:
- BB 00 6C 24 05 06 00 - Ellinia
- BB 00 6E 1C 4E 0E 00 - Leafre
Reference: UserInterfaceHandler.ShipObjectRequest()
"""
def handle_ship_object(packet, client_pid) do
# Map ID is encoded in the packet in various ways
# The full packet structure varies by client version
case Character.get_state_by_client(client_pid) do
{:ok, character_id, char_state} ->
map_id = char_state.map
# Determine ship effect based on map
effect = get_ship_effect(map_id)
# TODO: Check event manager for actual docked status
# Boats/Trains/Geenie/Flight managers
Logger.debug("Ship object request: map #{map_id}, effect #{effect}, character #{character_id}")
# TODO: Send boat packet with effect
# c.sendPacket(MaplePacketCreator.boatPacket(effect))
:ok
{:error, reason} ->
Logger.warn("Failed to handle ship object: #{inspect(reason)}")
end
end
# ============================================================================
# Helper Functions
# ============================================================================
# Returns the ship effect value for a given map
# Effect: 1 = Coming, 3 = Going, 1034 = Balrog
defp get_ship_effect(map_id) do
case map_id do
# Ellinia Station >> Orbis
101000300 -> 3
# Orbis Station >> Ellinia
200000111 -> 3
# Orbis Station >> Ludi
200000121 -> 3
# Ludi Station >> Orbis
220000110 -> 3
# Orbis Station >> Ariant
200000151 -> 3
# Ariant Station >> Orbis
260000100 -> 3
# Leafre Station >> Orbis
240000110 -> 3
# Orbis Station >> Leafre
200000131 -> 3
# During boat rides
200090010 -> 1 # To Orbis
200090000 -> 1 # To Ellinia
_ -> 3 # Default: going
end
end
end

View File

@@ -6,6 +6,7 @@ defmodule Odinsea.Channel.Packets do
alias Odinsea.Net.Packet.Out
alias Odinsea.Net.Opcodes
alias Odinsea.Game.Reactor
@doc """
Sends character information on login.
@@ -298,4 +299,915 @@ defmodule Odinsea.Channel.Packets do
|> Out.encode_string(message)
|> Out.to_data()
end
# ============================================================================
# Monster Packets
# ============================================================================
@doc """
Spawns a monster on the map (LP_MobEnterField).
## Parameters
- monster: Monster.t() struct
- spawn_type: spawn animation type (-1 = normal, -2 = regen, -3 = revive, etc.)
- link: linked mob OID (for multi-mobs)
Reference: MobPacket.spawnMonster()
"""
def spawn_monster(monster, spawn_type \\ -1, link \\ 0) do
Out.new(Opcodes.lp_spawn_monster())
|> Out.encode_int(monster.oid)
|> Out.encode_byte(1) # 1 = Control normal, 5 = Control none
|> Out.encode_int(monster.mob_id)
# Temporary stat encoding (buffs/debuffs)
|> encode_mob_temporary_stat(monster)
# Position
|> Out.encode_short(monster.position.x)
|> Out.encode_short(monster.position.y)
# Move action (bitfield)
|> Out.encode_byte(monster.stance)
# Foothold SN
|> Out.encode_short(monster.fh)
# Origin FH
|> Out.encode_short(monster.fh)
# Spawn type
|> Out.encode_byte(spawn_type)
# Link OID (for spawn_type -3 or >= 0)
|> then(fn packet ->
if spawn_type == -3 or spawn_type >= 0 do
Out.encode_int(packet, link)
else
packet
end
end)
# Carnival team
|> Out.encode_byte(0)
# Aftershock - 8 bytes (0xFF at end for GMS)
|> Out.encode_long(0)
# GMS specific
|> Out.encode_byte(-1)
|> Out.to_data()
end
@doc """
Assigns monster control to a player (LP_MobChangeController).
## Parameters
- monster: Monster.t() struct
- new_spawn: whether this is a new spawn
- aggro: aggro mode (2 = aggro, 1 = normal)
Reference: MobPacket.controlMonster()
"""
def control_monster(monster, new_spawn \\ false, aggro \\ false) do
Out.new(Opcodes.lp_spawn_monster_control())
|> Out.encode_byte(if aggro, do: 2, else: 1)
|> Out.encode_int(monster.oid)
|> Out.encode_byte(1) # 1 = Control normal, 5 = Control none
|> Out.encode_int(monster.mob_id)
# Temporary stat encoding
|> encode_mob_temporary_stat(monster)
# Position
|> Out.encode_short(monster.position.x)
|> Out.encode_short(monster.position.y)
# Move action
|> Out.encode_byte(monster.stance)
# Foothold SN
|> Out.encode_short(monster.fh)
# Origin FH
|> Out.encode_short(monster.fh)
# Spawn type (-4 = fake, -2 = new spawn, -1 = normal)
|> Out.encode_byte(cond do
new_spawn -> -2
true -> -1
end)
# Carnival team
|> Out.encode_byte(0)
# Big bang - another long
|> Out.encode_long(0)
# GMS specific
|> Out.encode_byte(-1)
|> Out.to_data()
end
@doc """
Stops controlling a monster (LP_MobChangeController with byte 0).
Reference: MobPacket.stopControllingMonster()
"""
def stop_controlling_monster(oid) do
Out.new(Opcodes.lp_spawn_monster_control())
|> Out.encode_byte(0)
|> Out.encode_int(oid)
|> Out.to_data()
end
@doc """
Monster movement packet (LP_MobMove).
## Parameters
- oid: monster object ID
- next_attack_possible: whether next attack is possible
- left: facing direction (raw byte from client)
- skill_data: skill data from movement
- move_path: movement path data (binary from client)
Reference: MobPacket.onMove()
"""
def move_monster(oid, next_attack_possible, left, skill_data, move_path) do
Out.new(Opcodes.lp_move_monster())
|> Out.encode_int(oid)
|> Out.encode_byte(0)
|> Out.encode_byte(0)
|> Out.encode_byte(if next_attack_possible, do: 1, else: 0)
|> Out.encode_byte(left)
|> Out.encode_int(skill_data)
|> Out.encode_int(0) # multi target for ball size
|> Out.encode_int(0) # rand time for area attack
# Movement path (raw binary from client)
|> Out.encode_bytes(move_path)
|> Out.to_data()
end
@doc """
Damage monster packet (LP_MobDamaged).
## Parameters
- oid: monster object ID
- damage: damage amount (capped at Integer max)
Reference: MobPacket.damageMonster()
"""
def damage_monster(oid, damage) do
# Cap damage at max int
damage_capped = min(damage, 2_147_483_647)
Out.new(Opcodes.lp_damage_monster())
|> Out.encode_int(oid)
|> Out.encode_byte(0)
|> Out.encode_int(damage_capped)
|> Out.to_data()
end
@doc """
Kill monster packet (LP_MobLeaveField).
## Parameters
- monster: Monster.t() struct
- leave_type: how the mob is leaving (0 = remain hp, 1 = etc, 2 = self destruct, etc.)
Reference: MobPacket.killMonster()
"""
def kill_monster(monster, leave_type \\ 1) do
Out.new(Opcodes.lp_kill_monster())
|> Out.encode_int(monster.oid)
|> Out.encode_byte(leave_type)
# If swallow type, encode swallower character ID
|> then(fn packet ->
if leave_type == 4 do
Out.encode_int(packet, -1)
else
packet
end
end)
|> Out.to_data()
end
@doc """
Show monster HP indicator (LP_MobHPIndicator).
## Parameters
- oid: monster object ID
- hp_percentage: HP percentage (0-100)
Reference: MobPacket.showMonsterHP()
"""
def show_monster_hp(oid, hp_percentage) do
Out.new(Opcodes.lp_show_monster_hp())
|> Out.encode_int(oid)
|> Out.encode_byte(hp_percentage)
|> Out.to_data()
end
@doc """
Show boss HP bar (BOSS_ENV).
## Parameters
- monster: Monster.t() struct
Reference: MobPacket.showBossHP()
"""
def show_boss_hp(monster) do
# Cap HP at max int for display
current_hp = min(monster.hp, 2_147_483_647)
max_hp = min(monster.max_hp, 2_147_483_647)
Out.new(0x9D) # BOSS_ENV opcode
|> Out.encode_byte(5)
|> Out.encode_int(monster.mob_id)
|> Out.encode_int(current_hp)
|> Out.encode_int(max_hp)
|> Out.encode_byte(6) # Tag color (default)
|> Out.encode_byte(5) # Tag bg color (default)
|> Out.to_data()
end
@doc """
Monster control acknowledgment (LP_MobCtrlAck).
## Parameters
- oid: monster object ID
- mob_ctrl_sn: control sequence number
- next_attack_possible: whether next attack is possible
- mp: monster MP
- skill_command: skill command from client
- slv: skill level
Reference: MobPacket.onCtrlAck()
"""
def mob_ctrl_ack(oid, mob_ctrl_sn, next_attack_possible, mp, skill_command, slv) do
Out.new(Opcodes.lp_move_monster_response())
|> Out.encode_int(oid)
|> Out.encode_short(mob_ctrl_sn)
|> Out.encode_byte(if next_attack_possible, do: 1, else: 0)
|> Out.encode_short(mp)
|> Out.encode_byte(skill_command)
|> Out.encode_byte(slv)
|> Out.encode_int(0) # forced attack idx
|> Out.to_data()
end
# ============================================================================
# Reactor Packets
# ============================================================================
@doc """
Spawns a reactor on the map (LP_ReactorEnterField).
## Parameters
- reactor: Reactor.t() struct
Reference: MaplePacketCreator.spawnReactor()
"""
def spawn_reactor(reactor) do
Out.new(Opcodes.lp_reactor_spawn())
|> Out.encode_int(reactor.oid)
|> Out.encode_int(reactor.reactor_id)
|> Out.encode_byte(reactor.state)
|> Out.encode_short(reactor.x)
|> Out.encode_short(reactor.y)
|> Out.encode_byte(reactor.facing_direction)
|> Out.encode_string(reactor.name)
|> Out.to_data()
end
@doc """
Triggers/hits a reactor (LP_ReactorChangeState).
## Parameters
- reactor: Reactor.t() struct
- stance: stance value (usually 0)
Reference: MaplePacketCreator.triggerReactor()
"""
def trigger_reactor(reactor, stance \\ 0) do
# Cap state for herb/vein reactors (100000-200011 range)
state =
if reactor.reactor_id >= 100000 and reactor.reactor_id <= 200011 do
min(reactor.state, 4)
else
reactor.state
end
Out.new(Opcodes.lp_reactor_hit())
|> Out.encode_int(reactor.oid)
|> Out.encode_byte(state)
|> Out.encode_short(reactor.x)
|> Out.encode_short(reactor.y)
|> Out.encode_short(stance)
|> Out.encode_byte(0)
|> Out.encode_byte(0)
|> Out.to_data()
end
@doc """
Destroys/removes a reactor from the map (LP_ReactorLeaveField).
## Parameters
- reactor: Reactor.t() struct
Reference: MaplePacketCreator.destroyReactor()
"""
def destroy_reactor(reactor) do
Out.new(Opcodes.lp_reactor_destroy())
|> Out.encode_int(reactor.oid)
|> Out.encode_byte(reactor.state)
|> Out.encode_short(reactor.x)
|> Out.encode_short(reactor.y)
|> Out.to_data()
end
# ============================================================================
# Helper Functions for Monster Encoding
# ============================================================================
@doc false
defp encode_mob_temporary_stat(packet, monster) do
# For GMS v342, encode changed stats first
packet
|> encode_mob_changed_stats(monster)
# Then encode temporary status effects (buffs/debuffs)
|> encode_mob_status_mask(monster)
end
@doc false
defp encode_mob_changed_stats(packet, _monster) do
# For GMS: encode byte 1 if stats are changed, 0 if not
# For now, assume no changed stats
packet
|> Out.encode_byte(0)
# If changed stats exist, encode:
# - hp (int)
# - mp (int)
# - exp (int)
# - watk (int)
# - matk (int)
# - PDRate (int)
# - MDRate (int)
# - acc (int)
# - eva (int)
# - pushed (int)
# - level (int)
end
@doc false
defp encode_mob_status_mask(packet, monster) do
# Encode status effects (poison, stun, freeze, etc.)
# For now, assume no status effects - send empty mask
# Mask is typically 16 integers (64 bytes) for GMS
packet
|> Out.encode_bytes(<<0::size(16 * 32)-little>>)
# If status effects exist, encode for each effect:
# - nOption (short)
# - rOption (int) - skill ID | skill level << 16
# - tOption (short) - duration / 500
end
# ============================================================================
# Admin/System Packets
# ============================================================================
@doc """
Drop message packet (system message displayed to player).
Types:
- 0 = Notice (blue)
- 1 = Popup (red)
- 2 = Megaphone
- 3 = Super Megaphone
- 4 = Top scrolling message
- 5 = System message (yellow)
- 6 = Quiz
Reference: MaplePacketCreator.dropMessage()
"""
def drop_message(type, message) do
Out.new(Opcodes.lp_blow_weather())
|> Out.encode_int(type)
|> Out.encode_string(message)
|> Out.to_data()
end
@doc """
Server-wide broadcast message.
Reference: MaplePacketCreator.serverMessage()
"""
def server_message(message) do
Out.new(Opcodes.lp_event_msg())
|> Out.encode_byte(1)
|> Out.encode_string(message)
|> Out.to_data()
end
@doc """
Scrolling server message (top of screen banner).
Reference: MaplePacketCreator.serverMessage()
"""
def scrolling_message(message) do
Out.new(Opcodes.lp_event_msg())
|> Out.encode_byte(4)
|> Out.encode_string(message)
|> Out.to_data()
end
@doc """
Screenshot request packet (admin tool).
Sends a session key to the client for screenshot verification.
Reference: ClientPool.getScreenshot()
"""
def screenshot_request(session_key) do
Out.new(Opcodes.lp_screen_msg())
|> Out.encode_long(session_key)
|> Out.to_data()
end
@doc """
Start lie detector on player.
Reference: MaplePacketCreator.sendLieDetector()
"""
def start_lie_detector do
Out.new(Opcodes.lp_lie_detector())
|> Out.encode_byte(1) # Start lie detector
|> Out.to_data()
end
@doc """
Lie detector result packet.
Status:
- 0 = Success
- 1 = Failed (wrong answer)
- 2 = Timeout
- 3 = Error
"""
def lie_detector_result(status) do
Out.new(Opcodes.lp_lie_detector())
|> Out.encode_byte(status)
|> Out.to_data()
end
@doc """
Admin result packet (command acknowledgment).
Reference: MaplePacketCreator.getAdminResult()
"""
def admin_result(success, message) do
Out.new(Opcodes.lp_admin_result())
|> Out.encode_byte(if success, do: 1, else: 0)
|> Out.encode_string(message)
|> Out.to_data()
end
@doc """
Force disconnect packet (kick player).
Reference: MaplePacketCreator.getForcedDisconnect()
"""
def force_disconnect(reason \\ 0) do
Out.new(Opcodes.lp_forced_stat_ex())
|> Out.encode_byte(reason)
|> Out.to_data()
end
# ============================================================================
# Drop Packets
# ============================================================================
@doc """
Spawns a drop on the map (LP_DropItemFromMapObject).
## Parameters
- drop: Drop.t() struct
- source_position: Position where drop originated (monster/player position)
- animation: Animation type
- 1 = animation (drop falls from source)
- 2 = no animation (instant spawn)
- 3 = spawn disappearing item [Fade]
- 4 = spawn disappearing item
- delay: Delay before drop appears (in ms)
Reference: MaplePacketCreator.dropItemFromMapObject()
"""
def spawn_drop(drop, source_position \\ nil, animation \\ 1, delay \\ 0) do
source_pos = source_position || drop.position
packet = Out.new(Opcodes.lp_drop_item_from_mapobject())
|> Out.encode_byte(animation)
|> Out.encode_int(drop.oid)
|> Out.encode_byte(if drop.meso > 0, do: 1, else: 0) # 1 = mesos, 0 = item
|> Out.encode_int(Drop.display_id(drop))
|> Out.encode_int(drop.owner_id)
|> Out.encode_byte(drop.drop_type)
|> Out.encode_short(drop.position.x)
|> Out.encode_short(drop.position.y)
|> Out.encode_int(0) # Unknown
# If animation != 2, encode source position
packet = if animation != 2 do
packet
|> Out.encode_short(source_pos.x)
|> Out.encode_short(source_pos.y)
|> Out.encode_short(delay)
else
packet
end
# If not meso, encode expiration time
packet = if drop.meso == 0 do
# Expiration time - for now, send 0 (no expiration)
# In full implementation, this would be the item's expiration timestamp
Out.encode_long(packet, 0)
else
packet
end
# Pet pickup byte
# 0 = player can pick up, 1 = pet can pick up
packet
|> Out.encode_short(if drop.player_drop, do: 0, else: 1)
|> Out.to_data()
end
@doc """
Removes a drop from the map (LP_RemoveItemFromMap).
## Parameters
- oid: Drop object ID
- animation: Removal animation
- 0 = Expire/fade out
- 1 = Without animation
- 2 = Pickup animation
- 4 = Explode animation
- 5 = Pet pickup
- character_id: Character ID performing the action (for pickup animations)
- slot: Pet slot (for pet pickup animation)
Reference: MaplePacketCreator.removeItemFromMap()
"""
def remove_drop(oid, animation \\ 1, character_id \\ 0, slot \\ 0) do
packet = Out.new(Opcodes.lp_remove_item_from_map())
|> Out.encode_byte(animation)
|> Out.encode_int(oid)
# If animation >= 2, encode character ID
packet = if animation >= 2 do
Out.encode_int(packet, character_id)
else
packet
end
# If animation == 5 (pet pickup), encode slot
packet = if animation == 5 do
Out.encode_int(packet, slot)
else
packet
end
Out.to_data(packet)
end
@doc """
Spawns multiple drops at once (for explosive drops).
Broadcasts a spawn packet for each drop with minimal animation.
"""
def spawn_drops(drops, source_position, animation \\ 2) do
Enum.map(drops, fn drop ->
spawn_drop(drop, source_position, animation)
end)
end
@doc """
Sends existing drops to a joining player.
"""
def send_existing_drops(client_pid, drops) do
Enum.each(drops, fn drop ->
# Only send drops that haven't been picked up
if not drop.picked_up do
packet = spawn_drop(drop, nil, 2) # No animation
send(client_pid, {:send_packet, packet})
end
end)
end
# ============================================================================
# Pet Packets
# ============================================================================
@doc """
Updates pet information in inventory (ModifyInventoryItem).
Ported from PetPacket.updatePet()
"""
def update_pet(pet, item \\ nil, active \\ true) do
# Encode inventory update with pet info
Out.new(Opcodes.lp_modify_inventory_item())
|> Out.encode_byte(0) # Inventory mode
|> Out.encode_byte(2) # Update count
|> Out.encode_byte(3) # Mode type
|> Out.encode_byte(5) # Inventory type (CASH)
|> Out.encode_short(pet.inventory_position)
|> Out.encode_byte(0)
|> Out.encode_byte(5) # Inventory type (CASH)
|> Out.encode_short(pet.inventory_position)
|> Out.encode_byte(3) # Item type for pet
|> Out.encode_int(pet.pet_item_id)
|> Out.encode_byte(1)
|> Out.encode_long(pet.unique_id)
|> encode_pet_item_info(pet, item, active)
|> Out.to_data()
end
@doc """
Spawns a pet on the map (LP_SpawnPet).
Ported from PetPacket.showPet()
## Parameters
- character_id: Owner's character ID
- pet: Pet.t() struct
- remove: If true, removes the pet
- hunger: If true and removing, shows hunger message
"""
def spawn_pet(character_id, pet, remove \\ false, hunger \\ false) do
packet = Out.new(Opcodes.lp_spawn_pet())
|> Out.encode_int(character_id)
# Encode pet slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, pet.summoned)
else
Out.encode_int(packet, pet.summoned)
end
if remove do
packet
|> Out.encode_byte(0) # Remove flag
|> Out.encode_byte(if hunger, do: 1, else: 0)
else
packet
|> Out.encode_byte(1) # Show flag
|> Out.encode_byte(0) # Unknown flag
|> Out.encode_int(pet.pet_item_id)
|> Out.encode_string(pet.name)
|> Out.encode_long(pet.unique_id)
|> Out.encode_short(pet.position.x)
|> Out.encode_short(pet.position.y - 20) # Offset Y slightly
|> Out.encode_byte(pet.stance)
|> Out.encode_int(pet.position.fh)
end
|> Out.to_data()
end
@doc """
Removes a pet from the map.
Ported from PetPacket.removePet()
"""
def remove_pet(character_id, slot) do
packet = Out.new(Opcodes.lp_spawn_pet())
|> Out.encode_int(character_id)
# Encode slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, slot)
else
Out.encode_int(packet, slot)
end
packet
|> Out.encode_short(0) # Remove flag
|> Out.to_data()
end
@doc """
Moves a pet on the map (LP_PetMove).
Ported from PetPacket.movePet()
"""
def move_pet(character_id, pet_unique_id, slot, movement_data) do
packet = Out.new(Opcodes.lp_move_pet())
|> Out.encode_int(character_id)
# Encode slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, slot)
else
Out.encode_int(packet, slot)
end
packet
|> Out.encode_long(pet_unique_id)
|> Out.encode_bytes(movement_data)
|> Out.to_data()
end
@doc """
Pet chat packet (LP_PetChat).
Ported from PetPacket.petChat()
"""
def pet_chat(character_id, slot, command, text) do
packet = Out.new(Opcodes.lp_pet_chat())
|> Out.encode_int(character_id)
# Encode slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, slot)
else
Out.encode_int(packet, slot)
end
packet
|> Out.encode_short(command)
|> Out.encode_string(text)
|> Out.encode_byte(0) # hasQuoteRing
|> Out.to_data()
end
@doc """
Pet command response (LP_PetCommand).
Ported from PetPacket.commandResponse()
## Parameters
- character_id: Owner's character ID
- slot: Pet slot (0-2)
- command: Command ID that was executed
- success: Whether the command succeeded
- food: Whether this was a food command
"""
def pet_command_response(character_id, slot, command, success, food) do
packet = Out.new(Opcodes.lp_pet_command())
|> Out.encode_int(character_id)
# Encode slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, slot)
else
Out.encode_int(packet, slot)
end
# Command encoding differs for food
packet = if command == 1 do
Out.encode_byte(packet, 1)
else
Out.encode_byte(packet, 0)
end
packet = Out.encode_byte(packet, command)
packet = if command == 1 do
# Food command
Out.encode_byte(packet, 0)
else
# Regular command
Out.encode_short(packet, if(success, do: 1, else: 0))
end
Out.to_data(packet)
end
@doc """
Shows own pet level up effect (LP_ShowItemGainInChat).
Ported from PetPacket.showOwnPetLevelUp()
"""
def show_own_pet_level_up(slot) do
Out.new(Opcodes.lp_show_item_gain_inchat())
|> Out.encode_byte(6) # Type: pet level up
|> Out.encode_byte(0)
|> Out.encode_int(slot)
|> Out.to_data()
end
@doc """
Shows pet level up effect to other players (LP_ShowForeignEffect).
Ported from PetPacket.showPetLevelUp()
"""
def show_pet_level_up(character_id, slot) do
Out.new(Opcodes.lp_show_foreign_effect())
|> Out.encode_int(character_id)
|> Out.encode_byte(6) # Type: pet level up
|> Out.encode_byte(0)
|> Out.encode_int(slot)
|> Out.to_data()
end
@doc """
Pet name change/update (LP_PetNameChanged).
Ported from PetPacket.showPetUpdate()
"""
def pet_name_change(character_id, pet_unique_id, slot) do
packet = Out.new(Opcodes.lp_pet_namechange())
|> Out.encode_int(character_id)
# Encode slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, slot)
else
Out.encode_int(packet, slot)
end
packet
|> Out.encode_long(pet_unique_id)
|> Out.encode_byte(0) # Unknown
|> Out.to_data()
end
@doc """
Pet stat update (UPDATE_STATS with PET flag).
Ported from PetPacket.petStatUpdate()
"""
def pet_stat_update(pets) do
# Filter summoned pets
summoned_pets = Enum.filter(pets, fn pet -> pet.summoned > 0 end)
packet = Out.new(Opcodes.lp_update_stats())
|> Out.encode_byte(0) # Reset mask flag
# Encode stat mask based on GMS mode
pet_stat_value = if Odinsea.Constants.Game.gms?(), do: 0x200000, else: 0x200000
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_long(packet, pet_stat_value)
else
Out.encode_int(packet, pet_stat_value)
end
# Encode summoned pet unique IDs (up to 3)
packet = Enum.reduce(summoned_pets, packet, fn pet, p ->
Out.encode_long(p, pet.unique_id)
end)
# Fill remaining slots with empty
remaining = 3 - length(summoned_pets)
packet = Enum.reduce(1..remaining, packet, fn _, p ->
Out.encode_long(p, 0)
end)
packet
|> Out.encode_byte(0)
|> Out.encode_short(0)
|> Out.to_data()
end
# ============================================================================
# Pet Encoding Helpers
# ============================================================================
@doc false
defp encode_pet_item_info(packet, pet, item, active) do
# Encode full pet item info structure
# This includes pet stats, name, level, closeness, fullness, flags, etc.
packet = packet
|> Out.encode_string(pet.name)
|> Out.encode_byte(pet.level)
|> Out.encode_short(pet.closeness)
|> Out.encode_byte(pet.fullness)
|> Out.encode_long(if active, do: pet.unique_id, else: 0)
|> Out.encode_short(pet.inventory_position)
|> Out.encode_int(pet.seconds_left)
|> Out.encode_short(pet.flags)
|> Out.encode_int(pet.pet_item_id)
# Add equip info (3 slots: hat, saddle, decor)
# For now, encode empty slots
packet
|> Out.encode_long(0) # Pet equip slot 1
|> Out.encode_long(0) # Pet equip slot 2
|> Out.encode_long(0) # Pet equip slot 3
end
@doc """
Encodes pet info for character spawn packet.
Called when spawning a player with their active pets.
"""
def encode_spawn_pets(packet, pets) do
# Get summoned pets in slot order
summoned_pets = pets
|> Enum.filter(fn pet -> pet.summoned > 0 end)
|> Enum.sort_by(fn pet -> pet.summoned end)
# Encode 3 pet slots (0 = no pet)
{pet1, rest1} = List.pop_at(summoned_pets, 0, nil)
{pet2, rest2} = List.pop_at(rest1, 0, nil)
{pet3, _} = List.pop_at(rest2, 0, nil)
packet
|> encode_single_pet_for_spawn(pet1)
|> encode_single_pet_for_spawn(pet2)
|> encode_single_pet_for_spawn(pet3)
end
defp encode_single_pet_for_spawn(packet, nil) do
packet
|> Out.encode_byte(0) # No pet
end
defp encode_single_pet_for_spawn(packet, pet) do
packet
|> Out.encode_byte(1) # Has pet
|> Out.encode_int(pet.pet_item_id)
|> Out.encode_string(pet.name)
|> Out.encode_long(pet.unique_id)
|> Out.encode_short(pet.position.x)
|> Out.encode_short(pet.position.y)
|> Out.encode_byte(pet.stance)
|> Out.encode_int(pet.position.fh)
|> Out.encode_byte(pet.level)
|> Out.encode_short(pet.closeness)
|> Out.encode_byte(pet.fullness)
|> Out.encode_short(pet.flags)
|> Out.encode_int(0) # Pet equip item ID 1
|> Out.encode_int(0) # Pet equip item ID 2
|> Out.encode_int(0) # Pet equip item ID 3
|> Out.encode_int(0) # Pet equip item ID 4
end
end

View File

@@ -35,6 +35,25 @@ defmodule Odinsea.Channel.Players do
:ok
end
@doc """
Adds a player with full character state.
Extracts relevant fields from character state.
"""
def add_character(character_id, %{} = character_state) do
player_data = %{
character_id: character_id,
name: character_state.name,
map_id: character_state.map_id,
level: character_state.level,
job: character_state.job,
gm: Map.get(character_state, :gm, 0),
client_pid: character_state.client_pid
}
:ets.insert(@table, {character_id, player_data})
:ok
end
@doc """
Removes a player from the channel storage.
"""