feat(db): guilds, guild_members, guild_invites and guild_events

Four tables (MySQL migration 97, SQLite 80) and the IGuilds interface with MySQL, SQLite and TestSQL implementations,
for the guild system (docs/Guilds.md). A guild has a name (unique without regard to case), a name status like
pet_names (1 waiting for moderation, 2 approved), its founder and when it was made. A character is in at most one guild
(guild_members, with its rank: 1 leader, 2 officer, 3 veteran, 4 recruit, the client's names, and when it joined) and
has at most one pending invite (guild_invites). guild_events is each guild's history and outlives the guild. Deleting a
character removes its membership and the invites to and from it. Nothing uses the tables yet.

Check: both migrations run on a fresh and an existing database; DatabaseParityTests Guilds passes against MariaDB
(DLU_TEST_MYSQL_HOST) and SQLite gives the same results. Nothing to check in game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 05:40:38 -05:00
parent c99e2a719f
commit 88ebdf685e
14 changed files with 645 additions and 1 deletions

View File

@@ -55,6 +55,7 @@
#include "IApiKeys.h" #include "IApiKeys.h"
#include "IUgcLookup.h" #include "IUgcLookup.h"
#include "IPermissionGrants.h" #include "IPermissionGrants.h"
#include "IGuilds.h"
#ifdef _DEBUG #ifdef _DEBUG
# define DLU_SQL_TRY_CATCH_RETHROW(x) do { try { x; } catch (std::exception& ex) { LOG("SQL Error: %s", ex.what()); throw; } } while(0) # define DLU_SQL_TRY_CATCH_RETHROW(x) do { try { x; } catch (std::exception& ex) { LOG("SQL Error: %s", ex.what()); throw; } } while(0)
@@ -68,7 +69,7 @@ class GameDatabase :
public IPropertyContents, public IProperty, public IPetNames, public ICharXml, public IPropertyContents, public IProperty, public IPetNames, public ICharXml,
public IMigrationHistory, public IUgc, public IFriends, public ICharInfo, public IMigrationHistory, public IUgc, public IFriends, public ICharInfo,
public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList, public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList,
public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation, public IBbbAutosave, public IServerTraffic, public IApiKeys, public IUgcLookup, public IPermissionGrants { public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation, public IBbbAutosave, public IServerTraffic, public IApiKeys, public IUgcLookup, public IPermissionGrants, public IGuilds {
public: public:
virtual ~GameDatabase() = default; virtual ~GameDatabase() = default;
// TODO: These should be made private. // TODO: These should be made private.

View File

@@ -0,0 +1,100 @@
#ifndef __IGUILDS__H__
#define __IGUILDS__H__
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "dCommonVars.h"
/**
* Guilds (docs/Guilds.md). The chat server owns them: it is the only writer apart from the dashboard. A character is in
* at most one guild and has at most one pending invite. The leader is the member with rank 1; there is no leader column.
*/
class IGuilds {
public:
// guilds.name_status, the way pet_names.approved counts
enum NameStatus : int32_t {
NAME_PENDING = 1, // waits for a moderator; other players see no guild name
NAME_APPROVED = 2,
};
struct Guild {
int64_t id{};
std::string name;
int32_t nameStatus{};
LWOOBJID founderId{};
int64_t createdAt{};
};
struct Member {
LWOOBJID characterId{};
int64_t guildId{};
uint8_t rank{}; // 1 leader, 2 officer, 3 veteran, 4 recruit (the client's names)
int64_t joinedAt{};
std::string name; // charinfo.name; empty when the character is gone
};
struct Invite {
LWOOBJID characterId{}; // who is invited
int64_t guildId{};
LWOOBJID inviterId{};
int64_t createdAt{};
};
// One line of a guild's history
struct Event {
uint64_t id{};
int64_t guildId{};
int64_t time{};
std::string kind; // created, joined, left, kicked, rank, leader, renamed, name_approved, name_rejected, disbanded
LWOOBJID characterId{}; // who it was about (0: the guild itself)
std::string characterName;
std::string actor; // the character or dashboard user who did it
std::string detail;
};
struct GuildSummary {
Guild guild;
uint32_t memberCount{};
LWOOBJID leaderId{};
std::string leaderName;
};
struct GuildPage {
uint32_t total{}; // every guild (or every pending one)
uint32_t filtered{}; // matching the search
std::vector<GuildSummary> guilds;
};
// Adds a guild (guild.id is not used) and returns its id
virtual int64_t InsertGuild(const Guild& guild) = 0;
virtual std::optional<Guild> GetGuild(int64_t guildId) = 0;
// Names are unique without regard to case
virtual std::optional<Guild> GetGuildByName(const std::string& name) = 0;
virtual void SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) = 0;
// Removes the guild, its members and its invites. Its events stay.
virtual void DeleteGuild(int64_t guildId) = 0;
virtual void AddGuildMember(const Member& member) = 0;
virtual void RemoveGuildMember(LWOOBJID characterId) = 0;
virtual void SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) = 0;
virtual std::optional<Member> GetGuildMember(LWOOBJID characterId) = 0;
// Ordered by rank, then who joined first
virtual std::vector<Member> GetGuildMembers(int64_t guildId) = 0;
// Replaces the character's invite
virtual void SetGuildInvite(const Invite& invite) = 0;
virtual std::optional<Invite> GetGuildInvite(LWOOBJID characterId) = 0;
virtual void DeleteGuildInvite(LWOOBJID characterId) = 0;
virtual uint64_t InsertGuildEvent(const Event& event) = 0;
// Newest first; guildId 0: every guild's
virtual std::vector<Event> GetGuildEvents(int64_t guildId, uint32_t limit) = 0;
// Guilds for the dashboard, newest first. search matches the name, or the id when it is a number.
virtual GuildPage GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) = 0;
};
#endif //!__IGUILDS__H__

