feat: keep every brick by brick save before it is split into models

The save is stored as the client sent it in the new ugc_unsplit table, and each model split from it points back to it (ugc.unsplit_id), so the whole build can be looked at or restored later. A save is kept while any model split from it exists: deleting models removes saves no model points to any more, and deleting a character removes its saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-10-16 20:46:33 -05:00
parent d870207a9f
commit 4bbf22f912
12 changed files with 78 additions and 13 deletions

View File

@@ -1,6 +1,7 @@
#ifndef __IPROPERTIESCONTENTS__H__
#define __IPROPERTIESCONTENTS__H__
#include <optional>
#include <array>
#include <cstdint>
#include <string_view>
@@ -22,7 +23,7 @@ public:
LWOOBJID placedBy{};
};
// Inserts a new UGC model into the database.
// Inserts a new UGC model into the database. unsplitId is the save it was split from (InsertUnsplitUgcModel), if any.
virtual void InsertNewUgcModel(
std::stringstream& sd0Data,
const uint64_t blueprintId,
@@ -30,7 +31,11 @@ public:
const LWOOBJID characterId,
// Unix seconds before which the UGC server doesn't make it (0: right away). The owner's other models waiting
// to be made wait until then too (they may be edited again).
const int64_t processAfter) = 0;
const int64_t processAfter,
const std::optional<LWOOBJID> unsplitId = std::nullopt) = 0;
// Keeps a brick by brick save as the client sent it, before it is split into models.
virtual void InsertUnsplitUgcModel(std::stringstream& sd0Data, const LWOOBJID id, const uint32_t accountId, const LWOOBJID characterId) = 0;
// Get the property models for the given property id.
virtual std::vector<IPropertyContents::Model> GetPropertyModels(const LWOOBJID& propertyId) = 0;

View File

@@ -107,6 +107,7 @@ void MySQLDatabase::DeleteCharacter(const LWOOBJID characterId) {
ExecuteDelete("DELETE FROM properties_contents WHERE property_id IN (SELECT id FROM properties WHERE owner_id=?);", characterId);
ExecuteDelete("DELETE FROM properties WHERE owner_id=?;", characterId);
ExecuteDelete("DELETE FROM ugc WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM ugc_unsplit WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM activity_log WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM mail WHERE receiver_id=?;", characterId);
ExecuteDelete("DELETE FROM ignore_list WHERE player_id=? OR ignored_player_id=?;", characterId, characterId);

View File

@@ -404,7 +404,9 @@ public:
const uint64_t blueprintId,
const uint32_t accountId,
const LWOOBJID characterId,
const int64_t processAfter) override;
const int64_t processAfter,
const std::optional<LWOOBJID> unsplitId) override;
void InsertUnsplitUgcModel(std::stringstream& sd0Data, const LWOOBJID id, const uint32_t accountId, const LWOOBJID characterId) override;
std::vector<MailInfo> GetMailForPlayer(const LWOOBJID characterId, const uint32_t numberOfMail) override;
std::optional<MailInfo> GetMail(const uint64_t mailId) override;
uint32_t GetUnreadMailCount(const LWOOBJID characterId) override;

View File

