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