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

@@ -3,6 +3,7 @@ set(DCHATSERVER_SOURCES
"ChatPacketHandler.cpp"
"ChatJSONUtils.cpp"
"ChatWeb.cpp"
"GuildManager.cpp"
"PlayerContainer.cpp"
"TeamContainer.cpp"
)

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);
}
}

116
dChatServer/GuildManager.h Normal file
View File

@@ -0,0 +1,116 @@
#ifndef GUILDMANAGER_H
#define GUILDMANAGER_H
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
#include "dCommonVars.h"
#include "eGuildRank.h"
#include "IGuilds.h"
struct LUBitStream;
/**
* The chat server's guilds (docs/Guilds.md): it is the guild authority. State is in the database (IGuilds); what it
* tells clients goes through their worlds. It only runs on the chat server's main thread.
*
* Everything outside the guild tables is reached through Hooks, so the rules can be tested without a server.
*/
class GuildManager {
public:
// What the chat server knows about an online player
struct OnlinePlayer {
LWOOBJID id{};
std::string name;
LWOZONEID zone{};
};
// The chat filter's verdict on a guild name
enum class NameCheck {
APPROVED, // on the allow list
PENDING, // not on the deny list, not (all) on the allow list: a moderator decides
DENIED, // on the deny list
};
struct Hooks {
std::function<std::optional<OnlinePlayer>(LWOOBJID)> findOnline;
std::function<std::optional<OnlinePlayer>(const std::string&)> findOnlineByName;
// Whether a character with this name exists (for "not online" versus "could not invite")
std::function<bool(const std::string&)> characterExists;
// A client packet to an online player (routed through their world)
std::function<void(LWOOBJID, const LUBitStream&)> sendToPlayer;
// A chat -> world packet to the world an online player is in
std::function<void(LWOOBJID, const LUBitStream&)> sendToWorldOf;
// A system chat line to an online player
std::function<void(LWOOBJID, const std::string&)> notify;
std::function<NameCheck(const std::string&)> checkName;
std::function<int64_t()> now;
};
struct Settings {
uint32_t maxMembers = 100;
int64_t inviteTimeout = 600; // seconds an invite can be answered
};
GuildManager(IGuilds& db, Hooks hooks, Settings settings);
// GuildNameRules::IsValid
static bool IsValidName(const std::u16string& name);
// The client's rank rules as DLU sets them (the client lets anyone try: GuildCanWeInvite/Kick are always true)
static bool CanInvite(eGuildRank rank);
static bool CanKick(eGuildRank actor, eGuildRank target);
static bool CanSetRank(eGuildRank actor, eGuildRank target, eGuildRank newRank);
// From the client (through its world)
void Create(LWOOBJID playerID, const std::u16string& name);
void Invite(LWOOBJID playerID, const std::string& targetName);
void AnswerInvite(LWOOBJID playerID, bool declined);
void Leave(LWOOBJID playerID);
void GetAll(LWOOBJID playerID);
// DLU slash commands (the client has no controls for them)
void Kick(LWOOBJID playerID, const std::string& targetName);
void SetRank(LWOOBJID playerID, const std::string& targetName, eGuildRank rank);
void Disband(LWOOBJID playerID);
// A player came online (login) or changed worlds
void PlayerOnline(LWOOBJID playerID, bool login);
// A player went offline (before the chat server forgets them)
void PlayerOffline(LWOOBJID playerID);
// The dashboard changed a guild in the database (renamed, name moderated, disbanded, member removed): tell the
// online players what changed for them
void GuildChanged(int64_t guildID);
// Online members of the player's guild, the player included (empty: not in a guild)
std::vector<LWOOBJID> OnlineGuildmates(LWOOBJID playerID);
// The name other players see: nothing while it waits for moderation
static std::string ShownName(const IGuilds::Guild& guild);
// MM/DD/YYYY, UTC
static std::string FormatDate(int64_t time);
private:
void SendStatus(LWOOBJID characterID, int64_t guildID, const std::string& shownName);
void SendData(LWOOBJID playerID);
void SendDataToOnline(int64_t guildID);
void LogEvent(int64_t guildID, const std::string& kind, LWOOBJID characterID, const std::string& characterName, const std::string& actor, const std::string& detail = "");
// The members, with a leader again if the guild lost its (the leader's character was deleted). Empty: the guild is
// gone (deleted when it had no members left).
std::vector<IGuilds::Member> Members(int64_t guildID);
std::optional<IGuilds::Member> FindMember(const std::vector<IGuilds::Member>& members, const std::string& name) const;
// Removes a member and tells everyone; hands the guild on when the leader goes, deletes it with the last member
void RemoveMember(const IGuilds::Guild& guild, const IGuilds::Member& member, eGuildLeaveReason reason, const std::string& actor);
std::string NameOf(LWOOBJID playerID, const std::string& fallback = "");
IGuilds& m_Db;
Hooks m_Hooks;
Settings m_Settings;
// Online players' guilds, to know who to tell when the dashboard changes a guild behind the chat server's back
std::unordered_map<LWOOBJID, int64_t> m_OnlineGuild;
};
#endif // GUILDMANAGER_H

