From a1ee18fce7afe3899186b4272d370b8e1f3ca8ea Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 07:45:50 -0500 Subject: [PATCH 1/9] feat(inventory): items can carry where they came from to the client Live servers put _Metric_ keys in AddItemToInventoryClientSync's extra info, after the item's own config, saying where the items came from: the source object's LOT (_Metric_Souce_LOT_Int, misspelt as live had it), the mission, activity, coins paid, mail or trade. LootMetrics holds them and writes them with the key names and name value types live used (the mail ID as type 8, the trade ID as type 9). AddItem, ReceiveItem (its options), the new item constructor and Item::SetCount pass them through to the message; the item does not keep them. RemoveItem and Item::SetCount can also say what took items away (ItemRemovalSource), filling RemoveItemFromInventory's loot source and source object instead of none. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/InventoryComponent.cpp | 18 ++++++----- dGame/dComponents/InventoryComponent.h | 9 ++++-- dGame/dGameMessages/InventoryMessages.cpp | 8 +++++ dGame/dGameMessages/InventoryMessages.h | 5 +++ dGame/dInventory/CMakeLists.txt | 3 +- dGame/dInventory/Item.cpp | 10 ++++-- dGame/dInventory/Item.h | 9 ++++-- dGame/dInventory/LootMetrics.cpp | 27 ++++++++++++++++ dGame/dInventory/LootMetrics.h | 39 +++++++++++++++++++++++ 9 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 dGame/dInventory/LootMetrics.cpp create mode 100644 dGame/dInventory/LootMetrics.h 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/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; +}; From 898ae024f050f71dc1de95b84aaa322fea7ab7fa Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 07:45:56 -0500 Subject: [PATCH 2/9] feat(loot): a picked up item says which object it dropped from Live's AddItemToInventoryClientSync for a picked up drop had _Metric_Souce_LOT_Int in its extra info on all 2,344 live pickups: the LOT of the DropClientLoot's source object, 1 when a player was the source (activity rewards and chests, which drop from the player). DLU sent none. The drop remembers its source object's LOT when it is registered for the player, and the pickup sends it. A source object already gone when it drops sends no key. Package contents keep sending none, as live's did. Co-Authored-By: Claude Opus 5.5 --- dGame/Entity.cpp | 7 ++++++- dGame/dUtilities/Loot.h | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/dUtilities/Loot.h b/dGame/dUtilities/Loot.h index f6bb0bd92..c2eba6e4a 100644 --- a/dGame/dUtilities/Loot.h +++ b/dGame/dUtilities/Loot.h @@ -20,6 +20,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; From ed2d32978163ce411ff4200988106415e759af77 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 07:46:05 -0500 Subject: [PATCH 3/9] feat(loot): rewards, purchases, mail and trades say where items came from The other item sources live marked in AddItemToInventoryClientSync's extra info, with the keys and values live sent: - mission and achievement rewards: _Metric_Mission_ID_Int and source LOT 1, the player (225 of 225 live rewards) - activity rewards: _Metric_Activity_ID_Int and the activity object's LOT - vendor purchases: the vendor's LOT and _Metric_Currency_Delta_Int, the coins paid as a negative number (left out when the item costs no coins) - mail attachments: _Metric_Mail_ID_Int64 - traded items: _Metric_Transaction_ID_Int64, the trade's ID Loot::GiveLoot gains overloads that pass them on. Co-Authored-By: Claude Opus 5.5 --- dGame/TradingManager.cpp | 10 +++++++--- dGame/dComponents/VendorComponent.cpp | 6 +++++- dGame/dMission/Mission.cpp | 9 +++++++-- dGame/dUtilities/Loot.cpp | 20 ++++++++++++++++---- dGame/dUtilities/Loot.h | 4 ++++ dGame/dUtilities/Mail.cpp | 5 ++++- 6 files changed, 43 insertions(+), 11 deletions(-) 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/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/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 c2eba6e4a..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; @@ -31,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); From a5126536a403775428d2e5e20ee5326024ae7bf7 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 07:46:13 -0500 Subject: [PATCH 4/9] fix(quickbuild): the item cost is taken with the quickbuild as its source Live's RemoveItemFromInventory for a quickbuild's item cost (the FV Stone Warrior pedestal's 5 Maelstrom Infected Bricks, taken when the build starts) had the loot source Quickbuild and the quickbuild's object ID as the loot source object. DLU sent loot source None with no source. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/QuickBuildComponent.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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(); } From e77535984df066307b63bc5add58d748a7cc4c64 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 07:46:13 -0500 Subject: [PATCH 5/9] test(loot): item source keys and the quickbuild item cost's source Covers the key names and name value types, a pickup from an object, from the player and from an object that is gone, metrics after an item's config (and not kept on the item), a removal's loot source, and a quickbuild taking its item cost with itself as the source. Co-Authored-By: Claude Opus 5.5 --- .../dComponentsTests/CMakeLists.txt | 1 + .../dComponentsTests/LootMetricsTests.cpp | 247 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 tests/dGameTests/dComponentsTests/LootMetricsTests.cpp 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(); +} From 53c03515a724ce9fcfb51ae2a04aaee815a85206 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 07:46:20 -0500 Subject: [PATCH 6/9] feat(ai): pets stop at the walls their group can't cross Pets are walked by the server's MovementAI like enemies, but only enemies' paths were cut at movement blockers, so pets walked through the Nimbus Station pet ranch's "PR - Pet Blocker" walls. Their collision group (3) is the one the pet blocker's group (18) touches in the client's collision filter, so a pet's path now stops at them (and at navmesh carvers), whether wandering or following its owner; a pet left behind still warps to its owner. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/MovementAIComponent.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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()); } From 175d2930b798c3a568d3dcbeb10bc051b3f6a85e Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 07:48:56 -0500 Subject: [PATCH 7/9] feat(physics): real sizes for the navmesh carvers the server didn't know Walls whose asset shape the server doesn't know are left out of the movement blockers (a stand in cube would be a guess), which left out every navmesh carver in Robot City (hedges, hedge corners, potted bushes, greebles, robot statues) and the Ninjago Monastery benches. Their sizes now come from the client's own collision shapes (res/physics): the bounds of all of a shape's parts, with the offset of the box from the object's position. The hedge corner is an L, so its box also covers the inside of the corner. The same bounds match two shapes the server already had (misc_phys_10x1x5: 10 x 5 x 1; Trigger_Rectangle_Box: 8 x 8 x 4). CreatePhysicsEntity's shape lookup becomes CreateAssetShape, which needs no component, for walls without an object. Still unknown: the Ninjago Monastery cave's primitive model carver (a 77.6 cube whose origin, centre or base, isn't known). Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/PhysicsComponent.cpp | 76 +++++++++++++++++++------- dGame/dComponents/PhysicsComponent.h | 8 +++ 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/dGame/dComponents/PhysicsComponent.cpp b/dGame/dComponents/PhysicsComponent.cpp index cbdf4e1f6..65bb25f5a 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; @@ -91,9 +117,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 +139,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..a8f127403 100644 --- a/dGame/dComponents/PhysicsComponent.h +++ b/dGame/dComponents/PhysicsComponent.h @@ -4,6 +4,7 @@ #include "Component.h" #include "NiPoint3.h" #include "NiQuaternion.h" +#include "dCommonVars.h" namespace GameMessages { struct GetObjectReportInfo; @@ -39,6 +40,13 @@ protected: // 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 From da9ae3b3c45bcdacf38d4d7644bf87077f86c075 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 07:49:04 -0500 Subject: [PATCH 8/9] feat(zone): don't spawn carver_only objects, keep them as walls The client never loads a level object whose config has carver_only set (its resource manager skips the load; LWOBasePhysComponent::LoadConfigData 0x00c495c9 reads the flag next to navmesh_carver), and live never sent one: none of the 331 carver_only placements (mostly Crux Prime's trigger boxes) shows up among the live constructions, where their zones' other objects do. DLU spawned them as objects. They are skipped now. The ones that carve the navmesh (53, in six zones, among them the Avant Gardens Sentinel camp walls) still stop the server's movers: their shape goes into the world's movement blockers without an object, owned by the physics world (dpWorld::AddOwnedMovementBlocker) and freed when it shuts down. None of them has a script, trigger or group. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/PhysicsComponent.cpp | 50 ++++++++++++++++++++++++++ dGame/dComponents/PhysicsComponent.h | 16 +++++++++ dPhysics/dpWorld.cpp | 9 +++++ dPhysics/dpWorld.h | 5 +++ dZoneManager/Level.cpp | 8 +++++ 5 files changed, 88 insertions(+) diff --git a/dGame/dComponents/PhysicsComponent.cpp b/dGame/dComponents/PhysicsComponent.cpp index 65bb25f5a..0e4aa5fec 100644 --- a/dGame/dComponents/PhysicsComponent.cpp +++ b/dGame/dComponents/PhysicsComponent.cpp @@ -97,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; diff --git a/dGame/dComponents/PhysicsComponent.h b/dGame/dComponents/PhysicsComponent.h index a8f127403..ca6c30185 100644 --- a/dGame/dComponents/PhysicsComponent.h +++ b/dGame/dComponents/PhysicsComponent.h @@ -6,6 +6,8 @@ #include "NiQuaternion.h" #include "dCommonVars.h" +struct LwoNameValue; + namespace GameMessages { struct GetObjectReportInfo; struct GetPosition; @@ -34,6 +36,20 @@ 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); 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 From f6659d35f95fa858e296812980ef6d896116e4d9 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 07:49:19 -0500 Subject: [PATCH 9/9] test(ai): pets at pet blockers, carver_only walls and hedge sizes A pet chasing across a "PR - Pet Blocker" stops in front of it; a carver_only navmesh carver added without an object stops an enemy and goes away with the physics world; carver_only alone blocks nothing; a Robot City hedge's blocker has the size and offset of its collision shape. Co-Authored-By: Claude Opus 5.5 --- .../MovementBlockerScenarioTests.cpp | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) 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); +}