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