kimi gone wild
This commit is contained in:
@@ -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
|
||||
|
||||
282
lib/odinsea/channel/handler/alliance.ex
Normal file
282
lib/odinsea/channel/handler/alliance.ex
Normal 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
|
||||
254
lib/odinsea/channel/handler/bbs.ex
Normal file
254
lib/odinsea/channel/handler/bbs.ex
Normal 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
|
||||
418
lib/odinsea/channel/handler/buddy.ex
Normal file
418
lib/odinsea/channel/handler/buddy.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
224
lib/odinsea/channel/handler/duey.ex
Normal file
224
lib/odinsea/channel/handler/duey.ex
Normal 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
|
||||
566
lib/odinsea/channel/handler/guild.ex
Normal file
566
lib/odinsea/channel/handler/guild.ex
Normal 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
|
||||
641
lib/odinsea/channel/handler/item_maker.ex
Normal file
641
lib/odinsea/channel/handler/item_maker.ex
Normal 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
|
||||
356
lib/odinsea/channel/handler/mob.ex
Normal file
356
lib/odinsea/channel/handler/mob.ex
Normal 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
|
||||
181
lib/odinsea/channel/handler/monster_carnival.ex
Normal file
181
lib/odinsea/channel/handler/monster_carnival.ex
Normal 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
|
||||
510
lib/odinsea/channel/handler/party.ex
Normal file
510
lib/odinsea/channel/handler/party.ex
Normal 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
|
||||
528
lib/odinsea/channel/handler/pet.ex
Normal file
528
lib/odinsea/channel/handler/pet.ex
Normal 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
|
||||
@@ -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)}")
|
||||
|
||||
973
lib/odinsea/channel/handler/player_shop.ex
Normal file
973
lib/odinsea/channel/handler/player_shop.ex
Normal 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
|
||||
674
lib/odinsea/channel/handler/players.ex
Normal file
674
lib/odinsea/channel/handler/players.ex
Normal 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
|
||||
263
lib/odinsea/channel/handler/summon.ex
Normal file
263
lib/odinsea/channel/handler/summon.ex
Normal 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
|
||||
158
lib/odinsea/channel/handler/ui.ex
Normal file
158
lib/odinsea/channel/handler/ui.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user