feat(db): zone edit locks and zone editors

Tables for the one account holding a zone's lock and for each zone's assigned
editors, on MySQL and SQLite, with the interface and the test database.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-10 18:22:03 -06:00
parent 3072715dc3
commit 0e57b95229
13 changed files with 274 additions and 1 deletions

View File

@@ -50,6 +50,7 @@
#include "IFeaturedProperties.h"
#include "IMessageCaptures.h"
#include "IContraband.h"
#include "IZoneEditing.h"
#include "IPropertyRent.h"
#include "IPropertyReputation.h"
#include "IBbbAutosave.h"
@@ -72,7 +73,7 @@ class GameDatabase :
public IPropertyContents, public IProperty, public IPetNames, public ICharXml,
public IMigrationHistory, public IUgc, public IFriends, public ICharInfo,
public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList,
public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IChatFlags, public IClientSysInfo, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation, public IBbbAutosave, public IServerTraffic, public IApiKeys, public IUgcLookup, public IPermissionGrants, public IGuilds, public IDatabaseUsage {
public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IChatFlags, public IClientSysInfo, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation, public IBbbAutosave, public IServerTraffic, public IApiKeys, public IUgcLookup, public IPermissionGrants, public IGuilds, public IDatabaseUsage, public IZoneEditing {
public:
virtual ~GameDatabase() = default;
// TODO: These should be made private.

View File

@@ -0,0 +1,45 @@
#ifndef __IZONEEDITING__H__
#define __IZONEEDITING__H__
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
/**
* Who may edit a zone's files (ZoneEditLock.h, docs/ZonePublish.md): each zone's assigned editors, and the one
* account holding its lock. The rules are ZoneEditLock's; these are only the rows.
*/
class IZoneEditing {
public:
struct ZoneLock {
uint32_t zoneId{};
uint32_t accountId{};
int64_t lockedAt{};
};
struct ZoneEditor {
uint32_t zoneId{};
uint32_t accountId{};
std::string assignedBy;
int64_t assignedAt{};
};
virtual std::optional<ZoneLock> GetZoneLock(uint32_t zoneId) = 0;
// Every held lock, by zone
virtual std::vector<ZoneLock> GetZoneLocks() = 0;
// Takes the zone's lock when nobody holds it; false when someone does (one statement, so two takers can't both win)
virtual bool TakeZoneLock(uint32_t zoneId, uint32_t accountId, int64_t time) = 0;
// Whether there was a lock to release
virtual bool ReleaseZoneLock(uint32_t zoneId) = 0;
// A zone's editors, by account; zoneId 0: every zone's, by zone then account
virtual std::vector<ZoneEditor> GetZoneEditors(uint32_t zoneId) = 0;
virtual bool IsZoneEditor(uint32_t zoneId, uint32_t accountId) = 0;
// Adds the account to the zone's editors unless it is one
virtual void AddZoneEditor(const ZoneEditor& editor) = 0;
// Whether the account was one of the zone's editors
virtual bool RemoveZoneEditor(uint32_t zoneId, uint32_t accountId) = 0;
};
#endif //!__IZONEEDITING__H__

View File

@@ -287,6 +287,15 @@ public:
void SetPropertyRent(LWOOBJID propertyId, int64_t amount, int64_t due) override;
void SetPropertyPrivacy(LWOOBJID propertyId, int32_t privacyOption) override;
// IZoneEditing
std::optional<ZoneLock> GetZoneLock(uint32_t zoneId) override;
std::vector<ZoneLock> GetZoneLocks() override;
bool TakeZoneLock(uint32_t zoneId, uint32_t accountId, int64_t time) override;
bool ReleaseZoneLock(uint32_t zoneId) override;
std::vector<ZoneEditor> GetZoneEditors(uint32_t zoneId) override;
bool IsZoneEditor(uint32_t zoneId, uint32_t accountId) override;
void AddZoneEditor(const ZoneEditor& editor) override;
bool RemoveZoneEditor(uint32_t zoneId, uint32_t accountId) override;
// IContraband
std::vector<ContrabandItem> GetContrabandItems() override;
void SetContrabandItem(const ContrabandItem& item) override;

View File

@@ -22,6 +22,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES
"LiveOps.cpp"
"FeaturedProperties.cpp"
"Contraband.cpp"
"ZoneEditing.cpp"
"PropertyRent.cpp"
"PropertyReputation.cpp"
"BbbAutosave.cpp"

View File

@@ -0,0 +1,46 @@
#include "MySQLDatabase.h"
std::optional<IZoneEditing::ZoneLock> MySQLDatabase::GetZoneLock(uint32_t zoneId) {
auto result = ExecuteSelect("SELECT zone_id, account_id, locked_at FROM zone_edit_locks WHERE zone_id = ?;", zoneId);
if (!result->next()) return std::nullopt;
return ZoneLock{ result->getUInt("zone_id"), result->getUInt("account_id"), result->getInt64("locked_at") };
}
std::vector<IZoneEditing::ZoneLock> MySQLDatabase::GetZoneLocks() {
std::vector<ZoneLock> locks;
auto result = ExecuteSelect("SELECT zone_id, account_id, locked_at FROM zone_edit_locks ORDER BY zone_id;");
while (result->next()) locks.push_back({ result->getUInt("zone_id"), result->getUInt("account_id"), result->getInt64("locked_at") });
return locks;
}
bool MySQLDatabase::TakeZoneLock(uint32_t zoneId, uint32_t accountId, int64_t time) {
return ExecuteUpdate("INSERT IGNORE INTO zone_edit_locks (zone_id, account_id, locked_at) VALUES (?, ?, ?);", zoneId, accountId, time) > 0;
}
bool MySQLDatabase::ReleaseZoneLock(uint32_t zoneId) {
return ExecuteUpdate("DELETE FROM zone_edit_locks WHERE zone_id = ?;", zoneId) > 0;
}
std::vector<IZoneEditing::ZoneEditor> MySQLDatabase::GetZoneEditors(uint32_t zoneId) {
std::vector<ZoneEditor> editors;
auto result = ExecuteSelect(
"SELECT zone_id, account_id, assigned_by, assigned_at FROM zone_editors WHERE (? = 0 OR zone_id = ?) ORDER BY zone_id, account_id;", zoneId, zoneId);
while (result->next()) {
editors.push_back({ result->getUInt("zone_id"), result->getUInt("account_id"), result->getString("assigned_by").c_str(), result->getInt64("assigned_at") });
}
return editors;
}
bool MySQLDatabase::IsZoneEditor(uint32_t zoneId, uint32_t accountId) {
auto result = ExecuteSelect("SELECT 1 FROM zone_editors WHERE zone_id = ? AND account_id = ?;", zoneId, accountId);
return result->next();
}
void MySQLDatabase::AddZoneEditor(const ZoneEditor& editor) {
ExecuteInsert("INSERT IGNORE INTO zone_editors (zone_id, account_id, assigned_by, assigned_at) VALUES (?, ?, ?, ?);",
editor.zoneId, editor.accountId, editor.assignedBy, editor.assignedAt);
}
bool MySQLDatabase::RemoveZoneEditor(uint32_t zoneId, uint32_t accountId) {
return ExecuteUpdate("DELETE FROM zone_editors WHERE zone_id = ? AND account_id = ?;", zoneId, accountId) > 0;
}

View File

@@ -271,6 +271,15 @@ public:
void SetPropertyRent(LWOOBJID propertyId, int64_t amount, int64_t due) override;
void SetPropertyPrivacy(LWOOBJID propertyId, int32_t privacyOption) override;
// IZoneEditing
std::optional<ZoneLock> GetZoneLock(uint32_t zoneId) override;
std::vector<ZoneLock> GetZoneLocks() override;
bool TakeZoneLock(uint32_t zoneId, uint32_t accountId, int64_t time) override;
bool ReleaseZoneLock(uint32_t zoneId) override;
std::vector<ZoneEditor> GetZoneEditors(uint32_t zoneId) override;
bool IsZoneEditor(uint32_t zoneId, uint32_t accountId) override;
void AddZoneEditor(const ZoneEditor& editor) override;
bool RemoveZoneEditor(uint32_t zoneId, uint32_t accountId) override;
// IContraband
std::vector<ContrabandItem> GetContrabandItems() override;
void SetContrabandItem(const ContrabandItem& item) override;

View File

@@ -22,6 +22,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES
"LiveOps.cpp"
"FeaturedProperties.cpp"
"Contraband.cpp"
"ZoneEditing.cpp"
"PropertyRent.cpp"
"PropertyReputation.cpp"
"BbbAutosave.cpp"

View File

@@ -0,0 +1,49 @@
#include "SQLiteDatabase.h"
std::optional<IZoneEditing::ZoneLock> SQLiteDatabase::GetZoneLock(uint32_t zoneId) {
auto [_, result] = ExecuteSelect("SELECT zone_id, account_id, locked_at FROM zone_edit_locks WHERE zone_id = ?;", zoneId);
if (result.eof()) return std::nullopt;
return ZoneLock{ static_cast<uint32_t>(result.getIntField("zone_id")), static_cast<uint32_t>(result.getIntField("account_id")), result.getInt64Field("locked_at") };
}
std::vector<IZoneEditing::ZoneLock> SQLiteDatabase::GetZoneLocks() {
std::vector<ZoneLock> locks;
auto [_, result] = ExecuteSelect("SELECT zone_id, account_id, locked_at FROM zone_edit_locks ORDER BY zone_id;");
for (; !result.eof(); result.nextRow()) {
locks.push_back({ static_cast<uint32_t>(result.getIntField("zone_id")), static_cast<uint32_t>(result.getIntField("account_id")), result.getInt64Field("locked_at") });
}
return locks;
}
bool SQLiteDatabase::TakeZoneLock(uint32_t zoneId, uint32_t accountId, int64_t time) {
return ExecuteUpdate("INSERT OR IGNORE INTO zone_edit_locks (zone_id, account_id, locked_at) VALUES (?, ?, ?);", zoneId, accountId, time) > 0;
}
bool SQLiteDatabase::ReleaseZoneLock(uint32_t zoneId) {
return ExecuteUpdate("DELETE FROM zone_edit_locks WHERE zone_id = ?;", zoneId) > 0;
}
std::vector<IZoneEditing::ZoneEditor> SQLiteDatabase::GetZoneEditors(uint32_t zoneId) {
std::vector<ZoneEditor> editors;
auto [_, result] = ExecuteSelect(
"SELECT zone_id, account_id, assigned_by, assigned_at FROM zone_editors WHERE (? = 0 OR zone_id = ?) ORDER BY zone_id, account_id;", zoneId, zoneId);
for (; !result.eof(); result.nextRow()) {
editors.push_back({ static_cast<uint32_t>(result.getIntField("zone_id")), static_cast<uint32_t>(result.getIntField("account_id")),
result.getStringField("assigned_by"), result.getInt64Field("assigned_at") });
}
return editors;
}
bool SQLiteDatabase::IsZoneEditor(uint32_t zoneId, uint32_t accountId) {
auto [_, result] = ExecuteSelect("SELECT 1 FROM zone_editors WHERE zone_id = ? AND account_id = ?;", zoneId, accountId);
return !result.eof();
}
void SQLiteDatabase::AddZoneEditor(const ZoneEditor& editor) {
ExecuteInsert("INSERT OR IGNORE INTO zone_editors (zone_id, account_id, assigned_by, assigned_at) VALUES (?, ?, ?, ?);",
editor.zoneId, editor.accountId, editor.assignedBy, editor.assignedAt);
}
bool SQLiteDatabase::RemoveZoneEditor(uint32_t zoneId, uint32_t accountId) {
return ExecuteUpdate("DELETE FROM zone_editors WHERE zone_id = ? AND account_id = ?;", zoneId, accountId) > 0;
}

View File

@@ -321,6 +321,28 @@ class TestSQLDatabase : public GameDatabase {
void SetPropertyRent(LWOOBJID propertyId, int64_t amount, int64_t due) override {}
void SetPropertyPrivacy(LWOOBJID propertyId, int32_t privacyOption) override {}
// IZoneEditing: kept in memory, so tests can use the locks and editors
std::optional<ZoneLock> GetZoneLock(uint32_t zoneId) override {
const auto it = m_ZoneLocks.find(zoneId);
return it == m_ZoneLocks.end() ? std::nullopt : std::optional<ZoneLock>(it->second);
}
std::vector<ZoneLock> GetZoneLocks() override {
std::vector<ZoneLock> locks;
for (const auto& [_, lock] : m_ZoneLocks) locks.push_back(lock);
return locks;
}
bool TakeZoneLock(uint32_t zoneId, uint32_t accountId, int64_t time) override {
return m_ZoneLocks.try_emplace(zoneId, ZoneLock{ zoneId, accountId, time }).second;
}
bool ReleaseZoneLock(uint32_t zoneId) override { return m_ZoneLocks.erase(zoneId) > 0; }
std::vector<ZoneEditor> GetZoneEditors(uint32_t zoneId) override {
std::vector<ZoneEditor> editors;
for (const auto& [key, editor] : m_ZoneEditors) if (zoneId == 0 || key.first == zoneId) editors.push_back(editor);
return editors;
}
bool IsZoneEditor(uint32_t zoneId, uint32_t accountId) override { return m_ZoneEditors.contains({ zoneId, accountId }); }
void AddZoneEditor(const ZoneEditor& editor) override { m_ZoneEditors.try_emplace({ editor.zoneId, editor.accountId }, editor); }
bool RemoveZoneEditor(uint32_t zoneId, uint32_t accountId) override { return m_ZoneEditors.erase({ zoneId, accountId }) > 0; }
// IContraband
std::vector<ContrabandItem> GetContrabandItems() override { return {}; }
void SetContrabandItem(const ContrabandItem& item) override {}
@@ -483,6 +505,10 @@ class TestSQLDatabase : public GameDatabase {
void DeleteAccount(const uint32_t accountId) override {};
void InsertAuditLog(uint32_t accountId, const std::string_view accountName, const std::string_view action, const std::string_view description, uint32_t targetAccountId, LWOOBJID targetCharacterId) override {};
std::string GetAuditLogTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override { return "{}"; };
private:
std::map<uint32_t, ZoneLock> m_ZoneLocks;
std::map<std::pair<uint32_t, uint32_t>, ZoneEditor> m_ZoneEditors;
};
#endif //!TESTSQLDATABASE_H

View File

@@ -0,0 +1,15 @@
-- Who may edit a zone's files (docs/ZonePublish.md): each zone's assigned editors, and the one account holding its
-- lock. A lock has no expiry: its holder releases it, or an admin does.
CREATE TABLE IF NOT EXISTS zone_edit_locks (
zone_id INT UNSIGNED NOT NULL PRIMARY KEY,
account_id INT NOT NULL REFERENCES accounts(id),
locked_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS zone_editors (
zone_id INT UNSIGNED NOT NULL,
account_id INT NOT NULL REFERENCES accounts(id),
assigned_by VARCHAR(64) NOT NULL DEFAULT '',
assigned_at BIGINT NOT NULL,
PRIMARY KEY (zone_id, account_id)
);

View File

@@ -0,0 +1,15 @@
-- Who may edit a zone's files (docs/ZonePublish.md): each zone's assigned editors, and the one account holding its
-- lock. A lock has no expiry: its holder releases it, or an admin does.
CREATE TABLE IF NOT EXISTS zone_edit_locks (
zone_id INTEGER NOT NULL PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id),
locked_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS zone_editors (
zone_id INTEGER NOT NULL,
account_id INTEGER NOT NULL REFERENCES accounts(id),
assigned_by TEXT NOT NULL DEFAULT '',
assigned_at BIGINT NOT NULL,
PRIMARY KEY (zone_id, account_id)
);

View File

@@ -455,3 +455,37 @@ TEST(DatabaseUsageSqlite, MeasuresTablesAndKeepsHistory) {
EXPECT_TRUE(g_Db->GetDatabaseUsageTables(1000).empty());
EXPECT_EQ(g_Db->GetDatabaseUsageTotals(0, 5000).size(), 1u);
}
// A zone's lock has one holder, taken in one statement; its editors are a set (docs/ZonePublish.md)
TEST(ZoneEditingSqliteTests, LocksAndEditors) {
auto& db = *g_Db;
EXPECT_FALSE(db.GetZoneLock(1900));
EXPECT_TRUE(db.TakeZoneLock(1900, 5, 1000));
EXPECT_FALSE(db.TakeZoneLock(1900, 6, 1001)); // held: the second taker gets nothing
auto lock = db.GetZoneLock(1900);
ASSERT_TRUE(lock);
EXPECT_EQ(lock->accountId, 5u);
EXPECT_EQ(lock->lockedAt, 1000);
EXPECT_TRUE(db.TakeZoneLock(1901, 6, 1002));
EXPECT_EQ(db.GetZoneLocks().size(), 2u);
EXPECT_TRUE(db.ReleaseZoneLock(1900));
EXPECT_FALSE(db.ReleaseZoneLock(1900));
EXPECT_TRUE(db.TakeZoneLock(1900, 6, 1003));
EXPECT_TRUE(db.ReleaseZoneLock(1900));
EXPECT_TRUE(db.ReleaseZoneLock(1901));
db.AddZoneEditor({ 1900, 5, "admin", 2000 });
db.AddZoneEditor({ 1900, 5, "other", 2001 });
db.AddZoneEditor({ 1900, 6, "admin", 2002 });
db.AddZoneEditor({ 1901, 5, "admin", 2003 });
const auto editors = db.GetZoneEditors(1900);
ASSERT_EQ(editors.size(), 2u);
EXPECT_EQ(editors[0].accountId, 5u);
EXPECT_EQ(editors[0].assignedBy, "admin");
EXPECT_EQ(db.GetZoneEditors(0).size(), 3u);
EXPECT_TRUE(db.IsZoneEditor(1900, 6));
EXPECT_FALSE(db.IsZoneEditor(1901, 6));
EXPECT_TRUE(db.RemoveZoneEditor(1900, 6));
EXPECT_FALSE(db.RemoveZoneEditor(1900, 6));
EXPECT_FALSE(db.IsZoneEditor(1900, 6));
}

View File

@@ -72,6 +72,8 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::Event, id, guildId, time, kind, char
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::GuildSummary, guild, memberCount, leaderId, leaderName);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::GuildPage, total, filtered, guilds);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IApiKeys::ApiKey, id, accountId, name, note, keyHash, keyPrefix, permissions, readOnly, allowedIps, allowedPaths, rateLimit, dailyQuota, createdAt, createdBy, issuedAt, expiresAt, revokedAt, revokedBy, lastUsedAt, lastIp, requestCount, quotaDay, dayCount);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IZoneEditing::ZoneLock, zoneId, accountId, lockedAt);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IZoneEditing::ZoneEditor, zoneId, accountId, assignedBy, assignedAt);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ICharacterSnapshots::CharacterSnapshot, id, characterId, takenAt, reason, actor, size, hash, compressed);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IChatLog::ChatMessage, id, time, channel, senderId, senderName, accountId, recipientId, recipientName, zoneId, instanceId, cloneId, message, blocked, guildId, teamId, filtered);
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IChatLog::WhisperPartner, characterId, name, messages, firstTime, lastTime);
@@ -1414,6 +1416,26 @@ TEST_F(ParitySeeded, ApiKeys) {
Both("RevokeAccountApiKeys", [](GameDatabase& db) { return json{ db.RevokeAccountApiKeys(2, "bob", 1700001100), db.GetApiKeys(2), db.GetApiKeys(1) }; });
}
TEST_F(ParitySeeded, ZoneEditing) {
Both("AddZoneEditor", [](GameDatabase& db) {
db.AddZoneEditor({ 1100, 2, "alice", 1700000000 });
db.AddZoneEditor({ 1100, 2, "again", 1700000050 }); // already one: kept as it was
db.AddZoneEditor({ 1100, 1, "alice", 1700000100 });
db.AddZoneEditor({ 1200, 2, "alice", 1700000200 });
return json{ db.GetZoneEditors(1100), db.GetZoneEditors(0), db.GetZoneEditors(1300), db.IsZoneEditor(1100, 2), db.IsZoneEditor(1200, 1) };
});
Both("TakeZoneLock", [](GameDatabase& db) {
return json{ db.TakeZoneLock(1100, 2, 1700000300), db.TakeZoneLock(1100, 1, 1700000400), db.TakeZoneLock(1200, 1, 1700000500),
db.GetZoneLock(1100), db.GetZoneLock(1300), db.GetZoneLocks() };
});
Both("ReleaseZoneLock", [](GameDatabase& db) {
return json{ db.ReleaseZoneLock(1100), db.ReleaseZoneLock(1100), db.GetZoneLock(1100), db.TakeZoneLock(1100, 1, 1700000600), db.GetZoneLocks() };
});
Both("RemoveZoneEditor", [](GameDatabase& db) {
return json{ db.RemoveZoneEditor(1100, 2), db.RemoveZoneEditor(1100, 2), db.GetZoneEditors(0) };
});
}
TEST_F(ParitySeeded, Moderation) {
Both("InsertPlayerReport", [](GameDatabase& db) {
IModeration::PlayerReport report;