View File

@@ -113,5 +113,7 @@ void MySQLDatabase::DeleteCharacter(const LWOOBJID characterId) {
ExecuteDelete("DELETE FROM ugc_modular_build WHERE character_id=?;", characterId); ExecuteDelete("DELETE FROM ugc_modular_build WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM pet_names WHERE owner_id=?;", characterId); ExecuteDelete("DELETE FROM pet_names WHERE owner_id=?;", characterId);
ExecuteDelete("DELETE FROM player_positions WHERE character_id=?;", characterId); ExecuteDelete("DELETE FROM player_positions WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM guild_members WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM guild_invites WHERE character_id=? OR inviter_id=?;", characterId, characterId);
ExecuteDelete("DELETE FROM charinfo WHERE id=? LIMIT 1;", characterId); ExecuteDelete("DELETE FROM charinfo WHERE id=? LIMIT 1;", characterId);
} }

View File

@@ -227,6 +227,23 @@ public:
std::vector<UgcPlacement> GetUgcPlacements(const std::vector<LWOOBJID>& ugcIds) override; std::vector<UgcPlacement> GetUgcPlacements(const std::vector<LWOOBJID>& ugcIds) override;
std::vector<UgcMail> GetUgcMail(const std::vector<LWOOBJID>& subkeys, const LOT modelItemLot) override; std::vector<UgcMail> GetUgcMail(const std::vector<LWOOBJID>& subkeys, const LOT modelItemLot) override;
// IGuilds
int64_t InsertGuild(const Guild& guild) override;
std::optional<Guild> GetGuild(int64_t guildId) override;
std::optional<Guild> GetGuildByName(const std::string& name) override;
void SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) override;
void DeleteGuild(int64_t guildId) override;
void AddGuildMember(const Member& member) override;
void RemoveGuildMember(LWOOBJID characterId) override;
void SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) override;
std::optional<Member> GetGuildMember(LWOOBJID characterId) override;
std::vector<Member> GetGuildMembers(int64_t guildId) override;
void SetGuildInvite(const Invite& invite) override;
std::optional<Invite> GetGuildInvite(LWOOBJID characterId) override;
void DeleteGuildInvite(LWOOBJID characterId) override;
uint64_t InsertGuildEvent(const Event& event) override;
std::vector<Event> GetGuildEvents(int64_t guildId, uint32_t limit) override;
GuildPage GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) override;
// IPermissionGrants // IPermissionGrants
uint64_t InsertPermissionGrant(const Grant& grant) override; uint64_t InsertPermissionGrant(const Grant& grant) override;
std::optional<Grant> GetPermissionGrant(uint64_t id) override; std::optional<Grant> GetPermissionGrant(uint64_t id) override;

View File

@@ -13,6 +13,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES
"AccountStrikes.cpp" "AccountStrikes.cpp"
"ApiKeys.cpp" "ApiKeys.cpp"
"PermissionGrants.cpp" "PermissionGrants.cpp"
"Guilds.cpp"
"Moderation.cpp" "Moderation.cpp"
"ServerHealth.cpp" "ServerHealth.cpp"
"PlayerPositions.cpp" "PlayerPositions.cpp"

View File

