refactor: item set passive abilities come from the CDClient and item scripts

The server hardcoded each item set's passives by set ID. They now come from data:

- On-kill bonuses (Paradox imagination, Sentinel armor repair, Bat Lord heal) are the
  set's DarkInspiration skills in ItemSetSkills. The client turns those into a status
  effect that casts the behavior's action when the wearer kills something of a faction
  in faction_list; the server now runs the same behavior on the smashed enemy, so the
  amounts, faction check and extra effects (e.g. rank 3 Sorcerer team imagination) follow
  the data. A behavior repeated in a higher tier does not stack.
- Low imagination / low armor skills are what the live equipmenttriggers item scripts
  do. Items link to those scripts through their ScriptComponent; the script vars (skill,
  items required, set, cooldown) only exist in the scripts, so they are mirrored in a
  small table keyed by script name. The Sentinel scripts have no cooldown (was 11s).
- Knockback immunity while quickbuilding comes from the Immunity behavior's
  immune_quickbuild_interrupts (the 5 item Assembly rank 2/3 set skill) instead of a
  set ID list; ImmunityBehavior now pops its immunities when an equip skill is uncast.

Removes eItemSetPassiveAbilityID and InventoryComponent::HasAnyPassive (unused now).

Refs #691

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 04:18:51 -05:00
parent 347a0a7286
commit e44eaf1862
13 changed files with 494 additions and 353 deletions

View File

@@ -1,58 +0,0 @@
#pragma once
#ifndef __EITEMSETPASSIVEABILITYID__H__
#define __EITEMSETPASSIVEABILITYID__H__
enum class eItemSetPassiveAbilityID {
EngineerRank1 = 2,
EngineerRank2 = 3,
EngineerRank3 = 4,
KnightRank1 = 7,
KnightRank2 = 8,
KnightRank3 = 9,
SpaceRangerRank1 = 10,
SpaceRangerRank2 = 11,
SpaceRangerRank3 = 12,
SamuraiRank1 = 13,
SamuraiRank2 = 14,
SamuraiRank3 = 15,
SorcererRank1 = 16,
SorcererRank2 = 17,
SorcererRank3 = 18,
SpaceMarauderRank1 = 19,
SpaceMarauderRank2 = 20,
SpaceMarauderRank3 = 21,
ShinobiRank1 = 22,
ShinobiRank2 = 23,
ShinobiRank3 = 24,
InventorRank1 = 25,
InventorRank2 = 26,
InventorRank3 = 27,
SummonerRank1 = 28,
SummonerRank2 = 29,
SummonerRank3 = 30,
AdventurerRank1 = 31,
AdventurerRank2 = 32,
AdventurerRank3 = 33,
DaredevilRank1 = 34,
DaredevilRank2 = 35,
DaredevilRank3 = 36,
BuccaneerRank1 = 37,
BuccaneerRank2 = 38,
BuccaneerRank3 = 39,
BoneSuit = 40,
ImaginationSpinjitzu = 41,
BatLord = 42,
MosaicJester = 43,
ExplorienBot = 44,
Unnamed1 = 45,
Unnamed2 = 46,
Unnamed3 = 47,
EarthSpinjitzu = 48,
Unnamed4 = 49,
FireSpinjitzu = 50,
IceSpinjitzu = 51,
LightningSpinjitzu = 52
};
#endif //!__EITEMSETPASSIVEABILITYID__H__

View File