27
dCommon/GuildNameRules.h Normal file
View File

@@ -0,0 +1,27 @@
#ifndef GUILDNAMERULES_H
#define GUILDNAMERULES_H
#include <cstddef>
#include <string>
// What a guild name may be (docs/Guilds.md), for the chat server and the dashboard
namespace GuildNameRules {
constexpr size_t MIN_LENGTH = 3;
constexpr size_t MAX_LENGTH = 30; // the client sends at most 30 characters
// MIN_LENGTH to MAX_LENGTH of A-Z a-z 0-9, space, ' - . with no space at either end and no two in a row
inline bool IsValid(const std::u16string& name) {
if (name.size() < MIN_LENGTH || name.size() > MAX_LENGTH) return false;
if (name.front() == u' ' || name.back() == u' ') return false;
for (size_t i = 0; i < name.size(); i++) {
const char16_t c = name[i];
const bool allowed = (c >= u'a' && c <= u'z') || (c >= u'A' && c <= u'Z') || (c >= u'0' && c <= u'9') ||
c == u' ' || c == u'\'' || c == u'-' || c == u'.';
if (!allowed) return false;
if (c == u' ' && i > 0 && name[i - 1] == u' ') return false;
}
return true;
}
}
#endif // GUILDNAMERULES_H

View File

@@ -1,5 +1,6 @@
set(DGAMETEST_SOURCES
"GameDependencies.cpp"
"GuildManagerTests.cpp"
"LUBitStreamTests.cpp"
"MailIdPinTests.cpp"
"EconomyLedgerTests.cpp"
@@ -49,7 +50,7 @@ if(APPLE)
endif()
target_link_libraries(dGameTests ${COMMON_LIBRARIES} GTest::gtest_main
dGame dScripts dPhysics Detour Recast tinyxml2 dWorldServer dZoneManager dChatFilter dNavigation bcrypt MD5)
dGame dScripts dPhysics Detour Recast tinyxml2 dWorldServer dZoneManager dChatFilter dChatServer dNavigation bcrypt MD5)
# Discover the tests
gtest_discover_tests(dGameTests)

View File

