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

567 lines
18 KiB
Elixir

defmodule Odinsea.Channel.Handler.Guild do
@moduledoc """
Handles guild operations.
Ported from src/handling/channel/handler/GuildHandler.java
Manages guild create, join, leave, ranks, skills, and alliance.
"""
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Channel.Packets
alias Odinsea.Game.Character
alias Odinsea.World.Guild
# Guild creation location (Henesys Guild Headquarters)
@guild_creation_map_id 200_000_301
@guild_create_cost 500_000
@emblem_change_cost 1_500_000
# Invited list: {name => {guild_id, expiration_time}}
@invited_table :guild_invited
@doc """
Initializes the guild handler ETS table.
"""
def init do
:ets.new(@invited_table, [:set, :public, :named_table])
:ok
end
@doc """
Handles guild operations (CP_GUILD_OPERATION).
Ported from GuildHandler.Guild()
Operation:
- 0x02: Create guild
- 0x05: Invite player
- 0x06: Accept invitation
- 0x07: Leave guild
- 0x08: Expel member
- 0x0E: Change rank titles
- 0x0F: Change member rank
- 0x10: Change emblem
- 0x11: Change notice
- 0x1D: Purchase skill
- 0x1E: Activate skill
- 0x1F: Change leader
"""
def handle_guild_operation(packet, client_state) do
with {:ok, character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(character_pid) do
# Prune expired invites periodically
prune_expired_invites()
{operation, packet} = In.decode_byte(packet)
Logger.debug("Guild operation: #{operation} from #{character.name}")
case operation do
0x02 -> handle_create_guild(packet, character, client_state)
0x05 -> handle_invite_player(packet, character, client_state)
0x06 -> handle_accept_invitation(packet, character, client_state)
0x07 -> handle_leave_guild(character, client_state)
0x08 -> handle_expel_member(packet, character, client_state)
0x0E -> handle_change_rank_titles(packet, character, client_state)
0x0F -> handle_change_rank(packet, character, client_state)
0x10 -> handle_change_emblem(packet, character, client_state)
0x11 -> handle_change_notice(packet, character, client_state)
0x1D -> handle_purchase_skill(packet, character, client_state)
0x1E -> handle_activate_skill(packet, character, client_state)
0x1F -> handle_change_leader(packet, character, client_state)
_ ->
Logger.warning("Unknown guild operation: #{operation}")
{:ok, client_state}
end
else
{:error, reason} ->
Logger.warning("Guild operation failed: #{inspect(reason)}")
{:ok, client_state}
end
end
@doc """
Handles guild request denial (CP_DENY_GUILD_REQUEST).
Ported from GuildHandler.DenyGuildRequest()
"""
def handle_deny_guild_request(packet, client_state) do
with {:ok, _character_pid} <- get_character(client_state),
{:ok, character} <- Character.get_state(client_state.character_id) do
{from_name, _packet} = In.decode_string(packet)
from_name = String.downcase(from_name)
# Remove from invited list
case :ets.lookup(@invited_table, from_name) do
[{^from_name, {guild_id, _expires}}] ->
:ets.delete(@invited_table, from_name)
# Notify inviter
notify_guild_denied(from_name, character.name)
[] ->
:ok
end
else
_ -> :ok
end
{:ok, client_state}
end
# ============================================================================
# Guild Operation Handlers
# ============================================================================
defp handle_create_guild(packet, character, client_state) do
cond do
character.guild_id && character.guild_id > 0 ->
Character.send_message(character.id, "You cannot create a new Guild while in one.", 1)
character.map_id != @guild_creation_map_id ->
Character.send_message(character.id, "You cannot create a new Guild while in one.", 1)
character.meso < @guild_create_cost ->
Character.send_message(character.id, "You do not have enough mesos to create a Guild.", 1)
true ->
{guild_name, _packet} = In.decode_string(packet)
if valid_guild_name?(guild_name) do
case Guild.create_guild(character.id, guild_name) do
{:ok, guild_id} ->
# Deduct mesos
Character.gain_meso(character.id, -@guild_create_cost, true, true)
# Set guild info
Character.set_guild(character.id, guild_id, 1)
Character.save_guild_status(character.id)
# TODO: Finish achievement 35
# Set online in guild
Guild.set_online(guild_id, character.id, true, client_state.channel_id)
# Send guild info
# TODO: Implement showGuildInfo packet
# Gain GP for creation
Guild.gain_gp(guild_id, 500, character.id)
# Respawn player (update guild tag)
respawn_player(character.id)
Character.send_message(character.id, "You have successfully created a Guild.", 1)
Logger.info("Guild '#{guild_name}' (ID: #{guild_id}) created by #{character.name}")
{:error, reason} ->
Character.send_message(character.id, "Please try again.", 1)
Logger.error("Failed to create guild: #{inspect(reason)}")
end
else
Character.send_message(character.id, "The Guild name you have chosen is not accepted.", 1)
end
end
{:ok, client_state}
end
defp handle_invite_player(packet, character, client_state) do
# Check if in guild and has invite permission (rank <= 2)
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
{target_name, _packet} = In.decode_string(packet)
target_name_lower = String.downcase(target_name)
# Check if already handling invitation
case :ets.lookup(@invited_table, target_name_lower) do
[{_, _}] ->
Character.send_message(character.id, "The player is currently handling an invitation.", 5)
[] ->
# Try to find target
case Odinsea.Channel.Players.find_by_name(client_state.channel_id, target_name_lower) do
{:ok, target} ->
# Check if can invite
if target.guild_id == nil || target.guild_id == 0 do
# Send invite
send_guild_invite(target, character)
# Add to invited list (expires in 20 minutes)
expiration = System.system_time(:millisecond) + 20 * 60 * 1000
:ets.insert(@invited_table, {target_name_lower, {character.guild_id, expiration}})
else
# TODO: Send appropriate error packet
:ok
end
{:error, :not_found} ->
# TODO: Send error packet
:ok
end
end
end
{:ok, client_state}
end
defp handle_accept_invitation(packet, character, client_state) do
if character.guild_id && character.guild_id > 0 do
# Already in guild
{:ok, client_state}
else
{guild_id, packet} = In.decode_int(packet)
{cid, _packet} = In.decode_int(packet)
# Verify character ID matches
if cid == character.id do
target_name = String.downcase(character.name)
case :ets.lookup(@invited_table, target_name) do
[{^target_name, {^guild_id, _expires}}] ->
# Remove from invited
:ets.delete(@invited_table, target_name)
# Join guild
case Guild.add_member(guild_id, character) do
{:ok, _member} ->
# Set guild info
Character.set_guild(character.id, guild_id, 5)
Character.save_guild_status(character.id)
# Send guild info
# TODO: Implement showGuildInfo packet
# Send alliance info if applicable
guild = Guild.get_guild(guild_id)
if guild && guild.alliance_id > 0 do
# TODO: Send alliance info
:ok
end
# Respawn player
respawn_player(character.id)
{:error, :guild_full} ->
Character.send_message(character.id, "The Guild you are trying to join is already full.", 1)
{:error, reason} ->
Logger.error("Failed to add guild member: #{inspect(reason)}")
end
[] ->
# No pending invitation
:ok
end
end
{:ok, client_state}
end
end
defp handle_leave_guild(character, client_state) do
if character.guild_id && character.guild_id > 0 do
case Guild.leave_guild(character.guild_id, character.id) do
:ok ->
# Clear guild info
Character.set_guild(character.id, 0, 5)
Character.save_guild_status(character.id)
# Send empty guild info
# TODO: Implement showGuildInfo with null
Logger.info("#{character.name} left guild #{character.guild_id}")
{:error, reason} ->
Logger.error("Failed to leave guild: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_expel_member(packet, character, client_state) do
{target_id, packet} = In.decode_int(packet)
{target_name, _packet} = In.decode_string(packet)
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
case Guild.expel_member(character.guild_id, character.id, target_id, target_name) do
:ok ->
# Update expelled character if online
case Registry.lookup(Odinsea.CharacterRegistry, target_id) do
[{pid, _}] ->
Character.set_guild(target_id, 0, 5)
# TODO: Send guild info update
[] ->
# Send note to offline character
send_note(target_name, character.name, "You have been expelled from the guild.")
end
Logger.info("#{target_name} expelled from guild by #{character.name}")
{:error, reason} ->
Logger.error("Failed to expel member: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_change_rank_titles(packet, character, client_state) do
if character.guild_id && character.guild_id > 0 && character.guild_rank == 1 do
# Read 5 rank titles
titles = for _i <- 1..5 do
{title, remaining} = In.decode_string(packet)
packet = remaining
title
end
case Guild.change_rank_titles(character.guild_id, titles, character.id) do
:ok ->
Logger.info("Guild #{character.guild_id} rank titles changed")
{:error, reason} ->
Logger.error("Failed to change rank titles: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_change_rank(packet, character, client_state) do
{target_id, packet} = In.decode_byte(packet)
{new_rank, _packet} = In.decode_byte(packet)
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
# Validate rank
if new_rank > 1 && new_rank <= 5 && (new_rank > 2 || character.guild_rank == 1) do
case Guild.change_rank(character.guild_id, target_id, new_rank, character.id) do
:ok ->
# Update target's rank if online
case Registry.lookup(Odinsea.CharacterRegistry, target_id) do
[{pid, _}] ->
Character.set_guild_rank(target_id, new_rank)
[] ->
:ok
end
{:error, reason} ->
Logger.error("Failed to change rank: #{inspect(reason)}")
end
end
end
{:ok, client_state}
end
defp handle_change_emblem(packet, character, client_state) do
cond do
character.guild_id == nil || character.guild_id == 0 ->
{:ok, client_state}
character.guild_rank != 1 ->
{:ok, client_state}
character.map_id != @guild_creation_map_id ->
{:ok, client_state}
character.meso < @emblem_change_cost ->
Character.send_message(character.id, "You do not have enough mesos to create an emblem.", 1)
{:ok, client_state}
true ->
{bg, packet} = In.decode_short(packet)
{bg_color, packet} = In.decode_byte(packet)
{logo, packet} = In.decode_short(packet)
{logo_color, _packet} = In.decode_byte(packet)
case Guild.set_emblem(character.guild_id, bg, bg_color, logo, logo_color, character.id) do
:ok ->
# Deduct mesos
Character.gain_meso(character.id, -@emblem_change_cost, true, true)
# Respawn all members to update emblem
respawn_all_guild_members(character.guild_id)
{:error, reason} ->
Logger.error("Failed to change emblem: #{inspect(reason)}")
end
{:ok, client_state}
end
end
defp handle_change_notice(packet, character, client_state) do
{notice, _packet} = In.decode_string(packet)
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
if String.length(notice) <= 100 do
case Guild.set_notice(character.guild_id, notice, character.id) do
:ok ->
Logger.info("Guild #{character.guild_id} notice changed")
{:error, reason} ->
Logger.error("Failed to change notice: #{inspect(reason)}")
end
end
end
{:ok, client_state}
end
defp handle_purchase_skill(packet, character, client_state) do
{skill_id, _packet} = In.decode_int(packet)
if character.guild_id && character.guild_id > 0 do
# TODO: Validate skill and level
# TODO: Check if character has enough mesos
case Guild.purchase_skill(character.guild_id, skill_id, character.name, character.id) do
{:ok, _level} ->
# Deduct mesos
# TODO: Get skill price
:ok
{:error, reason} ->
Logger.error("Failed to purchase guild skill: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_activate_skill(packet, character, client_state) do
{skill_id, _packet} = In.decode_int(packet)
if character.guild_id && character.guild_id > 0 do
# TODO: Check if skill is purchased and not expired
# TODO: Check if character has enough mesos for extension
case Guild.activate_skill(character.guild_id, skill_id, character.name) do
:ok ->
# Deduct mesos
:ok
{:error, reason} ->
Logger.error("Failed to activate guild skill: #{inspect(reason)}")
end
end
{:ok, client_state}
end
defp handle_change_leader(packet, character, client_state) do
{new_leader_id, _packet} = In.decode_int(packet)
if character.guild_id && character.guild_id > 0 && character.guild_rank == 1 do
# Get current leader
guild = Guild.get_guild(character.guild_id)
if guild && guild.leader_id != new_leader_id do
case Guild.change_leader(character.guild_id, new_leader_id, character.id) do
:ok ->
# Update ranks
Character.set_guild_rank(character.id, 2)
Character.set_guild_rank(new_leader_id, 1)
{:error, reason} ->
Character.send_message(character.id, "This user is already the guild leader.", 1)
Logger.error("Failed to change leader: #{inspect(reason)}")
end
else
Character.send_message(character.id, "This user is already the guild leader.", 1)
end
end
{:ok, client_state}
end
# ============================================================================
# Helper Functions
# ============================================================================
defp get_character(client_state) do
case client_state.character_id do
nil -> {:error, :no_character}
character_id ->
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
[{pid, _}] -> {:ok, pid}
[] -> {:error, :character_not_found}
end
end
end
defp valid_guild_name?(name) do
cond do
String.length(name) < 3 -> false
String.length(name) > 12 -> false
true -> Regex.match?(~r/^[a-zA-Z]+$/, name)
end
end
defp send_guild_invite(target, inviter) do
case Registry.lookup(Odinsea.CharacterRegistry, target.id) do
[{pid, _}] ->
invite_packet = Packets.guild_invite(inviter)
send(pid, {:send_packet, invite_packet})
[] -> :ok
end
end
defp notify_guild_denied(inviter_name, denier_name) do
# Find inviter and send denial
case Odinsea.Channel.Players.find_by_name(1, inviter_name) do
{:ok, inviter} ->
case Registry.lookup(Odinsea.CharacterRegistry, inviter.id) do
[{pid, _}] ->
packet = Packets.deny_guild_invitation(denier_name)
send(pid, {:send_packet, packet})
[] -> :ok
end
{:error, _} -> :ok
end
end
defp send_note(to_name, from_name, message) do
# TODO: Implement note sending via database
Logger.debug("Note to #{to_name} from #{from_name}: #{message}")
:ok
end
defp respawn_player(character_id) do
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
[{pid, _}] ->
case Character.get_state(pid) do
{:ok, character} ->
# Broadcast guild name and icon update
# TODO: Implement proper respawn
:ok
_ -> :ok
end
[] -> :ok
end
end
defp respawn_all_guild_members(guild_id) do
case Guild.get_guild(guild_id) do
nil -> :ok
guild ->
Enum.each(guild.members, fn member ->
if member.online do
respawn_player(member.id)
end
end)
end
end
defp prune_expired_invites do
now = System.system_time(:millisecond)
:ets.select_delete(@invited_table, [
{{:_, {:_, :"$1"}}, [{:<, :"$1", now}], [true]}
])
end
end