@@ -96,6 +96,10 @@ void ImmunityBehavior::Timer(BehaviorContext* context, BehaviorBranchContext bra
}
void ImmunityBehavior::UnCast(BehaviorContext* context, BehaviorBranchContext branch) {
Timer(context, branch, branch.target);
}
void ImmunityBehavior::Load() {
//Stun
this->m_ImmuneToStunAttack = GetBoolean("immune_stun_attack", false);

View File

@@ -17,6 +17,9 @@ public:
void Timer(BehaviorContext* context, BehaviorBranchContext branch, LWOOBJID second) override;
// Removes the immunities again when an equipped item's or item set's skill is uncast
void UnCast(BehaviorContext* context, BehaviorBranchContext branch) override;
void Load() override;
private:

View File

@@ -470,18 +470,10 @@ bool DestroyableComponent::IsCooldownImmune() const {
bool DestroyableComponent::IsKnockbackImmune() const {
auto* characterComponent = m_Parent->GetComponent<CharacterComponent>();
auto* inventoryComponent = m_Parent->GetComponent<InventoryComponent>();
if (characterComponent != nullptr && inventoryComponent != nullptr && characterComponent->GetCurrentActivity() == eGameActivity::QUICKBUILDING) {
const auto hasPassive = inventoryComponent->HasAnyPassive({
eItemSetPassiveAbilityID::EngineerRank2, eItemSetPassiveAbilityID::EngineerRank3,
eItemSetPassiveAbilityID::SummonerRank2, eItemSetPassiveAbilityID::SummonerRank3,
eItemSetPassiveAbilityID::InventorRank2, eItemSetPassiveAbilityID::InventorRank3,
}, 5);
if (hasPassive) {
return true;
}
// Quickbuild interrupt immunity comes from Immunity behaviors, e.g. the 5 item Assembly rank 2 and 3 set skill (584)
if (characterComponent != nullptr && characterComponent->GetCurrentActivity() == eGameActivity::QUICKBUILDING && GetImmuneToQuickbuildInterrupt()) {
return true;
}
return IsImmune() || m_IsShielded || m_AttacksToBlock > 0;

View File

@@ -1357,21 +1357,6 @@ void InventoryComponent::TriggerPassiveAbility(PassiveAbilityTrigger trigger, En
}
}
bool InventoryComponent::HasAnyPassive(const std::vector<eItemSetPassiveAbilityID>& passiveIDs, int32_t equipmentRequirement) const {
for (auto* set : m_Itemsets) {
if (set->GetEquippedCount() < equipmentRequirement) {
continue;
}
// Check if the set has any of the passive abilities
if (std::find(passiveIDs.begin(), passiveIDs.end(), static_cast<eItemSetPassiveAbilityID>(set->GetID())) != passiveIDs.end()) {
return true;
}
}
return false;
}
void InventoryComponent::DespawnPet() {
auto* current = PetComponent::GetActivePet(m_Parent->GetObjectID());

View File

@@ -17,7 +17,6 @@
#include "DatabasePet.h"
#include "Component.h"
#include "ItemSetPassiveAbility.h"
#include "eItemSetPassiveAbilityID.h"
#include "PossessorComponent.h"
#include "eInventoryType.h"
#include "eReplicaComponentType.h"
@@ -358,14 +357,6 @@ public:
*/
void TriggerPassiveAbility(PassiveAbilityTrigger trigger, Entity* target = nullptr);
/**
* Returns if the entity has any of the passed passive abilities equipped
* @param passiveIDs the IDs to check for
* @param equipmentRequirement the number of equipment required to be allowed to have the ability
* @return if the entity has any of the passed passive abilities equipped
*/
bool HasAnyPassive(const std::vector<eItemSetPassiveAbilityID>& passiveIDs, int32_t equipmentRequirement) const;
/**
* Despawns the currently active pet, if any
*/

View File

@@ -466,6 +466,19 @@ void SkillComponent::HandleUnmanaged(const uint32_t behaviorId, const LWOOBJID t
behavior->Handle(&context, bitStream, { target });
}
void SkillComponent::CalculateUnmanaged(const uint32_t behaviorId, const LWOOBJID caster, const LWOOBJID target) {
BehaviorContext context{ caster, true };
context.unmanaged = true;
context.caster = caster;
auto* behavior = Behavior::CreateBehavior(behaviorId);
RakNet::BitStream bitStream{};
behavior->Calculate(&context, bitStream, { target });
}
void SkillComponent::HandleUnCast(const uint32_t behaviorId, const LWOOBJID target) {
BehaviorContext context{ target };

View File

@@ -174,6 +174,15 @@ public:
*/
static void HandleUnmanaged(uint32_t behaviorId, LWOOBJID target, LWOOBJID source = LWOOBJID_EMPTY);
/**
* Calculates a behavior on the server only, without syncing it to clients, for effects the
* clients already run on their own (e.g. item set DarkInspiration status effects).
* @param behaviorId the root behavior ID of the skill
* @param caster the entity the behavior is cast by
* @param target the entity the behavior targets
*/
static void CalculateUnmanaged(uint32_t behaviorId, LWOOBJID caster, LWOOBJID target);
/**
* Computes a server-side skill uncast calculation without an associated entity.
* @param behaviorId the root behavior ID of the skill

View File

@@ -18,8 +18,6 @@ ItemSet::ItemSet(const uint32_t id, InventoryComponent* inventoryComponent) {
this->m_ID = id;
this->m_InventoryComponent = inventoryComponent;
this->m_PassiveAbilities = ItemSetPassiveAbility::FindAbilities(id, m_InventoryComponent->GetParent(), this);
auto query = CDClientDatabase::CreatePreppedStmt(
"SELECT skillSetWith2, skillSetWith3, skillSetWith4, skillSetWith5, skillSetWith6, itemIDs FROM ItemSets WHERE setID = ?;");
query.bind(1, static_cast<int>(id));
@@ -44,7 +42,7 @@ ItemSet::ItemSet(const uint32_t id, InventoryComponent* inventoryComponent) {
auto skillResult = skillQuery.execQuery();
if (skillResult.eof()) {
return;
continue;
}
while (!skillResult.eof()) {
@@ -96,6 +94,8 @@ ItemSet::ItemSet(const uint32_t id, InventoryComponent* inventoryComponent) {
if (validToken) m_Items.push_back(validToken.value());
}
m_PassiveAbilities = ItemSetPassiveAbility::FindAbilities(id, m_Items, { m_SkillsWith2, m_SkillsWith3, m_SkillsWith4, m_SkillsWith5, m_SkillsWith6 }, m_InventoryComponent->GetParent(), this);
m_Equipped = {};
for (const auto item : m_Items) {

View File

@@ -1,9 +1,68 @@
#include "ItemSetPassiveAbility.h"
#include <algorithm>
#include <cctype>
#include <string>
#include "DestroyableComponent.h"
#include "SkillComponent.h"
#include "ItemSet.h"
#include "eItemSetPassiveAbilityID.h"
#include "EntityManager.h"
#include "CDClientManager.h"
#include "CDComponentsRegistryTable.h"
#include "CDScriptComponentTable.h"
#include "CDSkillBehaviorTable.h"
#include "Behavior.h"
#include "eReplicaComponentType.h"
namespace {
struct ScriptStatTrigger {
std::string_view script;
ItemSetStatTrigger trigger;
};
constexpr auto LowImagination = PassiveAbilityTrigger::AssemblyImagination;
constexpr auto LowArmor = PassiveAbilityTrigger::SentinelArmor;
// The CDClient links set items to these scripts through their ScriptComponent, but what each
// script does only exists in the scripts themselves (res/scripts/equipmenttriggers/<name>.lua,
// which all run skillSetTriggerTemplate.lua with these vars), so they are mirrored here like dScripts.
// Paradox and Venture have scripts of the same kind (paradox1-3, venture*) but no item uses them.
constexpr ScriptStatTrigger StatTriggerScripts[] = {
// Low Imagination, IMAGINATION LESS 1
{ "assembly1.lua", { LowImagination, 394, 4, 2, 0.0f } },
{ "assembly2.lua", { LowImagination, 581, 4, 3, 0.0f } },
{ "assembly3.lua", { LowImagination, 582, 4, 4, 0.0f } },
{ "assemblyengineer1.lua", { LowImagination, 394, 4, 2, 11.0f } },
{ "assemblyengineer2.lua", { LowImagination, 581, 4, 3, 11.0f } },
{ "assemblyengineer3.lua", { LowImagination, 582, 4, 4, 11.0f } },
{ "assemblyinventor1.lua", { LowImagination, 394, 4, 25, 11.0f } },
{ "assemblyinventor2.lua", { LowImagination, 581, 4, 26, 11.0f } },
{ "assemblyinventor3.lua", { LowImagination, 582, 4, 27, 11.0f } },
{ "assemblysummoner1.lua", { LowImagination, 394, 4, 28, 11.0f } },
{ "assemblysummoner2.lua", { LowImagination, 581, 4, 29, 11.0f } },
{ "assemblysummoner3.lua", { LowImagination, 582, 4, 30, 11.0f } },
// Low Armor, ARMOR LESS 1
{ "sentinelknight1.lua", { LowArmor, 559, 4, 7, 0.0f } },
{ "sentinelknight2.lua", { LowArmor, 560, 4, 8, 0.0f } },
{ "sentinelknight3.lua", { LowArmor, 561, 4, 9, 0.0f } },
{ "sentinelspaceranger1.lua", { LowArmor, 1101, 4, 10, 0.0f } },
{ "sentinelspaceranger2.lua", { LowArmor, 1102, 4, 11, 0.0f } },
{ "sentinelspaceranger3.lua", { LowArmor, 1103, 4, 12, 0.0f } },
{ "sentinelsamurai1.lua", { LowArmor, 562, 4, 13, 0.0f } },
{ "sentinelsamurai2.lua", { LowArmor, 563, 4, 14, 0.0f } },
{ "sentinelsamurai3.lua", { LowArmor, 564, 4, 15, 0.0f } },
};
std::string ScriptFileName(std::string_view scriptName) {
const auto slash = scriptName.find_last_of("/\\");
if (slash != std::string_view::npos) scriptName.remove_prefix(slash + 1);
std::string fileName{ scriptName };
std::transform(fileName.begin(), fileName.end(), fileName.begin(), [](unsigned char c) { return std::tolower(c); });
return fileName;
}
}
ItemSetPassiveAbility::ItemSetPassiveAbility(PassiveAbilityTrigger trigger, Entity* parent, ItemSet* itemSet) {
m_Trigger = trigger;
@@ -13,9 +72,6 @@ ItemSetPassiveAbility::ItemSetPassiveAbility(PassiveAbilityTrigger trigger, Enti
m_Cooldown = 0.0f;
}
ItemSetPassiveAbility::~ItemSetPassiveAbility() {
}
void ItemSetPassiveAbility::Trigger(PassiveAbilityTrigger trigger, Entity* target) {
if (m_Trigger != trigger || m_Cooldown > 0.0f) {
return;
@@ -32,7 +88,7 @@ void ItemSetPassiveAbility::Update(float deltaTime) {
void ItemSetPassiveAbility::Activate(Entity* target) {
if (m_Trigger == PassiveAbilityTrigger::EnemySmashed) {
OnEnemySmshed(target);
OnEnemySmashed(target);
return;
}
@@ -46,156 +102,91 @@ void ItemSetPassiveAbility::Activate(Entity* target) {
Game::entityManager->SerializeEntity(m_Parent);
const auto id = static_cast<eItemSetPassiveAbilityID>(m_ItemSet->GetID());
const auto parentID = m_Parent->GetObjectID();
const auto equippedCount = m_ItemSet->GetEquippedCount();
if (m_ItemSet->GetEquippedCount() < m_StatTrigger.itemsRequired) return;
switch (id) {
// Assembly
case eItemSetPassiveAbilityID::InventorRank1:
case eItemSetPassiveAbilityID::SummonerRank1:
case eItemSetPassiveAbilityID::EngineerRank1: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(394, 4401, parentID);
break;
}
case eItemSetPassiveAbilityID::InventorRank2:
case eItemSetPassiveAbilityID::SummonerRank2:
case eItemSetPassiveAbilityID::EngineerRank2: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(581, 9433, parentID);
break;
}
case eItemSetPassiveAbilityID::InventorRank3:
case eItemSetPassiveAbilityID::SummonerRank3:
case eItemSetPassiveAbilityID::EngineerRank3: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(582, 9435, parentID);
break;
}
// Sentinel
case eItemSetPassiveAbilityID::KnightRank1: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(559, 8884, parentID);
break;
}
case eItemSetPassiveAbilityID::KnightRank2: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(560, 8885, parentID);
break;
}
case eItemSetPassiveAbilityID::KnightRank3: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(561, 8890, parentID);
break;
}
case eItemSetPassiveAbilityID::SpaceRangerRank1: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(1101, 24612, parentID);
break;
}
case eItemSetPassiveAbilityID::SpaceRangerRank2: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(1102, 24617, parentID);
break;
}
case eItemSetPassiveAbilityID::SpaceRangerRank3: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(1103, 24622, parentID);
break;
}
case eItemSetPassiveAbilityID::SamuraiRank1: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(562, 8899, parentID);
break;
}
case eItemSetPassiveAbilityID::SamuraiRank2: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(563, 8904, parentID);
break;
}
case eItemSetPassiveAbilityID::SamuraiRank3: {
if (equippedCount < 4) return;
m_Cooldown = 11.0f;
skillComponent->CalculateBehavior(564, 8909, parentID);
break;
}
default:
break;
}
m_Cooldown = m_StatTrigger.cooldown;
skillComponent->CastSkill(m_StatTrigger.skillID, m_Parent->GetObjectID());
}
std::vector<ItemSetPassiveAbility> ItemSetPassiveAbility::FindAbilities(uint32_t itemSetID, Entity* parent, ItemSet* itemSet) {
const ItemSetStatTrigger* ItemSetPassiveAbility::GetStatTriggerForScript(std::string_view scriptName) {
const auto fileName = ScriptFileName(scriptName);
for (const auto& entry : StatTriggerScripts) {
if (entry.script == fileName) return &entry.trigger;
}
return nullptr;
}
std::vector<ItemSetStatTrigger> ItemSetPassiveAbility::FindStatTriggers(uint32_t itemSetID, const std::vector<LOT>& items) {
std::vector<ItemSetStatTrigger> triggers;
auto* registryTable = CDClientManager::GetTable<CDComponentsRegistryTable>();
auto* scriptTable = CDClientManager::GetTable<CDScriptComponentTable>();
for (const auto lot : items) {
const auto scriptComponentID = registryTable->GetByIDAndType(lot, eReplicaComponentType::SCRIPT, -1);
if (scriptComponentID < 0) continue;
const auto* trigger = GetStatTriggerForScript(scriptTable->GetByID(scriptComponentID).script_name);
// Like the script, only fire for the set it was written for
if (!trigger || trigger->setID != itemSetID) continue;
// Every item of a set carries the same script, but only one of them casts the skill
const auto alreadyFound = std::any_of(triggers.begin(), triggers.end(), [trigger](const ItemSetStatTrigger& found) {
return found.trigger == trigger->trigger && found.skillID == trigger->skillID;
});
if (!alreadyFound) triggers.push_back(*trigger);
}
return triggers;
}
std::vector<ItemSetOnKillSkill> ItemSetPassiveAbility::FindOnKillSkills(const std::array<std::vector<uint32_t>, 5>& skillSets) {
std::vector<ItemSetOnKillSkill> onKillSkills;
auto* skillTable = CDClientManager::GetTable<CDSkillBehaviorTable>();
for (size_t tier = 0; tier < skillSets.size(); ++tier) {
for (const auto skillID : skillSets[tier]) {
const auto behaviorID = skillTable->GetSkillByID(skillID).behaviorID;
if (behaviorID == 0) continue;
if (Behavior::GetBehaviorTemplate(behaviorID) != BehaviorTemplate::DARK_INSPIRATION) continue;
// The client keys the status effect by behavior ID, so a behavior repeated in a higher tier
// (e.g. the Sentinel rank 3 armor repair at 5 and 6 items) does not stack. Keep the lowest tier.
const auto alreadyFound = std::any_of(onKillSkills.begin(), onKillSkills.end(), [behaviorID](const ItemSetOnKillSkill& found) {
return found.behaviorID == behaviorID;
});
if (alreadyFound) continue;
onKillSkills.push_back({ static_cast<uint32_t>(tier + 2), skillID, behaviorID });
}
}
return onKillSkills;
}
std::vector<ItemSetPassiveAbility> ItemSetPassiveAbility::FindAbilities(uint32_t itemSetID, const std::vector<LOT>& items, const std::array<std::vector<uint32_t>, 5>& skillSets, Entity* parent, ItemSet* itemSet) {
std::vector<ItemSetPassiveAbility> abilities;
switch (static_cast<eItemSetPassiveAbilityID>(itemSetID)) {
// Assembly
case eItemSetPassiveAbilityID::SummonerRank1:
case eItemSetPassiveAbilityID::SummonerRank2:
case eItemSetPassiveAbilityID::SummonerRank3:
case eItemSetPassiveAbilityID::InventorRank1:
case eItemSetPassiveAbilityID::InventorRank2:
case eItemSetPassiveAbilityID::InventorRank3:
case eItemSetPassiveAbilityID::EngineerRank1:
case eItemSetPassiveAbilityID::EngineerRank2:
case eItemSetPassiveAbilityID::EngineerRank3: {
abilities.emplace_back(PassiveAbilityTrigger::AssemblyImagination, parent, itemSet);
break;
for (const auto& statTrigger : FindStatTriggers(itemSetID, items)) {
auto& ability = abilities.emplace_back(statTrigger.trigger, parent, itemSet);
ability.m_StatTrigger = statTrigger;
}
// Sentinel
case eItemSetPassiveAbilityID::KnightRank1:
case eItemSetPassiveAbilityID::KnightRank2:
case eItemSetPassiveAbilityID::KnightRank3:
case eItemSetPassiveAbilityID::SpaceRangerRank1:
case eItemSetPassiveAbilityID::SpaceRangerRank2:
case eItemSetPassiveAbilityID::SpaceRangerRank3:
case eItemSetPassiveAbilityID::SamuraiRank1:
case eItemSetPassiveAbilityID::SamuraiRank2:
case eItemSetPassiveAbilityID::SamuraiRank3: {
abilities.emplace_back(PassiveAbilityTrigger::SentinelArmor, parent, itemSet);
abilities.emplace_back(PassiveAbilityTrigger::EnemySmashed, parent, itemSet);
break;
}
// Paradox
case eItemSetPassiveAbilityID::BatLord:
case eItemSetPassiveAbilityID::SpaceMarauderRank1:
case eItemSetPassiveAbilityID::SpaceMarauderRank2:
case eItemSetPassiveAbilityID::SpaceMarauderRank3:
case eItemSetPassiveAbilityID::SorcererRank1:
case eItemSetPassiveAbilityID::SorcererRank2:
case eItemSetPassiveAbilityID::SorcererRank3:
case eItemSetPassiveAbilityID::ShinobiRank1:
case eItemSetPassiveAbilityID::ShinobiRank2:
case eItemSetPassiveAbilityID::ShinobiRank3: {
abilities.emplace_back(PassiveAbilityTrigger::EnemySmashed, parent, itemSet);
break;
}
default:
break;
auto onKillSkills = FindOnKillSkills(skillSets);
if (!onKillSkills.empty()) {
auto& ability = abilities.emplace_back(PassiveAbilityTrigger::EnemySmashed, parent, itemSet);
ability.m_OnKillSkills = std::move(onKillSkills);
}
return abilities;
}
void ItemSetPassiveAbility::OnEnemySmshed(Entity* target) {
void ItemSetPassiveAbility::OnEnemySmashed(Entity* target) {
if (!target) return;
auto* destroyableComponent = m_Parent->GetComponent<DestroyableComponent>();
auto* skillComponent = m_Parent->GetComponent<SkillComponent>();
@@ -203,118 +194,16 @@ void ItemSetPassiveAbility::OnEnemySmshed(Entity* target) {
return;
}
Game::entityManager->SerializeEntity(m_Parent);
const auto id = static_cast<eItemSetPassiveAbilityID>(m_ItemSet->GetID());
const auto parentID = m_Parent->GetObjectID();
const auto equippedCount = m_ItemSet->GetEquippedCount();
switch (id) {
// Bat Lord
case eItemSetPassiveAbilityID::BatLord: {
if (equippedCount < 5) return;
destroyableComponent->Heal(3);
break;
}
// Sentinel
case eItemSetPassiveAbilityID::KnightRank1: {
if (equippedCount < 5) return;
destroyableComponent->Repair(1);
break;
}
case eItemSetPassiveAbilityID::KnightRank2: {
if (equippedCount < 5) return;
destroyableComponent->Repair(1);
break;
}
case eItemSetPassiveAbilityID::KnightRank3: {
if (equippedCount < 5) return;
destroyableComponent->Repair(1);
break;
// The client adds a status effect for each DarkInspiration set skill when it is cast on equip,
// which casts the behavior's action when the wearer kills something of a faction in its faction_list.
// A tier's skills stay cast while at least that many items are equipped.
for (const auto& onKillSkill : m_OnKillSkills) {
if (equippedCount < onKillSkill.itemsRequired) continue;
SkillComponent::CalculateUnmanaged(onKillSkill.behaviorID, m_Parent->GetObjectID(), target->GetObjectID());
}
case eItemSetPassiveAbilityID::SpaceRangerRank1: {
if (equippedCount < 5) return;
destroyableComponent->Repair(1);
break;
}
case eItemSetPassiveAbilityID::SpaceRangerRank2: {
if (equippedCount < 5) return;
destroyableComponent->Repair(1);
break;
}
case eItemSetPassiveAbilityID::SpaceRangerRank3: {
if (equippedCount < 5) return;
destroyableComponent->Repair(1);
break;
}
case eItemSetPassiveAbilityID::SamuraiRank1: {
if (equippedCount < 5) return;
destroyableComponent->Repair(1);
break;
}
case eItemSetPassiveAbilityID::SamuraiRank2: {
if (equippedCount < 5) return;
destroyableComponent->Repair(1);
break;
}
case eItemSetPassiveAbilityID::SamuraiRank3: {
if (equippedCount < 5) return;
destroyableComponent->Repair(1);
break;
}
// Paradox
case eItemSetPassiveAbilityID::SpaceMarauderRank1: {
if (equippedCount < 4) return;
destroyableComponent->Imagine(1);
break;
}
case eItemSetPassiveAbilityID::SpaceMarauderRank2: {
if (equippedCount < 4) return;
destroyableComponent->Imagine(2);
break;
}
case eItemSetPassiveAbilityID::SpaceMarauderRank3: {
if (equippedCount < 4) return;
destroyableComponent->Imagine(3);
break;
}
case eItemSetPassiveAbilityID::ShinobiRank1: {
if (equippedCount < 4) return;
destroyableComponent->Imagine(1);
break;
}
case eItemSetPassiveAbilityID::ShinobiRank2: {
if (equippedCount < 4) return;
destroyableComponent->Imagine(2);
break;
}
case eItemSetPassiveAbilityID::ShinobiRank3: {
if (equippedCount < 4) return;
destroyableComponent->Imagine(3);
break;
}
case eItemSetPassiveAbilityID::SorcererRank1: {
if (equippedCount < 4) return;
destroyableComponent->Imagine(1);
break;
}
case eItemSetPassiveAbilityID::SorcererRank2: {
if (equippedCount < 4) return;
destroyableComponent->Imagine(2);
break;
}
case eItemSetPassiveAbilityID::SorcererRank3: {
if (equippedCount < 4) return;
destroyableComponent->Imagine(3);
break;
}
default:
break;
}
Game::entityManager->SerializeEntity(m_Parent);
}

View File

@@ -1,5 +1,7 @@
#pragma once
#include <array>
#include <string_view>
#include <vector>
#include "dCommonVars.h"
@@ -16,6 +18,28 @@ enum class PassiveAbilityTrigger
EnemySmashed, // Enemy is smashed
};
/**
* What a stat-triggered set ability does, as set up by one of the live
* server's res/scripts/equipmenttriggers item scripts.
*/
struct ItemSetStatTrigger {
PassiveAbilityTrigger trigger; // the Lua "trigger" var (stat + threshold)
uint32_t skillID; // the Lua "skillID" var
uint32_t itemsRequired; // the Lua "itemsRequired" var
uint32_t setID; // the Lua "skillSet" var: the ItemSets row the script belongs to
float cooldown; // the Lua "factionCooldownTime" var; 0 for scripts without "isFactionSkill"
};
/**
* One set skill that runs when the wearer smashes something: a skill in the set's
* ItemSetSkills whose root behavior is DarkInspiration (the client's "OnKillFactionBehavior").
*/
struct ItemSetOnKillSkill {
uint32_t itemsRequired; // the skillSetWithN tier the skill comes from
uint32_t skillID;
uint32_t behaviorID;
};
/**
* Passive abilities that belong to an item set, activated when a PassiveAbilityTrigger condition is met
*/
@@ -23,7 +47,6 @@ class ItemSetPassiveAbility
{
public:
ItemSetPassiveAbility(PassiveAbilityTrigger trigger, Entity* parent, ItemSet* itemSet);
~ItemSetPassiveAbility();
void Update(float deltaTime);
/**
@@ -38,22 +61,51 @@ public:
void Activate(Entity* target = nullptr);
/**
* Finds all the passive abilities associated with a certain item set
* Finds all the passive abilities of an item set, from the CDClient and the item scripts on its items
* @param itemSetID the item set to find abilities for
* @param items the LOTs in the set (ItemSets.itemIDs)
* @param skillSets the skills of each skillSetWithN tier, index 0 being skillSetWith2
* @param parent the parent to add to the passive abilities
* @param itemSet the item set to add to the passive abilities
* @return the passive abilities for the provided item set
*/
static std::vector<ItemSetPassiveAbility> FindAbilities(uint32_t itemSetID, Entity* parent, ItemSet* itemSet);
static std::vector<ItemSetPassiveAbility> FindAbilities(uint32_t itemSetID, const std::vector<LOT>& items, const std::array<std::vector<uint32_t>, 5>& skillSets, Entity* parent, ItemSet* itemSet);
/**
* The stat triggers the set's items carry. The live server set these up in the equipmenttriggers
* item scripts, which are linked to the items through their ScriptComponent.
*/
static std::vector<ItemSetStatTrigger> FindStatTriggers(uint32_t itemSetID, const std::vector<LOT>& items);
/**
* The on-kill skills among the set's skills: those whose root behavior is DarkInspiration.
*/
static std::vector<ItemSetOnKillSkill> FindOnKillSkills(const std::array<std::vector<uint32_t>, 5>& skillSets);
/**
* The trigger a live equipmenttriggers script sets up, by the script's file name (any path, any case).
* @return nullptr when the script is not a set stat trigger
*/
static const ItemSetStatTrigger* GetStatTriggerForScript(std::string_view scriptName);
private:
void OnEnemySmshed(Entity* target = nullptr);
void OnEnemySmashed(Entity* target);
/**
* The means of triggering this ability
*/
PassiveAbilityTrigger m_Trigger;
/**
* What a stat trigger does, unused for EnemySmashed
*/
ItemSetStatTrigger m_StatTrigger{};
/**
* The skills an EnemySmashed ability runs
*/
std::vector<ItemSetOnKillSkill> m_OnKillSkills;
/**
* The owner of this ability
*/

View File

@@ -9,6 +9,7 @@ set(DGAMETEST_SOURCES
"ContrabandTests.cpp"
"KnockbackTests.cpp"
"CollisionFilterTests.cpp"
"ItemSetPassiveAbilityTests.cpp"
)
add_subdirectory(dComponentsTests)
@@ -26,6 +27,8 @@ file(COPY ${COMPONENT_TEST_DATA} DESTINATION ${CMAKE_CURRENT_BINARY_DIR})
# Add the executable. Remember to add all tests above this!
add_executable(dGameTests ${DGAMETEST_SOURCES})
target_include_directories(dGameTests PRIVATE "." "${PROJECT_SOURCE_DIR}/dChatServer" "${PROJECT_SOURCE_DIR}/dScripts")
# Tests against the live CDClient look for it in the source tree's build folder
target_compile_definitions(dGameTests PRIVATE PROJECT_SOURCE_DIR="${PROJECT_SOURCE_DIR}")
if(APPLE)
add_custom_target(dGameTestsLink

View File

@@ -0,0 +1,258 @@
#include <gtest/gtest.h>
#include <cstdlib>
#include <filesystem>
#include <map>
#include <string>
#include "GameDependencies.h"
#include "ItemSetPassiveAbility.h"
#include "CDClientDatabase.h"
#include "CDClientManager.h"
#include "CDScriptComponentTable.h"
#include "CDSkillBehaviorTable.h"
#include "CDBehaviorTemplateTable.h"
// Item set passive abilities come from the CDClient (DarkInspiration set skills) and the item
// scripts linked to the set items (equipmenttriggers). These tests check both against the values
// the server used to hardcode per set ID.
namespace {
std::array<std::vector<uint32_t>, 5> ReadSkillSets(uint32_t setID) {
std::array<std::vector<uint32_t>, 5> skillSets;
auto query = CDClientDatabase::CreatePreppedStmt(
"SELECT skillSetWith2, skillSetWith3, skillSetWith4, skillSetWith5, skillSetWith6 FROM ItemSets WHERE setID = ?;");
query.bind(1, static_cast<int>(setID));
auto result = query.execQuery();
if (result.eof()) return skillSets;
for (int i = 0; i < 5; ++i) {
if (result.fieldIsNull(i)) continue;
auto skillQuery = CDClientDatabase::CreatePreppedStmt("SELECT SkillID FROM ItemSetSkills WHERE SkillSetID = ?;");
skillQuery.bind(1, result.getIntField(i));
auto skills = skillQuery.execQuery();
while (!skills.eof()) {
skillSets[i].push_back(skills.getIntField(0));
skills.nextRow();
}
}
return skillSets;
}
std::vector<LOT> ReadItems(uint32_t setID) {
std::vector<LOT> items;
auto query = CDClientDatabase::CreatePreppedStmt("SELECT itemIDs FROM ItemSets WHERE setID = ?;");
query.bind(1, static_cast<int>(setID));
auto result = query.execQuery();
if (result.eof()) return items;
std::string ids = result.getStringField(0);
std::string token;
for (const char c : ids + ",") {
if (c == ',') {
if (!token.empty()) items.push_back(std::stoi(token));
token.clear();
} else if (!std::isspace(static_cast<unsigned char>(c))) {
token += c;
}
}
return items;
}
float Parameter(uint32_t behaviorID, const std::string& name) {
auto query = CDClientDatabase::CreatePreppedStmt("SELECT value FROM BehaviorParameter WHERE behaviorID = ? AND parameterID = ?;");
query.bind(1, static_cast<int>(behaviorID));
query.bind(2, name.c_str());
auto result = query.execQuery();
return result.eof() ? -1.0f : static_cast<float>(result.getFloatField(0));
}
uint32_t Template(uint32_t behaviorID) {
auto query = CDClientDatabase::CreatePreppedStmt("SELECT templateID FROM BehaviorTemplate WHERE behaviorID = ?;");
query.bind(1, static_cast<int>(behaviorID));
auto result = query.execQuery();
return result.eof() ? 0 : result.getIntField(0);
}
// What a DarkInspiration on-kill behavior ends up doing to the wearer: "<template>=<amount>"
std::string OnKillEffect(uint32_t behaviorID) {
constexpr uint32_t TargetCaster = 14;
constexpr uint32_t Heal = 5;
constexpr uint32_t Imagination = 13;
constexpr uint32_t RepairArmor = 22;
if (Parameter(behaviorID, "faction_list") != 4.0f) return "wrong faction";
const auto targetCaster = static_cast<uint32_t>(Parameter(behaviorID, "action"));
if (Template(targetCaster) != TargetCaster) return "not target caster";
const auto effect = static_cast<uint32_t>(Parameter(targetCaster, "action"));
switch (Template(effect)) {
case Heal: return "heal=" + std::to_string(static_cast<int>(Parameter(effect, "health")));
case Imagination: return "imagination=" + std::to_string(static_cast<int>(Parameter(effect, "imagination")));
case RepairArmor: return "armor=" + std::to_string(static_cast<int>(Parameter(effect, "armor")));
default: return "template " + std::to_string(Template(effect));
}
}
void LoadTables() {
CDClientManager::GetTable<CDScriptComponentTable>()->LoadValuesFromDatabase();
CDClientManager::GetTable<CDSkillBehaviorTable>()->LoadValuesFromDatabase();
CDClientManager::GetTable<CDBehaviorTemplateTable>()->LoadValuesFromDatabase();
}
}
TEST(ItemSetPassiveAbilityTests, StatTriggerScriptLookupIgnoresPathAndCase) {
const auto* trigger = ItemSetPassiveAbility::GetStatTriggerForScript("scripts\\equipmenttriggers\\assemblyInventor2.lua");
ASSERT_NE(trigger, nullptr);
EXPECT_EQ(trigger->trigger, PassiveAbilityTrigger::AssemblyImagination);
EXPECT_EQ(trigger->skillID, 581);
EXPECT_EQ(trigger->itemsRequired, 4);
EXPECT_EQ(trigger->setID, 26);
EXPECT_FLOAT_EQ(trigger->cooldown, 11.0f);
trigger = ItemSetPassiveAbility::GetStatTriggerForScript("scripts/equipmenttriggers/sentinelKnight1.lua");
ASSERT_NE(trigger, nullptr);
EXPECT_EQ(trigger->trigger, PassiveAbilityTrigger::SentinelArmor);
EXPECT_EQ(trigger->skillID, 559);
EXPECT_EQ(trigger->setID, 7);
EXPECT_EQ(ItemSetPassiveAbility::GetStatTriggerForScript("scripts\\equipmenttriggers\\gempack.lua"), nullptr);
EXPECT_EQ(ItemSetPassiveAbility::GetStatTriggerForScript(""), nullptr);
}
// A small CDClient: items 90001-90002 carry the Summoner rank 1 script, 90003 the Knight rank 1 script and
// 90004 no script; skills 90201 and 90203 are DarkInspiration, 90202 a buff.
class ItemSetPassiveAbilityFixtureTest : public GameDependenciesTest {
protected:
void SetUp() override {
SetUpDependencies();
CDClientDatabase::Connect(":memory:");
for (const auto* sql : {
"CREATE TABLE ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER);",
"CREATE TABLE ScriptComponent (id INTEGER, script_name TEXT, client_script_name TEXT);",
"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);",
// Two items of the set carry the Summoner rank 1 script (set 28), one carries the Knight rank 1 script (set 7)
"INSERT INTO ComponentsRegistry VALUES (90001, 5, 90101), (90002, 5, 90101), (90003, 5, 90102), (90004, 11, 1);",
"INSERT INTO ScriptComponent VALUES (90101, 'scripts\\equipmenttriggers\\assemblysummoner1.lua', ''), (90102, 'scripts\\equipmenttriggers\\sentinelKnight1.lua', '');",
"INSERT INTO SkillBehavior (skillID, behaviorID, imaginationcost, cooldowngroup, cooldown, cancelType) VALUES (90201, 900001, 0, 0, 0, 0), (90202, 900002, 0, 0, 0, 0), (90203, 900003, 0, 0, 0, 0);",
// 24 = DarkInspiration, 30 = Buff
"INSERT INTO BehaviorTemplate VALUES (900001, 24, 0, ''), (900002, 30, 0, ''), (900003, 24, 0, '');",
}) {
CDClientDatabase::ExecuteDML(sql);
}
LoadTables();
}
void TearDown() override {
TearDownDependencies();
}
};
TEST_F(ItemSetPassiveAbilityFixtureTest, StatTriggersComeFromTheItemScriptsOfThatSet) {
const std::vector<LOT> items = { 90001, 90002, 90003, 90004 };
const auto summoner = ItemSetPassiveAbility::FindStatTriggers(28, items);
ASSERT_EQ(summoner.size(), 1); // two items carry the script, the skill is cast once
EXPECT_EQ(summoner[0].trigger, PassiveAbilityTrigger::AssemblyImagination);
EXPECT_EQ(summoner[0].skillID, 394);
EXPECT_EQ(summoner[0].itemsRequired, 4);
EXPECT_FLOAT_EQ(summoner[0].cooldown, 11.0f);
const auto knight = ItemSetPassiveAbility::FindStatTriggers(7, items);
ASSERT_EQ(knight.size(), 1);
EXPECT_EQ(knight[0].trigger, PassiveAbilityTrigger::SentinelArmor);
EXPECT_EQ(knight[0].skillID, 559);
// The scripts only fire for the set they were written for
EXPECT_TRUE(ItemSetPassiveAbility::FindStatTriggers(9001, items).empty());
}
TEST_F(ItemSetPassiveAbilityFixtureTest, OnKillSkillsAreTheDarkInspirationSetSkills) {
std::array<std::vector<uint32_t>, 5> skillSets;
skillSets[2] = { 90202, 90201 }; // with4: a buff and an on-kill skill
skillSets[3] = { 90201, 90203 }; // with5: the same on-kill skill again and a second one
skillSets[4] = { 99999 }; // with6: a skill that is not in SkillBehavior
const auto onKill = ItemSetPassiveAbility::FindOnKillSkills(skillSets);
ASSERT_EQ(onKill.size(), 2);
EXPECT_EQ(onKill[0].skillID, 90201);
EXPECT_EQ(onKill[0].behaviorID, 900001);
EXPECT_EQ(onKill[0].itemsRequired, 4);
EXPECT_EQ(onKill[1].skillID, 90203);
EXPECT_EQ(onKill[1].behaviorID, 900003);
EXPECT_EQ(onKill[1].itemsRequired, 5);
EXPECT_TRUE(ItemSetPassiveAbility::FindOnKillSkills({}).empty());
}
// Against the live CDClient, when there is one (DLU_CDSERVER_SQLITE, or build/resServer/CDServer.sqlite).
class ItemSetPassiveAbilityLiveDataTest : 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(ItemSetPassiveAbilityLiveDataTest, StatTriggersMatchTheOldHardcodedSets) {
// set ID -> (trigger, skill) the server used to hardcode, all at 4 items
const std::map<uint32_t, std::pair<PassiveAbilityTrigger, uint32_t>> expected = {
{ 2, { PassiveAbilityTrigger::AssemblyImagination, 394 } }, { 25, { PassiveAbilityTrigger::AssemblyImagination, 394 } }, { 28, { PassiveAbilityTrigger::AssemblyImagination, 394 } },
{ 3, { PassiveAbilityTrigger::AssemblyImagination, 581 } }, { 26, { PassiveAbilityTrigger::AssemblyImagination, 581 } }, { 29, { PassiveAbilityTrigger::AssemblyImagination, 581 } },
{ 4, { PassiveAbilityTrigger::AssemblyImagination, 582 } }, { 27, { PassiveAbilityTrigger::AssemblyImagination, 582 } }, { 30, { PassiveAbilityTrigger::AssemblyImagination, 582 } },
{ 7, { PassiveAbilityTrigger::SentinelArmor, 559 } }, { 8, { PassiveAbilityTrigger::SentinelArmor, 560 } }, { 9, { PassiveAbilityTrigger::SentinelArmor, 561 } },
{ 10, { PassiveAbilityTrigger::SentinelArmor, 1101 } }, { 11, { PassiveAbilityTrigger::SentinelArmor, 1102 } }, { 12, { PassiveAbilityTrigger::SentinelArmor, 1103 } },
{ 13, { PassiveAbilityTrigger::SentinelArmor, 562 } }, { 14, { PassiveAbilityTrigger::SentinelArmor, 563 } }, { 15, { PassiveAbilityTrigger::SentinelArmor, 564 } },
};
for (uint32_t setID = 1; setID <= 60; ++setID) {
const auto triggers = ItemSetPassiveAbility::FindStatTriggers(setID, ReadItems(setID));
const auto it = expected.find(setID);
if (it == expected.end()) {
EXPECT_TRUE(triggers.empty()) << "set " << setID;
continue;
}
ASSERT_EQ(triggers.size(), 1) << "set " << setID;
EXPECT_EQ(triggers[0].trigger, it->second.first) << "set " << setID;
EXPECT_EQ(triggers[0].skillID, it->second.second) << "set " << setID;
EXPECT_EQ(triggers[0].itemsRequired, 4) << "set " << setID;
// The faction (Assembly) scripts have an 11 second cooldown, the Sentinel scripts none
EXPECT_FLOAT_EQ(triggers[0].cooldown, it->second.first == PassiveAbilityTrigger::AssemblyImagination ? 11.0f : 0.0f) << "set " << setID;
}
}
TEST_F(ItemSetPassiveAbilityLiveDataTest, OnKillSkillsMatchTheOldHardcodedSets) {
// set ID -> (items, effect) the server used to hardcode on enemy smash
const std::map<uint32_t, std::pair<uint32_t, std::string>> expected = {
{ 42, { 5, "heal=3" } }, // Bat Lord
{ 8, { 5, "armor=1" } }, { 9, { 5, "armor=1" } }, // Knight
{ 11, { 5, "armor=1" } }, { 12, { 5, "armor=1" } }, // Space Ranger
{ 14, { 5, "armor=1" } }, { 15, { 5, "armor=1" } }, // Samurai
{ 16, { 4, "imagination=1" } }, { 17, { 4, "imagination=2" } }, { 18, { 4, "imagination=3" } }, // Sorcerer
{ 19, { 4, "imagination=1" } }, { 20, { 4, "imagination=2" } }, { 21, { 4, "imagination=3" } }, // Space Marauder
{ 22, { 4, "imagination=1" } }, { 23, { 4, "imagination=2" } }, { 24, { 4, "imagination=3" } }, // Shinobi
};
for (const auto& [setID, expectation] : expected) {
const auto onKill = ItemSetPassiveAbility::FindOnKillSkills(ReadSkillSets(setID));
ASSERT_FALSE(onKill.empty()) << "set " << setID;
// The first on-kill skill is the one the server hardcoded; the data can add more
// (e.g. the rank 2 and 3 Paradox team imagination at 4 and 5 items)
EXPECT_EQ(onKill[0].itemsRequired, expectation.first) << "set " << setID;
EXPECT_EQ(OnKillEffect(onKill[0].behaviorID), expectation.second) << "set " << setID;
}
// The rank 1 Sentinel sets were hardcoded to repair armor at 5 items, but only have 4 items
for (const uint32_t setID : { 7, 10, 13 }) {
EXPECT_LT(ReadItems(setID).size(), 5) << "set " << setID;
EXPECT_TRUE(ItemSetPassiveAbility::FindOnKillSkills(ReadSkillSets(setID)).empty()) << "set " << setID;
}
}