defmodule Odinsea.World.Guild do @moduledoc """ Guild management service. Ported from src/handling/world/guild/MapleGuild.java Manages guild state including members, ranks, skills, and alliance. Supports guild creation, joining, leaving, and rank management. """ use GenServer require Logger alias Odinsea.Database.Repo import Ecto.Query @default_capacity 10 @max_capacity 200 @rank_titles ["Master", "Jr. Master", "Member", "Member", "Member"] @create_cost 500_000 # ============================================================================ # Data Structures # ============================================================================ defmodule GuildCharacter do @moduledoc "Guild member representation" defstruct [ :id, :name, :level, :job, :channel, :guild_rank, :alliance_rank, :guild_contribution, :online ] end defmodule GuildSkill do @moduledoc "Guild skill representation" defstruct [ :skill_id, :level, :timestamp, :purchaser, :activators ] end defmodule BBSThread do @moduledoc "Guild BBS thread" defstruct [ :thread_id, :local_id, :name, :content, :poster_id, :timestamp, :icon, :replies ] end # ============================================================================ # Client API # ============================================================================ def start_link(_) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @doc """ Creates a new guild. Returns {:ok, guild_id} on success, {:error, reason} on failure. """ def create_guild(leader_id, name) do GenServer.call(__MODULE__, {:create_guild, leader_id, name}) end @doc """ Gets a guild by ID. Returns the guild struct or nil if not found. """ def get_guild(guild_id) do GenServer.call(__MODULE__, {:get_guild, guild_id}) end @doc """ Gets guild by member character ID. """ def get_guild_by_character(character_id) do GenServer.call(__MODULE__, {:get_guild_by_character, character_id}) end @doc """ Adds a member to a guild. """ def add_member(guild_id, character) do GenServer.call(__MODULE__, {:add_member, guild_id, character}) end @doc """ Removes a member from a guild (leave). """ def leave_guild(guild_id, character_id) do GenServer.call(__MODULE__, {:leave_guild, guild_id, character_id}) end @doc """ Expels a member from a guild. """ def expel_member(guild_id, expeller_id, target_id, target_name) do GenServer.call(__MODULE__, {:expel_member, guild_id, expeller_id, target_id, target_name}) end @doc """ Changes a member's guild rank. """ def change_rank(guild_id, character_id, new_rank, changer_id) do GenServer.call(__MODULE__, {:change_rank, guild_id, character_id, new_rank, changer_id}) end @doc """ Changes guild rank titles. """ def change_rank_titles(guild_id, titles, changer_id) do GenServer.call(__MODULE__, {:change_rank_titles, guild_id, titles, changer_id}) end @doc """ Changes the guild leader. """ def change_leader(guild_id, new_leader_id, current_leader_id) do GenServer.call(__MODULE__, {:change_leader, guild_id, new_leader_id, current_leader_id}) end @doc """ Sets guild emblem. """ def set_emblem(guild_id, bg, bg_color, logo, logo_color, changer_id) do GenServer.call(__MODULE__, {:set_emblem, guild_id, bg, bg_color, logo, logo_color, changer_id}) end @doc """ Sets guild notice. """ def set_notice(guild_id, notice, changer_id) do GenServer.call(__MODULE__, {:set_notice, guild_id, notice, changer_id}) end @doc """ Increases guild capacity. """ def increase_capacity(guild_id, leader_id, true_max \\ false) do GenServer.call(__MODULE__, {:increase_capacity, guild_id, leader_id, true_max}) end @doc """ Gains guild points (GP). """ def gain_gp(guild_id, amount, character_id \\ nil, broadcast \\ true) do GenServer.call(__MODULE__, {:gain_gp, guild_id, amount, character_id, broadcast}) end @doc """ Sets member online status. """ def set_online(guild_id, character_id, online, channel) do GenServer.call(__MODULE__, {:set_online, guild_id, character_id, online, channel}) end @doc """ Updates member info (level/job change). """ def update_member(guild_id, character) do GenServer.call(__MODULE__, {:update_member, guild_id, character}) end @doc """ Disbands a guild. """ def disband_guild(guild_id, leader_id) do GenServer.call(__MODULE__, {:disband_guild, guild_id, leader_id}) end @doc """ Gets guild skills. """ def get_skills(guild_id) do GenServer.call(__MODULE__, {:get_skills, guild_id}) end @doc """ Purchases a guild skill. """ def purchase_skill(guild_id, skill_id, purchaser_name, purchaser_id) do GenServer.call(__MODULE__, {:purchase_skill, guild_id, skill_id, purchaser_name, purchaser_id}) end @doc """ Activates a guild skill. """ def activate_skill(guild_id, skill_id, activator_name) do GenServer.call(__MODULE__, {:activate_skill, guild_id, skill_id, activator_name}) end @doc """ Broadcasts a packet to all online guild members. """ def broadcast(guild_id, packet, except_character_id \\ nil) do GenServer.cast(__MODULE__, {:broadcast, guild_id, packet, except_character_id}) end @doc """ Guild chat - sends message to all online guild members. """ def guild_chat(guild_id, sender_name, sender_id, message) do GenServer.cast(__MODULE__, {:guild_chat, guild_id, sender_name, sender_id, message}) end @doc """ Sets alliance ID for a guild. """ def set_alliance(guild_id, alliance_id, alliance_rank) do GenServer.call(__MODULE__, {:set_alliance, guild_id, alliance_id, alliance_rank}) end # ============================================================================ # Server Callbacks # ============================================================================ @impl true def init(_) do # Load guilds from database on startup guilds = load_guilds_from_db() Logger.info("Guild service initialized with #{map_size(guilds)} guilds") {:ok, %{guilds: guilds}} end @impl true def handle_call({:create_guild, leader_id, name}, _from, state) do # Validate name cond do String.length(name) > 12 -> {:reply, {:error, :name_too_long}, state} String.length(name) < 3 -> {:reply, {:error, :name_too_short}, state} not valid_guild_name?(name) -> {:reply, {:error, :invalid_name}, state} true -> case create_guild_in_db(leader_id, name) do {:ok, guild_id} -> guild = create_new_guild_struct(guild_id, leader_id, name) new_state = %{state | guilds: Map.put(state.guilds, guild_id, guild)} Logger.info("Guild '#{name}' (ID: #{guild_id}) created by leader #{leader_id}") {:reply, {:ok, guild_id}, new_state} {:error, reason} -> {:reply, {:error, reason}, state} end end end @impl true def handle_call({:get_guild, guild_id}, _from, state) do {:reply, Map.get(state.guilds, guild_id), state} end @impl true def handle_call({:get_guild_by_character, character_id}, _from, state) do guild = state.guilds |> Map.values() |> Enum.find(fn g -> Enum.any?(g.members, fn m -> m.id == character_id end) end) {:reply, guild, state} end @impl true def handle_call({:add_member, guild_id, character}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> if length(guild.members) >= guild.capacity do {:reply, {:error, :guild_full}, state} else # Create new member with rank 5 (lowest) member = %GuildCharacter{ id: character.id, name: character.name, level: character.level, job: character.job, channel: character.channel_id || 1, guild_rank: 5, alliance_rank: if(guild.alliance_id > 0, do: 3, else: 0), guild_contribution: 0, online: true } updated_guild = %{guild | members: guild.members ++ [member]} # Save to database save_member_to_db(guild_id, member) # Broadcast new member to guild broadcast_new_member(updated_guild, member) # Gain GP for new member updated_guild = %{updated_guild | gp: guild.gp + 500} {:reply, {:ok, member}, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end end @impl true def handle_call({:leave_guild, guild_id, character_id}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> member = Enum.find(guild.members, fn m -> m.id == character_id end) if member do # Remove member members = Enum.reject(guild.members, fn m -> m.id == character_id end) # If leader leaves and there are members, promote next highest rank updated_guild = if guild.leader_id == character_id && length(members) > 0 do # Find highest ranked member (lowest rank number) new_leader = Enum.min_by(members, fn m -> m.guild_rank end) %{guild | members: members, leader_id: new_leader.id} else %{guild | members: members} end # Remove from database remove_member_from_db(guild_id, character_id) # Broadcast member left broadcast_member_left(updated_guild, member) # Deduct GP gp_loss = if member.guild_contribution > 0, do: -member.guild_contribution, else: -50 updated_guild = %{updated_guild | gp: max(0, guild.gp + gp_loss)} # If no members left, mark for disband final_state = if length(members) == 0 do disband_guild_in_db(guild_id) %{state | guilds: Map.delete(state.guilds, guild_id)} else %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)} end {:reply, :ok, final_state} else {:reply, {:error, :not_in_guild}, state} end end end @impl true def handle_call({:expel_member, guild_id, expeller_id, target_id, target_name}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> expeller = Enum.find(guild.members, fn m -> m.id == expeller_id end) target = Enum.find(guild.members, fn m -> m.id == target_id end) cond do not expeller || expeller.guild_rank > 2 -> {:reply, {:error, :no_permission}, state} not target || target.guild_rank <= expeller.guild_rank -> {:reply, {:error, :cannot_expel}, state} true -> # Remove member members = Enum.reject(guild.members, fn m -> m.id == target_id end) updated_guild = %{guild | members: members} # Remove from database remove_member_from_db(guild_id, target_id) # Send note if offline unless target.online do send_note(target_name, expeller.name, "You have been expelled from the guild.") end # Broadcast broadcast_member_expelled(updated_guild, target) # Deduct GP gp_loss = if target.guild_contribution > 0, do: -target.guild_contribution, else: -50 updated_guild = %{updated_guild | gp: max(0, guild.gp + gp_loss)} {:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end end @impl true def handle_call({:change_rank, guild_id, character_id, new_rank, changer_id}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> changer = Enum.find(guild.members, fn m -> m.id == changer_id end) target = Enum.find(guild.members, fn m -> m.id == character_id end) cond do not changer || changer.guild_rank > 2 -> {:reply, {:error, :no_permission}, state} new_rank <= 1 || new_rank > 5 -> {:reply, {:error, :invalid_rank}, state} new_rank <= 2 && changer.guild_rank != 1 -> {:reply, {:error, :no_permission}, state} not target -> {:reply, {:error, :member_not_found}, state} true -> # Update rank members = Enum.map(guild.members, fn m -> if m.id == character_id do %{m | guild_rank: new_rank} else m end end) updated_guild = %{guild | members: members} # Save to database update_rank_in_db(character_id, new_rank) # Broadcast broadcast_rank_changed(updated_guild, target, new_rank) {:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end end @impl true def handle_call({:change_rank_titles, guild_id, titles, changer_id}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> changer = Enum.find(guild.members, fn m -> m.id == changer_id end) if not changer || changer.guild_rank != 1 do {:reply, {:error, :no_permission}, state} else updated_guild = %{guild | rank_titles: titles} # Save to database update_rank_titles_in_db(guild_id, titles) # Broadcast broadcast_rank_titles_changed(updated_guild, titles) {:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end end @impl true def handle_call({:change_leader, guild_id, new_leader_id, current_leader_id}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} %{leader_id: actual_leader} when actual_leader != current_leader_id -> {:reply, {:error, :not_leader}, state} guild -> unless Enum.any?(guild.members, fn m -> m.id == new_leader_id end) do {:reply, {:error, :not_in_guild}, state} else # Update ranks: new leader -> 1, old leader -> 2 members = Enum.map(guild.members, fn m -> cond do m.id == new_leader_id -> %{m | guild_rank: 1} m.id == current_leader_id -> %{m | guild_rank: 2} true -> m end end) updated_guild = %{guild | members: members, leader_id: new_leader_id} # Save to database update_leader_in_db(guild_id, new_leader_id) update_rank_in_db(new_leader_id, 1) update_rank_in_db(current_leader_id, 2) # Broadcast broadcast_leader_changed(updated_guild, new_leader_id) Logger.info("Guild #{guild_id} leader changed from #{current_leader_id} to #{new_leader_id}") {:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end end @impl true def handle_call({:set_emblem, guild_id, bg, bg_color, logo, logo_color, changer_id}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> changer = Enum.find(guild.members, fn m -> m.id == changer_id end) if not changer || changer.guild_rank != 1 do {:reply, {:error, :no_permission}, state} else updated_guild = %{guild | logo_bg: bg, logo_bg_color: bg_color, logo: logo, logo_color: logo_color } # Save to database update_emblem_in_db(guild_id, bg, bg_color, logo, logo_color) # Broadcast broadcast_emblem_changed(updated_guild) {:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end end @impl true def handle_call({:set_notice, guild_id, notice, changer_id}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> changer = Enum.find(guild.members, fn m -> m.id == changer_id end) if not changer || changer.guild_rank > 2 do {:reply, {:error, :no_permission}, state} else updated_guild = %{guild | notice: String.slice(notice, 0, 100)} # Save to database update_notice_in_db(guild_id, updated_guild.notice) # Broadcast broadcast_notice_changed(updated_guild) {:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end end @impl true def handle_call({:increase_capacity, guild_id, leader_id, true_max}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> max_cap = if true_max, do: @max_capacity, else: div(@max_capacity, 2) cond do guild.leader_id != leader_id -> {:reply, {:error, :not_leader}, state} guild.capacity >= max_cap -> {:reply, {:error, :max_capacity}, state} true -> new_capacity = min(guild.capacity + 5, max_cap) updated_guild = %{guild | capacity: new_capacity} # Save to database update_capacity_in_db(guild_id, new_capacity) # Broadcast broadcast_capacity_changed(updated_guild) {:reply, {:ok, new_capacity}, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end end @impl true def handle_call({:gain_gp, guild_id, amount, _character_id, broadcast}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> new_gp = max(0, guild.gp + amount) updated_guild = %{guild | gp: new_gp} # Save to database update_gp_in_db(guild_id, new_gp) # Optionally broadcast if broadcast do broadcast_gp_changed(updated_guild, amount) end {:reply, {:ok, new_gp}, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end @impl true def handle_call({:set_online, guild_id, character_id, online, channel}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> members = Enum.map(guild.members, fn m -> if m.id == character_id do %{m | online: online, channel: if(online, do: channel, else: -1)} else m end end) updated_guild = %{guild | members: members} # Broadcast online status to other members broadcast_member_online(updated_guild, character_id, online) {:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end @impl true def handle_call({:update_member, guild_id, character}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> members = Enum.map(guild.members, fn m -> if m.id == character.id do %{m | level: character.level || m.level, job: character.job || m.job, channel: character.channel_id || m.channel } else m end end) updated_guild = %{guild | members: members} # Broadcast level/job change if applicable broadcast_member_info_updated(updated_guild, character) {:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end @impl true def handle_call({:disband_guild, guild_id, leader_id}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} %{leader_id: actual_leader} when actual_leader != leader_id -> {:reply, {:error, :not_leader}, state} guild -> # Broadcast disband broadcast_guild_disband(guild) # Remove from database disband_guild_in_db(guild_id) Logger.info("Guild #{guild_id} (#{guild.name}) disbanded") {:reply, :ok, %{state | guilds: Map.delete(state.guilds, guild_id)}} end end @impl true def handle_call({:set_alliance, guild_id, alliance_id, alliance_rank}, _from, state) do case Map.get(state.guilds, guild_id) do nil -> {:reply, {:error, :guild_not_found}, state} guild -> members = Enum.map(guild.members, fn m -> %{m | alliance_rank: alliance_rank} end) updated_guild = %{guild | alliance_id: alliance_id, members: members} # Save to database update_alliance_in_db(guild_id, alliance_id, alliance_rank) {:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}} end end @impl true def handle_cast({:broadcast, guild_id, packet, except_id}, state) do case Map.get(state.guilds, guild_id) do nil -> :ok guild -> Enum.each(guild.members, fn member -> if member.online && member.id != except_id do case Registry.lookup(Odinsea.CharacterRegistry, member.id) do [{pid, _}] -> send(pid, {:send_packet, packet}) [] -> :ok end end end) end {:noreply, state} end @impl true def handle_cast({:guild_chat, guild_id, sender_name, sender_id, message}, state) do case Map.get(state.guilds, guild_id) do nil -> :ok guild -> Enum.each(guild.members, fn member -> if member.online && member.id != sender_id do # Check blacklist # TODO: Implement blacklist check case Registry.lookup(Odinsea.CharacterRegistry, member.id) do [{pid, _}] -> packet = build_guild_chat_packet(sender_name, message) send(pid, {:send_packet, packet}) [] -> :ok end end end) end {:noreply, state} end # ============================================================================ # Database Functions (Stub implementations - would use Ecto) # ============================================================================ defp load_guilds_from_db do # TODO: Implement actual database loading # For now, return empty map %{} end defp create_guild_in_db(leader_id, name) do # TODO: Implement database insert # Return a new guild ID {:ok, System.unique_integer([:positive])} end defp save_member_to_db(_guild_id, _member) do # TODO: Implement :ok end defp remove_member_from_db(_guild_id, _character_id) do # TODO: Implement :ok end defp update_rank_in_db(_character_id, _rank) do # TODO: Implement :ok end defp update_leader_in_db(_guild_id, _leader_id) do # TODO: Implement :ok end defp update_rank_titles_in_db(_guild_id, _titles) do # TODO: Implement :ok end defp update_emblem_in_db(_guild_id, _bg, _bg_color, _logo, _logo_color) do # TODO: Implement :ok end defp update_notice_in_db(_guild_id, _notice) do # TODO: Implement :ok end defp update_capacity_in_db(_guild_id, _capacity) do # TODO: Implement :ok end defp update_gp_in_db(_guild_id, _gp) do # TODO: Implement :ok end defp update_alliance_in_db(_guild_id, _alliance_id, _alliance_rank) do # TODO: Implement :ok end defp disband_guild_in_db(_guild_id) do # TODO: Implement :ok end defp send_note(_to_name, _from_name, _message) do # TODO: Implement note sending :ok end # ============================================================================ # Helper Functions # ============================================================================ defp valid_guild_name?(name) do # Only allow letters Regex.match?(~r/^[a-zA-Z]+$/, name) end defp create_new_guild_struct(guild_id, leader_id, name) do %{ id: guild_id, name: name, leader_id: leader_id, gp: 0, logo: 0, logo_color: 0, logo_bg: 0, logo_bg_color: 0, capacity: @default_capacity, rank_titles: @rank_titles, notice: "", signature: System.system_time(:second), alliance_id: 0, members: [], skills: %{}, bbs: %{} } end # ============================================================================ # Broadcast Functions # ============================================================================ defp broadcast_new_member(guild, member) do # TODO: Implement packet Logger.debug("Broadcast new member #{member.name} to guild #{guild.id}") end defp broadcast_member_left(guild, member) do Logger.debug("Broadcast member left #{member.name} to guild #{guild.id}") end defp broadcast_member_expelled(guild, member) do Logger.debug("Broadcast member expelled #{member.name} to guild #{guild.id}") end defp broadcast_rank_changed(guild, member, new_rank) do Logger.debug("Broadcast rank change for #{member.name} to #{new_rank} in guild #{guild.id}") end defp broadcast_rank_titles_changed(guild, titles) do Logger.debug("Broadcast rank titles changed in guild #{guild.id}: #{inspect(titles)}") end defp broadcast_leader_changed(guild, new_leader_id) do Logger.debug("Broadcast leader changed to #{new_leader_id} in guild #{guild.id}") end defp broadcast_emblem_changed(guild) do Logger.debug("Broadcast emblem changed in guild #{guild.id}") end defp broadcast_notice_changed(guild) do Logger.debug("Broadcast notice changed in guild #{guild.id}") end defp broadcast_capacity_changed(guild) do Logger.debug("Broadcast capacity changed to #{guild.capacity} in guild #{guild.id}") end defp broadcast_gp_changed(guild, amount) do Logger.debug("Broadcast GP change #{amount} in guild #{guild.id}") end defp broadcast_member_online(guild, character_id, online) do Logger.debug("Broadcast member #{character_id} online=#{online} in guild #{guild.id}") end defp broadcast_member_info_updated(guild, character) do Logger.debug("Broadcast member info update for #{character.id} in guild #{guild.id}") end defp broadcast_guild_disband(guild) do Logger.debug("Broadcast guild disband for #{guild.id}") end defp build_guild_chat_packet(_sender_name, _message) do # TODO: Implement proper packet <<>> end end