@@ -0,0 +1,174 @@
#include "MySQLDatabase.h"
#include "GeneralUtils.h"
namespace {
template<typename Result> IGuilds::Guild ReadGuild(Result& result) {
IGuilds::Guild guild;
guild.id = result->getInt64("id");
guild.name = result->getString("name").c_str();
guild.nameStatus = result->getInt("name_status");
guild.founderId = result->getInt64("founder_id");
guild.createdAt = result->getInt64("created_at");
return guild;
}
template<typename Result> IGuilds::Member ReadMember(Result& result) {
IGuilds::Member member;
member.characterId = result->getInt64("character_id");
member.guildId = result->getInt64("guild_id");
member.rank = static_cast<uint8_t>(result->getUInt("guild_rank"));
member.joinedAt = result->getInt64("joined_at");
member.name = result->isNull("name") ? "" : result->getString("name").c_str();
return member;
}
template<typename Result> IGuilds::Event ReadEvent(Result& result) {
IGuilds::Event event;
event.id = result->getUInt64("id");
event.guildId = result->getInt64("guild_id");
event.time = result->getInt64("time");
event.kind = result->getString("kind").c_str();
event.characterId = result->getInt64("character_id");
event.characterName = result->getString("character_name").c_str();
event.actor = result->getString("actor").c_str();
event.detail = result->getString("detail").c_str();
return event;
}
constexpr const char* MEMBER_SELECT = "SELECT m.character_id, m.guild_id, m.guild_rank, m.joined_at, c.name "
"FROM guild_members m LEFT JOIN charinfo c ON c.id = m.character_id ";
// The member count and leader (lowest rank, first to join) of the guild in the outer query as g
constexpr const char* SUMMARY_SELECT = "SELECT g.*, "
"(SELECT COUNT(*) FROM guild_members m WHERE m.guild_id = g.id) AS member_count, "
"(SELECT m.character_id FROM guild_members m WHERE m.guild_id = g.id ORDER BY m.guild_rank, m.joined_at, m.character_id LIMIT 1) AS leader_id "
"FROM guilds g";
}
int64_t MySQLDatabase::InsertGuild(const Guild& guild) {
ExecuteInsert("INSERT INTO guilds (name, name_status, founder_id, created_at) VALUES (?, ?, ?, ?);",
guild.name, guild.nameStatus, guild.founderId, guild.createdAt);
auto result = ExecuteSelect("SELECT LAST_INSERT_ID() AS id;");
return result->next() ? result->getInt64("id") : 0;
}
std::optional<IGuilds::Guild> MySQLDatabase::GetGuild(int64_t guildId) {
auto result = ExecuteSelect("SELECT * FROM guilds WHERE id = ?;", guildId);
if (!result->next()) return std::nullopt;
return ReadGuild(result);
}
std::optional<IGuilds::Guild> MySQLDatabase::GetGuildByName(const std::string& name) {
auto result = ExecuteSelect("SELECT * FROM guilds WHERE LOWER(name) = LOWER(?) LIMIT 1;", name);
if (!result->next()) return std::nullopt;
return ReadGuild(result);
}
void MySQLDatabase::SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) {
ExecuteUpdate("UPDATE guilds SET name = ?, name_status = ? WHERE id = ?;", name, nameStatus, guildId);
}
void MySQLDatabase::DeleteGuild(int64_t guildId) {
ExecuteDelete("DELETE FROM guild_members WHERE guild_id = ?;", guildId);
ExecuteDelete("DELETE FROM guild_invites WHERE guild_id = ?;", guildId);
ExecuteDelete("DELETE FROM guilds WHERE id = ?;", guildId);
}
void MySQLDatabase::AddGuildMember(const Member& member) {
ExecuteInsert("INSERT INTO guild_members (character_id, guild_id, guild_rank, joined_at) VALUES (?, ?, ?, ?);",
member.characterId, member.guildId, static_cast<uint32_t>(member.rank), member.joinedAt);
}
void MySQLDatabase::RemoveGuildMember(LWOOBJID characterId) {
ExecuteDelete("DELETE FROM guild_members WHERE character_id = ?;", characterId);
}
void MySQLDatabase::SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) {
ExecuteUpdate("UPDATE guild_members SET guild_rank = ? WHERE character_id = ?;", static_cast<uint32_t>(rank), characterId);
}
std::optional<IGuilds::Member> MySQLDatabase::GetGuildMember(LWOOBJID characterId) {
auto result = ExecuteSelect(std::string(MEMBER_SELECT) + "WHERE m.character_id = ?;", characterId);
if (!result->next()) return std::nullopt;
return ReadMember(result);
}
std::vector<IGuilds::Member> MySQLDatabase::GetGuildMembers(int64_t guildId) {
std::vector<Member> members;
auto result = ExecuteSelect(std::string(MEMBER_SELECT) + "WHERE m.guild_id = ? ORDER BY m.guild_rank, m.joined_at, m.character_id;", guildId);
while (result->next()) members.push_back(ReadMember(result));
return members;
}
void MySQLDatabase::SetGuildInvite(const Invite& invite) {
ExecuteInsert("REPLACE INTO guild_invites (character_id, guild_id, inviter_id, created_at) VALUES (?, ?, ?, ?);",
invite.characterId, invite.guildId, invite.inviterId, invite.createdAt);
}
std::optional<IGuilds::Invite> MySQLDatabase::GetGuildInvite(LWOOBJID characterId) {
auto result = ExecuteSelect("SELECT * FROM guild_invites WHERE character_id = ?;", characterId);
if (!result->next()) return std::nullopt;
Invite invite;
invite.characterId = result->getInt64("character_id");
invite.guildId = result->getInt64("guild_id");
invite.inviterId = result->getInt64("inviter_id");
invite.createdAt = result->getInt64("created_at");
return invite;
}
void MySQLDatabase::DeleteGuildInvite(LWOOBJID characterId) {
ExecuteDelete("DELETE FROM guild_invites WHERE character_id = ?;", characterId);
}
uint64_t MySQLDatabase::InsertGuildEvent(const Event& event) {
ExecuteInsert("INSERT INTO guild_events (guild_id, time, kind, character_id, character_name, actor, detail) VALUES (?, ?, ?, ?, ?, ?, ?);",
event.guildId, event.time, event.kind, event.characterId, event.characterName, event.actor, event.detail);
auto result = ExecuteSelect("SELECT LAST_INSERT_ID() AS id;");
return result->next() ? result->getUInt64("id") : 0;
}
std::vector<IGuilds::Event> MySQLDatabase::GetGuildEvents(int64_t guildId, uint32_t limit) {
std::vector<Event> events;
auto result = guildId == 0
? ExecuteSelect("SELECT * FROM guild_events ORDER BY id DESC LIMIT ?;", limit)
: ExecuteSelect("SELECT * FROM guild_events WHERE guild_id = ? ORDER BY id DESC LIMIT ?;", guildId, limit);
while (result->next()) events.push_back(ReadEvent(result));
return events;
}
IGuilds::GuildPage MySQLDatabase::GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) {
GuildPage page;
const std::string pending = pendingOnly ? " WHERE g.name_status = 1" : "";
{
auto result = ExecuteSelect("SELECT COUNT(*) AS count FROM guilds g" + pending + ";");
page.total = result->next() ? result->getUInt("count") : 0;
}
// A number in the search box also matches the id (-1 never does)
const int64_t searchId = GeneralUtils::TryParse<int64_t>(search).value_or(-1);
const std::string searchFilter = "(g.name LIKE CONCAT('%', ?, '%') OR g.id = ?)";
const std::string where = search.empty() ? pending : (pendingOnly ? pending + " AND " : std::string(" WHERE ")) + searchFilter;
if (search.empty()) {
page.filtered = page.total;
} else {
auto result = ExecuteSelect("SELECT COUNT(*) AS count FROM guilds g" + where + ";", search, searchId);
page.filtered = result->next() ? result->getUInt("count") : 0;
}
const std::string query = std::string(SUMMARY_SELECT) + where + " ORDER BY g.id DESC LIMIT ?, ?;";
auto result = search.empty() ? ExecuteSelect(query, start, length) : ExecuteSelect(query, search, searchId, start, length);
while (result->next()) {
GuildSummary summary;
summary.guild = ReadGuild(result);
summary.memberCount = result->getUInt("member_count");
summary.leaderId = result->isNull("leader_id") ? 0 : result->getInt64("leader_id");
page.guilds.push_back(std::move(summary));
}
for (auto& summary : page.guilds) {
if (summary.leaderId == 0) continue;
auto name = ExecuteSelect("SELECT name FROM charinfo WHERE id = ?;", summary.leaderId);
if (name->next()) summary.leaderName = name->getString("name").c_str();
}
return page;
}

