From f6f937955ed5ddd760037a30822089d312531592 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 06:07:19 -0500 Subject: [PATCH] feat(world): guilds in the world: create box, character component, guild commands The world side of guilds (docs/Guilds.md): - TMP_GUILD_CREATE, which the client's guild create box sends to its world, goes to the chat server as GUILD_CREATE. - The character component reads the character's guild from the database when it loads (instead of the old unused "gn" and "gid" charxml attributes) and takes GUILD_GET_STATUS from the chat server; a change is serialized once (the client redraws the name billboard every time it reads a guild name). A name waiting for moderation isn't shown. - Slash commands (the client has none for guilds): /g and /guild (guild chat; the client's guild tab sends /g, which goes through the chat filter and mute like zone chat, then to the chat server as channel 10), /guildcreate (opens the create box with DisplayGuildCreateBox), /gkick, /grank , /gleader and /gdisband confirm. - The Guild Master script (LOT 3001, L_GUILD_CREATE.lua: using it opens the create box). Live never placed it; GMs can spawn it. Check in game (two accounts, a client with a FeatureGating row "guilds", 1, 0, 0 in its cdclient.fdb): /guildcreate, make a guild (the name shows under yours, the guild button appears on the status bar); invite the second character from the guild window, accept, both lists show both; guild chat tab; /grank, /gkick, /gleader; leave from the window; log the second character out and in (guildmate logged off/in lines). Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/CharacterComponent.cpp | 37 +++--- dGame/dComponents/CharacterComponent.h | 11 ++ dGame/dUtilities/SlashCommandHandler.cpp | 56 ++++++++ dGame/dUtilities/SlashCommands/CMakeLists.txt | 1 + .../SlashCommands/GuildCommands.cpp | 120 ++++++++++++++++++ .../dUtilities/SlashCommands/GuildCommands.h | 23 ++++ dScripts/CppScripts.cpp | 2 + dScripts/ai/FV/CMakeLists.txt | 1 + dScripts/ai/FV/FvGuildCreate.cpp | 11 ++ dScripts/ai/FV/FvGuildCreate.h | 9 ++ dWorldServer/WorldServer.cpp | 23 ++++ .../ReplicaConstructionTests.cpp | 40 ++++++ 12 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 dGame/dUtilities/SlashCommands/GuildCommands.cpp create mode 100644 dGame/dUtilities/SlashCommands/GuildCommands.h create mode 100644 dScripts/ai/FV/FvGuildCreate.cpp create mode 100644 dScripts/ai/FV/FvGuildCreate.h diff --git a/dGame/dComponents/CharacterComponent.cpp b/dGame/dComponents/CharacterComponent.cpp index 363a115e4..203652ca5 100644 --- a/dGame/dComponents/CharacterComponent.cpp +++ b/dGame/dComponents/CharacterComponent.cpp @@ -91,8 +91,8 @@ bool CharacterComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo cmptType.PushDebug("Has PVP flag turned on") = m_PvpEnabled; cmptType.PushDebug("GM Level") = GeneralUtils::ToUnderlying(m_GMLevel); cmptType.PushDebug("Editor level") = GeneralUtils::ToUnderlying(m_EditorLevel); - cmptType.PushDebug("Guild ID") = "0"; - cmptType.PushDebug("Guild Name") = ""; + cmptType.PushDebug("Guild ID") = std::to_string(m_GuildID); + cmptType.PushDebug("Guild Name") = GeneralUtils::UTF16ToWTF8(m_GuildName); cmptType.PushDebug("Reputation") = m_Reputation; cmptType.PushDebug("Current Activity Type") = GeneralUtils::ToUnderlying(m_CurrentActivity); cmptType.PushDebug("Property Clone ID") = m_Character->GetPropertyCloneID(); @@ -253,9 +253,28 @@ void CharacterComponent::Serialize(RakNet::BitStream& outBitStream, bool bIsInit outBitStream.Write(m_IsLEGOClubMember); outBitStream.Write(m_CountryCode); + // Written once per change: the client redraws the name billboard each time it reads a guild name + if (!bIsInitialUpdate) m_DirtySocialInfo = false; } } +void CharacterComponent::LoadGuild() { + // The chat server owns guilds (docs/Guilds.md); the character's is read when it loads and GUILD_GET_STATUS brings + // changes. A name waiting for moderation is not shown to other players. + const auto characterID = m_Character ? m_Character->GetObjectID() : m_Parent->GetObjectID(); + const auto member = Database::Get()->GetGuildMember(characterID); + const auto guild = member ? Database::Get()->GetGuild(member->guildId) : std::nullopt; + if (!guild) return; + SetGuild(guild->id, guild->nameStatus == IGuilds::NAME_APPROVED ? GeneralUtils::UTF8ToUTF16(guild->name) : u""); +} + +void CharacterComponent::SetGuild(const LWOOBJID guildID, const std::u16string& guildName) { + if (m_GuildID == guildID && m_GuildName == guildName) return; + m_GuildID = guildID; + m_GuildName = guildName; + m_DirtySocialInfo = true; +} + bool CharacterComponent::GetPvpEnabled() const { return m_PvpEnabled; } @@ -359,19 +378,7 @@ void CharacterComponent::LoadFromXml(const tinyxml2::XMLDocument& doc) { m_EditorEnabled = false; //We're not currently in HF if we're loading in } - //Annoying guild bs: - const tinyxml2::XMLAttribute* guildName = character->FindAttribute("gn"); - if (guildName) { - const char* gn = guildName->Value(); - int64_t gid = 0; - character->QueryInt64Attribute("gid", &gid); - if (gid != 0) { - std::string guildname(gn); - m_GuildName = GeneralUtils::UTF8ToUTF16(guildname); - m_GuildID = gid; - m_DirtySocialInfo = true; - } - } + LoadGuild(); if (character->FindAttribute("time")) { character->QueryUnsigned64Attribute("time", &m_TotalTimePlayed); diff --git a/dGame/dComponents/CharacterComponent.h b/dGame/dComponents/CharacterComponent.h index 86a7613f7..b91ba024f 100644 --- a/dGame/dComponents/CharacterComponent.h +++ b/dGame/dComponents/CharacterComponent.h @@ -129,6 +129,14 @@ public: */ bool GetPvpEnabled() const; + /** + * The character's guild as other players see it (docs/Guilds.md): guildName is empty while the name waits for + * moderation, and guildID 0 is no guild. Serializes again when it changed. + */ + void SetGuild(LWOOBJID guildID, const std::u16string& guildName); + LWOOBJID GetGuildID() const { return m_GuildID; } + const std::u16string& GetGuildName() const { return m_GuildName; } + /** * Returns the characters lifetime reputation * @return The lifetime reputation of this character. @@ -449,6 +457,9 @@ private: */ bool m_DirtySocialInfo = false; + // Reads the character's guild from the database (on load) + void LoadGuild(); + /** * The guild this character is in */ diff --git a/dGame/dUtilities/SlashCommandHandler.cpp b/dGame/dUtilities/SlashCommandHandler.cpp index 1d261f567..39e8ea255 100644 --- a/dGame/dUtilities/SlashCommandHandler.cpp +++ b/dGame/dUtilities/SlashCommandHandler.cpp @@ -19,6 +19,7 @@ #include "DEVGMCommands.h" #include "GMGreaterThanZeroCommands.h" #include "GMZeroCommands.h" +#include "GuildCommands.h" #include "LiveEvents.h" #include "Amf3.h" @@ -1328,6 +1329,61 @@ void SlashCommandHandler::Startup() { }; RegisterCommand(ServerUptimeCommand); + // Guilds (docs/Guilds.md): the client has no guild commands; its guild chat tab sends /g + Command guildChatCommand{ + .help = "Send a message to your guild.", + .info = "Send a message to your guild. The guild chat tab sends this.", + .aliases = { "g", "guild" }, + .handle = GuildCommands::Chat, + .requiredLevel = eGameMasterLevel::CIVILIAN + }; + RegisterCommand(guildChatCommand); + + Command guildCreateCommand{ + .help = "Open the window to create a guild.", + .info = "Open the window to create a guild (live opened it from the Guild Master NPC).", + .aliases = { "guildcreate", "createguild" }, + .handle = GuildCommands::OpenCreateBox, + .requiredLevel = eGameMasterLevel::CIVILIAN + }; + RegisterCommand(guildCreateCommand); + + Command guildKickCommand{ + .help = "Remove a player from your guild.", + .info = "Remove a player from your guild: the leader can remove anyone, officers veterans and recruits.", + .aliases = { "gkick", "guildkick" }, + .handle = GuildCommands::Kick, + .requiredLevel = eGameMasterLevel::CIVILIAN + }; + RegisterCommand(guildKickCommand); + + Command guildRankCommand{ + .help = "Set a guild member's rank: /grank ", + .info = "Set a guild member's rank. The leader sets any rank; officers move members between veteran and recruit.", + .aliases = { "grank", "guildrank" }, + .handle = GuildCommands::Rank, + .requiredLevel = eGameMasterLevel::CIVILIAN + }; + RegisterCommand(guildRankCommand); + + Command guildLeaderCommand{ + .help = "Hand your guild over to another member: /gleader ", + .info = "Make another member the guild's leader; you become an officer.", + .aliases = { "gleader", "guildleader" }, + .handle = GuildCommands::Leader, + .requiredLevel = eGameMasterLevel::CIVILIAN + }; + RegisterCommand(guildLeaderCommand); + + Command guildDisbandCommand{ + .help = "Disband your guild: /gdisband confirm", + .info = "The leader removes every member and the guild itself.", + .aliases = { "gdisband", "guilddisband" }, + .handle = GuildCommands::Disband, + .requiredLevel = eGameMasterLevel::CIVILIAN + }; + RegisterCommand(guildDisbandCommand); + //Commands that are handled by the client Command faqCommand{ diff --git a/dGame/dUtilities/SlashCommands/CMakeLists.txt b/dGame/dUtilities/SlashCommands/CMakeLists.txt index 999cd0ec0..b44c455f9 100644 --- a/dGame/dUtilities/SlashCommands/CMakeLists.txt +++ b/dGame/dUtilities/SlashCommands/CMakeLists.txt @@ -2,5 +2,6 @@ set(DGAME_DUTILITIES_SLASHCOMMANDS "DEVGMCommands.cpp" "GMGreaterThanZeroCommands.cpp" "GMZeroCommands.cpp" + "GuildCommands.cpp" PARENT_SCOPE ) diff --git a/dGame/dUtilities/SlashCommands/GuildCommands.cpp b/dGame/dUtilities/SlashCommands/GuildCommands.cpp new file mode 100644 index 000000000..ea71ea4de --- /dev/null +++ b/dGame/dUtilities/SlashCommands/GuildCommands.cpp @@ -0,0 +1,120 @@ +#include "GuildCommands.h" + +#include + +#include "Character.h" +#include "ChatPackets.h" +#include "ChatServerLink.h" +#include "dChatFilter.h" +#include "Entity.h" +#include "eChatChannel.h" +#include "eGameMasterLevel.h" +#include "eGuildRank.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "PlayerMessages.h" +#include "User.h" + +namespace { + std::string Lower(std::string text) { + for (auto& c : text) c = static_cast(std::tolower(static_cast(c))); + return text; + } + + std::string Trimmed(const std::string& text) { + const auto start = text.find_first_not_of(' '); + if (start == std::string::npos) return ""; + return text.substr(start, text.find_last_not_of(' ') - start + 1); + } + + LUWString PlayerName(const std::string& name) { + return LUWString(GeneralUtils::UTF8ToUTF16(name), 33); + } + + void SendRank(Entity* entity, const std::string& name, const eGuildRank rank) { + ChatPackets::GuildSetRank request; + request.playerID = entity->GetObjectID(); + request.targetPlayer = PlayerName(name); + request.rank = static_cast(rank); + ChatServerLink::Send(request); + } +} + +void GuildCommands::Chat(Entity* entity, const SystemAddress& sysAddr, const std::string args) { + const auto message = Trimmed(args); + if (message.empty()) return; + auto* character = entity->GetCharacter(); + if (!character) return; + auto* user = character->GetParentUser(); + if (user && user->GetIsMuted()) { + character->SendMuteNotice(); + return; + } + // The same filter as the zone's chat + if (!Game::chatFilter->IsSentenceOkay(message, character->GetGMLevel()).empty()) { + ChatPackets::SendSystemMessage(sysAddr, u"Your message was not sent to your guild."); + return; + } + + auto text = GeneralUtils::UTF8ToUTF16(message); + ChatPackets::GeneralChatMessage chat; + chat.playerID = entity->GetObjectID(); + chat.chatChannel = eChatChannel::GUILD; + chat.messageLength = static_cast(text.size()); + chat.senderName = PlayerName(character->GetName()); + chat.senderID = entity->GetObjectID(); + chat.senderGMLevel = static_cast(character->GetGMLevel()); + chat.message = LUWString(text, static_cast(text.size())); + ChatServerLink::Send(chat); +} + +void GuildCommands::OpenCreateBox(Entity* entity, const SystemAddress& sysAddr, const std::string args) { + GameMessages::DisplayGuildCreateBox box; + box.target = entity->GetObjectID(); + box.Send(sysAddr); +} + +void GuildCommands::Kick(Entity* entity, const SystemAddress& sysAddr, const std::string args) { + const auto name = Trimmed(args); + if (name.empty()) { + ChatPackets::SendSystemMessage(sysAddr, u"Usage: /gkick "); + return; + } + ChatPackets::GuildKick request; + request.playerID = entity->GetObjectID(); + request.kickedPlayer = PlayerName(name); + ChatServerLink::Send(request); +} + +void GuildCommands::Rank(Entity* entity, const SystemAddress& sysAddr, const std::string args) { + const auto split = GeneralUtils::SplitString(Trimmed(args), ' '); + const std::string rankName = split.size() == 2 ? Lower(split[1]) : ""; + eGuildRank rank = eGuildRank::NONE; + if (rankName == "officer") rank = eGuildRank::OFFICER; + else if (rankName == "veteran") rank = eGuildRank::VETERAN; + else if (rankName == "recruit") rank = eGuildRank::RECRUIT; + if (rank == eGuildRank::NONE) { + ChatPackets::SendSystemMessage(sysAddr, u"Usage: /grank "); + return; + } + SendRank(entity, split[0], rank); +} + +void GuildCommands::Leader(Entity* entity, const SystemAddress& sysAddr, const std::string args) { + const auto name = Trimmed(args); + if (name.empty()) { + ChatPackets::SendSystemMessage(sysAddr, u"Usage: /gleader "); + return; + } + SendRank(entity, name, eGuildRank::LEADER); +} + +void GuildCommands::Disband(Entity* entity, const SystemAddress& sysAddr, const std::string args) { + if (Trimmed(args) != "confirm") { + ChatPackets::SendSystemMessage(sysAddr, u"This removes every member and the guild itself. To do it, type /gdisband confirm"); + return; + } + ChatPackets::GuildDisband request; + request.playerID = entity->GetObjectID(); + ChatServerLink::Send(request); +} diff --git a/dGame/dUtilities/SlashCommands/GuildCommands.h b/dGame/dUtilities/SlashCommands/GuildCommands.h new file mode 100644 index 000000000..ba2c5af11 --- /dev/null +++ b/dGame/dUtilities/SlashCommands/GuildCommands.h @@ -0,0 +1,23 @@ +#ifndef GUILDCOMMANDS_H +#define GUILDCOMMANDS_H + +#include + +class Entity; +struct SystemAddress; + +/** + * Guild slash commands (docs/Guilds.md). The client has none: its guild chat tab sends "/g ", which reaches the + * server like any unknown command, and it has no controls for kicking, ranks or disbanding. The chat server decides + * everything; these only pass the request on. + */ +namespace GuildCommands { + void Chat(Entity* entity, const SystemAddress& sysAddr, const std::string args); + void OpenCreateBox(Entity* entity, const SystemAddress& sysAddr, const std::string args); + void Kick(Entity* entity, const SystemAddress& sysAddr, const std::string args); + void Rank(Entity* entity, const SystemAddress& sysAddr, const std::string args); + void Leader(Entity* entity, const SystemAddress& sysAddr, const std::string args); + void Disband(Entity* entity, const SystemAddress& sysAddr, const std::string args); +} + +#endif // GUILDCOMMANDS_H diff --git a/dScripts/CppScripts.cpp b/dScripts/CppScripts.cpp index b4bb79da7..c14d94674 100644 --- a/dScripts/CppScripts.cpp +++ b/dScripts/CppScripts.cpp @@ -158,6 +158,7 @@ #include "FvNinjaGuard.h" #include "FvPassThroughWall.h" #include "FvBounceOverWall.h" +#include "FvGuildCreate.h" #include "FvFong.h" #include "FvMaelstromGeyser.h" #include "FvRaceDragon.h" @@ -515,6 +516,7 @@ namespace { {"scripts\\ai\\FV\\L_FV_FACILITY_PIPES.lua", []() {return new FvFacilityPipes();}}, {"scripts\\02_server\\Map\\FV\\L_IMG_BRICK_CONSOLE_QB.lua", []() {return new ImgBrickConsoleQB();}}, {"scripts\\ai\\FV\\L_ACT_PARADOX_PIPE_FIX.lua", []() {return new ActParadoxPipeFix();}}, + {"scripts\\ai\\FV\\L_GUILD_CREATE.lua", []() {return new FvGuildCreate();}}, {"scripts\\ai\\FV\\L_FV_NINJA_GUARDS.lua", []() {return new FvNinjaGuard();}}, {"scripts\\ai\\FV\\L_ACT_PASS_THROUGH_WALL.lua", []() {return new FvPassThroughWall();}}, {"scripts\\ai\\FV\\L_ACT_BOUNCE_OVER_WALL.lua", []() {return new FvBounceOverWall();}}, diff --git a/dScripts/ai/FV/CMakeLists.txt b/dScripts/ai/FV/CMakeLists.txt index eaa493c6c..0c3185f85 100644 --- a/dScripts/ai/FV/CMakeLists.txt +++ b/dScripts/ai/FV/CMakeLists.txt @@ -7,6 +7,7 @@ set(DSCRIPTS_SOURCES_AI_FV "FvPandaSpawnerServer.cpp" "FvPandaServer.cpp" "FvBrickPuzzleServer.cpp" + "FvGuildCreate.cpp" "FvConsoleLeftQuickbuild.cpp" "FvConsoleRightQuickbuild.cpp" "FvFacilityBrick.cpp" diff --git a/dScripts/ai/FV/FvGuildCreate.cpp b/dScripts/ai/FV/FvGuildCreate.cpp new file mode 100644 index 000000000..52a366ac1 --- /dev/null +++ b/dScripts/ai/FV/FvGuildCreate.cpp @@ -0,0 +1,11 @@ +#include "FvGuildCreate.h" + +#include "Entity.h" +#include "PlayerMessages.h" + +void FvGuildCreate::OnUse(Entity* self, Entity* user) { + GameMessages::DisplayGuildCreateBox box; + box.target = user->GetObjectID(); + box.bShow = true; + box.Send(user->GetSystemAddress()); +} diff --git a/dScripts/ai/FV/FvGuildCreate.h b/dScripts/ai/FV/FvGuildCreate.h new file mode 100644 index 000000000..37f65c5a0 --- /dev/null +++ b/dScripts/ai/FV/FvGuildCreate.h @@ -0,0 +1,9 @@ +#pragma once +#include "CppScripts.h" + +// The Guild Master (LOT 3001, scripts\ai\FV\L_GUILD_CREATE.lua): using it opens the guild create box (docs/Guilds.md). +// Live never placed it in a zone. +class FvGuildCreate : public CppScripts::Script { +public: + void OnUse(Entity* self, Entity* user) override; +}; diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index 2a4529c0a..25b8a8275 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -669,6 +669,15 @@ namespace { Game::server->Send(bitStream, sysAddr, false); //send routed packet to player }); + // A player's guild changed (docs/Guilds.md): it shows under their name + handlers.On(MessageType::Chat::GUILD_GET_STATUS, [](const ChatPackets::GuildStatus& status, const SystemAddress&) { + auto* player = Game::entityManager->GetEntity(status.characterID); + auto* characterComponent = player ? player->GetComponent() : nullptr; + if (!characterComponent) return; + characterComponent->SetGuild(status.guildID, status.guildName.string); + Game::entityManager->SerializeEntity(player); + }); + // New mail for a player the chat server says is in this world handlers.On(MessageType::Chat::MAIL, [](const ChatPackets::MailNotify& notify, const SystemAddress&) { Mail::NotifyNewMailHere(notify.receiverID); @@ -1425,6 +1434,19 @@ namespace { } }; + // The guild create box (docs/Guilds.md): the chat server makes the guild + struct TmpGuildCreatePacket final : public WorldPackets::TmpGuildCreate { + void Handle() override { + auto* user = UserManager::Instance()->GetUser(sysAddr); + const auto* const lastChar = user ? user->GetLastUsedChar() : nullptr; + if (!lastChar) return; + ChatPackets::GuildCreate create; + create.playerID = lastChar->GetObjectID(); + create.guildName = LUWString(guildName, ChatPackets::GuildCreate().guildName.size); + ChatServerLink::Send(create); + } + }; + struct StringCheckPacket final : public WorldPackets::StringCheck { void Handle() override { const auto receiver = GetNarrowReceiver(); @@ -1707,6 +1729,7 @@ namespace { { MessageType::World::ROUTE_PACKET, Create }, { MessageType::World::STRING_CHECK, Create }, { MessageType::World::GENERAL_CHAT_MESSAGE, Create }, + { MessageType::World::TMP_GUILD_CREATE, Create }, { MessageType::World::HANDLE_FUNNESS, Create }, { MessageType::World::UI_HELP_TOP_5, Create }, { MessageType::World::REQUEST_UGC_MANIFEST_INFO, Create }, diff --git a/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp b/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp index 9f0fa6e9d..d5559e476 100644 --- a/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp +++ b/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp @@ -130,6 +130,46 @@ TEST_F(ReplicaConstructionTest, CharacterConstructionAlwaysWritesGmActivityAndSo player.SetCharacter(nullptr); } +// A guild change (docs/Guilds.md) is written once: the client reads guild id, a u8 length and 16-bit characters, and +// redraws the name billboard every time it reads a name. +TEST_F(ReplicaConstructionTest, CharacterGuildChangeIsWrittenOnce) { + User user(UNASSIGNED_SYSTEM_ADDRESS, "tester", "key"); + Character character(1, &user); + info.lot = 1; + Entity player(1152921506064087004, info); + player.SetCharacter(&character); + character.SetEntity(&player); + auto* const component = player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + RakNet::BitStream construction; + component->Serialize(construction, true); + + component->SetGuild(7, u"Brick"); + EXPECT_EQ(component->GetGuildID(), 7); + RakNet::BitStream serialization; + component->Serialize(serialization, false); + RakNet::BitStream expected; + expected.Write0(); // gm_pvp_info + expected.Write0(); // current_activity + expected.Write1(); // social_info + expected.Write(7); + expected.Write(5); + for (const char16_t c : std::u16string(u"Brick")) expected.Write(c); + expected.Write1(); + expected.Write(0); + ExpectSameBits(serialization, expected); + + RakNet::BitStream again; + component->Serialize(again, false); + EXPECT_EQ(again.GetNumberOfBitsUsed(), 3u); + // The same guild again changes nothing + component->SetGuild(7, u"Brick"); + RakNet::BitStream same; + component->Serialize(same, false); + EXPECT_EQ(same.GetNumberOfBitsUsed(), 3u); + + player.SetCharacter(nullptr); +} + // Live wrote the cheat block (gravity scale, speed multiplier) on construction only when one of them was changed: 216 // of 20,617 controllable-physics constructions had it (players at run speed 1.05, enemies with gravity 0), never 1/1. TEST_F(ReplicaConstructionTest, ControllablePhysicsConstructionWritesCheatsOnlyWhenChanged) {