mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-04 11:53:44 +00:00
Brighter icons: a world light, a fill from the camera, a highlight, exposure and contrast; with the defaults the icons' mean luminance matches the game's own model icons (118 against 120 on a scratch set). Every icon parameter is listed once (UgcIconParams: key, setting, range, default); the settings, the dashboard's settings entries and the icon editor come from that list. Presets per kind (player models, each car or rocket build type from ModularBuildComponent) and overrides per model or module combination are in ugc_icon_settings. Saved models wait ugc_debounce_seconds (sharedconfig) after the owner's last save before they're made (ugc.process_after); a client asking for one, the owner leaving the world or a reset ends the wait. Cars and rockets: one icon per combination of modules (sorted LOTs), shared by every build of it; builds of a combination made already are marked made right away, the client's per-blueprint downloads serve the shared files. The dashboard can delete one item's files, purge by filter or all, preview icons with any values on the UGC server (/admin routes, master password), save presets and overrides, and draw a kind's icons again (icons only). Migrations dlu/mysql/86 and dlu/sqlite/69. Not done yet: the dashboard editor's lighting controls in the page script (routes are there), docs for it, the empty-model state, /ugc?item= links, the shared fetch helper; the storage size and property loading bugs are next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
396 lines
16 KiB
C++
396 lines
16 KiB
C++
#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 "UgcKeys.h"
|
|
#include "User.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cstring>
|
|
#include <ctime>
|
|
#include <fstream>
|
|
#include <ranges>
|
|
#include <sstream>
|
|
|
|
namespace {
|
|
// Every item in one inventory, copied so the inventory can change while going through them
|
|
std::vector<Item*> ItemsIn(InventoryComponent& inventory, const eInventoryType type) {
|
|
std::vector<Item*> 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<LWOOBJID> IdsIn(InventoryComponent& inventory, const eInventoryType type) {
|
|
std::vector<LWOOBJID> 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<SavedModel> StoreModels(Entity& player, const std::string& sd0Data, const std::string& debugName) {
|
|
std::vector<SavedModel> 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;
|
|
}
|
|
|
|
// 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;
|
|
const auto processAfter = UgcDebounce::ProcessAfter(std::time(nullptr), debounce);
|
|
for (const auto& part : Lxfml::Split(lxfml)) {
|
|
const auto [modelId, blueprintId] = ObjectIDManager::GetNewModelIDs();
|
|
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);
|
|
|
|
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<const char*>(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<eDeleteReason>(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<LWOOBJID>& modelsInBbb, const std::optional<IBbbAutosave::Info>& 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<LWOOBJID>(u"blueprintid", blueprintId);
|
|
config.Insert<LWOOBJID>(u"userModelID", userModelId);
|
|
config.Insert<std::u16string>(u"userModelName", u"");
|
|
config.Insert<std::u16string>(u"userModelDesc", u"");
|
|
config.Insert<bool>(u"userModelHasBhvr", !behaviors.empty() && behaviors.find_first_not_of("0,") != std::string::npos);
|
|
config.Insert<std::string>(u"userModelBehaviors", behaviors.empty() ? "0,0,0,0,0" : behaviors);
|
|
config.Insert<std::string>(u"userModelBehaviorSourceIDs", "0,0,0,0,0");
|
|
config.Insert<bool>(u"userModelOpt", true);
|
|
config.Insert<int32_t>(u"userModelMod", 1);
|
|
config.Insert<int32_t>(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<LWOOBJID>(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<InventoryComponent>();
|
|
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<InventoryComponent>();
|
|
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<LWOOBJID>(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<InventoryComponent>();
|
|
IBbbAutosave::Info info;
|
|
info.lxfml = sd0;
|
|
if (inventory) info.sourceItems = IdsIn(*inventory, eInventoryType::MODELS_IN_BBB);
|
|
info.updatedAt = std::chrono::duration_cast<std::chrono::seconds>(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<InventoryComponent>();
|
|
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<InventoryComponent>();
|
|
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<InventoryComponent>();
|
|
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<int32_t>(rebuilt);
|
|
message.SendToClient(player.GetSystemAddress());
|
|
}
|
|
return rebuilt;
|
|
}
|