mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-06 21:03:44 +00:00
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:
@@ -3,6 +3,7 @@ set(DCHATSERVER_SOURCES
|
||||
"ChatPacketHandler.cpp"
|
||||
"ChatJSONUtils.cpp"
|
||||
"ChatWeb.cpp"
|
||||
"GuildManager.cpp"
|
||||
"PlayerContainer.cpp"
|
||||
"TeamContainer.cpp"
|
||||
)
|
||||
|
||||
494
dChatServer/GuildManager.cpp
Normal file
494
dChatServer/GuildManager.cpp
Normal 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
116
dChatServer/GuildManager.h
Normal 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
27
dCommon/GuildNameRules.h
Normal 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
|
||||
@@ -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)
|
||||
|
||||
536
tests/dGameTests/GuildManagerTests.cpp
Normal file
536
tests/dGameTests/GuildManagerTests.cpp
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user