Merge exp/loot-blocker-followups: item source keys, quickbuild cost source, pet blockers, carver_only walls

This commit is contained in:
Aaron Kimbrell
2026-09-30 07:49:50 -05:00
26 changed files with 649 additions and 52 deletions

View File

@@ -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);
}

View File

@@ -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<TradeItem>& items, InventoryComponent* from, MissionComponent* fromMissions,
const auto tradeId = m_TradeId;
const auto giveItems = [zone, tradeId](const std::vector<TradeItem>& 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<int32_t>(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,

View File

@@ -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<int>(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<uint32_t>(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<uint32_t>(item->GetCount(), left);
item->SetCount(item->GetCount() - delta, silent);
item->SetCount(item->GetCount() - delta, silent, true, true, eLootSourceType::NONE, {}, removalSource);
left -= delta;
}
}

View File

@@ -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

View File

@@ -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<uint32_t>(m_Parent->GetCollisionGroup()));
if (computedPath.empty()) computedPath.push_back(m_Parent->GetPosition());
}

View File

@@ -2,6 +2,8 @@
#include <algorithm>
#include <cctype>
#include <map>
#include <string>
#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<std::string, KnownBox> 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<int32_t>(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<CDComponentsRegistryTable>();
auto* const physicsTable = CDClientManager::GetTable<CDPhysicsComponentTable>();
// 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<int32_t>(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<uint32_t>(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<CDComponentsRegistryTable>();
auto componentID = compRegistryTable->GetByIDAndType(m_Parent->GetLOT(), type);
auto componentID = compRegistryTable->GetByIDAndType(lot, type);
CDPhysicsComponentTable* physComp = CDClientManager::GetTable<CDPhysicsComponentTable>();
@@ -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<uint32_t>(m_CollisionGroup));
toReturn->SetCollisionGroup(static_cast<uint32_t>(collisionGroup));
return toReturn;
}

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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<int32_t>(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);
}

View File

@@ -9,6 +9,7 @@
#include "Inventory.h"
#include "InventoryComponent.h"
#include "Item.h"
#include "LootMetrics.h"
#include "BrickByBrick.h"
#include <ranges>
@@ -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);

View File

@@ -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{};

View File

@@ -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 "."

View File

@@ -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<int>(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<int32_t>(removalSource.type);
removeItem.iLootTypeSource = removalSource.object;
removeItem.iObjID = id;
removeItem.iObjTemplate = lot;
removeItem.iStackCount = delta;

View File

@@ -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)

View File

@@ -0,0 +1,27 @@
#include "LootMetrics.h"
#include "LDFFormat.h"
namespace {
template<typename T>
void Append(std::u16string& out, const char16_t* key, const std::optional<T>& value) {
if (!value) return;
if (!out.empty()) out += u',';
out += GeneralUtils::ASCIIToUTF16(LDFData<T>(key, *value).GetString());
}
}
bool LootMetrics::Empty() const {
return !sourceLot && !missionId && !activityId && !currencyDelta && !mailId && !transactionId;
}
std::u16string LootMetrics::ToExtraInfo() const {
std::u16string out;
Append<int32_t>(out, u"_Metric_Souce_LOT_Int", sourceLot);
Append<int32_t>(out, u"_Metric_Mission_ID_Int", missionId);
Append<int32_t>(out, u"_Metric_Activity_ID_Int", activityId);
Append<int32_t>(out, u"_Metric_Currency_Delta_Int", currencyDelta);
Append<uint64_t>(out, u"_Metric_Mail_ID_Int64", mailId);
Append<LWOOBJID>(out, u"_Metric_Transaction_ID_Int64", transactionId);
return out;
}

View File

@@ -0,0 +1,39 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#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<LOT> sourceLot; // _Metric_Souce_LOT_Int (the key is misspelt in live's data)
std::optional<int32_t> missionId; // _Metric_Mission_ID_Int
std::optional<int32_t> activityId; // _Metric_Activity_ID_Int
std::optional<int32_t> currencyDelta; // _Metric_Currency_Delta_Int
std::optional<uint64_t> mailId; // _Metric_Mail_ID_Int64, name value type 8
std::optional<LWOOBJID> 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;
};

View File

@@ -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<int32_t>(info.id);
if (m_Completions > 0) {
std::vector<std::pair<LOT, uint32_t>> 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) {

View File

@@ -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<InventoryComponent>();
@@ -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<int32_t>(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<uint32_t>(minCoins + GeneralUtils::GenerateRandomNumber<float>(0, 1) * (maxCoins - minCoins));

View File

@@ -8,6 +8,7 @@
#include <vector>
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<LOT, int32_t>;
@@ -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);

View File

@@ -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<uint64_t>(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);

View File

@@ -3,6 +3,7 @@
#include "dpGrid.h"
#include "DetourCommon.h"
#include <memory>
#include <string>
#include "Game.h"
@@ -24,6 +25,7 @@ namespace {
bool phys_spatial_partitioning = true;
std::vector<dpMovementBlocker> m_MovementBlockers;
std::vector<std::unique_ptr<dpEntity>> 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; });
}

