feat(character): the editor level is the account's GM level, and the client is told it

A character's editor level is its account's GM level for accounts at editor_gm_level
or above, and 0 for everyone else (it was 1 or 0). It is written as the save's edit
attribute, sent with the character after the GM level (the editor flag, then the
level) and given in answer to the GetEditorLevel game message.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-10 19:11:29 -06:00
parent ca7d166c0b
commit dbe0722095
9 changed files with 139 additions and 35 deletions

View File

@@ -169,7 +169,7 @@ namespace {
c.Add(Int(SHARED, "version_current", "Version: current", "", "10", 0, 65535));
c.Add(Int(SHARED, "version_minor", "Version: minor", "", "64", 0, 65535));
c.Add(Int(WORLD, "editor_gm_level", "Client editor from GM level", "Characters of accounts at or above this GM level are saved with the client's editor enabled (edit=\"1\" in the character data); everyone else with 0. Used the next time a character is saved.", "9", 1, 9));
c.Add(Int(WORLD, "editor_gm_level", "Client editor from GM level", "Characters of accounts at or above this GM level have the client's level editor enabled: their editor level is the account's GM level (edit in the character data, and told to the client with the character); everyone else has 0. Used the next time a character loads or is saved.", "9", 1, 9));
c.AddSection("Logging and crashes");
c.Add(Bool(SHARED, "log_to_console", "Log to the console", "Also print log lines to the terminal.", true, true));

View File

@@ -64,8 +64,7 @@ CharacterComponent::CharacterComponent(Entity* parent, const int32_t componentID
m_PvpEnabled = false;
m_GMLevel = character->GetGMLevel();
m_EditorEnabled = false;
m_EditorLevel = m_GMLevel;
RefreshEditorLevel();
m_Reputation = 0;
m_CurrentActivity = eGameActivity::NONE;
@@ -307,6 +306,16 @@ void CharacterComponent::SetPvpEnabled(const bool value) {
m_PvpEnabled = value;
}
void CharacterComponent::RefreshEditorLevel() {
const auto* const parentUser = m_Character ? m_Character->GetParentUser() : nullptr;
const auto accountLevel = parentUser ? parentUser->GetMaxGMLevel() : eGameMasterLevel::CIVILIAN;
const auto editorGmLevel = Game::config ? GeneralUtils::TryParse<uint32_t>(Game::config->GetValue("editor_gm_level")).value_or(DEFAULT_EDITOR_GM_LEVEL) : DEFAULT_EDITOR_GM_LEVEL;
const auto level = static_cast<eGameMasterLevel>(EditAttribute(accountLevel, editorGmLevel));
if (level != m_EditorLevel) m_DirtyGMInfo = true;
m_EditorLevel = level;
m_EditorEnabled = level != eGameMasterLevel::CIVILIAN;
}
void CharacterComponent::SetGMLevel(eGameMasterLevel gmlevel) {
m_DirtyGMInfo = true;
if (gmlevel > eGameMasterLevel::CIVILIAN) m_IsGM = true;
@@ -402,8 +411,6 @@ void CharacterComponent::LoadFromSaveData(const tinyxml2::XMLDocument& doc) {
if (m_GMLevel > eGameMasterLevel::CIVILIAN) {
m_IsGM = true;
m_DirtyGMInfo = true;
m_EditorLevel = m_GMLevel;
m_EditorEnabled = false; //We're not currently in HF if we're loading in
}
LoadGuild();
@@ -481,12 +488,10 @@ void CharacterComponent::WriteSaveData(tinyxml2::XMLDocument& doc) {
// cm, the most coins the character can hold: always 9223372036854775807 on live, the client's own value without
// it. Nothing here caps coins by it, and one that is already in the save is left as it is.
if (!character->FindAttribute("cm")) character->SetAttribute("cm", std::numeric_limits<int64_t>::max());
// edit, the editor level: the client takes anything but 0 as the editor being on. 1 for a character whose account
// has the GM level of editor_gm_level (9 unless set), 0 for everyone else
const auto* const parentUser = m_Character ? m_Character->GetParentUser() : nullptr;
const auto accountLevel = parentUser ? parentUser->GetMaxGMLevel() : eGameMasterLevel::CIVILIAN;
const auto editorGmLevel = GeneralUtils::TryParse<uint32_t>(Game::config->GetValue("editor_gm_level")).value_or(DEFAULT_EDITOR_GM_LEVEL);
character->SetAttribute("edit", EditAttribute(accountLevel, editorGmLevel, m_EditorEnabled, m_EditorLevel));
// edit, the editor level: the client takes anything but 0 as the editor being on. The account's GM level for an
// account at editor_gm_level (9 unless set) or above, 0 for everyone else
RefreshEditorLevel();
character->SetAttribute("edit", static_cast<uint32_t>(m_EditorLevel));
// mldt, when the property moderation notice was last shown: not kept here, 0 as on most live saves
if (!character->FindAttribute("mldt")) character->SetAttribute("mldt", 0);
// gid and gn, the guild (0 and empty without one)

View File

@@ -474,30 +474,37 @@ private:
eGameMasterLevel m_GMLevel;
/**
* Whether the character has HF enabled
* Whether the client's level editor is on for the character: its editor level is not 0
*/
bool m_EditorEnabled;
bool m_EditorEnabled{};
public:
// editor_gm_level when the settings don't have it
static constexpr uint32_t DEFAULT_EDITOR_GM_LEVEL = 9;
/**
* The save's edit attribute (the client takes anything but 0 as the editor being on): 1 for an account whose GM
* level is at least editorGmLevel, else the editor level when the editor flag is on, else 0
* A character's editor level, which is also the save's edit attribute (the client takes anything but 0 as the
* editor being on): the account's GM level for an account at editorGmLevel or above, 0 for everyone else
*/
static uint32_t EditAttribute(const eGameMasterLevel accountLevel, const uint32_t editorGmLevel, const bool editorEnabled, const eGameMasterLevel editorLevel) {
static uint32_t EditAttribute(const eGameMasterLevel accountLevel, const uint32_t editorGmLevel) {
// (a civilian never, whatever the setting)
if (accountLevel != eGameMasterLevel::CIVILIAN && static_cast<uint32_t>(accountLevel) >= editorGmLevel) return 1;
return editorEnabled ? static_cast<uint32_t>(editorLevel) : 0;
if (accountLevel == eGameMasterLevel::CIVILIAN || static_cast<uint32_t>(accountLevel) < editorGmLevel) return 0;
return static_cast<uint32_t>(accountLevel);
}
// The editor level as the client is told it (construction, the save, GetEditorLevel): 0 when the editor is off
uint8_t GetEditorLevel() const { return static_cast<uint8_t>(m_EditorLevel); }
bool GetEditorEnabled() const { return m_EditorEnabled; }
// Works the editor level out again from the account's GM level and editor_gm_level
void RefreshEditorLevel();
private:
/**
* The level of the character in HF
* The character's editor level (EditAttribute), 0 for none
*/
eGameMasterLevel m_EditorLevel;
eGameMasterLevel m_EditorLevel{};
/**
* Whether the currently active activity has been changed

View File

@@ -204,6 +204,7 @@ json ToJson(const GameMessages::ReportBug& m);
json ToJson(const GameMessages::VerifyAck& m);
json ToJson(const GameMessages::Help& m);
json ToJson(const GameMessages::DisplayGuildCreateBox& m);
json ToJson(const GameMessages::GetEditorLevel& m);
json ToJson(const GameMessages::OpenPropertyVendor& m);
json ToJson(const GameMessages::OpenPropertyManagement& m);
json ToJson(const GameMessages::DownloadPropertyData& m);
@@ -1806,6 +1807,12 @@ json ToJson(const GameMessages::DisplayGuildCreateBox& m) {
j["bShow"] = ToJson(m.bShow);
return j;
}
json ToJson(const GameMessages::GetEditorLevel& m) {
json j = json::object();
j["editorEnabled"] = ToJson(m.editorEnabled);
j["editorLevel"] = ToJson(m.editorLevel);
return j;
}
json ToJson(const GameMessages::OpenPropertyVendor& m) {
json j = json::object();
return j;
@@ -2724,6 +2731,7 @@ const std::vector<Entry>& Entries() {
{ MessageType::Game::VERIFY_ACK, eDirection::TO_CLIENT, "VerifyAck", &ReadWith<GameMessages::VerifyAck> },
{ MessageType::Game::HELP, eDirection::TO_CLIENT, "Help", &ReadWith<GameMessages::Help> },
{ MessageType::Game::DISPLAY_GUILD_CREATE_BOX, eDirection::TO_CLIENT, "DisplayGuildCreateBox", &ReadWith<GameMessages::DisplayGuildCreateBox> },
{ MessageType::Game::GET_EDITOR_LEVEL, eDirection::TO_SERVER, "GetEditorLevel", &ReadWith<GameMessages::GetEditorLevel> },
{ MessageType::Game::OPEN_PROPERTY_VENDOR, eDirection::TO_CLIENT, "OpenPropertyVendor", &ReadWith<GameMessages::OpenPropertyVendor> },
{ MessageType::Game::OPEN_PROPERTY_MANAGEMENT, eDirection::TO_CLIENT, "OpenPropertyManagement", &ReadWith<GameMessages::OpenPropertyManagement> },
{ MessageType::Game::DOWNLOAD_PROPERTY_DATA, eDirection::TO_CLIENT, "DownloadPropertyData", &ReadWith<GameMessages::DownloadPropertyData> },

View File

@@ -222,6 +222,7 @@ namespace {
{ TOGGLE_GHOST_REFERENCE_OVERRIDE, []() { return std::make_unique<ToggleGhostReferenceOverride>(); } },
{ SET_GHOST_REFERENCE_POSITION, []() { return std::make_unique<SetGhostReferencePosition>(); } },
{ MODIFY_GHOSTING_DISTANCE, []() { return std::make_unique<ModifyGhostingDistance>(); } },
{ GET_EDITOR_LEVEL, []() { return std::make_unique<GetEditorLevel>(); } },
};
};
@@ -270,8 +271,7 @@ void GameMessageHandler::HandleMessage(RakNet::BitStream& inStream, const System
}
// The Happy Flower editor's messages, logged always until the server handles them (what a client sends while editing)
const bool isHappyFlower = messageID == MessageType::Game::GET_EDITOR_LEVEL
|| (messageID >= MessageType::Game::HF_ATTRIBUTES_PUSH && messageID <= MessageType::Game::HF_GETS_WANT_PATH);
const bool isHappyFlower = messageID >= MessageType::Game::HF_ATTRIBUTES_PUSH && messageID <= MessageType::Game::HF_GETS_WANT_PATH;
if (isHappyFlower) LOG("Received unhandled Happy Flower GM: %4i, %s", messageID, StringifiedEnum::ToString(messageID).data());
else LOG_DEBUG("Received Unknown GM with ID: %4i, %s", messageID, StringifiedEnum::ToString(messageID).data());
}

View File

@@ -1,5 +1,7 @@
#include "PlayerMessages.h"
#include "PlayerManager.h"
#include "Logger.h"
#include "BitStreamUtils.h"
#include "Character.h"
@@ -265,4 +267,28 @@ namespace GameMessages {
bool DisplayGuildCreateBox::Deserialize(RakNet::BitStream& bitStream) {
return bitStream.Read(bShow);
}
void GetEditorLevel::Serialize(RakNet::BitStream& bitStream) const {
bitStream.Write(editorEnabled);
bitStream.Write(editorLevel);
}
bool GetEditorLevel::Deserialize(RakNet::BitStream& bitStream) {
// A question: whatever it carries is not needed to answer it
if (bitStream.GetNumberOfUnreadBits() < 33) return true;
return bitStream.Read(editorEnabled) && bitStream.Read(editorLevel);
}
void GetEditorLevel::Handle(Entity& entity, const SystemAddress& sysAddr) {
// Only about the asker's own character
auto* const player = PlayerManager::GetPlayer(sysAddr);
if (!player || player != &entity) return;
const auto* const character = entity.GetComponent<CharacterComponent>();
if (!character) return;
GetEditorLevel answer;
answer.target = entity.GetObjectID();
answer.editorEnabled = character->GetEditorEnabled();
answer.editorLevel = character->GetEditorLevel();
answer.SendToClient(sysAddr);
}
}

View File

@@ -193,6 +193,18 @@ namespace GameMessages {
bool bShow{ true };
};
// Client -> server, and the server's answer to that client: whether the client's level editor is on for the
// player and at which level (the character's editor level, 0 for none). What a client sends is not used.
struct GetEditorLevel : public NetGameMsg {
GetEditorLevel() : NetGameMsg(MessageType::Game::GET_EDITOR_LEVEL) {}
void Serialize(RakNet::BitStream& bitStream) const override;
bool Deserialize(RakNet::BitStream& bitStream) override;
void Handle(Entity& entity, const SystemAddress& sysAddr) override;
bool editorEnabled{ false };
int32_t editorLevel{ 0 };
};
}
#endif // PLAYERMESSAGES_H

View File

@@ -118,8 +118,9 @@ save_lxfmls=0
# 1: saving a brick by brick model uses up the bricks in it, as live did. 0: they go back to the backpack.
bbb_consume_bricks=0
# Characters of accounts at or above this GM level are saved with the client's editor enabled (edit="1" in the
# character data); everyone else with 0.
# Characters of accounts at or above this GM level have the client's level editor enabled: their editor level is the
# account's GM level (edit="<level>" in the character data, and told to the client with the character); everyone
# else has 0.
editor_gm_level=9
# 1: logging out on a property and back in returns to that property. 0 (as live): to the last world before it.

View File

@@ -5,6 +5,7 @@
#include "CDClientDatabase.h"
#include "Character.h"
#include "CharacterComponent.h"
#include "PlayerMessages.h"
#include "Entity.h"
#include "User.h"
#include "dConfig.h"
@@ -165,18 +166,18 @@ TEST_F(CharacterSaveFieldsTests, LiveCharacterAttributesRoundTrip) {
EXPECT_STREQ(saved->Attribute("gn"), "Builders");
}
// edit: 1 for a character whose account has the GM level of editor_gm_level (9 unless set), 0 for everyone else
// edit, the editor level: the account's GM level for an account at editor_gm_level (9 unless set) or above, 0 for
// everyone else
TEST_F(CharacterSaveFieldsTests, EditAttributeFollowsTheAccountGmLevel) {
using enum eGameMasterLevel;
EXPECT_EQ(CharacterComponent::EditAttribute(OPERATOR, 9, false, OPERATOR), 1u);
EXPECT_EQ(CharacterComponent::EditAttribute(DEVELOPER, 9, false, DEVELOPER), 0u);
EXPECT_EQ(CharacterComponent::EditAttribute(CIVILIAN, 9, false, CIVILIAN), 0u);
EXPECT_EQ(CharacterComponent::EditAttribute(DEVELOPER, 8, false, DEVELOPER), 1u);
EXPECT_EQ(CharacterComponent::EditAttribute(JUNIOR_DEVELOPER, 8, false, JUNIOR_DEVELOPER), 0u);
EXPECT_EQ(CharacterComponent::EditAttribute(OPERATOR, 9), 9u);
EXPECT_EQ(CharacterComponent::EditAttribute(DEVELOPER, 9), 0u);
EXPECT_EQ(CharacterComponent::EditAttribute(CIVILIAN, 9), 0u);
EXPECT_EQ(CharacterComponent::EditAttribute(DEVELOPER, 8), 8u);
EXPECT_EQ(CharacterComponent::EditAttribute(OPERATOR, 8), 9u);
EXPECT_EQ(CharacterComponent::EditAttribute(JUNIOR_DEVELOPER, 8), 0u);
// A civilian never, whatever the setting
EXPECT_EQ(CharacterComponent::EditAttribute(CIVILIAN, 0, false, CIVILIAN), 0u);
// The editor flag still writes its level
EXPECT_EQ(CharacterComponent::EditAttribute(DEVELOPER, 9, true, DEVELOPER), 8u);
EXPECT_EQ(CharacterComponent::EditAttribute(CIVILIAN, 0), 0u);
const auto savedEdit = [this](const eGameMasterLevel accountLevel) {
tinyxml2::XMLDocument doc;
@@ -191,13 +192,13 @@ TEST_F(CharacterSaveFieldsTests, EditAttributeFollowsTheAccountGmLevel) {
return std::string(doc.FirstChildElement("obj")->FirstChildElement("char")->Attribute("edit"));
};
// The default level, 9
EXPECT_EQ(savedEdit(OPERATOR), "1");
EXPECT_EQ(savedEdit(OPERATOR), "9");
EXPECT_EQ(savedEdit(DEVELOPER), "0");
EXPECT_EQ(savedEdit(CIVILIAN), "0");
// editor_gm_level from the settings
Game::config->SetDatabaseValues({ { "editor_gm_level", "8" } }, {});
EXPECT_EQ(savedEdit(DEVELOPER), "1");
EXPECT_EQ(savedEdit(DEVELOPER), "8");
EXPECT_EQ(savedEdit(JUNIOR_DEVELOPER), "0");
Game::config->SetDatabaseValues({}, {});
}
@@ -410,3 +411,47 @@ TEST_F(CharacterSaveFieldsTests, PetsAsLiveWroteThem) {
inventory->WriteSaveData(doc);
EXPECT_EQ(Print(doc), R"(<obj v="1"><pet a="0"><p id="1152921510000000001" l="3254" m="2" n="Fluffy" t="0"/></pet></obj>)");
}
// The editor level is told to the client with the character: after the GM level, whether the editor is on and at
// which level
TEST_F(CharacterSaveFieldsTests, EditorLevelIsSerializedAfterTheGmLevel) {
const auto gmBlock = [this](const eGameMasterLevel accountLevel) {
User user(UNASSIGNED_SYSTEM_ADDRESS, "editor", "key");
user.SetMaxGMLevel(accountLevel);
Character character(1, &user);
Entity player(44, info);
auto* const characterComponent = player.AddComponent<CharacterComponent>(-1, &character, UNASSIGNED_SYSTEM_ADDRESS);
EXPECT_EQ(characterComponent->GetEditorLevel(), accountLevel == eGameMasterLevel::OPERATOR ? 9 : 0);
EXPECT_EQ(characterComponent->GetEditorEnabled(), accountLevel == eGameMasterLevel::OPERATOR);
// An update with the GM block: its flag, PvP, is a GM, the GM level, then the two editor fields
characterComponent->SetGMLevel(eGameMasterLevel::CIVILIAN);
RakNet::BitStream stream;
characterComponent->Serialize(stream, false);
bool written{}, pvp{}, isGm{}, editorEnabled{};
uint8_t gmLevel{}, editorLevel{};
EXPECT_TRUE(stream.Read(written) && stream.Read(pvp) && stream.Read(isGm) && stream.Read(gmLevel) && stream.Read(editorEnabled) && stream.Read(editorLevel));
EXPECT_TRUE(written);
EXPECT_EQ(gmLevel, 0);
return std::pair<bool, uint8_t>{ editorEnabled, editorLevel };
};
EXPECT_EQ(gmBlock(eGameMasterLevel::OPERATOR), (std::pair<bool, uint8_t>{ true, 9 }));
EXPECT_EQ(gmBlock(eGameMasterLevel::DEVELOPER), (std::pair<bool, uint8_t>{ false, 0 }));
EXPECT_EQ(gmBlock(eGameMasterLevel::CIVILIAN), (std::pair<bool, uint8_t>{ false, 0 }));
}
// GetEditorLevel's answer: one bit, then the level as 32 bits
TEST_F(CharacterSaveFieldsTests, GetEditorLevelAnswerLayout) {
GameMessages::GetEditorLevel answer;
answer.editorEnabled = true;
answer.editorLevel = 9;
RakNet::BitStream stream;
answer.Serialize(stream);
EXPECT_EQ(stream.GetNumberOfBitsUsed(), 33u);
GameMessages::GetEditorLevel read;
ASSERT_TRUE(read.Deserialize(stream));
EXPECT_TRUE(read.editorEnabled);
EXPECT_EQ(read.editorLevel, 9);
// A question with nothing in it reads too
RakNet::BitStream empty;
EXPECT_TRUE(GameMessages::GetEditorLevel().Deserialize(empty));
}