View File

@@ -77,5 +77,7 @@ void SQLiteDatabase::DeleteCharacter(const LWOOBJID characterId) {
ExecuteDelete("DELETE FROM ugc_modular_build WHERE character_id=?;", characterId); ExecuteDelete("DELETE FROM ugc_modular_build WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM pet_names WHERE owner_id=?;", characterId); ExecuteDelete("DELETE FROM pet_names WHERE owner_id=?;", characterId);
ExecuteDelete("DELETE FROM player_positions WHERE character_id=?;", characterId); ExecuteDelete("DELETE FROM player_positions WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM guild_members WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM guild_invites WHERE character_id=? OR inviter_id=?;", characterId, characterId);
ExecuteDelete("DELETE FROM charinfo WHERE id=?;", characterId); ExecuteDelete("DELETE FROM charinfo WHERE id=?;", characterId);
} }

View File

@@ -211,6 +211,23 @@ public:
std::vector<UgcPlacement> GetUgcPlacements(const std::vector<LWOOBJID>& ugcIds) override; std::vector<UgcPlacement> GetUgcPlacements(const std::vector<LWOOBJID>& ugcIds) override;
std::vector<UgcMail> GetUgcMail(const std::vector<LWOOBJID>& subkeys, const LOT modelItemLot) override; std::vector<UgcMail> GetUgcMail(const std::vector<LWOOBJID>& subkeys, const LOT modelItemLot) override;
// IGuilds
int64_t InsertGuild(const Guild& guild) override;
std::optional<Guild> GetGuild(int64_t guildId) override;
std::optional<Guild> GetGuildByName(const std::string& name) override;
void SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) override;
void DeleteGuild(int64_t guildId) override;
void AddGuildMember(const Member& member) override;
void RemoveGuildMember(LWOOBJID characterId) override;
void SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) override;
std::optional<Member> GetGuildMember(LWOOBJID characterId) override;
std::vector<Member> GetGuildMembers(int64_t guildId) override;
void SetGuildInvite(const Invite& invite) override;
std::optional<Invite> GetGuildInvite(LWOOBJID characterId) override;
void DeleteGuildInvite(LWOOBJID characterId) override;
uint64_t InsertGuildEvent(const Event& event) override;
std::vector<Event> GetGuildEvents(int64_t guildId, uint32_t limit) override;
GuildPage GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) override;
// IPermissionGrants // IPermissionGrants
uint64_t InsertPermissionGrant(const Grant& grant) override; uint64_t InsertPermissionGrant(const Grant& grant) override;
std::optional<Grant> GetPermissionGrant(uint64_t id) override; std::optional<Grant> GetPermissionGrant(uint64_t id) override;

View File

@@ -13,6 +13,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES
"AccountStrikes.cpp" "AccountStrikes.cpp"
"ApiKeys.cpp" "ApiKeys.cpp"
"PermissionGrants.cpp" "PermissionGrants.cpp"
"Guilds.cpp"
"Moderation.cpp" "Moderation.cpp"
"ServerHealth.cpp" "ServerHealth.cpp"
"PlayerPositions.cpp" "PlayerPositions.cpp"

View File

