diff --git a/dGame/dComponents/CharacterComponent.cpp b/dGame/dComponents/CharacterComponent.cpp index a47c0e6c5..80ca4a0ab 100644 --- a/dGame/dComponents/CharacterComponent.cpp +++ b/dGame/dComponents/CharacterComponent.cpp @@ -21,6 +21,14 @@ #include "EconomyLedger.h" #include "Database.h" #include "CDRewardCodesTable.h" +#include "CDObjectsTable.h" +#include "CDObjectSkillsTable.h" +#include "CDSkillBehaviorTable.h" +#include "CDBehaviorParameterTable.h" +#include "Behavior.h" + +#include +#include #include "Mail.h" #include "ZoneInstanceManager.h" #include "ClientPackets.h" @@ -527,34 +535,51 @@ void CharacterComponent::TrackMissionCompletion(bool isAchievement) { } void CharacterComponent::TrackLOTCollection(LOT lot) { - switch (lot) { - // Handle all the imagination powerup lots - case 935: // 1 point - case 4035: // 2 points - case 11910: // 3 points - case 11911: // 5 points - case 11918: // 10 points - UpdatePlayerStatistic(ImaginationPowerUpsCollected); - break; - // Handle all the armor powerup lots - case 6431: // 1 point - case 11912: // 2 points - case 11913: // 3 points - case 11914: // 5 points - case 11919: // 10 points - UpdatePlayerStatistic(ArmorPowerUpsCollected); - break; - // Handle all the life powerup lots - case 177: // 1 point - case 11915: // 2 points - case 11916: // 3 points - case 11917: // 5 points - case 11920: // 10 points - UpdatePlayerStatistic(LifePowerUpsCollected); - break; - default: - break; + const auto statistic = GetPowerUpStatistic(lot); + if (statistic) UpdatePlayerStatistic(statistic.value()); +} + +std::optional CharacterComponent::GetPowerUpStatistic(const LOT lot) { + static std::unordered_map> cache; + const auto cached = cache.find(lot); + if (cached != cache.end()) return cached->second; + + std::optional statistic; + + // A power-up counts towards the statistic of what its pickup skill restores + // (e.g. Imagination Powerup 2 points: skill 129 -> Imagination behavior) + const auto& object = CDClientManager::GetTable()->GetByID(lot); + if (object.id != 0 && object.type == "Powerup") { + auto* const skillTable = CDClientManager::GetTable(); + auto* const parameterTable = CDClientManager::GetTable(); + + std::vector toVisit; + for (const auto& skill : CDClientManager::GetTable()->Get(lot)) { + toVisit.push_back(skillTable->GetSkillByID(skill.skillID).behaviorID); + } + + std::set visited; + while (!toVisit.empty() && !statistic) { + const auto behaviorID = toVisit.back(); + toVisit.pop_back(); + if (behaviorID == 0 || !visited.insert(behaviorID).second) continue; + + switch (Behavior::GetBehaviorTemplate(behaviorID)) { + case BehaviorTemplate::IMAGINATION: statistic = ImaginationPowerUpsCollected; break; + case BehaviorTemplate::REPAIR_ARMOR: statistic = ArmorPowerUpsCollected; break; + case BehaviorTemplate::HEAL: statistic = LifePowerUpsCollected; break; + default: + // Parameters that are behaviors (e.g. "action", "behavior 1") lead further down the tree + for (const auto& [name, value] : parameterTable->GetParametersByBehaviorID(behaviorID)) { + if (value > 0.0f && (name == "action" || name.starts_with("behavior "))) toVisit.push_back(static_cast(value)); + } + break; + } + } } + + cache.emplace(lot, statistic); + return statistic; } void CharacterComponent::TrackHealthDelta(int32_t health) { diff --git a/dGame/dComponents/CharacterComponent.h b/dGame/dComponents/CharacterComponent.h index 1c67bf6ff..4633e8cb2 100644 --- a/dGame/dComponents/CharacterComponent.h +++ b/dGame/dComponents/CharacterComponent.h @@ -13,6 +13,7 @@ #include #include "Loot.h" #include "StatisticID.h" +#include enum class eGameActivity : uint32_t; @@ -190,6 +191,13 @@ public: */ void TrackLOTCollection(LOT lot); + /** + * The statistic a power-up counts towards, from what its pickup skill restores (life, armor or imagination) + * @param lot the LOT that was picked up + * @return the statistic, or nothing when the LOT is not such a power-up + */ + static std::optional GetPowerUpStatistic(LOT lot); + /** * Handles a change in health and updates the statistics * @param health the health delta diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index e33d85c92..b447ddd7a 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -10,6 +10,7 @@ set(DGAMETEST_SOURCES "KnockbackTests.cpp" "CollisionFilterTests.cpp" "ItemSetPassiveAbilityTests.cpp" + "PowerUpStatisticTests.cpp" ) add_subdirectory(dComponentsTests) diff --git a/tests/dGameTests/PowerUpStatisticTests.cpp b/tests/dGameTests/PowerUpStatisticTests.cpp new file mode 100644 index 000000000..7970680b1 --- /dev/null +++ b/tests/dGameTests/PowerUpStatisticTests.cpp @@ -0,0 +1,102 @@ +#include + +#include +#include +#include + +#include "GameDependencies.h" +#include "CharacterComponent.h" +#include "CDClientDatabase.h" +#include "CDClientManager.h" +#include "CDObjectSkillsTable.h" +#include "CDSkillBehaviorTable.h" +#include "CDBehaviorTemplateTable.h" +#include "CDBehaviorParameterTable.h" + +// Which statistic a picked up power-up counts towards comes from what its pickup skill restores. + +namespace { + void LoadTables() { + CDClientManager::GetTable()->LoadValuesFromDatabase(); + CDClientManager::GetTable()->LoadValuesFromDatabase(); + CDClientManager::GetTable()->LoadValuesFromDatabase(); + CDClientManager::GetTable()->LoadValuesFromDatabase(); + } +} + +class PowerUpStatisticFixtureTest : public GameDependenciesTest { +protected: + void SetUp() override { + SetUpDependencies(); + CDClientDatabase::Connect(":memory:"); + for (const auto* sql : { + "CREATE TABLE Objects (id INTEGER, name TEXT, placeable INTEGER, type TEXT, description TEXT, localize INTEGER, npcTemplateID INTEGER, displayName TEXT, interactionDistance REAL, nametag INTEGER, _internalNotes TEXT, locStatus INTEGER, gate_version TEXT, HQ_valid INTEGER);", + "CREATE TABLE ObjectSkills (objectTemplate INTEGER, skillID INTEGER, castOnType INTEGER, AICombatWeight INTEGER);", + "CREATE TABLE SkillBehavior (skillID INTEGER, locStatus INTEGER, behaviorID INTEGER, imaginationcost INTEGER, cooldowngroup INTEGER, cooldown REAL, inNpcEditor INTEGER, skillIcon INTEGER, oomSkillID TEXT, oomBehaviorEffectID INTEGER, castTypeDesc INTEGER, imBonusUI INTEGER, lifeBonusUI INTEGER, armorBonusUI INTEGER, damageUI INTEGER, hideIcon INTEGER, localize INTEGER, gate_version TEXT, cancelType INTEGER);", + "CREATE TABLE BehaviorTemplate (behaviorID INTEGER, templateID INTEGER, effectID INTEGER, effectHandle TEXT);", + "CREATE TABLE BehaviorParameter (behaviorID INTEGER, parameterID TEXT, value REAL);", + // 90501: a power-up whose skill is an And of a buff removal and armor repair; 90502: same skill, not a power-up; + // 90503: a power-up whose skill restores nothing + "INSERT INTO Objects (id, name, type) VALUES (90501, 'Armor Powerup', 'Powerup'), (90502, 'Not a powerup', 'Environmental'), (90503, 'Speed Powerup', 'Powerup');", + "INSERT INTO ObjectSkills VALUES (90501, 90601, 2, 0), (90502, 90601, 2, 0), (90503, 90602, 2, 0);", + "INSERT INTO SkillBehavior (skillID, behaviorID, imaginationcost, cooldowngroup, cooldown, cancelType) VALUES (90601, 900101, 0, 0, 0, 0), (90602, 900104, 0, 0, 0, 0);", + // 900101 And -> 900102 RemoveBuff, 900103 RepairArmor; 900104 Speed + "INSERT INTO BehaviorTemplate VALUES (900101, 18, 0, ''), (900102, 55, 0, ''), (900103, 22, 0, ''), (900104, 37, 0, '');", + "INSERT INTO BehaviorParameter VALUES (900101, 'behavior 1', 900102), (900101, 'behavior 2', 900103), (900103, 'armor', 1), (900104, 'run_speed', 900103);", + }) { + CDClientDatabase::ExecuteDML(sql); + } + LoadTables(); + } + + void TearDown() override { + TearDownDependencies(); + } +}; + +TEST_F(PowerUpStatisticFixtureTest, StatisticFollowsThePickupSkill) { + EXPECT_EQ(CharacterComponent::GetPowerUpStatistic(90501), std::optional(ArmorPowerUpsCollected)); + EXPECT_EQ(CharacterComponent::GetPowerUpStatistic(90502), std::nullopt); + // Only behavior parameters ("action", "behavior N") are followed, not other values that happen to be behavior IDs + EXPECT_EQ(CharacterComponent::GetPowerUpStatistic(90503), std::nullopt); + EXPECT_EQ(CharacterComponent::GetPowerUpStatistic(90504), std::nullopt); +} + +// Against the live CDClient, when there is one (DLU_CDSERVER_SQLITE, or build/resServer/CDServer.sqlite). +class PowerUpStatisticLiveDataTest : public GameDependenciesTest { +protected: + void SetUp() override { + std::string path; + if (const char* env = std::getenv("DLU_CDSERVER_SQLITE")) path = env; + else path = std::string(PROJECT_SOURCE_DIR) + "/build/resServer/CDServer.sqlite"; + if (!std::filesystem::exists(path)) GTEST_SKIP() << "No CDServer.sqlite at " << path; + + SetUpDependencies(); + CDClientDatabase::Connect(path); + CDClientDatabase::ExecuteDML("PRAGMA query_only = ON;"); // never write to the live CDClient + LoadTables(); + } + + void TearDown() override { + if (!IsSkipped()) TearDownDependencies(); + } +}; + +TEST_F(PowerUpStatisticLiveDataTest, MatchesTheOldHardcodedPowerUps) { + const std::map expected = { + { 935, ImaginationPowerUpsCollected }, { 4035, ImaginationPowerUpsCollected }, { 11910, ImaginationPowerUpsCollected }, + { 11911, ImaginationPowerUpsCollected }, { 11918, ImaginationPowerUpsCollected }, + { 6431, ArmorPowerUpsCollected }, { 11912, ArmorPowerUpsCollected }, { 11913, ArmorPowerUpsCollected }, + { 11914, ArmorPowerUpsCollected }, { 11919, ArmorPowerUpsCollected }, + { 177, LifePowerUpsCollected }, { 11915, LifePowerUpsCollected }, { 11916, LifePowerUpsCollected }, + { 11917, LifePowerUpsCollected }, { 11920, LifePowerUpsCollected }, + }; + for (const auto& [lot, statistic] : expected) { + EXPECT_EQ(CharacterComponent::GetPowerUpStatistic(lot), std::optional(statistic)) << "LOT " << lot; + } + + // Not power-ups, or power-ups that restore nothing + for (const LOT lot : { 6253, 7230, 8200, 13763 }) { + EXPECT_EQ(CharacterComponent::GetPowerUpStatistic(lot), std::nullopt) << "LOT " << lot; + } +}