@@ -64,6 +64,8 @@ std::vector<IUgc::Model> MySQLDatabase::GetAllUgcModels() {
void MySQLDatabase::RemoveUnreferencedUgcModels() {
ExecuteDelete("DELETE FROM ugc WHERE id NOT IN (SELECT ugc_id FROM properties_contents WHERE ugc_id IS NOT NULL);");
// An unsplit save is kept while any model split from it is
ExecuteDelete("DELETE FROM ugc_unsplit WHERE id NOT IN (SELECT unsplit_id FROM ugc WHERE unsplit_id IS NOT NULL);");
}
void MySQLDatabase::InsertNewUgcModel(
@@ -71,10 +73,11 @@ void MySQLDatabase::InsertNewUgcModel(
const uint64_t blueprintId,
const uint32_t accountId,
const LWOOBJID characterId,
const int64_t processAfter) {
const int64_t processAfter,
const std::optional<LWOOBJID> unsplitId) {
const std::istream stream(sd0Data.rdbuf());
ExecuteInsert(
"INSERT INTO `ugc`(`id`, `account_id`, `character_id`, `is_optimized`, `lxfml`, `bake_ao`, `filename`, `process_after`, `queued`) VALUES (?,?,?,?,?,?,?,?,1)",
"INSERT INTO `ugc`(`id`, `account_id`, `character_id`, `is_optimized`, `lxfml`, `bake_ao`, `filename`, `process_after`, `queued`, `unsplit_id`) VALUES (?,?,?,?,?,?,?,?,1,?)",
blueprintId,
accountId,
characterId,
@@ -82,15 +85,22 @@ void MySQLDatabase::InsertNewUgcModel(
&stream,
false,
"weedeater.lxfml",
processAfter
processAfter,
unsplitId
);
// The owner is still building: their other models waiting for their quiet period wait longer
if (processAfter > 0) ExecuteUpdate("UPDATE ugc SET process_after = ? WHERE character_id = ? AND is_optimized = 0 AND process_after > 0 AND process_after < ?;", processAfter, characterId, processAfter);
}
void MySQLDatabase::InsertUnsplitUgcModel(std::stringstream& sd0Data, const LWOOBJID id, const uint32_t accountId, const LWOOBJID characterId) {
const std::istream stream(sd0Data.rdbuf());
ExecuteInsert("INSERT INTO `ugc_unsplit`(`id`, `account_id`, `character_id`, `lxfml`) VALUES (?,?,?,?)", id, accountId, characterId, &stream);
}
void MySQLDatabase::DeleteUgcModelData(const LWOOBJID& modelId) {
ExecuteDelete("DELETE FROM ugc WHERE id = ?;", modelId);
ExecuteDelete("DELETE FROM properties_contents WHERE ugc_id = ?;", modelId);
ExecuteDelete("DELETE FROM ugc_unsplit WHERE id NOT IN (SELECT unsplit_id FROM ugc WHERE unsplit_id IS NOT NULL);");
}
void MySQLDatabase::UpdateUgcModelData(const LWOOBJID& modelId, std::stringstream& lxfml, const int64_t processAfter) {

View File

@@ -71,6 +71,7 @@ void SQLiteDatabase::DeleteCharacter(const LWOOBJID characterId) {
ExecuteDelete("DELETE FROM properties_contents WHERE property_id IN (SELECT id FROM properties WHERE owner_id=?);", characterId);
ExecuteDelete("DELETE FROM properties WHERE owner_id=?;", characterId);
ExecuteDelete("DELETE FROM ugc WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM ugc_unsplit WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM activity_log WHERE character_id=?;", characterId);
ExecuteDelete("DELETE FROM mail WHERE receiver_id=?;", characterId);
ExecuteDelete("DELETE FROM ignore_list WHERE player_id=? OR ignored_player_id=?;", characterId, characterId);

View File

@@ -388,7 +388,9 @@ public:
const uint64_t blueprintId,
const uint32_t accountId,
const LWOOBJID characterId,
const int64_t processAfter) override;
const int64_t processAfter,
const std::optional<LWOOBJID> unsplitId) override;
void InsertUnsplitUgcModel(std::stringstream& sd0Data, const LWOOBJID id, const uint32_t accountId, const LWOOBJID characterId) override;
std::vector<MailInfo> GetMailForPlayer(const LWOOBJID characterId, const uint32_t numberOfMail) override;
std::optional<MailInfo> GetMail(const uint64_t mailId) override;
uint32_t GetUnreadMailCount(const LWOOBJID characterId) override;

View File

@@ -65,6 +65,8 @@ std::vector<IUgc::Model> SQLiteDatabase::GetAllUgcModels() {
void SQLiteDatabase::RemoveUnreferencedUgcModels() {
ExecuteDelete("DELETE FROM ugc WHERE id NOT IN (SELECT ugc_id FROM properties_contents WHERE ugc_id IS NOT NULL);");
// An unsplit save is kept while any model split from it is
ExecuteDelete("DELETE FROM ugc_unsplit WHERE id NOT IN (SELECT unsplit_id FROM ugc WHERE unsplit_id IS NOT NULL);");
}
void SQLiteDatabase::InsertNewUgcModel(
@@ -72,10 +74,11 @@ void SQLiteDatabase::InsertNewUgcModel(
const uint64_t blueprintId,
const uint32_t accountId,
const LWOOBJID characterId,
const int64_t processAfter) {
const int64_t processAfter,
const std::optional<LWOOBJID> unsplitId) {
const std::istream stream(sd0Data.rdbuf());
ExecuteInsert(
"INSERT INTO `ugc`(`id`, `account_id`, `character_id`, `is_optimized`, `lxfml`, `bake_ao`, `filename`, `process_after`, `queued`) VALUES (?,?,?,?,?,?,?,?,1)",
"INSERT INTO `ugc`(`id`, `account_id`, `character_id`, `is_optimized`, `lxfml`, `bake_ao`, `filename`, `process_after`, `queued`, `unsplit_id`) VALUES (?,?,?,?,?,?,?,?,1,?)",
blueprintId,
accountId,
characterId,
@@ -83,15 +86,22 @@ void SQLiteDatabase::InsertNewUgcModel(
&stream,
false,
"weedeater.lxfml",
processAfter
processAfter,
unsplitId
);
// The owner is still building: their other models waiting for their quiet period wait longer
if (processAfter > 0) ExecuteUpdate("UPDATE ugc SET process_after = ? WHERE character_id = ? AND is_optimized = 0 AND process_after > 0 AND process_after < ?;", processAfter, characterId, processAfter);
}
void SQLiteDatabase::InsertUnsplitUgcModel(std::stringstream& sd0Data, const LWOOBJID id, const uint32_t accountId, const LWOOBJID characterId) {
const std::istream stream(sd0Data.rdbuf());
ExecuteInsert("INSERT INTO `ugc_unsplit`(`id`, `account_id`, `character_id`, `lxfml`) VALUES (?,?,?,?)", id, accountId, characterId, &stream);
}
void SQLiteDatabase::DeleteUgcModelData(const LWOOBJID& modelId) {
ExecuteDelete("DELETE FROM ugc WHERE id = ?;", modelId);
ExecuteDelete("DELETE FROM properties_contents WHERE ugc_id = ?;", modelId);
ExecuteDelete("DELETE FROM ugc_unsplit WHERE id NOT IN (SELECT unsplit_id FROM ugc WHERE unsplit_id IS NOT NULL);");
}
void SQLiteDatabase::UpdateUgcModelData(const LWOOBJID& modelId, std::stringstream& lxfml, const int64_t processAfter) {

View File

@@ -192,7 +192,11 @@ void TestSQLDatabase::InsertNewMail(const MailInfo& mail) {
}
void TestSQLDatabase::InsertNewUgcModel(std::stringstream& sd0Data, const uint64_t blueprintId, const uint32_t accountId, const LWOOBJID characterId, const int64_t processAfter) {
void TestSQLDatabase::InsertNewUgcModel(std::stringstream& sd0Data, const uint64_t blueprintId, const uint32_t accountId, const LWOOBJID characterId, const int64_t processAfter, const std::optional<LWOOBJID> unsplitId) {
}
void TestSQLDatabase::InsertUnsplitUgcModel(std::stringstream& sd0Data, const LWOOBJID id, const uint32_t accountId, const LWOOBJID characterId) {
}

View File

@@ -68,7 +68,9 @@ class TestSQLDatabase : public GameDatabase {
const uint64_t blueprintId,
const uint32_t accountId,
const LWOOBJID characterId,
const int64_t processAfter) override;
const int64_t processAfter,
const std::optional<LWOOBJID> unsplitId) override;
void InsertUnsplitUgcModel(std::stringstream& sd0Data, const LWOOBJID id, const uint32_t accountId, const LWOOBJID characterId) override;
std::vector<MailInfo> GetMailForPlayer(const LWOOBJID characterId, const uint32_t numberOfMail) override;
std::optional<MailInfo> GetMail(const uint64_t mailId) override;
uint32_t GetUnreadMailCount(const LWOOBJID characterId) override;

View File

@@ -115,6 +115,12 @@ namespace {
outFile << lxfml;
}
// Keep the save as the client sent it before splitting it; every model made from it points back to it
const auto accountId = character->GetParentUser()->GetAccountID();
const LWOOBJID unsplitId = ObjectIDManager::GetPersistentID();
std::stringstream unsplitData(sd0Data);
Database::Get()->InsertUnsplitUgcModel(unsplitData, unsplitId, accountId, character->GetID());
// A quiet period before the UGC server makes the models (ugc_debounce_seconds, sharedconfig.ini): a model
// the owner keeps editing isn't made for every save (docs/UgcServer.md)
const auto debounce = Game::config ? GeneralUtils::TryParse<int64_t>(Game::config->GetValue("ugc_debounce_seconds")).value_or(120) : 120;
@@ -125,7 +131,7 @@ namespace {
Sd0 model = sd0;
model.FromData(reinterpret_cast<const uint8_t*>(part.lxfml.data()), part.lxfml.size());
auto stream = model.GetAsStream();
Database::Get()->InsertNewUgcModel(stream, blueprintId, character->GetParentUser()->GetAccountID(), character->GetID(), processAfter);
Database::Get()->InsertNewUgcModel(stream, blueprintId, accountId, character->GetID(), processAfter, unsplitId);
auto& entry = saved.emplace_back();
entry.modelId = modelId;

View File

@@ -0,0 +1,11 @@
-- Every brick by brick save as the client sent it (sd0), before the server splits it into one model per group of
-- connected bricks; each model made from it points back to it
CREATE TABLE IF NOT EXISTS ugc_unsplit (
id BIGINT NOT NULL PRIMARY KEY,
account_id INT NOT NULL REFERENCES accounts(id),
character_id BIGINT NOT NULL REFERENCES charinfo(id),
lxfml MEDIUMBLOB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE ugc ADD COLUMN unsplit_id BIGINT NULL DEFAULT NULL;

View File

@@ -0,0 +1,11 @@
-- Every brick by brick save as the client sent it (sd0), before the server splits it into one model per group of
-- connected bricks; each model made from it points back to it
CREATE TABLE IF NOT EXISTS ugc_unsplit (
id BIGINT NOT NULL PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id),
character_id BIGINT NOT NULL REFERENCES charinfo(id),
lxfml BLOB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE ugc ADD COLUMN unsplit_id BIGINT NULL DEFAULT NULL;