@@ -0,0 +1,174 @@
#include "SQLiteDatabase.h"
#include "GeneralUtils.h"
namespace {
IGuilds::Guild ReadGuild(CppSQLite3Query& result) {
IGuilds::Guild guild;
guild.id = result.getInt64Field("id");
guild.name = result.getStringField("name");
guild.nameStatus = result.getIntField("name_status");
guild.founderId = result.getInt64Field("founder_id");
guild.createdAt = result.getInt64Field("created_at");
return guild;
}
IGuilds::Member ReadMember(CppSQLite3Query& result) {
IGuilds::Member member;
member.characterId = result.getInt64Field("character_id");
member.guildId = result.getInt64Field("guild_id");
member.rank = static_cast<uint8_t>(result.getIntField("guild_rank"));
member.joinedAt = result.getInt64Field("joined_at");
member.name = result.getStringField("name", "");
return member;
}
IGuilds::Event ReadEvent(CppSQLite3Query& result) {
IGuilds::Event event;
event.id = static_cast<uint64_t>(result.getInt64Field("id"));
event.guildId = result.getInt64Field("guild_id");
event.time = result.getInt64Field("time");
event.kind = result.getStringField("kind");
event.characterId = result.getInt64Field("character_id");
event.characterName = result.getStringField("character_name");
event.actor = result.getStringField("actor");
event.detail = result.getStringField("detail");
return event;
}
constexpr const char* MEMBER_SELECT = "SELECT m.character_id, m.guild_id, m.guild_rank, m.joined_at, c.name "
"FROM guild_members m LEFT JOIN charinfo c ON c.id = m.character_id ";
// The member count and leader (lowest rank, first to join) of the guild in the outer query as g
constexpr const char* SUMMARY_SELECT = "SELECT g.*, "
"(SELECT COUNT(*) FROM guild_members m WHERE m.guild_id = g.id) AS member_count, "
"(SELECT m.character_id FROM guild_members m WHERE m.guild_id = g.id ORDER BY m.guild_rank, m.joined_at, m.character_id LIMIT 1) AS leader_id "
"FROM guilds g";
}
int64_t SQLiteDatabase::InsertGuild(const Guild& guild) {
ExecuteInsert("INSERT INTO guilds (name, name_status, founder_id, created_at) VALUES (?, ?, ?, ?);",
guild.name, guild.nameStatus, guild.founderId, guild.createdAt);
auto [_, result] = ExecuteSelect("SELECT last_insert_rowid() AS id;");
return result.eof() ? 0 : result.getInt64Field("id");
}
std::optional<IGuilds::Guild> SQLiteDatabase::GetGuild(int64_t guildId) {
auto [_, result] = ExecuteSelect("SELECT * FROM guilds WHERE id = ?;", guildId);
if (result.eof()) return std::nullopt;
return ReadGuild(result);
}
std::optional<IGuilds::Guild> SQLiteDatabase::GetGuildByName(const std::string& name) {
auto [_, result] = ExecuteSelect("SELECT * FROM guilds WHERE LOWER(name) = LOWER(?) LIMIT 1;", name);
if (result.eof()) return std::nullopt;
return ReadGuild(result);
}
void SQLiteDatabase::SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) {
ExecuteUpdate("UPDATE guilds SET name = ?, name_status = ? WHERE id = ?;", name, nameStatus, guildId);
}
void SQLiteDatabase::DeleteGuild(int64_t guildId) {
ExecuteDelete("DELETE FROM guild_members WHERE guild_id = ?;", guildId);
ExecuteDelete("DELETE FROM guild_invites WHERE guild_id = ?;", guildId);
ExecuteDelete("DELETE FROM guilds WHERE id = ?;", guildId);
}
void SQLiteDatabase::AddGuildMember(const Member& member) {
ExecuteInsert("INSERT INTO guild_members (character_id, guild_id, guild_rank, joined_at) VALUES (?, ?, ?, ?);",
member.characterId, member.guildId, static_cast<uint32_t>(member.rank), member.joinedAt);
}
void SQLiteDatabase::RemoveGuildMember(LWOOBJID characterId) {
ExecuteDelete("DELETE FROM guild_members WHERE character_id = ?;", characterId);
}
void SQLiteDatabase::SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) {
ExecuteUpdate("UPDATE guild_members SET guild_rank = ? WHERE character_id = ?;", static_cast<uint32_t>(rank), characterId);
}
std::optional<IGuilds::Member> SQLiteDatabase::GetGuildMember(LWOOBJID characterId) {
auto [_, result] = ExecuteSelect(std::string(MEMBER_SELECT) + "WHERE m.character_id = ?;", characterId);
if (result.eof()) return std::nullopt;
return ReadMember(result);
}
std::vector<IGuilds::Member> SQLiteDatabase::GetGuildMembers(int64_t guildId) {
std::vector<Member> members;
auto [_, result] = ExecuteSelect(std::string(MEMBER_SELECT) + "WHERE m.guild_id = ? ORDER BY m.guild_rank, m.joined_at, m.character_id;", guildId);
for (; !result.eof(); result.nextRow()) members.push_back(ReadMember(result));
return members;
}
void SQLiteDatabase::SetGuildInvite(const Invite& invite) {
ExecuteInsert("REPLACE INTO guild_invites (character_id, guild_id, inviter_id, created_at) VALUES (?, ?, ?, ?);",
invite.characterId, invite.guildId, invite.inviterId, invite.createdAt);
}
std::optional<IGuilds::Invite> SQLiteDatabase::GetGuildInvite(LWOOBJID characterId) {
auto [_, result] = ExecuteSelect("SELECT * FROM guild_invites WHERE character_id = ?;", characterId);
if (result.eof()) return std::nullopt;
Invite invite;
invite.characterId = result.getInt64Field("character_id");
invite.guildId = result.getInt64Field("guild_id");
invite.inviterId = result.getInt64Field("inviter_id");
invite.createdAt = result.getInt64Field("created_at");
return invite;
}
void SQLiteDatabase::DeleteGuildInvite(LWOOBJID characterId) {
ExecuteDelete("DELETE FROM guild_invites WHERE character_id = ?;", characterId);
}
uint64_t SQLiteDatabase::InsertGuildEvent(const Event& event) {
ExecuteInsert("INSERT INTO guild_events (guild_id, time, kind, character_id, character_name, actor, detail) VALUES (?, ?, ?, ?, ?, ?, ?);",
event.guildId, event.time, event.kind, event.characterId, event.characterName, event.actor, event.detail);
auto [_, result] = ExecuteSelect("SELECT last_insert_rowid() AS id;");
return result.eof() ? 0 : static_cast<uint64_t>(result.getInt64Field("id"));
}
std::vector<IGuilds::Event> SQLiteDatabase::GetGuildEvents(int64_t guildId, uint32_t limit) {
std::vector<Event> events;
auto [_, result] = guildId == 0
? ExecuteSelect("SELECT * FROM guild_events ORDER BY id DESC LIMIT ?;", limit)
: ExecuteSelect("SELECT * FROM guild_events WHERE guild_id = ? ORDER BY id DESC LIMIT ?;", guildId, limit);
for (; !result.eof(); result.nextRow()) events.push_back(ReadEvent(result));
return events;
}
IGuilds::GuildPage SQLiteDatabase::GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) {
GuildPage page;
const std::string pending = pendingOnly ? " WHERE g.name_status = 1" : "";
{
auto [_, result] = ExecuteSelect("SELECT COUNT(*) AS count FROM guilds g" + pending + ";");
page.total = result.eof() ? 0 : static_cast<uint32_t>(result.getIntField("count"));
}
// A number in the search box also matches the id (-1 never does)
const int64_t searchId = GeneralUtils::TryParse<int64_t>(search).value_or(-1);
const std::string searchFilter = "(g.name LIKE '%' || ? || '%' OR g.id = ?)";
const std::string where = search.empty() ? pending : (pendingOnly ? pending + " AND " : std::string(" WHERE ")) + searchFilter;
if (search.empty()) {
page.filtered = page.total;
} else {
auto [_, result] = ExecuteSelect("SELECT COUNT(*) AS count FROM guilds g" + where + ";", search, searchId);
page.filtered = result.eof() ? 0 : static_cast<uint32_t>(result.getIntField("count"));
}
const std::string query = std::string(SUMMARY_SELECT) + where + " ORDER BY g.id DESC LIMIT ? OFFSET ?;";
auto [_, result] = search.empty() ? ExecuteSelect(query, length, start) : ExecuteSelect(query, search, searchId, length, start);
for (; !result.eof(); result.nextRow()) {
GuildSummary summary;
summary.guild = ReadGuild(result);
summary.memberCount = static_cast<uint32_t>(result.getIntField("member_count"));
summary.leaderId = result.getInt64Field("leader_id", 0);
page.guilds.push_back(std::move(summary));
}
for (auto& summary : page.guilds) {
if (summary.leaderId == 0) continue;
auto [__, name] = ExecuteSelect("SELECT name FROM charinfo WHERE id = ?;", summary.leaderId);
if (!name.eof()) summary.leaderName = name.getStringField("name");
}
return page;
}