View File

@@ -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<const dpMovementBlocker> GetMovementBlockers();

View File

@@ -17,6 +17,7 @@
#include "LevelFile.h"
#include "ZoneFileLog.h"
#include "ClientVersion.h"
#include "PhysicsComponent.h"
#include "dConfig.h"
#include <ranges>
@@ -166,6 +167,13 @@ void Level::LoadSceneObjects(const std::vector<SceneObject>& 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

View File

@@ -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

View File

@@ -0,0 +1,247 @@
#include "GameDependencies.h"
#include <gtest/gtest.h>
#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> entity;
std::unique_ptr<Character> 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<CDComponentsRegistryTable>().insert_or_assign(static_cast<uint64_t>(info.lot), 0);
entity = std::make_unique<Entity>(PLAYER, info);
inventory = entity->AddComponent<InventoryComponent>(-1);
character = std::make_unique<Character>(1, nullptr);
entity->SetCharacter(character.get());
entity->AddComponent<CharacterComponent>(-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<uint32_t>(96000 + lot);
auto& registry = CDClientManager::GetEntriesMutable<CDComponentsRegistryTable>();
registry.insert_or_assign(static_cast<uint64_t>(lot), componentID);
registry.insert_or_assign(static_cast<uint64_t>(eReplicaComponentType::ITEM) << 32 | static_cast<uint64_t>(lot), componentID);
CDItemComponent component{};
component.id = componentID;
component.stackSize = 999;
CDClientManager::GetEntriesMutable<CDItemComponentTable>().insert_or_assign(componentID, component);
}
static dServerMock& Server() { return *static_cast<dServerMock*>(Game::server); }
static void ClearSent() { Server().ClearSentPackets(); }
// The extra info of every AddItemToInventoryClientSync sent
static std::vector<std::u16string> AddedExtraInfo() {
std::vector<std::u16string> out;
for (const auto& packet : Server().GetSentPackets()) {
RakNet::BitStream bitStream(const_cast<uint8_t*>(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<GameMessages::RemoveItemFromInventory> Removed() {
std::vector<GameMessages::RemoveItemFromInventory> out;
for (const auto& packet : Server().GetSentPackets()) {
RakNet::BitStream bitStream(const_cast<uint8_t*>(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<std::u16string>{ 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<std::u16string>{ 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<std::u16string>{ u"_Metric_Souce_LOT_Int=1:1" });
}
TEST_F(LootMetricsTests, UnknownSourceSendsNoKey) {
DropAndPickUp(0x40040000000999LL, 0x5003);
EXPECT_EQ(AddedExtraInfo(), std::vector<std::u16string>{ u"" });
}
TEST_F(LootMetricsTests, MetricsFollowTheItemConfig) {
LwoNameValue config;
config.Insert<int32_t>(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<std::u16string>{ 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<int32_t>(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<MissionComponent>(-1);
entity->AddComponent<DestroyableComponent>(-1);
entity->AddComponent<LevelProgressionComponent>(-1);
auto pedestalInfo = info;
pedestalInfo.lot = 8551;
auto pedestal = std::make_unique<Entity>(0x0102030405060708LL, pedestalInfo);
pedestal->SetVar<std::u16string>(u"CheckPrecondition", GeneralUtils::ASCIIToUTF16(std::to_string(PRECONDITION)));
auto* const quickBuild = pedestal->AddComponent<QuickBuildComponent>(-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<int32_t>(eLootSourceType::QUICKBUILD));
EXPECT_EQ(removed[0].iLootTypeSource, pedestal->GetObjectID());
EXPECT_EQ(inventory->GetLotCount(BRICK), 2u);
pedestal.reset();
}

View File

@@ -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<CDComponentsRegistryTable>().insert_or_assign(static_cast<uint64_t>(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<SimplePhysicsComponent>(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<SimplePhysicsComponent>(-1)->SetCollisionGroup(PET_GROUP);
pet.AddComponent<PetComponent>(-1);
auto* const movement = pet.AddComponent<MovementAIComponent>(-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<bool>(u"carver_only", true);
settings.Insert<bool>(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<bool>(u"carver_only", false);
EXPECT_FALSE(PhysicsComponent::IsCarverOnly(settings));
// carver_only without navmesh_carver (most of them): nothing to block with
settings.Insert<bool>(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<bool>(u"navmesh_carver", true);
hedge->AddComponent<SimplePhysicsComponent>(HEDGE_PHYSICS);
const auto blockers = dpWorld::GetMovementBlockers();
ASSERT_EQ(blockers.size(), 1u);
const auto* const box = dynamic_cast<const dpShapeBox*>(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);
}