defmodule Odinsea.Channel.Handler.Guild do @moduledoc """ Handles guild operations. Ported from src/handling/channel/handler/GuildHandler.java Manages guild create, join, leave, ranks, skills, and alliance. """ require Logger alias Odinsea.Net.Packet.In alias Odinsea.Channel.Packets alias Odinsea.Game.Character alias Odinsea.World.Guild # Guild creation location (Henesys Guild Headquarters) @guild_creation_map_id 200_000_301 @guild_create_cost 500_000 @emblem_change_cost 1_500_000 # Invited list: {name => {guild_id, expiration_time}} @invited_table :guild_invited @doc """ Initializes the guild handler ETS table. """ def init do :ets.new(@invited_table, [:set, :public, :named_table]) :ok end @doc """ Handles guild operations (CP_GUILD_OPERATION). Ported from GuildHandler.Guild() Operation: - 0x02: Create guild - 0x05: Invite player - 0x06: Accept invitation - 0x07: Leave guild - 0x08: Expel member - 0x0E: Change rank titles - 0x0F: Change member rank - 0x10: Change emblem - 0x11: Change notice - 0x1D: Purchase skill - 0x1E: Activate skill - 0x1F: Change leader """ def handle_guild_operation(packet, client_state) do with {:ok, character_pid} <- get_character(client_state), {:ok, character} <- Character.get_state(character_pid) do # Prune expired invites periodically prune_expired_invites() {operation, packet} = In.decode_byte(packet) Logger.debug("Guild operation: #{operation} from #{character.name}") case operation do 0x02 -> handle_create_guild(packet, character, client_state) 0x05 -> handle_invite_player(packet, character, client_state) 0x06 -> handle_accept_invitation(packet, character, client_state) 0x07 -> handle_leave_guild(character, client_state) 0x08 -> handle_expel_member(packet, character, client_state) 0x0E -> handle_change_rank_titles(packet, character, client_state) 0x0F -> handle_change_rank(packet, character, client_state) 0x10 -> handle_change_emblem(packet, character, client_state) 0x11 -> handle_change_notice(packet, character, client_state) 0x1D -> handle_purchase_skill(packet, character, client_state) 0x1E -> handle_activate_skill(packet, character, client_state) 0x1F -> handle_change_leader(packet, character, client_state) _ -> Logger.warning("Unknown guild operation: #{operation}") {:ok, client_state} end else {:error, reason} -> Logger.warning("Guild operation failed: #{inspect(reason)}") {:ok, client_state} end end @doc """ Handles guild request denial (CP_DENY_GUILD_REQUEST). Ported from GuildHandler.DenyGuildRequest() """ def handle_deny_guild_request(packet, client_state) do with {:ok, _character_pid} <- get_character(client_state), {:ok, character} <- Character.get_state(client_state.character_id) do {from_name, _packet} = In.decode_string(packet) from_name = String.downcase(from_name) # Remove from invited list case :ets.lookup(@invited_table, from_name) do [{^from_name, {guild_id, _expires}}] -> :ets.delete(@invited_table, from_name) # Notify inviter notify_guild_denied(from_name, character.name) [] -> :ok end else _ -> :ok end {:ok, client_state} end # ============================================================================ # Guild Operation Handlers # ============================================================================ defp handle_create_guild(packet, character, client_state) do cond do character.guild_id && character.guild_id > 0 -> Character.send_message(character.id, "You cannot create a new Guild while in one.", 1) character.map_id != @guild_creation_map_id -> Character.send_message(character.id, "You cannot create a new Guild while in one.", 1) character.meso < @guild_create_cost -> Character.send_message(character.id, "You do not have enough mesos to create a Guild.", 1) true -> {guild_name, _packet} = In.decode_string(packet) if valid_guild_name?(guild_name) do case Guild.create_guild(character.id, guild_name) do {:ok, guild_id} -> # Deduct mesos Character.gain_meso(character.id, -@guild_create_cost, true, true) # Set guild info Character.set_guild(character.id, guild_id, 1) Character.save_guild_status(character.id) # TODO: Finish achievement 35 # Set online in guild Guild.set_online(guild_id, character.id, true, client_state.channel_id) # Send guild info # TODO: Implement showGuildInfo packet # Gain GP for creation Guild.gain_gp(guild_id, 500, character.id) # Respawn player (update guild tag) respawn_player(character.id) Character.send_message(character.id, "You have successfully created a Guild.", 1) Logger.info("Guild '#{guild_name}' (ID: #{guild_id}) created by #{character.name}") {:error, reason} -> Character.send_message(character.id, "Please try again.", 1) Logger.error("Failed to create guild: #{inspect(reason)}") end else Character.send_message(character.id, "The Guild name you have chosen is not accepted.", 1) end end {:ok, client_state} end defp handle_invite_player(packet, character, client_state) do # Check if in guild and has invite permission (rank <= 2) if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do {target_name, _packet} = In.decode_string(packet) target_name_lower = String.downcase(target_name) # Check if already handling invitation case :ets.lookup(@invited_table, target_name_lower) do [{_, _}] -> Character.send_message(character.id, "The player is currently handling an invitation.", 5) [] -> # Try to find target case Odinsea.Channel.Players.find_by_name(client_state.channel_id, target_name_lower) do {:ok, target} -> # Check if can invite if target.guild_id == nil || target.guild_id == 0 do # Send invite send_guild_invite(target, character) # Add to invited list (expires in 20 minutes) expiration = System.system_time(:millisecond) + 20 * 60 * 1000 :ets.insert(@invited_table, {target_name_lower, {character.guild_id, expiration}}) else # TODO: Send appropriate error packet :ok end {:error, :not_found} -> # TODO: Send error packet :ok end end end {:ok, client_state} end defp handle_accept_invitation(packet, character, client_state) do if character.guild_id && character.guild_id > 0 do # Already in guild {:ok, client_state} else {guild_id, packet} = In.decode_int(packet) {cid, _packet} = In.decode_int(packet) # Verify character ID matches if cid == character.id do target_name = String.downcase(character.name) case :ets.lookup(@invited_table, target_name) do [{^target_name, {^guild_id, _expires}}] -> # Remove from invited :ets.delete(@invited_table, target_name) # Join guild case Guild.add_member(guild_id, character) do {:ok, _member} -> # Set guild info Character.set_guild(character.id, guild_id, 5) Character.save_guild_status(character.id) # Send guild info # TODO: Implement showGuildInfo packet # Send alliance info if applicable guild = Guild.get_guild(guild_id) if guild && guild.alliance_id > 0 do # TODO: Send alliance info :ok end # Respawn player respawn_player(character.id) {:error, :guild_full} -> Character.send_message(character.id, "The Guild you are trying to join is already full.", 1) {:error, reason} -> Logger.error("Failed to add guild member: #{inspect(reason)}") end [] -> # No pending invitation :ok end end {:ok, client_state} end end defp handle_leave_guild(character, client_state) do if character.guild_id && character.guild_id > 0 do case Guild.leave_guild(character.guild_id, character.id) do :ok -> # Clear guild info Character.set_guild(character.id, 0, 5) Character.save_guild_status(character.id) # Send empty guild info # TODO: Implement showGuildInfo with null Logger.info("#{character.name} left guild #{character.guild_id}") {:error, reason} -> Logger.error("Failed to leave guild: #{inspect(reason)}") end end {:ok, client_state} end defp handle_expel_member(packet, character, client_state) do {target_id, packet} = In.decode_int(packet) {target_name, _packet} = In.decode_string(packet) if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do case Guild.expel_member(character.guild_id, character.id, target_id, target_name) do :ok -> # Update expelled character if online case Registry.lookup(Odinsea.CharacterRegistry, target_id) do [{pid, _}] -> Character.set_guild(target_id, 0, 5) # TODO: Send guild info update [] -> # Send note to offline character send_note(target_name, character.name, "You have been expelled from the guild.") end Logger.info("#{target_name} expelled from guild by #{character.name}") {:error, reason} -> Logger.error("Failed to expel member: #{inspect(reason)}") end end {:ok, client_state} end defp handle_change_rank_titles(packet, character, client_state) do if character.guild_id && character.guild_id > 0 && character.guild_rank == 1 do # Read 5 rank titles titles = for _i <- 1..5 do {title, remaining} = In.decode_string(packet) packet = remaining title end case Guild.change_rank_titles(character.guild_id, titles, character.id) do :ok -> Logger.info("Guild #{character.guild_id} rank titles changed") {:error, reason} -> Logger.error("Failed to change rank titles: #{inspect(reason)}") end end {:ok, client_state} end defp handle_change_rank(packet, character, client_state) do {target_id, packet} = In.decode_byte(packet) {new_rank, _packet} = In.decode_byte(packet) if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do # Validate rank if new_rank > 1 && new_rank <= 5 && (new_rank > 2 || character.guild_rank == 1) do case Guild.change_rank(character.guild_id, target_id, new_rank, character.id) do :ok -> # Update target's rank if online case Registry.lookup(Odinsea.CharacterRegistry, target_id) do [{pid, _}] -> Character.set_guild_rank(target_id, new_rank) [] -> :ok end {:error, reason} -> Logger.error("Failed to change rank: #{inspect(reason)}") end end end {:ok, client_state} end defp handle_change_emblem(packet, character, client_state) do cond do character.guild_id == nil || character.guild_id == 0 -> {:ok, client_state} character.guild_rank != 1 -> {:ok, client_state} character.map_id != @guild_creation_map_id -> {:ok, client_state} character.meso < @emblem_change_cost -> Character.send_message(character.id, "You do not have enough mesos to create an emblem.", 1) {:ok, client_state} true -> {bg, packet} = In.decode_short(packet) {bg_color, packet} = In.decode_byte(packet) {logo, packet} = In.decode_short(packet) {logo_color, _packet} = In.decode_byte(packet) case Guild.set_emblem(character.guild_id, bg, bg_color, logo, logo_color, character.id) do :ok -> # Deduct mesos Character.gain_meso(character.id, -@emblem_change_cost, true, true) # Respawn all members to update emblem respawn_all_guild_members(character.guild_id) {:error, reason} -> Logger.error("Failed to change emblem: #{inspect(reason)}") end {:ok, client_state} end end defp handle_change_notice(packet, character, client_state) do {notice, _packet} = In.decode_string(packet) if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do if String.length(notice) <= 100 do case Guild.set_notice(character.guild_id, notice, character.id) do :ok -> Logger.info("Guild #{character.guild_id} notice changed") {:error, reason} -> Logger.error("Failed to change notice: #{inspect(reason)}") end end end {:ok, client_state} end defp handle_purchase_skill(packet, character, client_state) do {skill_id, _packet} = In.decode_int(packet) if character.guild_id && character.guild_id > 0 do # TODO: Validate skill and level # TODO: Check if character has enough mesos case Guild.purchase_skill(character.guild_id, skill_id, character.name, character.id) do {:ok, _level} -> # Deduct mesos # TODO: Get skill price :ok {:error, reason} -> Logger.error("Failed to purchase guild skill: #{inspect(reason)}") end end {:ok, client_state} end defp handle_activate_skill(packet, character, client_state) do {skill_id, _packet} = In.decode_int(packet) if character.guild_id && character.guild_id > 0 do # TODO: Check if skill is purchased and not expired # TODO: Check if character has enough mesos for extension case Guild.activate_skill(character.guild_id, skill_id, character.name) do :ok -> # Deduct mesos :ok {:error, reason} -> Logger.error("Failed to activate guild skill: #{inspect(reason)}") end end {:ok, client_state} end defp handle_change_leader(packet, character, client_state) do {new_leader_id, _packet} = In.decode_int(packet) if character.guild_id && character.guild_id > 0 && character.guild_rank == 1 do # Get current leader guild = Guild.get_guild(character.guild_id) if guild && guild.leader_id != new_leader_id do case Guild.change_leader(character.guild_id, new_leader_id, character.id) do :ok -> # Update ranks Character.set_guild_rank(character.id, 2) Character.set_guild_rank(new_leader_id, 1) {:error, reason} -> Character.send_message(character.id, "This user is already the guild leader.", 1) Logger.error("Failed to change leader: #{inspect(reason)}") end else Character.send_message(character.id, "This user is already the guild leader.", 1) end end {:ok, client_state} end # ============================================================================ # Helper Functions # ============================================================================ defp get_character(client_state) do case client_state.character_id do nil -> {:error, :no_character} character_id -> case Registry.lookup(Odinsea.CharacterRegistry, character_id) do [{pid, _}] -> {:ok, pid} [] -> {:error, :character_not_found} end end end defp valid_guild_name?(name) do cond do String.length(name) < 3 -> false String.length(name) > 12 -> false true -> Regex.match?(~r/^[a-zA-Z]+$/, name) end end defp send_guild_invite(target, inviter) do case Registry.lookup(Odinsea.CharacterRegistry, target.id) do [{pid, _}] -> invite_packet = Packets.guild_invite(inviter) send(pid, {:send_packet, invite_packet}) [] -> :ok end end defp notify_guild_denied(inviter_name, denier_name) do # Find inviter and send denial case Odinsea.Channel.Players.find_by_name(1, inviter_name) do {:ok, inviter} -> case Registry.lookup(Odinsea.CharacterRegistry, inviter.id) do [{pid, _}] -> packet = Packets.deny_guild_invitation(denier_name) send(pid, {:send_packet, packet}) [] -> :ok end {:error, _} -> :ok end end defp send_note(to_name, from_name, message) do # TODO: Implement note sending via database Logger.debug("Note to #{to_name} from #{from_name}: #{message}") :ok end defp respawn_player(character_id) do case Registry.lookup(Odinsea.CharacterRegistry, character_id) do [{pid, _}] -> case Character.get_state(pid) do {:ok, character} -> # Broadcast guild name and icon update # TODO: Implement proper respawn :ok _ -> :ok end [] -> :ok end end defp respawn_all_guild_members(guild_id) do case Guild.get_guild(guild_id) do nil -> :ok guild -> Enum.each(guild.members, fn member -> if member.online do respawn_player(member.id) end end) end end defp prune_expired_invites do now = System.system_time(:millisecond) :ets.select_delete(@invited_table, [ {{:_, {:_, :"$1"}}, [{:<, :"$1", now}], [true]} ]) end end