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

918 lines
27 KiB
Elixir

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