Files
odinsea-elixir/lib/odinsea/world/party.ex
2026-02-14 23:12:33 -07:00

544 lines
16 KiB
Elixir

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