diff --git a/dDatabase/GameDatabase/GameDatabase.h b/dDatabase/GameDatabase/GameDatabase.h index 8a5435ceb..7058c7389 100644 --- a/dDatabase/GameDatabase/GameDatabase.h +++ b/dDatabase/GameDatabase/GameDatabase.h @@ -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. diff --git a/dDatabase/GameDatabase/ITables/IZoneEditing.h b/dDatabase/GameDatabase/ITables/IZoneEditing.h new file mode 100644 index 000000000..47739c74b --- /dev/null +++ b/dDatabase/GameDatabase/ITables/IZoneEditing.h @@ -0,0 +1,45 @@ +#ifndef __IZONEEDITING__H__ +#define __IZONEEDITING__H__ + +#include +#include +#include +#include + +/** + * 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 GetZoneLock(uint32_t zoneId) = 0; + // Every held lock, by zone + virtual std::vector 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 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__ diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h index bd7ecd0db..16346f2f9 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h @@ -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 GetZoneLock(uint32_t zoneId) override; + std::vector GetZoneLocks() override; + bool TakeZoneLock(uint32_t zoneId, uint32_t accountId, int64_t time) override; + bool ReleaseZoneLock(uint32_t zoneId) override; + std::vector 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 GetContrabandItems() override; void SetContrabandItem(const ContrabandItem& item) override; diff --git a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt index 32765ee33..2c88fc3d7 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt @@ -22,6 +22,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES "LiveOps.cpp" "FeaturedProperties.cpp" "Contraband.cpp" + "ZoneEditing.cpp" "PropertyRent.cpp" "PropertyReputation.cpp" "BbbAutosave.cpp" diff --git a/dDatabase/GameDatabase/MySQL/Tables/ZoneEditing.cpp b/dDatabase/GameDatabase/MySQL/Tables/ZoneEditing.cpp new file mode 100644 index 000000000..1d26a9127 --- /dev/null +++ b/dDatabase/GameDatabase/MySQL/Tables/ZoneEditing.cpp @@ -0,0 +1,46 @@ +#include "MySQLDatabase.h" + +std::optional 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 MySQLDatabase::GetZoneLocks() { + std::vector 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 MySQLDatabase::GetZoneEditors(uint32_t zoneId) { + std::vector 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; +} diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h index 287e69bfb..6f320675a 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h @@ -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 GetZoneLock(uint32_t zoneId) override; + std::vector GetZoneLocks() override; + bool TakeZoneLock(uint32_t zoneId, uint32_t accountId, int64_t time) override; + bool ReleaseZoneLock(uint32_t zoneId) override; + std::vector 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 GetContrabandItems() override; void SetContrabandItem(const ContrabandItem& item) override; diff --git a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt index 3f78c51b3..8fe482e16 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt @@ -22,6 +22,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES "LiveOps.cpp" "FeaturedProperties.cpp" "Contraband.cpp" + "ZoneEditing.cpp" "PropertyRent.cpp" "PropertyReputation.cpp" "BbbAutosave.cpp" diff --git a/dDatabase/GameDatabase/SQLite/Tables/ZoneEditing.cpp b/dDatabase/GameDatabase/SQLite/Tables/ZoneEditing.cpp new file mode 100644 index 000000000..221216560 --- /dev/null +++ b/dDatabase/GameDatabase/SQLite/Tables/ZoneEditing.cpp @@ -0,0 +1,49 @@ +#include "SQLiteDatabase.h" + +std::optional 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(result.getIntField("zone_id")), static_cast(result.getIntField("account_id")), result.getInt64Field("locked_at") }; +} + +std::vector SQLiteDatabase::GetZoneLocks() { + std::vector 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(result.getIntField("zone_id")), static_cast(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 SQLiteDatabase::GetZoneEditors(uint32_t zoneId) { + std::vector 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(result.getIntField("zone_id")), static_cast(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; +} diff --git a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h index 1cb50685a..3ef8c8ca6 100644 --- a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h +++ b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h @@ -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 GetZoneLock(uint32_t zoneId) override { + const auto it = m_ZoneLocks.find(zoneId); + return it == m_ZoneLocks.end() ? std::nullopt : std::optional(it->second); + } + std::vector GetZoneLocks() override { + std::vector 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 GetZoneEditors(uint32_t zoneId) override { + std::vector 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 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 m_ZoneLocks; + std::map, ZoneEditor> m_ZoneEditors; }; #endif //!TESTSQLDATABASE_H diff --git a/migrations/dlu/mysql/107_zone_editing.sql b/migrations/dlu/mysql/107_zone_editing.sql new file mode 100644 index 000000000..851159f4e --- /dev/null +++ b/migrations/dlu/mysql/107_zone_editing.sql @@ -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) +); diff --git a/migrations/dlu/sqlite/90_zone_editing.sql b/migrations/dlu/sqlite/90_zone_editing.sql new file mode 100644 index 000000000..8ac046146 --- /dev/null +++ b/migrations/dlu/sqlite/90_zone_editing.sql @@ -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) +); diff --git a/tests/dDatabaseTests/ChatHistorySqliteTests.cpp b/tests/dDatabaseTests/ChatHistorySqliteTests.cpp index ee3044d55..94945396b 100644 --- a/tests/dDatabaseTests/ChatHistorySqliteTests.cpp +++ b/tests/dDatabaseTests/ChatHistorySqliteTests.cpp @@ -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)); +} diff --git a/tests/dDatabaseTests/DatabaseParityTests.cpp b/tests/dDatabaseTests/DatabaseParityTests.cpp index 57eeec1d0..ea5c43994 100644 --- a/tests/dDatabaseTests/DatabaseParityTests.cpp +++ b/tests/dDatabaseTests/DatabaseParityTests.cpp @@ -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;