defmodule Odinsea.World.Party do @moduledoc """ Party management service. Ported from src/handling/world/MapleParty.java Manages party state including members, leader, and operations. Supports cross-channel party functionality. """ use GenServer require Logger alias Odinsea.Channel.Packets @max_party_size 6 @loot_rules [:free_for_all, :round_robin, :master, :master_looter] # ============================================================================ # Client API # ============================================================================ def start_link(_) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @doc """ Creates a new party with the given leader character. Returns {:ok, party_id} on success, {:error, reason} on failure. """ def create_party(leader_character) do GenServer.call(__MODULE__, {:create_party, leader_character}) end @doc """ Creates a party linked to an expedition. """ def create_expedition_party(leader_character, expedition_id) do GenServer.call(__MODULE__, {:create_expedition_party, leader_character, expedition_id}) end @doc """ Gets a party by ID. Returns the party struct or nil if not found. """ def get_party(party_id) do GenServer.call(__MODULE__, {:get_party, party_id}) end @doc """ Updates party with a member operation (join, leave, expel, etc.). """ def update_party(party_id, operation, character) do GenServer.call(__MODULE__, {:update_party, party_id, operation, character}) end @doc """ Disbands a party. """ def disband_party(party_id, leader_id) do GenServer.call(__MODULE__, {:disband_party, party_id, leader_id}) end @doc """ Changes the party leader. """ def change_leader(party_id, new_leader_id, current_leader_id) do GenServer.call(__MODULE__, {:change_leader, party_id, new_leader_id, current_leader_id}) end @doc """ Sets a character's online status in the party. """ def set_online(party_id, character_id, online, channel) do GenServer.call(__MODULE__, {:set_online, party_id, character_id, online, channel}) end @doc """ Updates character info (level, job, map) for a party member. """ def update_member(party_id, character) do GenServer.call(__MODULE__, {:update_member, party_id, character}) end @doc """ Gets all parties (for admin/debug purposes). """ def get_all_parties do GenServer.call(__MODULE__, :get_all_parties) end @doc """ Gets party members for broadcasting. Returns list of {character_id, channel} tuples. """ def get_member_channels(party_id) do GenServer.call(__MODULE__, {:get_member_channels, party_id}) end @doc """ Broadcasts a packet to all party members except the sender. """ def broadcast_to_party(party_id, packet, except_character_id \\ nil) do GenServer.cast(__MODULE__, {:broadcast_to_party, party_id, packet, except_character_id}) end # ============================================================================ # Server Callbacks # ============================================================================ @impl true def init(_) do state = %{ parties: %{}, next_id: 1 } Logger.info("Party service initialized") {:ok, state} end @impl true def handle_call({:create_party, leader}, _from, state) do party_id = state.next_id party = %{ id: party_id, leader_id: leader.id, members: [create_party_character(leader)], expedition_id: -1, disbanded: false, loot_rule: :free_for_all, created_at: System.system_time(:second) } new_state = %{ state | parties: Map.put(state.parties, party_id, party), next_id: party_id + 1 } Logger.info("Party #{party_id} created by #{leader.name}") {:reply, {:ok, party}, new_state} end @impl true def handle_call({:create_expedition_party, leader, expedition_id}, _from, state) do party_id = state.next_id party = %{ id: party_id, leader_id: leader.id, members: [create_party_character(leader)], expedition_id: expedition_id, disbanded: false, loot_rule: :free_for_all, created_at: System.system_time(:second) } new_state = %{ state | parties: Map.put(state.parties, party_id, party), next_id: party_id + 1 } Logger.info("Expedition party #{party_id} created for expedition #{expedition_id}") {:reply, {:ok, party}, new_state} end @impl true def handle_call({:get_party, party_id}, _from, state) do party = Map.get(state.parties, party_id) # Don't return disbanded parties if party && party.disbanded do {:reply, nil, state} else {:reply, party, state} end end @impl true def handle_call({:update_party, party_id, operation, character}, _from, state) do case Map.get(state.parties, party_id) do nil -> {:reply, {:error, :party_not_found}, state} party when party.disbanded -> {:reply, {:error, :party_disbanded}, state} party -> case apply_operation(party, operation, character) do {:ok, updated_party, result} -> new_state = %{state | parties: Map.put(state.parties, party_id, updated_party)} # Broadcast update to party members broadcast_party_update(updated_party, operation, character) {:reply, {:ok, result}, new_state} {:error, reason} -> {:reply, {:error, reason}, state} end end end @impl true def handle_call({:disband_party, party_id, leader_id}, _from, state) do case Map.get(state.parties, party_id) do nil -> {:reply, {:error, :party_not_found}, state} %{leader_id: actual_leader} when actual_leader != leader_id -> {:reply, {:error, :not_leader}, state} party -> updated_party = %{party | disbanded: true, members: []} # Notify all members broadcast_party_disband(party) # Remove from state after a delay (for cleanup) :timer.apply_after(60_000, __MODULE__, :cleanup_party, [party_id]) Logger.info("Party #{party_id} disbanded by leader #{leader_id}") {:reply, :ok, %{state | parties: Map.put(state.parties, party_id, updated_party)}} end end @impl true def handle_call({:change_leader, party_id, new_leader_id, current_leader_id}, _from, state) do case Map.get(state.parties, party_id) do nil -> {:reply, {:error, :party_not_found}, state} %{leader_id: actual_leader} when actual_leader != current_leader_id -> {:reply, {:error, :not_leader}, state} party -> # Check if new leader is in party unless Enum.any?(party.members, fn m -> m.id == new_leader_id end) do {:reply, {:error, :not_in_party}, state} else updated_party = %{party | leader_id: new_leader_id} broadcast_leader_changed(updated_party, new_leader_id) Logger.info("Party #{party_id} leader changed to #{new_leader_id}") {:reply, :ok, %{state | parties: Map.put(state.parties, party_id, updated_party)}} end end end @impl true def handle_call({:set_online, party_id, character_id, online, channel}, _from, state) do case Map.get(state.parties, party_id) do nil -> {:reply, {:error, :party_not_found}, state} party when party.disbanded -> {:reply, {:error, :party_disbanded}, state} party -> members = Enum.map(party.members, fn member -> if member.id == character_id do %{member | online: online, channel: channel} else member end end) updated_party = %{party | members: members} # Broadcast online status change broadcast_member_online(updated_party, character_id, online) {:reply, :ok, %{state | parties: Map.put(state.parties, party_id, updated_party)}} end end @impl true def handle_call({:update_member, party_id, character}, _from, state) do case Map.get(state.parties, party_id) do nil -> {:reply, {:error, :party_not_found}, state} party when party.disbanded -> {:reply, {:error, :party_disbanded}, state} party -> members = Enum.map(party.members, fn member -> if member.id == character.id do update_party_character(member, character) else member end end) updated_party = %{party | members: members} {:reply, :ok, %{state | parties: Map.put(state.parties, party_id, updated_party)}} end end @impl true def handle_call(:get_all_parties, _from, state) do active_parties = state.parties |> Map.values() |> Enum.reject(fn p -> p.disbanded end) {:reply, active_parties, state} end @impl true def handle_call({:get_member_channels, party_id}, _from, state) do case Map.get(state.parties, party_id) do nil -> {:reply, [], state} %{disbanded: true} -> {:reply, [], state} party -> channels = party.members |> Enum.filter(fn m -> m.online end) |> Enum.map(fn m -> {m.id, m.channel} end) {:reply, channels, state} end end @impl true def handle_cast({:broadcast_to_party, party_id, packet, except_id}, state) do case Map.get(state.parties, party_id) do nil -> :ok %{disbanded: true} -> :ok party -> # Broadcast to all online members except sender Enum.each(party.members, fn member -> if member.online && member.id != except_id do # Get character PID and send packet case Registry.lookup(Odinsea.CharacterRegistry, member.id) do [{pid, _}] -> send(pid, {:send_packet, packet}) [] -> :ok end end end) end {:noreply, state} end # ============================================================================ # Party Operations # ============================================================================ defp apply_operation(party, :join, character) do if length(party.members) >= @max_party_size do {:error, :party_full} else # Check if already in party if Enum.any?(party.members, fn m -> m.id == character.id end) do {:error, :already_in_party} else party_char = create_party_character(character) updated_party = %{party | members: party.members ++ [party_char]} {:ok, updated_party, party_char} end end end defp apply_operation(party, :leave, character) do members = Enum.reject(party.members, fn m -> m.id == character.id end) # If leader leaves and there are other members, promote next member updated_party = if party.leader_id == character.id && length(members) > 0 do [new_leader | _] = members %{party | members: members, leader_id: new_leader.id} else %{party | members: members} end {:ok, updated_party, :ok} end defp apply_operation(party, :expel, character) do # Only leader can expel members = Enum.reject(party.members, fn m -> m.id == character.id end) updated_party = %{party | members: members} {:ok, updated_party, :ok} end defp apply_operation(party, :silent_update, character) do # Update member info without broadcasting members = Enum.map(party.members, fn member -> if member.id == character.id do update_party_character(member, character) else member end end) {:ok, %{party | members: members}, :ok} end defp apply_operation(party, :log_onoff, character) do members = Enum.map(party.members, fn member -> if member.id == character.id do %{member | online: character.online, channel: character.channel} else member end end) {:ok, %{party | members: members}, :ok} end defp apply_operation(_party, operation, _character) do {:error, {:unknown_operation, operation}} end # ============================================================================ # Helper Functions # ============================================================================ defp create_party_character(character) do %{ id: character.id, name: character.name, level: character.level, job: character.job, channel: character.channel_id || 1, map_id: character.map_id || 100000000, online: true, # Door info for mystic door skill door_town: 999999999, door_target: 999999999, door_skill: 0, door_x: 0, door_y: 0 } end defp update_party_character(existing, character) do %{ existing | level: character.level || existing.level, job: character.job || existing.job, channel: character.channel_id || existing.channel, map_id: character.map_id || existing.map_id } end defp broadcast_party_update(party, operation, character) do # Build party update packet and broadcast to members Enum.each(party.members, fn member -> if member.online && member.id != character.id do # Send party update packet case Registry.lookup(Odinsea.CharacterRegistry, member.id) do [{pid, _}] -> packet = build_party_update_packet(party, operation, character) send(pid, {:send_packet, packet}) [] -> :ok end end end) end defp broadcast_party_disband(party) do Enum.each(party.members, fn member -> if member.online do case Registry.lookup(Odinsea.CharacterRegistry, member.id) do [{pid, _}] -> packet = build_party_disband_packet(party.id) send(pid, {:send_packet, packet}) [] -> :ok end end end) end defp broadcast_leader_changed(party, new_leader_id) do Enum.each(party.members, fn member -> if member.online do case Registry.lookup(Odinsea.CharacterRegistry, member.id) do [{pid, _}] -> packet = build_leader_change_packet(party.id, new_leader_id) send(pid, {:send_packet, packet}) [] -> :ok end end end) end defp broadcast_member_online(party, character_id, online) do Enum.each(party.members, fn member -> if member.online && member.id != character_id do case Registry.lookup(Odinsea.CharacterRegistry, member.id) do [{pid, _}] -> packet = build_member_online_packet(party.id, character_id, online) send(pid, {:send_packet, packet}) [] -> :ok end end end) end # ============================================================================ # Packet Builders (to be implemented in Channel.Packets) # ============================================================================ defp build_party_update_packet(_party, _operation, _character) do # TODO: Implement party update packet # For now, return empty (needs proper packet structure) <<>> end defp build_party_disband_packet(_party_id) do # TODO: Implement party disband packet <<>> end defp build_leader_change_packet(_party_id, _new_leader_id) do # TODO: Implement leader change packet <<>> end defp build_member_online_packet(_party_id, _character_id, _online) do # TODO: Implement member online packet <<>> end @doc """ Cleanup a disbanded party (called after delay). """ def cleanup_party(party_id) do GenServer.cast(__MODULE__, {:cleanup_party, party_id}) end @impl true def handle_cast({:cleanup_party, party_id}, state) do case Map.get(state.parties, party_id) do %{disbanded: true} -> {:noreply, %{state | parties: Map.delete(state.parties, party_id)}} _ -> {:noreply, state} end end end