diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 7b91c6edb..e922583e4 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -180,6 +180,7 @@ namespace { c.Add(Bool(WORLD, "allow_nameplate_off", "Players can hide their nameplate", "Staff always can; this lets players (GM 0) turn off the name above their head too.", false)); c.Add(Bool(WORLD, "allow_players_to_skip_cinematics", "Players can skip cinematics", "Most cutscenes get a skip option.", false)); c.Add(Bool(WORLD, "auto_reject_empty_properties", "Reject empty properties", "Properties made public without models are rejected automatically.", false)); + c.Add(Bool(WORLD, "property_bff_build", "Best friends build on properties", "Best friends of a property's owner can join the owner's build mode there: place, move and pick up models, build brick by brick and edit behaviors. Only the owner starts build mode. Off (as live): only the owner builds.", false)); c.Add(Unused(Bool(WORLD, "disable_drops", "Turn off loot drops", "Not read by this version.", false))); c.AddSection("Hardcore mode", "Players lose items, coins and U-score when they die, and earn extra U-score from enemies."); diff --git a/dGame/Entity.cpp b/dGame/Entity.cpp index 02eb83e8a..6ad34c39c 100644 --- a/dGame/Entity.cpp +++ b/dGame/Entity.cpp @@ -167,6 +167,9 @@ Entity::~Entity() { return; } + // Ends their building on the property; the owner leaving can stop their best friends building + if (auto* property = PropertyManagementComponent::Instance()) property->OnPlayerRemoved(*this); + auto* zoneControl = Game::entityManager->GetZoneControlEntity(); if (zoneControl) { zoneControl->GetScript()->OnPlayerExit(zoneControl, this); diff --git a/dGame/dComponents/PropertyManagementComponent.cpp b/dGame/dComponents/PropertyManagementComponent.cpp index 6847d48b1..4befcd900 100644 --- a/dGame/dComponents/PropertyManagementComponent.cpp +++ b/dGame/dComponents/PropertyManagementComponent.cpp @@ -35,6 +35,8 @@ #include "BrickByBrick.h" #include "eLootSourceType.h" #include "eKillType.h" +#include "PropertyBuilders.h" +#include "BuildingMessages.h" PropertyManagementComponent* PropertyManagementComponent::instance = nullptr; @@ -91,6 +93,77 @@ PropertyManagementComponent::PropertyManagementComponent(Entity* parent, const i Load(); } + + // Turning property_bff_build on or off changes who can build right away + static bool configHandlerAdded = false; + if (!configHandlerAdded && Game::config) { + Game::config->AddConfigHandler([]() { if (instance) instance->UpdateBuildRights(); }); + configHandlerAdded = true; + } +} + +PropertyManagementComponent::~PropertyManagementComponent() { + if (instance == this) instance = nullptr; +} + +bool PropertyManagementComponent::IsBestFriend(const LWOOBJID player) const { + if (owner == LWOOBJID_EMPTY || player == owner) return false; + const auto cached = bestFriends.find(player); + if (cached != bestFriends.end()) return cached->second; + const auto status = Database::Get()->GetBestFriendStatus(player, owner); + const bool isBestFriend = status && status->bestFriendStatus == 3; + bestFriends[player] = isBestFriend; + return isBestFriend; +} + +bool PropertyManagementComponent::CanBuild(const Entity& player) const { + const bool bestFriendsBuild = PropertyBuilders::BestFriendsBuild(); + PropertyBuilders::Player state{ .id = player.GetObjectID(), .isBuilding = builders.contains(player.GetObjectID()) }; + // Only a best friend needs the lookup + if (bestFriendsBuild && state.id != owner) state.isBestFriend = IsBestFriend(state.id); + return PropertyBuilders::CanBuild(state, owner, builders.contains(owner), bestFriendsBuild); +} + +void PropertyManagementComponent::OnPlayerLoaded(Entity& player) { + IsBestFriend(player.GetObjectID()); +} + +void PropertyManagementComponent::OnPlayerRemoved(Entity& player) { + const auto id = player.GetObjectID(); + const bool wasBuilding = EndBuilding(player); + sentBuildRights.erase(id); + bestFriends.erase(id); + // The owner leaving build mode stops best friends who aren't building from joining + if (wasBuilding) UpdateBuildRights(); +} + +void PropertyManagementComponent::UpdateBuildRights() { + for (auto* player : PlayerManager::GetAllPlayers()) { + if (!player) continue; + const auto id = player->GetObjectID(); + const auto sent = sentBuildRights.find(id); + // Never sent the property's data: the client has no rights to take away + const bool couldBuild = sent != sentBuildRights.end() && sent->second; + const bool canBuild = CanBuild(*player); + if (couldBuild == canBuild) continue; + + if (!canBuild && builders.contains(id)) { + // Out of build mode, as when they leave it themselves + GameMessages::SetBuildModeConfirmed confirmed; + confirmed.target = id; + confirmed.start = false; + confirmed.warnVisitors = true; + confirmed.playerId = id; + confirmed.startPos = player->GetPosition(); + confirmed.Send(player->GetSystemAddress()); + if (player->GetCharacter()) player->GetCharacter()->SetBuildMode(false); + EndBuilding(*player); + Game::zoneManager->GetZoneControlObject()->OnZonePropertyEditEnd(); + ChatPackets::SendSystemMessage(player->GetSystemAddress(), u"You can't build on this property any more."); + } + + OnQueryPropertyData(player, player->GetSystemAddress()); + } } LWOOBJID PropertyManagementComponent::GetOwnerId() const { @@ -102,7 +175,7 @@ Entity* PropertyManagementComponent::GetOwner() const { } void PropertyManagementComponent::SetOwner(Entity* value) { - owner = value->GetObjectID(); + SetOwnerId(value->GetObjectID()); } std::vector PropertyManagementComponent::GetPaths() const { @@ -190,7 +263,7 @@ void PropertyManagementComponent::UpdatePropertyDetails(std::string name, std::s Database::Get()->UpdatePropertyDetails(info); DashboardNotify::Changed("properties", propertyId); - OnQueryPropertyData(GetOwner(), UNASSIGNED_SYSTEM_ADDRESS); + OnQueryPropertyData(nullptr, UNASSIGNED_SYSTEM_ADDRESS); } bool PropertyManagementComponent::Claim(const LWOOBJID playerId) { @@ -251,10 +324,20 @@ bool PropertyManagementComponent::Claim(const LWOOBJID playerId) { return true; } -void PropertyManagementComponent::OnStartBuilding() { - auto* ownerEntity = GetOwner(); +void PropertyManagementComponent::OnStartBuilding(Entity& builder) { + const bool first = builders.empty(); + if (!builders.insert(builder.GetObjectID()).second) return; - if (ownerEntity == nullptr) return; + // The owner starting lets their best friends join + if (builder.GetObjectID() == owner) UpdateBuildRights(); + + auto inventoryComponent = builder.GetComponent(); + + // Push equipped items + if (inventoryComponent) inventoryComponent->PushEquippedItems(); + + // Someone else is already building: the property is already private and paused + if (!first) return; const auto players = PlayerManager::GetAllPlayers(); @@ -274,16 +357,13 @@ void PropertyManagementComponent::OnStartBuilding() { } } + // Everyone who can't build here leaves for (auto* player : players) { - if (player == ownerEntity) continue; + if (!player || CanBuild(*player)) continue; auto* characterComponent = player->GetComponent(); if (characterComponent) characterComponent->SendToZone(zoneId); } - auto inventoryComponent = ownerEntity->GetComponent(); - - // Push equipped items - if (inventoryComponent) inventoryComponent->PushEquippedItems(); for (auto modelID : models | std::views::keys) { auto* model = Game::entityManager->GetEntity(modelID); @@ -302,13 +382,22 @@ void PropertyManagementComponent::OnStartBuilding() { } } -void PropertyManagementComponent::OnFinishBuilding() { - auto* ownerEntity = GetOwner(); +void PropertyManagementComponent::OnFinishBuilding(const Entity& builder) { + // A best friend who left can't come back until the owner builds; the owner leaving stops the others joining + if (EndBuilding(builder)) UpdateBuildRights(); +} - if (ownerEntity == nullptr) return; +bool PropertyManagementComponent::EndBuilding(const Entity& builder) { + if (builders.erase(builder.GetObjectID()) == 0) return false; + + // Others are still building: keep the property private and paused + if (!builders.empty()) { + Save(); + return true; + } UpdateApprovedStatus(false); - + SetPrivacyOption(originalPrivacyOption); Save(); @@ -328,9 +417,10 @@ void PropertyManagementComponent::OnFinishBuilding() { for (auto* const entity : Game::entityManager->GetEntitiesInGroup("SpawnedPropertyEnemies")) { if (entity) entity->Smash(); } + return true; } -Entity* PropertyManagementComponent::SpawnModel(const LOT lot, const LWOOBJID modelId, const NiPoint3& position, const NiQuaternion& rotation, const LwoNameValue& config) { +Entity* PropertyManagementComponent::SpawnModel(const LOT lot, const LWOOBJID modelId, const NiPoint3& position, const NiQuaternion& rotation, const LwoNameValue& config, const LWOOBJID placer) { auto* node = new SpawnerNode(); node->position = position; node->rotation = rotation; @@ -361,15 +451,17 @@ Entity* PropertyManagementComponent::SpawnModel(const LOT lot, const LWOOBJID mo auto* model = spawner->Spawn(); if (!model) return nullptr; - // Placed while the owner is editing: stays still until they finish (OnFinishBuilding resumes every model) + // Placed while someone is building: stays still until the last builder finishes (OnFinishBuilding resumes every + // model) auto* modelComponent = model->GetComponent(); if (modelComponent) modelComponent->Pause(); models.insert_or_assign(model->GetObjectID(), spawnerId); + placedBy.insert_or_assign(modelId, placer); return model; } -LWOOBJID PropertyManagementComponent::PlaceModelFromItem(Item& item, const NiPoint3& position, const NiQuaternion& rotation) { +LWOOBJID PropertyManagementComponent::PlaceModelFromItem(const Entity& builder, Item& item, const NiPoint3& position, const NiQuaternion& rotation) { LWOOBJID modelId = LWOOBJID_EMPTY; Entity* model = nullptr; @@ -389,35 +481,38 @@ LWOOBJID PropertyManagementComponent::PlaceModelFromItem(Item& item, const NiPoi 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); + model = SpawnModel(BrickByBrick::MODEL_OBJECT_LOT, modelId, position, rotation, modelConfig, builder.GetObjectID()); } 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, {}); + model = SpawnModel(item.GetLot(), modelId, position, rotation, {}, builder.GetObjectID()); } 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 + // Straight to the database: the model must not be lost if the server stops before the builder 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); + // Each player's own character, as PropertyContentsFromClient answers: the owner may not be here to target + for (const auto* player : PlayerManager::GetAllPlayers()) { + if (!player) continue; + msg.target = player->GetObjectID(); + msg.Send(player->GetSystemAddress()); + } } -void PropertyManagementComponent::UpdateModelPosition(const LWOOBJID id, const NiPoint3 position, NiQuaternion rotation) { +void PropertyManagementComponent::UpdateModelPosition(Entity& builder, 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* entity = &builder; auto* inventoryComponent = entity->GetComponent(); if (inventoryComponent == nullptr) return; @@ -433,7 +528,7 @@ void PropertyManagementComponent::UpdateModelPosition(const LWOOBJID id, const N rotation = { rotation.w, rotation.z, rotation.y, rotation.x }; } - const auto modelId = PlaceModelFromItem(*item, position, rotation); + const auto modelId = PlaceModelFromItem(builder, *item, position, rotation); if (modelId == LWOOBJID_EMPTY) return; // As a live server answered a placed model @@ -464,15 +559,9 @@ void PropertyManagementComponent::UpdateModelPosition(const LWOOBJID id, const N if (missionComponent != nullptr) missionComponent->Progress(eMissionTaskType::PLACE_MODEL, 0); } -void PropertyManagementComponent::DeleteModel(const LWOOBJID id, const int deleteReason) { +void PropertyManagementComponent::DeleteModel(Entity& builder, const LWOOBJID id, const int deleteReason) { LOG("Delete model: (%llu) (%i)", id, deleteReason); - auto* entity = GetOwner(); - if (entity == nullptr) return; - - auto* inventoryComponent = entity->GetComponent(); - if (inventoryComponent == nullptr) return; - auto* model = Game::entityManager->GetEntity(id); if (model == nullptr) { LOG("Failed to find model entity"); @@ -486,6 +575,32 @@ void PropertyManagementComponent::DeleteModel(const LWOOBJID id, const int delet } const auto modelId = index->second; + + // The model goes back to whoever placed it + const auto placer = GetPlacedBy(modelId); + auto* placerEntity = placer == builder.GetObjectID() ? &builder : PlayerManager::GetPlayer(placer); + const auto placerName = [&]() -> std::string { + if (placerEntity && placerEntity->GetCharacter()) return placerEntity->GetCharacter()->GetName(); + const auto info = Database::Get()->GetCharacterInfo(placer); + return info ? info->name : "another player"; + }; + switch (PropertyBuilders::PlanModelReturn(builder.GetObjectID(), placer, placerEntity != nullptr, deleteReason)) { + case PropertyBuilders::eModelReturn::PLACER_AWAY: + LOG("%llu picked up model %llu placed by %llu, who isn't here; leaving it on the property", builder.GetObjectID(), modelId, placer); + ChatPackets::SendSystemMessage(builder.GetSystemAddress(), "This model is " + placerName() + "'s. It stays on the property until they are here to get it back."); + return; + case PropertyBuilders::eModelReturn::NOT_THEIRS: + ChatPackets::SendSystemMessage(builder.GetSystemAddress(), "This model is " + placerName() + "'s. Only they can take it apart."); + return; + default: + break; + } + auto& receiver = *placerEntity; + const bool toBuilder = &receiver == &builder; + + auto* inventoryComponent = receiver.GetComponent(); + if (inventoryComponent == nullptr) return; + const auto removal = BrickByBrick::PlanModelRemoval(deleteReason); // Every way off the property puts the model in MODELS; taking it apart then opens it in brick by brick building @@ -498,14 +613,17 @@ void PropertyManagementComponent::DeleteModel(const LWOOBJID id, const int delet subKey = modelId; } + // Only the player picking it up carries it const auto received = inventoryComponent->ReceiveItem(LWOOBJID_EMPTY, itemLot, 1, eLootSourceType::PROPERTY, config, subKey, false, - { .inventory = eInventoryType::MODELS, .showFlyingLoot = false, .equip = removal.equip }); + { .inventory = eInventoryType::MODELS, .showFlyingLoot = false, .equip = toBuilder && removal.equip }); if (received.id == LWOOBJID_EMPTY) { - LOG("Could not give model %llu back to %llu, leaving it on the property", modelId, entity->GetObjectID()); + LOG("Could not give model %llu back to %llu, leaving it on the property", modelId, receiver.GetObjectID()); + if (!toBuilder) ChatPackets::SendSystemMessage(builder.GetSystemAddress(), "This model is " + placerName() + "'s, and they have no room for it. It stays on the property."); return; } models.erase(index); + placedBy.erase(modelId); Game::entityManager->DestructEntity(model); auto* spawner = Game::zoneManager->GetSpawner(modelId); if (spawner != nullptr) { @@ -517,36 +635,47 @@ void PropertyManagementComponent::DeleteModel(const LWOOBJID id, const int delet // Straight to the database, as for placing Save(); - if (removal.notifyPostDelete) { + if (removal.notifyPostDelete && toBuilder) { auto* item = inventoryComponent->FindItemById(received.id); GameMessages::HandleUGCEquipPostDeleteBasedOnEditMode msg; - msg.target = entity->GetObjectID(); + msg.target = builder.GetObjectID(); msg.invItem = received.id; msg.itemsTotal = item ? item->GetCount() : 1; - msg.Send(entity->GetSystemAddress()); + msg.Send(builder.GetSystemAddress()); + } + + if (!toBuilder) { + const auto builderName = builder.GetCharacter() ? builder.GetCharacter()->GetName() : "Another player"; + ChatPackets::SendSystemMessage(builder.GetSystemAddress(), "This model is " + placerName() + "'s, so it went back to them."); + ChatPackets::SendSystemMessage(receiver.GetSystemAddress(), builderName + " picked up one of your models. It is back in your models."); } SendModelsOnProperty(); { GameMessages::PlaceModelResponse msg; - msg.target = entity->GetObjectID(); + msg.target = builder.GetObjectID(); msg.response = BrickByBrick::PLACE_MODEL_REMOVED; - msg.Send(entity->GetSystemAddress()); + msg.Send(builder.GetSystemAddress()); } switch (static_cast(deleteReason)) { case BrickByBrick::eDeleteReason::PICKING_MODEL_UP: - Game::entityManager->GetZoneControlEntity()->OnZonePropertyModelPickedUp(entity); + Game::entityManager->GetZoneControlEntity()->OnZonePropertyModelPickedUp(&builder); break; case BrickByBrick::eDeleteReason::RETURNING_MODEL_TO_INVENTORY: - Game::entityManager->GetZoneControlEntity()->OnZonePropertyModelRemoved(entity); + Game::entityManager->GetZoneControlEntity()->OnZonePropertyModelRemoved(&builder); break; default: break; } } +LWOOBJID PropertyManagementComponent::GetPlacedBy(const LWOOBJID modelId) const { + const auto placer = placedBy.find(modelId); + return PropertyBuilders::Placer(placer != placedBy.end() ? placer->second : LWOOBJID_EMPTY, owner); +} + void PropertyManagementComponent::UpdateApprovedStatus(const bool value, const std::string& rejectionReason) { if (owner == LWOOBJID_EMPTY) return; @@ -627,20 +756,16 @@ void PropertyManagementComponent::Load() { auto* model = spawner->Spawn(); models.insert_or_assign(model->GetObjectID(), spawnerId); + if (databaseModel.placedBy != LWOOBJID_EMPTY) placedBy.insert_or_assign(databaseModel.id, databaseModel.placedBy); } } void PropertyManagementComponent::Save() { - if (propertyId == LWOOBJID_EMPTY) { + // From the property's own ids: it saves whoever is here (the owner may not be) + if (propertyId == LWOOBJID_EMPTY || owner == LWOOBJID_EMPTY) { return; } - const auto* const owner = GetOwner(); - if (!owner) return; - - const auto* const character = owner->GetCharacter(); - if (!character) return; - auto present = Database::Get()->GetPropertyModels(propertyId); std::vector modelIds; @@ -659,12 +784,13 @@ void PropertyManagementComponent::Save() { if (!modelComponent) continue; const auto modelBehaviors = modelComponent->GetBehaviorsForSave(); - // save the behaviors of the model + // save the behaviors of the model, as the model's placer's + const auto placer = GetPlacedBy(id); for (const auto& [behaviorId, behaviorStr] : modelBehaviors) { if (behaviorStr.empty() || behaviorId == -1 || behaviorId == 0) continue; IBehaviors::Info info{ .behaviorId = behaviorId, - .characterId = character->GetID(), + .characterId = placer, .behaviorInfo = behaviorStr }; Database::Get()->AddBehavior(info); @@ -685,6 +811,7 @@ void PropertyManagementComponent::Save() { for (auto i = 0; i < model.behaviors.size(); i++) { model.behaviors[i] = modelBehaviors[i].first; } + model.placedBy = placer; Database::Get()->InsertNewPropertyModel(propertyId, model, "Objects_" + std::to_string(model.lot) + "_name"); } else { @@ -795,10 +922,29 @@ void PropertyManagementComponent::OnQueryPropertyData(Entity* originator, const message.pathPositions = GetPaths(); LOG("(%llu) sending property data (%d)", author, true); - message.Send(UNASSIGNED_SYSTEM_ADDRESS); + // Each player gets their own: it tells builders they own the property + if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS) { + for (auto* player : PlayerManager::GetAllPlayers()) { + if (player) SendPropertyData(*player, message); + } + } else { + auto* player = PlayerManager::GetPlayer(sysAddr); + if (player) SendPropertyData(*player, message); + } // send rejection here? } +void PropertyManagementComponent::SendPropertyData(const Entity& player, const GameMessages::DownloadPropertyData& message) { + const bool canBuild = CanBuild(player); + sentBuildRights[player.GetObjectID()] = canBuild; + + // The client lets only the property's owner build (ownerId == its character); a best friend who can build is told + // they own it, while ownerName stays the owner's + auto forPlayer = message; + if (canBuild) forPlayer.ownerId = player.GetObjectID(); + forPlayer.Send(player.GetSystemAddress()); +} + void PropertyManagementComponent::OnUse(Entity* originator) { OnQueryPropertyData(originator, UNASSIGNED_SYSTEM_ADDRESS); GameMessages::OpenPropertyManagement msg; @@ -807,6 +953,7 @@ void PropertyManagementComponent::OnUse(Entity* originator) { } void PropertyManagementComponent::SetOwnerId(const LWOOBJID value) { + if (owner != value) bestFriends.clear(); owner = value; } @@ -829,5 +976,5 @@ void PropertyManagementComponent::ApplyModeration(const bool approved, const std rejectionReason = approved ? "" : reason; // The dashboard makes rejected properties private; don't let a later save here publish it again if (!approved) privacyOption = PropertyPrivacyOption::Private; - OnQueryPropertyData(GetOwner(), UNASSIGNED_SYSTEM_ADDRESS); + OnQueryPropertyData(nullptr, UNASSIGNED_SYSTEM_ADDRESS); } diff --git a/dGame/dComponents/PropertyManagementComponent.h b/dGame/dComponents/PropertyManagementComponent.h index cdb15bd1b..217419506 100644 --- a/dGame/dComponents/PropertyManagementComponent.h +++ b/dGame/dComponents/PropertyManagementComponent.h @@ -2,6 +2,8 @@ #include "ePropertyPrivacyOption.h" #include +#include +#include #include "Entity.h" #include "Component.h" #include "eReplicaComponentType.h" @@ -9,6 +11,10 @@ class Item; +namespace GameMessages { + struct DownloadPropertyData; +} + /** * Main component that handles interactions with a property, generally the plaques you see on properties. */ @@ -16,8 +22,24 @@ class PropertyManagementComponent final : public Component { public: static constexpr eReplicaComponentType ComponentType = eReplicaComponentType::PROPERTY_MANAGEMENT; PropertyManagementComponent(Entity* parent, const int32_t componentID); + ~PropertyManagementComponent() override; static PropertyManagementComponent* Instance(); + /** + * Whether the player can build on this property: its owner, or with property_bff_build a best friend of the owner + * while the owner is building (and, once in build mode, until they leave it) + */ + bool CanBuild(const Entity& player) const; + + // A player finished loading into this world: looks up whether they are a best friend of the owner + void OnPlayerLoaded(Entity& player); + + // A player left this world: ends their building and updates who else can build + void OnPlayerRemoved(Entity& player); + + // Tells every player whose right to build changed (CanBuild) and ends building for those who lost it + void UpdateBuildRights(); + /** * Event handler for when an entity requests information about this property, will send back whether it's owned, etc. * @param originator the entity that triggered the event @@ -90,44 +112,51 @@ public: bool Claim(LWOOBJID playerId); /** - * Event triggered when the owner of the property starts building, will kick other entities out + * Event triggered when a player who can build starts building. The first one to start (the owner) makes the property + * private, pauses the models and sends away the players who can't build. */ - void OnStartBuilding(); + void OnStartBuilding(Entity& builder); /** - * Event triggered when the owner of the property finished building, will re-apply this property for moderation - * request. + * Event triggered when a builder finished building. When the last one finishes the property is re-applied for + * moderation, gets its privacy back and the models run again. */ - void OnFinishBuilding(); + void OnFinishBuilding(const Entity& builder); /** - * Updates the position of a model on the property - * @param id the ID of the model to reposition + * Places a model from the builder's inventory on the property + * @param builder the player placing the model + * @param id the ID of the model item to place * @param position the position to place the model on * @param rotation the rotation to place the model on */ - void UpdateModelPosition(LWOOBJID id, NiPoint3 position, NiQuaternion rotation); + void UpdateModelPosition(Entity& builder, LWOOBJID id, NiPoint3 position, NiQuaternion rotation); /** - * Deletes a model for a property + * Takes a model off the property, into the inventory of the player who placed it + * @param builder the player taking the model off * @param id the ID of the model to delete * @param deleteReason the reason of the deletion, e.g. picked up or destroyed (in case of UGC) */ - void DeleteModel(LWOOBJID id, int deleteReason); + void DeleteModel(Entity& builder, 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 + * @param placedBy the player who placed it */ - Entity* SpawnModel(LOT lot, LWOOBJID modelId, const NiPoint3& position, const NiQuaternion& rotation, const LwoNameValue& config); + Entity* SpawnModel(LOT lot, LWOOBJID modelId, const NiPoint3& position, const NiQuaternion& rotation, const LwoNameValue& config, LWOOBJID placedBy); /** - * Places a model item on the property, uses the item up and saves the property + * Places a model item of the builder's 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); + LWOOBJID PlaceModelFromItem(const Entity& builder, Item& item, const NiPoint3& position, const NiQuaternion& rotation); + + // The player who placed a model (by its id in properties_contents); the owner for models placed before this was kept + LWOOBJID GetPlacedBy(LWOOBJID modelId) const; // GetModelsOnProperty with every model on the property void SendModelsOnProperty() const; @@ -175,6 +204,16 @@ public: void OnChatMessageReceived(const std::string& sMessage) const; private: + // Sends the property's data to one player; a player who can build is told they own it, which is the client's only + // check before editing (the owner's name stays the owner's) + void SendPropertyData(const Entity& player, const GameMessages::DownloadPropertyData& message); + + // Whether the player is a best friend of the owner (looked up the first time) + bool IsBestFriend(LWOOBJID player) const; + + // OnFinishBuilding without updating who can build; false if they weren't building + bool EndBuilding(const Entity& builder); + /** * This */ @@ -249,4 +288,17 @@ private: * The privacy setting before it was changed, saved to set back after a player finishes building */ PropertyPrivacyOption originalPrivacyOption = PropertyPrivacyOption::Private; + + // Whether each player on the property is a best friend of the owner, looked up once per player (again when the + // owner changes) + mutable std::map bestFriends; + + // Whether each player on the property was last told they can build (see SendPropertyData) + std::map sentBuildRights; + + // Players building right now + std::set builders; + + // Who placed each model, by its id in properties_contents (LWOOBJID_EMPTY: the owner) + std::map placedBy; }; diff --git a/dGame/dGameMessages/BuildingMessages.cpp b/dGame/dGameMessages/BuildingMessages.cpp index 76e0c6d24..eb82a1d14 100644 --- a/dGame/dGameMessages/BuildingMessages.cpp +++ b/dGame/dGameMessages/BuildingMessages.cpp @@ -435,6 +435,7 @@ namespace GameMessages { void SetBuildMode::Handle(Entity& entity, const SystemAddress& sysAddr) { auto* player = Game::entityManager->GetEntity(playerId); + if (!player || !player->GetCharacter()) return; auto confirmedStartPos = startPos; if (confirmedStartPos == NiPoint3Constant::ZERO) { @@ -453,7 +454,8 @@ namespace GameMessages { confirmed.modeValue = modeValue; confirmed.playerId = playerId; confirmed.startPos = confirmedStartPos; - confirmed.Send(UNASSIGNED_SYSTEM_ADDRESS); + // Only the builder: another player building on the same property must not be put in (or out of) build mode + confirmed.Send(sysAddr); } void SetBuildModeConfirmed::Serialize(RakNet::BitStream& bitStream) const { diff --git a/dGame/dGameMessages/PropertyMessages.cpp b/dGame/dGameMessages/PropertyMessages.cpp index d79fc1bf5..eea80e416 100644 --- a/dGame/dGameMessages/PropertyMessages.cpp +++ b/dGame/dGameMessages/PropertyMessages.cpp @@ -345,9 +345,12 @@ namespace GameMessages { void SetPropertyAccess::Handle(Entity& entity, const SystemAddress& sysAddr) { LOG("Set privacy option to: %i", accessType); - if (PropertyManagementComponent::Instance() == nullptr) return; + auto* property = PropertyManagementComponent::Instance(); + // Only the owner decides who visits (a best friend who builds sees the owner's plaque too) + const auto* sender = PlayerManager::GetPlayer(sysAddr); + if (!property || !sender || property->GetOwnerId() != sender->GetObjectID()) return; - PropertyManagementComponent::Instance()->SetPrivacyOption(static_cast(accessType)); + property->SetPrivacyOption(static_cast(accessType)); } void UpdatePropertyOrModelForFilterCheck::Serialize(RakNet::BitStream& bitStream) const { @@ -370,7 +373,12 @@ namespace GameMessages { } void UpdatePropertyOrModelForFilterCheck::Handle(Entity& entity, const SystemAddress& sysAddr) { - PropertyManagementComponent::Instance()->UpdatePropertyDetails(GeneralUtils::UTF16ToWTF8(newName), GeneralUtils::UTF16ToWTF8(newDescription)); + auto* property = PropertyManagementComponent::Instance(); + // Only the owner names the property + const auto* sender = PlayerManager::GetPlayer(sysAddr); + if (!property || !sender || property->GetOwnerId() != sender->GetObjectID()) return; + + property->UpdatePropertyDetails(GeneralUtils::UTF16ToWTF8(newName), GeneralUtils::UTF16ToWTF8(newDescription)); } void QueryPropertyData::Handle(Entity& entity, const SystemAddress& sysAddr) { @@ -406,16 +414,19 @@ namespace GameMessages { void PropertyEditorBegin::Handle(Entity& entity, const SystemAddress& sysAddr) { auto* property = PropertyManagementComponent::Instance(); - if (!property || property->GetOwnerId() != entity.GetObjectID()) return; - property->OnStartBuilding(); + auto* builder = PlayerManager::GetPlayer(sysAddr); + if (!property || !builder || !property->CanBuild(*builder)) return; + property->OnStartBuilding(*builder); Game::zoneManager->GetZoneControlObject()->OnZonePropertyEditBegin(); } void PropertyEditorEnd::Handle(Entity& entity, const SystemAddress& sysAddr) { auto* property = PropertyManagementComponent::Instance(); - if (!property || property->GetOwnerId() != entity.GetObjectID()) return; - property->OnFinishBuilding(); + auto* builder = PlayerManager::GetPlayer(sysAddr); + // A builder who lost the right to build was already taken out of build mode (UpdateBuildRights) + if (!property || !builder || !property->CanBuild(*builder)) return; + property->OnFinishBuilding(*builder); Game::zoneManager->GetZoneControlObject()->OnZonePropertyEditEnd(); } @@ -503,9 +514,10 @@ namespace GameMessages { void UpdateModelFromClient::Handle(Entity& entity, const SystemAddress& sysAddr) { auto* property = PropertyManagementComponent::Instance(); - // Only the owner edits their property; the model comes from the owner's inventory - if (!property || property->GetOwnerId() != entity.GetObjectID()) return; - property->UpdateModelPosition(modelID, position, rotation); + // Only players who can build edit the property; the model comes from the builder's inventory + auto* builder = PlayerManager::GetPlayer(sysAddr); + if (!property || !builder || !property->CanBuild(*builder)) return; + property->UpdateModelPosition(*builder, modelID, position, rotation); } void DeleteModelFromClient::Serialize(RakNet::BitStream& bitStream) const { @@ -521,8 +533,9 @@ namespace GameMessages { void DeleteModelFromClient::Handle(Entity& entity, const SystemAddress& sysAddr) { auto* property = PropertyManagementComponent::Instance(); - if (!property || property->GetOwnerId() != entity.GetObjectID()) return; - property->DeleteModel(modelID, reason); + auto* builder = PlayerManager::GetPlayer(sysAddr); + if (!property || !builder || !property->CanBuild(*builder)) return; + property->DeleteModel(*builder, modelID, reason); } void PropertyEntranceSync::Serialize(RakNet::BitStream& bitStream) const { @@ -683,10 +696,12 @@ namespace GameMessages { } void ControlBehaviors::Handle(Entity& entity, const SystemAddress& sysAddr) { - auto* const owner = PropertyManagementComponent::Instance()->GetOwner(); - if (!owner) return; + auto* const property = PropertyManagementComponent::Instance(); + // The model's behaviors are edited by a player who can build here; the answers go to them + auto* const builder = PlayerManager::GetPlayer(sysAddr); + if (!property || !builder || !property->CanBuild(*builder)) return; - ::ControlBehaviors::Instance().ProcessCommand(&entity, *args, command, owner); + ::ControlBehaviors::Instance().ProcessCommand(&entity, *args, command, builder); } void GetHotPropertyData::Handle(Entity& entity, const SystemAddress& sysAddr) { diff --git a/dGame/dGameMessages/ZoneMessages.cpp b/dGame/dGameMessages/ZoneMessages.cpp index 996385f4c..6d45a2572 100644 --- a/dGame/dGameMessages/ZoneMessages.cpp +++ b/dGame/dGameMessages/ZoneMessages.cpp @@ -17,6 +17,7 @@ #include "RacingControlComponent.h" #include "WorldMigration.h" #include "BrickByBrick.h" +#include "PropertyManagementComponent.h" #include "dConfig.h" #include "dZoneManager.h" #include "eReplicaComponentType.h" @@ -123,6 +124,9 @@ namespace GameMessages { // models that were open BrickByBrick::OnPlayerLoaded(*entity); + // On a property: whether they are a best friend of the owner, who may build (and the owner arriving lets them) + if (auto* property = PropertyManagementComponent::Instance()) property->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 index 79ed43064..01be999f2 100644 --- a/dGame/dUtilities/BrickByBrick.cpp +++ b/dGame/dUtilities/BrickByBrick.cpp @@ -249,9 +249,9 @@ void BrickByBrick::Save(Entity& player, const LWOOBJID localId, const std::strin 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()); + // Brick by brick building happens on a property the player can build on; the build becomes models placed there + if (!inventory || !property || !property->CanBuild(player)) { + LOG("Player %llu saved a brick by brick model but can't build on this property", player.GetObjectID()); response.reasonCode = eBlueprintSaveResponseType::PlacementFailed; response.Send(player.GetSystemAddress()); return; @@ -285,7 +285,7 @@ void BrickByBrick::Save(Entity& player, const LWOOBJID localId, const std::strin 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->SpawnModel(MODEL_OBJECT_LOT, model.modelId, model.center, QuatUtils::IDENTITY, config, player.GetObjectID()); } property->Save(); DashboardNotify::Changed("properties", property->GetId()); @@ -315,8 +315,8 @@ void BrickByBrick::ReturnModel(Entity& player, const LWOOBJID itemId, const bool 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); + if (hasWorldTransform && property && property->CanBuild(player)) { + property->PlaceModelFromItem(player, *item, position, rotation); property->SendModelsOnProperty(); } else { MoveKeepingId(*inventory, item, eInventoryType::MODELS); diff --git a/dGame/dUtilities/CMakeLists.txt b/dGame/dUtilities/CMakeLists.txt index df9c882e9..5596d72e5 100644 --- a/dGame/dUtilities/CMakeLists.txt +++ b/dGame/dUtilities/CMakeLists.txt @@ -8,6 +8,7 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp" "GUID.cpp" "LiveEvents.cpp" "Contraband.cpp" + "PropertyBuilders.cpp" "PropertyRent.cpp" "PropertyReputation.cpp" "Loot.cpp" diff --git a/dGame/dUtilities/PropertyBuilders.cpp b/dGame/dUtilities/PropertyBuilders.cpp new file mode 100644 index 000000000..7576638fb --- /dev/null +++ b/dGame/dUtilities/PropertyBuilders.cpp @@ -0,0 +1,28 @@ +#include "PropertyBuilders.h" + +#include "BrickByBrick.h" +#include "dConfig.h" +#include "Game.h" + +bool PropertyBuilders::BestFriendsBuild() { + return Game::config && Game::config->GetValue("property_bff_build") == "1"; +} + +bool PropertyBuilders::CanBuild(const Player& player, const LWOOBJID owner, const bool ownerBuilding, const bool bestFriendsBuild) { + if (owner == LWOOBJID_EMPTY || player.id == LWOOBJID_EMPTY) return false; + if (player.id == owner) return true; + if (!bestFriendsBuild || !player.isBestFriend) return false; + // Joining needs the owner building; staying doesn't + return ownerBuilding || player.isBuilding; +} + +LWOOBJID PropertyBuilders::Placer(const LWOOBJID placedBy, const LWOOBJID owner) { + return placedBy == LWOOBJID_EMPTY ? owner : placedBy; +} + +PropertyBuilders::eModelReturn PropertyBuilders::PlanModelReturn(const LWOOBJID picker, const LWOOBJID placer, const bool placerHere, const int32_t deleteReason) { + if (picker == placer) return eModelReturn::PICKER; + // Taking a model apart opens it from the picker's own inventory (BBBLoadItemRequest), where it wouldn't be + if (static_cast(deleteReason) == BrickByBrick::eDeleteReason::BREAKING_MODEL_APART) return eModelReturn::NOT_THEIRS; + return placerHere ? eModelReturn::PLACER : eModelReturn::PLACER_AWAY; +} diff --git a/dGame/dUtilities/PropertyBuilders.h b/dGame/dUtilities/PropertyBuilders.h new file mode 100644 index 000000000..b432f0a46 --- /dev/null +++ b/dGame/dUtilities/PropertyBuilders.h @@ -0,0 +1,44 @@ +#ifndef __PROPERTYBUILDERS__H__ +#define __PROPERTYBUILDERS__H__ + +#include + +#include "dCommonVars.h" + +/** + * Who builds on a property (docs/PropertyBuilding.md): the owner, and with property_bff_build the owner's best friends + * while the owner is building. The rules here have no game state (unit tested); PropertyManagementComponent applies + * them. + */ +namespace PropertyBuilders { + // worldconfig.ini's property_bff_build + bool BestFriendsBuild(); + + // Where a player stands on the property + struct Player { + LWOOBJID id{}; + bool isBestFriend{}; // of the owner + bool isBuilding{}; // in build mode on the property + }; + + /** + * Whether a player can build: the owner always. A best friend of the owner (with property_bff_build) while the owner + * is building, and after the owner stopped until they leave build mode themselves. + */ + bool CanBuild(const Player& player, LWOOBJID owner, bool ownerBuilding, bool bestFriendsBuild); + + // Who placed a model, from its properties_contents.placed_by (0, NULL in the database: the owner) + LWOOBJID Placer(LWOOBJID placedBy, LWOOBJID owner); + + enum class eModelReturn { + PICKER, // the player taking it off placed it: into their inventory + PLACER, // someone else placed it and is in this world: into the placer's inventory + PLACER_AWAY, // someone else placed it and isn't in this world: it stays on the property + NOT_THEIRS, // someone else placed it and the picker wants to take it apart: it stays on the property + }; + + // Where a model taken off the property goes (DeleteModelFromClient's reason, see BrickByBrick::eDeleteReason) + eModelReturn PlanModelReturn(LWOOBJID picker, LWOOBJID placer, bool placerHere, int32_t deleteReason); +} + +#endif //!__PROPERTYBUILDERS__H__ diff --git a/docs/PropertyBuilding.md b/docs/PropertyBuilding.md new file mode 100644 index 000000000..f9bcb51c2 --- /dev/null +++ b/docs/PropertyBuilding.md @@ -0,0 +1,81 @@ +# Property building + +Who can build on a property, and what happens to the models they place. The rules are in +`dGame/dUtilities/PropertyBuilders.h` (unit tested in `tests/dGameTests/PropertyBuildersTests.cpp`); +`PropertyManagementComponent` applies them. + +## Setting (worldconfig.ini) + +| Setting | Default | Meaning | +|---|---|---| +| `property_bff_build` | `0` | `1`: best friends of the owner can join the owner's build mode on the property. `0` (as live): only the owner builds. | + +It is read when used; a config reload applies it to players already on the property. + +## Who can build + +`PropertyManagementComponent::CanBuild(player)`: + +- The owner: always. Only the owner starts build mode (`PropertyEditorBegin`). +- A best friend of the owner (`friends.best_friend = 3`), with `property_bff_build=1`: + - while the owner is in build mode: can enter build mode and build; + - after the owner left build mode (or the world): keeps building while still in build mode; once they leave build + mode they can't enter it again until the owner enters it again. +- Anyone else, and anyone on an unclaimed property: never. + +Best friend status is looked up from the database once per player (when they load or are first checked) and again +when the property's owner changes. + +Building means: entering build mode (`PropertyEditorBegin`/`End`), placing, moving and picking up models +(`UpdateModelFromClient`, `DeleteModelFromClient`), saving and placing brick by brick models (`BBBSaveRequest`, +`UnUseBBBModel` with a world position) and editing behaviors (`ControlBehaviors`). Each uses the builder's own +inventory, missions and client; `ControlBehaviors` answers the player who sent it. + +Owner only, whatever the setting: privacy (`SetPropertyAccess`) and the property's name and description +(`UpdatePropertyOrModelForFilterCheck`). + +## Property data per player + +The client allows editing only when `DownloadPropertyData.ownerId` is its own character (without it the client shows +`PRECONDITION_OWN_PROPERTY`). Every `DownloadPropertyData` is sent to each player on their own address: a player who +can build and is not the owner gets their own character id as `ownerId`; everyone else gets the owner's. +`ownerName` is always the owner's. The plaque then shows a builder the owner's management screen; privacy and name +changes from it are refused. + +The rights each player was last sent are kept. When they change (the owner enters or leaves build mode, a best friend +leaves build mode, a builder leaves the world, the setting changes, the property is claimed) each player whose +rights changed gets `DownloadPropertyData` again. A player in build mode who lost the right (the setting turned off) +is sent `SetBuildModeConfirmed` with `start=false`, their building ends as if they had left build mode, and they get a +chat message. + +`SetBuildModeConfirmed` goes only to the player who asked. `GetModelsOnProperty` goes to each player, targeting their +own character. + +## Building together + +- The first builder to enter build mode (the owner) makes the property private (keeping the previous privacy), + pauses and resets the models, smashes spawned property enemies and sends every player who can't build to the + property's launch zone. Best friends who can build stay. +- Later builders only push their equipped items. +- Each builder leaving build mode saves the property. The last one sets the property back to pending moderation, + restores the previous privacy, resumes the models and smashes spawned property enemies. +- A builder leaving the world while in build mode counts as leaving build mode. + +Saving uses the property's id and owner id; it does not need the owner to be in the world. + +## Who placed a model + +`properties_contents.placed_by` (MySQL migration 93, SQLite migration 76) is the character who placed the model: +written when a model is placed from an inventory or saved from brick by brick building. `NULL` (models placed before +the column, dashboard imports) means the owner. A model's behaviors are saved under its placer's character. + +A model taken off the property (`DeleteModelFromClient`) goes to its placer: + +| Taken off by | Placer in the world | Result | +|---|---|---| +| The placer | - | Into the placer's MODELS, as before (carried when picked up). | +| Another builder | Yes | Into the placer's MODELS, not carried. Both get a chat message. The builder gets `PlaceModelResponse` 16 (removed). | +| Another builder | No | Stays placed; the builder gets a chat message. | +| Another builder, taking it apart | - | Stays placed; only the placer takes their model apart. | + +If the placer's inventory can't take it, the model stays placed. Nothing is deleted or mailed. diff --git a/resources/worldconfig.ini b/resources/worldconfig.ini index 8baff6937..0c45ddf81 100644 --- a/resources/worldconfig.ini +++ b/resources/worldconfig.ini @@ -81,6 +81,11 @@ cdclient_mismatch_message=We detected that your client is out of date. Please up # Auto reject properties which contain no models | must be 1 in order to auto reject. auto_reject_empty_properties=0 +# 1: best friends of a property's owner can join the owner's build mode on the property (place, move and pick up +# models, brick by brick building, behaviors). Only the owner starts build mode; best friends in it stay until they +# leave it. 0 (as live): only the owner builds. +property_bff_build=0 + # comma delimited list of items to not drop in hardcore mode hardcore_excluded_item_drops=6086,7044 diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index 8d226b6c9..0688440d2 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -12,6 +12,7 @@ set(DGAMETEST_SOURCES "ItemSetPassiveAbilityTests.cpp" "PowerUpStatisticTests.cpp" "BrickByBrickTests.cpp" + "PropertyBuildersTests.cpp" "ModularBuildTableTests.cpp" "UgcManifestTests.cpp" "SceneGhostingTests.cpp" diff --git a/tests/dGameTests/PropertyBuildersTests.cpp b/tests/dGameTests/PropertyBuildersTests.cpp new file mode 100644 index 000000000..9303cd337 --- /dev/null +++ b/tests/dGameTests/PropertyBuildersTests.cpp @@ -0,0 +1,83 @@ +#include "PropertyBuilders.h" +#include "BrickByBrick.h" + +#include + +// Who builds on a property and where models taken off it go (docs/PropertyBuilding.md). +namespace { + constexpr LWOOBJID OWNER = 1152921510000000001LL; + constexpr LWOOBJID FRIEND = 1152921510000000002LL; + constexpr LWOOBJID VISITOR = 1152921510000000003LL; + + int32_t Reason(const BrickByBrick::eDeleteReason reason) { return static_cast(reason); } + + bool CanBuild(const LWOOBJID id, const bool isBestFriend, const bool isBuilding, const bool ownerBuilding, const bool bestFriendsBuild, const LWOOBJID owner = OWNER) { + return PropertyBuilders::CanBuild({ .id = id, .isBestFriend = isBestFriend, .isBuilding = isBuilding }, owner, ownerBuilding, bestFriendsBuild); + } +} + +TEST(PropertyBuildersTests, OnlyTheOwnerBuildsByDefault) { + EXPECT_TRUE(CanBuild(OWNER, false, false, false, false)); + EXPECT_FALSE(CanBuild(FRIEND, true, false, true, false)); + EXPECT_FALSE(CanBuild(FRIEND, true, true, true, false)); + EXPECT_FALSE(CanBuild(VISITOR, false, false, true, false)); +} + +TEST(PropertyBuildersTests, NobodyBuildsOnAnUnclaimedProperty) { + EXPECT_FALSE(CanBuild(FRIEND, true, true, true, true, LWOOBJID_EMPTY)); + EXPECT_FALSE(CanBuild(LWOOBJID_EMPTY, false, false, false, true, LWOOBJID_EMPTY)); +} + +TEST(PropertyBuildersTests, TheOwnerStartsBuilding) { + // A best friend can't start: the owner isn't building + EXPECT_FALSE(CanBuild(FRIEND, true, false, false, true)); + // The owner always can + EXPECT_TRUE(CanBuild(OWNER, false, false, false, true)); +} + +TEST(PropertyBuildersTests, BestFriendsJoinWhileTheOwnerBuilds) { + EXPECT_TRUE(CanBuild(FRIEND, true, false, true, true)); + EXPECT_TRUE(CanBuild(FRIEND, true, true, true, true)); + // Not a best friend: never + EXPECT_FALSE(CanBuild(VISITOR, false, false, true, true)); + EXPECT_FALSE(CanBuild(VISITOR, false, true, true, true)); +} + +TEST(PropertyBuildersTests, BestFriendsStayAfterTheOwnerStops) { + // Still in build mode: keeps building + EXPECT_TRUE(CanBuild(FRIEND, true, true, false, true)); + // Left build mode: can't come back until the owner builds again + EXPECT_FALSE(CanBuild(FRIEND, true, false, false, true)); +} + +TEST(PropertyBuildersTests, ModelsWithNoPlacerAreTheOwners) { + EXPECT_EQ(PropertyBuilders::Placer(LWOOBJID_EMPTY, OWNER), OWNER); + EXPECT_EQ(PropertyBuilders::Placer(FRIEND, OWNER), FRIEND); + EXPECT_EQ(PropertyBuilders::Placer(OWNER, OWNER), OWNER); +} + +TEST(PropertyBuildersTests, APlayersOwnModelGoesToThem) { + using enum BrickByBrick::eDeleteReason; + using enum PropertyBuilders::eModelReturn; + for (const auto reason : { PICKING_MODEL_UP, RETURNING_MODEL_TO_INVENTORY, BREAKING_MODEL_APART }) { + EXPECT_EQ(PropertyBuilders::PlanModelReturn(FRIEND, FRIEND, true, Reason(reason)), PICKER); + EXPECT_EQ(PropertyBuilders::PlanModelReturn(OWNER, OWNER, true, Reason(reason)), PICKER); + } +} + +TEST(PropertyBuildersTests, SomeoneElsesModelGoesBackToThem) { + using enum BrickByBrick::eDeleteReason; + using enum PropertyBuilders::eModelReturn; + EXPECT_EQ(PropertyBuilders::PlanModelReturn(FRIEND, OWNER, true, Reason(PICKING_MODEL_UP)), PLACER); + EXPECT_EQ(PropertyBuilders::PlanModelReturn(OWNER, FRIEND, true, Reason(RETURNING_MODEL_TO_INVENTORY)), PLACER); + // Not in this world: it stays placed + EXPECT_EQ(PropertyBuilders::PlanModelReturn(OWNER, FRIEND, false, Reason(PICKING_MODEL_UP)), PLACER_AWAY); + EXPECT_EQ(PropertyBuilders::PlanModelReturn(FRIEND, OWNER, false, Reason(RETURNING_MODEL_TO_INVENTORY)), PLACER_AWAY); +} + +TEST(PropertyBuildersTests, OnlyThePlacerTakesTheirModelApart) { + using enum BrickByBrick::eDeleteReason; + using enum PropertyBuilders::eModelReturn; + EXPECT_EQ(PropertyBuilders::PlanModelReturn(FRIEND, OWNER, true, Reason(BREAKING_MODEL_APART)), NOT_THEIRS); + EXPECT_EQ(PropertyBuilders::PlanModelReturn(OWNER, FRIEND, false, Reason(BREAKING_MODEL_APART)), NOT_THEIRS); +}