View File

@@ -214,6 +214,22 @@ class TestSQLDatabase : public GameDatabase {
std::vector<AccountNote> GetAccountNotes(uint32_t accountId) override { return {}; } std::vector<AccountNote> GetAccountNotes(uint32_t accountId) override { return {}; }
std::optional<AccountNote> GetAccountNote(uint64_t id) override { return {}; } std::optional<AccountNote> GetAccountNote(uint64_t id) override { return {}; }
void DeleteAccountNote(uint64_t id) override {} void DeleteAccountNote(uint64_t id) override {}
int64_t InsertGuild(const Guild& guild) override { return 0; }
std::optional<Guild> GetGuild(int64_t guildId) override { return {}; }
std::optional<Guild> GetGuildByName(const std::string& name) override { return {}; }
void SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) override {}
void DeleteGuild(int64_t guildId) override {}
void AddGuildMember(const Member& member) override {}
void RemoveGuildMember(LWOOBJID characterId) override {}
void SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) override {}
std::optional<Member> GetGuildMember(LWOOBJID characterId) override { return {}; }
std::vector<Member> GetGuildMembers(int64_t guildId) override { return {}; }
void SetGuildInvite(const Invite& invite) override {}
std::optional<Invite> GetGuildInvite(LWOOBJID characterId) override { return {}; }
void DeleteGuildInvite(LWOOBJID characterId) override {}
uint64_t InsertGuildEvent(const Event& event) override { return 0; }
std::vector<Event> GetGuildEvents(int64_t guildId, uint32_t limit) override { return {}; }
GuildPage GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) override { return {}; }
uint64_t InsertPermissionGrant(const Grant& grant) override { return 0; } uint64_t InsertPermissionGrant(const Grant& grant) override { return 0; }
std::optional<Grant> GetPermissionGrant(uint64_t id) override { return {}; } std::optional<Grant> GetPermissionGrant(uint64_t id) override { return {}; }
std::vector<Grant> GetPermissionGrants(const std::string& targetType, int64_t targetId) override { return {}; } std::vector<Grant> GetPermissionGrants(const std::string& targetType, int64_t targetId) override { return {}; }

View File

