From 616ca46e95624f39ea559d63a1b78e70ef82c107 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 12:51:41 -0500 Subject: [PATCH] fix(loot): activity reward coins use the reward's ChallengeRating level ActivityRewards coins were always read from CurrencyTable npcminlevel 1. Live used the reward row's ChallengeRating as the npcminlevel, and level 1 when the currency index has no row for that level: - FV foot races (ChallengeRating 4, index 1) gave 36 and 48 coins, which only level 4 (30-50) fits; level 1 is 3-5. - Frakjaw's chest (activity 58, ChallengeRating 6) gave 250 each to a team of 2: its currency indices 123-126 only have a level 6 row (500), so DLU gave nothing. - Quickbuilds, wishing wells and chests have ChallengeRating 1; survival and the shooting galleries have ratings with no row and keep level 1. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/ActivityComponent.cpp | 12 +----- dGame/dUtilities/Loot.cpp | 40 +++++++++---------- dGame/dUtilities/Loot.h | 13 ++++++ tests/dGameTests/CMakeLists.txt | 1 + tests/dGameTests/LootActivityCoinsTests.cpp | 44 +++++++++++++++++++++ 5 files changed, 79 insertions(+), 31 deletions(-) create mode 100644 tests/dGameTests/LootActivityCoinsTests.cpp diff --git a/dGame/dComponents/ActivityComponent.cpp b/dGame/dComponents/ActivityComponent.cpp index a45ee4b8b..e03955373 100644 --- a/dGame/dComponents/ActivityComponent.cpp +++ b/dGame/dComponents/ActivityComponent.cpp @@ -27,7 +27,6 @@ #include "MessageType/Chat.h" #include "ObjectIDManager.h" -#include "CDCurrencyTableTable.h" #include "CDActivityRewardsTable.h" #include "CDActivitiesTable.h" #include "LeaderboardManager.h" @@ -532,16 +531,7 @@ void ActivityInstance::RewardParticipant(Entity* participant) { std::vector activityRewards = activityRewardsTable->Query([this](CDActivityRewards entry) { return (entry.objectTemplate == m_ActivityInfo.ActivityID); }); if (!activityRewards.empty()) { - uint32_t minCoins = 0; - uint32_t maxCoins = 0; - - auto* currencyTableTable = CDClientManager::GetTable(); - std::vector currencyTable = currencyTableTable->Query([=](CDCurrencyTable entry) { return (entry.currencyIndex == activityRewards[0].CurrencyIndex && entry.npcminlevel == 1); }); - - if (!currencyTable.empty()) { - minCoins = currencyTable[0].minvalue; - maxCoins = currencyTable[0].maxvalue; - } + const auto [minCoins, maxCoins] = Loot::GetActivityCoinRange(activityRewards[0]); Loot::DropLoot(participant, m_Parent->GetObjectID(), activityRewards[0].LootMatrixIndex, minCoins, maxCoins); } diff --git a/dGame/dUtilities/Loot.cpp b/dGame/dUtilities/Loot.cpp index 6b647325d..a03dbcae7 100644 --- a/dGame/dUtilities/Loot.cpp +++ b/dGame/dUtilities/Loot.cpp @@ -547,6 +547,24 @@ void Loot::GiveLoot(Entity* player, const Loot::Return& result, eLootSourceType } } +std::pair Loot::GetActivityCoinRange(const std::vector& currencyRows, const uint32_t challengeRating) { + const auto atLevel = [¤cyRows](const uint32_t level) { + return std::ranges::find_if(currencyRows, [level](const CDCurrencyTable& row) { return row.npcminlevel == level; }); + }; + + auto row = atLevel(challengeRating); + if (row == currencyRows.end()) row = atLevel(1); + if (row == currencyRows.end()) return { 0, 0 }; + return { row->minvalue, row->maxvalue }; +} + +std::pair Loot::GetActivityCoinRange(const CDActivityRewards& reward) { + auto* const currencyTableTable = CDClientManager::GetTable(); + const auto currencyIndex = reward.CurrencyIndex; + const auto rows = currencyTableTable->Query([currencyIndex](const CDCurrencyTable& entry) { return entry.currencyIndex == currencyIndex; }); + return GetActivityCoinRange(rows, reward.ChallengeRating); +} + void Loot::GiveActivityLoot(Entity* player, const LWOOBJID source, uint32_t activityID, int32_t rating) { CDActivityRewardsTable* activityRewardsTable = CDClientManager::GetTable(); std::vector activityRewards = activityRewardsTable->Query([activityID](CDActivityRewards entry) { return (entry.objectTemplate == activityID); }); @@ -561,16 +579,7 @@ void Loot::GiveActivityLoot(Entity* player, const LWOOBJID source, uint32_t acti if (!selectedReward) return; - uint32_t minCoins = 0; - uint32_t maxCoins = 0; - - CDCurrencyTableTable* currencyTableTable = CDClientManager::GetTable(); - std::vector currencyTable = currencyTableTable->Query([selectedReward](CDCurrencyTable entry) { return (entry.currencyIndex == selectedReward->CurrencyIndex && entry.npcminlevel == 1); }); - - if (currencyTable.size() > 0) { - minCoins = currencyTable[0].minvalue; - maxCoins = currencyTable[0].maxvalue; - } + const auto [minCoins, maxCoins] = GetActivityCoinRange(*selectedReward); GiveLoot(player, selectedReward->LootMatrixIndex, eLootSourceType::ACTIVITY); @@ -612,16 +621,7 @@ void Loot::DropActivityLoot(Entity* player, const LWOOBJID source, uint32_t acti return; } - uint32_t minCoins = 0; - uint32_t maxCoins = 0; - - CDCurrencyTableTable* currencyTableTable = CDClientManager::GetTable(); - std::vector currencyTable = currencyTableTable->Query([selectedReward](CDCurrencyTable entry) { return (entry.currencyIndex == selectedReward->CurrencyIndex && entry.npcminlevel == 1); }); - - if (currencyTable.size() > 0) { - minCoins = currencyTable[0].minvalue; - maxCoins = currencyTable[0].maxvalue; - } + const auto [minCoins, maxCoins] = GetActivityCoinRange(*selectedReward); DropLoot(player, source, selectedReward->LootMatrixIndex, minCoins, maxCoins); } diff --git a/dGame/dUtilities/Loot.h b/dGame/dUtilities/Loot.h index bc638eb4c..b05a7cf4c 100644 --- a/dGame/dUtilities/Loot.h +++ b/dGame/dUtilities/Loot.h @@ -2,9 +2,14 @@ #include "dCommonVars.h" #include "eLootSourceType.h" +#include #include +#include +#include class Entity; +struct CDActivityRewards; +struct CDCurrencyTable; namespace GameMessages { struct DropClientLoot; @@ -28,6 +33,14 @@ namespace Loot { void DropItem(Entity& player, GameMessages::DropClientLoot& lootMsg, bool useTeam = false, bool forceFfa = false); void DropActivityLoot(Entity* player, const LWOOBJID source, uint32_t activityID, int32_t rating = 0); + /** + * The coin range {min, max} of an ActivityRewards row, as live rolled it: the reward's ChallengeRating is the + * CurrencyTable npcminlevel, and level 1 is used when the currency index has no row for that level. + * currencyRows are the CurrencyTable rows of the reward's CurrencyIndex. {0, 0} when neither level has a row. + */ + std::pair GetActivityCoinRange(const std::vector& currencyRows, uint32_t challengeRating); + std::pair GetActivityCoinRange(const CDActivityRewards& reward); + // How far from where it spawns a dropped item lands, as live dropped them (median 10.0 units, uniform direction). constexpr float ITEM_DROP_DISTANCE = 10.0f; diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index 207a46d6a..2c8ac6fbe 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -22,6 +22,7 @@ set(DGAMETEST_SOURCES "HolidayEventsTests.cpp" "SetCurrencySourceTests.cpp" "LootDropPositionTests.cpp" + "LootActivityCoinsTests.cpp" "SpiderQueenTests.cpp" "TacArcTests.cpp" ) diff --git a/tests/dGameTests/LootActivityCoinsTests.cpp b/tests/dGameTests/LootActivityCoinsTests.cpp new file mode 100644 index 000000000..bbede18f4 --- /dev/null +++ b/tests/dGameTests/LootActivityCoinsTests.cpp @@ -0,0 +1,44 @@ +#include "Loot.h" +#include "CDCurrencyTableTable.h" + +#include + +// ActivityRewards coins: the reward's ChallengeRating is the CurrencyTable npcminlevel, level 1 when that level has +// no row, as live rolled them. + +namespace { + CDCurrencyTable Row(const uint32_t index, const uint32_t level, const uint32_t min, const uint32_t max) { + return CDCurrencyTable{ .currencyIndex = index, .npcminlevel = level, .minvalue = min, .maxvalue = max, .id = 0 }; + } + + // CurrencyTable index 1 as the client data has it + const std::vector index1 = { Row(1, 1, 3, 5), Row(1, 2, 5, 9), Row(1, 3, 9, 15), Row(1, 4, 30, 50), Row(1, 5, 60, 100) }; +} + +// FV foot races (ChallengeRating 4): live gave 36 and 48 coins, which only level 4 (30-50) fits +TEST(LootActivityCoinsTests, ChallengeRatingPicksTheLevel) { + EXPECT_EQ(Loot::GetActivityCoinRange(index1, 4), std::make_pair(30u, 50u)); +} + +// Quickbuilds, wishing wells and chests (ChallengeRating 1) +TEST(LootActivityCoinsTests, ChallengeRatingOneIsLevelOne) { + EXPECT_EQ(Loot::GetActivityCoinRange(index1, 1), std::make_pair(3u, 5u)); +} + +// Survival and shooting galleries: their ChallengeRating has no row, so level 1 is used +TEST(LootActivityCoinsTests, MissingLevelFallsBackToLevelOne) { + EXPECT_EQ(Loot::GetActivityCoinRange(index1, 7), std::make_pair(3u, 5u)); + EXPECT_EQ(Loot::GetActivityCoinRange(index1, 0), std::make_pair(3u, 5u)); +} + +// Frakjaw's chest (activity 58, ChallengeRating 6): index 125 has only a level 6 row of 500, which live split +// between a team of 2 +TEST(LootActivityCoinsTests, IndexWithOnlyTheChallengeLevel) { + const std::vector index125 = { Row(125, 6, 500, 500) }; + EXPECT_EQ(Loot::GetActivityCoinRange(index125, 6), std::make_pair(500u, 500u)); + EXPECT_EQ(Loot::GetActivityCoinRange(index125, 2), std::make_pair(0u, 0u)); +} + +TEST(LootActivityCoinsTests, NoRowsNoCoins) { + EXPECT_EQ(Loot::GetActivityCoinRange(std::vector{}, 1), std::make_pair(0u, 0u)); +}