From 9256b31b231e925bb756307933ac66bce9d4b4a8 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 04:08:19 -0500 Subject: [PATCH] feat(db): bbb_autosave table for the client's BBB autosave The client sends SetBBBAutosave (996) with the model being built every five minutes, before an AFK kick and before quitting, and expects the server to rebuild an unfinished model later (RebuildBBBAutosaveMsg). This keeps the last one per character with the model items that were in the BBB inventory at the time. MySQL 82 / SQLite 65; parity test included. Refs #1632 Co-Authored-By: Claude Opus 5.5 --- dDatabase/GameDatabase/GameDatabase.h | 3 +- .../GameDatabase/ITables/BbbAutosaveItems.h | 38 +++++++++++++++++++ dDatabase/GameDatabase/ITables/IBbbAutosave.h | 30 +++++++++++++++ dDatabase/GameDatabase/MySQL/MySQLDatabase.h | 5 +++ .../GameDatabase/MySQL/Tables/BbbAutosave.cpp | 30 +++++++++++++++ .../GameDatabase/MySQL/Tables/CMakeLists.txt | 1 + .../GameDatabase/SQLite/SQLiteDatabase.h | 5 +++ .../SQLite/Tables/BbbAutosave.cpp | 29 ++++++++++++++ .../GameDatabase/SQLite/Tables/CMakeLists.txt | 1 + .../GameDatabase/TestSQL/TestSQLDatabase.h | 11 ++++++ migrations/dlu/mysql/82_bbb_autosave.sql | 9 +++++ migrations/dlu/sqlite/65_bbb_autosave.sql | 7 ++++ tests/dDatabaseTests/DatabaseParityTests.cpp | 19 ++++++++++ 13 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 dDatabase/GameDatabase/ITables/BbbAutosaveItems.h create mode 100644 dDatabase/GameDatabase/ITables/IBbbAutosave.h create mode 100644 dDatabase/GameDatabase/MySQL/Tables/BbbAutosave.cpp create mode 100644 dDatabase/GameDatabase/SQLite/Tables/BbbAutosave.cpp create mode 100644 migrations/dlu/mysql/82_bbb_autosave.sql create mode 100644 migrations/dlu/sqlite/65_bbb_autosave.sql diff --git a/dDatabase/GameDatabase/GameDatabase.h b/dDatabase/GameDatabase/GameDatabase.h index acbc2d6fa..922843aa8 100644 --- a/dDatabase/GameDatabase/GameDatabase.h +++ b/dDatabase/GameDatabase/GameDatabase.h @@ -50,6 +50,7 @@ #include "IContraband.h" #include "IPropertyRent.h" #include "IPropertyReputation.h" +#include "IBbbAutosave.h" #ifdef _DEBUG # define DLU_SQL_TRY_CATCH_RETHROW(x) do { try { x; } catch (std::exception& ex) { LOG("SQL Error: %s", ex.what()); throw; } } while(0) @@ -63,7 +64,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 IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation { + 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 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: virtual ~GameDatabase() = default; // TODO: These should be made private. diff --git a/dDatabase/GameDatabase/ITables/BbbAutosaveItems.h b/dDatabase/GameDatabase/ITables/BbbAutosaveItems.h new file mode 100644 index 000000000..af098f385 --- /dev/null +++ b/dDatabase/GameDatabase/ITables/BbbAutosaveItems.h @@ -0,0 +1,38 @@ +#ifndef __BBBAUTOSAVEITEMS__H__ +#define __BBBAUTOSAVEITEMS__H__ + +#include +#include +#include +#include + +#include "dCommonVars.h" + +// bbb_autosave.source_items: the item ids as a comma separated list +namespace BbbAutosaveItems { + inline std::string Join(const std::vector& items) { + std::string out; + for (const auto id : items) { + if (!out.empty()) out += ','; + out += std::to_string(id); + } + return out; + } + + inline std::vector Parse(const std::string_view text) { + std::vector items; + size_t start = 0; + while (start < text.size()) { + auto end = text.find(',', start); + if (end == std::string_view::npos) end = text.size(); + const std::string part(text.substr(start, end - start)); + char* parsedEnd = nullptr; + const auto id = std::strtoll(part.c_str(), &parsedEnd, 10); + if (parsedEnd != part.c_str() && id != 0) items.push_back(id); + start = end + 1; + } + return items; + } +} + +#endif //!__BBBAUTOSAVEITEMS__H__ diff --git a/dDatabase/GameDatabase/ITables/IBbbAutosave.h b/dDatabase/GameDatabase/ITables/IBbbAutosave.h new file mode 100644 index 000000000..27d6082cd --- /dev/null +++ b/dDatabase/GameDatabase/ITables/IBbbAutosave.h @@ -0,0 +1,30 @@ +#ifndef __IBBBAUTOSAVE__H__ +#define __IBBBAUTOSAVE__H__ + +#include +#include +#include +#include + +#include "dCommonVars.h" + +// The brick by brick model a character was building when the client last sent SetBBBAutosave (the client's quick save: +// every five minutes, before an AFK kick and before quitting). The server rebuilds it into a model when the build ends +// without a save (see docs/BuildWorkflow.md). +class IBbbAutosave { +public: + struct Info { + std::string lxfml; // sd0 compressed LXFML as the client sent it + std::vector sourceItems; // the models that were in the BBB inventory when it was sent + int64_t updatedAt{}; // Unix seconds + }; + + virtual std::optional GetBbbAutosave(const LWOOBJID characterId) = 0; + + // Replaces the character's autosave + virtual void SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) = 0; + + virtual void DeleteBbbAutosave(const LWOOBJID characterId) = 0; +}; + +#endif //!__IBBBAUTOSAVE__H__ diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h index 721ae54e7..5a88c5006 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h @@ -215,6 +215,11 @@ public: std::vector GetZoneLimits() override; void SetZoneLimit(const ZoneLimit& limit) override; void DeleteZoneLimit(uint32_t zoneId) override; + // IBbbAutosave + std::optional GetBbbAutosave(const LWOOBJID characterId) override; + void SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) override; + void DeleteBbbAutosave(const LWOOBJID characterId) override; + // IPropertyReputation VisitorHistory GetPropertyVisitorHistory(LWOOBJID propertyId, uint32_t accountId, uint32_t day, uint32_t days) override; int64_t GetPropertyReputationOnDay(LWOOBJID propertyId, uint32_t day) override; diff --git a/dDatabase/GameDatabase/MySQL/Tables/BbbAutosave.cpp b/dDatabase/GameDatabase/MySQL/Tables/BbbAutosave.cpp new file mode 100644 index 000000000..91aea1f0e --- /dev/null +++ b/dDatabase/GameDatabase/MySQL/Tables/BbbAutosave.cpp @@ -0,0 +1,30 @@ +#include "MySQLDatabase.h" + +#include "BbbAutosaveItems.h" + +std::optional MySQLDatabase::GetBbbAutosave(const LWOOBJID characterId) { + auto result = ExecuteSelect("SELECT lxfml, source_items, updated_at FROM bbb_autosave WHERE character_id = ?;", characterId); + if (!result->next()) return std::nullopt; + + IBbbAutosave::Info info; + std::unique_ptr blob(result->getBlob("lxfml")); + std::stringstream data; + if (blob) data << blob->rdbuf(); + info.lxfml = data.str(); + info.sourceItems = BbbAutosaveItems::Parse(result->getString("source_items").c_str()); + info.updatedAt = result->getInt64("updated_at"); + return info; +} + +void MySQLDatabase::SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) { + std::istringstream lxfml(info.lxfml); + const auto items = BbbAutosaveItems::Join(info.sourceItems); + ExecuteInsert( + "INSERT INTO bbb_autosave (character_id, lxfml, source_items, updated_at) VALUES (?, ?, ?, ?) " + "ON DUPLICATE KEY UPDATE lxfml = VALUES(lxfml), source_items = VALUES(source_items), updated_at = VALUES(updated_at);", + characterId, static_cast(&lxfml), items, info.updatedAt); +} + +void MySQLDatabase::DeleteBbbAutosave(const LWOOBJID characterId) { + ExecuteDelete("DELETE FROM bbb_autosave WHERE character_id = ?;", characterId); +} diff --git a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt index 8384b6f2e..676ff817b 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt @@ -21,6 +21,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES "Contraband.cpp" "PropertyRent.cpp" "PropertyReputation.cpp" + "BbbAutosave.cpp" "MessageCaptures.cpp" "ChatLog.cpp" "RelatedData.cpp" diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h index 58d90a7d8..4fcb61205 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h @@ -199,6 +199,11 @@ public: std::vector GetZoneLimits() override; void SetZoneLimit(const ZoneLimit& limit) override; void DeleteZoneLimit(uint32_t zoneId) override; + // IBbbAutosave + std::optional GetBbbAutosave(const LWOOBJID characterId) override; + void SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) override; + void DeleteBbbAutosave(const LWOOBJID characterId) override; + // IPropertyReputation VisitorHistory GetPropertyVisitorHistory(LWOOBJID propertyId, uint32_t accountId, uint32_t day, uint32_t days) override; int64_t GetPropertyReputationOnDay(LWOOBJID propertyId, uint32_t day) override; diff --git a/dDatabase/GameDatabase/SQLite/Tables/BbbAutosave.cpp b/dDatabase/GameDatabase/SQLite/Tables/BbbAutosave.cpp new file mode 100644 index 000000000..4b847cfb2 --- /dev/null +++ b/dDatabase/GameDatabase/SQLite/Tables/BbbAutosave.cpp @@ -0,0 +1,29 @@ +#include "SQLiteDatabase.h" + +#include "BbbAutosaveItems.h" + +std::optional SQLiteDatabase::GetBbbAutosave(const LWOOBJID characterId) { + auto [_, result] = ExecuteSelect("SELECT lxfml, source_items, updated_at FROM bbb_autosave WHERE character_id = ?;", characterId); + if (result.eof()) return std::nullopt; + + IBbbAutosave::Info info; + int length{}; + const auto* blob = result.getBlobField("lxfml", length); + if (blob && length > 0) info.lxfml.assign(reinterpret_cast(blob), length); + info.sourceItems = BbbAutosaveItems::Parse(result.getStringField("source_items", "")); + info.updatedAt = result.getInt64Field("updated_at"); + return info; +} + +void SQLiteDatabase::SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) { + std::istringstream lxfml(info.lxfml); + const auto items = BbbAutosaveItems::Join(info.sourceItems); + ExecuteInsert( + "INSERT INTO bbb_autosave (character_id, lxfml, source_items, updated_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(character_id) DO UPDATE SET lxfml = excluded.lxfml, source_items = excluded.source_items, updated_at = excluded.updated_at;", + characterId, static_cast(&lxfml), items, info.updatedAt); +} + +void SQLiteDatabase::DeleteBbbAutosave(const LWOOBJID characterId) { + ExecuteDelete("DELETE FROM bbb_autosave WHERE character_id = ?;", characterId); +} diff --git a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt index b2bad1825..475c21a2b 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt @@ -21,6 +21,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES "Contraband.cpp" "PropertyRent.cpp" "PropertyReputation.cpp" + "BbbAutosave.cpp" "MessageCaptures.cpp" "ChatLog.cpp" "RelatedData.cpp" diff --git a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h index 804cc4d60..b65e6b377 100644 --- a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h +++ b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h @@ -2,6 +2,7 @@ #define TESTSQLDATABASE_H #include "GameDatabase.h" +#include class TestSQLDatabase : public GameDatabase { void Connect() override; @@ -245,6 +246,16 @@ class TestSQLDatabase : public GameDatabase { std::vector GetZoneLimits() override { return {}; } void SetZoneLimit(const ZoneLimit& limit) override {} void DeleteZoneLimit(uint32_t zoneId) override {} + // IBbbAutosave: kept in memory so the build workflow can be tested + std::optional GetBbbAutosave(const LWOOBJID characterId) override { + const auto it = m_BbbAutosaves.find(characterId); + if (it == m_BbbAutosaves.end()) return std::nullopt; + return it->second; + } + void SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) override { m_BbbAutosaves[characterId] = info; } + void DeleteBbbAutosave(const LWOOBJID characterId) override { m_BbbAutosaves.erase(characterId); } + std::map m_BbbAutosaves; + // IPropertyReputation VisitorHistory GetPropertyVisitorHistory(LWOOBJID propertyId, uint32_t accountId, uint32_t day, uint32_t days) override { return {}; } int64_t GetPropertyReputationOnDay(LWOOBJID propertyId, uint32_t day) override { return 0; } diff --git a/migrations/dlu/mysql/82_bbb_autosave.sql b/migrations/dlu/mysql/82_bbb_autosave.sql new file mode 100644 index 000000000..3b2bf9403 --- /dev/null +++ b/migrations/dlu/mysql/82_bbb_autosave.sql @@ -0,0 +1,9 @@ +/* The brick by brick model each character was building when their client last sent SetBBBAutosave, and the model items + that were in their BBB inventory at that time. Rebuilt into models when a build ends without a save (see + docs/BuildWorkflow.md). */ +CREATE TABLE IF NOT EXISTS bbb_autosave ( + character_id BIGINT NOT NULL PRIMARY KEY, + lxfml MEDIUMBLOB NOT NULL, + source_items TEXT NOT NULL, + updated_at BIGINT NOT NULL DEFAULT 0 +); diff --git a/migrations/dlu/sqlite/65_bbb_autosave.sql b/migrations/dlu/sqlite/65_bbb_autosave.sql new file mode 100644 index 000000000..1acb6a3a0 --- /dev/null +++ b/migrations/dlu/sqlite/65_bbb_autosave.sql @@ -0,0 +1,7 @@ +/* bbb_autosave: see the MySQL migration. */ +CREATE TABLE IF NOT EXISTS bbb_autosave ( + character_id BIGINT NOT NULL PRIMARY KEY, + lxfml BLOB NOT NULL, + source_items TEXT NOT NULL, + updated_at BIGINT NOT NULL DEFAULT 0 +); diff --git a/tests/dDatabaseTests/DatabaseParityTests.cpp b/tests/dDatabaseTests/DatabaseParityTests.cpp index 6867144a3..8d128ec3c 100644 --- a/tests/dDatabaseTests/DatabaseParityTests.cpp +++ b/tests/dDatabaseTests/DatabaseParityTests.cpp @@ -1506,6 +1506,25 @@ TEST_F(ParitySeeded, PropertyReputation) { Both("GetPropertyReputationDays", [](GameDatabase& db) { return db.GetPropertyReputationDays(PROP_REP, 19980); }); } +TEST_F(ParitySeeded, BbbAutosave) { + std::string binary = "sd0\x01\xff"; + for (int i = 0; i < 256; i++) binary += static_cast(i); + const auto describe = [](const std::optional& info) { + if (!info) return json(nullptr); + return json{ info->lxfml, info->sourceItems, info->updatedAt }; + }; + EXPECT_EQ(Both("GetBbbAutosave empty", [&](GameDatabase& db) { return describe(db.GetBbbAutosave(CHAR_BOB)); }), json(nullptr)); + Both("SetBbbAutosave", [&](GameDatabase& db) { + db.SetBbbAutosave(CHAR_BOB, { "first", { 1152921510000000001LL }, 1700000000 }); + db.SetBbbAutosave(CHAR_BOB, { binary, { 1152921510000000002LL, 1152921510000000003LL }, 1700000001 }); + return describe(db.GetBbbAutosave(CHAR_BOB)); + }); + EXPECT_EQ(Both("DeleteBbbAutosave", [&](GameDatabase& db) { + db.DeleteBbbAutosave(CHAR_BOB); + return describe(db.GetBbbAutosave(CHAR_BOB)); + }), json(nullptr)); +} + TEST_F(ParitySeeded, Contraband) { Both("GetContrabandItems empty", [](GameDatabase& db) { return db.GetContrabandItems(); }); Both("SetContrabandItem", [](GameDatabase& db) {