mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(chat): the chat server handles guilds
Connects GuildManager to the chat server (ChatGuilds): the client's GUILD_INVITE, GUILD_INVITE_RESPONSE, GUILD_LEAVE and GUILD_GET_ALL (routed through its world), the worlds' GUILD_CREATE, GUILD_KICK, GUILD_SET_RANK and GUILD_DISBAND, guild chat (GENERAL_CHAT_MESSAGE in channel 10, sent to the online members as channel 10 private chat, which the client's guild tab shows, and logged as "guild"), guildmates told when a member logs in, out or changes worlds, and a member's pending invite dropped when they log off. Client packets go through the member's world as WorldRoutePacket; GUILD_GET_STATUS goes to the world itself. Guild names: the chat filter's deny list refuses a name (dChatFilter::HasDenyList, since without a deny list the deny check refuses everything); a name the allow list doesn't cover waits for moderation, as pet names do. The dashboard's new player action GUILD_CHANGED goes from master to the chat server only (answered 1 when chat is connected), which catches online members up. chatconfig.ini: guild_max_members (100) and guild_invite_timeout (600 seconds). Check in game: nothing yet on its own (the world side makes guilds reachable). Server log: the chat server starts and logs no unhandled guild packets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,8 @@ public:
|
||||
bool ReadWordlistDCF(const std::string& filepath, bool allowList);
|
||||
void ExportWordlistToDCF(const std::string& filepath, bool allowList);
|
||||
std::set<std::pair<uint8_t, uint8_t>> IsSentenceOkay(const std::string& message, eGameMasterLevel gmLevel, bool allowList = true);
|
||||
// Whether a deny list is loaded (without one, IsSentenceOkay(..., false) refuses every message)
|
||||
bool HasDenyList() const { return !m_DeniedWords.empty(); }
|
||||
|
||||
/**
|
||||
* Load the words staff added on the dashboard (chat_filter_words) again, replacing the ones loaded before.
|
||||
|
||||
@@ -2,6 +2,7 @@ set(DCHATSERVER_SOURCES
|
||||
"ChatIgnoreList.cpp"
|
||||
"ChatPacketHandler.cpp"
|
||||
"ChatJSONUtils.cpp"
|
||||
"ChatGuilds.cpp"
|
||||
"ChatWeb.cpp"
|
||||
"GuildManager.cpp"
|
||||
"PlayerContainer.cpp"
|
||||
|
||||
82
dChatServer/ChatGuilds.cpp
Normal file
82
dChatServer/ChatGuilds.cpp
Normal file
@@ -0,0 +1,82 @@
|
||||
#include "ChatGuilds.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
#include <memory>
|
||||
|
||||
#include "ChatPacketHandler.h"
|
||||
#include "ChatPackets.h"
|
||||
#include "Database.h"
|
||||
#include "dChatFilter.h"
|
||||
#include "dConfig.h"
|
||||
#include "dServer.h"
|
||||
#include "eGameMasterLevel.h"
|
||||
#include "Game.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "GuildManager.h"
|
||||
#include "PlayerContainer.h"
|
||||
|
||||
namespace {
|
||||
std::unique_ptr<GuildManager> g_Guilds;
|
||||
|
||||
std::string Lower(std::string text) {
|
||||
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return text;
|
||||
}
|
||||
|
||||
std::optional<GuildManager::OnlinePlayer> Online(const PlayerData& player) {
|
||||
if (!player) return std::nullopt;
|
||||
return GuildManager::OnlinePlayer{ player.playerID, player.playerName, player.zoneID };
|
||||
}
|
||||
}
|
||||
|
||||
void ChatGuilds::Initialize() {
|
||||
GuildManager::Settings settings;
|
||||
settings.maxMembers = GeneralUtils::TryParse<uint32_t>(Game::config->GetValue("guild_max_members")).value_or(settings.maxMembers);
|
||||
settings.inviteTimeout = GeneralUtils::TryParse<int64_t>(Game::config->GetValue("guild_invite_timeout")).value_or(settings.inviteTimeout);
|
||||
|
||||
GuildManager::Hooks hooks;
|
||||
hooks.findOnline = [](const LWOOBJID id) { return Online(Game::playerContainer.GetPlayerData(id)); };
|
||||
hooks.findOnlineByName = [](const std::string& name) -> std::optional<GuildManager::OnlinePlayer> {
|
||||
const auto wanted = Lower(name);
|
||||
for (const auto& [id, player] : Game::playerContainer.GetAllPlayers()) {
|
||||
if (player && Lower(player.playerName) == wanted) return Online(player);
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
hooks.characterExists = [](const std::string& name) { return Database::Get()->GetCharacterInfo(name).has_value(); };
|
||||
hooks.sendToPlayer = [](const LWOOBJID id, const LUBitStream& msg) {
|
||||
const auto& player = Game::playerContainer.GetPlayerData(id);
|
||||
if (player) ChatPacketHandler::SendRouted(id, player.worldServerSysAddr, msg);
|
||||
};
|
||||
hooks.sendToWorldOf = [](const LWOOBJID id, const LUBitStream& msg) {
|
||||
const auto& player = Game::playerContainer.GetPlayerData(id);
|
||||
if (!player) return;
|
||||
RakNet::BitStream bitStream;
|
||||
msg.WritePacket(bitStream);
|
||||
Game::server->Send(bitStream, player.worldServerSysAddr, false);
|
||||
};
|
||||
hooks.notify = [](const LWOOBJID id, const std::string& text) {
|
||||
const auto& player = Game::playerContainer.GetPlayerData(id);
|
||||
if (!player) return;
|
||||
ChatPackets::Client::GeneralChatMessage chatMessage;
|
||||
chatMessage.chatChannel = 4;
|
||||
chatMessage.senderName = LUWString("", 33);
|
||||
chatMessage.message = GeneralUtils::UTF8ToUTF16(text);
|
||||
ChatPacketHandler::SendRouted(id, player.worldServerSysAddr, chatMessage);
|
||||
};
|
||||
hooks.checkName = [](const std::string& name) {
|
||||
if (!Game::chatFilter) return GuildManager::NameCheck::PENDING;
|
||||
// The deny list refuses a name; one the allow list doesn't cover waits for a moderator (as pet names do)
|
||||
if (Game::chatFilter->HasDenyList() && !Game::chatFilter->IsSentenceOkay(name, eGameMasterLevel::CIVILIAN, false).empty()) return GuildManager::NameCheck::DENIED;
|
||||
if (!Game::chatFilter->IsSentenceOkay(name, eGameMasterLevel::CIVILIAN).empty()) return GuildManager::NameCheck::PENDING;
|
||||
return GuildManager::NameCheck::APPROVED;
|
||||
};
|
||||
hooks.now = [] { return static_cast<int64_t>(std::time(nullptr)); };
|
||||
|
||||
g_Guilds = std::make_unique<GuildManager>(*Database::Get(), std::move(hooks), settings);
|
||||
}
|
||||
|
||||
GuildManager& ChatGuilds::Get() {
|
||||
return *g_Guilds;
|
||||
}
|
||||
13
dChatServer/ChatGuilds.h
Normal file
13
dChatServer/ChatGuilds.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#ifndef CHATGUILDS_H
|
||||
#define CHATGUILDS_H
|
||||
|
||||
class GuildManager;
|
||||
|
||||
// The chat server's GuildManager, connected to its players, database, chat filter and worlds (docs/Guilds.md)
|
||||
namespace ChatGuilds {
|
||||
// Reads guild_max_members and guild_invite_timeout; call once after the database, config and chat filter are up
|
||||
void Initialize();
|
||||
GuildManager& Get();
|
||||
}
|
||||
|
||||
#endif // CHATGUILDS_H
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "ChatPacketHandler.h"
|
||||
#include "ChatGuilds.h"
|
||||
#include "GuildManager.h"
|
||||
#include "PlayerContainer.h"
|
||||
#include "Database.h"
|
||||
#include <ctime>
|
||||
@@ -470,6 +472,17 @@ void ChatPacketHandler::HandleChatMessage(const ChatPackets::GeneralChatMessage&
|
||||
}
|
||||
break;
|
||||
}
|
||||
case eChatChannel::GUILD: {
|
||||
// Sent by the world for /g, which is what the client's guild chat tab sends
|
||||
const auto members = ChatGuilds::Get().OnlineGuildmates(playerID);
|
||||
if (members.empty()) return;
|
||||
LogChat(sender, "guild", message.GetAsString());
|
||||
for (const auto memberId : members) {
|
||||
const auto& member = Game::playerContainer.GetPlayerData(memberId);
|
||||
if (member) SendPrivateChatMessage(sender, member, member, message, eChatChannel::GUILD, eChatMessageResponseCode::SENT);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
LOG("Unhandled Chat channel [%s]", StringifiedEnum::ToString(channel).data());
|
||||
break;
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
#include "ChatIgnoreList.h"
|
||||
#include "StringifiedEnum.h"
|
||||
#include "TeamContainer.h"
|
||||
#include "ChatGuilds.h"
|
||||
#include "GuildManager.h"
|
||||
#include "PacketDispatcher.h"
|
||||
#include "ChatPackets.h"
|
||||
#include "master/LiveUpdate.h"
|
||||
@@ -145,6 +147,7 @@ int main(int argc, char** argv) {
|
||||
Game::randomEngine = std::mt19937(time(0));
|
||||
|
||||
Game::playerContainer.Initialize();
|
||||
ChatGuilds::Initialize();
|
||||
|
||||
//Run it until server gets a kill message from Master:
|
||||
auto t = std::chrono::high_resolution_clock::now();
|
||||
@@ -269,6 +272,15 @@ namespace {
|
||||
announcement.WritePacket(bitStream);
|
||||
Game::server->Send(bitStream, sysAddr, true); // send to everyone except origin
|
||||
});
|
||||
// Guilds (docs/Guilds.md): the first four come from the client through its world, the rest from worlds
|
||||
handlers.On<GuildInvite>(Chat::GUILD_INVITE, [](const GuildInvite& invite, const SystemAddress&) { ChatGuilds::Get().Invite(invite.playerID, invite.invitedPlayer.GetAsString()); });
|
||||
handlers.On<GuildInviteResponse>(Chat::GUILD_INVITE_RESPONSE, [](const GuildInviteResponse& response, const SystemAddress&) { ChatGuilds::Get().AnswerInvite(response.playerID, response.declined != 0); });
|
||||
handlers.On<GuildLeave>(Chat::GUILD_LEAVE, [](const GuildLeave& leave, const SystemAddress&) { ChatGuilds::Get().Leave(leave.playerID); });
|
||||
handlers.On<GuildGetAll>(Chat::GUILD_GET_ALL, [](const GuildGetAll& request, const SystemAddress&) { ChatGuilds::Get().GetAll(request.playerID); });
|
||||
handlers.On<GuildCreate>(Chat::GUILD_CREATE, [](const GuildCreate& create, const SystemAddress&) { ChatGuilds::Get().Create(create.playerID, create.guildName.string); });
|
||||
handlers.On<GuildKick>(Chat::GUILD_KICK, [](const GuildKick& kick, const SystemAddress&) { ChatGuilds::Get().Kick(kick.playerID, kick.kickedPlayer.GetAsString()); });
|
||||
handlers.On<GuildSetRank>(Chat::GUILD_SET_RANK, [](const GuildSetRank& rank, const SystemAddress&) { ChatGuilds::Get().SetRank(rank.playerID, rank.targetPlayer.GetAsString(), static_cast<eGuildRank>(rank.rank)); });
|
||||
handlers.On<GuildDisband>(Chat::GUILD_DISBAND, [](const GuildDisband& disband, const SystemAddress&) { ChatGuilds::Get().Disband(disband.playerID); });
|
||||
handlers.On<UnexpectedDisconnect>(Chat::UNEXPECTED_DISCONNECT, [](const UnexpectedDisconnect& notify, const SystemAddress& sysAddr) { Game::playerContainer.ScheduleRemovePlayer(notify, sysAddr); });
|
||||
handlers.On<FindPlayerRequest>(Chat::WHO, ChatPacketHandler::HandleWho);
|
||||
handlers.On<ShowAllRequest>(Chat::SHOW_ALL, ChatPacketHandler::HandleShowAll);
|
||||
@@ -308,6 +320,8 @@ namespace {
|
||||
Game::chatFilter->ReloadCustomWords();
|
||||
LOG("Reloaded the chat filter's words (changed on the dashboard)");
|
||||
}
|
||||
// A guild changed on the dashboard: tell its online members
|
||||
if (request.action == ePlayerAction::GUILD_CHANGED) ChatGuilds::Get().GuildChanged(request.targetId);
|
||||
});
|
||||
return handlers;
|
||||
}();
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
#include "MessageType/Chat.h"
|
||||
#include "ChatWeb.h"
|
||||
#include "TeamContainer.h"
|
||||
#include "ChatGuilds.h"
|
||||
#include "GuildManager.h"
|
||||
|
||||
void PlayerContainer::Initialize() {
|
||||
m_MaxNumberOfBestFriends =
|
||||
@@ -52,6 +54,8 @@ void PlayerContainer::InsertPlayer(const ChatPackets::LoginSessionNotify& notify
|
||||
ChatPacketHandler::LoadFriends(data);
|
||||
m_PlayersToRemove.erase(playerId);
|
||||
LOG("Took over user: %s (%llu), zone: %i", data.playerName.c_str(), data.playerID, data.zoneID.GetMapID());
|
||||
// Guildmates' clients only get the zone again (no "logged in" line)
|
||||
ChatGuilds::Get().PlayerOnline(playerId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,6 +79,7 @@ void PlayerContainer::InsertPlayer(const ChatPackets::LoginSessionNotify& notify
|
||||
|
||||
Database::Get()->UpdateActivityLog(data.playerID, isLogin ? eActivityType::PlayerLoggedIn : eActivityType::PlayerChangedZone, data.zoneID.GetMapID());
|
||||
m_PlayersToRemove.erase(playerId);
|
||||
ChatGuilds::Get().PlayerOnline(playerId, isLogin);
|
||||
}
|
||||
|
||||
void PlayerContainer::ScheduleRemovePlayer(const ChatPackets::UnexpectedDisconnect& notify, const SystemAddress& sysAddr) {
|
||||
@@ -117,6 +122,8 @@ void PlayerContainer::RemovePlayer(const LWOOBJID playerID) {
|
||||
TeamContainer::RemoveMember(team, playerID, false, false, true);
|
||||
}
|
||||
|
||||
ChatGuilds::Get().PlayerOffline(playerID);
|
||||
|
||||
ChatWeb::SendWSPlayerUpdate(player, eActivityType::PlayerLoggedOut);
|
||||
|
||||
m_PlayerCount--;
|
||||
|
||||
@@ -269,6 +269,10 @@ namespace {
|
||||
c.Add(Int(CHAT, "max_number_of_best_friends", "Best friends", "5 is live accurate.", "5", 0, 1000, true));
|
||||
c.Add(Int(CHAT, "max_ignores", "Ignore list size", "", "32", 0, 1000));
|
||||
|
||||
c.AddSection("Guilds", "docs/Guilds.md. Players also need the client's FeatureGating row \"guilds\".");
|
||||
c.Add(Int(CHAT, "guild_max_members", "Guild members", "The client has no limit of its own.", "100", 1, 1000, true));
|
||||
c.Add(Int(CHAT, "guild_invite_timeout", "Seconds to answer a guild invite", "", "600", 10, 86400, true));
|
||||
|
||||
c.AddSection("Dashboard sign-in");
|
||||
c.Add(Int(DASHBOARD, "min_dashboard_gm_level", "Lowest GM level allowed in", "0 lets every player see their own account.", "0", 0, 9));
|
||||
c.Add(Int(DASHBOARD, "require_2fa_gm_level", "Two-factor login required from GM level", "Staff at or above this level must use an authenticator app. 0 turns it off.", "0", 0, 9));
|
||||
|
||||
@@ -305,6 +305,9 @@ uint32_t DashboardActions::Apply(const PlayerActionRequest& request) {
|
||||
return LiveEvents::Reload();
|
||||
case ePlayerAction::RELOAD_CONTRABAND:
|
||||
return Contraband::Reload();
|
||||
case ePlayerAction::GUILD_CHANGED:
|
||||
// The chat server's (master doesn't send it to worlds)
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -941,6 +941,16 @@ namespace {
|
||||
pending.action = request.action;
|
||||
pending.deadline = std::chrono::steady_clock::now() + PLAYER_ACTION_TIMEOUT;
|
||||
|
||||
// Guilds belong to the chat server: it tells the players (docs/Guilds.md)
|
||||
if (request.action == ePlayerAction::GUILD_CHANGED) {
|
||||
const bool chatConnected = chatServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS;
|
||||
if (chatConnected) MasterPackets::SendTo(chatServerMasterPeerSysAddr, request);
|
||||
pending.affected = chatConnected ? 1 : 0;
|
||||
g_PendingPlayerActions[request.requestId] = std::move(pending);
|
||||
FinishPlayerAction(request.requestId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& instance : Game::im->GetInstances()) {
|
||||
if (!instance || !instance->GetIsReady() || instance->GetIsShuttingDown()) continue;
|
||||
MasterPackets::SendTo(instance->GetSysAddr(), request);
|
||||
|
||||
@@ -36,6 +36,7 @@ enum class ePlayerAction : uint8_t {
|
||||
MISSION_ACCEPT, // as /addmission (prerequisites are skipped)
|
||||
RELOAD_LIVE_OPS, // load the running live events and open challenges again (every world, answering 1 each; see LiveEvents.h)
|
||||
RELOAD_CONTRABAND, // load the contraband list again (every world, answering 1 each; see Contraband.h)
|
||||
GUILD_CHANGED, // guild targetId changed in the database (renamed, name moderated, disbanded, member removed): only the chat server acts on it, answering 1 through master when it is connected
|
||||
};
|
||||
|
||||
struct PlayerActionRequest : public LUBitStream {
|
||||
|
||||
@@ -7,6 +7,11 @@ max_number_of_best_friends=5
|
||||
# going over 50 will be allowed in some secnarios, but proper handling will require client modding
|
||||
max_number_of_friends=50
|
||||
|
||||
# Guilds (docs/Guilds.md). The client has no limit of its own; live's is unknown.
|
||||
guild_max_members=100
|
||||
# Seconds a guild invite can be answered
|
||||
guild_invite_timeout=600
|
||||
|
||||
web_server_enabled=0
|
||||
|
||||
# Unused for now
|
||||
|
||||
Reference in New Issue
Block a user