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<AMFBoolValue>("Has PVP flag turned on") = m_PvpEnabled;
cmptType.PushDebug<AMFIntValue>("GM Level") = GeneralUtils::ToUnderlying(m_GMLevel); cmptType.PushDebug<AMFIntValue>("GM Level") = GeneralUtils::ToUnderlying(m_GMLevel);
cmptType.PushDebug<AMFIntValue>("Editor level") = GeneralUtils::ToUnderlying(m_EditorLevel); cmptType.PushDebug<AMFIntValue>("Editor level") = GeneralUtils::ToUnderlying(m_EditorLevel);
cmptType.PushDebug<AMFStringValue>("Guild ID") = "0"; cmptType.PushDebug<AMFStringValue>("Guild ID") = std::to_string(m_GuildID);
cmptType.PushDebug<AMFStringValue>("Guild Name") = ""; cmptType.PushDebug<AMFStringValue>("Guild Name") = GeneralUtils::UTF16ToWTF8(m_GuildName);
cmptType.PushDebug<AMFDoubleValue>("Reputation") = m_Reputation; cmptType.PushDebug<AMFDoubleValue>("Reputation") = m_Reputation;
cmptType.PushDebug<AMFIntValue>("Current Activity Type") = GeneralUtils::ToUnderlying(m_CurrentActivity); cmptType.PushDebug<AMFIntValue>("Current Activity Type") = GeneralUtils::ToUnderlying(m_CurrentActivity);
cmptType.PushDebug<AMFDoubleValue>("Property Clone ID") = m_Character->GetPropertyCloneID(); 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_IsLEGOClubMember);
outBitStream.Write(m_CountryCode); 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 { bool CharacterComponent::GetPvpEnabled() const {
return m_PvpEnabled; 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 m_EditorEnabled = false; //We're not currently in HF if we're loading in
} }
//Annoying guild bs: LoadGuild();
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;
}
}
if (character->FindAttribute("time")) { if (character->FindAttribute("time")) {
character->QueryUnsigned64Attribute("time", &m_TotalTimePlayed); character->QueryUnsigned64Attribute("time", &m_TotalTimePlayed);

View File

@@ -129,6 +129,14 @@ public:
*/ */
bool GetPvpEnabled() const; 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 * Returns the characters lifetime reputation
* @return The lifetime reputation of this character. * @return The lifetime reputation of this character.
@@ -449,6 +457,9 @@ private:
*/ */
bool m_DirtySocialInfo = false; bool m_DirtySocialInfo = false;
// Reads the character's guild from the database (on load)
void LoadGuild();
/** /**
* The guild this character is in * The guild this character is in
*/ */

View File

@@ -19,6 +19,7 @@
#include "DEVGMCommands.h" #include "DEVGMCommands.h"
#include "GMGreaterThanZeroCommands.h" #include "GMGreaterThanZeroCommands.h"
#include "GMZeroCommands.h" #include "GMZeroCommands.h"
#include "GuildCommands.h"
#include "LiveEvents.h" #include "LiveEvents.h"
#include "Amf3.h" #include "Amf3.h"
@@ -1328,6 +1329,61 @@ void SlashCommandHandler::Startup() {
}; };
RegisterCommand(ServerUptimeCommand); 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 //Commands that are handled by the client
Command faqCommand{ Command faqCommand{

View File

@@ -2,5 +2,6 @@ set(DGAME_DUTILITIES_SLASHCOMMANDS
"DEVGMCommands.cpp" "DEVGMCommands.cpp"
"GMGreaterThanZeroCommands.cpp" "GMGreaterThanZeroCommands.cpp"
"GMZeroCommands.cpp" "GMZeroCommands.cpp"
"GuildCommands.cpp"
PARENT_SCOPE 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

View File

@@ -158,6 +158,7 @@
#include "FvNinjaGuard.h" #include "FvNinjaGuard.h"
#include "FvPassThroughWall.h" #include "FvPassThroughWall.h"
#include "FvBounceOverWall.h" #include "FvBounceOverWall.h"
#include "FvGuildCreate.h"
#include "FvFong.h" #include "FvFong.h"
#include "FvMaelstromGeyser.h" #include "FvMaelstromGeyser.h"
#include "FvRaceDragon.h" #include "FvRaceDragon.h"
@@ -515,6 +516,7 @@ namespace {
{"scripts\\ai\\FV\\L_FV_FACILITY_PIPES.lua", []() {return new FvFacilityPipes();}}, {"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\\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_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_FV_NINJA_GUARDS.lua", []() {return new FvNinjaGuard();}},
{"scripts\\ai\\FV\\L_ACT_PASS_THROUGH_WALL.lua", []() {return new FvPassThroughWall();}}, {"scripts\\ai\\FV\\L_ACT_PASS_THROUGH_WALL.lua", []() {return new FvPassThroughWall();}},
{"scripts\\ai\\FV\\L_ACT_BOUNCE_OVER_WALL.lua", []() {return new FvBounceOverWall();}}, {"scripts\\ai\\FV\\L_ACT_BOUNCE_OVER_WALL.lua", []() {return new FvBounceOverWall();}},

View File

@@ -7,6 +7,7 @@ set(DSCRIPTS_SOURCES_AI_FV
"FvPandaSpawnerServer.cpp" "FvPandaSpawnerServer.cpp"
"FvPandaServer.cpp" "FvPandaServer.cpp"
"FvBrickPuzzleServer.cpp" "FvBrickPuzzleServer.cpp"
"FvGuildCreate.cpp"
"FvConsoleLeftQuickbuild.cpp" "FvConsoleLeftQuickbuild.cpp"
"FvConsoleRightQuickbuild.cpp" "FvConsoleRightQuickbuild.cpp"
"FvFacilityBrick.cpp" "FvFacilityBrick.cpp"

View File

@@ -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());
}

View File

@@ -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;
};

View File

@@ -669,6 +669,15 @@ namespace {
Game::server->Send(bitStream, sysAddr, false); //send routed packet to player 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<ChatPackets::GuildStatus>(MessageType::Chat::GUILD_GET_STATUS, [](const ChatPackets::GuildStatus& status, const SystemAddress&) {
auto* player = Game::entityManager->GetEntity(status.characterID);
auto* characterComponent = player ? player->GetComponent<CharacterComponent>() : 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 // New mail for a player the chat server says is in this world
handlers.On<ChatPackets::MailNotify>(MessageType::Chat::MAIL, [](const ChatPackets::MailNotify& notify, const SystemAddress&) { handlers.On<ChatPackets::MailNotify>(MessageType::Chat::MAIL, [](const ChatPackets::MailNotify& notify, const SystemAddress&) {
Mail::NotifyNewMailHere(notify.receiverID); 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 { struct StringCheckPacket final : public WorldPackets::StringCheck {
void Handle() override { void Handle() override {
const auto receiver = GetNarrowReceiver(); const auto receiver = GetNarrowReceiver();
@@ -1707,6 +1729,7 @@ namespace {
{ MessageType::World::ROUTE_PACKET, Create<RoutePacket> }, { MessageType::World::ROUTE_PACKET, Create<RoutePacket> },
{ MessageType::World::STRING_CHECK, Create<StringCheckPacket> }, { MessageType::World::STRING_CHECK, Create<StringCheckPacket> },
{ MessageType::World::GENERAL_CHAT_MESSAGE, Create<GeneralChatMessagePacket> }, { MessageType::World::GENERAL_CHAT_MESSAGE, Create<GeneralChatMessagePacket> },
{ MessageType::World::TMP_GUILD_CREATE, Create<TmpGuildCreatePacket> },
{ MessageType::World::HANDLE_FUNNESS, Create<HandleFunnessPacket> }, { MessageType::World::HANDLE_FUNNESS, Create<HandleFunnessPacket> },
{ MessageType::World::UI_HELP_TOP_5, Create<UIHelpTop5Packet> }, { MessageType::World::UI_HELP_TOP_5, Create<UIHelpTop5Packet> },
{ MessageType::World::REQUEST_UGC_MANIFEST_INFO, Create<RequestUgcManifestInfoPacket> }, { MessageType::World::REQUEST_UGC_MANIFEST_INFO, Create<RequestUgcManifestInfoPacket> },

View File

@@ -130,6 +130,46 @@ TEST_F(ReplicaConstructionTest, CharacterConstructionAlwaysWritesGmActivityAndSo
player.SetCharacter(nullptr); 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<CharacterComponent>(-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<LWOOBJID>(7);
expected.Write<uint8_t>(5);
for (const char16_t c : std::u16string(u"Brick")) expected.Write<uint16_t>(c);
expected.Write1();
expected.Write<uint32_t>(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 // 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. // 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) { TEST_F(ReplicaConstructionTest, ControllablePhysicsConstructionWritesCheatsOnlyWhenChanged) {