diff --git a/dGame/Entity.cpp b/dGame/Entity.cpp index 71bf23434..76e6f46c8 100644 --- a/dGame/Entity.cpp +++ b/dGame/Entity.cpp @@ -1537,7 +1537,10 @@ void Entity::PickupItem(const LWOOBJID& objectID) const { } EconomyLedger::RecordMapEvent(IEconomyLedger::eMapEvent::POWERUP_PICKUPS, info.lot, GetPosition(), 1, this); } else { - inv->AddItem(info.lot, info.count, eLootSourceType::PICKUP, eInventoryType::INVALID, {}, LWOOBJID_EMPTY, true, false, LWOOBJID_EMPTY, eInventoryType::INVALID, 1); + // Live told the client which object the item dropped from (all 2,344 live pickups of a dropped item) + LootMetrics metrics{}; + if (info.sourceLot != 0) metrics.sourceLot = info.sourceLot; + inv->AddItem(info.lot, info.count, eLootSourceType::PICKUP, eInventoryType::INVALID, {}, LWOOBJID_EMPTY, true, false, LWOOBJID_EMPTY, eInventoryType::INVALID, 1, false, -1, metrics); } droppedLoot.erase(objectID); @@ -2118,10 +2121,12 @@ bool Entity::MsgRequestServerObjectInfo(GameMessages::RequestServerObjectInfoEve bool Entity::MsgDropClientLoot(GameMessages::DropClientLootEvent& event) { const auto& dropLootMsg = event.msg; if (dropLootMsg.item != LOT_NULL && dropLootMsg.item != 0) { + const auto* const source = Game::entityManager->GetEntity(dropLootMsg.sourceID); Loot::Info info{ .id = dropLootMsg.lootID, .lot = dropLootMsg.item, .count = dropLootMsg.count, + .sourceLot = source ? source->GetLOT() : 0, }; AddLootItem(info); } diff --git a/dGame/TradingManager.cpp b/dGame/TradingManager.cpp index c13edbd84..938f380c0 100644 --- a/dGame/TradingManager.cpp +++ b/dGame/TradingManager.cpp @@ -189,7 +189,8 @@ void Trade::Complete() { recordCoins(m_CoinsA, characterA->GetID(), characterB->GetID()); recordCoins(m_CoinsB, characterB->GetID(), characterA->GetID()); - const auto giveItems = [zone](const std::vector& items, InventoryComponent* from, MissionComponent* fromMissions, + const auto tradeId = m_TradeId; + const auto giveItems = [zone, tradeId](const std::vector& items, InventoryComponent* from, MissionComponent* fromMissions, InventoryComponent* to, const LWOOBJID fromCharacter, const LWOOBJID toCharacter) { for (const auto& tradeItem : items) { auto* itemToRemove = from->FindItemById(tradeItem.itemId); @@ -204,9 +205,12 @@ void Trade::Complete() { const auto bound = itemToRemove->GetBound(); itemToRemove->SetCount(itemToRemove->GetCount() - tradeItem.itemCount); fromMissions->Progress(eMissionTaskType::GATHER, tradeItem.itemLot, LWOOBJID_EMPTY, "", -static_cast(tradeItem.itemCount)); + // Live sent the trade's ID with the traded items + ReceiveItemOptions options{}; + options.metrics.transactionId = tradeId; const auto received = whole - ? to->ReceiveItem(LWOOBJID_EMPTY, tradeItem.itemLot, tradeItem.itemCount, eLootSourceType::TRADE, config, subKey, bound) - : to->ReceiveItem(LWOOBJID_EMPTY, tradeItem.itemLot, tradeItem.itemCount, eLootSourceType::TRADE); + ? to->ReceiveItem(LWOOBJID_EMPTY, tradeItem.itemLot, tradeItem.itemCount, eLootSourceType::TRADE, config, subKey, bound, options) + : to->ReceiveItem(LWOOBJID_EMPTY, tradeItem.itemLot, tradeItem.itemCount, eLootSourceType::TRADE, {}, LWOOBJID_EMPTY, false, options); EconomyLedger::RecordTransfer({ .method = IEconomyLedger::eTransferMethod::TRADE, .itemId = tradeItem.itemId, .newItemId = received.id, .lot = tradeItem.itemLot, .count = tradeItem.itemCount, .fromCharacter = fromCharacter, .toCharacter = toCharacter, .zone = zone, diff --git a/dGame/dComponents/InventoryComponent.cpp b/dGame/dComponents/InventoryComponent.cpp index 26aa1a871..5935c13a5 100644 --- a/dGame/dComponents/InventoryComponent.cpp +++ b/dGame/dComponents/InventoryComponent.cpp @@ -228,7 +228,8 @@ void InventoryComponent::AddItem( const eInventoryType inventorySourceType, const int32_t sourceType, const bool bound, - int32_t preferredSlot) { + int32_t preferredSlot, + const LootMetrics& metrics) { if (count == 0) { LOG("Attempted to add 0 of item (%i) to the inventory!", lot); @@ -262,7 +263,7 @@ void InventoryComponent::AddItem( return; } - auto* item = new Item(lot, inventory, slot, count, config, parent, showFlyingLoot, isModMoveAndEquip, subKey, bound, lootSourceType); + auto* item = new Item(lot, inventory, slot, count, config, parent, showFlyingLoot, isModMoveAndEquip, subKey, bound, lootSourceType, metrics); if (missions != nullptr && !IsTransferInventory(inventoryType)) { missions->Progress(eMissionTaskType::GATHER, lot, LWOOBJID_EMPTY, "", count, IsTransferInventory(inventorySourceType)); @@ -295,7 +296,7 @@ void InventoryComponent::AddItem( left -= delta; - existing->SetCount(existing->GetCount() + delta, false, true, showFlyingLoot, lootSourceType); + existing->SetCount(existing->GetCount() + delta, false, true, showFlyingLoot, lootSourceType, metrics); if (isModMoveAndEquip) { existing->Equip(); @@ -348,7 +349,7 @@ void InventoryComponent::AddItem( continue; } - auto* item = new Item(lot, inventory, slot, size, {}, parent, showFlyingLoot, isModMoveAndEquip, subKey, false, lootSourceType); + auto* item = new Item(lot, inventory, slot, size, {}, parent, showFlyingLoot, isModMoveAndEquip, subKey, false, lootSourceType, metrics); isModMoveAndEquip = false; } @@ -374,6 +375,7 @@ ReceivedItem InventoryComponent::ReceiveItem(const LWOOBJID id, const LOT lot, c GameMessages::AddItemToInventoryClientSync addItem; addItem.target = m_Parent->GetObjectID(); addItem.SetItem(*item); + addItem.AddMetrics(options.metrics); addItem.eLootTypeSource = lootSourceType; addItem.iSubkey = subKey; addItem.itemCount = static_cast(count); @@ -398,7 +400,7 @@ ReceivedItem InventoryComponent::ReceiveItem(const LWOOBJID id, const LOT lot, c for (const auto& [itemId, item] : inventory->GetItems()) { if (item->GetLot() == lot) before[itemId] = item->GetCount(); } - AddItem(lot, count, lootSourceType, inventoryType, config, LWOOBJID_EMPTY, options.showFlyingLoot, options.equip, subKey, options.sourceInventory, 0, bound, options.preferredSlot); + AddItem(lot, count, lootSourceType, inventoryType, config, LWOOBJID_EMPTY, options.showFlyingLoot, options.equip, subKey, options.sourceInventory, 0, bound, options.preferredSlot, options.metrics); LWOOBJID receivedBy = LWOOBJID_EMPTY; int64_t largestGain = 0; @@ -420,7 +422,7 @@ bool InventoryComponent::IsUniqueLot(const LOT lot) { return !isBrick && info.stackSize <= 1; } -bool InventoryComponent::RemoveItem(const LOT lot, const uint32_t count, eInventoryType inventoryType, const bool ignoreBound, const bool silent) { +bool InventoryComponent::RemoveItem(const LOT lot, const uint32_t count, eInventoryType inventoryType, const bool ignoreBound, const bool silent, const ItemRemovalSource& removalSource) { if (count == 0) { LOG("Attempted to remove 0 of item (%i) from the inventory!", lot); return false; @@ -437,7 +439,7 @@ bool InventoryComponent::RemoveItem(const LOT lot, const uint32_t count, eInvent auto* item = FindItemByLot(lot, inventoryType, false, ignoreBound); if (!item) break; const auto delta = std::min(left, item->GetCount()); - item->SetCount(item->GetCount() - delta, silent); + item->SetCount(item->GetCount() - delta, silent, true, true, eLootSourceType::NONE, {}, removalSource); left -= delta; } return true; @@ -448,7 +450,7 @@ bool InventoryComponent::RemoveItem(const LOT lot, const uint32_t count, eInvent auto* item = inventory->FindItemByLot(lot, false, ignoreBound); if (!item) break; const auto delta = std::min(item->GetCount(), left); - item->SetCount(item->GetCount() - delta, silent); + item->SetCount(item->GetCount() - delta, silent, true, true, eLootSourceType::NONE, {}, removalSource); left -= delta; } } diff --git a/dGame/dComponents/InventoryComponent.h b/dGame/dComponents/InventoryComponent.h index 202399065..5a452a796 100644 --- a/dGame/dComponents/InventoryComponent.h +++ b/dGame/dComponents/InventoryComponent.h @@ -23,6 +23,7 @@ #include "eReplicaComponentType.h" #include "eLootSourceType.h" #include "Loot.h" +#include "LootMetrics.h" class Entity; class ItemSet; @@ -57,6 +58,7 @@ struct ReceiveItemOptions { bool equip = false; int32_t preferredSlot = -1; eInventoryType sourceInventory = INVALID; // set when moving between the player's own inventories + LootMetrics metrics{}; // where the items came from, sent with them (e.g. the mail or trade ID) }; // Which item or stack ReceiveItem put the items in @@ -153,6 +155,7 @@ public: * @param bound whether this item is bound * @param preferredSlot the preferred slot to store this item * @param lootSourceType The source of the loot. Defaults to none. + * @param metrics where the items came from (source LOT, mission, activity...), sent with them and not kept */ void AddItem( LOT lot, @@ -167,7 +170,8 @@ public: eInventoryType inventorySourceType = INVALID, int32_t sourceType = 0, bool bound = false, - int32_t preferredSlot = -1 + int32_t preferredSlot = -1, + const LootMetrics& metrics = {} ); /** @@ -190,8 +194,9 @@ public: * @param inventoryType optional inventory type to remove the item from * @param ignoreBound ignores bound items * @param silent silently remove the item + * @param removalSource what took the items, sent with the removal (e.g. a quickbuild taking its item cost) */ - bool RemoveItem(LOT lot, uint32_t count, eInventoryType inventoryType = INVALID, bool ignoreBound = false, bool silent = false); + bool RemoveItem(LOT lot, uint32_t count, eInventoryType inventoryType = INVALID, bool ignoreBound = false, bool silent = false, const ItemRemovalSource& removalSource = {}); /** * Moves an existing item to an inventory of the entity diff --git a/dGame/dComponents/MovementAIComponent.cpp b/dGame/dComponents/MovementAIComponent.cpp index e56d42757..ca392dacb 100644 --- a/dGame/dComponents/MovementAIComponent.cpp +++ b/dGame/dComponents/MovementAIComponent.cpp @@ -464,8 +464,11 @@ void MovementAIComponent::SetDestination(const NiPoint3 destination) { // The path is walked without collision, so an enemy's stops at walls it can't cross (navmesh carvers, enemy only // blockers); with nothing left to walk it stays where it is. A patrol along its level path is left as designed. + // Pets are walked here too, and their group (3) is the one the pet ranch's "PR - Pet Blocker" (group 18) touches: + // the client's filter lets pets collide with it and players through. const bool patrolling = m_Path && m_BaseCombatAI && m_BaseCombatAI->GetState() == AiState::idle; - if (m_BaseCombatAI && !patrolling) { + const bool isPet = m_Parent->HasComponent(eReplicaComponentType::PET); + if ((m_BaseCombatAI && !patrolling) || isPet) { computedPath = dpWorld::ClampPath(m_Parent->GetPosition(), std::move(computedPath), static_cast(m_Parent->GetCollisionGroup())); if (computedPath.empty()) computedPath.push_back(m_Parent->GetPosition()); } diff --git a/dGame/dComponents/PhysicsComponent.cpp b/dGame/dComponents/PhysicsComponent.cpp index cbdf4e1f6..0e4aa5fec 100644 --- a/dGame/dComponents/PhysicsComponent.cpp +++ b/dGame/dComponents/PhysicsComponent.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include "eReplicaComponentType.h" #include "NiPoint3.h" @@ -21,6 +23,30 @@ #include "Entity.h" #include "Logger.h" +namespace { + struct KnownBox { + NiPoint3 size; // width (x), height (y, up from the base) and depth (z) + NiPoint3 offset; // where the box's base centre is, from the object's position in its own axes, before scaling + }; + + /** + * Assets whose collision the server stands in for with a box: the bounds of all the parts of the asset's collision + * shape in the client's physics files (res/physics), by file name in lower case. They are the navmesh carvers of + * Robot City (hedges, greebles, robot statues) and the Ninjago Monastery (benches). + * The hedge corner is an L: its box also covers the inside of the corner. + */ + const std::map KNOWN_BOXES = { + { "env_won_nim_bush_square-section.hkx", { { 4.3949f, 4.9645f, 9.0341f }, { -0.0343f, 0.0004f, -0.0527f } } }, + { "env_won_nim_bush_square-corner.hkx", { { 9.1824f, 4.7916f, 9.7440f }, { -1.0403f, 0.2150f, 1.0503f } } }, + { "env_nim_ag_bushinpot.hkx", { { 5.4603f, 5.7094f, 11.4660f }, { 0.1707f, -0.2426f, 0.5737f } } }, + { "greeble_001w.hkx", { { 3.18f, 1.90f, 3.18f }, { 0.0f, 0.01f, 0.0f } } }, + { "greeble_002w.hkx", { { 2.38f, 1.907f, 1.5802f }, { -0.4f, 0.01f, 0.0001f } } }, + { "robot_statue_01.hkx", { { 1.8642f, 3.1844f, 1.67f }, { 0.4f, 0.01f, -0.275f } } }, + { "robot_statue_02.hkx", { { 3.0994f, 4.1242f, 1.9228f }, { 0.0165f, 0.0117f, 0.0967f } } }, + { "env_won_gnar_ninja-bench.hkx", { { 6.4f, 0.9796f, 1.6f }, { 0.0f, -0.0052f, 0.0005f } } }, + }; +} + PhysicsComponent::PhysicsComponent(Entity* parent, const int32_t componentID) : Component(parent, componentID) { m_Position = NiPoint3Constant::ZERO; m_Rotation = QuatUtils::IDENTITY; @@ -71,6 +97,56 @@ void PhysicsComponent::RegisterMovementBlocker(const eReplicaComponentType type, dpWorld::AddMovementBlocker(m_MovementBlocker, *filter); } +namespace { + bool SettingIsTrue(const LwoNameValue& settings, const std::u16string& key) { + const auto it = settings.find(key); + return it != settings.end() && it->second && GeneralUtils::TryParse(it->second->GetValueAsString()).value_or(0) != 0; + } +} + +bool PhysicsComponent::IsCarverOnly(const LwoNameValue& settings) { + return SettingIsTrue(settings, u"carver_only"); +} + +bool PhysicsComponent::AddLevelMovementBlocker(const LWOOBJID id, const LOT lot, const LwoNameValue& settings, const NiPoint3& position, const NiQuaternion& rotation, const float scale) { + auto* const registry = CDClientManager::GetTable(); + auto* const physicsTable = CDClientManager::GetTable(); + + // The physics component it would have: fixed simple physics is solid (no level object of this kind moves), a + // phantom volume is not + for (const auto type : { eReplicaComponentType::SIMPLE_PHYSICS, eReplicaComponentType::PHANTOM_PHYSICS }) { + const auto componentID = registry->GetByIDAndType(lot, type, -1); + if (componentID == -1) continue; + + int32_t collisionGroup = 0; + if (const auto* const info = physicsTable->GetByID(componentID)) collisionGroup = info->collisionGroup; + if (const auto it = settings.find(u"CollisionGroupID"); it != settings.end() && it->second) { + collisionGroup = GeneralUtils::TryParse(it->second->GetValueAsString()).value_or(collisionGroup); + } + + const bool solid = type == eReplicaComponentType::SIMPLE_PHYSICS; + const auto filter = dpMovementBlockers::BlockingFilter(SettingIsTrue(settings, u"navmesh_carver"), solid, static_cast(collisionGroup)); + if (!filter) return false; + + auto shapePosition = position; + bool isFallback = false; + auto* const blocker = CreateAssetShape(id, lot, type, collisionGroup, scale, rotation, shapePosition, &isFallback); + if (!blocker) return false; + if (isFallback) { + LOG_DEBUG("%llu (LOT %i) should block movement, but the server doesn't know its shape", id, lot); + delete blocker; + return false; + } + + blocker->SetScale(scale); + blocker->SetRotation(rotation); + blocker->SetPosition(shapePosition); + dpWorld::AddOwnedMovementBlocker(blocker, *filter); + return true; + } + return false; +} + bool PhysicsComponent::OnGetPosition(GameMessages::GetPosition& msg) { msg.pos = GetPosition(); return true; @@ -91,9 +167,13 @@ void PhysicsComponent::Serialize(RakNet::BitStream& outBitStream, bool bIsInitia } dpEntity* PhysicsComponent::CreatePhysicsEntity(eReplicaComponentType type, bool* isFallback) { + return CreateAssetShape(m_Parent->GetObjectID(), m_Parent->GetLOT(), type, m_CollisionGroup, m_Parent->GetDefaultScale(), m_Rotation, m_Position, isFallback); +} + +dpEntity* PhysicsComponent::CreateAssetShape(const LWOOBJID id, const LOT lot, const eReplicaComponentType type, const int32_t collisionGroup, const float scale, const NiQuaternion& rotation, NiPoint3& position, bool* isFallback) { if (isFallback) *isFallback = false; CDComponentsRegistryTable* compRegistryTable = CDClientManager::GetTable(); - auto componentID = compRegistryTable->GetByIDAndType(m_Parent->GetLOT(), type); + auto componentID = compRegistryTable->GetByIDAndType(lot, type); CDPhysicsComponentTable* physComp = CDClientManager::GetTable(); @@ -109,51 +189,55 @@ dpEntity* PhysicsComponent::CreatePhysicsEntity(eReplicaComponentType type, bool dpEntity* toReturn; if (info->physicsAsset == "miscellaneous\\misc_phys_10x1x5.hkx") { - toReturn = new dpEntity(m_Parent->GetObjectID(), 10.0f, 5.0f, 1.0f); + toReturn = new dpEntity(id, 10.0f, 5.0f, 1.0f); } else if (info->physicsAsset == "miscellaneous\\misc_phys_640x640.hkx") { // TODO Fix physics simulation to do simulation at high velocities due to bullet through paper problem... - toReturn = new dpEntity(m_Parent->GetObjectID(), 1638.4f, 13.521004f * 2.0f, 1638.4f); + toReturn = new dpEntity(id, 1638.4f, 13.521004f * 2.0f, 1638.4f); // Move this down by 13.521004 units so it is still effectively at the same height as before - m_Position = m_Position - NiPoint3Constant::UNIT_Y * 13.521004f; + position = position - NiPoint3Constant::UNIT_Y * 13.521004f; } else if (info->physicsAsset == "env\\trigger_wall_tall.hkx") { - toReturn = new dpEntity(m_Parent->GetObjectID(), 10.0f, 25.0f, 1.0f); + toReturn = new dpEntity(id, 10.0f, 25.0f, 1.0f); } else if (info->physicsAsset == "env\\env_gen_placeholderphysics.hkx") { - toReturn = new dpEntity(m_Parent->GetObjectID(), 20.0f, 20.0f, 20.0f); + toReturn = new dpEntity(id, 20.0f, 20.0f, 20.0f); } else if (assetFile == "poi_trigger_wall.hkx") { // test\POI_trigger_wall.hkx in the CDClient (e.g. "Clear threat list Trigger Wall"), env\ in the client's files: // a box 1 wide, 12.98 tall from its base and 20.45 deep - toReturn = new dpEntity(m_Parent->GetObjectID(), 1.0f, 12.9755f, 20.45f); + toReturn = new dpEntity(id, 1.0f, 12.9755f, 20.45f); } else if (assetFile == "trigger_rectangle_box.hkx") { // "Trigger Rectangle Box": a box 8 wide, 8 tall from its base and 4 deep - toReturn = new dpEntity(m_Parent->GetObjectID(), 8.0f, 8.0f, 4.0f); + toReturn = new dpEntity(id, 8.0f, 8.0f, 4.0f); } else if (info->physicsAsset == "env\\NG_NinjaGo\\env_ng_gen_gate_chamber_puzzle_ceiling_tile_falling_phantom.hkx") { - toReturn = new dpEntity(m_Parent->GetObjectID(), 18.0f, 5.0f, 15.0f); - m_Position += QuatUtils::Forward(m_Rotation) * 7.5f; + toReturn = new dpEntity(id, 18.0f, 5.0f, 15.0f); + position += QuatUtils::Forward(rotation) * 7.5f; } else if (info->physicsAsset == "env\\NG_NinjaGo\\ng_flamejet_brick_phantom.HKX") { - toReturn = new dpEntity(m_Parent->GetObjectID(), 1.0f, 1.0f, 12.0f); - m_Position += QuatUtils::Forward(m_Rotation) * 6.0f; + toReturn = new dpEntity(id, 1.0f, 1.0f, 12.0f); + position += QuatUtils::Forward(rotation) * 6.0f; } else if (info->physicsAsset == "env\\Ring_Trigger.hkx") { - toReturn = new dpEntity(m_Parent->GetObjectID(), 6.0f, 6.0f, 6.0f); + toReturn = new dpEntity(id, 6.0f, 6.0f, 6.0f); } else if (info->physicsAsset == "env\\vfx_propertyImaginationBall.hkx") { - toReturn = new dpEntity(m_Parent->GetObjectID(), 4.5f); + toReturn = new dpEntity(id, 4.5f); } else if (info->physicsAsset == "env\\env_won_fv_gas-blocking-volume.hkx") { - toReturn = new dpEntity(m_Parent->GetObjectID(), 390.496826f, 111.467964f, 600.821534f, true); - m_Position.y -= (111.467964f * m_Parent->GetDefaultScale()) / 2; + toReturn = new dpEntity(id, 390.496826f, 111.467964f, 600.821534f, true); + position.y -= (111.467964f * scale) / 2; // Leaving these out for now since they cause more issues than they solve in racing tracks without proper OBB checks. } /* else if (info->physicsAsset == "env\\GFTrack_DeathVolume1_CaveExit.hkx") { - toReturn = new dpEntity(m_Parent->GetObjectID(), 112.416870f, 50.363434f, 87.679268f); + toReturn = new dpEntity(id, 112.416870f, 50.363434f, 87.679268f); } else if (info->physicsAsset == "env\\GFTrack_DeathVolume2_RoadGaps.hkx") { - toReturn = new dpEntity(m_Parent->GetObjectID(), 48.386536f, 50.363434f, 259.361755f); - } */ else { + toReturn = new dpEntity(id, 48.386536f, 50.363434f, 259.361755f); + } */ else if (const auto known = KNOWN_BOXES.find(assetFile); known != KNOWN_BOXES.end()) { + const auto& [size, offset] = known->second; + toReturn = new dpEntity(id, size.x, size.y, size.z); + position += (QuatUtils::Right(rotation) * offset.x + QuatUtils::Up(rotation) * offset.y + QuatUtils::Forward(rotation) * offset.z) * scale; + } else { // LOG_DEBUG("This one is supposed to have %s", info->physicsAsset.c_str()); //add fallback cube: - toReturn = new dpEntity(m_Parent->GetObjectID(), 2.0f, 2.0f, 2.0f); + toReturn = new dpEntity(id, 2.0f, 2.0f, 2.0f); if (isFallback) *isFallback = true; } // Only touch what the client lets this group touch (e.g. POI walls ignore enemies, threat clearing walls ignore players) - toReturn->SetCollisionGroup(static_cast(m_CollisionGroup)); + toReturn->SetCollisionGroup(static_cast(collisionGroup)); return toReturn; } diff --git a/dGame/dComponents/PhysicsComponent.h b/dGame/dComponents/PhysicsComponent.h index 18dd793c9..ca6c30185 100644 --- a/dGame/dComponents/PhysicsComponent.h +++ b/dGame/dComponents/PhysicsComponent.h @@ -4,6 +4,9 @@ #include "Component.h" #include "NiPoint3.h" #include "NiQuaternion.h" +#include "dCommonVars.h" + +struct LwoNameValue; namespace GameMessages { struct GetObjectReportInfo; @@ -33,12 +36,33 @@ public: int32_t GetCollisionGroup() const noexcept { return m_CollisionGroup; } void SetCollisionGroup(int32_t group) noexcept { m_CollisionGroup = group; } + + /** + * Whether a level object is carver_only, which the client never loads (LWOResMgr2Interface::Run skips its load + * when its config has carver_only set; LWOBasePhysComponent::LoadConfigData 0x00c495c9 reads it next to + * navmesh_carver). Live never sent one. + */ + [[nodiscard]] static bool IsCarverOnly(const LwoNameValue& settings); + + /** + * For a level object the server doesn't spawn (carver_only): adds its shape to the world's movement blockers, the + * way its physics component would have (RegisterMovementBlocker), owned by the world. + * @return whether it blocks movement (its data makes it a wall and the server knows its shape) + */ + static bool AddLevelMovementBlocker(LWOOBJID id, LOT lot, const LwoNameValue& settings, const NiPoint3& position, const NiQuaternion& rotation, float scale); protected: bool OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& msg); // isFallback, when given, says whether the asset had no known shape and got a stand in cube dpEntity* CreatePhysicsEntity(eReplicaComponentType type, bool* isFallback = nullptr); + /** + * The shape of the physics asset of a LOT's physics component of the given type, for an object placed with this + * scale and rotation, or nullptr when it has none. Some shapes move position (in and out) to line up with the + * client's. isFallback, when given, says whether the asset had no known shape and got a stand in cube. + */ + static dpEntity* CreateAssetShape(LWOOBJID id, LOT lot, eReplicaComponentType type, int32_t collisionGroup, float scale, const NiQuaternion& rotation, NiPoint3& position, bool* isFallback = nullptr); + /** * Makes this object a wall the server's movers can't walk through when its data says it is one * (dpMovementBlockers::BlockingFilter: a navmesh carver, or a solid object only enemies collide with). Only diff --git a/dGame/dComponents/QuickBuildComponent.cpp b/dGame/dComponents/QuickBuildComponent.cpp index f607cb924..aa52e4867 100644 --- a/dGame/dComponents/QuickBuildComponent.cpp +++ b/dGame/dComponents/QuickBuildComponent.cpp @@ -664,9 +664,11 @@ void QuickBuildComponent::TakeItemCosts(Entity& user) { if (!inventoryComponent) return; // Live took the precondition items (for example the FV Stone Warrior pedestal's 5 Maelstrom Infected Bricks) - // when the build started and gave them back when it was cancelled + // when the build started and gave them back when it was cancelled. The removal names the quickbuild as its loot + // source (Quickbuild, the quickbuild's object ID), as live's did. + const ItemRemovalSource removalSource{ eLootSourceType::QUICKBUILD, m_Parent->GetObjectID() }; for (const auto& cost : m_Precondition->GetItemCosts(&user)) { - if (inventoryComponent->RemoveItem(cost.lot, cost.count, eInventoryType::ALL)) m_TakenItems.push_back(cost); + if (inventoryComponent->RemoveItem(cost.lot, cost.count, eInventoryType::ALL, false, false, removalSource)) m_TakenItems.push_back(cost); } if (!m_TakenItems.empty()) m_TakenItemsFrom = user.GetObjectID(); } diff --git a/dGame/dComponents/VendorComponent.cpp b/dGame/dComponents/VendorComponent.cpp index 131594019..554e7b2eb 100644 --- a/dGame/dComponents/VendorComponent.cpp +++ b/dGame/dComponents/VendorComponent.cpp @@ -208,7 +208,11 @@ void VendorComponent::Buy(Entity* buyer, LOT lot, uint32_t count) { } character->SetCoins(character->GetCoins() - (coinCost), eLootSourceType::VENDOR, CoinSource::Object(*m_Parent)); - inventoryComponent->AddItem(lot, count, eLootSourceType::VENDOR); + // Live sent the vendor's LOT, and the coins paid when there were any, with the bought items + LootMetrics metrics{}; + metrics.sourceLot = m_Parent->GetLOT(); + if (coinCost > 0) metrics.currencyDelta = -static_cast(coinCost); + inventoryComponent->AddItem(lot, count, eLootSourceType::VENDOR, eInventoryType::INVALID, {}, LWOOBJID_EMPTY, true, false, LWOOBJID_EMPTY, eInventoryType::INVALID, 0, false, -1, metrics); SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_SUCCESS); } diff --git a/dGame/dGameMessages/InventoryMessages.cpp b/dGame/dGameMessages/InventoryMessages.cpp index 83fbf5693..593895b53 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 "LootMetrics.h" #include "BrickByBrick.h" #include @@ -53,6 +54,13 @@ namespace GameMessages { slotID = item.GetSlot(); } + void AddItemToInventoryClientSync::AddMetrics(const LootMetrics& metrics) { + const auto text = metrics.ToExtraInfo(); + if (text.empty()) return; + if (!extraInfo.empty()) extraInfo += u','; + extraInfo += text; + } + void AddItemToInventoryClientSync::Serialize(RakNet::BitStream& bitStream) const { bitStream.Write(bBound); bitStream.Write(bIsBOE); diff --git a/dGame/dGameMessages/InventoryMessages.h b/dGame/dGameMessages/InventoryMessages.h index ad6e7925d..4b17b7142 100644 --- a/dGame/dGameMessages/InventoryMessages.h +++ b/dGame/dGameMessages/InventoryMessages.h @@ -17,6 +17,8 @@ class Item; // and groups. // Field names follow the client (legouniverse.exe 1.10.64); fields are listed in wire order. // Received messages are handled by the target's InventoryComponent. +struct LootMetrics; + namespace GameMessages { // Server -> client. struct AddItemToInventoryClientSync : public NetGameMsg { @@ -28,6 +30,9 @@ namespace GameMessages { // slot). The caller sets the rest. void SetItem(const Item& item); + // Adds where the items came from (LootMetrics) to the extra info, after the item's config + void AddMetrics(const LootMetrics& metrics); + bool bBound{}; bool bIsBOE{}; bool bIsBOP{}; diff --git a/dGame/dInventory/CMakeLists.txt b/dGame/dInventory/CMakeLists.txt index cefc57e22..bd27b47af 100644 --- a/dGame/dInventory/CMakeLists.txt +++ b/dGame/dInventory/CMakeLists.txt @@ -3,7 +3,8 @@ set(DGAME_DINVENTORY_SOURCES "Inventory.cpp" "Item.cpp" "ItemSet.cpp" - "ItemSetPassiveAbility.cpp") + "ItemSetPassiveAbility.cpp" + "LootMetrics.cpp") add_library(dInventory OBJECT ${DGAME_DINVENTORY_SOURCES}) target_include_directories(dInventory PUBLIC "." diff --git a/dGame/dInventory/Item.cpp b/dGame/dInventory/Item.cpp index 4a90955ab..eeb75ef47 100644 --- a/dGame/dInventory/Item.cpp +++ b/dGame/dInventory/Item.cpp @@ -82,7 +82,8 @@ Item::Item( bool isModMoveAndEquip, LWOOBJID subKey, bool bound, - eLootSourceType lootSourceType) { + eLootSourceType lootSourceType, + const LootMetrics& metrics) { if (!Inventory::IsValidItem(lot)) { return; } @@ -113,6 +114,7 @@ Item::Item( GameMessages::AddItemToInventoryClientSync addItem; addItem.target = entity->GetObjectID(); addItem.SetItem(*this); + addItem.AddMetrics(metrics); addItem.eLootTypeSource = lootSourceType; addItem.iSubkey = subKey; addItem.itemCount = static_cast(this->count); @@ -178,7 +180,7 @@ PreconditionExpression* Item::GetPreconditionExpression() const { return preconditions; } -void Item::SetCount(const uint32_t value, const bool silent, const bool disassemble, const bool showFlyingLoot, eLootSourceType lootSourceType) { +void Item::SetCount(const uint32_t value, const bool silent, const bool disassemble, const bool showFlyingLoot, eLootSourceType lootSourceType, const LootMetrics& metrics, const ItemRemovalSource& removalSource) { if (value == count) { return; } @@ -202,6 +204,7 @@ void Item::SetCount(const uint32_t value, const bool silent, const bool disassem GameMessages::AddItemToInventoryClientSync addItem; addItem.target = entity->GetObjectID(); addItem.SetItem(*this); + addItem.AddMetrics(metrics); addItem.eLootTypeSource = lootSourceType; addItem.itemCount = delta; addItem.newObjID = id; @@ -213,7 +216,8 @@ void Item::SetCount(const uint32_t value, const bool silent, const bool disassem removeItem.target = entity->GetObjectID(); removeItem.bConfirmed = true; removeItem.eInvType = inventory->GetType(); - removeItem.eLootTypeSource = LOOTTYPE_NONE; + removeItem.eLootTypeSource = static_cast(removalSource.type); + removeItem.iLootTypeSource = removalSource.object; removeItem.iObjID = id; removeItem.iObjTemplate = lot; removeItem.iStackCount = delta; diff --git a/dGame/dInventory/Item.h b/dGame/dInventory/Item.h index baccf28fb..8aedde8e9 100644 --- a/dGame/dInventory/Item.h +++ b/dGame/dInventory/Item.h @@ -8,6 +8,7 @@ #include "Preconditions.h" #include "eInventoryType.h" #include "eLootSourceType.h" +#include "LootMetrics.h" namespace tinyxml2 { class XMLElement; @@ -58,6 +59,7 @@ public: * @param isModMoveAndEquip equips the item * @param subKey optional subkey for this item, e.g. for pets * @param bound if the item should be bound + * @param metrics where the items came from, sent with the item (not kept) */ explicit Item( LOT lot, @@ -70,7 +72,8 @@ public: bool isModMoveAndEquip = false, LWOOBJID subKey = LWOOBJID_EMPTY, bool bound = false, - eLootSourceType lootSourceType = eLootSourceType::NONE + eLootSourceType lootSourceType = eLootSourceType::NONE, + const LootMetrics& metrics = {} ); ~Item(); @@ -93,8 +96,10 @@ public: * @param silent if true, the client will not be notified of the change with GMs * @param disassemble if items were removed, this returns all the sub parts of the item individually if it had assembly part lots * @param showFlyingLoot shows flying loot to the client, if not silent + * @param metrics where added items came from, sent with them (not kept) + * @param removalSource what took removed items away, sent with the removal */ - void SetCount(uint32_t value, bool silent = false, bool disassemble = true, bool showFlyingLoot = true, eLootSourceType lootSourceType = eLootSourceType::NONE); + void SetCount(uint32_t value, bool silent = false, bool disassemble = true, bool showFlyingLoot = true, eLootSourceType lootSourceType = eLootSourceType::NONE, const LootMetrics& metrics = {}, const ItemRemovalSource& removalSource = {}); /** * Returns the number of items this item represents (e.g. for stacks) diff --git a/dGame/dInventory/LootMetrics.cpp b/dGame/dInventory/LootMetrics.cpp new file mode 100644 index 000000000..5e4c7aaa3 --- /dev/null +++ b/dGame/dInventory/LootMetrics.cpp @@ -0,0 +1,27 @@ +#include "LootMetrics.h" + +#include "LDFFormat.h" + +namespace { + template + void Append(std::u16string& out, const char16_t* key, const std::optional& value) { + if (!value) return; + if (!out.empty()) out += u','; + out += GeneralUtils::ASCIIToUTF16(LDFData(key, *value).GetString()); + } +} + +bool LootMetrics::Empty() const { + return !sourceLot && !missionId && !activityId && !currencyDelta && !mailId && !transactionId; +} + +std::u16string LootMetrics::ToExtraInfo() const { + std::u16string out; + Append(out, u"_Metric_Souce_LOT_Int", sourceLot); + Append(out, u"_Metric_Mission_ID_Int", missionId); + Append(out, u"_Metric_Activity_ID_Int", activityId); + Append(out, u"_Metric_Currency_Delta_Int", currencyDelta); + Append(out, u"_Metric_Mail_ID_Int64", mailId); + Append(out, u"_Metric_Transaction_ID_Int64", transactionId); + return out; +} diff --git a/dGame/dInventory/LootMetrics.h b/dGame/dInventory/LootMetrics.h new file mode 100644 index 000000000..331dce257 --- /dev/null +++ b/dGame/dInventory/LootMetrics.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +#include "dCommonVars.h" +#include "eLootSourceType.h" + +/** + * Where added items came from, as live servers put it in the extra info of AddItemToInventoryClientSync next to the + * item's own config. Only the message carries these keys; the item does not keep them. + * Live: a picked up drop had _Metric_Souce_LOT_Int (the source object's LOT, 1 for a player sourced + * drop) on all 2,344 pickups of a DropClientLoot; mission and achievement rewards the mission ID and source LOT 1; + * activity rewards the activity ID and the activity object's LOT; vendor purchases the vendor's LOT and the coins paid + * (negative); mail the mail ID; trades the trade ID. Package contents had none. + */ +struct LootMetrics { + std::optional sourceLot; // _Metric_Souce_LOT_Int (the key is misspelt in live's data) + std::optional missionId; // _Metric_Mission_ID_Int + std::optional activityId; // _Metric_Activity_ID_Int + std::optional currencyDelta; // _Metric_Currency_Delta_Int + std::optional mailId; // _Metric_Mail_ID_Int64, name value type 8 + std::optional transactionId; // _Metric_Transaction_ID_Int64, name value type 9 + + [[nodiscard]] bool Empty() const; + + /** + * The keys as name value text (key=type:value), comma separated like an item's config in the extra info. + * Empty when there are none. + */ + [[nodiscard]] std::u16string ToExtraInfo() const; +}; + +// What took items away, for RemoveItemFromInventory's loot source fields (e.g. a quickbuild taking its item cost) +struct ItemRemovalSource { + eLootSourceType type = eLootSourceType::NONE; + LWOOBJID object = LWOOBJID_EMPTY; +}; diff --git a/dGame/dMission/Mission.cpp b/dGame/dMission/Mission.cpp index c9e3e2c96..8f5a6a9ef 100644 --- a/dGame/dMission/Mission.cpp +++ b/dGame/dMission/Mission.cpp @@ -510,6 +510,11 @@ void Mission::YieldRewards() { } } + // Live sent the mission's ID and the player's LOT with each reward item (225 of 225 live mission and achievement rewards) + LootMetrics rewardMetrics{}; + rewardMetrics.sourceLot = entity->GetLOT(); + rewardMetrics.missionId = static_cast(info.id); + if (m_Completions > 0) { std::vector> items; @@ -528,7 +533,7 @@ void Mission::YieldRewards() { // If a mission rewards zero of an item, make it reward 1. auto count = pair.second > 0 ? pair.second : 1; LOG("Player %llu is receiving %i of item %i from repeatable mission %i", entity->GetObjectID(), count, pair.first, info.id); - inventoryComponent->AddItem(pair.first, count, IsMission() ? eLootSourceType::MISSION : eLootSourceType::ACHIEVEMENT); + inventoryComponent->AddItem(pair.first, count, IsMission() ? eLootSourceType::MISSION : eLootSourceType::ACHIEVEMENT, eInventoryType::INVALID, {}, LWOOBJID_EMPTY, true, false, LWOOBJID_EMPTY, eInventoryType::INVALID, 0, false, -1, rewardMetrics); } if (info.reward_currency_repeatable > 0 || coinsToSend > 0) { @@ -556,7 +561,7 @@ void Mission::YieldRewards() { // If a mission rewards zero of an item, make it reward 1. auto count = pair.second > 0 ? pair.second : 1; LOG("Player %llu is receiving %i of item %i from mission %i", entity->GetObjectID(), count, pair.first, info.id); - inventoryComponent->AddItem(pair.first, count, IsMission() ? eLootSourceType::MISSION : eLootSourceType::ACHIEVEMENT); + inventoryComponent->AddItem(pair.first, count, IsMission() ? eLootSourceType::MISSION : eLootSourceType::ACHIEVEMENT, eInventoryType::INVALID, {}, LWOOBJID_EMPTY, true, false, LWOOBJID_EMPTY, eInventoryType::INVALID, 0, false, -1, rewardMetrics); } if (info.reward_currency > 0 || coinsToSend > 0) { diff --git a/dGame/dUtilities/Loot.cpp b/dGame/dUtilities/Loot.cpp index 20549b401..78e340cfc 100644 --- a/dGame/dUtilities/Loot.cpp +++ b/dGame/dUtilities/Loot.cpp @@ -523,14 +523,22 @@ Loot::Return Loot::RollLootMatrix(Entity* player, uint32_t matrixIndex) { } void Loot::GiveLoot(Entity* player, uint32_t matrixIndex, eLootSourceType lootSourceType) { + GiveLoot(player, matrixIndex, lootSourceType, LootMetrics{}); +} + +void Loot::GiveLoot(Entity* player, const Loot::Return& result, eLootSourceType lootSourceType) { + GiveLoot(player, result, lootSourceType, LootMetrics{}); +} + +void Loot::GiveLoot(Entity* player, uint32_t matrixIndex, eLootSourceType lootSourceType, const LootMetrics& metrics) { player = player->GetOwner(); // If the owner is overwritten, we collect that here const auto result = RollLootMatrix(player, matrixIndex); - GiveLoot(player, result, lootSourceType); + GiveLoot(player, result, lootSourceType, metrics); } -void Loot::GiveLoot(Entity* player, const Loot::Return& result, eLootSourceType lootSourceType) { +void Loot::GiveLoot(Entity* player, const Loot::Return& result, eLootSourceType lootSourceType, const LootMetrics& metrics) { player = player->GetOwner(); // if the owner is overwritten, we collect that here auto* inventoryComponent = player->GetComponent(); @@ -539,7 +547,7 @@ void Loot::GiveLoot(Entity* player, const Loot::Return& result, eLootSourceType return; for (const auto& pair : result) { - inventoryComponent->AddItem(pair.first, pair.second, lootSourceType); + inventoryComponent->AddItem(pair.first, pair.second, lootSourceType, eInventoryType::INVALID, {}, LWOOBJID_EMPTY, true, false, LWOOBJID_EMPTY, eInventoryType::INVALID, 0, false, -1, metrics); } } @@ -577,7 +585,11 @@ void Loot::GiveActivityLoot(Entity* player, const LWOOBJID source, uint32_t acti const auto [minCoins, maxCoins] = GetActivityCoinRange(*selectedReward); - GiveLoot(player, selectedReward->LootMatrixIndex, eLootSourceType::ACTIVITY); + // Live sent the activity's ID and the activity object's LOT with each reward item + LootMetrics metrics{}; + metrics.activityId = static_cast(activityID); + if (const auto* const sourceEntity = Game::entityManager->GetEntity(source)) metrics.sourceLot = sourceEntity->GetLOT(); + GiveLoot(player, selectedReward->LootMatrixIndex, eLootSourceType::ACTIVITY, metrics); uint32_t coins = static_cast(minCoins + GeneralUtils::GenerateRandomNumber(0, 1) * (maxCoins - minCoins)); diff --git a/dGame/dUtilities/Loot.h b/dGame/dUtilities/Loot.h index f6bb0bd92..842fcec05 100644 --- a/dGame/dUtilities/Loot.h +++ b/dGame/dUtilities/Loot.h @@ -8,6 +8,7 @@ #include class Entity; +struct LootMetrics; struct CDActivityRewards; struct CDCurrencyTable; @@ -20,6 +21,9 @@ namespace Loot { LWOOBJID id = 0; LOT lot = 0; int32_t count = 0; + // The LOT of the object it dropped from (1 when a player was the source), 0 when that object was not found. + // Sent back as the item's source when it is picked up (LootMetrics::sourceLot). + LOT sourceLot = 0; }; using Return = std::map; @@ -28,6 +32,9 @@ namespace Loot { void CacheMatrix(const uint32_t matrixIndex); void GiveLoot(Entity* player, uint32_t matrixIndex, eLootSourceType lootSourceType = eLootSourceType::NONE); void GiveLoot(Entity* player, const Loot::Return& result, eLootSourceType lootSourceType = eLootSourceType::NONE); + // As above, telling the client where the items came from (LootMetrics) + void GiveLoot(Entity* player, uint32_t matrixIndex, eLootSourceType lootSourceType, const LootMetrics& metrics); + void GiveLoot(Entity* player, const Loot::Return& result, eLootSourceType lootSourceType, const LootMetrics& metrics); void GiveActivityLoot(Entity* player, const LWOOBJID source, uint32_t activityID, int32_t rating = 0); void DropLoot(Entity* player, const LWOOBJID source, uint32_t matrixIndex, uint32_t minCoins, uint32_t maxCoins); void DropItem(Entity& player, GameMessages::DropClientLoot& lootMsg, bool useTeam = false, bool forceFfa = false); diff --git a/dGame/dUtilities/Mail.cpp b/dGame/dUtilities/Mail.cpp index e258824aa..fbde91cd7 100644 --- a/dGame/dUtilities/Mail.cpp +++ b/dGame/dUtilities/Mail.cpp @@ -239,7 +239,10 @@ namespace Mail { LwoNameValue config; config.InsertLines(playerMail->itemConfig); - const auto claimed = inv->ReceiveItem(playerMail->itemID, playerMail->itemLOT, playerMail->itemCount, eLootSourceType::MAIL, config, playerMail->itemSubkey); + // Live sent the mail's ID with the attachment + ReceiveItemOptions options{}; + options.metrics.mailId = static_cast(mailID); + const auto claimed = inv->ReceiveItem(playerMail->itemID, playerMail->itemLOT, playerMail->itemCount, eLootSourceType::MAIL, config, playerMail->itemSubkey, false, options); Database::Get()->ClaimMailItem(mailID); DashboardNotify::Changed("mail", playerMail->receiverId); diff --git a/dPhysics/dpWorld.cpp b/dPhysics/dpWorld.cpp index 1aad9f0f9..47dc5d7c2 100644 --- a/dPhysics/dpWorld.cpp +++ b/dPhysics/dpWorld.cpp @@ -3,6 +3,7 @@ #include "dpGrid.h" #include "DetourCommon.h" +#include #include #include "Game.h" @@ -24,6 +25,7 @@ namespace { bool phys_spatial_partitioning = true; std::vector m_MovementBlockers; + std::vector> m_OwnedMovementBlockers; }; void dpWorld::Initialize(unsigned int zoneID, bool generateNewNavMesh) { @@ -88,6 +90,7 @@ void dpWorld::Shutdown() { } m_MovementBlockers.clear(); + m_OwnedMovementBlockers.clear(); } bool dpWorld::IsLoaded() { @@ -197,6 +200,12 @@ void dpWorld::AddMovementBlocker(const dpEntity* entity, const uint32_t filter) m_MovementBlockers.push_back({ entity, filter }); } +void dpWorld::AddOwnedMovementBlocker(dpEntity* entity, const uint32_t filter) { + if (!entity) return; + m_OwnedMovementBlockers.emplace_back(entity); + AddMovementBlocker(entity, filter); +} + void dpWorld::RemoveMovementBlocker(const dpEntity* entity) { std::erase_if(m_MovementBlockers, [entity](const dpMovementBlocker& blocker) { return blocker.entity == entity; }); } diff --git a/dPhysics/dpWorld.h b/dPhysics/dpWorld.h index f14931c85..37bdf10d1 100644 --- a/dPhysics/dpWorld.h +++ b/dPhysics/dpWorld.h @@ -34,6 +34,11 @@ namespace dpWorld { * the caller, who removes it before deleting it; it is not stepped with the world. */ void AddMovementBlocker(const dpEntity* entity, uint32_t filter); + /** + * A movement blocker with no object of its own (a wall from the level files the server doesn't spawn), owned by the + * world from now on and freed when it shuts down. + */ + void AddOwnedMovementBlocker(dpEntity* entity, uint32_t filter); void RemoveMovementBlocker(const dpEntity* entity); std::span GetMovementBlockers(); diff --git a/dZoneManager/Level.cpp b/dZoneManager/Level.cpp index dd870a42c..27e9aa712 100644 --- a/dZoneManager/Level.cpp +++ b/dZoneManager/Level.cpp @@ -17,6 +17,7 @@ #include "LevelFile.h" #include "ZoneFileLog.h" #include "ClientVersion.h" +#include "PhysicsComponent.h" #include "dConfig.h" #include @@ -166,6 +167,13 @@ void Level::LoadSceneObjects(const std::vector& objects) { continue; } + // The client never loads a carver_only object (and live never sent one), but it still carves the AI navmesh: + // keep it as a wall the server's movers can't walk through, without an object + if (obj.lot != 176 && PhysicsComponent::IsCarverOnly(obj.settings)) { + PhysicsComponent::AddLevelMovementBlocker(obj.id, obj.lot, obj.settings, obj.position, obj.rotation, obj.scale); + continue; + } + if (obj.lot == 176) { //Spawner MakeSpawner(obj, m_SceneID); } else { //Regular object diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index 28e0f3be7..103739781 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -19,6 +19,7 @@ set(DCOMPONENTS_TESTS "ReplicaConstructionTests.cpp" "ReplicaDecoderTests.cpp" "ItemRemovalTests.cpp" + "LootMetricsTests.cpp" ) # Get the folder name and prepend it to the files above diff --git a/tests/dGameTests/dComponentsTests/LootMetricsTests.cpp b/tests/dGameTests/dComponentsTests/LootMetricsTests.cpp new file mode 100644 index 000000000..d4c0caa92 --- /dev/null +++ b/tests/dGameTests/dComponentsTests/LootMetricsTests.cpp @@ -0,0 +1,247 @@ +#include "GameDependencies.h" +#include + +#include "CDClientDatabase.h" +#include "CDComponentsRegistryTable.h" +#include "CDItemComponentTable.h" +#include "Character.h" +#include "CharacterComponent.h" +#include "Entity.h" +#include "EntityManager.h" +#include "InventoryComponent.h" +#include "InventoryMessages.h" +#include "Item.h" +#include "LootMetrics.h" +#include "QuickBuildComponent.h" +#include "MissionComponent.h" +#include "DestroyableComponent.h" +#include "LevelProgressionComponent.h" +#include "eQuickBuildState.h" +#include "eReplicaComponentType.h" + +#include "dGameMessagesTests/GameMessageTestUtils.h" + +using namespace GameMessageTestUtils; + +// The _Metric_ keys live put in AddItemToInventoryClientSync's extra info saying where items came from +// (LootMetrics), and the loot source of RemoveItemFromInventory. +class LootMetricsTests : public GameDependenciesTest { +protected: + static constexpr LOT ITEM = 7000; + static constexpr LOT SMASHABLE = 4712; + static constexpr LWOOBJID PLAYER = 0x1000000000000001LL; + + std::unique_ptr entity; + std::unique_ptr character; + InventoryComponent* inventory{}; + + void SetUp() override { + SetUpDependencies(); + CDClientDatabase::Connect(":memory:"); + CDClientDatabase::ExecuteDML("CREATE TABLE ItemSets (setID INTEGER, itemIDs TEXT);"); + CDClientDatabase::ExecuteDML("CREATE TABLE Objects (id INTEGER, name TEXT, type TEXT);"); + CDClientDatabase::ExecuteDML("CREATE TABLE ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER);"); + CDClientDatabase::ExecuteDML("CREATE TABLE ObjectSkills (objectTemplate INTEGER, skillID INTEGER, castOnType INTEGER, AICombatWeight INTEGER);"); + CDClientDatabase::ExecuteDML("CREATE TABLE SkillBehavior (skillID INTEGER, behaviorID INTEGER);"); + CDClientDatabase::ExecuteDML("CREATE TABLE Missions (id INTEGER, isMission INTEGER);"); + RegisterLot(ITEM); + + info.lot = 1; // a player + CDClientManager::GetEntriesMutable().insert_or_assign(static_cast(info.lot), 0); + entity = std::make_unique(PLAYER, info); + inventory = entity->AddComponent(-1); + character = std::make_unique(1, nullptr); + entity->SetCharacter(character.get()); + entity->AddComponent(-1, character.get(), ClientAddress())->InitializeStatisticsFromString(""); + Game::entityManager->_addEntity(entity.get()); // drops from the player, and the quickbuild, look it up there + ClearSent(); + } + + void TearDown() override { + Game::entityManager->_removeEntity(PLAYER); + entity->SetCharacter(nullptr); + entity.reset(); + character.reset(); + TearDownDependencies(); + } + + static void RegisterLot(const LOT lot) { + const auto componentID = static_cast(96000 + lot); + auto& registry = CDClientManager::GetEntriesMutable(); + registry.insert_or_assign(static_cast(lot), componentID); + registry.insert_or_assign(static_cast(eReplicaComponentType::ITEM) << 32 | static_cast(lot), componentID); + CDItemComponent component{}; + component.id = componentID; + component.stackSize = 999; + CDClientManager::GetEntriesMutable().insert_or_assign(componentID, component); + } + + static dServerMock& Server() { return *static_cast(Game::server); } + + static void ClearSent() { Server().ClearSentPackets(); } + + // The extra info of every AddItemToInventoryClientSync sent + static std::vector AddedExtraInfo() { + std::vector out; + for (const auto& packet : Server().GetSentPackets()) { + RakNet::BitStream bitStream(const_cast(packet.bytes.data()), packet.bytes.size(), false); + LWOOBJID target{}; + MessageType::Game id{}; + if (!GameMessages::NetGameMsg::ReadPacketHeader(bitStream, target, id)) continue; + if (id != MessageType::Game::ADD_ITEM_TO_INVENTORY_CLIENT_SYNC) continue; + GameMessages::AddItemToInventoryClientSync msg; + EXPECT_TRUE(msg.Deserialize(bitStream)); + out.push_back(msg.extraInfo); + } + return out; + } + + static std::vector Removed() { + std::vector out; + for (const auto& packet : Server().GetSentPackets()) { + RakNet::BitStream bitStream(const_cast(packet.bytes.data()), packet.bytes.size(), false); + LWOOBJID target{}; + MessageType::Game id{}; + if (!GameMessages::NetGameMsg::ReadPacketHeader(bitStream, target, id)) continue; + if (id != MessageType::Game::REMOVE_ITEM_FROM_INVENTORY) continue; + auto& msg = out.emplace_back(); + EXPECT_TRUE(msg.Deserialize(bitStream)); + } + return out; + } + + // Drops an item for the player from the given source object and picks it up + void DropAndPickUp(const LWOOBJID source, const LWOOBJID lootId) { + GameMessages::DropClientLoot drop; + drop.target = PLAYER; + drop.ownerID = PLAYER; + drop.sourceID = source; + drop.item = ITEM; + drop.count = 1; + drop.lootID = lootId; + GameMessages::DropClientLootEvent event(drop); + entity->MsgDropClientLoot(event); + ClearSent(); + entity->PickupItem(lootId); + } +}; + +TEST_F(LootMetricsTests, KeysAsLiveWroteThem) { + EXPECT_TRUE(LootMetrics{}.Empty()); + EXPECT_EQ(LootMetrics{}.ToExtraInfo(), u""); + + LootMetrics pickup{}; + pickup.sourceLot = 1; + EXPECT_EQ(pickup.ToExtraInfo(), u"_Metric_Souce_LOT_Int=1:1"); + + LootMetrics mission{}; + mission.sourceLot = 1; + mission.missionId = 889; + EXPECT_EQ(mission.ToExtraInfo(), u"_Metric_Souce_LOT_Int=1:1,_Metric_Mission_ID_Int=1:889"); + + LootMetrics vendor{}; + vendor.sourceLot = 13380; + vendor.currencyDelta = -20000; + EXPECT_EQ(vendor.ToExtraInfo(), u"_Metric_Souce_LOT_Int=1:13380,_Metric_Currency_Delta_Int=1:-20000"); + + // Live: the mail ID as name value type 8, the trade ID as type 9 + LootMetrics mail{}; + mail.mailId = 1152921510716557582ULL; + EXPECT_EQ(mail.ToExtraInfo(), u"_Metric_Mail_ID_Int64=8:1152921510716557582"); + LootMetrics trade{}; + trade.transactionId = 1152921510834827094LL; + EXPECT_EQ(trade.ToExtraInfo(), u"_Metric_Transaction_ID_Int64=9:1152921510834827094"); +} + +TEST_F(LootMetricsTests, PickupSaysWhichObjectItDroppedFrom) { + info.lot = SMASHABLE; + auto* const smashable = Game::entityManager->CreateEntity(info, nullptr, nullptr, false, 0x40040000000100LL); + ASSERT_NE(smashable, nullptr); + + DropAndPickUp(smashable->GetObjectID(), 0x5000); + EXPECT_EQ(AddedExtraInfo(), std::vector{ u"_Metric_Souce_LOT_Int=1:4712" }); + + // A second one joins the stack; the message still says where it came from + DropAndPickUp(smashable->GetObjectID(), 0x5001); + EXPECT_EQ(AddedExtraInfo(), std::vector{ u"_Metric_Souce_LOT_Int=1:4712" }); +} + +TEST_F(LootMetricsTests, PlayerSourcedPickupIsLotOne) { + // Activity rewards and chests drop from the player (live: 1 on 741 pickups) + DropAndPickUp(PLAYER, 0x5002); + EXPECT_EQ(AddedExtraInfo(), std::vector{ u"_Metric_Souce_LOT_Int=1:1" }); +} + +TEST_F(LootMetricsTests, UnknownSourceSendsNoKey) { + DropAndPickUp(0x40040000000999LL, 0x5003); + EXPECT_EQ(AddedExtraInfo(), std::vector{ u"" }); +} + +TEST_F(LootMetricsTests, MetricsFollowTheItemConfig) { + LwoNameValue config; + config.Insert(u"a", 5); + LootMetrics metrics{}; + metrics.sourceLot = 1; + inventory->AddItem(ITEM, 1, eLootSourceType::PICKUP, eInventoryType::INVALID, config, LWOOBJID_EMPTY, true, false, LWOOBJID_EMPTY, eInventoryType::INVALID, 0, false, -1, metrics); + EXPECT_EQ(AddedExtraInfo(), std::vector{ u"a=1:5,_Metric_Souce_LOT_Int=1:1" }); + // Only the message carries them + const auto* const item = inventory->FindItemByLot(ITEM); + ASSERT_NE(item, nullptr); + EXPECT_EQ(item->GetConfig().values.size(), 1u); +} + +TEST_F(LootMetricsTests, RemovalSaysWhatTookTheItems) { + inventory->AddItem(ITEM, 5, eLootSourceType::NONE); + ClearSent(); + constexpr LWOOBJID QUICKBUILD = 0x40040000000200LL; + ASSERT_TRUE(inventory->RemoveItem(ITEM, 5, eInventoryType::ALL, false, false, { eLootSourceType::QUICKBUILD, QUICKBUILD })); + const auto removed = Removed(); + ASSERT_EQ(removed.size(), 1u); + EXPECT_EQ(removed[0].eLootTypeSource, static_cast(eLootSourceType::QUICKBUILD)); + EXPECT_EQ(removed[0].iLootTypeSource, QUICKBUILD); + EXPECT_EQ(removed[0].iStackCount, 5u); + + // Without a source, as before + inventory->AddItem(ITEM, 1, eLootSourceType::NONE); + ClearSent(); + ASSERT_TRUE(inventory->RemoveItem(ITEM, 1)); + ASSERT_EQ(Removed().size(), 1u); + EXPECT_EQ(Removed()[0].eLootTypeSource, LOOTTYPE_NONE); + EXPECT_EQ(Removed()[0].iLootTypeSource, LWOOBJID_EMPTY); +} + +// Live: the FV Stone Warrior pedestal's 5 Maelstrom Infected Bricks were taken with the loot source Quickbuild and the +// quickbuild as the source object +TEST_F(LootMetricsTests, QuickbuildTakesItsCostAsTheSource) { + constexpr LOT BRICK = 6194; + constexpr int32_t PRECONDITION = 90099; // stands in for 99: have 5 of LOT 6194 + RegisterLot(BRICK); + CDClientDatabase::ExecuteDML("CREATE TABLE IF NOT EXISTS Preconditions (id INTEGER, type INTEGER, targetLOT TEXT, targetCount INTEGER);"); + CDClientDatabase::ExecuteDML("INSERT INTO Preconditions VALUES (" + std::to_string(PRECONDITION) + ", 2, '6194', 5);"); + inventory->AddItem(BRICK, 7, eLootSourceType::NONE); + // What the precondition check looks at besides the inventory + entity->AddComponent(-1); + entity->AddComponent(-1); + entity->AddComponent(-1); + + auto pedestalInfo = info; + pedestalInfo.lot = 8551; + auto pedestal = std::make_unique(0x0102030405060708LL, pedestalInfo); + pedestal->SetVar(u"CheckPrecondition", GeneralUtils::ASCIIToUTF16(std::to_string(PRECONDITION))); + auto* const quickBuild = pedestal->AddComponent(-1); + quickBuild->SetResetTime(20.0f); + quickBuild->SetCompleteTime(10.0f); + ClearSent(); + + quickBuild->OnUse(entity.get()); + EXPECT_EQ(quickBuild->GetState(), eQuickBuildState::BUILDING); + const auto removed = Removed(); + ASSERT_EQ(removed.size(), 1u); + EXPECT_EQ(removed[0].iObjTemplate, BRICK); + EXPECT_EQ(removed[0].iStackCount, 5u); + EXPECT_EQ(removed[0].eLootTypeSource, static_cast(eLootSourceType::QUICKBUILD)); + EXPECT_EQ(removed[0].iLootTypeSource, pedestal->GetObjectID()); + EXPECT_EQ(inventory->GetLotCount(BRICK), 2u); + + pedestal.reset(); +} diff --git a/tests/dGameTests/dComponentsTests/MovementBlockerScenarioTests.cpp b/tests/dGameTests/dComponentsTests/MovementBlockerScenarioTests.cpp index d2a83af47..1db2f3c65 100644 --- a/tests/dGameTests/dComponentsTests/MovementBlockerScenarioTests.cpp +++ b/tests/dGameTests/dComponentsTests/MovementBlockerScenarioTests.cpp @@ -12,6 +12,7 @@ #include "Entity.h" #include "eReplicaComponentType.h" #include "MovementAIComponent.h" +#include "PetComponent.h" #include "PhantomPhysicsComponent.h" #include "SimplePhysicsComponent.h" @@ -24,6 +25,11 @@ protected: static constexpr int32_t ENEMY_BLOCKER_PHYSICS = 4237; static constexpr int32_t CARVER_PHYSICS = 3901; static constexpr int32_t CLEAR_THREAT_PHYSICS = 6691; + static constexpr LOT PET_BLOCKER_LOT = 3913; // PR - Pet Blocker: solid, group 18 + static constexpr int32_t PET_BLOCKER_PHYSICS = 1913; + static constexpr LOT HEDGE_LOT = 3027; // AG - bush square section: solid, a navmesh carver in Robot City + static constexpr int32_t HEDGE_PHYSICS = 90301; + static constexpr int32_t PET_GROUP = 3; void SetUp() override { SetUpDependencies(); @@ -42,10 +48,13 @@ protected: AddPhysics(ENEMY_BLOCKER_LOT, eReplicaComponentType::SIMPLE_PHYSICS, ENEMY_BLOCKER_PHYSICS, "miscellaneous\\misc_phys_10x1x5.hkx", 18); AddPhysics(CARVER_LOT, eReplicaComponentType::PHANTOM_PHYSICS, CARVER_PHYSICS, "miscellaneous\\misc_phys_10x1x5.hkx", 7); AddPhysics(CLEAR_THREAT_LOT, eReplicaComponentType::PHANTOM_PHYSICS, CLEAR_THREAT_PHYSICS, "test\\POI_trigger_wall.hkx", 18); + AddPhysics(PET_BLOCKER_LOT, eReplicaComponentType::SIMPLE_PHYSICS, PET_BLOCKER_PHYSICS, "miscellaneous\\misc_phys_10x1x5.hkx", 18); + AddPhysics(HEDGE_LOT, eReplicaComponentType::SIMPLE_PHYSICS, HEDGE_PHYSICS, "env\\env_won_nim_bush_square-section.hkx", 7); CDClientManager::GetEntriesMutable().insert_or_assign(static_cast(info.lot), 0); } void TearDown() override { + dpWorld::Shutdown(); // frees the blockers the world owns TearDownDependencies(); } @@ -136,3 +145,72 @@ TEST_F(MovementBlockerScenarioTest, NonEnemyMoverIsNotClamped) { movement->SetDestination({ 0.0f, 0.0f, 10.0f }); EXPECT_NEAR(movement->GetDestination().z, 10.0f, 1e-3f); } + +// Pets are walked by the server too; the pet ranch's pet blockers keep them in (group 18 touches pets, group 3) +TEST_F(MovementBlockerScenarioTest, PetBlockerStopsAPet) { + auto wall = Wall(PET_BLOCKER_LOT, 104); + wall->AddComponent(PET_BLOCKER_PHYSICS); + ASSERT_EQ(dpWorld::GetMovementBlockers().size(), 1u); + + EntityInfo petInfo = info; + petInfo.pos = { 0.0f, 0.0f, -10.0f }; + Entity pet(204, petInfo); + pet.AddComponent(-1)->SetCollisionGroup(PET_GROUP); + pet.AddComponent(-1); + auto* const movement = pet.AddComponent(-1, MovementAIInfo{}); + movement->SetDestination({ 0.0f, 0.0f, 10.0f }); + EXPECT_LT(movement->GetDestination().z, -0.5f); + EXPECT_GT(movement->GetDestination().z, -3.0f); +} + +// A carver_only object is never spawned (the client never loads one), but it still stops the chase +TEST_F(MovementBlockerScenarioTest, CarverOnlyWallBlocksWithoutAnObject) { + LwoNameValue settings; + settings.Insert(u"carver_only", true); + settings.Insert(u"navmesh_carver", true); + EXPECT_TRUE(PhysicsComponent::IsCarverOnly(settings)); + ASSERT_TRUE(PhysicsComponent::AddLevelMovementBlocker(105, CARVER_LOT, settings, NiPoint3Constant::ZERO, QuatUtils::IDENTITY, 1.0f)); + ASSERT_EQ(dpWorld::GetMovementBlockers().size(), 1u); + + EntityInfo enemyInfo = info; + enemyInfo.pos = { 0.0f, 0.0f, -10.0f }; + Entity enemy(205, enemyInfo); + EXPECT_LT(ChaseAcross(enemy).z, -0.5f); + + dpWorld::Shutdown(); + EXPECT_TRUE(dpWorld::GetMovementBlockers().empty()); +} + +TEST_F(MovementBlockerScenarioTest, CarverOnlyNeedsItsFlagAndAWall) { + LwoNameValue settings; + EXPECT_FALSE(PhysicsComponent::IsCarverOnly(settings)); + settings.Insert(u"carver_only", false); + EXPECT_FALSE(PhysicsComponent::IsCarverOnly(settings)); + // carver_only without navmesh_carver (most of them): nothing to block with + settings.Insert(u"carver_only", true); + EXPECT_TRUE(PhysicsComponent::IsCarverOnly(settings)); + EXPECT_FALSE(PhysicsComponent::AddLevelMovementBlocker(106, CARVER_LOT, settings, NiPoint3Constant::ZERO, QuatUtils::IDENTITY, 1.0f)); + EXPECT_TRUE(dpWorld::GetMovementBlockers().empty()); +} + +// The Robot City hedges carve the navmesh; their size comes from the client's collision shape (4.39 x 4.96 x 9.03) +TEST_F(MovementBlockerScenarioTest, HedgeHasItsRealSize) { + auto hedge = Wall(HEDGE_LOT, 107); + hedge->SetVar(u"navmesh_carver", true); + hedge->AddComponent(HEDGE_PHYSICS); + const auto blockers = dpWorld::GetMovementBlockers(); + ASSERT_EQ(blockers.size(), 1u); + const auto* const box = dynamic_cast(blockers[0].entity->GetShape()); + ASSERT_NE(box, nullptr); + EXPECT_NEAR(box->m_MaxX - box->m_MinX, 4.3949f, 1e-3f); + EXPECT_NEAR(box->m_MaxY - box->m_MinY, 4.9645f, 1e-3f); + EXPECT_NEAR(box->m_MaxZ - box->m_MinZ, 9.0341f, 1e-3f); + // Centred where the shape is, a little off the object's position + EXPECT_NEAR((box->m_MaxX + box->m_MinX) / 2.0f, -0.0343f, 1e-3f); + EXPECT_NEAR((box->m_MaxZ + box->m_MinZ) / 2.0f, -0.0527f, 1e-3f); + + EntityInfo enemyInfo = info; + enemyInfo.pos = { 0.0f, 0.0f, -10.0f }; + Entity enemy(206, enemyInfo); + EXPECT_LT(ChaseAcross(enemy).z, -4.5f); +}