From 431eddd5bde3e53cb40e2948cb86c5f620d12481 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 06:16:48 -0500 Subject: [PATCH] feat(dashboard): guilds page, guild name review and guild chat in the chat log A Guilds page (Moderation, permission guilds_manage, GM 5 by default): every guild with its name status, member count, leader and age, a pending-only switch, and per guild its members (with ranks) and history (guild_events). Staff can approve a guild name that waits for review, reject it (the guild becomes "Guild "), rename a guild (same name rules as the game, unique without regard to case), remove a member (a leader's guild goes to the next member, the last member's guild is deleted) and disband a guild. Every change is audited and added to the guild's history, and the chat server is asked (GUILD_CHANGED through master) to tell the online members. Pending guild names also show in the Review Queue. Guild chat ("guild" in the chat log) counts as private chat like whispers and team chat (chat_private), with its own channel filter and Prometheus counter. Check on the dashboard: /guilds lists a guild made in game; approve/reject/rename/remove/disband update the in-game guild window and name billboard of online members; the Review Queue shows a guild whose name waits for review. Co-Authored-By: Claude Opus 5.5 --- dCommon/Permissions.cpp | 3 +- dDashboardServer/DashboardServer.cpp | 2 + dDashboardServer/routes/CMakeLists.txt | 1 + dDashboardServer/routes/ChatRoutes.cpp | 4 +- dDashboardServer/routes/DashboardRoutes.cpp | 4 +- dDashboardServer/routes/GuildRoutes.cpp | 200 ++++++++++++++++++ dDashboardServer/routes/GuildRoutes.h | 10 + dDashboardServer/routes/PrometheusMetrics.cpp | 4 +- dDashboardServer/templates/chat_log.jinja2 | 2 +- dDashboardServer/templates/guilds.jinja2 | 150 +++++++++++++ dDashboardServer/templates/header.jinja2 | 9 +- dDashboardServer/templates/moderation.jinja2 | 32 +++ dDatabase/GameDatabase/ITables/IChatLog.h | 2 +- 13 files changed, 410 insertions(+), 13 deletions(-) create mode 100644 dDashboardServer/routes/GuildRoutes.cpp create mode 100644 dDashboardServer/routes/GuildRoutes.h create mode 100644 dDashboardServer/templates/guilds.jinja2 diff --git a/dCommon/Permissions.cpp b/dCommon/Permissions.cpp index e70778cb7..d994e8062 100644 --- a/dCommon/Permissions.cpp +++ b/dCommon/Permissions.cpp @@ -55,8 +55,9 @@ namespace { { "players_history", "Moderation", "Player movement history", "Replay where players went on the 3D world view (positions kept for position_history_days; every replay is audited)", 5 }, { "moderate_names", "Moderation", "Moderate names", "The moderation queue: approve and reject character names", 3 }, { "moderate_pet_names", "Moderation", "Moderate pet names", "Approve and reject pet names", 5 }, + { "guilds_manage", "Moderation", "Guilds", "See guilds, their members and history; approve, reject and change guild names, remove members and disband guilds", 5 }, { "chat_view", "Moderation", "Read chat", "The chat log: what players said in zones, and what the chat filter stopped", 3 }, - { "chat_private", "Moderation", "Read private chat", "Whispers and team chat in the chat log", 8 }, + { "chat_private", "Moderation", "Read private chat", "Whispers, team and guild chat in the chat log", 8 }, { "chat_filter_manage", "Moderation", "Chat filter", "See and change the words the chat filter allows and stops, and apply them in running worlds", 5 }, { "player_reports_view", "Moderation", "View player reports", "Read what players reported from the game's Report Abuse window", 2 }, { "player_reports_manage", "Moderation", "Handle player reports", "Act on or dismiss player reports (a strike also needs strikes_give)", 3 }, diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 18ddb736e..e76b98859 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -101,6 +101,7 @@ #include "ApiKeyService.h" #include "ApiKeyRoutes.h" #include "GrantRoutes.h" +#include "GuildRoutes.h" #include "PermissionGrantsLoader.h" #include "JWTUtils.h" #include "GeneralUtils.h" @@ -536,6 +537,7 @@ int main(int argc, char** argv) { RegisterLeaderboardRoutes(); ApiKeyRoutes::RegisterRoutes(); GrantRoutes::RegisterRoutes(); + GuildRoutes::RegisterRoutes(); RequireAuthMiddleware::SetApiAccessCheck([](const HTTPContext& context) { return Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get()); }); RequireAuthMiddleware::SetForbiddenPage([](const HTTPContext& context, HTTPReply& reply) { RouteUtils::RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to open this page."); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index e83d8a0b3..c347e04e1 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -6,6 +6,7 @@ set(DASHBOARDROUTES_SOURCES "AuthRoutes.cpp" "ApiKeyRoutes.cpp" "GrantRoutes.cpp" + "GuildRoutes.cpp" "RouteUtils.cpp" "PlayerActions.cpp" "AccountRoutes.cpp" diff --git a/dDashboardServer/routes/ChatRoutes.cpp b/dDashboardServer/routes/ChatRoutes.cpp index 83c5c94f8..59b24deb5 100644 --- a/dDashboardServer/routes/ChatRoutes.cpp +++ b/dDashboardServer/routes/ChatRoutes.cpp @@ -22,7 +22,7 @@ namespace { constexpr uint32_t MAX_PAGE = 500; uint64_t g_LastPushed = 0; - bool IsPrivate(const std::string& channel) { return channel == "whisper" || channel == "team"; } + bool IsPrivate(const std::string& channel) { return channel == "whisper" || channel == "team" || channel == "guild"; } nlohmann::json MessageJson(const IChatLog::ChatMessage& m) { const auto& zones = ZoneNames(); @@ -37,7 +37,7 @@ namespace { IChatLog::ChatQuery Query(const HTTPContext& context, const std::function& get) { IChatLog::ChatQuery q; q.channel = get("channel"); - // Whispers and team chat need their own permission + // Whispers, team and guild chat need their own permission q.includePrivate = Can(context, "chat_private"); if (IsPrivate(q.channel) && !q.includePrivate) q.channel = "none"; const auto character = get("character"); diff --git a/dDashboardServer/routes/DashboardRoutes.cpp b/dDashboardServer/routes/DashboardRoutes.cpp index cb66967a4..f3a066581 100644 --- a/dDashboardServer/routes/DashboardRoutes.cpp +++ b/dDashboardServer/routes/DashboardRoutes.cpp @@ -433,8 +433,8 @@ void RegisterDashboardRoutes() { // Moderation and tools // Holds three queues; anyone who may work one of them gets in and sees only theirs - Route(eHTTPMethod::GET, "/moderation", 1, "Moderation queue (names, pet names, properties)", [](HTTPReply& reply, const HTTPContext& context) { - if (!Can(context, "moderate_names") && !Can(context, "moderate_pet_names") && !Can(context, "moderate_properties")) { + Route(eHTTPMethod::GET, "/moderation", 1, "Moderation queue (names, pet names, guild names, properties)", [](HTTPReply& reply, const HTTPContext& context) { + if (!Can(context, "moderate_names") && !Can(context, "moderate_pet_names") && !Can(context, "guilds_manage") && !Can(context, "moderate_properties")) { return RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to review names or properties."); } RenderPage(reply, context, "moderation.jinja2", "moderation"); diff --git a/dDashboardServer/routes/GuildRoutes.cpp b/dDashboardServer/routes/GuildRoutes.cpp new file mode 100644 index 000000000..1b6d5bafa --- /dev/null +++ b/dDashboardServer/routes/GuildRoutes.cpp @@ -0,0 +1,200 @@ +#include "GuildRoutes.h" + +#include + +#include "Database.h" +#include "eGuildRank.h" +#include "eHTTPMethod.h" +#include "GeneralUtils.h" +#include "GuildNameRules.h" +#include "HTTPContext.h" +#include "master/PlayerAction.h" +#include "PlayerActions.h" +#include "RouteUtils.h" +#include "Web.h" +#include "WSRoutes.h" + +using namespace RouteUtils; + +namespace { + constexpr const char* MANAGE = "guilds_manage"; + constexpr uint32_t HISTORY_LENGTH = 200; + + int64_t Now() { return static_cast(std::time(nullptr)); } + + std::string Actor(const HTTPContext& context) { + return "[dashboard] " + context.authenticatedUser; + } + + const char* StatusName(int32_t status) { + return status == IGuilds::NAME_APPROVED ? "approved" : "pending"; + } + + nlohmann::json GuildJson(const IGuilds::Guild& guild) { + return { {"id", std::to_string(guild.id)}, {"name", guild.name}, {"name_status", StatusName(guild.nameStatus)}, + {"founder_id", std::to_string(guild.founderId)}, {"created_at", guild.createdAt} }; + } + + void AddEvent(const HTTPContext& context, int64_t guildId, const std::string& kind, LWOOBJID characterId, const std::string& characterName, const std::string& detail) { + Database::Get()->InsertGuildEvent({ 0, guildId, Now(), kind, characterId, characterName, Actor(context), detail }); + } + + // The chat server tells the guild's online members (it is the guild authority); the reply carries the request id + nlohmann::json TellChat(int64_t guildId, uint32_t requester) { + PlayerActionRequest request; + request.action = ePlayerAction::GUILD_CHANGED; + request.targetId = guildId; + const auto id = PlayerActions::Request(request, requester, [](const PlayerActionResult& result) -> PlayerActions::Outcome { + return { true, result.affected ? "The guild's online members were told." : "The chat server isn't running; members see the change when they next log in." }; + }); + return { {"requestId", id} }; + } + + std::optional RequireGuild(const HTTPContext& context, HTTPReply& reply) { + const auto guildId = PathId(context.path, 2); + if (!guildId) { + JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid guild ID"); + return std::nullopt; + } + auto guild = Database::Get()->GetGuild(*guildId); + if (!guild) JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such guild"); + return guild; + } + + // A name that is free: "Guild ", then "Guild -2" and so on + std::string PlaceholderName(int64_t guildId) { + const std::string base = "Guild " + std::to_string(guildId); + std::string name = base; + for (int i = 2; Database::Get()->GetGuildByName(name); i++) name = base + "-" + std::to_string(i); + return name; + } + + void Changed(const std::string& guildId) { + BroadcastTableChanged("guilds", guildId); + } +} + +void GuildRoutes::RegisterRoutes() { + Route(eHTTPMethod::GET, "/guilds", Perm(MANAGE), "Guilds: members, history and name moderation", [](HTTPReply& reply, const HTTPContext& context) { + RenderPage(reply, context, "guilds.jinja2", "guilds"); + }); + + ReadRoute(eHTTPMethod::POST, "/api/tables/guilds", Perm(MANAGE), "Guilds (DataTables), newest first. Body adds {pending} (only names waiting for review)", + [](HTTPReply& reply, const HTTPContext& context) { + const auto request = ParseDataTablesRequest(context.body); + const auto body = ParseBody(context); + if (!request || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); + const auto page = Database::Get()->GetGuildPage(request->start, request->length, request->search, body->value("pending", false)); + nlohmann::json data = nlohmann::json::array(); + for (const auto& summary : page.guilds) { + auto row = GuildJson(summary.guild); + row["member_count"] = summary.memberCount; + row["leader_id"] = summary.leaderId ? std::to_string(summary.leaderId) : ""; + row["leader_name"] = summary.leaderName; + data.push_back(std::move(row)); + } + JsonReply(reply, eHTTPStatusCode::OK, { {"draw", request->draw}, {"recordsTotal", page.total}, {"recordsFiltered", page.filtered}, {"data", data} }); + }); + + Route(eHTTPMethod::GET, "/api/guilds/:id", Perm(MANAGE), "A guild with its members (rank 1 leader, 2 officer, 3 veteran, 4 recruit) and its history, newest first", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + auto result = GuildJson(*guild); + if (const auto founder = Database::Get()->GetCharacterInfo(guild->founderId)) result["founder_name"] = founder->name; + nlohmann::json members = nlohmann::json::array(); + for (const auto& member : Database::Get()->GetGuildMembers(guild->id)) { + members.push_back({ {"id", std::to_string(member.characterId)}, {"name", member.name}, {"rank", member.rank}, {"joined_at", member.joinedAt} }); + } + nlohmann::json events = nlohmann::json::array(); + for (const auto& event : Database::Get()->GetGuildEvents(guild->id, HISTORY_LENGTH)) { + events.push_back({ {"id", event.id}, {"time", event.time}, {"kind", event.kind}, {"character_id", std::to_string(event.characterId)}, + {"character_name", event.characterName}, {"actor", event.actor}, {"detail", event.detail} }); + } + result["members"] = members; + result["events"] = events; + JsonSuccess(reply, { {"guild", result} }); + }); + + Route(eHTTPMethod::POST, "/api/guilds/:id/approve", Perm(MANAGE), "Approve a guild name that waits for review; other players see it from then on", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + Database::Get()->SetGuildName(guild->id, guild->name, IGuilds::NAME_APPROVED); + AddEvent(context, guild->id, "name_approved", 0, "", guild->name); + Audit(context, "approve_guild_name", "Guild " + std::to_string(guild->id) + ": " + guild->name); + Changed(std::to_string(guild->id)); + JsonSuccess(reply, TellChat(guild->id, context.accountId)); + }); + + Route(eHTTPMethod::POST, "/api/guilds/:id/reject", Perm(MANAGE), "Reject a guild name: the guild is renamed \"Guild \". Body (optional): {reason}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + const auto body = ParseBody(context).value_or(nlohmann::json::object()); + const auto reason = body.value("reason", std::string{}); + const auto name = PlaceholderName(guild->id); + Database::Get()->SetGuildName(guild->id, name, IGuilds::NAME_APPROVED); + AddEvent(context, guild->id, "name_rejected", 0, "", guild->name + (reason.empty() ? "" : " (" + reason + ")")); + Audit(context, "reject_guild_name", "Guild " + std::to_string(guild->id) + ": " + guild->name + " -> " + name + (reason.empty() ? "" : " (" + reason + ")")); + Changed(std::to_string(guild->id)); + auto result = TellChat(guild->id, context.accountId); + result["name"] = name; + JsonSuccess(reply, result); + }); + + Route(eHTTPMethod::POST, "/api/guilds/:id/rename", Perm(MANAGE), "Rename a guild (approved). Body: {name} (3-30 of letters, digits, space ' - .; unique without regard to case)", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + const auto body = ParseBody(context); + if (!body || !body->contains("name") || !(*body)["name"].is_string()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Give the new name"); + const auto name = (*body)["name"].get(); + if (!GuildNameRules::IsValid(GeneralUtils::UTF8ToUTF16(name))) { + return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "A guild name is 3 to 30 letters, digits, spaces, ' - and . (no space at either end or twice in a row)"); + } + const auto taken = Database::Get()->GetGuildByName(name); + if (taken && taken->id != guild->id) return JsonError(reply, eHTTPStatusCode::CONFLICT, "Guild " + std::to_string(taken->id) + " has that name"); + Database::Get()->SetGuildName(guild->id, name, IGuilds::NAME_APPROVED); + AddEvent(context, guild->id, "renamed", 0, "", guild->name + " -> " + name); + Audit(context, "rename_guild", "Guild " + std::to_string(guild->id) + ": " + guild->name + " -> " + name); + Changed(std::to_string(guild->id)); + JsonSuccess(reply, TellChat(guild->id, context.accountId)); + }); + + Route(eHTTPMethod::POST, "/api/guilds/:id/disband", Perm(MANAGE), "Disband a guild: every member leaves and the guild is deleted (its history stays)", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + const auto members = Database::Get()->GetGuildMembers(guild->id); + Database::Get()->DeleteGuild(guild->id); + AddEvent(context, guild->id, "disbanded", 0, "", guild->name + ", " + std::to_string(members.size()) + " member(s)"); + Audit(context, "disband_guild", "Guild " + std::to_string(guild->id) + ": " + guild->name + ", " + std::to_string(members.size()) + " member(s)"); + Changed(std::to_string(guild->id)); + JsonSuccess(reply, TellChat(guild->id, context.accountId)); + }); + + Route(eHTTPMethod::POST, "/api/guilds/:id/members/:character/remove", Perm(MANAGE), + "Remove a member. A leader's guild goes to the highest-ranked, longest-serving member; the last member's guild is deleted", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + const auto characterId = PathId(context.path, 4); + const auto member = characterId ? Database::Get()->GetGuildMember(*characterId) : std::nullopt; + if (!member || member->guildId != guild->id) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "That character isn't in this guild"); + Database::Get()->RemoveGuildMember(member->characterId); + AddEvent(context, guild->id, "kicked", member->characterId, member->name, ""); + const auto remaining = Database::Get()->GetGuildMembers(guild->id); + if (remaining.empty()) { + Database::Get()->DeleteGuild(guild->id); + AddEvent(context, guild->id, "disbanded", 0, "", "the last member was removed"); + } else if (member->rank == static_cast(eGuildRank::LEADER)) { + // Ordered by rank, then who joined first + Database::Get()->SetGuildMemberRank(remaining.front().characterId, static_cast(eGuildRank::LEADER)); + AddEvent(context, guild->id, "leader", remaining.front().characterId, remaining.front().name, "the leader was removed"); + } + Audit(context, "remove_guild_member", "Guild " + std::to_string(guild->id) + " (" + guild->name + "): removed " + member->name, AuditTarget::Character(member->characterId)); + Changed(std::to_string(guild->id)); + JsonSuccess(reply, TellChat(guild->id, context.accountId)); + }); +} diff --git a/dDashboardServer/routes/GuildRoutes.h b/dDashboardServer/routes/GuildRoutes.h new file mode 100644 index 000000000..d7659e2a4 --- /dev/null +++ b/dDashboardServer/routes/GuildRoutes.h @@ -0,0 +1,10 @@ +#pragma once + +/** + * Guilds (docs/Guilds.md): the list, each guild's members and history, and moderation (approve or reject a name that + * waits for review, rename, disband, remove a member). Needs guilds_manage. The dashboard writes the database, audits + * the change, adds it to the guild's history and asks the chat server (through master) to tell the online members. + */ +namespace GuildRoutes { + void RegisterRoutes(); +} diff --git a/dDashboardServer/routes/PrometheusMetrics.cpp b/dDashboardServer/routes/PrometheusMetrics.cpp index 6978f1a02..47a9f73cd 100644 --- a/dDashboardServer/routes/PrometheusMetrics.cpp +++ b/dDashboardServer/routes/PrometheusMetrics.cpp @@ -47,8 +47,8 @@ namespace { // Chat messages counted since the dashboard started, by channel bool g_ChatStarted{}; uint64_t g_ChatSeen{}; - std::map g_ChatMessages{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"web", 0} }; - std::map g_ChatBlocked{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"web", 0} }; + std::map g_ChatMessages{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"guild", 0}, {"web", 0} }; + std::map g_ChatBlocked{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"guild", 0}, {"web", 0} }; std::string LowerName(std::string_view name) { std::string out(name); diff --git a/dDashboardServer/templates/chat_log.jinja2 b/dDashboardServer/templates/chat_log.jinja2 index 5acd70d97..85c0b1197 100644 --- a/dDashboardServer/templates/chat_log.jinja2 +++ b/dDashboardServer/templates/chat_log.jinja2 @@ -62,7 +62,7 @@
diff --git a/dDashboardServer/templates/guilds.jinja2 b/dDashboardServer/templates/guilds.jinja2 new file mode 100644 index 000000000..031fa9ea2 --- /dev/null +++ b/dDashboardServer/templates/guilds.jinja2 @@ -0,0 +1,150 @@ +{% extends "base.jinja2" %} + +{% block title %}Guilds - DarkflameServer{% endblock %} + +{% block content %} +

Guilds

+
+

Every guild. A name the chat filter doesn't allow waits for review; other players see no guild name until it is approved. Rejecting a name renames the guild “Guild <id>”.

+
+ + +
+
+ +
+
+ + + + + + + + + + + + + +
IDNameStatusMembersLeaderCreatedActions
+
+
+ +
+
+
+
+ + +
+
+
+

+
+
+
Members
+ + + +
CharacterRankJoined
+
+
+
History
+ + + +
WhenWhatWhoByDetail
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/dDashboardServer/templates/header.jinja2 b/dDashboardServer/templates/header.jinja2 index 5c7dd2f08..b21eea79e 100644 --- a/dDashboardServer/templates/header.jinja2 +++ b/dDashboardServer/templates/header.jinja2 @@ -17,14 +17,14 @@
{% set inboxPages = ["moderation", "player_reports", "bug_reports"] %} - {% if can.moderate_names or can.moderate_pet_names or can.moderate_properties or can.player_reports_view or can.bug_reports_view %} + {% if can.moderate_names or can.moderate_pet_names or can.guilds_manage or can.moderate_properties or can.player_reports_view or can.bug_reports_view %} {% endif %} +{% if can.guilds_manage %} +
+
Pending Guild Names
+
+ + + +
IDGuild NameLeaderMembersActions
+
+
+{% endif %} + {% if can.moderate_properties %}
Pending Properties
@@ -85,6 +97,26 @@ $(function () { ], $.extend({ liveTable: 'pet_names' }, small)); } + if (DASH.can('guilds_manage')) { + serverTable('#pendingGuildsTable', '/api/tables/guilds', [ + { data: 'id', orderable: false }, + { data: 'name', orderable: false, render: function (d) { return fmt.link('/guilds', d); } }, + { data: 'leader_id', orderable: false, render: function (d, t, row) { return d ? fmt.character(d, row.leader_name) : ''; } }, + { data: 'member_count', orderable: false }, + { data: 'id', orderable: false, render: function (d, t, row) { + return '' + + ''; + } } + ], $.extend({ liveTable: 'guilds', extra: function () { return { pending: true }; } }, small)); + $('#pendingGuildsTable').on('click', '[data-guild-action]', function () { + var id = this.getAttribute('data-id'); + if (this.getAttribute('data-guild-action') === 'approve') { api.action('/api/guilds/' + id + '/approve', {}, 'Approved').catch(function () {}); return; } + var reason = prompt('Reject the guild name “' + this.getAttribute('data-name') + '”? The guild is renamed “Guild ' + id + '”. Reason (optional, kept in its history):', ''); + if (reason === null) return; + api.action('/api/guilds/' + id + '/reject', { reason: reason }, 'Rejected').catch(function () {}); + }); + } + if (DASH.can('moderate_properties')) { serverTable('#pendingPropsTable', '/api/tables/pending_properties', [ { data: 'id', orderable: false }, diff --git a/dDatabase/GameDatabase/ITables/IChatLog.h b/dDatabase/GameDatabase/ITables/IChatLog.h index e138a5b78..b11f06923 100644 --- a/dDatabase/GameDatabase/ITables/IChatLog.h +++ b/dDatabase/GameDatabase/ITables/IChatLog.h @@ -17,7 +17,7 @@ public: struct ChatMessage { uint64_t id{}; int64_t time{}; - std::string channel; // zone, whisper, team, web + std::string channel; // zone, whisper, team, guild, web LWOOBJID senderId{}; // the character; 0 for messages from the web std::string senderName; uint32_t accountId{};