From 1f29e9604a1417312e027a841fc6fd8437727a67 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 02:17:48 -0500 Subject: [PATCH] feat(stats): coins, bricks and smashes counted by the server as live did The passport totals for coins, bricks and enemies came from the client's ModifyPlayerZoneStatistic, which live's client sent for its per-zone counts; live's server counted the totals itself and told the client (UpdatePlayerStatistic). Now: - CurrencyCollected (the amount gained) right after every SetCurrency that raised the coins (pickups, missions, achievements, selling, activities); none for losses. Captures: 15,815 pickups + mission/achievement/vendor/ activity gains, all with the stat; no loss had one. - BricksCollected (the count) after every add to the bricks inventory the client is told about (captures: 999 of 1,010, pickups and relocations). - The kill counts go to the killer right after Die, before the loot: EnemiesSmashed when the DestructibleComponent's isnpc is set, else SmashablesSmashed for a smashable (captures: 2,766 NPC kills and 538 smashables; faction or AI don't decide it, e.g. the Banana Cluster has AI and counted as a smashable). Neither while racing. Before, every kill was a SmashablesSmashed. - ModifyPlayerZoneStatistic from the client only updates the zone counts, so nothing is counted twice. Check in game: pick up coins, sell an item, finish a mission, pick up bricks, smash a stromling and a crate; each passport number goes up by the right amount at once and stays the same after relogging (no double counts). The per-zone statistics still go up. Co-Authored-By: Claude Opus 5.5 --- dGame/Character.cpp | 6 ++ dGame/Entity.cpp | 8 +-- dGame/dComponents/CharacterComponent.cpp | 9 ++- dGame/dComponents/CharacterComponent.h | 7 +++ dGame/dComponents/DestroyableComponent.cpp | 10 ++++ dGame/dComponents/DestroyableComponent.h | 9 +++ dGame/dComponents/InventoryComponent.cpp | 1 + dGame/dInventory/Item.cpp | 2 + tests/dGameTests/PlayerStatisticTests.cpp | 65 ++++++++++++++++++++++ 9 files changed, 107 insertions(+), 10 deletions(-) diff --git a/dGame/Character.cpp b/dGame/Character.cpp index ba1ce034a..d61c6c7ed 100644 --- a/dGame/Character.cpp +++ b/dGame/Character.cpp @@ -10,6 +10,7 @@ #include #include "Entity.h" #include "EntityManager.h" +#include "CharacterComponent.h" #include "GameMessages.h" #include "EffectsMessages.h" #include "MissionMessages.h" @@ -595,6 +596,7 @@ void Character::SetCoins(int64_t newCoins, eLootSourceType lootSource) { } EconomyLedger::RecordCoins(m_ID, newCoins - m_Coins, lootSource); + const auto gained = newCoins > m_Coins ? static_cast(newCoins - m_Coins) : 0; m_Coins = newCoins; auto* entity = Game::entityManager->GetEntity(m_ObjectID); @@ -607,6 +609,10 @@ void Character::SetCoins(int64_t newCoins, eLootSourceType lootSource) { setCurrency.sourceTradeID = 0; setCurrency.sourceType = lootSource; setCurrency.SendToClient(entity->GetSystemAddress()); + + // Live followed every coin gain with CurrencyCollected (the amount gained); losses had none + auto* characterComponent = entity->GetComponent(); + if (gained > 0 && characterComponent) characterComponent->UpdatePlayerStatistic(CurrencyCollected, gained); } bool Character::HasBeenToWorld(LWOMAPID mapID) const { diff --git a/dGame/Entity.cpp b/dGame/Entity.cpp index 3c905563d..594017cc2 100644 --- a/dGame/Entity.cpp +++ b/dGame/Entity.cpp @@ -438,6 +438,7 @@ void Entity::Initialize() { comp->SetDeathBehavior(destCompData[0].death_behavior); comp->SetIsSmashable(comp->GetIsSmashable() || destCompData[0].isSmashable); + comp->SetIsNPC(destCompData[0].isnpc); comp->SetLootMatrixID(destCompData[0].LootMatrixIndex); comp->SetCurrencyIndex(destCompData[0].CurrencyIndex); @@ -1473,13 +1474,6 @@ void Entity::Kill(Entity* murderer, const eKillType killType) { characterComponent->UpdatePlayerStatistic(TimesSmashed); } - // Track a player smashing something else - if (murderer != nullptr) { - auto* murdererCharacterComponent = murderer->GetComponent(); - if (murdererCharacterComponent != nullptr) { - murdererCharacterComponent->UpdatePlayerStatistic(SmashablesSmashed); - } - } } void Entity::AddDieCallback(const std::function& callback) { diff --git a/dGame/dComponents/CharacterComponent.cpp b/dGame/dComponents/CharacterComponent.cpp index 9bda59a74..341b9cc78 100644 --- a/dGame/dComponents/CharacterComponent.cpp +++ b/dGame/dComponents/CharacterComponent.cpp @@ -606,6 +606,10 @@ void CharacterComponent::TrackArmorDelta(int32_t armor) { } } +void CharacterComponent::TrackItemsAdded(eInventoryType inventoryType, uint32_t count) { + if (inventoryType == eInventoryType::BRICKS && count > 0) UpdatePlayerStatistic(BricksCollected, count); +} + void CharacterComponent::TrackQuickBuildComplete() { UpdatePlayerStatistic(QuickBuildsCompleted); @@ -670,14 +674,13 @@ void CharacterComponent::SendPlayerStatistic(StatisticID updateID, uint64_t upda void CharacterComponent::HandleZoneStatisticsUpdate(LWOMAPID zoneID, const std::u16string& name, int32_t value) { auto zoneStatistics = &GetZoneStatisticsForMap(zoneID); + // The client's per-zone counts; the passport totals are the server's (UpdatePlayerStatistic), so they aren't + // counted twice if (name == u"BricksCollected") { - m_BricksCollected += value; zoneStatistics->m_BricksCollected += value; } else if (name == u"CoinsCollected") { - m_CurrencyCollected += value; zoneStatistics->m_CoinsCollected += value; } else if (name == u"EnemiesSmashed") { - m_EnemiesSmashed += value; zoneStatistics->m_EnemiesSmashed += value; } } diff --git a/dGame/dComponents/CharacterComponent.h b/dGame/dComponents/CharacterComponent.h index 601d1d104..fa354ded7 100644 --- a/dGame/dComponents/CharacterComponent.h +++ b/dGame/dComponents/CharacterComponent.h @@ -13,6 +13,7 @@ #include #include "Loot.h" #include "StatisticID.h" +#include "eInventoryType.h" #include #include @@ -217,6 +218,12 @@ public: */ void TrackArmorDelta(int32_t armor); + /** + * Counts items added to the inventory the client was told about (AddItemToInventoryClientSync): live followed + * every add to the bricks inventory with BricksCollected (the count), whatever the source. + */ + void TrackItemsAdded(eInventoryType inventoryType, uint32_t count); + /** * Handles completing a rebuild by updating the statistics */ diff --git a/dGame/dComponents/DestroyableComponent.cpp b/dGame/dComponents/DestroyableComponent.cpp index a0233e597..d4dc0709d 100644 --- a/dGame/dComponents/DestroyableComponent.cpp +++ b/dGame/dComponents/DestroyableComponent.cpp @@ -743,6 +743,16 @@ void DestroyableComponent::Smash(const LWOOBJID source, const eKillType killType die.lootOwnerID = source; die.Send(UNASSIGNED_SYSTEM_ADDRESS); + // Live told the killer right after Die, before the loot: EnemiesSmashed for an NPC, SmashablesSmashed for another + // smashable. Racing counts its own (RacingSmashablesSmashed). + if (owner && owner != m_Parent) { + auto* killerCharacter = owner->GetComponent(); + if (killerCharacter && !killerCharacter->GetIsRacing()) { + if (m_IsNPC) killerCharacter->UpdatePlayerStatistic(EnemiesSmashed); + else if (m_IsSmashable) killerCharacter->UpdatePlayerStatistic(SmashablesSmashed); + } + } + //NANI?! if (!isPlayer) { if (owner != nullptr) { diff --git a/dGame/dComponents/DestroyableComponent.h b/dGame/dComponents/DestroyableComponent.h index 9f95ef9d1..ff54f6fce 100644 --- a/dGame/dComponents/DestroyableComponent.h +++ b/dGame/dComponents/DestroyableComponent.h @@ -225,6 +225,13 @@ public: */ bool GetIsSmashable() const { return m_IsSmashable; } + /** + * Whether this is an NPC (the DestructibleComponent's isnpc): live counted smashing one as EnemiesSmashed, other + * smashables as SmashablesSmashed + */ + void SetIsNPC(bool value) { m_IsNPC = value; } + bool GetIsNPC() const { return m_IsNPC; } + /** * Returns the current is-dead value, this is mostly unused * @return the current is-dead value, this is mostly unused @@ -574,6 +581,8 @@ private: */ bool m_IsSmashable; + bool m_IsNPC = false; + /** * Whether this entity is dead. Unused, here for serialization */ diff --git a/dGame/dComponents/InventoryComponent.cpp b/dGame/dComponents/InventoryComponent.cpp index 67725479c..87b57727d 100644 --- a/dGame/dComponents/InventoryComponent.cpp +++ b/dGame/dComponents/InventoryComponent.cpp @@ -345,6 +345,7 @@ ReceivedItem InventoryComponent::ReceiveItem(const LWOOBJID id, const LOT lot, c addItem.newObjID = id; addItem.showFlyingLoot = options.showFlyingLoot && !options.equip; addItem.SendToClient(m_Parent->GetSystemAddress()); + if (auto* characterComponent = m_Parent->GetComponent()) characterComponent->TrackItemsAdded(inventoryType, count); EconomyLedger::RecordItems(m_Parent, lot, count, static_cast(lootSourceType)); Contraband::OnItemAdded(m_Parent, lot, count, lootSourceType, options.sourceInventory); if (options.equip) { diff --git a/dGame/dInventory/Item.cpp b/dGame/dInventory/Item.cpp index 6e2970f89..45c493a10 100644 --- a/dGame/dInventory/Item.cpp +++ b/dGame/dInventory/Item.cpp @@ -119,6 +119,7 @@ Item::Item( addItem.newObjID = id; addItem.showFlyingLoot = showFlyingLoot; addItem.SendToClient(entity->GetSystemAddress()); + if (auto* characterComponent = entity->GetComponent()) characterComponent->TrackItemsAdded(inventory->GetType(), this->count); if (isModMoveAndEquip) { Equip(); @@ -206,6 +207,7 @@ void Item::SetCount(const uint32_t value, const bool silent, const bool disassem addItem.newObjID = id; addItem.showFlyingLoot = showFlyingLoot; addItem.SendToClient(entity->GetSystemAddress()); + if (auto* characterComponent = entity->GetComponent()) characterComponent->TrackItemsAdded(inventory->GetType(), delta); } else { GameMessages::RemoveItemFromInventory removeItem; removeItem.target = entity->GetObjectID(); diff --git a/tests/dGameTests/PlayerStatisticTests.cpp b/tests/dGameTests/PlayerStatisticTests.cpp index 5aacca5fb..45e390a78 100644 --- a/tests/dGameTests/PlayerStatisticTests.cpp +++ b/tests/dGameTests/PlayerStatisticTests.cpp @@ -5,6 +5,8 @@ #include "CharacterComponent.h" #include "Entity.h" #include "PlayerMessages.h" +#include "DestroyableComponent.h" +#include "CDClientDatabase.h" #include "dGameMessagesTests/GameMessageTestUtils.h" // Live sent UpdatePlayerStatistic (1481) server -> client for the statistics the server counted (docs/CaptureUnknowns.md). @@ -109,3 +111,66 @@ TEST_F(PlayerStatisticTest, MetersTraveledGoOutInBatches) { ASSERT_EQ(statistics.size(), 1); EXPECT_EQ(statistics[0].updateValue, 0); } + +TEST_F(PlayerStatisticTest, BricksAddedAreCollected) { + auto statistics = Statistics(Capture([&] { characterComponent->TrackItemsAdded(eInventoryType::BRICKS, 3); })); + ASSERT_EQ(statistics.size(), 1); + EXPECT_EQ(statistics[0].updateID, static_cast(BricksCollected)); + EXPECT_EQ(statistics[0].updateValue, 3); + EXPECT_TRUE(Statistics(Capture([&] { characterComponent->TrackItemsAdded(eInventoryType::ITEMS, 1); })).empty()); +} + +TEST_F(PlayerStatisticTest, ZoneStatisticsFromTheClientLeaveThePassportTotalsAlone) { + // The client reports these per zone; the totals come from what the server sent (not counted twice) + characterComponent->HandleZoneStatisticsUpdate(1100, u"CoinsCollected", 10); + characterComponent->HandleZoneStatisticsUpdate(1100, u"BricksCollected", 2); + characterComponent->HandleZoneStatisticsUpdate(1100, u"EnemiesSmashed", 1); + EXPECT_EQ(characterComponent->StatisticsToString().substr(0, 10), "0;0;0;0;0;"); +} + +// Live: Die, then (to the killer) EnemiesSmashed for an NPC or SmashablesSmashed for another smashable, then the loot +TEST_F(PlayerStatisticTest, SmashingCountsEnemiesAndSmashables) { + // Smash finds the killer through the entity manager, which looks the new entity up in the CDClient + CDClientDatabase::Connect(":memory:"); + CDClientDatabase::ExecuteDML("CREATE TABLE ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER);"); + ASSERT_NE(Game::entityManager->CreateEntity(info, nullptr, nullptr, true, 0x3FFFFFFFFFFELL), nullptr); // the zone control object loot asks + auto* killer = Game::entityManager->CreateEntity(info, nullptr, nullptr, false, PLAYER + 1); + ASSERT_NE(killer, nullptr); + auto* killerCharacter = killer->AddComponent(-1, character.get(), ClientAddress()); + killerCharacter->InitializeStatisticsFromString(""); + + LWOOBJID victimID = 0x3FFF000000000001LL; + const auto smash = [&](bool npc, bool smashable) { + auto* victim = Game::entityManager->CreateEntity(info, nullptr, nullptr, false, victimID++); + auto* destroyable = victim->AddComponent(-1); + destroyable->SetMaxHealth(10.0f); + destroyable->SetHealth(10); + destroyable->SetIsNPC(npc); + destroyable->SetIsSmashable(smashable); + return Capture([&] { destroyable->Smash(killer->GetObjectID()); }); + }; + + for (const auto& [npc, smashable, expected] : { std::tuple{ true, true, EnemiesSmashed }, std::tuple{ false, true, SmashablesSmashed } }) { + const auto packets = smash(npc, smashable); + size_t die = packets.size(), statistic = packets.size(); + for (size_t i = 0; i < packets.size(); i++) { + RakNet::BitStream bitStream(const_cast(packets[i].bytes.data()), packets[i].bytes.size(), false); + LWOOBJID target{}; + MessageType::Game msgId{}; + if (!GameMessages::NetGameMsg::ReadPacketHeader(bitStream, target, msgId)) continue; + if (msgId == MessageType::Game::DIE && die == packets.size()) die = i; + if (msgId == MessageType::Game::UPDATE_PLAYER_STATISTIC) statistic = i; + } + ASSERT_LT(die, packets.size()); + ASSERT_LT(statistic, packets.size()); + EXPECT_LT(die, statistic); + const auto statistics = Statistics({ packets[statistic] }); + ASSERT_EQ(statistics.size(), 1); + EXPECT_EQ(statistics[0].target, killer->GetObjectID()); + EXPECT_EQ(statistics[0].updateID, static_cast(expected)); + } + + // Neither an NPC nor smashable (e.g. a collectible spawner): nothing + EXPECT_TRUE(Statistics(smash(false, false)).empty()); + killer->SetCharacter(nullptr); +}