@@ -0,0 +1,536 @@
// The chat server's guild rules (dChatServer/GuildManager, docs/Guilds.md) against an in-memory IGuilds, with every
// packet it sends captured.
#include "GuildManager.h"
#include "ChatPackets.h"
#include "ClientPackets.h"
#include "GameDependencies.h"
#include <algorithm>
#include <map>
#include <gtest/gtest.h>
namespace {
// IGuilds in memory, ordered the way the SQL implementations order
class FakeGuilds : public IGuilds {
public:
std::map<int64_t, Guild> guilds;
std::map<LWOOBJID, Member> members;
std::map<LWOOBJID, Invite> invites;
std::vector<Event> events;
std::map<LWOOBJID, std::string> characterNames;
int64_t nextId = 1;
static std::string Lower(std::string text) {
std::transform(text.begin(), text.end(), text.begin(), ::tolower);
return text;
}
int64_t InsertGuild(const Guild& guild) override {
if (GetGuildByName(guild.name)) throw std::runtime_error("duplicate name");
auto copy = guild;
copy.id = nextId++;
guilds[copy.id] = copy;
return copy.id;
}
std::optional<Guild> GetGuild(int64_t guildId) override {
const auto it = guilds.find(guildId);
return it == guilds.end() ? std::nullopt : std::optional<Guild>(it->second);
}
std::optional<Guild> GetGuildByName(const std::string& name) override {
for (const auto& [id, guild] : guilds) if (Lower(guild.name) == Lower(name)) return guild;
return std::nullopt;
}
void SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) override {
guilds[guildId].name = name;
guilds[guildId].nameStatus = nameStatus;
}
void DeleteGuild(int64_t guildId) override {
guilds.erase(guildId);
std::erase_if(members, [&](const auto& entry) { return entry.second.guildId == guildId; });
std::erase_if(invites, [&](const auto& entry) { return entry.second.guildId == guildId; });
}
void AddGuildMember(const Member& member) override {
auto copy = member;
copy.name = characterNames[member.characterId];
members[member.characterId] = copy;
}
void RemoveGuildMember(LWOOBJID characterId) override { members.erase(characterId); }
void SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) override { members[characterId].rank = rank; }
std::optional<Member> GetGuildMember(LWOOBJID characterId) override {
const auto it = members.find(characterId);
return it == members.end() ? std::nullopt : std::optional<Member>(it->second);
}
std::vector<Member> GetGuildMembers(int64_t guildId) override {
std::vector<Member> result;
for (const auto& [id, member] : members) if (member.guildId == guildId) result.push_back(member);
std::sort(result.begin(), result.end(), [](const Member& a, const Member& b) {
return std::tie(a.rank, a.joinedAt, a.characterId) < std::tie(b.rank, b.joinedAt, b.characterId);
});
return result;
}
void SetGuildInvite(const Invite& invite) override { invites[invite.characterId] = invite; }
std::optional<Invite> GetGuildInvite(LWOOBJID characterId) override {
const auto it = invites.find(characterId);
return it == invites.end() ? std::nullopt : std::optional<Invite>(it->second);
}
void DeleteGuildInvite(LWOOBJID characterId) override { invites.erase(characterId); }
uint64_t InsertGuildEvent(const Event& event) override {
events.push_back(event);
events.back().id = events.size();
return events.size();
}
std::vector<Event> GetGuildEvents(int64_t guildId, uint32_t limit) override { return events; }
GuildPage GetGuildPage(uint32_t, uint32_t, const std::string&, bool) override { return {}; }
};
struct Sent {
LWOOBJID to{};
bool toWorld{};
uint32_t id{};
std::vector<uint8_t> bytes;
};
constexpr LWOOBJID ALICE = 0x1000000000000001LL;
constexpr LWOOBJID BOB = 0x1000000000000002LL;
constexpr LWOOBJID CAROL = 0x1000000000000003LL;
constexpr LWOOBJID DAVE = 0x1000000000000004LL;
constexpr LWOOBJID ERIN = 0x1000000000000005LL; // exists, never online
}
class GuildManagerTests : public GameDependenciesTest {
protected:
FakeGuilds db;
std::map<LWOOBJID, GuildManager::OnlinePlayer> online;
std::vector<Sent> sent;
std::vector<std::pair<LWOOBJID, std::string>> notes;
int64_t now = 1700000000;
GuildManager::NameCheck nameCheck = GuildManager::NameCheck::APPROVED;
std::unique_ptr<GuildManager> guilds;
void SetUp() override {
SetUpDependencies();
const std::map<LWOOBJID, std::string> names{ { ALICE, "Alice" }, { BOB, "Bob" }, { CAROL, "Carol" }, { DAVE, "Dave" }, { ERIN, "Erin" } };
db.characterNames = names;
for (const auto id : { ALICE, BOB, CAROL, DAVE }) online[id] = { id, names.at(id), LWOZONEID(1200, 1, 0) };
GuildManager::Hooks hooks;
hooks.findOnline = [this](LWOOBJID id) -> std::optional<GuildManager::OnlinePlayer> {
const auto it = online.find(id);
return it == online.end() ? std::nullopt : std::optional<GuildManager::OnlinePlayer>(it->second);
};
hooks.findOnlineByName = [this](const std::string& name) -> std::optional<GuildManager::OnlinePlayer> {
for (const auto& [id, player] : online) if (FakeGuilds::Lower(player.name) == FakeGuilds::Lower(name)) return player;
return std::nullopt;
};
hooks.characterExists = [this](const std::string& name) {
for (const auto& [id, known] : db.characterNames) if (FakeGuilds::Lower(known) == FakeGuilds::Lower(name)) return true;
return false;
};
hooks.sendToPlayer = [this](LWOOBJID to, const LUBitStream& msg) { Record(to, false, msg); };
hooks.sendToWorldOf = [this](LWOOBJID to, const LUBitStream& msg) { Record(to, true, msg); };
hooks.notify = [this](LWOOBJID to, const std::string& text) { notes.emplace_back(to, text); };
hooks.checkName = [this](const std::string&) { return nameCheck; };
hooks.now = [this] { return now; };
guilds = std::make_unique<GuildManager>(db, hooks, GuildManager::Settings{ 4, 600 });
}
void TearDown() override { TearDownDependencies(); }
void Record(LWOOBJID to, bool toWorld, const LUBitStream& msg) {
RakNet::BitStream bitStream;
msg.WritePacket(bitStream);
sent.push_back({ to, toWorld, msg.internalPacketID, { bitStream.GetData(), bitStream.GetData() + bitStream.GetNumberOfBytesUsed() } });
}
// The packets of type T sent to `to`, read back
template<typename T>
std::vector<T> To(LWOOBJID to) {
std::vector<T> result;
const T probe;
for (const auto& packet : sent) {
if (packet.to != to || packet.id != probe.internalPacketID) continue;
const bool worldPacket = probe.connectionType == ServiceType::CHAT;
if (packet.toWorld != worldPacket) continue;
RakNet::BitStream bitStream(const_cast<uint8_t*>(packet.bytes.data()), packet.bytes.size(), false);
LUBitStream header;
EXPECT_TRUE(header.ReadHeader(bitStream));
T msg;
EXPECT_TRUE(msg.Deserialize(bitStream));
result.push_back(msg);
}
return result;
}
// Alice leads "Brick Builders"; each name in `joining` is invited by Alice and accepts
int64_t MakeGuild(std::initializer_list<LWOOBJID> joining = {}) {
guilds->Create(ALICE, u"Brick Builders");
const auto guild = db.GetGuildByName("Brick Builders");
EXPECT_TRUE(guild);
for (const auto id : joining) {
guilds->Invite(ALICE, online.at(id).name);
now++;
guilds->AnswerInvite(id, false);
}
sent.clear();
notes.clear();
return guild ? guild->id : 0;
}
};
TEST_F(GuildManagerTests, NameRules) {
EXPECT_TRUE(GuildManager::IsValidName(u"Brick Builders"));
EXPECT_TRUE(GuildManager::IsValidName(u"O'Brien's-Crew.2"));
EXPECT_TRUE(GuildManager::IsValidName(std::u16string(30, u'a')));
EXPECT_FALSE(GuildManager::IsValidName(std::u16string(31, u'a')));
EXPECT_FALSE(GuildManager::IsValidName(u"ab"));
EXPECT_FALSE(GuildManager::IsValidName(u" Brick"));
EXPECT_FALSE(GuildManager::IsValidName(u"Brick "));
EXPECT_FALSE(GuildManager::IsValidName(u"Brick Builders"));
EXPECT_FALSE(GuildManager::IsValidName(u"Brick_Builders"));
EXPECT_FALSE(GuildManager::IsValidName(u"Café"));
}
TEST_F(GuildManagerTests, RankRules) {
using enum eGuildRank;
EXPECT_TRUE(GuildManager::CanInvite(LEADER));
EXPECT_TRUE(GuildManager::CanInvite(OFFICER));
EXPECT_FALSE(GuildManager::CanInvite(VETERAN));
EXPECT_FALSE(GuildManager::CanInvite(RECRUIT));
EXPECT_TRUE(GuildManager::CanKick(LEADER, OFFICER));
EXPECT_FALSE(GuildManager::CanKick(OFFICER, OFFICER));
EXPECT_TRUE(GuildManager::CanKick(OFFICER, RECRUIT));
EXPECT_FALSE(GuildManager::CanKick(VETERAN, RECRUIT));
EXPECT_TRUE(GuildManager::CanSetRank(LEADER, RECRUIT, OFFICER));
EXPECT_TRUE(GuildManager::CanSetRank(LEADER, OFFICER, LEADER));
EXPECT_FALSE(GuildManager::CanSetRank(OFFICER, RECRUIT, OFFICER));
EXPECT_TRUE(GuildManager::CanSetRank(OFFICER, RECRUIT, VETERAN));
EXPECT_FALSE(GuildManager::CanSetRank(OFFICER, OFFICER, VETERAN));
EXPECT_FALSE(GuildManager::CanSetRank(OFFICER, RECRUIT, LEADER));
EXPECT_FALSE(GuildManager::CanSetRank(LEADER, RECRUIT, NONE));
}
TEST_F(GuildManagerTests, Create) {
guilds->Create(ALICE, u" Brick Builders ");
const auto responses = To<ClientPackets::GuildCreateResponse>(ALICE);
ASSERT_EQ(responses.size(), 1u);
EXPECT_EQ(responses[0].result, eGuildCreateResponse::CREATED);
EXPECT_EQ(responses[0].guildName, "Brick Builders");
const auto guild = db.GetGuildByName("brick builders");
ASSERT_TRUE(guild);
EXPECT_EQ(responses[0].guildID, guild->id);
EXPECT_EQ(guild->nameStatus, IGuilds::NAME_APPROVED);
EXPECT_EQ(db.GetGuildMember(ALICE)->rank, static_cast<uint8_t>(eGuildRank::LEADER));
// Alice's world puts the guild in her character component
const auto status = To<ChatPackets::GuildStatus>(ALICE);
ASSERT_EQ(status.size(), 1u);
EXPECT_EQ(status[0].guildID, guild->id);
EXPECT_EQ(status[0].guildName.GetAsString(), "Brick Builders");
ASSERT_EQ(db.events.size(), 1u);
EXPECT_EQ(db.events[0].kind, "created");
}
TEST_F(GuildManagerTests, CreateRefusals) {
const auto result = [&](LWOOBJID player) {
const auto responses = To<ClientPackets::GuildCreateResponse>(player);
sent.clear();
return responses.empty() ? eGuildCreateResponse::FAILED : responses.back().result;
};
guilds->Create(ALICE, u"x");
EXPECT_EQ(result(ALICE), eGuildCreateResponse::BAD_NAME);
nameCheck = GuildManager::NameCheck::DENIED;
guilds->Create(ALICE, u"Rude Words");
EXPECT_EQ(result(ALICE), eGuildCreateResponse::BAD_NAME);
nameCheck = GuildManager::NameCheck::APPROVED;
guilds->Create(ALICE, u"Brick Builders");
EXPECT_EQ(result(ALICE), eGuildCreateResponse::CREATED);
guilds->Create(BOB, u"BRICK builders");
EXPECT_EQ(result(BOB), eGuildCreateResponse::EXISTS);
// Already in a guild
guilds->Create(ALICE, u"Second Guild");
EXPECT_EQ(result(ALICE), eGuildCreateResponse::FAILED);
EXPECT_EQ(db.guilds.size(), 1u);
}
TEST_F(GuildManagerTests, PendingNameIsNotShown) {
nameCheck = GuildManager::NameCheck::PENDING;
guilds->Create(ALICE, u"Unknown Words");
EXPECT_EQ(To<ClientPackets::GuildCreateResponse>(ALICE).back().result, eGuildCreateResponse::CREATED);
EXPECT_EQ(db.GetGuildByName("Unknown Words")->nameStatus, IGuilds::NAME_PENDING);
EXPECT_EQ(To<ChatPackets::GuildStatus>(ALICE).back().guildName.GetAsString(), "");
ASSERT_EQ(notes.size(), 1u);
// The members see it in their guild window
sent.clear();
guilds->GetAll(ALICE);
EXPECT_EQ(To<ClientPackets::GuildData>(ALICE).back().guildName, "Unknown Words");
}
TEST_F(GuildManagerTests, InviteAndAccept) {
const auto guildID = MakeGuild({ BOB });
guilds->Invite(ALICE, "carol");
const auto initial = To<ClientPackets::GuildInviteInitialResponse>(ALICE);
ASSERT_EQ(initial.size(), 1u);
EXPECT_EQ(initial[0].response, eGuildInviteResponse::SENT);
EXPECT_EQ(initial[0].playerName, "Carol");
const auto invites = To<ClientPackets::GuildInvite>(CAROL);
ASSERT_EQ(invites.size(), 1u);
EXPECT_EQ(invites[0].inviterName, "Alice");
EXPECT_EQ(invites[0].guildName, "Brick Builders");
sent.clear();
guilds->AnswerInvite(CAROL, false);
const auto confirm = To<ClientPackets::GuildInviteConfirm>(CAROL);
ASSERT_EQ(confirm.size(), 1u);
EXPECT_FALSE(confirm[0].failed);
EXPECT_EQ(db.GetGuildMember(CAROL)->rank, static_cast<uint8_t>(eGuildRank::RECRUIT));
EXPECT_EQ(To<ChatPackets::GuildStatus>(CAROL).back().guildID, guildID);
// The inviter hears the answer and gets the list again; the other members get GUILD_ADD_PLAYER
EXPECT_EQ(To<ClientPackets::GuildInviteFinalResponse>(ALICE).back().response, eGuildInviteFinalResponse::JOINED);
EXPECT_EQ(To<ClientPackets::GuildData>(ALICE).back().members.size(), 3u);
EXPECT_TRUE(To<ClientPackets::GuildAddPlayer>(ALICE).empty());
const auto added = To<ClientPackets::GuildAddPlayer>(BOB);
ASSERT_EQ(added.size(), 1u);
EXPECT_EQ(added[0].playerID, CAROL);
EXPECT_EQ(added[0].rank, eGuildRank::RECRUIT);
EXPECT_TRUE(added[0].online);
EXPECT_FALSE(db.GetGuildInvite(CAROL));
}
TEST_F(GuildManagerTests, InviteRefusals) {
MakeGuild({ BOB });
const auto result = [&](LWOOBJID player) {
const auto responses = To<ClientPackets::GuildInviteInitialResponse>(player);
sent.clear();
return responses.empty() ? eGuildInviteResponse::SENT : responses.back().response;
};
// Recruits can't invite
guilds->Invite(BOB, "Carol");
EXPECT_EQ(result(BOB), eGuildInviteResponse::FAILED);
guilds->Invite(ALICE, "Erin");
EXPECT_EQ(result(ALICE), eGuildInviteResponse::NOT_ONLINE);
guilds->Invite(ALICE, "Nobody");
EXPECT_EQ(result(ALICE), eGuildInviteResponse::FAILED);
guilds->Invite(ALICE, "Bob");
EXPECT_EQ(result(ALICE), eGuildInviteResponse::ALREADY_IN_GUILD);
guilds->Invite(ALICE, "Alice");
EXPECT_EQ(result(ALICE), eGuildInviteResponse::FAILED);
guilds->Invite(ALICE, "Carol");
EXPECT_EQ(result(ALICE), eGuildInviteResponse::SENT);
guilds->Invite(ALICE, "Carol");
EXPECT_EQ(result(ALICE), eGuildInviteResponse::INVITE_PENDING);
// An invite runs out
now += 601;
guilds->Invite(ALICE, "Carol");
EXPECT_EQ(result(ALICE), eGuildInviteResponse::SENT);
// Someone not in a guild
guilds->Invite(DAVE, "Carol");
EXPECT_EQ(result(DAVE), eGuildInviteResponse::FAILED);
}
TEST_F(GuildManagerTests, GuildIsFull) {
MakeGuild({ BOB, CAROL, DAVE });
online[ERIN] = { ERIN, "Erin", LWOZONEID(1200, 1, 0) };
guilds->Invite(ALICE, "Erin");
EXPECT_EQ(To<ClientPackets::GuildInviteInitialResponse>(ALICE).back().response, eGuildInviteResponse::FAILED);
EXPECT_FALSE(db.GetGuildMember(ERIN));
}
TEST_F(GuildManagerTests, DeclineAndExpiredAnswers) {
MakeGuild();
guilds->Invite(ALICE, "Bob");
sent.clear();
guilds->AnswerInvite(BOB, true);
EXPECT_EQ(To<ClientPackets::GuildInviteFinalResponse>(ALICE).back().response, eGuildInviteFinalResponse::DECLINED);
EXPECT_TRUE(To<ClientPackets::GuildInviteConfirm>(BOB).empty());
EXPECT_FALSE(db.GetGuildMember(BOB));
guilds->Invite(ALICE, "Bob");
now += 601;
sent.clear();
guilds->AnswerInvite(BOB, false);
EXPECT_TRUE(To<ClientPackets::GuildInviteConfirm>(BOB).back().failed);
EXPECT_FALSE(db.GetGuildMember(BOB));
// No invite at all
sent.clear();
guilds->AnswerInvite(CAROL, false);
EXPECT_TRUE(To<ClientPackets::GuildInviteConfirm>(CAROL).back().failed);
}
TEST_F(GuildManagerTests, Data) {
MakeGuild({ BOB, CAROL });
online.erase(CAROL);
guilds->GetAll(BOB);
const auto data = To<ClientPackets::GuildData>(BOB);
ASSERT_EQ(data.size(), 1u);
EXPECT_EQ(data[0].status, 0);
EXPECT_EQ(data[0].guildName, "Brick Builders");
EXPECT_EQ(data[0].foundDate, "11/14/2023");
ASSERT_EQ(data[0].members.size(), 3u);
EXPECT_EQ(data[0].members[0].playerID, ALICE);
EXPECT_EQ(data[0].members[0].rank, eGuildRank::LEADER);
EXPECT_TRUE(data[0].members[0].online);
EXPECT_EQ(data[0].members[0].zoneID, LWOZONEID(1200, 1, 0));
EXPECT_EQ(data[0].members[2].name, "Carol");
EXPECT_FALSE(data[0].members[2].online);
EXPECT_EQ(data[0].members[2].zoneID, LWOZONEID(0, 0, 0));
sent.clear();
guilds->GetAll(DAVE);
const auto none = To<ClientPackets::GuildData>(DAVE);
ASSERT_EQ(none.size(), 1u);
EXPECT_EQ(none[0].status, 1);
EXPECT_TRUE(none[0].members.empty());
}
TEST_F(GuildManagerTests, LeaveHandsTheGuildOn) {
const auto guildID = MakeGuild({ BOB, CAROL });
guilds->SetRank(ALICE, "Carol", eGuildRank::OFFICER);
sent.clear();
guilds->Leave(ALICE);
EXPECT_FALSE(db.GetGuildMember(ALICE));
// Carol outranks Bob, who joined first
EXPECT_EQ(db.GetGuildMember(CAROL)->rank, static_cast<uint8_t>(eGuildRank::LEADER));
for (const auto id : { ALICE, BOB, CAROL }) {
const auto removed = To<ClientPackets::GuildRemovePlayer>(id);
ASSERT_EQ(removed.size(), 1u) << id;
EXPECT_EQ(removed[0].reason, eGuildLeaveReason::LEFT);
EXPECT_EQ(removed[0].playerID, ALICE);
EXPECT_EQ(removed[0].newLeaderID, CAROL);
}
EXPECT_EQ(To<ChatPackets::GuildStatus>(ALICE).back().guildID, 0);
guilds->Leave(BOB);
sent.clear();
guilds->Leave(CAROL);
// The last one out: the guild is gone
EXPECT_FALSE(db.GetGuild(guildID));
EXPECT_EQ(To<ClientPackets::GuildRemovePlayer>(CAROL).back().newLeaderID, LWOOBJID_EMPTY);
EXPECT_EQ(db.events.back().kind, "disbanded");
}
TEST_F(GuildManagerTests, Kick) {
MakeGuild({ BOB, CAROL });
guilds->SetRank(ALICE, "Bob", eGuildRank::OFFICER);
notes.clear();
sent.clear();
// Officers can't kick officers or the leader, recruits nobody
guilds->Kick(CAROL, "Bob");
guilds->Kick(BOB, "Alice");
EXPECT_EQ(notes.size(), 2u);
EXPECT_TRUE(db.GetGuildMember(BOB));
guilds->Kick(BOB, "carol");
EXPECT_FALSE(db.GetGuildMember(CAROL));
const auto removed = To<ClientPackets::GuildRemovePlayer>(CAROL);
ASSERT_EQ(removed.size(), 1u);
EXPECT_EQ(removed[0].reason, eGuildLeaveReason::KICKED);
EXPECT_EQ(removed[0].playerID, CAROL);
EXPECT_EQ(To<ClientPackets::GuildRemovePlayer>(ALICE).size(), 1u);
EXPECT_EQ(db.events.back().kind, "kicked");
EXPECT_EQ(db.events.back().actor, "Bob");
}
TEST_F(GuildManagerTests, Ranks) {
MakeGuild({ BOB, CAROL });
guilds->SetRank(ALICE, "Bob", eGuildRank::OFFICER);
EXPECT_EQ(db.GetGuildMember(BOB)->rank, static_cast<uint8_t>(eGuildRank::OFFICER));
// Everyone online gets the list again (the client has no rank change packet)
EXPECT_EQ(To<ClientPackets::GuildData>(CAROL).size(), 1u);
guilds->SetRank(BOB, "Carol", eGuildRank::VETERAN);
EXPECT_EQ(db.GetGuildMember(CAROL)->rank, static_cast<uint8_t>(eGuildRank::VETERAN));
guilds->SetRank(BOB, "Carol", eGuildRank::OFFICER);
EXPECT_EQ(db.GetGuildMember(CAROL)->rank, static_cast<uint8_t>(eGuildRank::VETERAN));
// Handing the guild over
guilds->SetRank(ALICE, "Carol", eGuildRank::LEADER);
EXPECT_EQ(db.GetGuildMember(CAROL)->rank, static_cast<uint8_t>(eGuildRank::LEADER));
EXPECT_EQ(db.GetGuildMember(ALICE)->rank, static_cast<uint8_t>(eGuildRank::OFFICER));
}
TEST_F(GuildManagerTests, Disband) {
const auto guildID = MakeGuild({ BOB, CAROL });
online.erase(CAROL);
guilds->Disband(BOB);
EXPECT_TRUE(db.GetGuild(guildID));
guilds->Disband(ALICE);
EXPECT_FALSE(db.GetGuild(guildID));
EXPECT_TRUE(db.members.empty());
// Each online member is told they left, so their client drops the guild
for (const auto id : { ALICE, BOB }) {
const auto removed = To<ClientPackets::GuildRemovePlayer>(id);
ASSERT_EQ(removed.size(), 1u);
EXPECT_EQ(removed[0].playerID, id);
EXPECT_EQ(To<ChatPackets::GuildStatus>(id).back().guildID, 0);
}
EXPECT_TRUE(To<ClientPackets::GuildRemovePlayer>(CAROL).empty());
}
TEST_F(GuildManagerTests, OnlineStatus) {
MakeGuild({ BOB, CAROL });
guilds->PlayerOffline(CAROL);
online.erase(CAROL);
auto updates = To<ClientPackets::GuildLoginLogout>(ALICE);
ASSERT_EQ(updates.size(), 1u);
EXPECT_FALSE(updates[0].online);
EXPECT_EQ(updates[0].playerID, CAROL);
EXPECT_EQ(To<ClientPackets::GuildLoginLogout>(BOB).size(), 1u);
sent.clear();
online[CAROL] = { CAROL, "Carol", LWOZONEID(1100, 5, 0) };
guilds->PlayerOnline(CAROL, true);
updates = To<ClientPackets::GuildLoginLogout>(ALICE);
ASSERT_EQ(updates.size(), 1u);
EXPECT_TRUE(updates[0].online);
EXPECT_FALSE(updates[0].worldUpdateOnly);
EXPECT_EQ(updates[0].zoneID, LWOZONEID(1100, 5, 0));
EXPECT_TRUE(To<ClientPackets::GuildLoginLogout>(CAROL).empty());
sent.clear();
guilds->PlayerOnline(CAROL, false);
EXPECT_TRUE(To<ClientPackets::GuildLoginLogout>(ALICE).back().worldUpdateOnly);
EXPECT_EQ(guilds->OnlineGuildmates(CAROL).size(), 3u);
EXPECT_TRUE(guilds->OnlineGuildmates(DAVE).empty());
}
TEST_F(GuildManagerTests, OfflinePlayersLoseTheirInvite) {
MakeGuild();
guilds->Invite(ALICE, "Bob");
guilds->PlayerOffline(BOB);
EXPECT_FALSE(db.GetGuildInvite(BOB));
}
TEST_F(GuildManagerTests, LeaderlessGuildIsRepaired) {
const auto guildID = MakeGuild({ BOB, CAROL });
// Alice's character was deleted
db.members.erase(ALICE);
online.erase(ALICE);
guilds->GetAll(BOB);
EXPECT_EQ(db.GetGuildMember(BOB)->rank, static_cast<uint8_t>(eGuildRank::LEADER));
EXPECT_EQ(To<ClientPackets::GuildData>(BOB).back().members[0].rank, eGuildRank::LEADER);
EXPECT_TRUE(db.GetGuild(guildID));
}
TEST_F(GuildManagerTests, DashboardChanges) {
const auto guildID = MakeGuild({ BOB, CAROL });
// Renamed and approved
db.SetGuildName(guildID, "New Name", IGuilds::NAME_APPROVED);
guilds->GuildChanged(guildID);
for (const auto id : { ALICE, BOB, CAROL }) {
EXPECT_EQ(To<ChatPackets::GuildStatus>(id).back().guildName.GetAsString(), "New Name");
EXPECT_EQ(To<ClientPackets::GuildData>(id).back().guildName, "New Name");
}
// A member removed on the dashboard
sent.clear();
db.RemoveGuildMember(CAROL);
guilds->GuildChanged(guildID);
EXPECT_EQ(To<ClientPackets::GuildRemovePlayer>(CAROL).back().playerID, CAROL);
EXPECT_EQ(To<ChatPackets::GuildStatus>(CAROL).back().guildID, 0);
// Disbanded on the dashboard
sent.clear();
db.DeleteGuild(guildID);
guilds->GuildChanged(guildID);
for (const auto id : { ALICE, BOB }) {
EXPECT_EQ(To<ClientPackets::GuildRemovePlayer>(id).back().playerID, id);
EXPECT_EQ(To<ChatPackets::GuildStatus>(id).back().guildID, 0);
}
EXPECT_TRUE(To<ClientPackets::GuildRemovePlayer>(CAROL).empty());
}