Skip to content

Ironbreaker career perk/ability fixes - #31

Open
Dannyboi802 wants to merge 2 commits into
next_updatefrom
Ironbreaker-fixes
Open

Dannyboi802 wants to merge 2 commits into
next_updatefrom
Ironbreaker-fixes

Conversation

@Dannyboi802

Copy link
Copy Markdown
Contributor
  • Correct troop targeting, ammunition bonuses, and upgrade discounts.
  • Fix shield-block charging, Shieldwall targeting, and explosion/friendly-fire defenses.
  • Increase Impenetrable duration and separate Gromril/Rune perk activation.
  • Update descriptions.

- Correct troop targeting, ammunition bonuses, and upgrade discounts.
- Fix shield-block charging, Shieldwall targeting, and explosion/friendly-fire defenses.
- Increase Impenetrable duration and separate Gromril/Rune perk activation.
- Update descriptions.
@Dannyboi802
Dannyboi802 requested a review from SlyDevil September 7, 2026 20:46
public static float ApplyIronbreakerFriendlyFireReduction(Agent attacker, Agent victim, float damage)
{
if (Campaign.Current == null ||
Hero.MainHero?.HasCareerChoice("GromrilArmorPassive4") != true ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could pattern matching in conjunction with a NOT apply to any of these? might simplify it a bit.

!(attacker.Team is Team.Valid) for example.. food for thought. Close if wrong.

I like the guard clauses though.

if (chargeType == ChargeType.DamageTaken && affectedAgent == Agent.Main && affectedAgent.GetHero() == Hero.MainHero)
{
return chargeValue * 5;
return (collisionFlag & CareerHelper.ChargeCollisionFlag.HitShield) != 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be shield only, or the enum is expanded to include weapon blocks and any sort of blocking counts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it should be the shield only. ill test it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question is about design intent, not functionality.
The restricted mechanical space is self-imposed. We can change the underlying flags to permit detecting other forms of defending oneself beyond blocking with a shield.

If shield-only blocks are the intention, the career ability description should probably specify that.

@@ -1186,6 +1186,12 @@ public float CalculateWardSaveFactor(Agent attacker, Agent victim, float[] resis
{
result.LimitMin(0.11f);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Limit the min to 0.1f so an ironbreaker can actually benefit from the 90% ward save listed instead of 89%.

{
result.LimitMin(0.11f);
}
else if (victim.HasAttribute("Impenetrable"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add attributes to TORConstants.CharacterAttibutes. Their spelling only needs to exist in 1 location to limit errors.

{
ammoCount.AddFactor(0.1f);
}
ammoCount.AddFactor(0.01f * ironbeardCount);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Store the 1% value in the PassiveEffect on the career choice, then fetch the choice and use choice.GetPassiveValue().

}
}

ammoCount.Add(12);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Store specific values where possible on the choice so description and value are defined in the same place.

There's probably other lines this applies to, but not going to leave comments on them all.

if (characterObject.IsUndead()) return new ExplainedNumber(0);

var explainedNumber = base.GetGoldCostForUpgrade(party, characterObject, upgradeTarget);
var applyIronbreakerDiscountLast = party.LeaderHero?.HasCareerChoice("IronPricePassive3") == true &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use an early out here for all non-MainParties as all subsequent evaluations don't matter to them.

Suggested change
var applyIronbreakerDiscountLast = party.LeaderHero?.HasCareerChoice("IronPricePassive3") == true &&
if (party.LeaderHero == null || party != PartyBase.MainParty || party.LeaderHero != Hero.MainHero) return explainedNumber;


if (characterObject.HasAttribute(CharacterAttributes.IRONBREAKER))
{
explainedNumber.AddFactor(3f);

@SlyDevil SlyDevil Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change this to an Add instead of AddFactor.
.Add() will affect the base number that all factors are applied on and prevent this +300% increase from being additive with other factors. Consequently, all of the additions here for the IronPricePassive3 perk can be removed.

Suggested change
explainedNumber.Add(explainedNumber.BaseNumber * 3);

This won't be exactly identical to what's written below due to base.GetGoldCostForUpgrade factors that would have been folded into the BaseNumber and would therefore be multiplicative with the IronPricePassive3 reduction, but it will be relatively similar, simpler, and more consistent with how factors are applied in the game.

Few factors are multiplicative; making one so should have a conscious reason for why it deviates and description formulations considered in how we communicate to the player that a value is being treated differently from others.


if (characterObject.Culture.StringId == TORConstants.Cultures.DAWI)
{
if (party == PartyBase.MainParty)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This .MainParty check is superfluous if the guard clause is added higher up and can be removed.
party.LeaderHero... covers both the check for MainParty and has the incidental effect of checking if the player is a prisoner and would be able to attempt an upgrade in that state.




if (Hero.MainHero.HasCareerChoice("IronDrakesPassive2") && character.HasAttribute(CharacterAttributes.IRONBREAKER))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be removed. This is a bug I introduced in commit 799baa4 and I'll fix separately.

This would only fix the bug for the player when having this specific perk and troops instead of all contexts.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See commit b653ab9

blow.VictimBodyPart = BoneBodyPartType.Chest;
blow.StrikeType = StrikeType.Thrust;
if (hasShockWave)
if (hasShockWave && !agent.HasAttribute("NestCleansing"))

@SlyDevil SlyDevil Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was there a case where using the AgentStat model was insufficient to have the desired behavior?

This seems unnecessary. GetKnockBackResistance and GetKnockDownResistance in the AgentStatCalculateModel are used as percentages of max health.
Ignoring knockdown resistance as it doesn't apply here, if agents with this attribute receive a value of 1 in those overrides, they can resist knockdown and knockback up to 100% of their hp. Anything beyond that wouldn't matter because they'd be dead.

The addition of BlowFlags here is only about the capacity for a blow to cause those effects, not about the agent's resistance to them.

This method shouldn't need to have any evaluations for specific contexts because those should already have been accounted for elsewhere. This method intends to apply the Blow for the engine's sake, but the factors adjusting the blow should already be determined before reaching this.

try
{
if (agent.IsFadingOut()) return;
damageAmount = (int)TORDamageHelper.ApplyIronbreakerFriendlyFireReduction(damager, agent, damageAmount);

@SlyDevil SlyDevil Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The damage should already be known when entering this method, this evaluation doesn't go here.

If a value is entering and bypassing relevant damage reductions, the prior calculations should be accounting for those.
Note the routes that are failing to account for reductions so they can be fixed upstream at their source.

{
var baseDamage = explosionDamage * MBRandom.RandomFloatRanged(1 - damageVariance, 1 + damageVariance);
var damage = (explosionRadius - distance) / explosionRadius * baseDamage;
damage = TORDamageHelper.ApplyIronbreakerExplosionDefenses(affector, agent, damage);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's interesting to know that explosions have been bypassing the rest of the damage system.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a feeling they were, allied trollhammers made you feel way to squishy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for the moment, but in the future we'll have to look at the triggered effects from explosions.

Some of them currently have these custom explosions activating that ignore the hit resolution system while others make use of the AffectsArea and AffectsAreaBig weapon flags which are resolved by passing through the system as a normal blow.
There's 2 behaviours occurring and there's no way for a player to predict how divergent they are in practice.

return base.GetKnockBackResistance(agent);
}

public override float GetKnockDownResistance(Agent agent, StrikeType strikeType)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nest cleansing goes here if it prevents knockdown.

return;
}

BookSpellDamage(spellDamage.CastId, spellDamage.Target, spellDamage.Damage, 0, spellDamage.DamageType);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Damage was already calculated upstream before it is queuing. This doesn't belong here.

Follow the references backwards and you should reach TORAbilityModel.CalculateAbilityDamage where the calculations are performed for resistances. These should be added with the others at that point otherwise you have created a new damage resistance that is multiplicative with others of the same damage type instead of additive like the rest.

{
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do all explosions also carry the ShockWave attribute? Can that be added that to the guard before checking the string ids of irrelevant effects?


// TOR's explosion templates use these identifiers, including cloned variants.
// HasShockWave alone also describes stomps and wind effects, not just explosions.
return template.StringID?.IndexOf("_explosion", StringComparison.OrdinalIgnoreCase) >= 0 ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This criteria has a very large definition for "explosions" that will be affected by the NestCleansing keystone.

This is a design choice that requires more justification.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Due to the similarity in formulation between this keystone and the Gunpowder perk about reducing explosion damage, the definition in general needs to be explicited because their applications are very divergent in what they attempt to include.

Though, due to the code having been removed for the Bomb Suit perk at some point, there wasn't a way to have known that based on the available code.

ApplyCareerPassives(attacker, victim, AttackTypeMask.Ranged, new float[(int)DamageType.All + 1], resistances);
ApplyNestCleansingExplosionResistance(victim, DamageType.Physical, resistances);

var physicalFactor = Math.Max(0f, 1f - resistances[(int)DamageType.Physical]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is DamageType.Physical an arbitrary choice made here, or is the intent to only affect Physical damage?
ie. Is there an incongruity with how you would apply the explosion damage reduction to triggered effects where it applies to all damage types.

protected override void InitializeKeyStones()
{
_ironbreakerRoot.Initialize(CareerID, "Khazukan Kazakit-ha! For a brief period become Impenetrable. Gain +95% personal 'Ward Save', but move 25% slower. For every level of Athletics, gain 0.004s of Impenetrable. (Ability is charged by receiving and blocking damage.)", null, true,
_ironbreakerRoot.Initialize(CareerID, "Khazukan Kazakit-ha! Become Impenetrable for 10 seconds plus 0.05 seconds per Athletics point. Gain +90% personal 'Ward Save', but move 25% slower. (Ability is charged by receiving and blocking damage.)", null, true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How long does this take to charge in practice?

The cooldown is entirely covered by the base duration here. With skill scaling at higher levels, the player is likely to have 30+ seconds of duration.
Is there any downtime or the player will have permanent uptime on at least 90% damage reduction + the rest of the buffs?

});

_gromrilArmorKeystone.Initialize(CareerID, "Impenetrable increases personal 'Physical Resistance' by 0.5% when a hit is taken, lasts 10s.", "GromrilArmor", false,
_gromrilArmorKeystone.Initialize(CareerID, "At 5s: +5% Physical Resistance per stored enemy melee hit for 10s. +25% reload speed; +0.02s Impenetrable per Gunpowder.", "GromrilArmor", false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is 5 seconds the threshold?

});

_gromrilArmorKeystone.Initialize(CareerID, "Impenetrable increases personal 'Physical Resistance' by 0.5% when a hit is taken, lasts 10s.", "GromrilArmor", false,
_gromrilArmorKeystone.Initialize(CareerID, "At 5s: +5% Physical Resistance per stored enemy melee hit for 10s. +25% reload speed; +0.02s Impenetrable per Gunpowder.", "GromrilArmor", false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"per stored melee hit" -> received hit?
Is this blocked? That deals damage to the player? Other?

});

_gromrilArmorKeystone.Initialize(CareerID, "Impenetrable increases personal 'Physical Resistance' by 0.5% when a hit is taken, lasts 10s.", "GromrilArmor", false,
_gromrilArmorKeystone.Initialize(CareerID, "At 5s: +5% Physical Resistance per stored enemy melee hit for 10s. +25% reload speed; +0.02s Impenetrable per Gunpowder.", "GromrilArmor", false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this scaling with Gunpowder and granting reload speed?

Neither of these seem related to armour made from high quality metal.
This looks like a previous copy-paste error is being propagated instead of removed.

With all 3 keystones granting reload rate increases, the player would have either +75% reload rate if they stack in effect, or a triple duration buff if they stack in duration, or identical buffs from 3 sources that don't stack.
None of these outcomes seems particularly intended from the code, description, and design notes are lacking.

});

_gromrilArmorKeystone.Initialize(CareerID, "Impenetrable increases personal 'Physical Resistance' by 0.5% when a hit is taken, lasts 10s.", "GromrilArmor", false,
_gromrilArmorKeystone.Initialize(CareerID, "At 5s: +5% Physical Resistance per stored enemy melee hit for 10s. +25% reload speed; +0.02s Impenetrable per Gunpowder.", "GromrilArmor", false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the intent that this buff to resistance is applied while Impenetrable is active, or it's accumulated and applied after it wears off?
Overlaps with?

Given the uptime on Impenetrable with 90% damage reduction, is this meant to encourage the player to block attacks for the first couple seconds, to then have such high damage reduction that they can attack without care for defense for the next interval while it's active?

});

_runeWeaponsKeystone.Initialize(CareerID, "Impenetrable increases personal 'Physical' damage by 0.5% when a hit is taken, lasts 10s.", "RuneWeapons", false,
_runeWeaponsKeystone.Initialize(CareerID, "At 5s: +5% Physical damage per stored enemy melee hit for 10s. +25% reload speed; +0.02s Impenetrable per Gunpowder.", "RuneWeapons", false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of the comments for gromrilArmorKeystone apply here as well.
Why those values?
What's the usage intent of the buff? How would a player actively plan and execute on benefiting from it?
Why reload rate + gunpowder scaling?

_ironDrakesPassive2.Initialize(CareerID, "-25% 'Oathgold' upgrade cost for Ironbreakers, Ironbeards, Irondrakes, and Trollhammer Irondrakes.", "IronDrakes", false, ChoiceType.Passive, null, new CareerChoiceObject.PassiveEffect(-25, PassiveEffectType.CustomResourceUpgradeCostModifier, true,
characterObject => characterObject.HasAttribute(CharacterAttributes.IRONBREAKER)));
_ironDrakesPassive3.Initialize(CareerID, "+12 ammunition for Drakefire canisters carried by you and companions in your party.", "IronDrakes", false, ChoiceType.Passive, null, new CareerChoiceObject.PassiveEffect());
_ironDrakesPassive4.Initialize(CareerID, "+1% ammunition for 'Ironbreaker' troops per Ironbeard unit.", "IronDrakes", false, ChoiceType.Passive, null, new CareerChoiceObject.PassiveEffect());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"per Ironbeard unit" seems like it'll be confusing. I usually read "unit" as a composition of troops which implies to me that I'd get only +1% ammo.

Perhaps, "+1% ammunition for Ironbreakers per Ironbeard troop."?

The presence of both "troops" and "unit" in the description implies that they are referring to different categorizations.

_nestCleansingPassive1.Initialize(CareerID, "+10 personal Hitpoints.", "NestCleansing", false, ChoiceType.Passive, null, new CareerChoiceObject.PassiveEffect(10, PassiveEffectType.Health));
_nestCleansingPassive2.Initialize(CareerID, "+20% personal 'Fire Resistance'.", "NestCleansing", false, ChoiceType.Passive, null, new CareerChoiceObject.PassiveEffect(PassiveEffectType.Resistance, new DamageProportionTuple(DamageType.Fire, 20), AttackTypeMask.All));
_nestCleansingPassive3.Initialize(CareerID, "Explosive charges gain +2 ammunition.", "NestCleansing", false, ChoiceType.Passive, null, new CareerChoiceObject.PassiveEffect());
_nestCleansingPassive3.Initialize(CareerID, "Your personal explosive charges gain +2 ammunition.", "NestCleansing", false, ChoiceType.Passive, null, new CareerChoiceObject.PassiveEffect());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this not have been achieved with PassiveEffectType.Ammo, putting the value into the PassiveEffect, and adding a predicate for detecting explosive charges?

_gromrilArmorPassive1.Initialize(CareerID, "+20% 'Physical Resistance' for 'Ironbreaker' troops.", "GromrilArmor", false, ChoiceType.Passive, null, new CareerChoiceObject.PassiveEffect(PassiveEffectType.TroopResistance, new DamageProportionTuple(DamageType.Physical, 20), AttackTypeMask.Melee,
(attacker, victim, mask) => attacker.Team == victim.Team && attacker.Character.StringId.Contains("ironbreaker")));
_gromrilArmorPassive1.Initialize(CareerID, "+20% 'Physical Resistance' for 'Ironbreaker' troops.", "GromrilArmor", false, ChoiceType.Passive, null, new CareerChoiceObject.PassiveEffect(PassiveEffectType.TroopResistance, new DamageProportionTuple(DamageType.Physical, 20), AttackTypeMask.All,
(attacker, victim, mask) => victim.BelongsToMainParty() && victim.Character.IsIronbreakerUnit()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent.IsPlayerTroop or .IsPlayerUnit are simpler checks than .BelongsToMainParty to determine the same thing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya, wow, I see how common that BelongsToMainParty check is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left a note directly on the agent.BelongsToMainParty (albeit on a different branch that isn't yet merged to next_update) about this.

Leave this as is for the moment; the actual check being performed within BelongsToMainParty is likely what will be changed.

@SlyDevil

Copy link
Copy Markdown
Contributor

This would have been overall easier to go through if the changes for each perk were in separate commits to simplify following the changes and their impacts between files.

Even with your spreadsheet detailing the previous functionality and what you were aiming for with these changes, there's often a lack of sufficient context to follow how you're going about accomplishing the goals and why in the way you've chosen.
Sharing some of your thinking makes it easier to differentiate between "goal is correct, but method to reach it isn't" and "stated goal isn't the actual goal, the method is fine but seems wrong due to lack of information".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants