Files
odinsea-elixir/lib/odinsea/channel/packets.ex
2026-02-14 23:58:01 -07:00

2041 lines
57 KiB
Elixir

defmodule Odinsea.Channel.Packets do
@moduledoc """
Channel server packet builders.
Ported from Java tools.packet.MaplePacketCreator (relevant parts)
"""
alias Odinsea.Net.Packet.Out
alias Odinsea.Net.Opcodes
alias Odinsea.Game.Reactor
# =============================================================================
# Character Info & Field Entry
# =============================================================================
@doc """
Sends character information on login.
Ported from MaplePacketCreator.getCharInfo()
"""
def get_char_info(character, restored_buffs \\ []) do
packet = Out.new(Opcodes.lp_set_field())
# GMS v342 specific header
packet = if Odinsea.Constants.Game.gms?() do
packet
|> Out.encode_short(2)
|> Out.encode_long(1)
|> Out.encode_long(2)
else
packet
|> Out.encode_int(character.channel - 1)
|> Out.encode_int(0)
end
# Field key and character data flag
packet = packet
|> Out.encode_byte(0) # Field key
|> Out.encode_int(0) # Unknown
|> Out.encode_byte(1) # Character data flag
|> Out.encode_short(0) # Notification info count
# Random seeds for anti-cheat
packet = packet
|> Out.encode_int(:rand.uniform(2_147_483_647))
|> Out.encode_int(:rand.uniform(2_147_483_647))
|> Out.encode_int(:rand.uniform(2_147_483_647))
# Full character info
packet = encode_character_info(packet, character)
# GMS padding
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_bytes(packet, <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>)
else
packet
end
# Server timestamp and additional data
packet
|> Out.encode_long(korean_timestamp(System.currentTimeMillis()))
|> Out.encode_int(50) # Unknown
|> Out.encode_byte(0) # Unknown
|> Out.encode_byte(1) # Unknown
|> Out.to_data()
end
@doc """
Warp to map packet.
Ported from MaplePacketCreator.getWarpToMap()
"""
def warp_to_map(map_id, spawn_point, character) do
packet = Out.new(Opcodes.lp_set_field())
# GMS v342 specific header
packet = if Odinsea.Constants.Game.gms?() do
packet
|> Out.encode_short(2)
|> Out.encode_long(1)
|> Out.encode_long(2)
else
packet
end
packet = packet
|> Out.encode_long(character.channel - 1)
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, 0)
else
packet
end
packet
|> Out.encode_long(0) # Field key
|> Out.encode_byte(0) # Not character data
|> Out.encode_int(map_id)
|> Out.encode_byte(spawn_point)
|> Out.encode_int(character.hp)
|> Out.encode_byte(0) # Unknown
|> Out.encode_long(korean_timestamp(System.currentTimeMillis()))
|> Out.encode_int(50)
|> Out.encode_byte(0)
|> Out.encode_byte(if is_resist?(character.job), do: 0, else: 1)
|> Out.to_data()
end
@doc """
Enables cash shop access.
"""
def enable_cash_shop do
Out.new(Opcodes.lp_set_cash_shop_opened())
|> Out.encode_byte(1)
|> Out.to_data()
end
@doc """
Server blocked message.
"""
def server_blocked(reason) do
Out.new(Opcodes.lp_server_blocked())
|> Out.encode_byte(reason)
|> Out.to_data()
end
@doc """
Enable client actions.
"""
def enable_actions do
Out.new(Opcodes.lp_enable_action())
|> Out.encode_byte(0)
|> Out.to_data()
end
@doc """
Channel change command.
"""
def get_channel_change(ip, port, character_id) do
ip_parts = parse_ip(ip)
Out.new(Opcodes.lp_migrate_command())
|> Out.encode_short(0) # Not cash shop
|> encode_ip(ip_parts)
|> Out.encode_short(port)
|> Out.encode_int(character_id)
|> Out.encode_bytes(<<0, 0>>)
|> Out.to_data()
end
defp parse_ip(host) when is_binary(host) do
case String.split(host, ".") do
[a, b, c, d] ->
{
String.to_integer(a),
String.to_integer(b),
String.to_integer(c),
String.to_integer(d)
}
_ ->
{127, 0, 0, 1}
end
end
defp encode_ip(packet, {a, b, c, d}) do
packet
|> Out.encode_byte(a)
|> Out.encode_byte(b)
|> Out.encode_byte(c)
|> Out.encode_byte(d)
end
# =============================================================================
# Player Spawn/Remove
# =============================================================================
@doc """
Spawns a player on the map.
Ported from MaplePacketCreator.spawnPlayerMapobject()
"""
def spawn_player(oid, character_state) do
packet = Out.new(Opcodes.lp_spawn_player())
|> Out.encode_int(oid)
# Damage skin (custom client feature)
packet = if Odinsea.Constants.Game.custom_client?() do
Out.encode_int(packet, character_state.damage_skin || 0)
else
packet
end
packet = packet
|> Out.encode_byte(character_state.level)
|> Out.encode_string(character_state.name)
# Ultimate Explorer name
|> Out.encode_string(character_state.ultimate_explorer || "")
# Guild info
packet = encode_guild_info(packet, character_state.guild)
# Buff mask encoding
packet = encode_player_buff_mask(packet, character_state.buffs || [])
packet = packet
# ITEM_EFFECT
|> Out.encode_int(character_state.item_effect || 0)
# CHAIR
|> Out.encode_int(character_state.chair || 0)
# Position
|> Out.encode_short(character_state.position.x)
|> Out.encode_short(character_state.position.y)
|> Out.encode_byte(character_state.position.stance)
|> Out.encode_short(character_state.position.foothold)
# Job
|> Out.encode_short(character_state.job)
# Appearance encoding (gender, skin, face, hair + equipment)
packet = encode_appearance(packet, character_state)
packet = packet
# Driver ID / passenger ID (for mounts)
|> Out.encode_int(0)
# Chalkboard text
|> Out.encode_string(character_state.chalkboard || "")
# Ring info (3 ring slots)
packet = encode_ring_info(packet, character_state.rings)
# Marriage ring
packet = packet
|> Out.encode_int(0)
# Mount info
packet = encode_mount(packet, character_state.mount)
packet = packet
# Player shop (none for now)
|> Out.encode_byte(0)
# Admin byte
|> Out.encode_byte(if character_state.is_admin, do: 1, else: 0)
# Pet info
packet = encode_spawn_pets(packet, character_state.pets || [])
packet
# Taming mob (none)
|> Out.encode_int(0)
# Mini game info
|> Out.encode_byte(0)
# Chalkboard flag
|> Out.encode_byte(if character_state.chalkboard, do: 1, else: 0)
# New year cards
|> Out.encode_byte(0)
# Berserk flag
|> Out.encode_byte(if character_state.berserk, do: 1, else: 0)
|> Out.to_data()
end
@doc """
Removes a player from the map.
"""
def remove_player(oid) do
Out.new(Opcodes.lp_remove_player_from_map())
|> Out.encode_int(oid)
|> Out.to_data()
end
@doc """
Updates character look (appearance change).
Ported from MaplePacketCreator.updateCharLook()
"""
def update_char_look(character_id, character_state) do
Out.new(Opcodes.lp_update_char_look())
|> Out.encode_int(character_id)
|> Out.encode_byte(1)
|> encode_appearance(character_state)
|> encode_ring_info(character_state.rings)
|> Out.encode_int(0) # -> charid to follow
|> Out.to_data()
end
# =============================================================================
# Equipment & Appearance Encoding
# =============================================================================
@doc """
Encodes full character appearance including equipment.
Ported from PacketHelper.addCharLook()
"""
def encode_appearance(packet, character) do
packet = packet
|> Out.encode_byte(character.gender)
|> Out.encode_byte(character.skin_color)
|> Out.encode_int(character.face)
|> Out.encode_int(character.hair)
# Equipment encoding
encode_equipment(packet, character.equipment)
end
@doc """
Encodes equipment for character appearance.
Ported from PacketHelper.addCharLook() equipment encoding section.
"""
def encode_equipment(packet, equipment) when is_map(equipment) do
# Separate visible and masked equipment
{visible_equip, masked_equip} = process_equipment_slots(equipment)
# Encode visible equipment (slots 1-99)
packet = Enum.reduce(visible_equip, packet, fn {slot, item_id}, p ->
p
|> Out.encode_byte(slot)
|> Out.encode_int(item_id)
end)
# End of visible items marker
packet = Out.encode_byte(packet, 0xFF)
# Encode masked equipment (overrides visible)
packet = Enum.reduce(masked_equip, packet, fn {slot, item_id}, p ->
p
|> Out.encode_byte(slot)
|> Out.encode_int(item_id)
end)
# End of masked items marker
packet = Out.encode_byte(packet, 0xFF)
# Cash weapon (slot -111)
cash_weapon = Map.get(equipment, -111, 0)
packet = Out.encode_int(packet, cash_weapon)
# Ears (for certain items like elf ears)
packet = Out.encode_int(packet, 0)
packet
end
def encode_equipment(packet, _equipment) do
# Empty equipment - just send end markers
packet
|> Out.encode_byte(0xFF)
|> Out.encode_byte(0xFF)
|> Out.encode_int(0)
|> Out.encode_int(0)
end
defp process_equipment_slots(equipment) do
equipment
|> Enum.reduce({%{}, %{}}, fn {pos, item_id}, {visible, masked} ->
slot = abs(pos)
cond do
# Hidden equipment (not visible)
slot > 127 -> {visible, masked}
# Visible equipment (slots 1-99)
slot < 100 ->
if Map.has_key?(visible, slot) do
# Move existing to masked
{Map.put(visible, slot, item_id), Map.put(masked, slot, visible[slot])}
else
{Map.put(visible, slot, item_id), masked}
end
# Cash equipment (slots 100+, except 111)
slot > 100 and slot != 111 ->
actual_slot = slot - 100
if Map.has_key?(visible, actual_slot) do
{Map.put(visible, actual_slot, item_id), Map.put(masked, actual_slot, visible[actual_slot])}
else
{Map.put(visible, actual_slot, item_id), masked}
end
# Special slots
true -> {visible, masked}
end
end)
end
# =============================================================================
# Buff Encoding
# =============================================================================
@doc """
Encodes player buff mask for spawn packets.
Ported from spawnPlayerMapobject() buff encoding.
"""
def encode_player_buff_mask(packet, buffs) do
# Default buff mask flags
default_mask = 0
# ENERGY_CHARGE | DASH_SPEED | DASH_JUMP | MONSTER_RIDING | SPEED_INFUSION | HOMING_BEACON | DEFAULT_BUFFSTAT
# Build mask array (typically 14-16 integers for GMS v342)
mask_size = if Odinsea.Constants.Game.gms?(), do: 14, else: 14
# Start with default mask in first position
masks = List.duplicate(0, mask_size)
masks = [default_mask | tl(masks)]
# Apply active buffs to mask
masks = Enum.reduce(buffs, masks, fn buff, acc_masks ->
position = buff.stat.position
value = buff.stat.value
List.update_at(acc_masks, mask_size - position, fn existing ->
Bitwise.bor(existing, value)
end)
end)
# Encode all mask integers
packet = Enum.reduce(masks, packet, fn mask, p ->
Out.encode_int(p, mask)
end)
# Encode buff values for special buffs
packet = Enum.reduce(buffs, packet, fn buff, p ->
case buff.stat do
:combo_counter ->
p
|> Out.encode_byte(buff.value)
:weapon_charge ->
p
|> Out.encode_short(buff.value)
|> Out.encode_int(buff.source_id)
:shadow_partner ->
p
|> Out.encode_short(buff.value)
|> Out.encode_int(buff.source_id)
_ -> p
end
end)
# CHAR_MAGIC_SPAWN and special buff data
packet
|> Out.encode_bytes(<<0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00>>)
|> Out.encode_byte(0x01)
|> Out.encode_int(:rand.uniform(2_147_483_647))
|> Out.encode_bytes(<<0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00>>)
|> Out.encode_byte(0x01)
|> Out.encode_int(:rand.uniform(2_147_483_647))
|> Out.encode_bytes(<<0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00>>)
|> Out.encode_byte(0x01)
|> Out.encode_int(:rand.uniform(2_147_483_647))
|> Out.encode_short(0)
|> Out.encode_int(0)
|> Out.encode_int(0)
|> Out.encode_byte(0x01)
|> Out.encode_int(:rand.uniform(2_147_483_647))
|> Out.encode_long(0)
|> Out.encode_byte(0x01)
|> Out.encode_int(:rand.uniform(2_147_483_647))
|> Out.encode_bytes(<<0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00>>)
|> Out.encode_byte(0x01)
|> Out.encode_int(:rand.uniform(2_147_483_647))
|> Out.encode_bytes(<<0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00>>)
|> Out.encode_byte(0x01)
|> Out.encode_int(:rand.uniform(2_147_483_647))
|> Out.encode_short(0)
|> Out.encode_short(0)
end
@doc """
Give buff to player.
Ported from MaplePacketCreator.giveBuff()
"""
def give_buff(buff_id, duration, stat_ups) do
packet = Out.new(Opcodes.lp_give_buff())
# Encode buff mask
packet = encode_buff_mask(packet, stat_ups)
# Encode stat values
packet = Enum.reduce(stat_ups, packet, fn {stat, value}, p ->
if can_stack?(stat) do
p
else
p
|> Out.encode_short(value)
|> Out.encode_int(buff_id)
|> Out.encode_int(duration)
end
end)
packet
|> Out.encode_byte(0)
|> Out.encode_byte(0)
|> Out.encode_byte(0)
|> Out.encode_short(0)
|> Out.encode_byte(4)
|> Out.to_data()
end
@doc """
Cancel buff.
Ported from MaplePacketCreator.onResetTemporaryStat()
"""
def cancel_buff(stat_ups) do
packet = Out.new(Opcodes.lp_cancel_buff())
|> encode_buff_mask(stat_ups)
|> Out.encode_byte(3)
|> Out.encode_byte(1)
|> Out.to_data()
end
@doc """
Give foreign buff (to other players).
Ported from MaplePacketCreator.giveForeignBuff()
"""
def give_foreign_buff(character_id, stat_ups, effect) do
packet = Out.new(Opcodes.lp_give_foreign_buff())
|> Out.encode_int(character_id)
|> encode_buff_mask(stat_ups)
# Encode stat values for special buffs
packet = Enum.reduce(stat_ups, packet, fn {stat, value}, p ->
case stat do
s when s in [:shadow_partner, :mechanic, :dark_aura, :blue_aura, :yellow_aura, :giant_potion, :spirit_link, :repeat_effect, :weapon_charge, :spirit_surge, :morph] ->
p
|> Out.encode_short(value)
|> Out.encode_int(effect.source_id)
:familiar_shadow ->
p
|> Out.encode_int(value)
|> Out.encode_int(effect.char_color)
_ ->
Out.encode_short(p, value)
end
end)
packet
|> Out.encode_short(0)
|> Out.encode_short(0)
|> Out.encode_byte(1)
|> Out.encode_byte(1)
|> Out.to_data()
end
@doc """
Cancel foreign buff.
Ported from MaplePacketCreator.cancelForeignBuff()
"""
def cancel_foreign_buff(character_id, stat_ups) do
Out.new(Opcodes.lp_cancel_foreign_buff())
|> Out.encode_int(character_id)
|> encode_buff_mask(stat_ups)
|> Out.encode_byte(3)
|> Out.encode_byte(1)
|> Out.to_data()
end
defp encode_buff_mask(packet, stat_ups) do
mask_size = if Odinsea.Constants.Game.gms?(), do: 14, else: 14
masks = Enum.reduce(stat_ups, List.duplicate(0, mask_size), fn {stat, _value}, acc ->
position = stat_position(stat)
value = stat_value(stat)
List.update_at(acc, mask_size - position, fn existing ->
Bitwise.bor(existing, value)
end)
end)
Enum.reduce(masks, packet, fn mask, p ->
Out.encode_int(p, mask)
end)
end
defp stat_position(_stat), do: 1
defp stat_value(_stat), do: 1
defp can_stack?(_stat), do: false
# =============================================================================
# Stat Updates
# =============================================================================
@doc """
Update player stats.
Ported from MaplePacketCreator.updatePlayerStats()
"""
def update_stats(stats, item_reaction \\ false, job \\ 0) do
packet = Out.new(Opcodes.lp_update_stats())
|> Out.encode_byte(if item_reaction, do: 1, else: 0)
# Calculate update mask
update_mask = Enum.reduce(stats, 0, fn {stat, _value}, acc ->
Bitwise.bor(acc, stat_value(stat))
end)
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_long(packet, update_mask)
else
Out.encode_int(packet, update_mask)
end
# Encode stat values
packet = Enum.reduce(stats, packet, fn {stat, value}, p ->
encode_stat_value(p, stat, value, job)
end)
packet
|> Out.encode_short(0)
|> Out.to_data()
end
defp encode_stat_value(packet, stat, value, job) do
cond do
stat in [:skin, :face, :hair] ->
Out.encode_int(packet, value)
stat in [:level, :job] ->
Out.encode_byte(packet, value)
stat == :available_sp ->
cond do
is_evan?(job) or is_resist?(job) or is_mercedes?(job) ->
# SP by job level for Evan/Resistance/Mercedes
packet
true ->
Out.encode_short(packet, value)
end
stat in [:hp, :max_hp, :mp, :max_mp, :exp, :meso] ->
Out.encode_int(packet, value)
stat in [:str, :dex, :int, :luk, :remaining_ap] ->
Out.encode_short(packet, value)
true ->
Out.encode_int(packet, value)
end
end
# =============================================================================
# EXP & Level Up
# =============================================================================
@doc """
Show EXP gain from monster.
Ported from MaplePacketCreator.GainEXP_Monster()
"""
def gain_exp_monster(gain, white \\ true, party_bonus \\ 0, class_bonus \\ 0, equipment_bonus \\ 0, premium_bonus \\ 0, bonus_exp \\ 0) do
gain_capped = min(gain, 2_147_483_647)
Out.new(Opcodes.lp_show_status_info())
|> Out.encode_byte(3) # 3 = exp
|> Out.encode_byte(if white, do: 1, else: 0)
|> Out.encode_int(gain_capped)
|> Out.encode_byte(0) # Not in chat
|> Out.encode_int(bonus_exp) # Event Bonus
|> Out.encode_byte(0)
|> Out.encode_byte(0)
|> Out.encode_int(0) # Wedding bonus
|> Out.encode_int(0) # Party ring bonus
|> Out.encode_byte(0)
|> Out.encode_int(party_bonus) # Party size indicator
|> Out.encode_int(equipment_bonus) # Equipment Bonus EXP
|> Out.encode_int(premium_bonus) # Premium bonus EXP
|> Out.encode_int(0) # Rainbow Week Bonus EXP
|> Out.encode_int(class_bonus) # Class bonus EXP
|> Out.encode_int(0) # Summer week
|> Out.to_data()
end
@doc """
Show EXP gain (other sources).
Ported from MaplePacketCreator.GainEXP_Others()
"""
def gain_exp_others(gain, in_chat \\ false, white \\ true) do
packet = Out.new(Opcodes.lp_show_status_info())
|> Out.encode_byte(3) # 3 = exp
|> Out.encode_byte(if white, do: 1, else: 0)
|> Out.encode_int(gain)
|> Out.encode_byte(if in_chat, do: 1, else: 0)
if in_chat do
packet
|> Out.encode_bytes(<<0, 0, 0, 0, 0, 0>>)
else
packet
|> Out.encode_bytes(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>)
end
|> Out.to_data()
end
@doc """
Show level up effect to player.
Ported from MaplePacketCreator.showSpecialEffect()
"""
def show_level_up do
Out.new(Opcodes.lp_show_item_gain_inchat())
|> Out.encode_byte(0) # 0 = Level up
|> Out.to_data()
end
@doc """
Show level up effect to other players.
Ported from MaplePacketCreator.showForeignEffect()
"""
def show_foreign_level_up(character_id) do
Out.new(Opcodes.lp_show_foreign_effect())
|> Out.encode_int(character_id)
|> Out.encode_byte(0) # 0 = Level up
|> Out.to_data()
end
@doc """
Show job change effect.
Ported from MaplePacketCreator.showForeignEffect()
"""
def show_job_change(character_id) do
Out.new(Opcodes.lp_show_foreign_effect())
|> Out.encode_int(character_id)
|> Out.encode_byte(8) # 8 = Job change
|> Out.to_data()
end
@doc """
Show skill effect.
Ported from MaplePacketCreator.showBuffeffect()
"""
def show_skill_effect(character_id, skill_id, effect_id, player_level, skill_level, direction \\ 3) do
packet = Out.new(Opcodes.lp_show_foreign_effect())
|> Out.encode_int(character_id)
|> Out.encode_byte(effect_id)
|> Out.encode_int(skill_id)
|> Out.encode_byte(player_level - 1)
|> Out.encode_byte(skill_level)
if direction != 3 do
Out.encode_byte(packet, direction)
else
packet
end
|> Out.to_data()
end
@doc """
Show own skill effect.
Ported from MaplePacketCreator.showOwnBuffEffect()
"""
def show_own_skill_effect(skill_id, effect_id, player_level, skill_level, direction \\ 3) do
packet = Out.new(Opcodes.lp_show_item_gain_inchat())
|> Out.encode_byte(effect_id)
|> Out.encode_int(skill_id)
|> Out.encode_byte(player_level - 1)
|> Out.encode_byte(skill_level)
if direction != 3 do
Out.encode_byte(packet, direction)
else
packet
end
|> Out.to_data()
end
# =============================================================================
# Portal Packets
# =============================================================================
@doc """
Spawn a portal on the map.
Ported from MaplePacketCreator.spawnPortal()
"""
def spawn_portal(town_id, target_id, skill_id, position) do
packet = Out.new(Opcodes.lp_spawn_portal())
|> Out.encode_int(town_id)
|> Out.encode_int(target_id)
if town_id != 999_999_999 and target_id != 999_999_999 do
packet
|> Out.encode_int(skill_id)
|> Out.encode_short(position.x)
|> Out.encode_short(position.y)
else
packet
end
|> Out.to_data()
end
@doc """
Spawn a door (mystic door skill).
Ported from MaplePacketCreator.spawnDoor()
"""
def spawn_door(oid, position, animation \\ true) do
Out.new(Opcodes.lp_spawn_door())
|> Out.encode_byte(if animation, do: 0, else: 1)
|> Out.encode_int(oid)
|> Out.encode_short(position.x)
|> Out.encode_short(position.y)
|> Out.to_data()
end
@doc """
Remove a door.
Ported from MaplePacketCreator.removeDoor()
"""
def remove_door(oid, animation \\ true) do
Out.new(Opcodes.lp_remove_door())
|> Out.encode_byte(if animation, do: 0, else: 1)
|> Out.encode_int(oid)
|> Out.to_data()
end
@doc """
Instant map warp (for portals).
Ported from MaplePacketCreator.instantMapWarp()
"""
def instant_map_warp(portal) do
Out.new(Opcodes.lp_current_map_warp())
|> Out.encode_byte(0)
|> Out.encode_byte(portal)
|> Out.to_data()
end
# =============================================================================
# Chat Packets
# =============================================================================
@doc """
User chat packet.
Ported from LocalePacket.UserChat()
"""
def user_chat(character_id, message, is_admin \\ false, only_balloon \\ false) do
Out.new(Opcodes.lp_chattext())
|> Out.encode_int(character_id)
|> Out.encode_byte(if is_admin, do: 1, else: 0)
|> Out.encode_string(message)
|> Out.encode_byte(if only_balloon, do: 1, else: 0)
|> Out.to_data()
end
@doc """
Whisper chat packet (received whisper).
Ported from LocalePacket.WhisperChat()
"""
def whisper_received(sender_name, channel, message) do
Out.new(Opcodes.lp_whisper())
|> Out.encode_byte(0x12)
|> Out.encode_string(sender_name)
|> Out.encode_short(channel - 1)
|> Out.encode_string(message)
|> Out.to_data()
end
@doc """
Whisper reply packet (sent whisper status).
Mode: 1 = success, 0 = failure
"""
def whisper_reply(recipient_name, mode) do
Out.new(Opcodes.lp_whisper())
|> Out.encode_byte(0x0A)
|> Out.encode_string(recipient_name)
|> Out.encode_byte(mode)
|> Out.to_data()
end
@doc """
Find player reply (player found on channel).
"""
def find_player_reply(target_name, channel, is_buddy \\ false) do
Out.new(Opcodes.lp_whisper())
|> Out.encode_byte(0x09)
|> Out.encode_string(target_name)
|> Out.encode_byte(if is_buddy, do: 0x48, else: 0x01)
|> Out.encode_byte(channel)
|> Out.to_data()
end
@doc """
Find player reply with map (player found on same channel).
"""
def find_player_with_map(target_name, map_id, is_buddy \\ false) do
Out.new(Opcodes.lp_whisper())
|> Out.encode_byte(0x09)
|> Out.encode_string(target_name)
|> Out.encode_byte(if is_buddy, do: 0x48, else: 0x01)
|> Out.encode_int(map_id)
|> Out.encode_bytes(<<0, 0, 0>>)
|> Out.to_data()
end
@doc """
Party chat packet.
Type: 0 = buddy, 1 = party, 2 = guild, 3 = alliance, 4 = expedition
"""
def multi_chat(sender_name, message, chat_type) do
Out.new(Opcodes.lp_multi_chat())
|> Out.encode_byte(chat_type)
|> Out.encode_string(sender_name)
|> Out.encode_string(message)
|> Out.to_data()
end
# =============================================================================
# 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
packet = 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)
packet = if spawn_type == -3 or spawn_type >= 0 do
Out.encode_int(packet, link)
else
packet
end
packet
# 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
packet = 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
packet = Out.new(Opcodes.lp_kill_monster())
|> Out.encode_int(monster.oid)
|> Out.encode_byte(leave_type)
# If swallow type, encode swallower character ID
packet = if leave_type == 4 do
Out.encode_int(packet, -1)
else
packet
end
Out.to_data(packet)
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(Opcodes.lp_boss_env())
|> 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
# =============================================================================
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
defp encode_mob_changed_stats(packet, monster) do
if Odinsea.Constants.Game.gms?() do
changed_stats = monster.changed_stats
if changed_stats do
packet = Out.encode_byte(packet, 1)
packet
|> Out.encode_int(min(changed_stats.hp || 0, 2_147_483_647))
|> Out.encode_int(changed_stats.mp || 0)
|> Out.encode_int(changed_stats.exp || 0)
|> Out.encode_int(changed_stats.watk || 0)
|> Out.encode_int(changed_stats.matk || 0)
|> Out.encode_int(changed_stats.pdrate || 0)
|> Out.encode_int(changed_stats.mdrate || 0)
|> Out.encode_int(changed_stats.acc || 0)
|> Out.encode_int(changed_stats.eva || 0)
|> Out.encode_int(changed_stats.pushed || 0)
|> Out.encode_int(changed_stats.level || 0)
else
Out.encode_byte(packet, 0)
end
else
packet
end
end
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 14-16 integers (56-64 bytes) for GMS v342
mask_size = if Odinsea.Constants.Game.gms?(), do: 14, else: 16
packet = Enum.reduce(1..mask_size, packet, fn _, p ->
Out.encode_int(p, 0)
end)
# If status effects exist, encode for each effect:
# - nOption (short)
# - rOption (int) - skill ID | skill level << 16
# - tOption (short) - duration / 500
packet
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
# =============================================================================
# Private Helper Functions
# =============================================================================
defp encode_character_info(packet, character) do
# Basic stats
packet = packet
|> Out.encode_int(character.id)
|> Out.encode_string(character.name, 13)
|> Out.encode_byte(character.gender)
|> Out.encode_byte(character.skin_color)
|> Out.encode_int(character.face)
|> Out.encode_int(character.hair)
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_bytes(packet, <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>)
else
packet
end
packet = packet
|> Out.encode_byte(character.level)
|> Out.encode_short(character.job)
# Stats encoding
|> encode_char_stats(character)
|> Out.encode_short(character.remaining_ap)
# SP encoding for Evan/Resistance/Mercedes
packet = if is_evan?(character.job) or is_resist?(character.job) or is_mercedes?(character.job) do
remaining_sps = character.remaining_sps || []
non_zero = Enum.filter(remaining_sps, fn {_, sp} -> sp > 0 end)
packet = Out.encode_byte(packet, length(non_zero))
Enum.reduce(non_zero, packet, fn {job_level, sp}, p ->
p
|> Out.encode_byte(job_level + 1)
|> Out.encode_byte(sp)
end)
else
Out.encode_short(packet, character.remaining_sp)
end
packet
|> Out.encode_int(character.exp)
|> Out.encode_int(character.fame)
|> Out.encode_int(character.gach_exp)
|> Out.encode_int(character.map)
|> Out.encode_byte(character.spawnpoint)
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_int(packet, 0)
else
packet
end
packet
|> Out.encode_short(character.subcategory)
|> Out.encode_byte(character.fatigue)
|> Out.encode_int(current_date())
# Traits
|> encode_traits(character)
|> Out.encode_bytes(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>)
|> Out.encode_int(character.pvp_exp)
|> Out.encode_byte(character.pvp_rank)
|> Out.encode_int(character.battle_points)
|> Out.encode_byte(5)
if Odinsea.Constants.Game.gms?() do
Out.encode_int(packet, 0)
else
packet
end
end
defp encode_char_stats(packet, character) do
stats = character.stats
packet
|> Out.encode_short(stats.str)
|> Out.encode_short(stats.dex)
|> Out.encode_short(stats.int)
|> Out.encode_short(stats.luk)
|> Out.encode_int(stats.hp)
|> Out.encode_int(stats.max_hp)
|> Out.encode_int(stats.mp)
|> Out.encode_int(stats.max_mp)
end
defp encode_traits(packet, character) do
traits = character.traits || %{}
Enum.reduce([:charisma, :insight, :will, :craft, :sense, :charm], packet, fn trait, p ->
trait_data = Map.get(traits, trait, %{exp: 0})
Out.encode_int(p, trait_data.exp)
end)
end
defp encode_guild_info(packet, nil) do
packet
|> Out.encode_int(0)
|> Out.encode_int(0)
end
defp encode_guild_info(packet, guild) do
packet
|> Out.encode_string(guild.name)
|> Out.encode_short(guild.logo_bg)
|> Out.encode_byte(guild.logo_bg_color)
|> Out.encode_short(guild.logo)
|> Out.encode_byte(guild.logo_color)
end
defp encode_ring_info(packet, nil) do
packet
|> Out.encode_int(0)
|> Out.encode_int(0)
|> Out.encode_int(0)
end
defp encode_ring_info(packet, rings) do
# Encode 3 ring slots
rings = rings || []
{r1, r2, r3} = case rings do
[a, b, c | _] -> {a, b, c}
[a, b] -> {a, b, nil}
[a] -> {a, nil, nil}
[] -> {nil, nil, nil}
end
packet
|> encode_single_ring(r1)
|> encode_single_ring(r2)
|> encode_single_ring(r3)
end
defp encode_single_ring(packet, nil) do
Out.encode_int(packet, 0)
end
defp encode_single_ring(packet, ring) do
packet
|> Out.encode_int(1)
|> Out.encode_long(ring.id)
|> Out.encode_long(ring.partner_ring_id)
|> Out.encode_int(ring.item_id)
end
defp encode_mount(packet, nil) do
packet
|> Out.encode_byte(0)
|> Out.encode_byte(1)
|> Out.encode_int(0)
|> Out.encode_int(0)
end
defp encode_mount(packet, mount) do
packet
|> Out.encode_byte(1)
|> Out.encode_int(mount.level)
|> Out.encode_int(mount.exp)
|> Out.encode_int(mount.fatigue)
end
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
# =============================================================================
# Item/Quest Packets
# =============================================================================
@doc """
Show item gain effect.
Ported from MaplePacketCreator.getShowItemGain()
"""
def show_item_gain(item_id, quantity, in_chat \\ true) do
packet = Out.new(Opcodes.lp_show_status_info())
|> Out.encode_byte(0) # 0 = item
|> Out.encode_int(item_id)
|> Out.encode_int(quantity)
if in_chat do
packet
|> Out.encode_bytes(<<0, 0, 0, 0, 0, 0>>)
else
packet
end
|> Out.to_data()
end
@doc """
Server notice packet (broadcast message).
Ported from MaplePacketCreator.serverNotice()
Types:
- 0 = Notice (blue)
- 1 = Popup (red)
- 2 = Megaphone
- 3 = Super Megaphone
- 4 = Scrolling message (top)
- 5 = System message (yellow)
"""
def server_notice(type, message) do
Out.new(Opcodes.lp_event_msg())
|> Out.encode_byte(type)
|> Out.encode_string(message)
|> Out.to_data()
end
# =============================================================================
# Utility Functions
# =============================================================================
defp korean_timestamp(millis) do
ft_ut_offset = 116_444_592_000_000_000
if millis > 0 do
trunc(millis * 10_000 + ft_ut_offset)
else
150_842_304_000_000_000 # MAX_TIME
end
end
defp current_date do
now = DateTime.utc_now()
now.year * 10000 + now.month * 100 + now.day
end
defp is_evan?(job), do: div(job, 100) == 22
defp is_resist?(job), do: div(job, 1000) == 3
defp is_mercedes?(job), do: div(job, 100) == 23
end