⚓ Seaworth Games

lib/bonuses

Bonus System

Every stat, resistance, and immunity a hero or creature has lives on one node in a graph — but it's two graphs sharing the same nodes: a slow, lazy one for the everyday case, and a fast, eager one for when a bonus has to jump sideways.

Two DAGs, one node set

Calling attachTo() wires a node into both graphs at once. They diverge in what they mean and when they act.

Every CBonusSystemNode keeps two separate parent lists: parentsToInherit (who it pulls bonuses from, on every query — the black DAG) and parentsToPropagate (who it may push its own bonuses ontoattachTo(), which sets up both at once: a black edge for the everyday "I inherit your stats," and a red edge in case one of your bonuses is ever tagged with a propagator that targets me. Most bonuses never use that red edge at all; it just sits there available.

The two mechanisms don't just differ in direction, they differ in when they run. Inheritance is lazy: nothing happens at attach time beyond registering the edge; a hero's stats aren't computed until something calls getAllBonuses(), which walks parentsToInherit recursively (getAllBonusesRec()) right then. Propagation is eager: the instant a bonus with a propagator is added, propagateBonus() walks the red ancestors and copies the bonus onto every matching node immediately, whether or not anyone ever asks for it. Removing the bonus, or detaching the node, runs the same walk in reverse (unpropagateBonus(), removedRedDescendant()) — propagation is the one part of this system that has to do real bookkeeping on every structural change; inheritance, computed fresh on demand, never does.

Magi stack bonus: propagator BATTLE_WIDE BattleInfo a CBonusSystemNode itself, type BATTLE_WIDE Controlling hero attached via attachTo() propagateBonus() eager — runs the instant the bonus is added getAllBonusesRec() lazy — walked on the hero's next query Two hops, two different mechanisms: a sideways push the ordinary army hierarchy can't reach, then an ordinary pull for the last leg.
Magi and Archmagi cut their hero's spell costs on the battlefield — but a battlefield stack isn't an ancestor of its own hero in the normal army hierarchy, so ordinary inheritance can't carry the bonus there. BattleInfo is itself constructed as a CBonusSystemNode of type BATTLE_WIDE, and every army attaches to it when battle starts. The Magi's bonus, tagged with the BATTLE_WIDE propagator, gets copied onto that shared node the moment it's added; the hero then inherits it from there like any other ancestor.

Querying: the cache stack

Nothing about walking a DAG on every stat check is fast by default. Three layers of caching make it fast anyway.

Layer 1, per node. Each node keeps cachedBonuses — every bonus it can see, limiters already applied, from its last full recompute — alongside a stamp, cachedLast. A per-node atomic nodeChanged counter is what that stamp is checked against; if they match, getAllBonuses() reads the cache under a shared lock and returns. If they don't, it recomputes via getAllBonusesRec(), re-applies limiters, and re-stamps.

Layer 2, per query shape. Callers that pass a cachingStr (a string built from the selector, e.g. "type_123_subtype_4") get a second lookup in cachedRequests, so two different selectors on the same unchanged node don't both pay for a full limiter pass.

Invalidation is one process-wide atomic counter, globalCounter, bumped on any structural change (attach, detach, bonus added or removed) and pushed down through invalidateChildrenNodes() — but only through that node's own descendants, and only as far as it needs to: the recursion checks if (nodeChanged == changeCounter) return; before descending, so a node already stamped with the current change (reachable by more than one path in a DAG, which is the whole reason it's a DAG and not a tree) is never walked twice.

Layer 3, above all of it. BonusCache.h defines call-site caches that don't live on the node at all — BonusValueCache, and UnitBonusValuesProxy, which pins down roughly sixteen of combat's hottest per-unit queries (melee/ranged attack and defense, min/max damage, HYPNOTIZED, FORGETFULL, free shooting…) as fixed array slots, each stamped against getTreeVersion() — the same nodeChanged counter, exposed as a public accessor. It's a cache of the cache: the exact hasBonusOfType() / valOfBonuses() calls this atlas's Battle System doc cited on CUnitState sit underneath this proxy for the handful of values combat asks about every single hex-reachability and damage calculation.

Three knobs on one bonus

A bonus can carry a limiter, a propagator, and an updater at once — each answers a different question.

Limiters

restrict — who along the black DAG actually receives this bonus

ILimiter::limit() returns one of four verdicts (ACCEPT / DISCARD / NOT_SURE / NOT_APPLICABLE), not a bare bool. A bonus's limiter list is ANDed — every one must accept — which is a real trap: a list of several CCreatureTypeLimiters (one per creature) rejects every creature, since no single stack matches all of them at once. The fix is one bonus per creature, or the explicit AnyOfLimiter — VCMI ships AllOfLimiter / AnyOfLimiter / NoneOfLimiter as composite limiters precisely for when OR logic is what's actually wanted.

Propagators

redirect — where a bonus jumps to, outside the normal hierarchy

Only one concrete class exists, CPropagatorNodeType, parameterized by a target BonusNodeType. Named instances live in bonusPropagatorMap: BATTLE_WIDE, HERO, TEAM, ARMY, PLAYER, TOWN_AND_VISITOR, GLOBAL_EFFECT — one propagator shape, seven destinations.

Updaters

rescale — the bonus's value changes as it's inherited, based on the node passing through it

TimesHeroLevelUpdater, TimesStackSizeUpdater, TimesArmySizeUpdater, GrowsWithLevelUpdater — and CompositeUpdater to stack more than one. Applied in getUpdatedBonus(), after inheritance, before the value is handed back.

A worked example

One config entry, all three knobs but propagation — a limiter and an updater on the same bonus.

"core:greaterGnollsFlail": {
  "bonuses": [{
    "type": "PRIMARY_SKILL", "subtype": "primSkill.attack", "val": 2,
    "limiters": [{ "type": "CREATURE_TYPE_LIMITER", "parameters": ["gnoll", true] }],
    "updater": "TIMES_HERO_LEVEL"
  }]
}

The base value is 2. CCreatureTypeLimiter restricts inheritance to gnoll stacks (and, per its second parameter, their upgrades) — every other creature in the army sees this bonus in the black DAG walk and discards it. TimesHeroLevelUpdater then rescales what's left by the wielding hero's level on the way through getUpdatedBonus(): a level-10 hero's gnolls get +20 attack, a level-3 hero's get +6, off the exact same artifact and the exact same stored bonus. No propagator is needed here — the artifact is already an ancestor of the army in the ordinary black DAG, so plain inheritance is enough; the flail only needed two of the three knobs.