2256 lines
64 KiB
Elixir
2256 lines
64 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
|
|
|
|
# =============================================================================
|
|
# Buddy List Packets
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Updates the buddy list for a character.
|
|
Ported from MaplePacketCreator.updateBuddylist()
|
|
|
|
## Parameters
|
|
- buddy_list: List of buddy entries
|
|
- deleted: Operation type (7 = update, or other values for different operations)
|
|
"""
|
|
def update_buddylist(buddy_list, deleted \\ 7) do
|
|
packet = Out.new(Opcodes.lp_buddylist())
|
|
|> Out.encode_byte(deleted)
|
|
|> Out.encode_byte(length(buddy_list))
|
|
|
|
# Encode each buddy entry
|
|
packet = Enum.reduce(buddy_list, packet, fn buddy, p ->
|
|
p
|
|
|> Out.encode_int(buddy.character_id)
|
|
|> Out.encode_string(buddy.name, 13)
|
|
|> Out.encode_byte(if buddy.visible, do: 0, else: 1)
|
|
|> Out.encode_int(if buddy.channel == -1, do: -1, else: buddy.channel - 1)
|
|
|> Out.encode_string(buddy.group || "ETC", 17)
|
|
end)
|
|
|
|
# Padding ints for each buddy (always 0)
|
|
packet = Enum.reduce(buddy_list, packet, fn _, p ->
|
|
Out.encode_int(p, 0)
|
|
end)
|
|
|
|
Out.to_data(packet)
|
|
end
|
|
|
|
@doc """
|
|
Sends a buddy request to a character.
|
|
Ported from MaplePacketCreator.requestBuddylistAdd()
|
|
|
|
## Parameters
|
|
- cid_from: Character ID sending the request
|
|
- name_from: Name of character sending the request
|
|
- level_from: Level of character sending the request
|
|
- job_from: Job ID of character sending the request
|
|
"""
|
|
def request_buddylist_add(cid_from, name_from, level_from, job_from) do
|
|
Out.new(Opcodes.lp_buddylist())
|
|
|> Out.encode_byte(9)
|
|
|> Out.encode_int(cid_from)
|
|
|> Out.encode_string(name_from)
|
|
|> Out.encode_int(level_from)
|
|
|> Out.encode_int(job_from)
|
|
|> Out.encode_int(cid_from)
|
|
|> Out.encode_string(name_from, 13)
|
|
|> Out.encode_byte(1)
|
|
|> Out.encode_int(0)
|
|
|> Out.encode_string("ETC", 16)
|
|
|> Out.encode_short(1)
|
|
|> Out.to_data()
|
|
end
|
|
|
|
@doc """
|
|
Sends a buddy list message/status code.
|
|
Ported from MaplePacketCreator.buddylistMessage()
|
|
|
|
## Message codes:
|
|
- 11: Buddy list full
|
|
- 12: Target buddy list full
|
|
- 13: Already registered as buddy
|
|
- 14: Character not found
|
|
- 15: Request sent
|
|
- 17: You cannot add yourself as a buddy
|
|
- 19: Already requested
|
|
- 21: Cannot add GM to buddy list
|
|
"""
|
|
def buddylist_message(message_code) do
|
|
Out.new(Opcodes.lp_buddylist())
|
|
|> Out.encode_byte(message_code)
|
|
|> Out.to_data()
|
|
end
|
|
|
|
# =============================================================================
|
|
# Party Packets
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Party creation confirmation packet.
|
|
Ported from MaplePacketCreator.partyCreated()
|
|
|
|
## Parameters
|
|
- party_id: The newly created party ID
|
|
"""
|
|
def party_created(party_id) do
|
|
opcode_byte = if Odinsea.Constants.Game.gms?(), do: 10, else: 8
|
|
|
|
Out.new(Opcodes.lp_party_operation())
|
|
|> Out.encode_byte(opcode_byte)
|
|
|> Out.encode_int(party_id)
|
|
|> Out.encode_int(999_999_999)
|
|
|> Out.encode_int(999_999_999)
|
|
|> Out.encode_long(0)
|
|
|> Out.encode_byte(0)
|
|
|> Out.encode_byte(1)
|
|
|> Out.to_data()
|
|
end
|
|
|
|
@doc """
|
|
Party invitation packet (sent to invited player).
|
|
Ported from MaplePacketCreator.partyInvite()
|
|
|
|
## Parameters
|
|
- from_character: Character struct of the inviter (needs :party_id, :name, :level, :job)
|
|
"""
|
|
def party_invite(from_character) do
|
|
party_id = from_character.party_id || 0
|
|
|
|
Out.new(Opcodes.lp_party_operation())
|
|
|> Out.encode_byte(4)
|
|
|> Out.encode_int(party_id)
|
|
|> Out.encode_string(from_character.name)
|
|
|> Out.encode_int(from_character.level)
|
|
|> Out.encode_int(from_character.job)
|
|
|> Out.encode_byte(0)
|
|
|> Out.to_data()
|
|
end
|
|
|
|
@doc """
|
|
Party request packet (for request-to-join scenarios).
|
|
Ported from MaplePacketCreator.partyRequestInvite()
|
|
|
|
## Parameters
|
|
- from_character: Character struct of the requester (needs :id, :name, :level, :job)
|
|
"""
|
|
def party_request(from_character) do
|
|
Out.new(Opcodes.lp_party_operation())
|
|
|> Out.encode_byte(7)
|
|
|> Out.encode_int(from_character.id)
|
|
|> Out.encode_string(from_character.name)
|
|
|> Out.encode_int(from_character.level)
|
|
|> Out.encode_int(from_character.job)
|
|
|> Out.to_data()
|
|
end
|
|
|
|
@doc """
|
|
Party status/error message packet.
|
|
Ported from MaplePacketCreator.partyStatusMessage()
|
|
|
|
## Message codes:
|
|
- 10: A beginner can't create a party
|
|
- 11: Your request for a party didn't work due to an unexpected error
|
|
- 13: You have yet to join a party
|
|
- 16: Already have joined a party
|
|
- 17: The party you're trying to join is already in full capacity
|
|
- 19: Unable to find the requested character in this channel
|
|
- 23: 'Char' have denied request to the party (with charname)
|
|
|
|
## Parameters
|
|
- message_code: The status/error code
|
|
- charname: Optional character name for personalized messages
|
|
"""
|
|
def party_status_message(message_code, charname \\ nil) do
|
|
opcode_byte = if Odinsea.Constants.Game.gms?() and message_code >= 7,
|
|
do: message_code + 2,
|
|
else: message_code
|
|
|
|
packet = Out.new(Opcodes.lp_party_operation())
|
|
|> Out.encode_byte(opcode_byte)
|
|
|
|
packet = if charname do
|
|
Out.encode_string(packet, charname)
|
|
else
|
|
packet
|
|
end
|
|
|
|
Out.to_data(packet)
|
|
end
|
|
|
|
# =============================================================================
|
|
# Guild Packets
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Guild invitation packet (sent to invited player).
|
|
Ported from MaplePacketCreator.guildInvite()
|
|
|
|
## Parameters
|
|
- guild_id: ID of the guild
|
|
- from_name: Name of the character sending the invite
|
|
- from_level: Level of the character sending the invite
|
|
- from_job: Job ID of the character sending the invite
|
|
"""
|
|
def guild_invite(guild_id, from_name, from_level, from_job) do
|
|
Out.new(Opcodes.lp_guild_operation())
|
|
|> Out.encode_byte(0x05)
|
|
|> Out.encode_int(guild_id)
|
|
|> Out.encode_string(from_name)
|
|
|> Out.encode_int(from_level)
|
|
|> Out.encode_int(from_job)
|
|
|> Out.to_data()
|
|
end
|
|
|
|
@doc """
|
|
Guild invitation denial packet.
|
|
Ported from MaplePacketCreator.denyGuildInvitation()
|
|
|
|
## Parameters
|
|
- charname: Name of the character who denied the invitation
|
|
"""
|
|
def deny_guild_invitation(charname) do
|
|
Out.new(Opcodes.lp_guild_operation())
|
|
|> Out.encode_byte(0x3D)
|
|
|> Out.encode_string(charname)
|
|
|> 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
|