Damage Calculator Script
Declared with "implements" : "damageCalculator" in the scripts section of a mod. This script decides what an attack is worth - the damage of the creatures, everything that raises or lowers it, and estimation on how many creatures die.
Unlike the other script types there is exactly one damage calculator in a game. It is not attached to a unit and nothing grants it: the engine asks it about every attack, whether the blow is being dealt or an AI is only weighing it. VCMI ships core:damageCalculator, and a mod changes the rules by stacking a patch over it rather than by declaring one of its own.
How damage is worked out
Every attack goes through the same three steps.
1. Base damage. What the creatures themselves deal, min and max, multiplied by how many of them are alive. Bless and curse collapse that range onto one of its ends, a ballista multiplies it by the attack of its hero.
2. Factors. Everything that scales base damage is a factor - a signed share of the base damage. Positive raises it, negative lowers it. What decides how a factor applies is its sign alone, not where it came from:
- factors that raise the damage add up: attack over defence (+5% per point), offence (+30%), luck (+100%) give
1 + 0.05×points + 0.3 + 1.0 - factors that lower it multiply, each taking its share of what is left: armourer (-15%) and a shooting penalty (-50%) give
0.85 × 0.5
The two totals are multiplied together. This is why a single -50% never quite halves the damage twice, and why giving a "boost" a negative value turns it into a mitigation rather than cancelling out other boosts.
3. Casualties. How many creatures the resulting damage kills, given the health left on the first one. This is only used for damage preview in UI, and for AI estimation - engine instead rolls damage within specified range.
Adding a factor
Write a patch, list it in patches, write the factor as a method of it, and hand its name to addDamageFactor:
Two lines register it: declareBonus for every bonus type the factor reads - see declaring what you look at - and addDamageFactor for the factor itself. The order factors are added in does not matter; what a factor is worth is decided by its sign, so return a negative number to lower the damage and a positive one to raise it.
addDamageFactor is given the name of the method rather than the method itself, so that a patch stacked later can override it and be the one that runs.
Changing a rule
Every step is a method and can be overridden, the factors of the base script among them - getBaseDamageSingle, getBaseDamageBlessCurse, getAttack, getDefense, getDamageCap, getCasualties, getJoustingFactor, getArmorerFactor, ...
Call up the chain with Base.method(self, ...) - a dot and an explicit self. Writing self:method(...) dispatches back into your own patch and loops forever.
Some steps exist only to be patched. getAttackIgnored and getDamageCap answer "nothing" in the base script, because nothing in Heroes 3 lowers the attack of whoever strikes it or caps the damage a blow may deal - the rules that do live in damage/enemyAttackReduction and damage/damageReceivedCap. Read those two for the shortest example of a patch, and damage/vulnerableFromBack for one that adds a factor.
Each patch keeps to one rule. That is what lets a mod drop or replace a single one of them without touching anything else, and while it is not required, it is worth following in mod patches too.
What the script is given
Script:calculate(battle, info) receives the battle and one table describing the attack:
attacker,defender- the two units. See UnitattackerHex,defenderHex- where the blow happens. Note that this position may differ from position reported by units - if this is estimation, and units are still at their old positions.shooting,luckyStrike,unluckyStrike,deathBlow,doubleDamage- what kind of blow this is. Random roll-based abilities are only set when actual calculation is performed by serverchargeDistance- hexes crossed to reach the target, which is what jousting scales withattackerBonuses,defenderBonuses- which of the declared bonus types each unit carries. Read them throughself:hasBonusOfType(info.attackerBonuses, "JOUSTING")attackFactorPerPoint,attackFactorCap,defenseFactorPerPoint,defenseFactorCap- the tuning constants fromgameConfig.json, so the script needs no access to settings
It answers with a table of three ranges:
damageBeforeDefense is what the blow would have been worth had the target no defences at all. Abilities that reflect a strike, such as fire shield, work from it - see damageBeforeDefense in combat event scripts.
Declaring what you look at
Reading a bonus means asking the engine, and the engine is on the other side of the language boundary. To keep that from happening twenty times per attack, the script declares which bonus types it looks at, and the engine reports which of them each unit actually carries:
A patch must declare whatever its factor looks at, or the check will not find it. Asking about a type that was never declared raises an error naming it, rather than quietly answering "not there" and costing damage.
Writing a factor that does not slow the game down
This script runs on every attack the game resolves and on every attack an AI considers - some two hundred thousand times per AI turn in a large battle. A factor that is careless about it is felt as the AI thinking longer, not as a dropped frame, so it is worth knowing which lines are cheap and which are not.
Reading info is free. Calling into the engine is not. Anything reached through a : on a unit, a bonus or the battle crosses into the engine and back. Reading a field of info, or of the two bonus tables, is a plain table lookup.
Check the bonus table before you ask anything. This is the single most useful habit: most units carry none of what a given factor looks for, and the table answers that without leaving the script.
hasBonusOfType reads the same table you could read yourself - info.attackerBonuses.JOUSTING does the same job - but it also complains when the type was never declared, instead of quietly answering "not there".
Four helpers do the check and the query in one step, so a factor rarely needs to write both:
| function | description |
|---|---|
self:hasBonusOfType(present, type) |
whether the unit carries it at all |
self:getBonusValueOfType(unit, present, type) |
what every bonus of that type is worth together |
self:getBonusValueOfSubtype(unit, present, type, subtype) |
the same, narrowed to one subtype |
self:getBonusValueOfTypeAndRange(unit, present, type, shooting) |
the same, counting only what applies to this kind of blow |
Each answers 0 without asking the engine when the snapshot says the type is absent, which is the usual case. present is info.attackerBonuses or info.defenderBonuses, whichever unit is being asked about.
Put the cheapest test first. Conditions are evaluated left to right, so order them by what they cost:
Ask for a value rather than a list. getBonusesValue returns what the matching bonuses are worth together, computed by the engine - one crossing. Fetching the list and adding up getVal() yourself crosses once for the list and once more for every bonus in it, and it also gets the answer wrong when bonuses do not simply add up (percentages, independent floors and ceilings).
Ask whether rather than which, when the answer is all you need. hasBonuses says yes or no without the list ever being built for the script:
Say as much as you can in the filter. Type, subtype, source and the kind of blow are all matched by the engine, and a query the engine can describe is also a query it can cache. Only what the filter cannot express - "from anything except a spell" - belongs in a filter afterwards:
shooting leaves out what does not count for this blow. A bonus limited to melee is absent from a shot and the other way round, and one limited to neither always counts. Pass the flag of the attack straight through rather than reading getEffectRange yourself:
It asks for the kind of blow rather than for an effect range, because "counts in melee" is two effect ranges at once - and asking for them one at a time would add the two answers up instead of combining them the way the engine does.
Do not build tables you do not need. A factor that returns 0 for most attacks should return it before creating anything.
Everything else - arithmetic, comparisons, local variables - costs nothing worth thinking about. Write the calculation plainly; it is the questions asked of the engine that add up.