@@ -0,0 +1,40 @@
/* Guilds (docs/Guilds.md), owned by the chat server. The leader is the member with guild_rank 1 (1 leader, 2 officer,
3 veteran, 4 recruit). name_status: 1 = the name waits for moderation, 2 = approved (as pet_names.approved).
A character is in at most one guild (guild_members) and has at most one pending invite (guild_invites).
guild_events is each guild's history; it is kept when a guild is disbanded. */
CREATE TABLE IF NOT EXISTS guilds (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
name_status INT NOT NULL DEFAULT 1,
founder_id BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
UNIQUE INDEX guilds_name (name)
);
CREATE TABLE IF NOT EXISTS guild_members (
character_id BIGINT NOT NULL PRIMARY KEY,
guild_id BIGINT NOT NULL,
guild_rank TINYINT UNSIGNED NOT NULL,
joined_at BIGINT NOT NULL,
INDEX guild_members_guild (guild_id)
);
CREATE TABLE IF NOT EXISTS guild_invites (
character_id BIGINT NOT NULL PRIMARY KEY,
guild_id BIGINT NOT NULL,
inviter_id BIGINT NOT NULL,
created_at BIGINT NOT NULL,
INDEX guild_invites_guild (guild_id)
);
CREATE TABLE IF NOT EXISTS guild_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
guild_id BIGINT NOT NULL,
time BIGINT NOT NULL,
kind VARCHAR(24) NOT NULL,
character_id BIGINT NOT NULL DEFAULT 0,
character_name VARCHAR(35) NOT NULL DEFAULT '',
actor VARCHAR(64) NOT NULL DEFAULT '',
detail VARCHAR(255) NOT NULL DEFAULT '',
INDEX guild_events_guild (guild_id, id)
);

View File

