feat(chat): GuildManager, the guild rules of the chat server

The chat server is the guild authority (docs/Guilds.md). GuildManager keeps guilds in the database (IGuilds) and tells
clients through their worlds, with every outside dependency behind hooks so the rules are tested without a server:

- Create: name rules (3-30 of letters, digits, space ' - ., no space at either end or twice), the chat filter's deny
  list refuses a name (BAD_NAME), names not on the allow list make the guild but wait for moderation (other players see
  no guild name until then), a taken name (case ignored) is EXISTS, someone in a guild can't make one.
- Invite / answer: leaders and officers invite; the client's answers for not online, already in a guild, invite pending
  and could not invite; one invite per player, answerable for 10 minutes and gone when the player logs off; a full guild
  (guild_max_members) takes nobody. The new member is a recruit; the inviter hears the answer and gets the list again,
  the other members get GUILD_ADD_PLAYER.
- Leave, and DLU's kick, rank and disband (the client has no controls for them): the leader kicks anyone, officers kick
  veterans and recruits; a leader who leaves hands the guild to the highest-ranked, longest-serving member, and the last
  one out ends it. Rank changes send everyone the list again (the client's GuildSetPlayerRank does nothing).
- GUILD_DATA for GUILD_GET_ALL, login/logout/world-change updates to guildmates, a guild that lost its leader (character
  deleted) gets one again, and GuildChanged catches online players up with what the dashboard did.
- Every change is a guild_events row. Each character's world gets GUILD_GET_STATUS with the guild and the name others
  may see.

Not connected to the chat server's packets yet.

Check: GuildManagerTests (18 tests) run every rule against an in-memory IGuilds. Nothing to check in game yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 05:50:13 -05:00
parent 130f3566cc
commit e49c22374b
6 changed files with 1176 additions and 1 deletions

View File

@@ -0,0 +1,494 @@
#include "GuildManager.h"
#include <algorithm>
#include <ctime>
#include <set>
#include "ChatPackets.h"
#include "ClientPackets.h"
#include "GeneralUtils.h"
#include "GuildNameRules.h"
#include "Logger.h"
namespace {
std::string Lower(std::string text) {
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return text;
}
const char* RankName(eGuildRank rank) {
switch (rank) {
case eGuildRank::LEADER: return "Leader";
case eGuildRank::OFFICER: return "Officer";
case eGuildRank::VETERAN: return "Veteran";
case eGuildRank::RECRUIT: return "Recruit";
default: return "no rank";
}
}
eGuildRank Rank(const IGuilds::Member& member) { return static_cast<eGuildRank>(member.rank); }
// The zone the client is given for a member it shows as offline
const LWOZONEID NO_ZONE(0, 0, 0);
}
GuildManager::GuildManager(IGuilds& db, Hooks hooks, Settings settings) : m_Db(db), m_Hooks(std::move(hooks)), m_Settings(settings) {}
bool GuildManager::IsValidName(const std::u16string& name) {
return GuildNameRules::IsValid(name);
}
bool GuildManager::CanInvite(const eGuildRank rank) {
return rank == eGuildRank::LEADER || rank == eGuildRank::OFFICER;
}
bool GuildManager::CanKick(const eGuildRank actor, const eGuildRank target) {
if (actor == eGuildRank::LEADER) return target != eGuildRank::LEADER;
if (actor == eGuildRank::OFFICER) return target == eGuildRank::VETERAN || target == eGuildRank::RECRUIT;
return false;
}
bool GuildManager::CanSetRank(const eGuildRank actor, const eGuildRank target, const eGuildRank newRank) {
if (target == eGuildRank::LEADER || newRank == eGuildRank::NONE || newRank > eGuildRank::RECRUIT) return false;
// Handing the guild over
if (newRank == eGuildRank::LEADER) return actor == eGuildRank::LEADER;
if (actor == eGuildRank::LEADER) return true;
// Officers move members between veteran and recruit
return actor == eGuildRank::OFFICER && (target == eGuildRank::VETERAN || target == eGuildRank::RECRUIT) &&
(newRank == eGuildRank::VETERAN || newRank == eGuildRank::RECRUIT);
}
std::string GuildManager::ShownName(const IGuilds::Guild& guild) {
return guild.nameStatus == IGuilds::NAME_APPROVED ? guild.name : "";
}
std::string GuildManager::FormatDate(const int64_t time) {
const auto seconds = static_cast<std::time_t>(time);
std::tm tm{};
gmtime_r(&seconds, &tm);
char buffer[16];
std::strftime(buffer, sizeof(buffer), "%m/%d/%Y", &tm);
return buffer;
}
std::string GuildManager::NameOf(const LWOOBJID playerID, const std::string& fallback) {
const auto online = m_Hooks.findOnline(playerID);
return online ? online->name : fallback;
}
void GuildManager::LogEvent(const int64_t guildID, const std::string& kind, const LWOOBJID characterID, const std::string& characterName, const std::string& actor, const std::string& detail) {
m_Db.InsertGuildEvent({ 0, guildID, m_Hooks.now(), kind, characterID, characterName, actor, detail });
}
void GuildManager::SendStatus(const LWOOBJID characterID, const int64_t guildID, const std::string& shownName) {
if (guildID == 0) m_OnlineGuild.erase(characterID);
else m_OnlineGuild[characterID] = guildID;
ChatPackets::GuildStatus status;
status.characterID = characterID;
status.guildID = guildID;
status.guildName = ClientPackets::Guild::FixedName(shownName, ClientPackets::Guild::NAME_SIZE);
m_Hooks.sendToWorldOf(characterID, status);
}
std::vector<IGuilds::Member> GuildManager::Members(const int64_t guildID) {
auto members = m_Db.GetGuildMembers(guildID);
if (members.empty()) {
if (m_Db.GetGuild(guildID)) {
LOG("Guild %lli has no members left; deleting it", guildID);
m_Db.DeleteGuild(guildID);
LogEvent(guildID, "disbanded", 0, "", "", "no members left");
}
return members;
}
const bool hasLeader = std::any_of(members.begin(), members.end(), [](const IGuilds::Member& member) { return Rank(member) == eGuildRank::LEADER; });
if (!hasLeader) {
// Ordered by rank, then who joined first
auto& heir = members.front();
LOG("Guild %lli has no leader (their character is gone); %llu leads it now", guildID, heir.characterId);
m_Db.SetGuildMemberRank(heir.characterId, static_cast<uint8_t>(eGuildRank::LEADER));
heir.rank = static_cast<uint8_t>(eGuildRank::LEADER);
LogEvent(guildID, "leader", heir.characterId, heir.name, "", "the leader's character is gone");
}
return members;
}
std::optional<IGuilds::Member> GuildManager::FindMember(const std::vector<IGuilds::Member>& members, const std::string& name) const {
const auto wanted = Lower(name);
for (const auto& member : members) {
if (Lower(member.name) == wanted) return member;
}
return std::nullopt;
}
std::vector<LWOOBJID> GuildManager::OnlineGuildmates(const LWOOBJID playerID) {
std::vector<LWOOBJID> online;
const auto member = m_Db.GetGuildMember(playerID);
if (!member) return online;
for (const auto& other : m_Db.GetGuildMembers(member->guildId)) {
if (m_Hooks.findOnline(other.characterId)) online.push_back(other.characterId);
}
return online;
}
void GuildManager::Create(const LWOOBJID playerID, const std::u16string& requestedName) {
const auto player = m_Hooks.findOnline(playerID);
if (!player) return;
// Spaces typed around the name are not part of it
auto name = requestedName;
while (!name.empty() && name.front() == u' ') name.erase(name.begin());
while (!name.empty() && name.back() == u' ') name.pop_back();
const auto utf8Name = GeneralUtils::UTF16ToWTF8(name);
ClientPackets::GuildCreateResponse response;
response.guildName = utf8Name;
const auto respond = [&](const eGuildCreateResponse result) {
response.result = result;
m_Hooks.sendToPlayer(playerID, response);
};
if (m_Db.GetGuildMember(playerID)) return respond(eGuildCreateResponse::FAILED);
if (!IsValidName(name)) return respond(eGuildCreateResponse::BAD_NAME);
const auto check = m_Hooks.checkName(utf8Name);
if (check == NameCheck::DENIED) return respond(eGuildCreateResponse::BAD_NAME);
if (m_Db.GetGuildByName(utf8Name)) return respond(eGuildCreateResponse::EXISTS);
const auto now = m_Hooks.now();
const int32_t status = check == NameCheck::APPROVED ? IGuilds::NAME_APPROVED : IGuilds::NAME_PENDING;
int64_t guildID = 0;
try {
guildID = m_Db.InsertGuild({ 0, utf8Name, status, playerID, now });
} catch (std::exception& ex) {
// Another guild took the name between the check and the insert
LOG("Could not create guild %s: %s", utf8Name.c_str(), ex.what());
return respond(eGuildCreateResponse::EXISTS);
}
if (guildID == 0) return respond(eGuildCreateResponse::FAILED);
m_Db.AddGuildMember({ playerID, guildID, static_cast<uint8_t>(eGuildRank::LEADER), now });
m_Db.DeleteGuildInvite(playerID);
LogEvent(guildID, "created", playerID, player->name, player->name, utf8Name);
LOG("%s created guild %lli: %s%s", player->name.c_str(), guildID, utf8Name.c_str(), status == IGuilds::NAME_PENDING ? " (name waits for moderation)" : "");
response.guildID = guildID;
respond(eGuildCreateResponse::CREATED);
SendStatus(playerID, guildID, status == IGuilds::NAME_APPROVED ? utf8Name : "");
if (status == IGuilds::NAME_PENDING) {
m_Hooks.notify(playerID, "Other players will see your guild's name once a moderator has approved it.");
}
}
void GuildManager::Invite(const LWOOBJID playerID, const std::string& targetName) {
ClientPackets::GuildInviteInitialResponse response;
response.playerName = targetName;
const auto respond = [&](const eGuildInviteResponse result) {
response.response = result;
m_Hooks.sendToPlayer(playerID, response);
};
const auto inviter = m_Db.GetGuildMember(playerID);
if (!inviter) return respond(eGuildInviteResponse::FAILED);
if (!CanInvite(Rank(*inviter))) {
m_Hooks.notify(playerID, "Only the guild's leader and officers can invite players.");
return respond(eGuildInviteResponse::FAILED);
}
const auto target = m_Hooks.findOnlineByName(targetName);
if (!target) return respond(m_Hooks.characterExists(targetName) ? eGuildInviteResponse::NOT_ONLINE : eGuildInviteResponse::FAILED);
response.playerName = target->name;
if (target->id == playerID) return respond(eGuildInviteResponse::FAILED);
if (m_Db.GetGuildMember(target->id)) return respond(eGuildInviteResponse::ALREADY_IN_GUILD);
const auto now = m_Hooks.now();
const auto pending = m_Db.GetGuildInvite(target->id);
if (pending && pending->createdAt + m_Settings.inviteTimeout > now) return respond(eGuildInviteResponse::INVITE_PENDING);
const auto guild = m_Db.GetGuild(inviter->guildId);
if (!guild) return respond(eGuildInviteResponse::FAILED);
if (m_Db.GetGuildMembers(guild->id).size() >= m_Settings.maxMembers) {
m_Hooks.notify(playerID, "Your guild is full.");
return respond(eGuildInviteResponse::FAILED);
}
m_Db.SetGuildInvite({ target->id, guild->id, playerID, now });
respond(eGuildInviteResponse::SENT);
ClientPackets::GuildInvite invite;
invite.inviterName = NameOf(playerID, inviter->name);
invite.guildName = guild->name;
m_Hooks.sendToPlayer(target->id, invite);
}
void GuildManager::AnswerInvite(const LWOOBJID playerID, const bool declined) {
const auto invite = m_Db.GetGuildInvite(playerID);
const auto confirmFailed = [&](const std::string& guildName) {
if (declined) return;
ClientPackets::GuildInviteConfirm confirm;
confirm.failed = true;
confirm.guildName = guildName;
m_Hooks.sendToPlayer(playerID, confirm);
};
if (!invite) return confirmFailed("");
m_Db.DeleteGuildInvite(playerID);
const auto guild = m_Db.GetGuild(invite->guildId);
if (!guild || invite->createdAt + m_Settings.inviteTimeout <= m_Hooks.now()) return confirmFailed(guild ? guild->name : "");
const auto inviteeName = NameOf(playerID);
ClientPackets::GuildInviteFinalResponse final;
final.playerName = inviteeName;
const auto tellInviter = [&](const eGuildInviteFinalResponse result) {
final.response = result;
if (m_Hooks.findOnline(invite->inviterId)) m_Hooks.sendToPlayer(invite->inviterId, final);
};
if (declined) return tellInviter(eGuildInviteFinalResponse::DECLINED);
const auto members = Members(guild->id);
if (members.empty() || m_Db.GetGuildMember(playerID) || members.size() >= m_Settings.maxMembers) {
tellInviter(eGuildInviteFinalResponse::FAILED);
return confirmFailed(guild->name);
}
const auto now = m_Hooks.now();
m_Db.AddGuildMember({ playerID, guild->id, static_cast<uint8_t>(eGuildRank::RECRUIT), now });
LogEvent(guild->id, "joined", playerID, inviteeName, NameOf(invite->inviterId));
ClientPackets::GuildInviteConfirm confirm;
confirm.guildName = guild->name;
m_Hooks.sendToPlayer(playerID, confirm);
SendStatus(playerID, guild->id, ShownName(*guild));
const auto invitee = m_Hooks.findOnline(playerID);
ClientPackets::GuildAddPlayer added;
added.playerName = inviteeName;
added.playerID = playerID;
added.rank = eGuildRank::RECRUIT;
added.online = invitee.has_value();
added.zoneID = invitee ? invitee->zone : NO_ZONE;
for (const auto& member : members) {
if (member.characterId == invite->inviterId || !m_Hooks.findOnline(member.characterId)) continue;
m_Hooks.sendToPlayer(member.characterId, added);
}
// The inviter hears the answer, then gets the list again (GUILD_ADD_PLAYER would say "has joined" a second time)
if (m_Hooks.findOnline(invite->inviterId)) {
tellInviter(eGuildInviteFinalResponse::JOINED);
SendData(invite->inviterId);
}
}
void GuildManager::RemoveMember(const IGuilds::Guild& guild, const IGuilds::Member& member, const eGuildLeaveReason reason, const std::string& actor) {
const auto members = m_Db.GetGuildMembers(guild.id);
m_Db.RemoveGuildMember(member.characterId);
const auto memberName = NameOf(member.characterId, member.name);
LogEvent(guild.id, reason == eGuildLeaveReason::KICKED ? "kicked" : "left", member.characterId, memberName, actor);
LWOOBJID newLeader = LWOOBJID_EMPTY;
bool othersLeft = false;
for (const auto& other : members) {
if (other.characterId == member.characterId) continue;
othersLeft = true;
// Ordered by rank, then who joined first: the first one left leads when the leader goes
if (Rank(member) == eGuildRank::LEADER && newLeader == LWOOBJID_EMPTY) {
newLeader = other.characterId;
m_Db.SetGuildMemberRank(newLeader, static_cast<uint8_t>(eGuildRank::LEADER));
LogEvent(guild.id, "leader", newLeader, NameOf(newLeader, other.name), actor, "the leader left");
}
}
if (!othersLeft) {
m_Db.DeleteGuild(guild.id);
LogEvent(guild.id, "disbanded", member.characterId, memberName, actor, "the last member left");
LOG("Guild %lli (%s) is gone: its last member left", guild.id, guild.name.c_str());
}
ClientPackets::GuildRemovePlayer removed;
removed.reason = reason;
removed.playerName = memberName;
removed.playerID = member.characterId;
removed.newLeaderID = newLeader;
for (const auto& other : members) {
if (m_Hooks.findOnline(other.characterId)) m_Hooks.sendToPlayer(other.characterId, removed);
}
if (m_Hooks.findOnline(member.characterId)) SendStatus(member.characterId, 0, "");
else m_OnlineGuild.erase(member.characterId);
}
void GuildManager::Leave(const LWOOBJID playerID) {
const auto member = m_Db.GetGuildMember(playerID);
if (!member) return;
const auto guild = m_Db.GetGuild(member->guildId);
if (!guild) return;
RemoveMember(*guild, *member, eGuildLeaveReason::LEFT, NameOf(playerID, member->name));
}
void GuildManager::Kick(const LWOOBJID playerID, const std::string& targetName) {
const auto actor = m_Db.GetGuildMember(playerID);
if (!actor) return m_Hooks.notify(playerID, "You are not in a guild.");
const auto guild = m_Db.GetGuild(actor->guildId);
if (!guild) return;
const auto target = FindMember(Members(guild->id), targetName);
if (!target) return m_Hooks.notify(playerID, targetName + " is not in your guild.");
if (target->characterId == playerID) return m_Hooks.notify(playerID, "To leave the guild, use the guild window's leave button.");
if (!CanKick(Rank(*actor), Rank(*target))) return m_Hooks.notify(playerID, "You can't remove " + target->name + " from the guild.");
RemoveMember(*guild, *target, eGuildLeaveReason::KICKED, NameOf(playerID, actor->name));
}
void GuildManager::SetRank(const LWOOBJID playerID, const std::string& targetName, const eGuildRank rank) {
const auto actor = m_Db.GetGuildMember(playerID);
if (!actor) return m_Hooks.notify(playerID, "You are not in a guild.");
const auto guild = m_Db.GetGuild(actor->guildId);
if (!guild) return;
const auto members = Members(guild->id);
const auto target = FindMember(members, targetName);
if (!target) return m_Hooks.notify(playerID, targetName + " is not in your guild.");
// Members() may have just made the actor the leader
const auto actorNow = FindMember(members, actor->name);
const auto actorRank = actorNow ? Rank(*actorNow) : Rank(*actor);
if (target->characterId == playerID || !CanSetRank(actorRank, Rank(*target), rank)) {
return m_Hooks.notify(playerID, "You can't make " + target->name + " a " + RankName(rank) + ".");
}
if (Rank(*target) == rank) return m_Hooks.notify(playerID, target->name + " is already a " + RankName(rank) + ".");
const auto actorName = NameOf(playerID, actor->name);
m_Db.SetGuildMemberRank(target->characterId, static_cast<uint8_t>(rank));
if (rank == eGuildRank::LEADER) {
m_Db.SetGuildMemberRank(playerID, static_cast<uint8_t>(eGuildRank::OFFICER));
LogEvent(guild->id, "leader", target->characterId, target->name, actorName, "handed over");
} else {
LogEvent(guild->id, "rank", target->characterId, target->name, actorName, RankName(rank));
}
m_Hooks.notify(playerID, target->name + " is now a " + RankName(rank) + ".");
// The client has no rank change packet (GuildSetPlayerRank does nothing): send the list again
SendDataToOnline(guild->id);
}
void GuildManager::Disband(const LWOOBJID playerID) {
const auto actor = m_Db.GetGuildMember(playerID);
if (!actor) return m_Hooks.notify(playerID, "You are not in a guild.");
if (Rank(*actor) != eGuildRank::LEADER) return m_Hooks.notify(playerID, "Only the guild's leader can disband it.");
const auto guild = m_Db.GetGuild(actor->guildId);
if (!guild) return;
const auto members = m_Db.GetGuildMembers(guild->id);
m_Db.DeleteGuild(guild->id);
const auto actorName = NameOf(playerID, actor->name);
LogEvent(guild->id, "disbanded", playerID, actorName, actorName);
LOG("%s disbanded guild %lli (%s)", actorName.c_str(), guild->id, guild->name.c_str());
for (const auto& member : members) {
if (!m_Hooks.findOnline(member.characterId)) {
m_OnlineGuild.erase(member.characterId);
continue;
}
// Each client drops the guild when it is told that it left
ClientPackets::GuildRemovePlayer removed;
removed.reason = eGuildLeaveReason::LEFT;
removed.playerName = NameOf(member.characterId, member.name);
removed.playerID = member.characterId;
m_Hooks.sendToPlayer(member.characterId, removed);
SendStatus(member.characterId, 0, "");
m_Hooks.notify(member.characterId, "The guild " + guild->name + " was disbanded.");
}
}
void GuildManager::SendData(const LWOOBJID playerID) {
ClientPackets::GuildData data;
const auto member = m_Db.GetGuildMember(playerID);
const auto guild = member ? m_Db.GetGuild(member->guildId) : std::nullopt;
const auto members = guild ? Members(guild->id) : std::vector<IGuilds::Member>{};
if (!guild || members.empty()) {
data.status = 1;
m_Hooks.sendToPlayer(playerID, data);
return;
}
data.guildName = guild->name;
data.joinDate = FormatDate(member->joinedAt);
data.foundDate = FormatDate(guild->createdAt);
for (const auto& other : members) {
const auto online = m_Hooks.findOnline(other.characterId);
ClientPackets::GuildData::Member entry;
entry.rank = Rank(other);
entry.online = online.has_value();
entry.zoneID = online ? online->zone : NO_ZONE;
entry.playerID = other.characterId;
entry.name = online ? online->name : other.name;
data.members.push_back(std::move(entry));
}
m_Hooks.sendToPlayer(playerID, data);
}
void GuildManager::SendDataToOnline(const int64_t guildID) {
for (const auto& member : m_Db.GetGuildMembers(guildID)) {
if (m_Hooks.findOnline(member.characterId)) SendData(member.characterId);
}
}
void GuildManager::GetAll(const LWOOBJID playerID) {
SendData(playerID);
}
void GuildManager::PlayerOnline(const LWOOBJID playerID, const bool login) {
const auto player = m_Hooks.findOnline(playerID);
const auto member = m_Db.GetGuildMember(playerID);
if (!player || !member) {
m_OnlineGuild.erase(playerID);
return;
}
m_OnlineGuild[playerID] = member->guildId;
ClientPackets::GuildLoginLogout update;
update.playerName = player->name;
update.playerID = playerID;
update.online = true;
update.zoneID = player->zone;
update.worldUpdateOnly = !login;
for (const auto& other : m_Db.GetGuildMembers(member->guildId)) {
if (other.characterId == playerID || !m_Hooks.findOnline(other.characterId)) continue;
m_Hooks.sendToPlayer(other.characterId, update);
}
}
void GuildManager::PlayerOffline(const LWOOBJID playerID) {
// An invite is answered in a message box; it is gone with the player
m_Db.DeleteGuildInvite(playerID);
m_OnlineGuild.erase(playerID);
const auto member = m_Db.GetGuildMember(playerID);
if (!member) return;
ClientPackets::GuildLoginLogout update;
update.playerName = NameOf(playerID, member->name);
update.playerID = playerID;
update.online = false;
update.zoneID = NO_ZONE;
for (const auto& other : m_Db.GetGuildMembers(member->guildId)) {
if (other.characterId == playerID || !m_Hooks.findOnline(other.characterId)) continue;
m_Hooks.sendToPlayer(other.characterId, update);
}
}
void GuildManager::GuildChanged(const int64_t guildID) {
const auto guild = m_Db.GetGuild(guildID);
const auto members = guild ? m_Db.GetGuildMembers(guildID) : std::vector<IGuilds::Member>{};
std::set<LWOOBJID> memberIDs;
for (const auto& member : members) memberIDs.insert(member.characterId);
// Online players who were in it and aren't any more
std::vector<LWOOBJID> removed;
for (const auto& [playerID, playerGuild] : m_OnlineGuild) {
if (playerGuild == guildID && !memberIDs.contains(playerID)) removed.push_back(playerID);
}
for (const auto playerID : removed) {
if (m_Hooks.findOnline(playerID)) {
ClientPackets::GuildRemovePlayer packet;
packet.reason = eGuildLeaveReason::KICKED;
packet.playerName = NameOf(playerID);
packet.playerID = playerID;
m_Hooks.sendToPlayer(playerID, packet);
SendStatus(playerID, 0, "");
m_Hooks.notify(playerID, guild ? "A moderator removed you from your guild." : "A moderator disbanded your guild.");
} else {
m_OnlineGuild.erase(playerID);
}
}
if (!guild) return;
const auto shown = ShownName(*guild);
for (const auto& member : members) {
if (!m_Hooks.findOnline(member.characterId)) continue;
SendStatus(member.characterId, guildID, shown);
SendData(member.characterId);
}
}