From 6ce261c58c1114c48f296912bdc12b7a82b67472 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 04:21:48 -0500 Subject: [PATCH] fix: brick by brick and model placement work the way the client expects Mapped from the 1.10.64 client and live captures (docs/BuildWorkflow.md). Model placement (PropertyManagementComponent): - A brick built model placed from the inventory spawned at the world origin with no rotation and without PlaceModelResponse/PreCreate, and was saved to properties_contents with ugc_id 0; it is now placed where the client put it, keeps its UGID and blueprint and is saved with them. - Picking up, putting away and taking apart a brick built model gave it to MODELS_IN_BBB and then deleted it; every way off the property now puts it in MODELS (carried when picked up), as live, with its blueprint config. Taking a premade model apart no longer deletes it either. - Placing and removing a model saves the property at once, so a crash or a disconnect before PropertyEditorEnd does not lose it. - DoneArrangingWithItem only answers when something new is picked (not when leaving), with the subject as build area; SetBuildModeConfirmed's warnVisitors matches live. Brick by brick (BrickByBrick): - BBBLoadItemRequest moves the model to MODELS_IN_BBB keeping its id and fails cleanly when the player has no such model. - MoveInventoryBatch moves bricks between BRICKS and BRICKS_IN_BBB (it was not handled, so the client and server disagreed until a relog). - BBBSaveRequest uses up the opened models, places the new ones through the property, returns the bricks (or uses them with bbb_consume_bricks=1), clears the autosave and sends RequeryPropertyModels. Every save makes new ugc rows (is_optimized 0, so the UGC server processes them). - Quick save: SetBBBAutosave is stored per character (bbb_autosave). - UnUseBBBModel puts a model back on the property where it was when it came from there, otherwise back in MODELS. - Leaving brick mode without a save, a disconnect or a crash: the autosave is rebuilt into models (RebuildBBBAutosaveMsg) or the opened models go back to MODELS. MODELS_IN_BBB is saved with the character now and loads into MODELS, BRICKS_IN_BBB into BRICKS. Fixes #1632 Fixes #159 Fixes #1565 Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/SettingsCatalog.cpp | 1 + dGame/dComponents/InventoryComponent.cpp | 16 +- .../PropertyManagementComponent.cpp | 421 +++++++----------- .../dComponents/PropertyManagementComponent.h | 20 + dGame/dGameMessages/BuildingMessages.cpp | 234 +++------- dGame/dGameMessages/BuildingMessages.h | 36 ++ dGame/dGameMessages/GameMessageHandler.cpp | 3 + dGame/dGameMessages/InventoryMessages.cpp | 5 + dGame/dGameMessages/InventoryMessages.h | 4 + dGame/dGameMessages/PropertyMessages.h | 6 + dGame/dGameMessages/ZoneMessages.cpp | 5 + dGame/dUtilities/BrickByBrick.cpp | 389 ++++++++++++++++ dGame/dUtilities/BrickByBrick.h | 106 +++++ dGame/dUtilities/CMakeLists.txt | 1 + resources/worldconfig.ini | 3 + tests/dGameTests/BrickByBrickTests.cpp | 121 +++++ tests/dGameTests/CMakeLists.txt | 1 + .../BuildingMessagesTests.cpp | 49 ++ .../PropertyMessagesTests.cpp | 6 + 19 files changed, 976 insertions(+), 451 deletions(-) create mode 100644 dGame/dUtilities/BrickByBrick.cpp create mode 100644 dGame/dUtilities/BrickByBrick.h create mode 100644 tests/dGameTests/BrickByBrickTests.cpp diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 8c69031c7..16650b8c3 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -144,6 +144,7 @@ namespace { c.Add(Format(Text(SHARED, "dump_folder", "Crash dump folder", "Where crash logs go. Empty turns them off.", "", true), eFormat::PATH)); c.Add(Bool(WORLD, "generate_dump", "Crash dumps from world servers", "Write a dump when a world server crashes (needs the crash dump folder).", false, true)); c.Add(Bool(WORLD, "save_lxfmls", "Save model files", "Save players' models (LXFML) to disk before they are split, for debugging.", false)); + c.Add(Bool(WORLD, "bbb_consume_bricks", "Brick building uses bricks", "Saving a brick by brick model uses up the bricks in it, as live did. Off: the bricks go back to the backpack.", false)); c.Add(Bool(SHARED, "dont_generate_dcf", "Don't build the chat filter file", "Skip compiling the chat word list to a file.", false, true)); c.AddSection("Chat web API", "A small HTTP API on the chat server, on localhost only."); diff --git a/dGame/dComponents/InventoryComponent.cpp b/dGame/dComponents/InventoryComponent.cpp index 65e4cc6b7..d2639a3c5 100644 --- a/dGame/dComponents/InventoryComponent.cpp +++ b/dGame/dComponents/InventoryComponent.cpp @@ -1,4 +1,5 @@ #include "InventoryComponent.h" +#include "BrickByBrick.h" #include "Contraband.h" #include "EconomyLedger.h" @@ -663,6 +664,9 @@ void InventoryComponent::LoadXml(const tinyxml2::XMLDocument& document) { bag->QueryAttribute("t", &type); + // A build in progress does not survive a reload: what was in the BBB inventories is back in MODELS and BRICKS + const auto savedType = type; + type = BrickByBrick::InventoryToLoadInto(type); auto* inventory = GetInventory(static_cast(type)); if (inventory == nullptr) { @@ -696,6 +700,12 @@ void InventoryComponent::LoadXml(const tinyxml2::XMLDocument& document) { itemElement->QueryAttribute("parent", &parent); // End custom xml + if (savedType != type && !inventory->IsSlotEmpty(slot)) { + const auto freeSlot = inventory->FindEmptySlot(); + if (freeSlot == -1) inventory->SetSize(inventory->GetSize() + 1); + slot = freeSlot != -1 ? freeSlot : inventory->FindEmptySlot(); + } + auto* item = new Item(id, lot, inventory, slot, count, bound, {}, parent, subKey); item->LoadConfigXml(*itemElement); @@ -740,7 +750,9 @@ void InventoryComponent::UpdateXml(tinyxml2::XMLDocument& document) { for (const auto& pair : this->m_Inventories) { auto* inventory = pair.second; - static const auto EXCLUDED_INVENTORIES = { VENDOR_BUYBACK, MODELS_IN_BBB, ITEM_SETS }; + // MODELS_IN_BBB is saved so a model open in brick by brick building survives a disconnect or a crash; it loads + // back into MODELS (BrickByBrick::InventoryToLoadInto) + static const auto EXCLUDED_INVENTORIES = { VENDOR_BUYBACK, ITEM_SETS }; if (std::ranges::find(EXCLUDED_INVENTORIES, inventory->GetType()) != EXCLUDED_INVENTORIES.end()) { continue; } @@ -1438,7 +1450,7 @@ BehaviorSlot InventoryComponent::FindBehaviorSlot(const std::string& equipLocati bool InventoryComponent::IsTransferInventory(eInventoryType type, bool includeVault) { - return type == VENDOR_BUYBACK || (includeVault && (type == VAULT_ITEMS || type == VAULT_MODELS)) || type == TEMP_ITEMS || type == TEMP_MODELS || type == MODELS_IN_BBB; + return type == VENDOR_BUYBACK || (includeVault && (type == VAULT_ITEMS || type == VAULT_MODELS)) || type == TEMP_ITEMS || type == TEMP_MODELS || type == MODELS_IN_BBB || type == BRICKS_IN_BBB; } uint32_t InventoryComponent::FindSkill(const LOT lot) { diff --git a/dGame/dComponents/PropertyManagementComponent.cpp b/dGame/dComponents/PropertyManagementComponent.cpp index ec5e634fb..6847d48b1 100644 --- a/dGame/dComponents/PropertyManagementComponent.cpp +++ b/dGame/dComponents/PropertyManagementComponent.cpp @@ -32,6 +32,9 @@ #include "CppScripts.h" #include #include "dConfig.h" +#include "BrickByBrick.h" +#include "eLootSourceType.h" +#include "eKillType.h" PropertyManagementComponent* PropertyManagementComponent::instance = nullptr; @@ -327,129 +330,132 @@ void PropertyManagementComponent::OnFinishBuilding() { } } -void PropertyManagementComponent::UpdateModelPosition(const LWOOBJID id, const NiPoint3 position, NiQuaternion rotation) { - LOG("Placing model <%f, %f, %f>", position.x, position.y, position.z); - - auto* entity = GetOwner(); - - if (entity == nullptr) { - return; - } - - auto* inventoryComponent = entity->GetComponent(); - - if (inventoryComponent == nullptr) { - return; - } - - auto* item = inventoryComponent->FindItemById(id); - - if (item == nullptr) { - LOG("Failed to find item with id %d", id); - - return; - } - - NiQuaternion originalRotation = rotation; - - const auto modelLOT = item->GetLot(); - - if (rotation != QuatUtils::IDENTITY) { - rotation = { rotation.w, rotation.z, rotation.y, rotation.x }; - } - - if (item->GetLot() == 6662) { - LWOOBJID spawnerID = item->GetSubKey(); - - EntityInfo info; - info.lot = 14; - info.pos = {}; - info.rot = {}; - info.spawner = nullptr; - info.spawnerID = spawnerID; - info.spawnerNodeID = 0; - info.settings = item->GetConfig(); - - Entity* newEntity = Game::entityManager->CreateEntity(info); - if (newEntity != nullptr) { - Game::entityManager->ConstructEntity(newEntity); - auto* modelComponent = newEntity->GetComponent(); - if (modelComponent) modelComponent->Pause(); - - // Make sure the propMgmt doesn't delete our model after the server dies - // Trying to do this after the entity is constructed. Shouldn't really change anything but - // There was an issue with builds not appearing since it was placed above ConstructEntity. - PropertyManagementComponent::Instance()->AddModel(newEntity->GetObjectID(), spawnerID); - } - - item->SetCount(item->GetCount() - 1); - return; - } - - item->SetCount(item->GetCount() - 1); - +Entity* PropertyManagementComponent::SpawnModel(const LOT lot, const LWOOBJID modelId, const NiPoint3& position, const NiQuaternion& rotation, const LwoNameValue& config) { auto* node = new SpawnerNode(); - node->position = position; node->rotation = rotation; + node->config = config; + // Every placed model: a model object whose UGID is its property model id (GetModelsOnProperty pairs the two) + node->config.Insert(u"userModelID", modelId); + node->config.Insert(u"modelType", 2); + node->config.Insert(u"propertyObjectID", true); + node->config.Insert(u"componentWhitelist", 1); + if (lot != BrickByBrick::MODEL_OBJECT_LOT) node->config.Insert(u"modelBehaviors", 0); SpawnerInfo info{}; - - info.templateID = modelLOT; + info.templateID = lot; info.nodes = { node }; info.templateScale = 1.0f; info.activeOnLoad = true; info.amountMaintained = 1; info.respawnTime = 10; - info.emulated = true; info.emulator = Game::entityManager->GetZoneControlEntity()->GetObjectID(); - - info.spawnerID = ObjectIDManager::GetPersistentID(); - GeneralUtils::SetBit(info.spawnerID, eObjectBits::CLIENT); + // The spawner's id is the model's id in properties_contents + info.spawnerID = modelId; const auto spawnerId = Game::zoneManager->MakeSpawner(info); - auto* spawner = Game::zoneManager->GetSpawner(spawnerId); - - info.nodes[0]->config.Insert(u"modelBehaviors", 0); - info.nodes[0]->config.Insert(u"userModelID", info.spawnerID); - info.nodes[0]->config.Insert(u"modelType", 2); - info.nodes[0]->config.Insert(u"propertyObjectID", true); - info.nodes[0]->config.Insert(u"componentWhitelist", 1); + if (!spawner) return nullptr; auto* model = spawner->Spawn(); + if (!model) return nullptr; + + // Placed while the owner is editing: stays still until they finish (OnFinishBuilding resumes every model) auto* modelComponent = model->GetComponent(); if (modelComponent) modelComponent->Pause(); models.insert_or_assign(model->GetObjectID(), spawnerId); + return model; +} + +LWOOBJID PropertyManagementComponent::PlaceModelFromItem(Item& item, const NiPoint3& position, const NiQuaternion& rotation) { + LWOOBJID modelId = LWOOBJID_EMPTY; + Entity* model = nullptr; + + if (item.GetLot() == BrickByBrick::MODEL_ITEM_LOT) { + // A brick built model keeps its UGID (the item's subkey and userModelID) and blueprint wherever it goes + const auto& config = item.GetConfig(); + modelId = item.GetSubKey(); + if (modelId == LWOOBJID_EMPTY) modelId = BrickByBrick::ConfigObjectId(config, u"userModelID"); + if (modelId == LWOOBJID_EMPTY) modelId = ObjectIDManager::GetNewModelIDs().modelID; + const auto blueprintId = BrickByBrick::ConfigObjectId(config, u"blueprintid"); + if (blueprintId == LWOOBJID_EMPTY) { + LOG("Model item %llu has no blueprint, not placing it", item.GetId()); + return LWOOBJID_EMPTY; + } + + LwoNameValue modelConfig; + modelConfig.Insert(u"blueprintid", blueprintId); + const auto behaviors = config.find(u"userModelBehaviors"); + if (behaviors != config.end() && behaviors->second) modelConfig.Insert(u"userModelBehaviors", behaviors->second->GetValueAsString()); + model = SpawnModel(BrickByBrick::MODEL_OBJECT_LOT, modelId, position, rotation, modelConfig); + } else { + // A premade model gets a new UGID each time it is placed (live: the placed model's id, then a new item id + // when it is picked up) + modelId = ObjectIDManager::GetPersistentID(); + GeneralUtils::SetBit(modelId, eObjectBits::CLIENT); + model = SpawnModel(item.GetLot(), modelId, position, rotation, {}); + } + + if (!model) return LWOOBJID_EMPTY; + + item.SetCount(item.GetCount() - 1, false, false, false, eLootSourceType::PROPERTY); + // Straight to the database: the model must not be lost if the server stops before the owner finishes editing + Save(); + return modelId; +} + +void PropertyManagementComponent::SendModelsOnProperty() const { + GameMessages::GetModelsOnProperty msg; + msg.target = owner; + msg.models = { models.begin(), models.end() }; + msg.Send(UNASSIGNED_SYSTEM_ADDRESS); +} + +void PropertyManagementComponent::UpdateModelPosition(const LWOOBJID id, const NiPoint3 position, NiQuaternion rotation) { + LOG("Placing model <%f, %f, %f>", position.x, position.y, position.z); + + auto* entity = GetOwner(); + if (entity == nullptr) return; + + auto* inventoryComponent = entity->GetComponent(); + if (inventoryComponent == nullptr) return; + + auto* item = inventoryComponent->FindItemById(id); + if (item == nullptr) { + LOG("Failed to find item with id %llu", id); + return; + } + + const NiQuaternion originalRotation = rotation; + if (rotation != QuatUtils::IDENTITY) { + rotation = { rotation.w, rotation.z, rotation.y, rotation.x }; + } + + const auto modelId = PlaceModelFromItem(*item, position, rotation); + if (modelId == LWOOBJID_EMPTY) return; + + // As a live server answered a placed model + { + GameMessages::HandleUGCEquipPreCreateBasedOnEditMode msg; + msg.target = entity->GetObjectID(); + msg.modelCount = 0; + msg.modelID = modelId; + msg.Send(entity->GetSystemAddress()); + } { GameMessages::PlaceModelResponse msg; msg.target = entity->GetObjectID(); msg.position = position; msg.propertyPlaqueID = m_Parent->GetObjectID(); - msg.response = 14; + msg.response = BrickByBrick::PLACE_MODEL_PLACED; msg.rotation = originalRotation; msg.Send(entity->GetSystemAddress()); } - { - GameMessages::HandleUGCEquipPreCreateBasedOnEditMode msg; - msg.target = entity->GetObjectID(); - msg.modelCount = 0; - msg.modelID = spawnerId; - msg.Send(entity->GetSystemAddress()); - } - - { - const auto& propertyModels = GetModels(); - GameMessages::GetModelsOnProperty msg; - msg.target = entity->GetObjectID(); - msg.models = { propertyModels.begin(), propertyModels.end() }; - LOG("Sending property models to (%llu) (%d)", msg.target, true); - msg.Send(UNASSIGNED_SYSTEM_ADDRESS); - } + SendModelsOnProperty(); Game::entityManager->GetZoneControlEntity()->OnZonePropertyModelPlaced(entity); @@ -462,205 +468,83 @@ void PropertyManagementComponent::DeleteModel(const LWOOBJID id, const int delet LOG("Delete model: (%llu) (%i)", id, deleteReason); auto* entity = GetOwner(); - - if (entity == nullptr) { - return; - } + if (entity == nullptr) return; auto* inventoryComponent = entity->GetComponent(); - - if (inventoryComponent == nullptr) { - return; - } + if (inventoryComponent == nullptr) return; auto* model = Game::entityManager->GetEntity(id); - if (model == nullptr) { LOG("Failed to find model entity"); - - return; - } - - if (model->GetLOT() == 14 && deleteReason == 0) { - LOG("User is trying to pick up a BBB model, but this is not implemented, so we return to prevent the user from losing the model"); - - { - GameMessages::HandleUGCEquipPostDeleteBasedOnEditMode msg; - msg.target = entity->GetObjectID(); - msg.invItem = LWOOBJID_EMPTY; - msg.itemsTotal = 0; - msg.Send(entity->GetSystemAddress()); - } - - // Need this to pop the user out of their current state - { - GameMessages::PlaceModelResponse msg; - msg.target = entity->GetObjectID(); - msg.position = entity->GetPosition(); - msg.propertyPlaqueID = m_Parent->GetObjectID(); - msg.response = 14; - msg.rotation = entity->GetRotation(); - msg.Send(entity->GetSystemAddress()); - } - return; } const auto index = models.find(id); - if (index == models.end()) { LOG("Failed to find model"); - return; } - const auto spawnerId = index->second; + const auto modelId = index->second; + const auto removal = BrickByBrick::PlanModelRemoval(deleteReason); - auto* spawner = Game::zoneManager->GetSpawner(spawnerId); - - models.erase(id); - - if (spawner == nullptr) { - LOG("Failed to find spawner"); + // Every way off the property puts the model in MODELS; taking it apart then opens it in brick by brick building + LOT itemLot = model->GetLOT(); + LwoNameValue config; + LWOOBJID subKey = LWOOBJID_EMPTY; + if (model->GetLOT() == BrickByBrick::MODEL_OBJECT_LOT) { + itemLot = BrickByBrick::MODEL_ITEM_LOT; + config = BrickByBrick::ModelItemConfig(model->GetVar(u"blueprintid"), modelId, model->GetVar(u"userModelBehaviors")); + subKey = modelId; } + const auto received = inventoryComponent->ReceiveItem(LWOOBJID_EMPTY, itemLot, 1, eLootSourceType::PROPERTY, config, subKey, false, + { .inventory = eInventoryType::MODELS, .showFlyingLoot = false, .equip = removal.equip }); + if (received.id == LWOOBJID_EMPTY) { + LOG("Could not give model %llu back to %llu, leaving it on the property", modelId, entity->GetObjectID()); + return; + } + + models.erase(index); Game::entityManager->DestructEntity(model); - - LOG("Deleting model LOT %i", model->GetLOT()); - - if (model->GetLOT() == 14) { - //add it to the inv - LwoNameValue actualConfig; - - //fill our settings with BBB gurbage - actualConfig.Insert(u"blueprintid", model->GetVar(u"blueprintid")); - actualConfig.Insert(u"userModelDesc", u"A cool model you made!"); - actualConfig.Insert(u"userModelHasBhvr", false); - actualConfig.Insert(u"userModelID", model->GetVar(u"userModelID")); - actualConfig.Insert(u"userModelMod", false); - actualConfig.Insert(u"userModelName", u"My Cool Model"); - actualConfig.Insert(u"userModelOpt", true); - actualConfig.Insert(u"userModelPhysicsType", 2); - - inventoryComponent->AddItem(6662, 1, eLootSourceType::DELETION, eInventoryType::MODELS_IN_BBB, actualConfig, LWOOBJID_EMPTY, false, false, spawnerId); - auto* item = inventoryComponent->FindItemBySubKey(spawnerId); - - if (item == nullptr) { - return; - } - - if (deleteReason == 0) { - //item->Equip(); - } - - if (deleteReason == 0 || deleteReason == 2) { - { - GameMessages::HandleUGCEquipPostDeleteBasedOnEditMode msg; - msg.target = entity->GetObjectID(); - msg.invItem = item->GetId(); - msg.itemsTotal = item->GetCount(); - msg.Send(entity->GetSystemAddress()); - } - } - - { - const auto& propertyModels = GetModels(); - GameMessages::GetModelsOnProperty msg; - msg.target = entity->GetObjectID(); - msg.models = { propertyModels.begin(), propertyModels.end() }; - LOG("Sending property models to (%llu) (%d)", msg.target, true); - msg.Send(UNASSIGNED_SYSTEM_ADDRESS); - } - - { - GameMessages::PlaceModelResponse msg; - msg.target = entity->GetObjectID(); - msg.position = NiPoint3Constant::ZERO; - msg.propertyPlaqueID = LWOOBJID_EMPTY; - msg.response = 16; - msg.rotation = QuatUtils::IDENTITY; - msg.Send(entity->GetSystemAddress()); - } - - if (spawner != nullptr) { - Game::zoneManager->RemoveSpawner(spawner->m_Info.spawnerID); - } else { - model->Smash(LWOOBJID_EMPTY, eKillType::SILENT); - } - - item->SetCount(0, true, false, false); - - return; - } - - inventoryComponent->AddItem(model->GetLOT(), 1, eLootSourceType::DELETION, INVALID, {}, LWOOBJID_EMPTY, false); - - auto* item = inventoryComponent->FindItemByLot(model->GetLOT()); - - if (item == nullptr) { - return; - } - - switch (deleteReason) { - case 0: // Pickup - { - item->Equip(); - - { - GameMessages::HandleUGCEquipPostDeleteBasedOnEditMode msg; - msg.target = entity->GetObjectID(); - msg.invItem = item->GetId(); - msg.itemsTotal = item->GetCount(); - msg.Send(entity->GetSystemAddress()); - } - Game::entityManager->GetZoneControlEntity()->OnZonePropertyModelPickedUp(entity); - - break; - } - case 1: // Return to inv - { - Game::entityManager->GetZoneControlEntity()->OnZonePropertyModelRemoved(entity); - - break; - } - case 2: // Break apart - { - item->SetCount(item->GetCount() - 1); - - LOG("DLU currently does not support breaking apart brick by brick models."); - - break; - } - default: - { - LOG("Invalid delete reason"); - } - } - - { - const auto& propertyModels = GetModels(); - GameMessages::GetModelsOnProperty msg; - msg.target = entity->GetObjectID(); - msg.models = { propertyModels.begin(), propertyModels.end() }; - LOG("Sending property models to (%llu) (%d)", msg.target, true); - msg.Send(UNASSIGNED_SYSTEM_ADDRESS); - } - - { - GameMessages::PlaceModelResponse msg; - msg.target = entity->GetObjectID(); - msg.position = NiPoint3Constant::ZERO; - msg.propertyPlaqueID = LWOOBJID_EMPTY; - msg.response = 16; - msg.rotation = QuatUtils::IDENTITY; - msg.Send(entity->GetSystemAddress()); - } - + auto* spawner = Game::zoneManager->GetSpawner(modelId); if (spawner != nullptr) { Game::zoneManager->RemoveSpawner(spawner->m_Info.spawnerID); } else { model->Smash(LWOOBJID_EMPTY, eKillType::SILENT); } + + // Straight to the database, as for placing + Save(); + + if (removal.notifyPostDelete) { + auto* item = inventoryComponent->FindItemById(received.id); + GameMessages::HandleUGCEquipPostDeleteBasedOnEditMode msg; + msg.target = entity->GetObjectID(); + msg.invItem = received.id; + msg.itemsTotal = item ? item->GetCount() : 1; + msg.Send(entity->GetSystemAddress()); + } + + SendModelsOnProperty(); + + { + GameMessages::PlaceModelResponse msg; + msg.target = entity->GetObjectID(); + msg.response = BrickByBrick::PLACE_MODEL_REMOVED; + msg.Send(entity->GetSystemAddress()); + } + + switch (static_cast(deleteReason)) { + case BrickByBrick::eDeleteReason::PICKING_MODEL_UP: + Game::entityManager->GetZoneControlEntity()->OnZonePropertyModelPickedUp(entity); + break; + case BrickByBrick::eDeleteReason::RETURNING_MODEL_TO_INVENTORY: + Game::entityManager->GetZoneControlEntity()->OnZonePropertyModelRemoved(entity); + break; + default: + break; + } } void PropertyManagementComponent::UpdateApprovedStatus(const bool value, const std::string& rejectionReason) { @@ -796,7 +680,8 @@ void PropertyManagementComponent::Save() { model.lot = entity->GetLOT(); model.position = position; model.rotation = rotation; - model.ugcId = 0; + // A brick built model keeps its blueprint (it used to be saved as 0, losing the model on the next load) + model.ugcId = model.lot == BrickByBrick::MODEL_OBJECT_LOT ? entity->GetVar(u"blueprintid") : 0; for (auto i = 0; i < model.behaviors.size(); i++) { model.behaviors[i] = modelBehaviors[i].first; } diff --git a/dGame/dComponents/PropertyManagementComponent.h b/dGame/dComponents/PropertyManagementComponent.h index 9e6c7d408..cdb15bd1b 100644 --- a/dGame/dComponents/PropertyManagementComponent.h +++ b/dGame/dComponents/PropertyManagementComponent.h @@ -5,6 +5,9 @@ #include "Entity.h" #include "Component.h" #include "eReplicaComponentType.h" +#include "LDFFormat.h" + +class Item; /** * Main component that handles interactions with a property, generally the plaques you see on properties. @@ -112,6 +115,23 @@ public: */ void DeleteModel(LWOOBJID id, int deleteReason); + /** + * Spawns a model on the property and records it in the property's models (not yet saved) + * @param lot the model object's LOT (14 for a brick built model) + * @param modelId the model's id in properties_contents (its UGID) + * @param config extra config for the model object + */ + Entity* SpawnModel(LOT lot, LWOOBJID modelId, const NiPoint3& position, const NiQuaternion& rotation, const LwoNameValue& config); + + /** + * Places a model item on the property, uses the item up and saves the property + * @return the placed model's id, or LWOOBJID_EMPTY if it could not be placed + */ + LWOOBJID PlaceModelFromItem(Item& item, const NiPoint3& position, const NiQuaternion& rotation); + + // GetModelsOnProperty with every model on the property + void SendModelsOnProperty() const; + /** * Updates whether or not this property is approved by a moderator * @param value true if the property should be approved, false otherwise diff --git a/dGame/dGameMessages/BuildingMessages.cpp b/dGame/dGameMessages/BuildingMessages.cpp index 96a7f0528..40cb7f280 100644 --- a/dGame/dGameMessages/BuildingMessages.cpp +++ b/dGame/dGameMessages/BuildingMessages.cpp @@ -29,6 +29,7 @@ #include "PropertyManagementComponent.h" #include "Sd0.h" #include "ScriptComponent.h" +#include "BrickByBrick.h" #include "User.h" #include "UserManager.h" @@ -217,31 +218,14 @@ namespace GameMessages { InventoryComponent* inv = static_cast(character->GetComponent(eReplicaComponentType::INVENTORY)); if (!inv) return; - if (PropertyManagementComponent::Instance() != nullptr) { - const auto& buildAreas = Game::entityManager->GetEntitiesByComponent(eReplicaComponentType::BUILD_BORDER); - - const auto& entities = Game::entityManager->GetEntitiesInGroup("PropertyPlaque"); - - Entity* buildArea; - - if (!buildAreas.empty()) { - buildArea = buildAreas[0]; - } else if (!entities.empty()) { - buildArea = entities[0]; - - LOG("Using PropertyPlaque"); - } else { - LOG("No build area found"); - - return; - } - - LOG("Build area found: %llu", buildArea->GetObjectID()); - + // Picking something new to build or arrange (a brick for a new model, a placed model to take apart) starts + // arranging again; the build area is the subject, as for StartBuildingWithItem. Leaving (newSourceType 0) gets + // no answer, as on live. + if (PropertyManagementComponent::Instance() != nullptr && newSourceType != 0) { StartArrangingWithItem arranging; arranging.target = character->GetObjectID(); arranging.firstTime = false; - arranging.buildAreaID = buildArea->GetObjectID(); + arranging.buildAreaID = entity.GetObjectID(); arranging.buildStartPos = character->GetPosition(); arranging.sourceBag = newSourceBag; arranging.sourceID = newSourceID; @@ -462,7 +446,7 @@ namespace GameMessages { SetBuildModeConfirmed confirmed; confirmed.target = entity.GetObjectID(); confirmed.start = start; - confirmed.warnVisitors = false; + confirmed.warnVisitors = !start; // as a live server answered: false going in, true coming out confirmed.modePaused = modePaused; confirmed.modeValue = modeValue; confirmed.playerId = playerId; @@ -523,23 +507,7 @@ namespace GameMessages { } void UnUseBBBModel::Handle(Entity& entity, const SystemAddress& sysAddr) { - auto* inventoryComponent = entity.GetComponent(); - if (inventoryComponent) { - auto* inventory = inventoryComponent->GetInventory(eInventoryType::MODELS_IN_BBB); - auto* item = inventory->FindItemById(modelID); - if (item) { - inventoryComponent->MoveItemToInventory(item, eInventoryType::MODELS, 1); - } else { - LOG("item id %llu not found in MODELS_IN_BBB inventory, likely because it does not exist", modelID); - } - } - - if (bHasWorldTransform) { - ClientPackets::BlueprintSaveResponse response; - response.localId = LWOOBJID_EMPTY; //always zero so that a check on the client passes - response.reasonCode = eBlueprintSaveResponseType::PlacementFailed; // Sending a non-zero error code here prevents the client from deleting its in progress build for some reason? - response.Send(sysAddr); - } + BrickByBrick::ReturnModel(entity, modelID, bHasWorldTransform, worldPos, worldRot); } void BBBLoadItemRequest::Serialize(RakNet::BitStream& bitStream) const { @@ -552,34 +520,14 @@ namespace GameMessages { } void BBBLoadItemRequest::Handle(Entity& entity, const SystemAddress& sysAddr) { - const LWOOBJID previousItemID = itemID; + const auto movedId = BrickByBrick::LoadModel(entity, itemID); - LOG("Load item request for: %lld", previousItemID); - LWOOBJID newId = previousItemID; - auto* inventoryComponent = entity.GetComponent(); - if (inventoryComponent) { - auto* inventory = inventoryComponent->GetInventory(eInventoryType::MODELS); - auto* itemToMove = inventory->FindItemById(previousItemID); - - if (itemToMove) { - LOT previousLot = itemToMove->GetLot(); - inventoryComponent->MoveItemToInventory(itemToMove, eInventoryType::MODELS_IN_BBB, 1, false); - - auto* destinationInventory = inventoryComponent->GetInventory(eInventoryType::MODELS_IN_BBB); - if (destinationInventory) { - auto* movedItem = destinationInventory->FindItemByLot(previousLot); - if (movedItem) newId = movedItem->GetId(); - } - } else { - LOG("item id %llu not found in MODELS inventory, likely because it does not exist", previousItemID); - } - } - - // Second argument always true (successful) for now + // A live server answered with the same id: the item keeps it in MODELS_IN_BBB. Without the model the client + // shows BBB_ERROR_LOADING_BLUEPRINT and drops the load (0x00b75bb0). ClientPackets::BlueprintLoadItemResponse response; - response.success = true; - response.itemId = previousItemID; - response.destItemId = newId; + response.success = movedId != LWOOBJID_EMPTY; + response.itemId = itemID; + response.destItemId = movedId != LWOOBJID_EMPTY ? movedId : itemID; response.Send(sysAddr); } @@ -604,129 +552,53 @@ namespace GameMessages { } void BBBSaveRequest::Handle(Entity& entity, const SystemAddress& sysAddr) { - /* - On DLU we had agreed that bricks wouldn't be taken anyway, but if your server decides otherwise, feel free to - comment this back out and add the needed code to get the bricks used from lxfml and take them from the inventory. + BrickByBrick::Save(entity, localID, lxfmlDataCompressed); + } - Note, in the live client it'll still display the bricks going out as they're being used, but on relog/world change, - they reappear as we didn't take them. + void ActivateBrickMode::Serialize(RakNet::BitStream& bitStream) const { + BitStreamUtils::WriteOptional(bitStream, buildObjectID, LWOOBJID_EMPTY); + BitStreamUtils::WriteOptional(bitStream, buildType, 2); + bitStream.Write(enterBuildFromWorld); + bitStream.Write(enterFlag); + } - TODO Apparently the bricks are supposed to be taken via MoveInventoryBatch? - */ + bool ActivateBrickMode::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, buildObjectID, LWOOBJID_EMPTY)); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, buildType, 2)); + VALIDATE_READ(bitStream.Read(enterBuildFromWorld)); + VALIDATE_READ(bitStream.Read(enterFlag)); + return true; + } - //Now, the cave of dragons: + void ActivateBrickMode::Handle(Entity& entity, const SystemAddress& sysAddr) { + if (!enterFlag) BrickByBrick::EndSession(entity); + } - //We need to get a new ID for our model first: - if (!entity.GetCharacter() || !entity.GetCharacter()->GetParentUser()) return; + void SetBBBAutosave::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(lxfmlDataCompressed.size()); + if (!lxfmlDataCompressed.empty()) bitStream.Write(lxfmlDataCompressed.data(), lxfmlDataCompressed.size()); + } - //We need to get the propertyID: (stolen from Wincent's propertyManagementComp) - const auto& worldId = Game::zoneManager->GetZone()->GetZoneID(); + bool SetBBBAutosave::Deserialize(RakNet::BitStream& bitStream) { + uint32_t size{}; + VALIDATE_READ(bitStream.Read(size)); + if (static_cast(size) * 8 > bitStream.GetNumberOfUnreadBits()) return false; + lxfmlDataCompressed.resize(size); + if (size != 0) VALIDATE_READ(bitStream.Read(lxfmlDataCompressed.data(), size)); + return true; + } - const auto zoneId = worldId.GetMapID(); - const auto cloneId = worldId.GetCloneID(); + void SetBBBAutosave::Handle(Entity& entity, const SystemAddress& sysAddr) { + BrickByBrick::Autosave(entity, lxfmlDataCompressed); + } - auto propertyInfo = Database::Get()->GetPropertyInfo(zoneId, cloneId); - LWOOBJID propertyId = LWOOBJID_EMPTY; - if (propertyInfo) propertyId = propertyInfo->id; + void RebuildBBBAutosaveMsg::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(count); + } - // Save the binary data to the Sd0 buffer - std::istringstream sd0DataStream(lxfmlDataCompressed); - Sd0 sd0(sd0DataStream); - - // Uncompress the data, split, and nornmalize the model - const auto asStr = sd0.GetAsStringUncompressed(); - - if (Game::config->GetValue("save_lxfmls") == "1") { - // save using localId to avoid conflicts - std::ofstream outFile("debug_lxfml_uncompressed_" + std::to_string(localID) + ".lxfml"); - outFile << asStr; - outFile.close(); - } - - auto splitLxfmls = Lxfml::Split(asStr); - LOG_DEBUG("Split into %zu models", splitLxfmls.size()); - - ClientPackets::BlueprintSaveResponse response; - response.localId = localID; - response.reasonCode = eBlueprintSaveResponseType::EverythingWorked; - - std::vector blueprintIDs; - std::vector modelIDs; - - for (size_t i = 0; i < splitLxfmls.size(); ++i) { - const auto [newID, blueprintID] = ObjectIDManager::GetNewModelIDs(); - - blueprintIDs.push_back(blueprintID); - modelIDs.push_back(newID); - - // Save each model to the database - sd0.FromData(reinterpret_cast(splitLxfmls[i].lxfml.data()), splitLxfmls[i].lxfml.size()); - auto sd0AsStream = sd0.GetAsStream(); - Database::Get()->InsertNewUgcModel(sd0AsStream, blueprintID, entity.GetCharacter()->GetParentUser()->GetAccountID(), entity.GetCharacter()->GetID()); - - // Insert the new property model - IPropertyContents::Model model; - model.id = newID; - model.ugcId = blueprintID; - model.position = splitLxfmls[i].center; - model.rotation = QuatUtils::IDENTITY; - model.lot = 14; - Database::Get()->InsertNewPropertyModel(propertyId, model, "Objects_14_name"); - DashboardNotify::Changed("properties", propertyId); - - /* - Commented out until UGC server would be updated to use a sd0 file instead of lxfml stream. - (or you uncomment the lxfml decomp stuff above) - */ - - // Send off to UGC for processing, if enabled: - // if (Game::config->GetValue("ugc_remote") == "1") { - // std::string ugcIP = Game::config->GetValue("ugc_ip"); - // int ugcPort = std::stoi(Game::config->GetValue("ugc_port")); - - // httplib::Client cli(ugcIP, ugcPort); //connect to UGC HTTP server using our config above ^ - - // //Send out a request: - // std::string request = "/3dservices/UGCC150/150" + std::to_string(blueprintID) + ".lxfml"; - // cli.Put(request.c_str(), lxfml.c_str(), "text/lxfml"); - - // //When the "put" above returns, it means that the UGC HTTP server is done processing our model & - // //the nif, hkx and checksum files are ready to be downloaded from cache. - // } - - // Write the ID and data to the response packet - auto& responseModel = response.models.emplace_back(); - responseModel.blueprintId = blueprintID; - for (const auto& chunk : sd0.GetAsVector()) responseModel.data.append(reinterpret_cast(chunk.data()), chunk.size()); - } - - response.Send(sysAddr); - - // Create entities for each model - for (size_t i = 0; i < splitLxfmls.size(); ++i) { - EntityInfo info; - info.lot = 14; - info.pos = splitLxfmls[i].center; - info.rot = QuatUtils::IDENTITY; - info.spawner = nullptr; - info.spawnerID = entity.GetObjectID(); - info.spawnerNodeID = 0; - - info.settings.Insert(u"blueprintid", blueprintIDs[i]); - info.settings.Insert(u"componentWhitelist", 1); - info.settings.Insert(u"modelType", 2); - info.settings.Insert(u"propertyObjectID", true); - info.settings.Insert(u"userModelID", modelIDs[i]); - Entity* newEntity = Game::entityManager->CreateEntity(info, nullptr); - if (newEntity) { - Game::entityManager->ConstructEntity(newEntity); - - //Make sure the propMgmt doesn't delete our model after the server dies - //Trying to do this after the entity is constructed. Shouldn't really change anything but - //there was an issue with builds not appearing since it was placed above ConstructEntity. - PropertyManagementComponent::Instance()->AddModel(newEntity->GetObjectID(), modelIDs[i]); - } - } + bool RebuildBBBAutosaveMsg::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(count)); + return true; } void SetModelToBuild::Serialize(RakNet::BitStream& bitStream) const { diff --git a/dGame/dGameMessages/BuildingMessages.h b/dGame/dGameMessages/BuildingMessages.h index 4bf0d36c3..264b7c48d 100644 --- a/dGame/dGameMessages/BuildingMessages.h +++ b/dGame/dGameMessages/BuildingMessages.h @@ -206,6 +206,42 @@ namespace GameMessages { uint32_t timeTakenInMs{}; }; + // Client -> server. The player went into (enterFlag) or came out of brick by brick building. + // Layout as GameMessage::ActivateBrickMode::Deserialize (0x00d8ecb0) reads it. + struct ActivateBrickMode : public NetGameMsg { + ActivateBrickMode() : NetGameMsg(MessageType::Game::ACTIVATE_BRICK_MODE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + void Handle(Entity& entity, const SystemAddress& sysAddr) override; + + LWOOBJID buildObjectID{ LWOOBJID_EMPTY }; // optional; the property's build area when entering + int32_t buildType{ 2 }; // optional; 0 nowhere, 1 in the world, 2 on a property + bool enterBuildFromWorld{ true }; + bool enterFlag{ true }; + }; + + // Client -> server. The client's quick save of the model being built: sd0 compressed LXFML, or just the sd0 header + // when there is nothing to keep. Sent every five minutes, when told the player is AFK (msgInformAFK) and before the + // client shuts down. Layout as GameMessage::SetBBBAutosave::Deserialize (0x00f2af60) reads it. + struct SetBBBAutosave : public NetGameMsg { + SetBBBAutosave() : NetGameMsg(MessageType::Game::SET_BBB_AUTOSAVE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + void Handle(Entity& entity, const SystemAddress& sysAddr) override; + + std::string lxfmlDataCompressed{}; // u32 byte count, then the bytes + }; + + // Server -> client. How many unfinished (autosaved) models the server rebuilt into models; the client shows + // BBB_AUTOSAVE_REBUILDING_SINGLE or _MULTIPLE (LWOBBBComponent_Client::msgRebuildBBBAutosaveMsg, 0x00cfabd0). + struct RebuildBBBAutosaveMsg : public NetGameMsg { + RebuildBBBAutosaveMsg() : NetGameMsg(MessageType::Game::REBUILD_BBB_AUTOSAVE_MSG) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + + int32_t count{}; + }; + struct SetModelToBuild : public NetGameMsg { SetModelToBuild() : NetGameMsg(MessageType::Game::SET_MODEL_TO_BUILD) {} void Serialize(RakNet::BitStream& bitStream) const override; diff --git a/dGame/dGameMessages/GameMessageHandler.cpp b/dGame/dGameMessages/GameMessageHandler.cpp index de4e4559e..5f6fdc23f 100644 --- a/dGame/dGameMessages/GameMessageHandler.cpp +++ b/dGame/dGameMessages/GameMessageHandler.cpp @@ -143,6 +143,9 @@ namespace { { UN_USE_BBB_MODEL, []() { return std::make_unique(); } }, { BBB_LOAD_ITEM_REQUEST, []() { return std::make_unique(); } }, { BBB_SAVE_REQUEST, []() { return std::make_unique(); } }, + { SET_BBB_AUTOSAVE, []() { return std::make_unique(); } }, + { ACTIVATE_BRICK_MODE, []() { return std::make_unique(); } }, + { MOVE_INVENTORY_BATCH, []() { return std::make_unique(); } }, // Vendors and donation vendors { REQUEST_VENDOR_STATUS_UPDATE, []() { return std::make_unique(); } }, diff --git a/dGame/dGameMessages/InventoryMessages.cpp b/dGame/dGameMessages/InventoryMessages.cpp index 97289bd8f..e3bd8cf61 100644 --- a/dGame/dGameMessages/InventoryMessages.cpp +++ b/dGame/dGameMessages/InventoryMessages.cpp @@ -9,6 +9,7 @@ #include "Inventory.h" #include "InventoryComponent.h" #include "Item.h" +#include "BrickByBrick.h" #include @@ -306,6 +307,10 @@ namespace GameMessages { return true; } + void MoveInventoryBatch::Handle(Entity& entity, const SystemAddress& sysAddr) { + BrickByBrick::MoveBricks(entity, *this); + } + void NotifyNotEnoughInvSpace::Serialize(RakNet::BitStream& bitStream) const { bitStream.Write(freeSlotsNeeded); BitStreamUtils::WriteOptional(bitStream, inventoryType, eInventoryType::ITEMS); diff --git a/dGame/dGameMessages/InventoryMessages.h b/dGame/dGameMessages/InventoryMessages.h index 41c56f68e..8e3b78af2 100644 --- a/dGame/dGameMessages/InventoryMessages.h +++ b/dGame/dGameMessages/InventoryMessages.h @@ -171,6 +171,10 @@ namespace GameMessages { MoveInventoryBatch() : NetGameMsg(MessageType::Game::MOVE_INVENTORY_BATCH) {} void Serialize(RakNet::BitStream& bitStream) const override; bool Deserialize(RakNet::BitStream& bitStream) override; + // Client -> server: brick by brick moving bricks between BRICKS and BRICKS_IN_BBB. The client has already taken + // them out of srcBag (LWOInventoryComponent_Common::msgMoveInventoryBatch, 0x00ce1310) and waits for + // AddItemToInventoryClientSync for dstBag. + void Handle(Entity& entity, const SystemAddress& sysAddr) override; bool bAllowPartial{}; bool bOutSuccess{}; diff --git a/dGame/dGameMessages/PropertyMessages.h b/dGame/dGameMessages/PropertyMessages.h index 3b9f9dd62..b1ee51f39 100644 --- a/dGame/dGameMessages/PropertyMessages.h +++ b/dGame/dGameMessages/PropertyMessages.h @@ -148,6 +148,12 @@ namespace GameMessages { NiQuaternion rotation{ QuatUtils::IDENTITY }; // optional }; + // Server -> client. No payload. Makes the client ask for the property's models again (PropertyContentsFromClient); + // a live server sent it after a brick by brick save and after placing a model. + struct RequeryPropertyModels : public NetGameMsg { + RequeryPropertyModels() : NetGameMsg(MessageType::Game::REQUERY_PROPERTY_MODELS) {} + }; + // Server -> client. Sent with id HANDLE_UGC_POST_CREATE_BASED_ON_EDIT_MODE (1301), which the client names // HandleUGCEquipPreCreateBasedOnEditMode. struct HandleUGCEquipPreCreateBasedOnEditMode : public NetGameMsg { diff --git a/dGame/dGameMessages/ZoneMessages.cpp b/dGame/dGameMessages/ZoneMessages.cpp index f1681b86e..996385f4c 100644 --- a/dGame/dGameMessages/ZoneMessages.cpp +++ b/dGame/dGameMessages/ZoneMessages.cpp @@ -16,6 +16,7 @@ #include "LevelProgressionComponent.h" #include "RacingControlComponent.h" #include "WorldMigration.h" +#include "BrickByBrick.h" #include "dConfig.h" #include "dZoneManager.h" #include "eReplicaComponentType.h" @@ -118,6 +119,10 @@ namespace GameMessages { // Moved here from another instance: put back what their save doesn't keep (the pet that was out) WorldMigration::OnPlayerLoaded(entity); + // A brick by brick build that ended without a save (disconnect, crash): rebuild the autosave or give back the + // models that were open + BrickByBrick::OnPlayerLoaded(*entity); + LOG("Player %s (%llu) loaded.", entity->GetCharacter()->GetName().c_str(), entity->GetObjectID()); // After we've done our thing, tell the client they're ready diff --git a/dGame/dUtilities/BrickByBrick.cpp b/dGame/dUtilities/BrickByBrick.cpp new file mode 100644 index 000000000..48ad65a6b --- /dev/null +++ b/dGame/dUtilities/BrickByBrick.cpp @@ -0,0 +1,389 @@ +#include "BrickByBrick.h" + +#include "BuildingMessages.h" +#include "Character.h" +#include "ClientPackets.h" +#include "DashboardNotify.h" +#include "Database.h" +#include "dConfig.h" +#include "EconomyLedger.h" +#include "eBlueprintSaveResponseType.h" +#include "eInventoryType.h" +#include "eLootSourceType.h" +#include "Entity.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "Inventory.h" +#include "InventoryComponent.h" +#include "InventoryMessages.h" +#include "Item.h" +#include "Logger.h" +#include "Lxfml.h" +#include "ObjectIDManager.h" +#include "PropertyManagementComponent.h" +#include "PropertyMessages.h" +#include "Sd0.h" +#include "User.h" + +#include +#include +#include +#include +#include +#include + +namespace { + // Every item in one inventory, copied so the inventory can change while going through them + std::vector ItemsIn(InventoryComponent& inventory, const eInventoryType type) { + std::vector items; + auto* bag = inventory.GetInventory(type); + if (!bag) return items; + for (auto* item : bag->GetItems() | std::views::values) items.push_back(item); + return items; + } + + std::vector IdsIn(InventoryComponent& inventory, const eInventoryType type) { + std::vector ids; + for (const auto* item : ItemsIn(inventory, type)) ids.push_back(item->GetId()); + return ids; + } + + // Moves one unique item (a model) to another of the player's inventories keeping its object id, as a live server + // did for BBBLoadItemRequest: RemoveItemFromInventory for the old place, AddItemToInventoryClientSync for the new. + LWOOBJID MoveKeepingId(InventoryComponent& inventory, Item* item, const eInventoryType to) { + if (!item) return LWOOBJID_EMPTY; + const auto from = item->GetInventory()->GetType(); + if (from == to) return item->GetId(); + + const auto id = item->GetId(); + const auto lot = item->GetLot(); + const auto count = item->GetCount(); + const auto subKey = item->GetSubKey(); + const auto bound = item->GetBound(); + const LwoNameValue config = item->GetConfig(); + + EconomyLedger::ScopedItemTransfer moving; + if (item->IsEquipped()) item->UnEquip(); + item->SetCount(0, false, false, false, eLootSourceType::RELOCATE); + const auto received = inventory.ReceiveItem(id, lot, count, eLootSourceType::RELOCATE, config, subKey, bound, + { .inventory = to, .showFlyingLoot = false, .sourceInventory = from }); + return received.id; + } + + // Bricks the player took into brick by brick building: used up by the model (as live did) when bbb_consume_bricks + // is 1, otherwise back to the backpack (DLU's choice: building does not cost bricks). + void SettleBricks(InventoryComponent& inventory, const bool usedByModel) { + const bool consume = usedByModel && Game::config && Game::config->GetValue("bbb_consume_bricks") == "1"; + for (auto* item : ItemsIn(inventory, eInventoryType::BRICKS_IN_BBB)) { + if (consume) { + item->SetCount(0, false, false, false, eLootSourceType::INVENTORY); + } else { + inventory.MoveItemToInventory(item, eInventoryType::BRICKS, item->GetCount(), false); + } + } + } + + struct SavedModel { + LWOOBJID modelId{}; + LWOOBJID blueprintId{}; + NiPoint3 center{}; + std::string sd0{}; // the model's own sd0 compressed LXFML + }; + + // Splits a build into its models and stores each as a new ugc row (is_optimized 0: the UGC server makes its mesh + // and icon). Every save makes new blueprint ids: the client caches blueprints by id. + std::vector StoreModels(Entity& player, const std::string& sd0Data, const std::string& debugName) { + std::vector saved; + auto* character = player.GetCharacter(); + if (!character || !character->GetParentUser()) return saved; + + std::istringstream sd0Stream(sd0Data); + Sd0 sd0(sd0Stream); + const auto lxfml = sd0.GetAsStringUncompressed(); + if (lxfml.empty()) return saved; + + if (Game::config && Game::config->GetValue("save_lxfmls") == "1") { + std::ofstream outFile("debug_lxfml_uncompressed_" + debugName + ".lxfml"); + outFile << lxfml; + } + + for (const auto& part : Lxfml::Split(lxfml)) { + const auto [modelId, blueprintId] = ObjectIDManager::GetNewModelIDs(); + Sd0 model = sd0; + model.FromData(reinterpret_cast(part.lxfml.data()), part.lxfml.size()); + auto stream = model.GetAsStream(); + Database::Get()->InsertNewUgcModel(stream, blueprintId, character->GetParentUser()->GetAccountID(), character->GetID()); + + auto& entry = saved.emplace_back(); + entry.modelId = modelId; + entry.blueprintId = blueprintId; + entry.center = part.center; + for (const auto& chunk : model.GetAsVector()) entry.sd0.append(reinterpret_cast(chunk.data()), chunk.size()); + } + return saved; + } +} + +bool BrickByBrick::IsEmptyModel(const std::string_view sd0) { + // The client clears its autosave with the bare sd0 header ("sd0" 01 ff) and no chunks + return sd0.size() <= 5; +} + +BrickByBrick::ModelRemoval BrickByBrick::PlanModelRemoval(const int32_t reason) { + switch (static_cast(reason)) { + case eDeleteReason::PICKING_MODEL_UP: return { .equip = true, .notifyPostDelete = true }; + case eDeleteReason::BREAKING_MODEL_APART: return { .equip = false, .notifyPostDelete = true }; + default: return { .equip = false, .notifyPostDelete = false }; + } +} + +BrickByBrick::Recovery BrickByBrick::PlanRecovery(const std::vector& modelsInBbb, const std::optional& autosave) { + Recovery recovery; + recovery.rebuild = autosave.has_value() && !IsEmptyModel(autosave->lxfml); + if (recovery.rebuild) recovery.consume = autosave->sourceItems; + for (const auto id : modelsInBbb) { + if (std::ranges::find(recovery.consume, id) == recovery.consume.end()) recovery.giveBack.push_back(id); + } + return recovery; +} + +LwoNameValue BrickByBrick::ModelItemConfig(const LWOOBJID blueprintId, const LWOOBJID userModelId, const std::string& behaviors) { + // The keys a live server gave a brick built model item (AddItemToInventoryClientSync when picking one up) + LwoNameValue config; + config.Insert(u"blueprintid", blueprintId); + config.Insert(u"userModelID", userModelId); + config.Insert(u"userModelName", u""); + config.Insert(u"userModelDesc", u""); + config.Insert(u"userModelHasBhvr", !behaviors.empty() && behaviors.find_first_not_of("0,") != std::string::npos); + config.Insert(u"userModelBehaviors", behaviors.empty() ? "0,0,0,0,0" : behaviors); + config.Insert(u"userModelBehaviorSourceIDs", "0,0,0,0,0"); + config.Insert(u"userModelOpt", true); + config.Insert(u"userModelMod", 1); + config.Insert(u"userModelPhysicsType", 2); + return config; +} + +LWOOBJID BrickByBrick::ConfigObjectId(const LwoNameValue& config, const std::u16string& key) { + const auto it = config.find(key); + if (it == config.end() || !it->second) return LWOOBJID_EMPTY; + return GeneralUtils::TryParse(it->second->GetValueAsString()).value_or(LWOOBJID_EMPTY); +} + +uint32_t BrickByBrick::InventoryToLoadInto(const uint32_t savedType) { + if (savedType == eInventoryType::MODELS_IN_BBB) return eInventoryType::MODELS; + if (savedType == eInventoryType::BRICKS_IN_BBB) return eInventoryType::BRICKS; + return savedType; +} + +LWOOBJID BrickByBrick::LoadModel(Entity& player, const LWOOBJID itemId) { + auto* inventory = player.GetComponent(); + if (!inventory) return LWOOBJID_EMPTY; + + auto* item = inventory->FindItemById(itemId); + if (!item) { + LOG("Player %llu opened model item %llu in brick by brick, but has no such item", player.GetObjectID(), itemId); + return LWOOBJID_EMPTY; + } + + const auto type = item->GetInventory()->GetType(); + if (type == eInventoryType::MODELS_IN_BBB) return itemId; + if (type != eInventoryType::MODELS) { + LOG("Player %llu opened item %llu from inventory %i in brick by brick", player.GetObjectID(), itemId, type); + return LWOOBJID_EMPTY; + } + + return MoveKeepingId(*inventory, item, eInventoryType::MODELS_IN_BBB); +} + +void BrickByBrick::Save(Entity& player, const LWOOBJID localId, const std::string& sd0Data) { + ClientPackets::BlueprintSaveResponse response; + response.localId = localId; + + auto* inventory = player.GetComponent(); + auto* property = PropertyManagementComponent::Instance(); + // Brick by brick building happens on the player's own property; the build becomes models placed there + if (!inventory || !property || property->GetOwnerId() != player.GetObjectID()) { + LOG("Player %llu saved a brick by brick model but is not on their own property", player.GetObjectID()); + response.reasonCode = eBlueprintSaveResponseType::PlacementFailed; + response.Send(player.GetSystemAddress()); + return; + } + + const auto models = StoreModels(player, sd0Data, std::to_string(localId)); + if (models.empty()) { + // BBB_COULD_NOT_GENERATE_MODEL; the client keeps the build + response.reasonCode = eBlueprintSaveResponseType::ModelGenerationFailed; + response.Send(player.GetSystemAddress()); + return; + } + LOG_DEBUG("Split into %zu models", models.size()); + + // As live: the bricks are settled, then the response, then the models that were opened are gone + SettleBricks(*inventory, true); + + response.reasonCode = eBlueprintSaveResponseType::EverythingWorked; + for (const auto& model : models) { + auto& responseModel = response.models.emplace_back(); + responseModel.blueprintId = model.blueprintId; + responseModel.data = model.sd0; + } + response.Send(player.GetSystemAddress()); + + for (auto* item : ItemsIn(*inventory, eInventoryType::MODELS_IN_BBB)) { + item->SetCount(0, false, false, false, eLootSourceType::INVENTORY); + } + Database::Get()->DeleteBbbAutosave(player.GetObjectID()); + + for (const auto& model : models) { + LwoNameValue config; + config.Insert(u"blueprintid", model.blueprintId); + property->SpawnModel(MODEL_OBJECT_LOT, model.modelId, model.center, QuatUtils::IDENTITY, config); + } + property->Save(); + DashboardNotify::Changed("properties", property->GetId()); + + GameMessages::RequeryPropertyModels requery; + requery.target = player.GetObjectID(); + requery.SendToClient(player.GetSystemAddress()); +} + +void BrickByBrick::Autosave(Entity& player, const std::string& sd0) { + if (IsEmptyModel(sd0)) { + Database::Get()->DeleteBbbAutosave(player.GetObjectID()); + return; + } + + auto* inventory = player.GetComponent(); + IBbbAutosave::Info info; + info.lxfml = sd0; + if (inventory) info.sourceItems = IdsIn(*inventory, eInventoryType::MODELS_IN_BBB); + info.updatedAt = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + Database::Get()->SetBbbAutosave(player.GetObjectID(), info); +} + +void BrickByBrick::ReturnModel(Entity& player, const LWOOBJID itemId, const bool hasWorldTransform, const NiPoint3& position, const NiQuaternion& rotation) { + auto* inventory = player.GetComponent(); + if (inventory) { + auto* item = inventory->FindItemById(itemId); + if (item && item->GetInventory()->GetType() == eInventoryType::MODELS_IN_BBB) { + auto* property = PropertyManagementComponent::Instance(); + if (hasWorldTransform && property && property->GetOwnerId() == player.GetObjectID()) { + property->PlaceModelFromItem(*item, position, rotation); + property->SendModelsOnProperty(); + } else { + MoveKeepingId(*inventory, item, eInventoryType::MODELS); + } + } else { + LOG("Player %llu returned model item %llu, which is not in their brick by brick inventory", player.GetObjectID(), itemId); + } + } + + // The autosave must not use up a model the player has back + auto autosave = Database::Get()->GetBbbAutosave(player.GetObjectID()); + if (autosave && std::erase(autosave->sourceItems, itemId) != 0) Database::Get()->SetBbbAutosave(player.GetObjectID(), *autosave); + + if (hasWorldTransform) { + // The client waits in its saving state after sending this (BBBManager::SendUnUseBBBModel, 0x00b6b210); any + // response with the local id it expects (none pending: 0) ends it. A code other than EverythingWorked keeps + // the build the player is working on (EverythingWorked would clear it and leave brick mode). + ClientPackets::BlueprintSaveResponse response; + response.localId = LWOOBJID_EMPTY; + response.reasonCode = eBlueprintSaveResponseType::PlacementFailed; + response.Send(player.GetSystemAddress()); + } +} + +void BrickByBrick::MoveBricks(Entity& player, const GameMessages::MoveInventoryBatch& move) { + const auto isBrickBag = [](const eInventoryType type) { return type == eInventoryType::BRICKS || type == eInventoryType::BRICKS_IN_BBB; }; + if (!isBrickBag(move.srcBag) || !isBrickBag(move.dstBag) || move.srcBag == move.dstBag || move.moveLOT == LOT_NULL || move.count == 0) { + LOG_DEBUG("Ignoring MoveInventoryBatch from %i to %i of %i", move.srcBag, move.dstBag, move.moveLOT); + return; + } + + auto* inventory = player.GetComponent(); + if (!inventory) return; + auto* source = inventory->GetInventory(move.srcBag); + if (!source) return; + + const auto available = source->GetLotCount(move.moveLOT); + const auto toMove = std::min(move.count, available); + if (toMove == 0 || (!move.bAllowPartial && toMove < move.count)) { + LOG("Player %llu moved %u of %i between brick inventories but has %u", player.GetObjectID(), move.count, move.moveLOT, available); + return; + } + + EconomyLedger::ScopedItemTransfer moving; + // The client already took them out of the source bag (0x00ce1310), so that side is silent + Item* item = move.startObjectID != LWOOBJID_EMPTY ? inventory->FindItemById(move.startObjectID) : nullptr; + uint32_t left = toMove; + while (left > 0) { + if (!item || item->GetInventory() != source || item->GetLot() != move.moveLOT) item = source->FindItemByLot(move.moveLOT); + if (!item) break; + const auto delta = std::min(item->GetCount(), left); + left -= delta; + item->SetCount(item->GetCount() - delta, true, false); + item = nullptr; + } + + const auto moved = toMove - left; + if (moved == 0) return; + inventory->ReceiveItem(LWOOBJID_EMPTY, move.moveLOT, moved, eLootSourceType::RELOCATE, {}, LWOOBJID_EMPTY, false, + { .inventory = move.dstBag, .showFlyingLoot = move.showFlyingLoot, .sourceInventory = move.srcBag }); +} + +void BrickByBrick::EndSession(Entity& player) { + RecoverUnfinishedBuild(player); +} + +void BrickByBrick::OnPlayerLoaded(Entity& player) { + RecoverUnfinishedBuild(player); +} + +uint32_t BrickByBrick::RecoverUnfinishedBuild(Entity& player) { + auto* inventory = player.GetComponent(); + if (!inventory) return 0; + + const auto autosave = Database::Get()->GetBbbAutosave(player.GetObjectID()); + auto recovery = PlanRecovery(IdsIn(*inventory, eInventoryType::MODELS_IN_BBB), autosave); + const bool hadBricks = !ItemsIn(*inventory, eInventoryType::BRICKS_IN_BBB).empty(); + if (!autosave && recovery.giveBack.empty() && !hadBricks) return 0; + + uint32_t rebuilt = 0; + if (recovery.rebuild) { + for (const auto& model : StoreModels(player, autosave->lxfml, "autosave_" + std::to_string(player.GetObjectID()))) { + inventory->ReceiveItem(LWOOBJID_EMPTY, MODEL_ITEM_LOT, 1, eLootSourceType::INVENTORY, ModelItemConfig(model.blueprintId, model.modelId), model.modelId, false, + { .inventory = eInventoryType::MODELS, .showFlyingLoot = false }); + rebuilt++; + } + } + + if (rebuilt > 0) { + // The rebuilt models replace the ones that were open when the autosave was made (they may already be back in + // MODELS after a reload) + for (const auto id : recovery.consume) { + auto* item = inventory->FindItemById(id); + if (item) item->SetCount(0, false, false, false, eLootSourceType::INVENTORY); + } + } else { + for (const auto id : recovery.consume) { + if (std::ranges::find(recovery.giveBack, id) == recovery.giveBack.end()) recovery.giveBack.push_back(id); + } + } + + for (const auto id : recovery.giveBack) { + auto* item = inventory->FindItemById(id); + if (item && item->GetInventory()->GetType() == eInventoryType::MODELS_IN_BBB) MoveKeepingId(*inventory, item, eInventoryType::MODELS); + } + + SettleBricks(*inventory, rebuilt > 0); + if (autosave) Database::Get()->DeleteBbbAutosave(player.GetObjectID()); + + LOG("Recovered the brick by brick build of %llu: %u models rebuilt, %zu models given back", player.GetObjectID(), rebuilt, recovery.giveBack.size()); + if (rebuilt > 0) { + GameMessages::RebuildBBBAutosaveMsg message; + message.target = player.GetObjectID(); + message.count = static_cast(rebuilt); + message.SendToClient(player.GetSystemAddress()); + } + return rebuilt; +} diff --git a/dGame/dUtilities/BrickByBrick.h b/dGame/dUtilities/BrickByBrick.h new file mode 100644 index 000000000..56d4d03b8 --- /dev/null +++ b/dGame/dUtilities/BrickByBrick.h @@ -0,0 +1,106 @@ +#ifndef BRICKBYBRICK_H +#define BRICKBYBRICK_H + +#include "dCommonVars.h" +#include "IBbbAutosave.h" +#include "LDFFormat.h" +#include "NiPoint3.h" +#include "NiQuaternion.h" + +#include +#include +#include +#include +#include + +class Entity; +class InventoryComponent; + +namespace GameMessages { + struct MoveInventoryBatch; +} + +// Brick by brick building and the model items it works on, as the 1.10.64 client expects them (docs/BuildWorkflow.md). +// +// While a player builds, MODELS_IN_BBB holds the models they opened (their original items) and BRICKS_IN_BBB the bricks +// they took out of their backpack. A save turns the build into new models on the property and uses up the originals. +// Anything else that ends a build (leaving without saving, a disconnect or a crash) goes through RecoverUnfinishedBuild: +// the client's last autosave is rebuilt into models, or the originals go back to MODELS, so a model is never lost. +namespace BrickByBrick { + // A brick built model as an item (in MODELS, VAULT_MODELS or MODELS_IN_BBB) + constexpr LOT MODEL_ITEM_LOT = 6662; + // A brick built model placed in the world + constexpr LOT MODEL_OBJECT_LOT = 14; + + // DeleteModelFromClient's reason + enum class eDeleteReason : int32_t { + PICKING_MODEL_UP = 0, // into MODELS, equipped to carry it + RETURNING_MODEL_TO_INVENTORY, // into MODELS + BREAKING_MODEL_APART, // into MODELS; the client then opens it with BBBLoadItemRequest + }; + + // PlaceModelResponse's response + constexpr int32_t PLACE_MODEL_PLACED = 14; + constexpr int32_t PLACE_MODEL_REMOVED = 16; + + // ---- Rules with no game state (unit tested) ---- + + // Whether a SetBBBAutosave / BBBSaveRequest payload holds no model: nothing, or only the sd0 header the client sends + // to clear its autosave + bool IsEmptyModel(std::string_view sd0); + + // What taking a model off the property does with it + struct ModelRemoval { + bool equip{}; // the player carries it + bool notifyPostDelete{}; // HandleUGCEquipPostDeleteBasedOnEditMode tells the client which item it became + }; + ModelRemoval PlanModelRemoval(int32_t reason); + + // What ending a build without a save does + struct Recovery { + bool rebuild{}; // the autosave is rebuilt into models + std::vector consume; // originals the rebuilt models replace (only once a model was rebuilt) + std::vector giveBack; // MODELS_IN_BBB items that go back to MODELS + }; + Recovery PlanRecovery(const std::vector& modelsInBbb, const std::optional& autosave); + + // The config of a brick built model item (6662) and of its placed object (14) + LwoNameValue ModelItemConfig(LWOOBJID blueprintId, LWOOBJID userModelId, const std::string& behaviors = ""); + + // An object id kept in a config (blueprintid, userModelID), or LWOOBJID_EMPTY + LWOOBJID ConfigObjectId(const LwoNameValue& config, const std::u16string& key); + + // ---- Handlers ---- + + // BBBLoadItemRequest: moves the model item from MODELS to MODELS_IN_BBB keeping its object id (as live did). + // Returns the id in MODELS_IN_BBB, or LWOOBJID_EMPTY when the player has no such model. + LWOOBJID LoadModel(Entity& player, LWOOBJID itemId); + + // BBBSaveRequest + void Save(Entity& player, LWOOBJID localId, const std::string& sd0); + + // SetBBBAutosave: the client's quick save + void Autosave(Entity& player, const std::string& sd0); + + // UnUseBBBModel: a model the player opened goes back where it came from (the import tool's undo, or a model that + // could not be loaded). With a world transform it goes back on the property there, otherwise to MODELS. + void ReturnModel(Entity& player, LWOOBJID itemId, bool hasWorldTransform, const NiPoint3& position, const NiQuaternion& rotation); + + // MoveInventoryBatch between BRICKS and BRICKS_IN_BBB + void MoveBricks(Entity& player, const GameMessages::MoveInventoryBatch& move); + + // ActivateBrickMode leaving brick mode + void EndSession(Entity& player); + + // PlayerLoaded + void OnPlayerLoaded(Entity& player); + + // Resolves a build that ended without a save; returns how many models were rebuilt from the autosave + uint32_t RecoverUnfinishedBuild(Entity& player); + + // Whether an inventory bag saved as a BBB bag loads into the normal one (MODELS_IN_BBB -> MODELS, + // BRICKS_IN_BBB -> BRICKS): a build never survives a reload, its items do. + uint32_t InventoryToLoadInto(uint32_t savedType); +}; + +#endif //!BRICKBYBRICK_H diff --git a/dGame/dUtilities/CMakeLists.txt b/dGame/dUtilities/CMakeLists.txt index bab3ea9fc..eb7351e5e 100644 --- a/dGame/dUtilities/CMakeLists.txt +++ b/dGame/dUtilities/CMakeLists.txt @@ -1,4 +1,5 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp" + "BrickByBrick.cpp" "ChatServerLink.cpp" "CheatDetection.cpp" "DashboardActions.cpp" diff --git a/resources/worldconfig.ini b/resources/worldconfig.ini index 5d23a32f6..3e3a5ce39 100644 --- a/resources/worldconfig.ini +++ b/resources/worldconfig.ini @@ -105,3 +105,6 @@ hardcore_coin_keep= # save pre-split lxfmls to disk for debugging 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 diff --git a/tests/dGameTests/BrickByBrickTests.cpp b/tests/dGameTests/BrickByBrickTests.cpp new file mode 100644 index 000000000..ce5f7e078 --- /dev/null +++ b/tests/dGameTests/BrickByBrickTests.cpp @@ -0,0 +1,121 @@ +#include "BrickByBrick.h" +#include "BbbAutosaveItems.h" +#include "Database.h" +#include "Entity.h" +#include "GameDependencies.h" +#include "eInventoryType.h" + +#include + +// The brick by brick workflow's rules (docs/BuildWorkflow.md): what happens to models and bricks at each step. +class BrickByBrickTests : public GameDependenciesTest { +protected: + void SetUp() override { SetUpDependencies(); } + void TearDown() override { TearDownDependencies(); } +}; + +namespace { + const std::string HEADER_ONLY("sd0\x01\xff", 5); + const std::string ONE_CHUNK("sd0\x01\xff\x02\x00\x00\x00\x78\x9c", 11); +} + +TEST_F(BrickByBrickTests, EmptyModelIsNothingOrTheBareHeader) { + EXPECT_TRUE(BrickByBrick::IsEmptyModel("")); + EXPECT_TRUE(BrickByBrick::IsEmptyModel(HEADER_ONLY)); + EXPECT_FALSE(BrickByBrick::IsEmptyModel(ONE_CHUNK)); +} + +TEST_F(BrickByBrickTests, TakingAModelOffThePropertyPutsItInModels) { + using enum BrickByBrick::eDeleteReason; + const auto pickUp = BrickByBrick::PlanModelRemoval(static_cast(PICKING_MODEL_UP)); + EXPECT_TRUE(pickUp.equip); + EXPECT_TRUE(pickUp.notifyPostDelete); + + const auto putAway = BrickByBrick::PlanModelRemoval(static_cast(RETURNING_MODEL_TO_INVENTORY)); + EXPECT_FALSE(putAway.equip); + EXPECT_FALSE(putAway.notifyPostDelete); + + // Taking a model apart: into MODELS, not carried; the client then opens it with BBBLoadItemRequest + const auto apart = BrickByBrick::PlanModelRemoval(static_cast(BREAKING_MODEL_APART)); + EXPECT_FALSE(apart.equip); + EXPECT_TRUE(apart.notifyPostDelete); + + const auto unknown = BrickByBrick::PlanModelRemoval(7); + EXPECT_FALSE(unknown.equip); +} + +TEST_F(BrickByBrickTests, LeavingWithoutAnAutosaveGivesEveryModelBack) { + const auto recovery = BrickByBrick::PlanRecovery({ 1, 2 }, std::nullopt); + EXPECT_FALSE(recovery.rebuild); + EXPECT_TRUE(recovery.consume.empty()); + EXPECT_EQ(recovery.giveBack, (std::vector{ 1, 2 })); +} + +TEST_F(BrickByBrickTests, AnAutosaveReplacesTheModelsItWasMadeFrom) { + IBbbAutosave::Info autosave{ ONE_CHUNK, { 2, 3 }, 0 }; + const auto recovery = BrickByBrick::PlanRecovery({ 1, 2 }, autosave); + EXPECT_TRUE(recovery.rebuild); + // 3 is no longer in the BBB inventory (e.g. back in MODELS after a reload) but still made the autosave + EXPECT_EQ(recovery.consume, (std::vector{ 2, 3 })); + // 1 was opened after the autosave: it goes back + EXPECT_EQ(recovery.giveBack, (std::vector{ 1 })); +} + +TEST_F(BrickByBrickTests, AnEmptyAutosaveRebuildsNothing) { + IBbbAutosave::Info autosave{ HEADER_ONLY, { 1 }, 0 }; + const auto recovery = BrickByBrick::PlanRecovery({ 1 }, autosave); + EXPECT_FALSE(recovery.rebuild); + EXPECT_TRUE(recovery.consume.empty()); + EXPECT_EQ(recovery.giveBack, (std::vector{ 1 })); +} + +TEST_F(BrickByBrickTests, BuildInventoriesLoadIntoTheNormalOnes) { + EXPECT_EQ(BrickByBrick::InventoryToLoadInto(eInventoryType::MODELS_IN_BBB), eInventoryType::MODELS); + EXPECT_EQ(BrickByBrick::InventoryToLoadInto(eInventoryType::BRICKS_IN_BBB), eInventoryType::BRICKS); + for (const auto type : { eInventoryType::ITEMS, eInventoryType::BRICKS, eInventoryType::MODELS, eInventoryType::VAULT_MODELS, eInventoryType::TEMP_MODELS }) { + EXPECT_EQ(BrickByBrick::InventoryToLoadInto(type), type); + } +} + +TEST_F(BrickByBrickTests, ModelItemConfigKeepsTheModelsIds) { + const auto config = BrickByBrick::ModelItemConfig(1152921508340227662, 1152921510759098851, "10447,0,0,0,0"); + EXPECT_EQ(BrickByBrick::ConfigObjectId(config, u"blueprintid"), 1152921508340227662); + EXPECT_EQ(BrickByBrick::ConfigObjectId(config, u"userModelID"), 1152921510759098851); + EXPECT_EQ(config.find(u"userModelBehaviors")->second->GetValueAsString(), "10447,0,0,0,0"); + EXPECT_EQ(config.find(u"userModelHasBhvr")->second->GetValueAsString(), "1"); + EXPECT_EQ(config.find(u"userModelPhysicsType")->second->GetValueAsString(), "2"); + + const auto plain = BrickByBrick::ModelItemConfig(5, 6); + EXPECT_EQ(plain.find(u"userModelHasBhvr")->second->GetValueAsString(), "0"); + EXPECT_EQ(plain.find(u"userModelBehaviors")->second->GetValueAsString(), "0,0,0,0,0"); + EXPECT_EQ(BrickByBrick::ConfigObjectId(plain, u"missing"), LWOOBJID_EMPTY); +} + +TEST_F(BrickByBrickTests, AutosaveItemsListRoundTrips) { + const std::vector items = { 1152921510759098815, 2, 3 }; + EXPECT_EQ(BbbAutosaveItems::Join(items), "1152921510759098815,2,3"); + EXPECT_EQ(BbbAutosaveItems::Parse(BbbAutosaveItems::Join(items)), items); + EXPECT_TRUE(BbbAutosaveItems::Parse("").empty()); + EXPECT_EQ(BbbAutosaveItems::Parse("4,,x,5"), (std::vector{ 4, 5 })); +} + +TEST_F(BrickByBrickTests, QuickSaveIsKeptUntilClearedWithTheBareHeader) { + Entity player(1152921510000000001, info); + BrickByBrick::Autosave(player, ONE_CHUNK); + const auto saved = Database::Get()->GetBbbAutosave(player.GetObjectID()); + ASSERT_TRUE(saved.has_value()); + EXPECT_EQ(saved->lxfml, ONE_CHUNK); + EXPECT_GT(saved->updatedAt, 0); + + BrickByBrick::Autosave(player, HEADER_ONLY); + EXPECT_FALSE(Database::Get()->GetBbbAutosave(player.GetObjectID()).has_value()); +} + +TEST_F(BrickByBrickTests, AReturnedModelIsNeverUsedUpByTheAutosave) { + Entity player(1152921510000000002, info); + Database::Get()->SetBbbAutosave(player.GetObjectID(), { ONE_CHUNK, { 7, 8 }, 1 }); + BrickByBrick::ReturnModel(player, 7, false, NiPoint3Constant::ZERO, QuatUtils::IDENTITY); + const auto saved = Database::Get()->GetBbbAutosave(player.GetObjectID()); + ASSERT_TRUE(saved.has_value()); + EXPECT_EQ(saved->sourceItems, (std::vector{ 8 })); +} diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index b447ddd7a..610b2d816 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -11,6 +11,7 @@ set(DGAMETEST_SOURCES "CollisionFilterTests.cpp" "ItemSetPassiveAbilityTests.cpp" "PowerUpStatisticTests.cpp" + "BrickByBrickTests.cpp" ) add_subdirectory(dComponentsTests) diff --git a/tests/dGameTests/dGameMessagesTests/BuildingMessagesTests.cpp b/tests/dGameTests/dGameMessagesTests/BuildingMessagesTests.cpp index 384c565cb..d9f249b8e 100644 --- a/tests/dGameTests/dGameMessagesTests/BuildingMessagesTests.cpp +++ b/tests/dGameTests/dGameMessagesTests/BuildingMessagesTests.cpp @@ -386,3 +386,52 @@ TEST_F(BuildingMessagesTests, GoldenBytes) { load.WritePacket(bitStream); EXPECT_PACKET_EQ(FromHex("53 05 00 17 00 00 00 00 01 03 02 00 00 00 00 00 00 de 03 00 00 00 00 00 00"), FromBitStream(bitStream)); } + +// Messages added for the brick by brick workflow (docs/BuildWorkflow.md), laid out as the 1.10.64 client reads them. +TEST_F(BuildingMessagesTests, BrickModeMessagesRoundTrip) { + GameMessages::ActivateBrickMode enter; + enter.buildObjectID = 0x1122334455667788; + enter.enterBuildFromWorld = false; + enter.enterFlag = true; + // flag 1 + the build area, flag 0 (build type 2, on a property), 0, 1 (0x00d8ecb0) + EXPECT_PACKET_EQ(FromHex("c4 3b b3 2a a2 19 91 08 90", 68), Payload(enter)); + const auto enterCopy = RoundTrip(enter); + EXPECT_EQ(enterCopy.buildObjectID, enter.buildObjectID); + EXPECT_EQ(enterCopy.buildType, 2); + EXPECT_FALSE(enterCopy.enterBuildFromWorld); + EXPECT_TRUE(enterCopy.enterFlag); + ExpectTruncatedFails(enter); + + GameMessages::ActivateBrickMode leave; + leave.buildType = 1; + leave.enterBuildFromWorld = true; + leave.enterFlag = false; + const auto leaveCopy = RoundTrip(leave); + EXPECT_EQ(leaveCopy.buildObjectID, LWOOBJID_EMPTY); + EXPECT_EQ(leaveCopy.buildType, 1); + EXPECT_FALSE(leaveCopy.enterFlag); + + // The client clears its autosave with the bare sd0 header: u32 size, then the bytes (0x00f2af60) + GameMessages::SetBBBAutosave clear; + clear.lxfmlDataCompressed = std::string("sd0\x01\xff", 5); + EXPECT_PACKET_EQ(FromHex("05 00 00 00 73 64 30 01 ff"), Payload(clear)); + EXPECT_EQ(RoundTrip(clear).lxfmlDataCompressed, clear.lxfmlDataCompressed); + ExpectTruncatedFails(clear); + + GameMessages::SetBBBAutosave empty; + EXPECT_PACKET_EQ(FromHex("00 00 00 00"), Payload(empty)); + EXPECT_TRUE(RoundTrip(empty).lxfmlDataCompressed.empty()); + + // A size larger than what is left is dropped rather than read past the end + RakNet::BitStream tooLong; + tooLong.Write(100); + tooLong.Write(1); + GameMessages::SetBBBAutosave read; + EXPECT_FALSE(read.Deserialize(tooLong)); + + GameMessages::RebuildBBBAutosaveMsg rebuilt; + rebuilt.count = 2; + EXPECT_PACKET_EQ(FromHex("02 00 00 00"), Payload(rebuilt)); + EXPECT_EQ(RoundTrip(rebuilt).count, 2); + ExpectTruncatedFails(rebuilt); +} diff --git a/tests/dGameTests/dGameMessagesTests/PropertyMessagesTests.cpp b/tests/dGameTests/dGameMessagesTests/PropertyMessagesTests.cpp index 24f00f0cf..d268572cf 100644 --- a/tests/dGameTests/dGameMessagesTests/PropertyMessagesTests.cpp +++ b/tests/dGameTests/dGameMessagesTests/PropertyMessagesTests.cpp @@ -619,3 +619,9 @@ TEST_F(PropertyMessagesTests, GoldenBytes) { enter.index = -1; EXPECT_PACKET_EQ(FromHex("ff ff ff ff 80", 33), Payload(enter)); } + +// A live server's RequeryPropertyModels (after a brick by brick save) had no payload +TEST_F(PropertyMessagesTests, RequeryPropertyModelsHasNoPayload) { + GameMessages::RequeryPropertyModels requery; + EXPECT_EQ(Payload(requery).bits, 0u); +}