544 lines
16 KiB
Elixir
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
|