diff --git a/dChatServer/CMakeLists.txt b/dChatServer/CMakeLists.txt index d28e02dae..83273058f 100644 --- a/dChatServer/CMakeLists.txt +++ b/dChatServer/CMakeLists.txt @@ -3,6 +3,7 @@ set(DCHATSERVER_SOURCES "ChatPacketHandler.cpp" "ChatJSONUtils.cpp" "ChatWeb.cpp" + "GuildManager.cpp" "PlayerContainer.cpp" "TeamContainer.cpp" ) diff --git a/dChatServer/GuildManager.cpp b/dChatServer/GuildManager.cpp new file mode 100644 index 000000000..28483c4d4 --- /dev/null +++ b/dChatServer/GuildManager.cpp @@ -0,0 +1,494 @@ +#include "GuildManager.h" + +#include +#include +#include + +#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(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(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(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 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(eGuildRank::LEADER)); + heir.rank = static_cast(eGuildRank::LEADER); + LogEvent(guildID, "leader", heir.characterId, heir.name, "", "the leader's character is gone"); + } + return members; +} + +std::optional GuildManager::FindMember(const std::vector& 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 GuildManager::OnlineGuildmates(const LWOOBJID playerID) { + std::vector 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(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(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(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(rank)); + if (rank == eGuildRank::LEADER) { + m_Db.SetGuildMemberRank(playerID, static_cast(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{}; + 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{}; + std::set memberIDs; + for (const auto& member : members) memberIDs.insert(member.characterId); + + // Online players who were in it and aren't any more + std::vector 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); + } +} diff --git a/dChatServer/GuildManager.h b/dChatServer/GuildManager.h new file mode 100644 index 000000000..c47d10358 --- /dev/null +++ b/dChatServer/GuildManager.h @@ -0,0 +1,116 @@ +#ifndef GUILDMANAGER_H +#define GUILDMANAGER_H + +#include +#include +#include +#include +#include +#include + +#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(LWOOBJID)> findOnline; + std::function(const std::string&)> findOnlineByName; + // Whether a character with this name exists (for "not online" versus "could not invite") + std::function characterExists; + // A client packet to an online player (routed through their world) + std::function sendToPlayer; + // A chat -> world packet to the world an online player is in + std::function sendToWorldOf; + // A system chat line to an online player + std::function notify; + std::function checkName; + std::function 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 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 Members(int64_t guildID); + std::optional FindMember(const std::vector& 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 m_OnlineGuild; +}; + +#endif // GUILDMANAGER_H diff --git a/dCommon/GuildNameRules.h b/dCommon/GuildNameRules.h new file mode 100644 index 000000000..657db6676 --- /dev/null +++ b/dCommon/GuildNameRules.h @@ -0,0 +1,27 @@ +#ifndef GUILDNAMERULES_H +#define GUILDNAMERULES_H + +#include +#include + +// 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 diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index ac305dcc6..99a3766b4 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -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) diff --git a/tests/dGameTests/GuildManagerTests.cpp b/tests/dGameTests/GuildManagerTests.cpp new file mode 100644 index 000000000..dfe172894 --- /dev/null +++ b/tests/dGameTests/GuildManagerTests.cpp @@ -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 +#include + +#include + +namespace { + // IGuilds in memory, ordered the way the SQL implementations order + class FakeGuilds : public IGuilds { + public: + std::map guilds; + std::map members; + std::map invites; + std::vector events; + std::map 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 GetGuild(int64_t guildId) override { + const auto it = guilds.find(guildId); + return it == guilds.end() ? std::nullopt : std::optional(it->second); + } + std::optional 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 GetGuildMember(LWOOBJID characterId) override { + const auto it = members.find(characterId); + return it == members.end() ? std::nullopt : std::optional(it->second); + } + std::vector GetGuildMembers(int64_t guildId) override { + std::vector 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 GetGuildInvite(LWOOBJID characterId) override { + const auto it = invites.find(characterId); + return it == invites.end() ? std::nullopt : std::optional(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 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 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 online; + std::vector sent; + std::vector> notes; + int64_t now = 1700000000; + GuildManager::NameCheck nameCheck = GuildManager::NameCheck::APPROVED; + std::unique_ptr guilds; + + void SetUp() override { + SetUpDependencies(); + const std::map 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 { + const auto it = online.find(id); + return it == online.end() ? std::nullopt : std::optional(it->second); + }; + hooks.findOnlineByName = [this](const std::string& name) -> std::optional { + 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(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 + std::vector To(LWOOBJID to) { + std::vector 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(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 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(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(eGuildRank::LEADER)); + // Alice's world puts the guild in her character component + const auto status = To(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(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(ALICE).back().result, eGuildCreateResponse::CREATED); + EXPECT_EQ(db.GetGuildByName("Unknown Words")->nameStatus, IGuilds::NAME_PENDING); + EXPECT_EQ(To(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(ALICE).back().guildName, "Unknown Words"); +} + +TEST_F(GuildManagerTests, InviteAndAccept) { + const auto guildID = MakeGuild({ BOB }); + guilds->Invite(ALICE, "carol"); + const auto initial = To(ALICE); + ASSERT_EQ(initial.size(), 1u); + EXPECT_EQ(initial[0].response, eGuildInviteResponse::SENT); + EXPECT_EQ(initial[0].playerName, "Carol"); + const auto invites = To(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(CAROL); + ASSERT_EQ(confirm.size(), 1u); + EXPECT_FALSE(confirm[0].failed); + EXPECT_EQ(db.GetGuildMember(CAROL)->rank, static_cast(eGuildRank::RECRUIT)); + EXPECT_EQ(To(CAROL).back().guildID, guildID); + // The inviter hears the answer and gets the list again; the other members get GUILD_ADD_PLAYER + EXPECT_EQ(To(ALICE).back().response, eGuildInviteFinalResponse::JOINED); + EXPECT_EQ(To(ALICE).back().members.size(), 3u); + EXPECT_TRUE(To(ALICE).empty()); + const auto added = To(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(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(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(ALICE).back().response, eGuildInviteFinalResponse::DECLINED); + EXPECT_TRUE(To(BOB).empty()); + EXPECT_FALSE(db.GetGuildMember(BOB)); + + guilds->Invite(ALICE, "Bob"); + now += 601; + sent.clear(); + guilds->AnswerInvite(BOB, false); + EXPECT_TRUE(To(BOB).back().failed); + EXPECT_FALSE(db.GetGuildMember(BOB)); + + // No invite at all + sent.clear(); + guilds->AnswerInvite(CAROL, false); + EXPECT_TRUE(To(CAROL).back().failed); +} + +TEST_F(GuildManagerTests, Data) { + MakeGuild({ BOB, CAROL }); + online.erase(CAROL); + guilds->GetAll(BOB); + const auto data = To(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(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(eGuildRank::LEADER)); + for (const auto id : { ALICE, BOB, CAROL }) { + const auto removed = To(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(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(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(CAROL); + ASSERT_EQ(removed.size(), 1u); + EXPECT_EQ(removed[0].reason, eGuildLeaveReason::KICKED); + EXPECT_EQ(removed[0].playerID, CAROL); + EXPECT_EQ(To(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(eGuildRank::OFFICER)); + // Everyone online gets the list again (the client has no rank change packet) + EXPECT_EQ(To(CAROL).size(), 1u); + guilds->SetRank(BOB, "Carol", eGuildRank::VETERAN); + EXPECT_EQ(db.GetGuildMember(CAROL)->rank, static_cast(eGuildRank::VETERAN)); + guilds->SetRank(BOB, "Carol", eGuildRank::OFFICER); + EXPECT_EQ(db.GetGuildMember(CAROL)->rank, static_cast(eGuildRank::VETERAN)); + // Handing the guild over + guilds->SetRank(ALICE, "Carol", eGuildRank::LEADER); + EXPECT_EQ(db.GetGuildMember(CAROL)->rank, static_cast(eGuildRank::LEADER)); + EXPECT_EQ(db.GetGuildMember(ALICE)->rank, static_cast(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(id); + ASSERT_EQ(removed.size(), 1u); + EXPECT_EQ(removed[0].playerID, id); + EXPECT_EQ(To(id).back().guildID, 0); + } + EXPECT_TRUE(To(CAROL).empty()); +} + +TEST_F(GuildManagerTests, OnlineStatus) { + MakeGuild({ BOB, CAROL }); + guilds->PlayerOffline(CAROL); + online.erase(CAROL); + auto updates = To(ALICE); + ASSERT_EQ(updates.size(), 1u); + EXPECT_FALSE(updates[0].online); + EXPECT_EQ(updates[0].playerID, CAROL); + EXPECT_EQ(To(BOB).size(), 1u); + + sent.clear(); + online[CAROL] = { CAROL, "Carol", LWOZONEID(1100, 5, 0) }; + guilds->PlayerOnline(CAROL, true); + updates = To(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(CAROL).empty()); + + sent.clear(); + guilds->PlayerOnline(CAROL, false); + EXPECT_TRUE(To(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(eGuildRank::LEADER)); + EXPECT_EQ(To(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(id).back().guildName.GetAsString(), "New Name"); + EXPECT_EQ(To(id).back().guildName, "New Name"); + } + // A member removed on the dashboard + sent.clear(); + db.RemoveGuildMember(CAROL); + guilds->GuildChanged(guildID); + EXPECT_EQ(To(CAROL).back().playerID, CAROL); + EXPECT_EQ(To(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(id).back().playerID, id); + EXPECT_EQ(To(id).back().guildID, 0); + } + EXPECT_TRUE(To(CAROL).empty()); +}