defmodule Odinsea.Scripting.PlayerAPI do @moduledoc """ Player Interaction API for scripts. This module provides the interface that scripts use to interact with players, including dialogue functions, warping, item manipulation, and game mechanics. ## API Objects Different script types receive this API with different variable names: - `cm` - Conversation manager (NPC/Quest scripts) - `pi` - Portal interaction (Portal scripts) - `rm` - Reactor manager (Reactor scripts) - `qm` - Quest manager (Quest scripts - same as cm) ## Dialogue Functions ### Basic Dialogs - `send_ok/1` - Show OK button dialog - `send_next/1` - Show Next button dialog - `send_prev/1` - Show Previous button dialog - `send_next_prev/1` - Show Next and Previous buttons ### Choice Dialogs - `send_yes_no/1` - Yes/No choice - `send_accept_decline/1` - Accept/Decline choice - `send_simple/1` - Menu selection with #L tags ### Input Dialogs - `send_get_text/1` - Text input - `send_get_number/4` - Number input with min/max - `send_style/2` - Style selection (hair/face) - `ask_avatar/2` - Avatar preview ### Player-to-NPC Dialogs (Speaker) - `send_next_s/2` - Next with speaker type - `send_ok_s/2` - OK with speaker type - `send_yes_no_s/2` - Yes/No with speaker type ## Player Actions ### Warping - `warp/1` - Warp to map (random portal) - `warp/2` - Warp to map with specific portal - `warp_instanced/1` - Warp to event instance map ### Items - `gain_item/2` - Give/take items - `gain_item_period/3` - Give item with expiration - `have_item/1` - Check if player has item - `can_hold/1` - Check if player can hold item ### Stats - `gain_meso/1` - Give/take meso - `gain_exp/1` - Give EXP - `change_job/1` - Change job - `max_stats/0` - Max all stats (GM) ## Example Usage def start(cm) do PlayerAPI.send_next(cm, "Hello there!") end def action(cm, mode, type, selection) do if mode == 1 do PlayerAPI.send_yes_no(cm, "Would you like to go to Henesys?") else PlayerAPI.dispose(cm) end end """ require Logger import Bitwise alias Odinsea.Game.{Character, Inventory, Item, Map} alias Odinsea.Game.{LifeFactory, Monster} alias Odinsea.Channel.Packets alias Odinsea.Net.Packet.Out alias Odinsea.Net.Opcodes # Message type codes (matching Java implementation) @msg_ok 0 @msg_next 0 @msg_prev 0 @msg_next_prev 0 @msg_yes_no 2 @msg_get_text 3 @msg_get_number 4 @msg_simple 5 @msg_accept_decline 0x0E @msg_style 9 # Default channel for map operations @default_channel 1 # ============================================================================ # Types # ============================================================================ @type t :: %__MODULE__{ client_pid: pid(), character_id: integer(), npc_id: integer(), quest_id: integer() | nil, manager_pid: pid() | nil, get_text: String.t() | nil } defstruct [ :client_pid, :character_id, :npc_id, :quest_id, :manager_pid, :get_text ] # ============================================================================ # Constructor # ============================================================================ @doc """ Creates a new Player API instance. ## Parameters - `client_pid` - Client process PID - `character_id` - Character ID - `npc_id` - NPC ID (or portal/reactor ID) - `quest_id` - Quest ID (nil for non-quest) - `manager_pid` - NPCManager PID (for disposal) """ @spec new(pid(), integer(), integer(), integer() | nil, pid() | nil) :: t() def new(client_pid, character_id, npc_id, quest_id \\ nil, manager_pid \\ nil) do %__MODULE__{ client_pid: client_pid, character_id: character_id, npc_id: npc_id, quest_id: quest_id, manager_pid: manager_pid, get_text: nil } end # ============================================================================ # Conversation Control # ============================================================================ @doc """ Ends the conversation. ## Parameters - `api` - The API struct """ @spec dispose(t()) :: :ok def dispose(%__MODULE__{manager_pid: nil}), do: :ok def dispose(%__MODULE__{manager_pid: pid, client_pid: cpid, character_id: cid}) do Odinsea.Scripting.NPCManager.dispose(cpid, cid) end @doc """ Schedules disposal on the next action. ## Parameters - `api` - The API struct """ @spec safe_dispose(t()) :: :ok def safe_dispose(%__MODULE__{manager_pid: nil}), do: :ok def safe_dispose(%__MODULE__{client_pid: cpid, character_id: cid}) do Odinsea.Scripting.NPCManager.safe_dispose(cpid, cid) end @doc """ Sets the last message type for validation. ## Parameters - `api` - The API struct - `msg_type` - Message type code """ @spec set_last_msg(t(), integer()) :: :ok def set_last_msg(%__MODULE__{client_pid: cpid, character_id: cid}, msg_type) do Odinsea.Scripting.NPCManager.set_last_msg(cpid, cid, msg_type) end # ============================================================================ # Basic Dialogs # ============================================================================ @doc """ Sends an OK dialog. ## Parameters - `api` - The API struct - `text` - Dialog text (can include #b#k tags) """ @spec send_ok(t(), String.t()) :: :ok def send_ok(api, text) do send_ok_npc(api, text, api.npc_id) end @doc """ Sends an OK dialog with specific NPC ID. ## Parameters - `api` - The API struct - `text` - Dialog text - `npc_id` - NPC ID to display """ @spec send_ok_npc(t(), String.t(), integer()) :: :ok def send_ok_npc(api, text, npc_id) do packet = npc_talk_packet(npc_id, 0, text, "00 00", 0, npc_id) send_packet(api.client_pid, packet) Logger.debug("NPC #{npc_id} says (OK): #{text}") set_last_msg(api, @msg_ok) :ok end @doc """ Sends a Next dialog (user clicks Next to continue). ## Parameters - `api` - The API struct - `text` - Dialog text """ @spec send_next(t(), String.t()) :: :ok def send_next(api, text) do send_next_npc(api, text, api.npc_id) end @doc """ Sends a Next dialog with specific NPC ID. """ @spec send_next_npc(t(), String.t(), integer()) :: :ok def send_next_npc(api, text, npc_id) do # Check for #L tags (would DC otherwise) if String.contains?(text, "#L") do send_simple_npc(api, text, npc_id) else # TODO: Send NPCTalk packet with "00 01" style Logger.debug("NPC #{npc_id} says (Next): #{text}") set_last_msg(api, @msg_next) end :ok end @doc """ Sends a Previous dialog. """ @spec send_prev(t(), String.t()) :: :ok def send_prev(api, text) do send_prev_npc(api, text, api.npc_id) end @doc """ Sends a Previous dialog with specific NPC ID. """ @spec send_prev_npc(t(), String.t(), integer()) :: :ok def send_prev_npc(api, text, npc_id) do if String.contains?(text, "#L") do send_simple_npc(api, text, npc_id) else packet = npc_talk_packet(npc_id, 0, text, "01 00", 0, npc_id) send_packet(api.client_pid, packet) Logger.debug("NPC #{npc_id} says (Prev): #{text}") set_last_msg(api, @msg_prev) end :ok end @doc """ Sends a Next/Previous dialog. """ @spec send_next_prev(t(), String.t()) :: :ok def send_next_prev(api, text) do send_next_prev_npc(api, text, api.npc_id) end @doc """ Sends a Next/Previous dialog with specific NPC ID. """ @spec send_next_prev_npc(t(), String.t(), integer()) :: :ok def send_next_prev_npc(api, text, npc_id) do if String.contains?(text, "#L") do send_simple_npc(api, text, npc_id) else packet = npc_talk_packet(npc_id, 0, text, "01 01", 0, npc_id) send_packet(api.client_pid, packet) Logger.debug("NPC #{npc_id} says (Next/Prev): #{text}") set_last_msg(api, @msg_next_prev) end :ok end # ============================================================================ # Speaker Dialogs (Player-to-NPC) # ============================================================================ @doc """ Sends a Next dialog with speaker. ## Parameters - `api` - The API struct - `text` - Dialog text - `speaker_type` - Speaker type (0=NPC, 1=No ESC, 3=Player) """ @spec send_next_s(t(), String.t(), integer()) :: :ok def send_next_s(api, text, speaker_type) do send_next_s_npc(api, text, speaker_type, api.npc_id) end @doc """ Sends a Next dialog with speaker and specific NPC ID. """ @spec send_next_s_npc(t(), String.t(), integer(), integer()) :: :ok def send_next_s_npc(api, text, speaker_type, npc_id) do if String.contains?(text, "#L") do send_simple_s_npc(api, text, speaker_type, npc_id) else packet = npc_talk_packet(api.npc_id, 0, text, "00 01", speaker_type, npc_id) send_packet(api.client_pid, packet) Logger.debug("NPC #{npc_id} says (NextS, type=#{speaker_type}): #{text}") set_last_msg(api, @msg_next) end :ok end @doc """ Sends an OK dialog with speaker. """ @spec send_ok_s(t(), String.t(), integer()) :: :ok def send_ok_s(api, text, speaker_type) do packet = npc_talk_packet(api.npc_id, 0, text, "00 00", speaker_type, api.npc_id) send_packet(api.client_pid, packet) Logger.debug("NPC #{api.npc_id} says (OkS, type=#{speaker_type}): #{text}") set_last_msg(api, @msg_ok) :ok end @doc """ Sends a Yes/No dialog with speaker. """ @spec send_yes_no_s(t(), String.t(), integer()) :: :ok def send_yes_no_s(api, text, speaker_type) do packet = npc_talk_packet(api.npc_id, 2, text, "", speaker_type, api.npc_id) send_packet(api.client_pid, packet) Logger.debug("NPC #{api.npc_id} asks (YesNoS, type=#{speaker_type}): #{text}") set_last_msg(api, @msg_yes_no) :ok end @doc """ Sends a Player-to-NPC dialog (convenience). """ @spec player_to_npc(t(), String.t()) :: :ok def player_to_npc(api, text) do send_next_s(api, text, 3) end # ============================================================================ # Choice Dialogs # ============================================================================ @doc """ Sends a Yes/No dialog. ## Parameters - `api` - The API struct - `text` - Question text """ @spec send_yes_no(t(), String.t()) :: :ok def send_yes_no(api, text) do send_yes_no_npc(api, text, api.npc_id) end @doc """ Sends a Yes/No dialog with specific NPC ID. """ @spec send_yes_no_npc(t(), String.t(), integer()) :: :ok def send_yes_no_npc(api, text, npc_id) do if String.contains?(text, "#L") do send_simple_npc(api, text, npc_id) else packet = npc_talk_packet(npc_id, 2, text, "", 0, npc_id) send_packet(api.client_pid, packet) Logger.debug("NPC #{npc_id} asks (Yes/No): #{text}") set_last_msg(api, @msg_yes_no) end :ok end @doc """ Sends an Accept/Decline dialog. """ @spec send_accept_decline(t(), String.t()) :: :ok def send_accept_decline(api, text) do send_accept_decline_npc(api, text, api.npc_id) end @doc """ Sends an Accept/Decline dialog with specific NPC ID. """ @spec send_accept_decline_npc(t(), String.t(), integer()) :: :ok def send_accept_decline_npc(api, text, npc_id) do if String.contains?(text, "#L") do send_simple_npc(api, text, npc_id) else # Type 0x0E (14) for Accept/Decline packet = npc_talk_packet(npc_id, 0x0E, text, "", 0, npc_id) send_packet(api.client_pid, packet) Logger.debug("NPC #{npc_id} asks (Accept/Decline): #{text}") set_last_msg(api, @msg_accept_decline) end :ok end @doc """ Sends a simple menu dialog. Text should contain #L tags for menu items: `#L0#Option 1#l\r\n#L1#Option 2#l` """ @spec send_simple(t(), String.t()) :: :ok def send_simple(api, text) do send_simple_npc(api, text, api.npc_id) end @doc """ Sends a simple menu dialog with specific NPC ID. """ @spec send_simple_npc(t(), String.t(), integer()) :: :ok def send_simple_npc(api, text, npc_id) do if not String.contains?(text, "#L") do # Would DC otherwise send_next_npc(api, text, npc_id) else packet = npc_talk_packet(npc_id, 5, text, "", 0, npc_id) send_packet(api.client_pid, packet) Logger.debug("NPC #{npc_id} menu: #{text}") set_last_msg(api, @msg_simple) end :ok end @doc """ Sends a simple menu with selection array. ## Parameters - `api` - The API struct - `text` - Intro text - `selections` - List of menu options """ @spec send_simple_selections(t(), String.t(), [String.t()]) :: :ok def send_simple_selections(api, text, selections) do menu_text = if length(selections) > 0 do text <> "#b\r\n" <> build_menu(selections) else text end send_simple(api, menu_text) end defp build_menu(selections) do selections |> Enum.with_index() |> Enum.map_join("\r\n", fn {item, idx} -> "#L#{idx}##{item}#l" end) end # ============================================================================ # Input Dialogs # ============================================================================ @doc """ Sends a text input dialog. ## Parameters - `api` - The API struct - `text` - Prompt text """ @spec send_get_text(t(), String.t()) :: :ok def send_get_text(api, text) do send_get_text_npc(api, text, api.npc_id) end @doc """ Sends a text input dialog with specific NPC ID. """ @spec send_get_text_npc(t(), String.t(), integer()) :: :ok def send_get_text_npc(api, text, npc_id) do if String.contains?(text, "#L") do send_simple_npc(api, text, npc_id) else packet = npc_talk_text_packet(npc_id, text, 0, 0, "") send_packet(api.client_pid, packet) Logger.debug("NPC #{npc_id} asks for text: #{text}") set_last_msg(api, @msg_get_text) end :ok end @doc """ Sends a text input dialog with constraints. ## Parameters - `api` - The API struct - `text` - Prompt text - `min` - Minimum length - `max` - Maximum length - `default` - Default text """ @spec send_get_text_constrained(t(), String.t(), integer(), integer(), String.t()) :: :ok def send_get_text_constrained(api, text, min, max, default) do packet = npc_talk_text_packet(api.npc_id, text, min, max, default) send_packet(api.client_pid, packet) Logger.debug("NPC #{api.npc_id} asks for text (#{min}-#{max}): #{text}") set_last_msg(api, @msg_get_text) :ok end @doc """ Sends a number input dialog. ## Parameters - `api` - The API struct - `text` - Prompt text - `default` - Default value - `min` - Minimum value - `max` - Maximum value """ @spec send_get_number(t(), String.t(), integer(), integer(), integer()) :: :ok def send_get_number(api, text, default, min, max) do if String.contains?(text, "#L") do send_simple(api, text) else packet = npc_talk_num_packet(api.npc_id, text, default, min, max) send_packet(api.client_pid, packet) Logger.debug("NPC #{api.npc_id} asks for number (#{min}-#{max}, default #{default}): #{text}") set_last_msg(api, @msg_get_number) end :ok end @doc """ Sends a style selection dialog (for hair/face). ## Parameters - `api` - The API struct - `text` - Prompt text - `styles` - List of style IDs """ @spec send_style(t(), String.t(), [integer()]) :: :ok def send_style(api, text, styles) do send_style_paged(api, text, styles, 0) end @doc """ Sends a style selection dialog with page. """ @spec send_style_paged(t(), String.t(), [integer()], integer()) :: :ok def send_style_paged(api, text, styles, page) do packet = npc_talk_style_packet(api.npc_id, text, styles, page) send_packet(api.client_pid, packet) Logger.debug("NPC #{api.npc_id} style selection (page #{page}): #{length(styles)} options") set_last_msg(api, @msg_style) :ok end @doc """ Sends an avatar selection dialog. """ @spec ask_avatar(t(), String.t(), [integer()]) :: :ok def ask_avatar(api, text, styles) do send_style_paged(api, text, styles, 0) end @doc """ Sends a map selection dialog. """ @spec ask_map_selection(t(), String.t()) :: :ok def ask_map_selection(api, selection_string) do packet = map_selection_packet(api.npc_id, selection_string) send_packet(api.client_pid, packet) Logger.debug("NPC #{api.npc_id} map selection") set_last_msg(api, 0x10) :ok end # ============================================================================ # Get/Set Text # ============================================================================ @doc """ Sets the text received from player input. ## Parameters - `api` - The API struct - `text` - The text value """ @spec set_get_text(t(), String.t()) :: t() def set_get_text(%__MODULE__{} = api, text) do %{api | get_text: text} end @doc """ Gets the text received from player input. """ @spec get_get_text(t()) :: String.t() | nil def get_get_text(%__MODULE__{get_text: text}), do: text # ============================================================================ # Character Appearance # ============================================================================ @doc """ Sets the character's hair style. ## Parameters - `api` - The API struct - `hair` - Hair style ID """ @spec set_hair(t(), integer()) :: :ok def set_hair(api, hair) do # Update character hair through Character module case Character.get_state(api.character_id) do nil -> Logger.warn("Character #{api.character_id} not found for set_hair") %Character.State{} = state -> # Send stat update packet packet = update_stat_packet([{:hair, hair}], state.job) send_packet(api.client_pid, packet) Logger.debug("Set hair to #{hair} for character #{api.character_id}") end :ok end @doc """ Sets the character's face. """ @spec set_face(t(), integer()) :: :ok def set_face(api, face) do case Character.get_state(api.character_id) do nil -> Logger.warn("Character #{api.character_id} not found for set_face") %Character.State{} = state -> packet = update_stat_packet([{:face, face}], state.job) send_packet(api.client_pid, packet) Logger.debug("Set face to #{face} for character #{api.character_id}") end :ok end @doc """ Sets the character's skin color. """ @spec set_skin(t(), integer()) :: :ok def set_skin(api, color) do case Character.get_state(api.character_id) do nil -> Logger.warn("Character #{api.character_id} not found for set_skin") %Character.State{} = state -> packet = update_stat_packet([{:skin, color}], state.job) send_packet(api.client_pid, packet) Logger.debug("Set skin color to #{color} for character #{api.character_id}") end :ok end @doc """ Sets a random avatar from given options. """ @spec set_random_avatar(t(), integer(), [integer()]) :: integer() def set_random_avatar(api, ticket, options) do if have_item(api, ticket) do gain_item(api, ticket, -1) style = Enum.random(options) cond do style < 100 -> set_skin(api, style) style < 30000 -> set_face(api, style) true -> set_hair(api, style) end 1 else -1 end end @doc """ Sets a specific avatar style. """ @spec set_avatar(t(), integer(), integer()) :: integer() def set_avatar(api, ticket, style) do if have_item(api, ticket) do gain_item(api, ticket, -1) cond do style < 100 -> set_skin(api, style) style < 30000 -> set_face(api, style) true -> set_hair(api, style) end 1 else -1 end end # ============================================================================ # Warping # ============================================================================ @doc """ Warps the player to a map. ## Parameters - `api` - The API struct - `map_id` - Target map ID """ @spec warp(t(), integer()) :: :ok def warp(api, map_id) do # Use random portal warp_portal(api, map_id, 0) end @doc """ Warps the player to a map with specific portal. ## Parameters - `api` - The API struct - `map_id` - Target map ID - `portal` - Portal ID or name """ @spec warp_portal(t(), integer(), integer() | String.t()) :: :ok def warp_portal(api, map_id, portal) do Logger.debug("Warping character #{api.character_id} to map #{map_id}, portal #{inspect(portal)}") # Use Character.change_map which handles the map transition spawn_point = if is_integer(portal), do: portal, else: 0 Character.change_map(api.character_id, map_id, spawn_point) :ok end @doc """ Warps the player to an instanced map. """ @spec warp_instanced(t(), integer()) :: :ok def warp_instanced(api, map_id) do Logger.debug("Warping character #{api.character_id} to instanced map #{map_id}") # TODO: Handle event instance maps Character.change_map(api.character_id, map_id, 0) :ok end @doc """ Warps all players on the current map. """ @spec warp_map(t(), integer(), integer()) :: :ok def warp_map(api, map_id, portal) do Logger.debug("Warping all players on current map to map #{map_id}") # Get current map and warp all players case Character.get_state(api.character_id) do nil -> :ok %Character.State{map_id: current_map_id} -> # Get all players on current map {:ok, channel_id} = Character.get_channel(api.character_id) players = Map.get_players(current_map_id, channel_id) Enum.each(players, fn {char_id, _} -> if char_id != api.character_id do Character.change_map(char_id, map_id, portal) end end) # Also warp self Character.change_map(api.character_id, map_id, portal) end :ok end @doc """ Warps the entire party. """ @spec warp_party(t(), integer()) :: :ok def warp_party(api, map_id) do Logger.debug("Warping party to map #{map_id}") # TODO: Get party members and warp them # For now, just warp self Character.change_map(api.character_id, map_id, 0) :ok end @doc """ Plays the portal sound effect. """ @spec play_portal_se(t()) :: :ok def play_portal_se(api) do # Send show effect packet for portal sound # Effect type 7 is the portal sound effect packet = show_own_buff_effect_packet(0, 7, 1, 1) send_packet(api.client_pid, packet) :ok end # ============================================================================ # Items # ============================================================================ @doc """ Gives or takes items from the player. ## Parameters - `api` - The API struct - `item_id` - Item ID - `quantity` - Positive to give, negative to take """ @spec gain_item(t(), integer(), integer()) :: :ok def gain_item(api, item_id, quantity) do gain_item_full(api, item_id, quantity, false, 0, -1, "") end @doc """ Gives items with random stats. """ @spec gain_item_random(t(), integer(), integer(), boolean()) :: :ok def gain_item_random(api, item_id, quantity, random_stats) do gain_item_full(api, item_id, quantity, random_stats, 0, -1, "") end @doc """ Gives items with slot bonus. """ @spec gain_item_slots(t(), integer(), integer(), boolean(), integer()) :: :ok def gain_item_slots(api, item_id, quantity, random_stats, slots) do gain_item_full(api, item_id, quantity, random_stats, 0, slots, "") end @doc """ Gives items with expiration period. """ @spec gain_item_period(t(), integer(), integer(), integer()) :: :ok def gain_item_period(api, item_id, quantity, days) do gain_item_full(api, item_id, quantity, false, days, -1, "") end @doc """ Gives items with full options. """ @spec gain_item_full(t(), integer(), integer(), boolean(), integer(), integer(), String.t()) :: :ok def gain_item_full(api, item_id, quantity, random_stats, period, slots, owner) do Logger.debug("Gain item #{item_id} x#{quantity} (random=#{random_stats}, period=#{period})") if quantity >= 0 do # Add item to inventory inventory_type = Inventory.get_type_by_item_id(item_id) # Get or create item item = if inventory_type == :equip do # For equipment, create with random stats if requested Item.new_equip(item_id, random_stats) else # For regular items Item.new(item_id, quantity) end # Set expiration if period > 0 item = if period > 0 do expire_time = System.system_time(:millisecond) + (period * 24 * 60 * 60 * 1000) %{item | expiration: expire_time} else item end # Set owner if provided item = if owner != "" do %{item | owner: owner} else item end # Add slots if specified (for equipment) item = if slots > 0 and inventory_type == :equip do %{item | upgrade_slots: item.upgrade_slots + slots} else item end # Add to inventory case Character.add_item(api.character_id, inventory_type, item) do :ok -> # Send item gain packet packet = Packets.show_item_gain(item_id, quantity, true) send_packet(api.client_pid, packet) :ok {:error, reason} -> Logger.warn("Failed to add item #{item_id}: #{inspect(reason)}") :ok end else # Remove item (negative quantity) remove_count = -quantity case Character.remove_item_by_id(api.character_id, item_id, remove_count) do :ok -> packet = Packets.show_item_gain(item_id, quantity, true) send_packet(api.client_pid, packet) :ok {:error, reason} -> Logger.warn("Failed to remove item #{item_id}: #{inspect(reason)}") :ok end end end @doc """ Checks if player has an item. """ @spec have_item(t(), integer()) :: boolean() def have_item(api, item_id) do have_item_count(api, item_id, 1) end @doc """ Checks if player has at least quantity of an item. """ @spec have_item_count(t(), integer(), integer()) :: boolean() def have_item_count(api, item_id, quantity) do case Character.get_state(api.character_id) do nil -> false %Character.State{inventories: inventories} -> inventory_type = Inventory.get_type_by_item_id(item_id) inventory = Map.get(inventories, inventory_type, Inventory.new(inventory_type)) Inventory.has_item_count(inventory, item_id, quantity) end end @doc """ Removes an item from inventory. """ @spec remove_item(t(), integer()) :: boolean() def remove_item(api, item_id) do case Character.remove_item_by_id(api.character_id, item_id, 1) do :ok -> packet = Packets.show_item_gain(item_id, -1, true) send_packet(api.client_pid, packet) true {:error, _reason} -> false end end @doc """ Checks if player can hold an item. """ @spec can_hold(t(), integer()) :: boolean() def can_hold(api, item_id) do case Character.get_state(api.character_id) do nil -> false %Character.State{inventories: inventories} -> inventory_type = Inventory.get_type_by_item_id(item_id) inventory = Map.get(inventories, inventory_type, Inventory.new(inventory_type)) Inventory.has_free_slot(inventory) end end @doc """ Checks if player can hold quantity of an item. """ @spec can_hold_quantity(t(), integer(), integer()) :: boolean() def can_hold_quantity(api, item_id, quantity) do case Character.get_state(api.character_id) do nil -> false %Character.State{inventories: inventories} -> inventory_type = Inventory.get_type_by_item_id(item_id) inventory = Map.get(inventories, inventory_type, Inventory.new(inventory_type)) Inventory.can_hold_quantity(inventory, item_id, quantity) end end # ============================================================================ # Meso/EXP # ============================================================================ @doc """ Gives or takes meso. ## Parameters - `api` - The API struct - `amount` - Positive to give, negative to take """ @spec gain_meso(t(), integer()) :: :ok def gain_meso(api, amount) do Logger.debug("Gain meso: #{amount} for character #{api.character_id}") case Character.get_state(api.character_id) do nil -> Logger.warn("Character #{api.character_id} not found for gain_meso") %Character.State{meso: current_meso} -> new_meso = max(0, current_meso + amount) # Update character meso Character.update_meso(api.character_id, new_meso) # Send meso update packet packet = update_stat_packet([{:meso, new_meso}], 0) send_packet(api.client_pid, packet) end :ok end @doc """ Gets the player's current meso. """ @spec get_meso(t()) :: integer() def get_meso(api) do case Character.get_state(api.character_id) do nil -> 0 %Character.State{meso: meso} -> meso end end @doc """ Gives EXP to the player. """ @spec gain_exp(t(), integer()) :: :ok def gain_exp(api, amount) do Logger.debug("Gain EXP: #{amount} for character #{api.character_id}") Character.gain_exp(api.character_id, amount, false) :ok end @doc """ Gives EXP with rate multiplier. """ @spec gain_exp_r(t(), integer()) :: :ok def gain_exp_r(api, amount) do # TODO: Apply rate multiplier gain_exp(api, amount) end # ============================================================================ # Jobs and Skills # ============================================================================ @doc """ Changes the player's job. """ @spec change_job(t(), integer()) :: :ok def change_job(api, job_id) do Logger.debug("Change job to #{job_id} for character #{api.character_id}") case Character.get_state(api.character_id) do nil -> Logger.warn("Character #{api.character_id} not found for change_job") %Character.State{} = state -> # Update job Character.update_job(api.character_id, job_id) # Send job update packet packet = update_stat_packet([{:job, job_id}], job_id) send_packet(api.client_pid, packet) # Show job change effect effect_packet = show_foreign_effect_packet(api.character_id, 8) # Broadcast to map {:ok, channel_id} = Character.get_channel(api.character_id) Map.broadcast(state.map_id, channel_id, effect_packet) end :ok end @doc """ Gets the player's current job. """ @spec get_job(t()) :: integer() def get_job(api) do case Character.get_state(api.character_id) do nil -> 0 %Character.State{job: job} -> job end end @doc """ Teaches a skill to the player. """ @spec teach_skill(t(), integer(), integer(), integer()) :: :ok def teach_skill(api, skill_id, level, master_level) do Logger.debug("Teach skill #{skill_id} level #{level}/#{master_level} to character #{api.character_id}") # Update skill through Character module Character.update_skill(api.character_id, skill_id, level, master_level) # Send skill update packet packet = update_skill_packet(skill_id, level, master_level, System.system_time(:millisecond)) send_packet(api.client_pid, packet) :ok end @doc """ Teaches a skill with max master level. """ @spec teach_skill_max(t(), integer(), integer()) :: :ok def teach_skill_max(api, skill_id, level) do # TODO: Get max level from skill data teach_skill(api, skill_id, level, 20) end @doc """ Checks if player has a skill. """ @spec has_skill(t(), integer()) :: boolean() def has_skill(api, skill_id) do case Character.get_state(api.character_id) do nil -> false %Character.State{skills: skills} -> case Map.get(skills, skill_id) do nil -> false %{level: level} -> level > 0 end end end @doc """ Maxes all skills for the player (GM). """ @spec max_all_skills(t()) :: :ok def max_all_skills(api) do Logger.debug("Max all skills for character #{api.character_id}") # TODO: Get all skills for current job and max them :ok end # ============================================================================ # Stats # ============================================================================ @doc """ Maxes all stats for the player (GM). """ @spec max_stats(t()) :: :ok def max_stats(_api) do Logger.debug("Max stats") # TODO: Set all stats to max :ok end @doc """ Adds AP (ability points). """ @spec gain_ap(t(), integer()) :: :ok def gain_ap(_api, amount) do Logger.debug("Gain AP: #{amount}") :ok end @doc """ Resets stats to specified values. """ @spec reset_stats(t(), integer(), integer(), integer(), integer()) :: :ok def reset_stats(_api, str, dex, int, luk) do Logger.debug("Reset stats to STR=#{str} DEX=#{dex} INT=#{int} LUK=#{luk}") :ok end @doc """ Gets a player stat by name. Valid stat names: "LVL", "STR", "DEX", "INT", "LUK", "HP", "MP", "MAXHP", "MAXMP", "RAP", "RSP", "GID", "GRANK", "ARANK", "GM", "ADMIN", "GENDER", "FACE", "HAIR" """ @spec get_player_stat(t(), String.t()) :: integer() def get_player_stat(_api, stat_name) do Logger.debug("Get stat: #{stat_name}") # TODO: Return stat value case stat_name do "LVL" -> 1 "GM" -> 0 "ADMIN" -> 0 _ -> 0 end end # ============================================================================ # Quests # ============================================================================ @doc """ Starts a quest. """ @spec start_quest(t(), integer()) :: :ok def start_quest(api, quest_id) do Logger.debug("Start quest #{quest_id} for character #{api.character_id}") # Start quest through Quest module Odinsea.Game.Quest.start_quest(api.character_id, quest_id, api.npc_id) # Send quest start packet packet = quest_operation_packet(quest_id, 1) send_packet(api.client_pid, packet) :ok end @doc """ Completes a quest. """ @spec complete_quest(t(), integer()) :: :ok def complete_quest(api, quest_id) do Logger.debug("Complete quest #{quest_id} for character #{api.character_id}") # Complete quest through Quest module Odinsea.Game.Quest.complete_quest(api.character_id, quest_id, api.npc_id) # Send quest complete packet packet = quest_operation_packet(quest_id, 2) send_packet(api.client_pid, packet) :ok end @doc """ Forfeits a quest. """ @spec forfeit_quest(t(), integer()) :: :ok def forfeit_quest(api, quest_id) do Logger.debug("Forfeit quest #{quest_id} for character #{api.character_id}") # Forfeit quest through Quest module Odinsea.Game.Quest.forfeit_quest(api.character_id, quest_id) # Send quest forfeit packet packet = quest_operation_packet(quest_id, 0) send_packet(api.client_pid, packet) :ok end @doc """ Forces a quest start. """ @spec force_start_quest(t(), integer()) :: :ok def force_start_quest(_api, quest_id) do Logger.debug("Force start quest #{quest_id}") :ok end @doc """ Forces a quest complete. """ @spec force_complete_quest(t(), integer()) :: :ok def force_complete_quest(_api, quest_id) do Logger.debug("Force complete quest #{quest_id}") :ok end @doc """ Gets quest custom data. """ @spec get_quest_custom_data(t()) :: String.t() | nil def get_quest_custom_data(_api) do nil end @doc """ Sets quest custom data. """ @spec set_quest_custom_data(t(), String.t()) :: :ok def set_quest_custom_data(_api, data) do Logger.debug("Set quest custom data: #{data}") :ok end @doc """ Gets quest record. """ @spec get_quest_record(t(), integer()) :: term() def get_quest_record(_api, _quest_id) do nil end @doc """ Gets quest status (0 = not started, 1 = in progress, 2 = completed). """ @spec get_quest_status(t(), integer()) :: integer() def get_quest_status(api, quest_id) do case Character.get_state(api.character_id) do nil -> 0 %Character.State{} -> Odinsea.Game.Quest.get_status(api.character_id, quest_id) end end @doc """ Checks if quest is active. """ @spec is_quest_active(t(), integer()) :: boolean() def is_quest_active(api, quest_id) do get_quest_status(api, quest_id) == 1 end @doc """ Checks if quest is finished. """ @spec is_quest_finished(t(), integer()) :: boolean() def is_quest_finished(api, quest_id) do get_quest_status(api, quest_id) == 2 end # ============================================================================ # Map/Mob Operations # ============================================================================ @doc """ Gets the current map ID. """ @spec get_map_id(t()) :: integer() def get_map_id(api) do case Character.get_state(api.character_id) do nil -> 100000000 %Character.State{map_id: map_id} -> map_id end end @doc """ Gets map reference. """ @spec get_map(t(), integer()) :: term() def get_map(_api, map_id) do # TODO: Get map instance %{id: map_id} end @doc """ Spawns a monster. """ @spec spawn_monster(t(), integer()) :: :ok def spawn_monster(api, mob_id) do spawn_monster_qty(api, mob_id, 1) end @doc """ Spawns multiple monsters. """ @spec spawn_monster_qty(t(), integer(), integer()) :: :ok def spawn_monster_qty(api, mob_id, qty) do Logger.debug("Spawn monster #{mob_id} x#{qty} for character #{api.character_id}") case Character.get_state(api.character_id) do nil -> :ok %Character.State{map_id: map_id} -> {:ok, channel_id} = Character.get_channel(api.character_id) # Get player's position for spawn location spawn_position = get_player_position(api) Enum.each(1..qty, fn _ -> spawn_monster_on_map(map_id, channel_id, mob_id, spawn_position) end) end :ok end @doc """ Spawns monster at position. """ @spec spawn_monster_pos(t(), integer(), integer(), integer(), integer()) :: :ok def spawn_monster_pos(api, mob_id, qty, x, y) do Logger.debug("Spawn monster #{mob_id} x#{qty} at (#{x}, #{y})") case Character.get_state(api.character_id) do nil -> :ok %Character.State{map_id: map_id} -> {:ok, channel_id} = Character.get_channel(api.character_id) position = %{x: x, y: y, fh: 0} Enum.each(1..qty, fn _ -> spawn_monster_on_map(map_id, channel_id, mob_id, position) end) end :ok end @doc """ Kills all monsters on current map. """ @spec kill_all_mob(t()) :: :ok def kill_all_mob(api) do Logger.debug("Kill all monsters for character #{api.character_id}") case Character.get_state(api.character_id) do nil -> :ok %Character.State{map_id: map_id} -> {:ok, channel_id} = Character.get_channel(api.character_id) # Get all monsters on map monsters = Map.get_monsters(map_id, channel_id) Enum.each(monsters, fn {oid, monster} -> # Kill monster Map.monster_killed(map_id, channel_id, oid, api.character_id) # Broadcast kill packet kill_packet = Packets.kill_monster(monster, 1) Map.broadcast(map_id, channel_id, kill_packet) end) end :ok end @doc """ Kills specific monster. """ @spec kill_mob(t(), integer()) :: :ok def kill_mob(api, mob_id) do Logger.debug("Kill monster #{mob_id} for character #{api.character_id}") case Character.get_state(api.character_id) do nil -> :ok %Character.State{map_id: map_id} -> {:ok, channel_id} = Character.get_channel(api.character_id) # Find and kill monster by mob_id monsters = Map.get_monsters(map_id, channel_id) Enum.each(monsters, fn {oid, monster} -> if monster.mob_id == mob_id do Map.monster_killed(map_id, channel_id, oid, api.character_id) kill_packet = Packets.kill_monster(monster, 1) Map.broadcast(map_id, channel_id, kill_packet) end end) end :ok end @doc """ Spawns an NPC. """ @spec spawn_npc(t(), integer()) :: :ok def spawn_npc(api, npc_id) do # TODO: Get player position and spawn Logger.debug("Spawn NPC #{npc_id}") :ok end @doc """ Spawns NPC at position. """ @spec spawn_npc_pos(t(), integer(), integer(), integer()) :: :ok def spawn_npc_pos(_api, npc_id, x, y) do Logger.debug("Spawn NPC #{npc_id} at (#{x}, #{y})") :ok end @doc """ Removes an NPC from current map. """ @spec remove_npc(t(), integer()) :: :ok def remove_npc(_api, npc_id) do Logger.debug("Remove NPC #{npc_id}") :ok end @doc """ Resets a map. """ @spec reset_map(t(), integer()) :: :ok def reset_map(_api, map_id) do Logger.debug("Reset map #{map_id}") # TODO: Reset map :ok end # ============================================================================ # Party Operations # ============================================================================ @doc """ Checks if player is party leader. """ @spec is_leader(t()) :: boolean() def is_leader(_api) do # TODO: Check party leadership false end @doc """ Gets party members in current map. """ @spec party_members_in_map(t()) :: integer() def party_members_in_map(_api) do # TODO: Count party members in map 1 end @doc """ Gets all party members. """ @spec get_party_members(t()) :: [term()] def get_party_members(_api) do [] end @doc """ Checks if all party members are in current map. """ @spec all_members_here(t()) :: boolean() def all_members_here(_api) do true end @doc """ Warps party with EXP reward. """ @spec warp_party_with_exp(t(), integer(), integer()) :: :ok def warp_party_with_exp(api, map_id, exp) do warp_party(api, map_id) gain_exp(api, exp) :ok end @doc """ Warps party with EXP and meso reward. """ @spec warp_party_with_exp_meso(t(), integer(), integer(), integer()) :: :ok def warp_party_with_exp_meso(api, map_id, exp, meso) do warp_party(api, map_id) gain_exp(api, exp) gain_meso(api, meso) :ok end # ============================================================================ # Guild Operations # ============================================================================ @doc """ Gets guild ID. """ @spec get_guild_id(t()) :: integer() def get_guild_id(_api) do 0 end @doc """ Increases guild capacity. """ @spec increase_guild_capacity(t(), boolean()) :: boolean() def increase_guild_capacity(_api, _true_max) do false end @doc """ Displays guild ranks. """ @spec display_guild_ranks(t()) :: :ok def display_guild_ranks(_api) do :ok end # ============================================================================ # Messages # ============================================================================ @doc """ Sends a message to the player. """ @spec player_message(t(), String.t()) :: :ok def player_message(api, message) do player_message_type(api, 5, message) end @doc """ Sends a message with type. Types: 1 = Popup, 5 = Chat, -1 = Important """ @spec player_message_type(t(), integer(), String.t()) :: :ok def player_message_type(api, type, message) do Logger.debug("Player message (#{type}): #{message}") packet = Packets.drop_message(type, message) send_packet(api.client_pid, packet) :ok end @doc """ Sends a message to the map. """ @spec map_message(t(), String.t()) :: :ok def map_message(api, message) do map_message_type(api, 5, message) end @doc """ Sends a message to the map with type. """ @spec map_message_type(t(), integer(), String.t()) :: :ok def map_message_type(api, type, message) do Logger.debug("Map message (#{type}): #{message}") case Character.get_state(api.character_id) do nil -> :ok %Character.State{map_id: map_id} -> {:ok, channel_id} = Character.get_channel(api.character_id) packet = Packets.server_notice(type, message) Map.broadcast(map_id, channel_id, packet) end :ok end @doc """ Sends a world message. """ @spec world_message(t(), integer(), String.t()) :: :ok def world_message(_api, type, message) do Logger.debug("World message (#{type}): #{message}") # Broadcast to all channels packet = Packets.server_notice(type, message) Odinsea.World.broadcast(packet) :ok end @doc """ Sends a guild message. """ @spec guild_message(t(), String.t()) :: :ok def guild_message(api, message) do Logger.debug("Guild message: #{message}") case Character.get_state(api.character_id) do nil -> :ok %Character.State{guild_id: guild_id} when guild_id > 0 -> packet = Packets.server_notice(5, message) Odinsea.World.Guild.guild_packet(guild_id, packet) _ -> :ok end :ok end @doc """ Shows quest message. """ @spec show_quest_msg(t(), String.t()) :: :ok def show_quest_msg(api, msg) do Logger.debug("Quest message: #{msg}") packet = show_quest_msg_packet(msg) send_packet(api.client_pid, packet) :ok end # ============================================================================ # Storage/Shop # ============================================================================ @doc """ Opens storage. """ @spec open_storage(t()) :: :ok def open_storage(_api) do Logger.debug("Open storage") :ok end @doc """ Opens a shop. """ @spec open_shop(t(), integer()) :: :ok def open_shop(_api, shop_id) do Logger.debug("Open shop #{shop_id}") :ok end # ============================================================================ # Event/Instance # ============================================================================ @doc """ Gets event manager. """ @spec get_event_manager(t(), String.t()) :: term() def get_event_manager(_api, _event_name) do nil end @doc """ Gets event instance. """ @spec get_event_instance(t()) :: term() def get_event_instance(_api) do nil end @doc """ Removes player from instance. """ @spec remove_player_from_instance(t()) :: boolean() def remove_player_from_instance(_api) do false end @doc """ Checks if player is in an instance. """ @spec is_player_instance(t()) :: boolean() def is_player_instance(_api) do false end # ============================================================================ # Miscellaneous # ============================================================================ @doc """ Opens an NPC by ID. """ @spec open_npc(t(), integer()) :: :ok def open_npc(%__MODULE__{client_pid: cpid, character_id: cid}, npc_id) do Odinsea.Scripting.NPCManager.start_conversation(cpid, cid, npc_id) end @doc """ Opens an NPC with specific script. """ @spec open_npc_script(t(), integer(), String.t()) :: :ok def open_npc_script(%__MODULE__{client_pid: cpid, character_id: cid}, npc_id, script_name) do Odinsea.Scripting.NPCManager.start_conversation(cpid, cid, npc_id, script_name: script_name) end @doc """ Gets player name. """ @spec get_name(t()) :: String.t() def get_name(_api) do "Unknown" end @doc """ Gets channel number. """ @spec get_channel(t()) :: integer() def get_channel(_api) do 1 end @doc """ Adds HP. """ @spec add_hp(t(), integer()) :: :ok def add_hp(_api, amount) do Logger.debug("Add HP: #{amount}") :ok end @doc """ Shows an effect. """ @spec show_effect(t(), boolean(), String.t()) :: :ok def show_effect(_api, _broadcast, effect) do Logger.debug("Show effect: #{effect}") :ok end @doc """ Plays a sound. """ @spec play_sound(t(), boolean(), String.t()) :: :ok def play_sound(_api, _broadcast, sound) do Logger.debug("Play sound: #{sound}") :ok end @doc """ Changes background music. """ @spec change_music(t(), String.t()) :: :ok def change_music(_api, song_name) do Logger.debug("Change music: #{song_name}") :ok end @doc """ Gains NX (cash points). """ @spec gain_nx(t(), integer()) :: :ok def gain_nx(_api, amount) do Logger.debug("Gain NX: #{amount}") :ok end # ============================================================================ # Private Helper Functions # ============================================================================ defp send_packet(client_pid, packet) when is_pid(client_pid) do send(client_pid, {:send_packet, packet}) end defp send_packet(_client_pid, _packet), do: :ok # NPC Talk packet (LP_NPCTalk) # Reference: MaplePacketCreator.getNPCTalk() defp npc_talk_packet(npc_id, msg_type, text, style, speaker_type, speaker_id) do packet = Out.new(Opcodes.lp_npc_talk()) |> Out.encode_byte(4) # NPC conversation type |> Out.encode_int(npc_id) |> Out.encode_byte(msg_type) |> Out.encode_string(text) # Encode style bytes ("00 00", "00 01", etc.) packet = encode_style(packet, style) # Encode speaker type and id if not default packet = if speaker_type != 0 do packet |> Out.encode_byte(speaker_type) |> Out.encode_int(speaker_id) else packet end Out.to_data(packet) end defp encode_style(packet, ""), do: packet defp encode_style(packet, style) when is_binary(style) do # Parse style string like "00 01" into bytes bytes = style |> String.split() |> Enum.map(fn hex -> case Integer.parse(hex, 16) do {n, _} -> n :error -> 0 end end) Enum.reduce(bytes, packet, fn byte_val, p -> Out.encode_byte(p, byte_val) end) end # NPC Talk Text packet (text input) defp npc_talk_text_packet(npc_id, text, min, max, default) do Out.new(Opcodes.lp_npc_talk()) |> Out.encode_byte(4) |> Out.encode_int(npc_id) |> Out.encode_byte(@msg_get_text) |> Out.encode_string(text) |> Out.encode_string(default) |> Out.encode_short(min) |> Out.encode_short(max) |> Out.to_data() end # NPC Talk Num packet (number input) defp npc_talk_num_packet(npc_id, text, default, min, max) do Out.new(Opcodes.lp_npc_talk()) |> Out.encode_byte(4) |> Out.encode_int(npc_id) |> Out.encode_byte(@msg_get_number) |> Out.encode_string(text) |> Out.encode_int(default) |> Out.encode_int(min) |> Out.encode_int(max) |> Out.to_data() end # NPC Talk Style packet (style selection) defp npc_talk_style_packet(npc_id, text, styles, _page) do packet = Out.new(Opcodes.lp_npc_talk()) |> Out.encode_byte(4) |> Out.encode_int(npc_id) |> Out.encode_byte(@msg_style) |> Out.encode_string(text) |> Out.encode_byte(length(styles)) # Encode style IDs packet = Enum.reduce(styles, packet, fn style_id, p -> Out.encode_int(p, style_id) end) Out.to_data(packet) end # Map Selection packet defp map_selection_packet(npc_id, selection_string) do Out.new(Opcodes.lp_npc_talk()) |> Out.encode_byte(4) |> Out.encode_int(npc_id) |> Out.encode_byte(0x10) # Map selection type |> Out.encode_string(selection_string) |> Out.to_data() end # Update Stats packet defp update_stat_packet(stats, job) do packet = Out.new(Opcodes.lp_update_stats()) |> Out.encode_byte(1) # Reset flag # Calculate stat mask mask = Enum.reduce(stats, 0, fn {stat, _}, acc -> acc ||| stat_mask(stat) end) packet = if Odinsea.Constants.Game.gms?() do Out.encode_long(packet, mask) else Out.encode_int(packet, mask) end # Encode stat values packet = Enum.reduce(stats, packet, fn {stat, value}, p -> encode_stat_value(p, stat, value) end) # Encode job Out.encode_short(packet, job) |> Out.to_data() end defp stat_mask(:skin), do: 0x01 defp stat_mask(:face), do: 0x02 defp stat_mask(:hair), do: 0x04 defp stat_mask(:pet), do: 0x08 defp stat_mask(:level), do: 0x10 defp stat_mask(:job), do: 0x20 defp stat_mask(:str), do: 0x40 defp stat_mask(:dex), do: 0x80 defp stat_mask(:int), do: 0x100 defp stat_mask(:luk), do: 0x200 defp stat_mask(:hp), do: 0x400 defp stat_mask(:max_hp), do: 0x800 defp stat_mask(:mp), do: 0x1000 defp stat_mask(:max_mp), do: 0x2000 defp stat_mask(:ap), do: 0x4000 defp stat_mask(:sp), do: 0x8000 defp stat_mask(:exp), do: 0x10000 defp stat_mask(:fame), do: 0x20000 defp stat_mask(:meso), do: 0x40000 defp stat_mask(_), do: 0 defp encode_stat_value(packet, stat, value) when stat in [:skin, :level] do Out.encode_byte(packet, value) end defp encode_stat_value(packet, _stat, value) do Out.encode_int(packet, value) end # Update Skill packet defp update_skill_packet(skill_id, level, master_level, expiration) do Out.new(Opcodes.lp_change_skill_record_result()) |> Out.encode_byte(1) # Success |> Out.encode_byte(1) # Count |> Out.encode_int(skill_id) |> Out.encode_int(level) |> Out.encode_int(master_level) |> Out.encode_long(expiration) |> Out.to_data() end # Quest Operation packet defp quest_operation_packet(quest_id, status) do # status: 0 = forfeit, 1 = start, 2 = complete mode = case status do 0 -> 0 # Forfeit 1 -> 1 # Start 2 -> 2 # Complete _ -> 1 end Out.new(Opcodes.lp_quest_clear()) |> Out.encode_byte(mode) |> Out.encode_int(quest_id) |> Out.to_data() end # Show Foreign Effect packet (for job change, etc.) defp show_foreign_effect_packet(character_id, effect_type) do Out.new(Opcodes.lp_show_foreign_effect()) |> Out.encode_int(character_id) |> Out.encode_byte(effect_type) |> Out.to_data() end # Show Own Buff Effect packet defp show_own_buff_effect_packet(skill_id, effect_type, direction, level) do Out.new(Opcodes.lp_show_own_buff_effect()) |> Out.encode_int(skill_id) |> Out.encode_byte(effect_type) |> Out.encode_byte(direction) |> Out.encode_byte(level) |> Out.to_data() end # Show Quest Message packet defp show_quest_msg_packet(msg) do Out.new(Opcodes.lp_quest_clear()) |> Out.encode_byte(0x0B) # Show quest message mode |> Out.encode_string(msg) |> Out.to_data() end # Simple selection helper for speaker dialogs defp send_simple_s_npc(api, text, speaker_type, npc_id) do packet = Out.new(Opcodes.lp_npc_talk()) |> Out.encode_byte(4) |> Out.encode_int(api.npc_id) |> Out.encode_byte(5) # Simple type |> Out.encode_string(text) |> Out.encode_byte(speaker_type) |> Out.encode_int(npc_id) |> Out.to_data() send_packet(api.client_pid, packet) set_last_msg(api, @msg_simple) end # Spawn monster on map helper defp spawn_monster_on_map(map_id, channel_id, mob_id, position) do # Get monster stats from LifeFactory case LifeFactory.get_monster_stats(mob_id) do nil -> Logger.warn("Monster stats not found for mob_id #{mob_id}") :error stats -> # Create monster monster = %Monster{ oid: 0, # Will be assigned by map mob_id: mob_id, stats: stats, hp: stats.hp, mp: stats.mp, max_hp: stats.hp, max_mp: stats.mp, position: position, stance: 5, controller_id: nil, controller_has_aggro: false, spawn_effect: 0, team: -1, fake: false, link_oid: 0, status_effects: %{}, poisons: [], attackers: %{}, last_attack: System.system_time(:millisecond), last_move: System.system_time(:millisecond), last_skill_use: 0, killed: false, drops_disabled: false, create_time: System.system_time(:millisecond) } # Spawn on map - the map will assign OID and broadcast # This is a simplified version - full implementation needs Map.spawn_monster_at spawn_packet = Packets.spawn_monster(monster, -1, 0) Map.broadcast(map_id, channel_id, spawn_packet) :ok end end # Get player position helper defp get_player_position(api) do case Character.get_state(api.character_id) do nil -> %{x: 0, y: 0, fh: 0} %Character.State{position: pos} -> %{x: pos.x, y: pos.y, fh: pos.foothold} end end end