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 <name> <officer|veteran|recruit>, /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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 06:07:19 -05:00
parent d1eec7dacb
commit f6f937955e
12 changed files with 319 additions and 15 deletions

View File

@@ -91,8 +91,8 @@ bool CharacterComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo
cmptType.PushDebug<AMFBoolValue>("Has PVP flag turned on") = m_PvpEnabled;
cmptType.PushDebug<AMFIntValue>("GM Level") = GeneralUtils::ToUnderlying(m_GMLevel);
cmptType.PushDebug<AMFIntValue>("Editor level") = GeneralUtils::ToUnderlying(m_EditorLevel);
cmptType.PushDebug<AMFStringValue>("Guild ID") = "0";
cmptType.PushDebug<AMFStringValue>("Guild Name") = "";
cmptType.PushDebug<AMFStringValue>("Guild ID") = std::to_string(m_GuildID);
cmptType.PushDebug<AMFStringValue>("Guild Name") = GeneralUtils::UTF16ToWTF8(m_GuildName);
cmptType.PushDebug<AMFDoubleValue>("Reputation") = m_Reputation;
cmptType.PushDebug<AMFIntValue>("Current Activity Type") = GeneralUtils::ToUnderlying(m_CurrentActivity);
cmptType.PushDebug<AMFDoubleValue>("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);

View File

@@ -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
*/

View File

@@ -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 <name> <officer|veteran|recruit>",
.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 <name>",
.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{

View File

@@ -2,5 +2,6 @@ set(DGAME_DUTILITIES_SLASHCOMMANDS
"DEVGMCommands.cpp"
"GMGreaterThanZeroCommands.cpp"
"GMZeroCommands.cpp"
"GuildCommands.cpp"
PARENT_SCOPE
)

View File

@@ -0,0 +1,120 @@
#include "GuildCommands.h"
#include <cctype>
#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<char>(std::tolower(static_cast<unsigned char>(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<uint8_t>(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<uint32_t>(text.size());
chat.senderName = PlayerName(character->GetName());
chat.senderID = entity->GetObjectID();
chat.senderGMLevel = static_cast<uint8_t>(character->GetGMLevel());
chat.message = LUWString(text, static_cast<uint32_t>(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 <name>");
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 <name> <officer|veteran|recruit>");
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 <name>");
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);
}

View File

@@ -0,0 +1,23 @@
#ifndef GUILDCOMMANDS_H
#define GUILDCOMMANDS_H
#include <string>
class Entity;
struct SystemAddress;
/**
* Guild slash commands (docs/Guilds.md). The client has none: its guild chat tab sends "/g <text>", 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