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

1214 lines
33 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
@doc """
Sends character information on login.
"""
def get_char_info(character, restored_buffs \\ []) do
# TODO: Full character encoding
# For now, send minimal info
Out.new(Opcodes.lp_set_field())
|> Out.encode_int(character.id)
|> Out.encode_byte(0) # Channel
|> Out.encode_byte(1) # Admin byte
|> Out.encode_byte(1) # Enabled
|> Out.encode_int(character.map)
|> Out.encode_byte(character.spawnpoint)
|> Out.encode_int(character.hp)
|> Out.to_data()
end
@doc """
Enables cash shop access.
"""
def enable_cash_shop do
Out.new(Opcodes.lp_set_cash_shop_opened())
|> Out.encode_byte(1)
|> Out.to_data()
end
@doc """
Server blocked message.
"""
def server_blocked(reason) do
Out.new(Opcodes.lp_server_blocked())
|> Out.encode_byte(reason)
|> Out.to_data()
end
@doc """
Enable client actions.
"""
def enable_actions do
Out.new(Opcodes.lp_enable_action())
|> Out.encode_byte(0)
|> Out.to_data()
end
@doc """
Channel change command.
"""
def get_channel_change(ip, port, character_id) do
ip_parts = parse_ip(ip)
Out.new(Opcodes.lp_migrate_command())
|> Out.encode_short(0) # Not cash shop
|> encode_ip(ip_parts)
|> Out.encode_short(port)
|> Out.encode_int(character_id)
|> Out.encode_bytes(<<0, 0>>)
|> Out.to_data()
end
defp parse_ip(host) when is_binary(host) do
case String.split(host, ".") do
[a, b, c, d] ->
{
String.to_integer(a),
String.to_integer(b),
String.to_integer(c),
String.to_integer(d)
}
_ ->
{127, 0, 0, 1}
end
end
defp encode_ip(packet, {a, b, c, d}) do
packet
|> Out.encode_byte(a)
|> Out.encode_byte(b)
|> Out.encode_byte(c)
|> Out.encode_byte(d)
end
@doc """
Spawns a player on the map.
Minimal implementation - will expand with equipment, buffs, etc.
"""
def spawn_player(oid, character_state) do
# Reference: MaplePacketCreator.spawnPlayerMapobject()
# This is a minimal implementation - full version needs equipment, buffs, etc.
Out.new(Opcodes.lp_spawn_player())
|> Out.encode_int(oid)
# Damage skin (custom client feature)
# |> Out.encode_int(0)
|> Out.encode_byte(character_state.level)
|> Out.encode_string(character_state.name)
# Ultimate Explorer name (empty for now)
|> Out.encode_string("")
# Guild info (no guild for now)
|> Out.encode_int(0)
|> Out.encode_int(0)
# Buff mask (no buffs for now - TODO: implement buffs)
# For now, send minimal buff data
|> encode_buff_mask()
# Foreign buff end
|> Out.encode_short(0)
# ITEM_EFFECT
|> Out.encode_int(0)
# CHAIR
|> Out.encode_int(0)
# Position
|> Out.encode_short(character_state.position.x)
|> Out.encode_short(character_state.position.y)
|> Out.encode_byte(character_state.position.stance)
# Foothold
|> Out.encode_short(character_state.position.foothold)
# Appearance (gender, skin, face, hair)
|> Out.encode_byte(character_state.gender)
|> Out.encode_byte(character_state.skin_color)
|> Out.encode_int(character_state.face)
# Mega - shown in rankings
|> Out.encode_byte(0)
# Equipment (TODO: implement proper equipment encoding)
|> encode_appearance_minimal(character_state)
# Driver ID / passenger ID (for mounts)
|> Out.encode_int(0)
# Chalkboard text
|> Out.encode_string("")
# Ring info (3 ring slots)
|> Out.encode_int(0)
|> Out.encode_int(0)
|> Out.encode_int(0)
# Marriage ring
|> Out.encode_int(0)
# Mount info (no mount for now)
|> encode_mount_minimal()
# Player shop (none for now)
|> Out.encode_byte(0)
# Admin byte
|> Out.encode_byte(0)
# Pet info (no pets for now)
|> encode_pets_minimal()
# Taming mob (none)
|> Out.encode_int(0)
# Mini game info
|> Out.encode_byte(0)
# Chalkboard
|> Out.encode_byte(0)
# New year cards
|> Out.encode_byte(0)
# Berserk
|> Out.encode_byte(0)
|> Out.to_data()
end
@doc """
Removes a player from the map.
"""
def remove_player(oid) do
Out.new(Opcodes.lp_remove_player_from_map())
|> Out.encode_int(oid)
|> Out.to_data()
end
# ============================================================================
# Helper Functions for Spawn Encoding
# ============================================================================
defp encode_buff_mask(packet) do
# Buff mask is an array of integers representing active buffs
# For GMS v342, this is typically 14-16 integers (56-64 bytes)
# For now, send all zeros (no buffs)
packet
|> Out.encode_bytes(<<0::size(14 * 32)-little>>)
end
defp encode_appearance_minimal(packet, character) do
# Equipment encoding:
# Map of slot -> item_id
# For minimal implementation, just show hair
packet
# Equipped items map (empty for now)
|> Out.encode_byte(0)
# Masked items map (empty for now)
|> Out.encode_byte(0)
# Weapon (cash weapon)
|> Out.encode_int(0)
# Hair
|> Out.encode_int(character.hair)
# Ears (12 bit encoding for multiple items)
|> Out.encode_int(0)
end
defp encode_mount_minimal(packet) do
packet
|> Out.encode_byte(0)
# Mount level
|> Out.encode_byte(1)
# Mount exp
|> Out.encode_int(0)
# Mount fatigue
|> Out.encode_int(0)
end
defp encode_pets_minimal(packet) do
# 3 pet slots
packet
|> Out.encode_byte(0)
|> Out.encode_byte(0)
|> Out.encode_byte(0)
end
# ============================================================================
# Chat Packets
# ============================================================================
@doc """
User chat packet.
Ported from LocalePacket.UserChat()
Reference: src/tools/packet/LocalePacket.java
"""
def user_chat(character_id, message, is_admin \\ false, only_balloon \\ false) do
Out.new(Opcodes.lp_chattext())
|> Out.encode_int(character_id)
|> Out.encode_byte(if is_admin, do: 1, else: 0)
|> Out.encode_string(message)
|> Out.encode_byte(if only_balloon, do: 1, else: 0)
|> Out.to_data()
end
@doc """
Whisper chat packet (received whisper).
Ported from LocalePacket.WhisperChat()
"""
def whisper_received(sender_name, channel, message) do
Out.new(Opcodes.lp_whisper())
|> Out.encode_byte(0x12)
|> Out.encode_string(sender_name)
|> Out.encode_short(channel - 1)
|> Out.encode_string(message)
|> Out.to_data()
end
@doc """
Whisper reply packet (sent whisper status).
Mode: 1 = success, 0 = failure
"""
def whisper_reply(recipient_name, mode) do
Out.new(Opcodes.lp_whisper())
|> Out.encode_byte(0x0A)
|> Out.encode_string(recipient_name)
|> Out.encode_byte(mode)
|> Out.to_data()
end
@doc """
Find player reply (player found on channel).
"""
def find_player_reply(target_name, channel, is_buddy \\ false) do
Out.new(Opcodes.lp_whisper())
|> Out.encode_byte(0x09)
|> Out.encode_string(target_name)
|> Out.encode_byte(if is_buddy, do: 0x48, else: 0x01)
|> Out.encode_byte(channel)
|> Out.to_data()
end
@doc """
Find player reply with map (player found on same channel).
"""
def find_player_with_map(target_name, map_id, is_buddy \\ false) do
Out.new(Opcodes.lp_whisper())
|> Out.encode_byte(0x09)
|> Out.encode_string(target_name)
|> Out.encode_byte(if is_buddy, do: 0x48, else: 0x01)
|> Out.encode_int(map_id)
|> Out.encode_bytes(<<0, 0, 0>>)
|> Out.to_data()
end
@doc """
Party chat packet.
Type: 0 = buddy, 1 = party, 2 = guild, 3 = alliance, 4 = expedition
"""
def multi_chat(sender_name, message, chat_type) do
Out.new(Opcodes.lp_multi_chat())
|> Out.encode_byte(chat_type)
|> Out.encode_string(sender_name)
|> Out.encode_string(message)
|> Out.to_data()
end
# ============================================================================
# Monster Packets
# ============================================================================
@doc """
Spawns a monster on the map (LP_MobEnterField).
## Parameters
- monster: Monster.t() struct
- spawn_type: spawn animation type (-1 = normal, -2 = regen, -3 = revive, etc.)
- link: linked mob OID (for multi-mobs)
Reference: MobPacket.spawnMonster()
"""
def spawn_monster(monster, spawn_type \\ -1, link \\ 0) do
Out.new(Opcodes.lp_spawn_monster())
|> Out.encode_int(monster.oid)
|> Out.encode_byte(1) # 1 = Control normal, 5 = Control none
|> Out.encode_int(monster.mob_id)
# Temporary stat encoding (buffs/debuffs)
|> encode_mob_temporary_stat(monster)
# Position
|> Out.encode_short(monster.position.x)
|> Out.encode_short(monster.position.y)
# Move action (bitfield)
|> Out.encode_byte(monster.stance)
# Foothold SN
|> Out.encode_short(monster.fh)
# Origin FH
|> Out.encode_short(monster.fh)
# Spawn type
|> Out.encode_byte(spawn_type)
# Link OID (for spawn_type -3 or >= 0)
|> then(fn packet ->
if spawn_type == -3 or spawn_type >= 0 do
Out.encode_int(packet, link)
else
packet
end
end)
# Carnival team
|> Out.encode_byte(0)
# Aftershock - 8 bytes (0xFF at end for GMS)
|> Out.encode_long(0)
# GMS specific
|> Out.encode_byte(-1)
|> Out.to_data()
end
@doc """
Assigns monster control to a player (LP_MobChangeController).
## Parameters
- monster: Monster.t() struct
- new_spawn: whether this is a new spawn
- aggro: aggro mode (2 = aggro, 1 = normal)
Reference: MobPacket.controlMonster()
"""
def control_monster(monster, new_spawn \\ false, aggro \\ false) do
Out.new(Opcodes.lp_spawn_monster_control())
|> Out.encode_byte(if aggro, do: 2, else: 1)
|> Out.encode_int(monster.oid)
|> Out.encode_byte(1) # 1 = Control normal, 5 = Control none
|> Out.encode_int(monster.mob_id)
# Temporary stat encoding
|> encode_mob_temporary_stat(monster)
# Position
|> Out.encode_short(monster.position.x)
|> Out.encode_short(monster.position.y)
# Move action
|> Out.encode_byte(monster.stance)
# Foothold SN
|> Out.encode_short(monster.fh)
# Origin FH
|> Out.encode_short(monster.fh)
# Spawn type (-4 = fake, -2 = new spawn, -1 = normal)
|> Out.encode_byte(cond do
new_spawn -> -2
true -> -1
end)
# Carnival team
|> Out.encode_byte(0)
# Big bang - another long
|> Out.encode_long(0)
# GMS specific
|> Out.encode_byte(-1)
|> Out.to_data()
end
@doc """
Stops controlling a monster (LP_MobChangeController with byte 0).
Reference: MobPacket.stopControllingMonster()
"""
def stop_controlling_monster(oid) do
Out.new(Opcodes.lp_spawn_monster_control())
|> Out.encode_byte(0)
|> Out.encode_int(oid)
|> Out.to_data()
end
@doc """
Monster movement packet (LP_MobMove).
## Parameters
- oid: monster object ID
- next_attack_possible: whether next attack is possible
- left: facing direction (raw byte from client)
- skill_data: skill data from movement
- move_path: movement path data (binary from client)
Reference: MobPacket.onMove()
"""
def move_monster(oid, next_attack_possible, left, skill_data, move_path) do
Out.new(Opcodes.lp_move_monster())
|> Out.encode_int(oid)
|> Out.encode_byte(0)
|> Out.encode_byte(0)
|> Out.encode_byte(if next_attack_possible, do: 1, else: 0)
|> Out.encode_byte(left)
|> Out.encode_int(skill_data)
|> Out.encode_int(0) # multi target for ball size
|> Out.encode_int(0) # rand time for area attack
# Movement path (raw binary from client)
|> Out.encode_bytes(move_path)
|> Out.to_data()
end
@doc """
Damage monster packet (LP_MobDamaged).
## Parameters
- oid: monster object ID
- damage: damage amount (capped at Integer max)
Reference: MobPacket.damageMonster()
"""
def damage_monster(oid, damage) do
# Cap damage at max int
damage_capped = min(damage, 2_147_483_647)
Out.new(Opcodes.lp_damage_monster())
|> Out.encode_int(oid)
|> Out.encode_byte(0)
|> Out.encode_int(damage_capped)
|> Out.to_data()
end
@doc """
Kill monster packet (LP_MobLeaveField).
## Parameters
- monster: Monster.t() struct
- leave_type: how the mob is leaving (0 = remain hp, 1 = etc, 2 = self destruct, etc.)
Reference: MobPacket.killMonster()
"""
def kill_monster(monster, leave_type \\ 1) do
Out.new(Opcodes.lp_kill_monster())
|> Out.encode_int(monster.oid)
|> Out.encode_byte(leave_type)
# If swallow type, encode swallower character ID
|> then(fn packet ->
if leave_type == 4 do
Out.encode_int(packet, -1)
else
packet
end
end)
|> Out.to_data()
end
@doc """
Show monster HP indicator (LP_MobHPIndicator).
## Parameters
- oid: monster object ID
- hp_percentage: HP percentage (0-100)
Reference: MobPacket.showMonsterHP()
"""
def show_monster_hp(oid, hp_percentage) do
Out.new(Opcodes.lp_show_monster_hp())
|> Out.encode_int(oid)
|> Out.encode_byte(hp_percentage)
|> Out.to_data()
end
@doc """
Show boss HP bar (BOSS_ENV).
## Parameters
- monster: Monster.t() struct
Reference: MobPacket.showBossHP()
"""
def show_boss_hp(monster) do
# Cap HP at max int for display
current_hp = min(monster.hp, 2_147_483_647)
max_hp = min(monster.max_hp, 2_147_483_647)
Out.new(0x9D) # BOSS_ENV opcode
|> Out.encode_byte(5)
|> Out.encode_int(monster.mob_id)
|> Out.encode_int(current_hp)
|> Out.encode_int(max_hp)
|> Out.encode_byte(6) # Tag color (default)
|> Out.encode_byte(5) # Tag bg color (default)
|> Out.to_data()
end
@doc """
Monster control acknowledgment (LP_MobCtrlAck).
## Parameters
- oid: monster object ID
- mob_ctrl_sn: control sequence number
- next_attack_possible: whether next attack is possible
- mp: monster MP
- skill_command: skill command from client
- slv: skill level
Reference: MobPacket.onCtrlAck()
"""
def mob_ctrl_ack(oid, mob_ctrl_sn, next_attack_possible, mp, skill_command, slv) do
Out.new(Opcodes.lp_move_monster_response())
|> Out.encode_int(oid)
|> Out.encode_short(mob_ctrl_sn)
|> Out.encode_byte(if next_attack_possible, do: 1, else: 0)
|> Out.encode_short(mp)
|> Out.encode_byte(skill_command)
|> Out.encode_byte(slv)
|> Out.encode_int(0) # forced attack idx
|> Out.to_data()
end
# ============================================================================
# Reactor Packets
# ============================================================================
@doc """
Spawns a reactor on the map (LP_ReactorEnterField).
## Parameters
- reactor: Reactor.t() struct
Reference: MaplePacketCreator.spawnReactor()
"""
def spawn_reactor(reactor) do
Out.new(Opcodes.lp_reactor_spawn())
|> Out.encode_int(reactor.oid)
|> Out.encode_int(reactor.reactor_id)
|> Out.encode_byte(reactor.state)
|> Out.encode_short(reactor.x)
|> Out.encode_short(reactor.y)
|> Out.encode_byte(reactor.facing_direction)
|> Out.encode_string(reactor.name)
|> Out.to_data()
end
@doc """
Triggers/hits a reactor (LP_ReactorChangeState).
## Parameters
- reactor: Reactor.t() struct
- stance: stance value (usually 0)
Reference: MaplePacketCreator.triggerReactor()
"""
def trigger_reactor(reactor, stance \\ 0) do
# Cap state for herb/vein reactors (100000-200011 range)
state =
if reactor.reactor_id >= 100000 and reactor.reactor_id <= 200011 do
min(reactor.state, 4)
else
reactor.state
end
Out.new(Opcodes.lp_reactor_hit())
|> Out.encode_int(reactor.oid)
|> Out.encode_byte(state)
|> Out.encode_short(reactor.x)
|> Out.encode_short(reactor.y)
|> Out.encode_short(stance)
|> Out.encode_byte(0)
|> Out.encode_byte(0)
|> Out.to_data()
end
@doc """
Destroys/removes a reactor from the map (LP_ReactorLeaveField).
## Parameters
- reactor: Reactor.t() struct
Reference: MaplePacketCreator.destroyReactor()
"""
def destroy_reactor(reactor) do
Out.new(Opcodes.lp_reactor_destroy())
|> Out.encode_int(reactor.oid)
|> Out.encode_byte(reactor.state)
|> Out.encode_short(reactor.x)
|> Out.encode_short(reactor.y)
|> Out.to_data()
end
# ============================================================================
# Helper Functions for Monster Encoding
# ============================================================================
@doc false
defp encode_mob_temporary_stat(packet, monster) do
# For GMS v342, encode changed stats first
packet
|> encode_mob_changed_stats(monster)
# Then encode temporary status effects (buffs/debuffs)
|> encode_mob_status_mask(monster)
end
@doc false
defp encode_mob_changed_stats(packet, _monster) do
# For GMS: encode byte 1 if stats are changed, 0 if not
# For now, assume no changed stats
packet
|> Out.encode_byte(0)
# If changed stats exist, encode:
# - hp (int)
# - mp (int)
# - exp (int)
# - watk (int)
# - matk (int)
# - PDRate (int)
# - MDRate (int)
# - acc (int)
# - eva (int)
# - pushed (int)
# - level (int)
end
@doc false
defp encode_mob_status_mask(packet, monster) do
# Encode status effects (poison, stun, freeze, etc.)
# For now, assume no status effects - send empty mask
# Mask is typically 16 integers (64 bytes) for GMS
packet
|> Out.encode_bytes(<<0::size(16 * 32)-little>>)
# If status effects exist, encode for each effect:
# - nOption (short)
# - rOption (int) - skill ID | skill level << 16
# - tOption (short) - duration / 500
end
# ============================================================================
# Admin/System Packets
# ============================================================================
@doc """
Drop message packet (system message displayed to player).
Types:
- 0 = Notice (blue)
- 1 = Popup (red)
- 2 = Megaphone
- 3 = Super Megaphone
- 4 = Top scrolling message
- 5 = System message (yellow)
- 6 = Quiz
Reference: MaplePacketCreator.dropMessage()
"""
def drop_message(type, message) do
Out.new(Opcodes.lp_blow_weather())
|> Out.encode_int(type)
|> Out.encode_string(message)
|> Out.to_data()
end
@doc """
Server-wide broadcast message.
Reference: MaplePacketCreator.serverMessage()
"""
def server_message(message) do
Out.new(Opcodes.lp_event_msg())
|> Out.encode_byte(1)
|> Out.encode_string(message)
|> Out.to_data()
end
@doc """
Scrolling server message (top of screen banner).
Reference: MaplePacketCreator.serverMessage()
"""
def scrolling_message(message) do
Out.new(Opcodes.lp_event_msg())
|> Out.encode_byte(4)
|> Out.encode_string(message)
|> Out.to_data()
end
@doc """
Screenshot request packet (admin tool).
Sends a session key to the client for screenshot verification.
Reference: ClientPool.getScreenshot()
"""
def screenshot_request(session_key) do
Out.new(Opcodes.lp_screen_msg())
|> Out.encode_long(session_key)
|> Out.to_data()
end
@doc """
Start lie detector on player.
Reference: MaplePacketCreator.sendLieDetector()
"""
def start_lie_detector do
Out.new(Opcodes.lp_lie_detector())
|> Out.encode_byte(1) # Start lie detector
|> Out.to_data()
end
@doc """
Lie detector result packet.
Status:
- 0 = Success
- 1 = Failed (wrong answer)
- 2 = Timeout
- 3 = Error
"""
def lie_detector_result(status) do
Out.new(Opcodes.lp_lie_detector())
|> Out.encode_byte(status)
|> Out.to_data()
end
@doc """
Admin result packet (command acknowledgment).
Reference: MaplePacketCreator.getAdminResult()
"""
def admin_result(success, message) do
Out.new(Opcodes.lp_admin_result())
|> Out.encode_byte(if success, do: 1, else: 0)
|> Out.encode_string(message)
|> Out.to_data()
end
@doc """
Force disconnect packet (kick player).
Reference: MaplePacketCreator.getForcedDisconnect()
"""
def force_disconnect(reason \\ 0) do
Out.new(Opcodes.lp_forced_stat_ex())
|> Out.encode_byte(reason)
|> Out.to_data()
end
# ============================================================================
# Drop Packets
# ============================================================================
@doc """
Spawns a drop on the map (LP_DropItemFromMapObject).
## Parameters
- drop: Drop.t() struct
- source_position: Position where drop originated (monster/player position)
- animation: Animation type
- 1 = animation (drop falls from source)
- 2 = no animation (instant spawn)
- 3 = spawn disappearing item [Fade]
- 4 = spawn disappearing item
- delay: Delay before drop appears (in ms)
Reference: MaplePacketCreator.dropItemFromMapObject()
"""
def spawn_drop(drop, source_position \\ nil, animation \\ 1, delay \\ 0) do
source_pos = source_position || drop.position
packet = Out.new(Opcodes.lp_drop_item_from_mapobject())
|> Out.encode_byte(animation)
|> Out.encode_int(drop.oid)
|> Out.encode_byte(if drop.meso > 0, do: 1, else: 0) # 1 = mesos, 0 = item
|> Out.encode_int(Drop.display_id(drop))
|> Out.encode_int(drop.owner_id)
|> Out.encode_byte(drop.drop_type)
|> Out.encode_short(drop.position.x)
|> Out.encode_short(drop.position.y)
|> Out.encode_int(0) # Unknown
# If animation != 2, encode source position
packet = if animation != 2 do
packet
|> Out.encode_short(source_pos.x)
|> Out.encode_short(source_pos.y)
|> Out.encode_short(delay)
else
packet
end
# If not meso, encode expiration time
packet = if drop.meso == 0 do
# Expiration time - for now, send 0 (no expiration)
# In full implementation, this would be the item's expiration timestamp
Out.encode_long(packet, 0)
else
packet
end
# Pet pickup byte
# 0 = player can pick up, 1 = pet can pick up
packet
|> Out.encode_short(if drop.player_drop, do: 0, else: 1)
|> Out.to_data()
end
@doc """
Removes a drop from the map (LP_RemoveItemFromMap).
## Parameters
- oid: Drop object ID
- animation: Removal animation
- 0 = Expire/fade out
- 1 = Without animation
- 2 = Pickup animation
- 4 = Explode animation
- 5 = Pet pickup
- character_id: Character ID performing the action (for pickup animations)
- slot: Pet slot (for pet pickup animation)
Reference: MaplePacketCreator.removeItemFromMap()
"""
def remove_drop(oid, animation \\ 1, character_id \\ 0, slot \\ 0) do
packet = Out.new(Opcodes.lp_remove_item_from_map())
|> Out.encode_byte(animation)
|> Out.encode_int(oid)
# If animation >= 2, encode character ID
packet = if animation >= 2 do
Out.encode_int(packet, character_id)
else
packet
end
# If animation == 5 (pet pickup), encode slot
packet = if animation == 5 do
Out.encode_int(packet, slot)
else
packet
end
Out.to_data(packet)
end
@doc """
Spawns multiple drops at once (for explosive drops).
Broadcasts a spawn packet for each drop with minimal animation.
"""
def spawn_drops(drops, source_position, animation \\ 2) do
Enum.map(drops, fn drop ->
spawn_drop(drop, source_position, animation)
end)
end
@doc """
Sends existing drops to a joining player.
"""
def send_existing_drops(client_pid, drops) do
Enum.each(drops, fn drop ->
# Only send drops that haven't been picked up
if not drop.picked_up do
packet = spawn_drop(drop, nil, 2) # No animation
send(client_pid, {:send_packet, packet})
end
end)
end
# ============================================================================
# Pet Packets
# ============================================================================
@doc """
Updates pet information in inventory (ModifyInventoryItem).
Ported from PetPacket.updatePet()
"""
def update_pet(pet, item \\ nil, active \\ true) do
# Encode inventory update with pet info
Out.new(Opcodes.lp_modify_inventory_item())
|> Out.encode_byte(0) # Inventory mode
|> Out.encode_byte(2) # Update count
|> Out.encode_byte(3) # Mode type
|> Out.encode_byte(5) # Inventory type (CASH)
|> Out.encode_short(pet.inventory_position)
|> Out.encode_byte(0)
|> Out.encode_byte(5) # Inventory type (CASH)
|> Out.encode_short(pet.inventory_position)
|> Out.encode_byte(3) # Item type for pet
|> Out.encode_int(pet.pet_item_id)
|> Out.encode_byte(1)
|> Out.encode_long(pet.unique_id)
|> encode_pet_item_info(pet, item, active)
|> Out.to_data()
end
@doc """
Spawns a pet on the map (LP_SpawnPet).
Ported from PetPacket.showPet()
## Parameters
- character_id: Owner's character ID
- pet: Pet.t() struct
- remove: If true, removes the pet
- hunger: If true and removing, shows hunger message
"""
def spawn_pet(character_id, pet, remove \\ false, hunger \\ false) do
packet = Out.new(Opcodes.lp_spawn_pet())
|> Out.encode_int(character_id)
# Encode pet slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, pet.summoned)
else
Out.encode_int(packet, pet.summoned)
end
if remove do
packet
|> Out.encode_byte(0) # Remove flag
|> Out.encode_byte(if hunger, do: 1, else: 0)
else
packet
|> Out.encode_byte(1) # Show flag
|> Out.encode_byte(0) # Unknown flag
|> Out.encode_int(pet.pet_item_id)
|> Out.encode_string(pet.name)
|> Out.encode_long(pet.unique_id)
|> Out.encode_short(pet.position.x)
|> Out.encode_short(pet.position.y - 20) # Offset Y slightly
|> Out.encode_byte(pet.stance)
|> Out.encode_int(pet.position.fh)
end
|> Out.to_data()
end
@doc """
Removes a pet from the map.
Ported from PetPacket.removePet()
"""
def remove_pet(character_id, slot) do
packet = Out.new(Opcodes.lp_spawn_pet())
|> Out.encode_int(character_id)
# Encode slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, slot)
else
Out.encode_int(packet, slot)
end
packet
|> Out.encode_short(0) # Remove flag
|> Out.to_data()
end
@doc """
Moves a pet on the map (LP_PetMove).
Ported from PetPacket.movePet()
"""
def move_pet(character_id, pet_unique_id, slot, movement_data) do
packet = Out.new(Opcodes.lp_move_pet())
|> Out.encode_int(character_id)
# Encode slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, slot)
else
Out.encode_int(packet, slot)
end
packet
|> Out.encode_long(pet_unique_id)
|> Out.encode_bytes(movement_data)
|> Out.to_data()
end
@doc """
Pet chat packet (LP_PetChat).
Ported from PetPacket.petChat()
"""
def pet_chat(character_id, slot, command, text) do
packet = Out.new(Opcodes.lp_pet_chat())
|> Out.encode_int(character_id)
# Encode slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, slot)
else
Out.encode_int(packet, slot)
end
packet
|> Out.encode_short(command)
|> Out.encode_string(text)
|> Out.encode_byte(0) # hasQuoteRing
|> Out.to_data()
end
@doc """
Pet command response (LP_PetCommand).
Ported from PetPacket.commandResponse()
## Parameters
- character_id: Owner's character ID
- slot: Pet slot (0-2)
- command: Command ID that was executed
- success: Whether the command succeeded
- food: Whether this was a food command
"""
def pet_command_response(character_id, slot, command, success, food) do
packet = Out.new(Opcodes.lp_pet_command())
|> Out.encode_int(character_id)
# Encode slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, slot)
else
Out.encode_int(packet, slot)
end
# Command encoding differs for food
packet = if command == 1 do
Out.encode_byte(packet, 1)
else
Out.encode_byte(packet, 0)
end
packet = Out.encode_byte(packet, command)
packet = if command == 1 do
# Food command
Out.encode_byte(packet, 0)
else
# Regular command
Out.encode_short(packet, if(success, do: 1, else: 0))
end
Out.to_data(packet)
end
@doc """
Shows own pet level up effect (LP_ShowItemGainInChat).
Ported from PetPacket.showOwnPetLevelUp()
"""
def show_own_pet_level_up(slot) do
Out.new(Opcodes.lp_show_item_gain_inchat())
|> Out.encode_byte(6) # Type: pet level up
|> Out.encode_byte(0)
|> Out.encode_int(slot)
|> Out.to_data()
end
@doc """
Shows pet level up effect to other players (LP_ShowForeignEffect).
Ported from PetPacket.showPetLevelUp()
"""
def show_pet_level_up(character_id, slot) do
Out.new(Opcodes.lp_show_foreign_effect())
|> Out.encode_int(character_id)
|> Out.encode_byte(6) # Type: pet level up
|> Out.encode_byte(0)
|> Out.encode_int(slot)
|> Out.to_data()
end
@doc """
Pet name change/update (LP_PetNameChanged).
Ported from PetPacket.showPetUpdate()
"""
def pet_name_change(character_id, pet_unique_id, slot) do
packet = Out.new(Opcodes.lp_pet_namechange())
|> Out.encode_int(character_id)
# Encode slot based on GMS mode
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_byte(packet, slot)
else
Out.encode_int(packet, slot)
end
packet
|> Out.encode_long(pet_unique_id)
|> Out.encode_byte(0) # Unknown
|> Out.to_data()
end
@doc """
Pet stat update (UPDATE_STATS with PET flag).
Ported from PetPacket.petStatUpdate()
"""
def pet_stat_update(pets) do
# Filter summoned pets
summoned_pets = Enum.filter(pets, fn pet -> pet.summoned > 0 end)
packet = Out.new(Opcodes.lp_update_stats())
|> Out.encode_byte(0) # Reset mask flag
# Encode stat mask based on GMS mode
pet_stat_value = if Odinsea.Constants.Game.gms?(), do: 0x200000, else: 0x200000
packet = if Odinsea.Constants.Game.gms?() do
Out.encode_long(packet, pet_stat_value)
else
Out.encode_int(packet, pet_stat_value)
end
# Encode summoned pet unique IDs (up to 3)
packet = Enum.reduce(summoned_pets, packet, fn pet, p ->
Out.encode_long(p, pet.unique_id)
end)
# Fill remaining slots with empty
remaining = 3 - length(summoned_pets)
packet = Enum.reduce(1..remaining, packet, fn _, p ->
Out.encode_long(p, 0)
end)
packet
|> Out.encode_byte(0)
|> Out.encode_short(0)
|> Out.to_data()
end
# ============================================================================
# Pet Encoding Helpers
# ============================================================================
@doc false
defp encode_pet_item_info(packet, pet, item, active) do
# Encode full pet item info structure
# This includes pet stats, name, level, closeness, fullness, flags, etc.
packet = packet
|> Out.encode_string(pet.name)
|> Out.encode_byte(pet.level)
|> Out.encode_short(pet.closeness)
|> Out.encode_byte(pet.fullness)
|> Out.encode_long(if active, do: pet.unique_id, else: 0)
|> Out.encode_short(pet.inventory_position)
|> Out.encode_int(pet.seconds_left)
|> Out.encode_short(pet.flags)
|> Out.encode_int(pet.pet_item_id)
# Add equip info (3 slots: hat, saddle, decor)
# For now, encode empty slots
packet
|> Out.encode_long(0) # Pet equip slot 1
|> Out.encode_long(0) # Pet equip slot 2
|> Out.encode_long(0) # Pet equip slot 3
end
@doc """
Encodes pet info for character spawn packet.
Called when spawning a player with their active pets.
"""
def encode_spawn_pets(packet, pets) do
# Get summoned pets in slot order
summoned_pets = pets
|> Enum.filter(fn pet -> pet.summoned > 0 end)
|> Enum.sort_by(fn pet -> pet.summoned end)
# Encode 3 pet slots (0 = no pet)
{pet1, rest1} = List.pop_at(summoned_pets, 0, nil)
{pet2, rest2} = List.pop_at(rest1, 0, nil)
{pet3, _} = List.pop_at(rest2, 0, nil)
packet
|> encode_single_pet_for_spawn(pet1)
|> encode_single_pet_for_spawn(pet2)
|> encode_single_pet_for_spawn(pet3)
end
defp encode_single_pet_for_spawn(packet, nil) do
packet
|> Out.encode_byte(0) # No pet
end
defp encode_single_pet_for_spawn(packet, pet) do
packet
|> Out.encode_byte(1) # Has pet
|> Out.encode_int(pet.pet_item_id)
|> Out.encode_string(pet.name)
|> Out.encode_long(pet.unique_id)
|> Out.encode_short(pet.position.x)
|> Out.encode_short(pet.position.y)
|> Out.encode_byte(pet.stance)
|> Out.encode_int(pet.position.fh)
|> Out.encode_byte(pet.level)
|> Out.encode_short(pet.closeness)
|> Out.encode_byte(pet.fullness)
|> Out.encode_short(pet.flags)
|> Out.encode_int(0) # Pet equip item ID 1
|> Out.encode_int(0) # Pet equip item ID 2
|> Out.encode_int(0) # Pet equip item ID 3
|> Out.encode_int(0) # Pet equip item ID 4
end
end