@@ -0,0 +1,36 @@
/* Guilds, owned by the chat server. See the MySQL migration. */
CREATE TABLE IF NOT EXISTS guilds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE UNIQUE,
name_status INTEGER NOT NULL DEFAULT 1,
founder_id BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS guild_members (
character_id BIGINT NOT NULL PRIMARY KEY,
guild_id BIGINT NOT NULL,
guild_rank INTEGER NOT NULL,
joined_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS guild_members_guild ON guild_members (guild_id);
CREATE TABLE IF NOT EXISTS guild_invites (
character_id BIGINT NOT NULL PRIMARY KEY,
guild_id BIGINT NOT NULL,
inviter_id BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS guild_invites_guild ON guild_invites (guild_id);
CREATE TABLE IF NOT EXISTS guild_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id BIGINT NOT NULL,
time BIGINT NOT NULL,
kind TEXT NOT NULL,
character_id BIGINT NOT NULL DEFAULT 0,
character_name TEXT NOT NULL DEFAULT '',
actor TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS guild_events_guild ON guild_events (guild_id, id);

View File

@@ -65,6 +65,12 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountEmails::AccountToken, accountId, data
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountNotes::AccountNote, id, accountId, kind, text, actor, createdAt); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountNotes::AccountNote, id, accountId, kind, text, actor, createdAt);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountStrikes::Strike, id, accountId, characterId, source, subject, reason, givenById, givenBy, createdAt, revokedAt, revokedBy, revokeReason); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountStrikes::Strike, id, accountId, characterId, source, subject, reason, givenById, givenBy, createdAt, revokedAt, revokedBy, revokeReason);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IPermissionGrants::Grant, id, targetType, targetId, kind, name, deny, expiresAt, note, grantedAt, grantedById, grantedBy, revokedAt, revokedBy); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IPermissionGrants::Grant, id, targetType, targetId, kind, name, deny, expiresAt, note, grantedAt, grantedById, grantedBy, revokedAt, revokedBy);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::Guild, id, name, nameStatus, founderId, createdAt);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::Member, characterId, guildId, rank, joinedAt, name);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::Invite, characterId, guildId, inviterId, createdAt);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::Event, id, guildId, time, kind, characterId, characterName, actor, detail);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::GuildSummary, guild, memberCount, leaderId, leaderName);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::GuildPage, total, filtered, guilds);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IApiKeys::ApiKey, id, accountId, name, note, keyHash, keyPrefix, permissions, readOnly, allowedIps, allowedPaths, rateLimit, dailyQuota, createdAt, createdBy, issuedAt, expiresAt, revokedAt, revokedBy, lastUsedAt, lastIp, requestCount, quotaDay, dayCount); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IApiKeys::ApiKey, id, accountId, name, note, keyHash, keyPrefix, permissions, readOnly, allowedIps, allowedPaths, rateLimit, dailyQuota, createdAt, createdBy, issuedAt, expiresAt, revokedAt, revokedBy, lastUsedAt, lastIp, requestCount, quotaDay, dayCount);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ICharacterSnapshots::CharacterSnapshot, id, characterId, takenAt, reason, actor, size, hash, compressed); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ICharacterSnapshots::CharacterSnapshot, id, characterId, takenAt, reason, actor, size, hash, compressed);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IChatLog::ChatMessage, id, time, channel, senderId, senderName, accountId, recipientId, recipientName, zoneId, instanceId, cloneId, message, blocked); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IChatLog::ChatMessage, id, time, channel, senderId, senderName, accountId, recipientId, recipientName, zoneId, instanceId, cloneId, message, blocked);
@@ -1289,6 +1295,63 @@ TEST_F(ParitySeeded, PermissionGrants) {
}); });
} }
TEST_F(ParitySeeded, Guilds) {
constexpr LWOOBJID GONE = 1152921510000900001LL; // no charinfo row
Both("InsertGuild", [](GameDatabase& db) {
return json{ db.InsertGuild({ 0, "Brick Builders", IGuilds::NAME_APPROVED, CHAR_ALICE, 1700000000 }),
db.InsertGuild({ 0, "Maelstrom Hunters", IGuilds::NAME_PENDING, CHAR_BOB, 1700000100 }),
db.InsertGuild({ 0, "Empty", IGuilds::NAME_APPROVED, CHAR_GM, 1700000200 }) };
});
Both("GetGuild", [](GameDatabase& db) { return json{ db.GetGuild(1), db.GetGuild(2), db.GetGuild(99) }; });
Both("GetGuildByName", [](GameDatabase& db) {
return json{ db.GetGuildByName("Brick Builders"), db.GetGuildByName("brick BUILDERS"), db.GetGuildByName("Brick"), db.GetGuildByName("") };
});
Both("AddGuildMember", [&](GameDatabase& db) {
db.AddGuildMember({ CHAR_ALICE, 1, 1, 1700000000 });
db.AddGuildMember({ CHAR_GM, 1, 4, 1700000300 });
db.AddGuildMember({ GONE, 1, 4, 1700000250 });
db.AddGuildMember({ CHAR_BOB, 2, 1, 1700000100 });
});
Both("GetGuildMember", [&](GameDatabase& db) { return json{ db.GetGuildMember(CHAR_ALICE), db.GetGuildMember(GONE), db.GetGuildMember(CHAR_ALICE2) }; });
Both("GetGuildMembers", [](GameDatabase& db) { return json{ db.GetGuildMembers(1), db.GetGuildMembers(2), db.GetGuildMembers(3) }; });
Both("SetGuildMemberRank", [&](GameDatabase& db) { db.SetGuildMemberRank(CHAR_GM, 2); return db.GetGuildMembers(1); });
Both("GetGuildPage", [](GameDatabase& db) {
return json{ db.GetGuildPage(0, 10, "", false), db.GetGuildPage(0, 1, "", false), db.GetGuildPage(1, 10, "", false),
db.GetGuildPage(0, 10, "", true), db.GetGuildPage(0, 10, "hunt", false), db.GetGuildPage(0, 10, "1", false),
db.GetGuildPage(0, 10, "brick", true) };
});
Both("SetGuildName", [](GameDatabase& db) {
db.SetGuildName(2, "Hunters", IGuilds::NAME_APPROVED);
return json{ db.GetGuild(2), db.GetGuildByName("Maelstrom Hunters"), db.GetGuildPage(0, 10, "", true) };
});
Both("SetGuildInvite", [&](GameDatabase& db) {
db.SetGuildInvite({ CHAR_ALICE2, 1, CHAR_ALICE, 1700000400 });
const auto first = db.GetGuildInvite(CHAR_ALICE2);
db.SetGuildInvite({ CHAR_ALICE2, 2, CHAR_BOB, 1700000500 });
return json{ first, db.GetGuildInvite(CHAR_ALICE2), db.GetGuildInvite(CHAR_BOB) };
});
Both("DeleteGuildInvite", [&](GameDatabase& db) { db.DeleteGuildInvite(CHAR_ALICE2); return db.GetGuildInvite(CHAR_ALICE2); });
Both("RemoveGuildMember", [&](GameDatabase& db) { db.RemoveGuildMember(GONE); return json{ db.GetGuildMember(GONE), db.GetGuildMembers(1) }; });
Both("InsertGuildEvent", [&](GameDatabase& db) {
json ids = json::array();
ids.push_back(db.InsertGuildEvent({ 0, 1, 1700000000, "created", CHAR_ALICE, "Alice", "Alice", "Brick Builders" }));
ids.push_back(db.InsertGuildEvent({ 0, 2, 1700000100, "created", CHAR_BOB, "Bob", "Bob", "" }));
ids.push_back(db.InsertGuildEvent({ 0, 1, 1700000300, "joined", CHAR_GM, "GameMaster", "Alice", "" }));
return ids;
});
Both("GetGuildEvents", [](GameDatabase& db) { return json{ db.GetGuildEvents(1, 10), db.GetGuildEvents(0, 2), db.GetGuildEvents(3, 10) }; });
Both("DeleteGuild", [&](GameDatabase& db) {
db.SetGuildInvite({ CHAR_ALICE2, 1, CHAR_ALICE, 1700000600 });
db.DeleteGuild(1);
return json{ db.GetGuild(1), db.GetGuildMembers(1), db.GetGuildMember(CHAR_ALICE), db.GetGuildInvite(CHAR_ALICE2), db.GetGuildEvents(1, 10), db.GetGuildPage(0, 10, "", false) };
});
Both("Guild name unique", [](GameDatabase& db) {
bool threw = false;
try { db.InsertGuild({ 0, "hunters", IGuilds::NAME_APPROVED, CHAR_ALICE, 1700000700 }); } catch (...) { threw = true; }
return threw;
});
}
TEST_F(ParitySeeded, ApiKeys) { TEST_F(ParitySeeded, ApiKeys) {
Both("InsertApiKey", [](GameDatabase& db) { Both("InsertApiKey", [](GameDatabase& db) {
json ids = json::array(); json ids = json::array();