Skip to content

Combat

Wave capacity, bonuses, defense and the wave solver that client.attack fills waves with.

combat.solver

The soldier half of the client's wave auto-fill.

Ported from AFillFlankStrategy.fillFlankWithSoldiers and StrongestDefenceCounterRatioConsideredFlankStrategy.pickSoldierStack: pick the unit whose stack best counters whichever of the defender's melee or ranged defense is the weaker share, place it, repeat until the flank is full.

Tools are not placed yet, so a wave built here carries units only.

Classes:

Name Description
FillOptions

Which flanks to fill and which units may be used.

Inventory

A mutable pool of units to draw a wave from.

FilledAttack

A complete attack: its waves and its courtyard wave.

Functions:

Name Description
pick_soldier_stack

Choose one stack for a flank slot and take it out of the inventory.

fill_flank_with_soldiers

Fill one flank's unit slots.

fill_wave

Build one wave, flank by flank.

fill_yard_wave

Fill the courtyard wave, which goes out in cra's RW field.

fill_waves

Fill every wave the attack may carry, front to back.

wave_limit_violations

Which of an attack's containers hold more than they may.

logger module-attribute

logger = logging.getLogger(__name__)

FillOptions pydantic-model

Bases: EmpireModel

Which flanks to fill and which units may be used.

Every filter defaults to allowed, matching the client's AutoFillOptions.createNewUnitFilter. Turning one off excludes units that carry that cost: allow_ruby_cost=False keeps units whose healing costs rubies out of the wave, for instance.

Fields:

model_config class-attribute

model_config: ConfigDict = ConfigDict(extra='forbid')

fill_left pydantic-field

fill_left: bool = True

fill_middle pydantic-field

fill_middle: bool = True

fill_right pydantic-field

fill_right: bool = True

allow_coin_cost pydantic-field

allow_coin_cost: bool = True

allow_ruby_cost pydantic-field

allow_ruby_cost: bool = True

allow_mead pydantic-field

allow_mead: bool = True

allow_beef pydantic-field

allow_beef: bool = True

use_melee pydantic-field

use_melee: bool = True

use_ranged pydantic-field

use_ranged: bool = True

Inventory

Inventory(stacks: Mapping[int, int] | Iterable[tuple[int, int]])

A mutable pool of units to draw a wave from.

The solver deducts what it places, so one inventory can fill several waves in sequence without double-spending a unit.

Methods:

Name Description
available
soldier_count

How many actual units are left, ignoring tools and boost items.

deduct

Take up to limit of a unit out of the pool.

Attributes:

Name Type Description
counts dict[int, int]

counts instance-attribute

counts: dict[int, int] = {wod_id: count for wod_id, count in dict(stacks).items() if count > 0}

available

available(wod_id: int) -> int

soldier_count

soldier_count(game_data: GameData) -> int

How many actual units are left, ignoring tools and boost items.

deduct

deduct(wod_id: int, limit: int) -> int

Take up to limit of a unit out of the pool.

Returns:

Type Description
int

How many were actually taken

FilledAttack pydantic-model

Bases: EmpireModel

A complete attack: its waves and its courtyard wave.

waves goes in the A field of a cra and yard in RW.

Fields:

model_config class-attribute

model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True)

waves pydantic-field

waves: list[AttackWave]

yard pydantic-field

yard: WodAmountSlots = ()

The courtyard wave's unit slots, for send_attack's yard_wave

min_soldiers pydantic-field

min_soldiers: int = 0

The fewest units the waves must carry together

unread pydantic-field

What fill_attack filled without, each with the server's refusal; its error names the GGEError

unit_count

unit_count() -> int

Units committed across every wave and the courtyard.

wave_unit_count

wave_unit_count() -> int

Units across the waves alone, which is what the minimum counts.

pick_soldier_stack

pick_soldier_stack(free_items: int, inventory: Inventory, game_data: GameData, *, attacker: AttackerFlankEffects | None = None, defender: DefenderFlankEffects | None = None, options: FillOptions | None = None, unit_attack_bonuses: Mapping[int, float] | None = None) -> tuple[int, int] | None

Choose one stack for a flank slot and take it out of the inventory.

The choice weighs the best melee stack against the best ranged stack by the defender's opposite defense share, so the attack lands where the defense is thinner. With no defender information both shares are 1.0 and the stronger stack simply wins.

Parameters:

Name Type Description Default
free_items int

Remaining troop capacity of the flank

required
inventory Inventory

Pool to draw from; the chosen stack is deducted

required
game_data GameData

Loaded unit stats

required
attacker AttackerFlankEffects | None

Attacker multipliers (unbuffed when omitted)

None
defender DefenderFlankEffects | None

The defender's strength on this flank, if known

None
options FillOptions | None

Unit filters

None
unit_attack_bonuses Mapping[int, float] | None

Per-unit attack bonuses from active global effects, from global_unit_attack_bonuses

None

Returns:

Type Description
tuple[int, int] | None

(wod_id, count) placed, or None when nothing is eligible

fill_flank_with_soldiers

fill_flank_with_soldiers(capacity: int, slots: int, inventory: Inventory, game_data: GameData, *, attacker: AttackerFlankEffects | None = None, defender: DefenderFlankEffects | None = None, options: FillOptions | None = None, unit_attack_bonuses: Mapping[int, float] | None = None) -> list[tuple[int, int]]

Fill one flank's unit slots.

Stops early when a slot cannot be filled, as the client does, so a partly filled flank is a normal result rather than an error.

Parameters:

Name Type Description Default
capacity int

The flank's troop capacity

required
slots int

How many unlocked unit slots the flank has

required
inventory Inventory

Pool to draw from; placed stacks are deducted

required
game_data GameData

Loaded unit stats

required
attacker AttackerFlankEffects | None

Attacker multipliers

None
defender DefenderFlankEffects | None

The defender's strength on this flank, if known

None
options FillOptions | None

Unit filters

None
unit_attack_bonuses Mapping[int, float] | None

Per-unit attack buffs from active global effects

None

Returns:

Type Description
list[tuple[int, int]]

(wod_id, count) per filled slot

fill_wave

fill_wave(inventory: Inventory, game_data: GameData, capacity: WaveCapacity, *, attacker: AttackerFlankEffects | None = None, defense: Mapping[Flank, DefenderFlankEffects] | None = None, options: FillOptions | None = None, unit_attack_bonuses: Mapping[int, float] | None = None, strategies: Sequence[ToolStrategy] | None = None, area_type: int | None = None, space_id: int | None = None, target_is_player: bool = True, active_raid_boss_id: int | None = None) -> AttackWave

Build one wave, flank by flank.

Follows the client's order: each flank takes tools first, then soldiers, then check_flank returns the tools if no unit ended up on that flank. Tools come first because a placed tool changes the defense the soldiers are then picked against.

Parameters:

Name Type Description Default
inventory Inventory

Pool to draw from; placed stacks are deducted

required
game_data GameData

Loaded unit and tool stats

required
capacity WaveCapacity

The wave's size, from WaveCapacity.for_level

required
attacker AttackerFlankEffects | None

Attacker multipliers

None
defense Mapping[Flank, DefenderFlankEffects] | None

Defender strength per flank, if known

None
options FillOptions | None

Which flanks to fill and which units to allow

None
unit_attack_bonuses Mapping[int, float] | None

Per-unit attack buffs from active global effects

None
strategies Sequence[ToolStrategy] | None

The tool strategy pool; the client's five when omitted. Pass an empty list for a unit-only wave.

None
area_type int | None

The target's area type, which scopes a tool's effects

None
space_id int | None

The kingdom the target sits in, which some tools are limited to

None
target_is_player bool

Whether the target belongs to another player

True
active_raid_boss_id int | None

The boss of the alliance raid-boss event running now, None when none is. A tool whose effects are all tied to other raid bosses is not carried

None

Returns:

Type Description
AttackWave

An AttackWave ready for send_attack. Each container holds

AttackWave

one WodAmount per slot, locked slots included, with

AttackWave

EMPTY_SLOT ([-1, 0] on the wire) for an empty one, as the client sends it.

Client: CastleAttackWaveVO.getWaveInfoObject (bundle line 99930) and CastleFightItemContainer.getSlotList (bundle line 20573), which lists every slot of the container's CombatConst.ITEMS_* table (dll lines 18864-18876), unlocked ones first

fill_yard_wave

fill_yard_wave(inventory: Inventory, game_data: GameData, capacity: int, *, slots: int = YARD_SLOTS, defender: DefenderFlankEffects | None = None, options: FillOptions | None = None, unit_attack_bonuses: Mapping[int, float] | None = None) -> tuple[WodAmount, ...]

Fill the courtyard wave, which goes out in cra's RW field.

AFillWaveStrategy.fillYardContainer fills it with soldiers only - no tools - and passes no attacker effects, so units are scored on their raw values against the yard's own defenders.

Parameters:

Name Type Description Default
inventory Inventory

Pool to draw from

required
game_data GameData

Loaded unit stats

required
capacity int

The yard's capacity, from yard_capacity

required
slots int

How many unit slots the yard offers; all eight are open from level 0

YARD_SLOTS
defender DefenderFlankEffects | None

The yard's defenders, if known

None
options FillOptions | None

Unit filters

None
unit_attack_bonuses Mapping[int, float] | None

Per-unit attack buffs from active global effects

None

Returns:

Type Description
WodAmount

One WodAmount per slot, in slot order, for the RW field.

...

The client sends every slot, so an empty one goes out as [-1, 0] (EMPTY_SLOT).

fill_waves

fill_waves(inventory: Inventory, game_data: GameData, *, level: int, attacker_level: int | None = None, conquer: bool = False, wave_bonus: int = 0, flank_bonus_percent: float = 0.0, front_bonus_percent: float = 0.0, tool_bonus: float = 0.0, attacker: AttackerFlankEffects | None = None, defense: Mapping[Flank, DefenderFlankEffects] | None = None, options: FillOptions | None = None, unit_attack_bonuses: Mapping[int, float] | None = None, area_type: int | None = None, space_id: int | None = None, target_is_player: bool = True, active_raid_boss_id: int | None = None) -> list[AttackWave]

Fill every wave the attack may carry, front to back.

Sizes itself the way the client does: each flank's capacity and slots follow the level of whoever owns the target, while the number of waves follows the attacker's own level. Waves that come out empty are dropped, so the result is ready for send_attack.

Parameters:

Name Type Description Default
inventory Inventory

Pool to draw from; shared across every wave

required
game_data GameData

Loaded unit stats

required
level int

The target owner's level, which sizes each flank

required
attacker_level int | None

The attacker's own level, which decides how many waves an attack carries; defaults to the target's level

None
conquer bool

A conquest attack carries extra waves

False
wave_bonus int

Extra waves from the ADDITIONAL_WAVE legend skill

0
flank_bonus_percent float

Percentage bonus to units on each side flank

0.0
front_bonus_percent float

Percentage bonus to units in the middle

0.0
tool_bonus float

Extra flank tool capacity

0.0
attacker AttackerFlankEffects | None

Attacker multipliers

None
defense Mapping[Flank, DefenderFlankEffects] | None

Defender strength per flank, if known

None
options FillOptions | None

Which flanks to fill and which units to allow

None
unit_attack_bonuses Mapping[int, float] | None

Per-unit attack buffs from active global effects

None
area_type int | None

The target's area type, which scopes a tool's effects

None
space_id int | None

The kingdom the target sits in, which some tools are limited to

None
target_is_player bool

Whether the target belongs to another player

True
active_raid_boss_id int | None

The boss of the alliance raid-boss event running now, None when none is. A tool whose effects are all tied to other raid bosses is not carried

None

Returns:

Type Description
list[AttackWave]

One AttackWave per filled wave, in send order

wave_limit_violations

wave_limit_violations(waves: Sequence[AttackWave], capacity: WaveCapacity, *, yard: Sequence[WodAmount] | None = None, yard_capacity: int | None = None) -> list[str]

Which of an attack's containers hold more than they may.

The client refuses to send at all when any wave's unit container or the courtyard is over capacity - exceedsUnitLimit() and exceedsLimit(), both of which are freeItems < 0. It shows a blocking dialog instead. A hand-built army can trip this; the solver cannot.

Parameters:

Name Type Description Default
waves Sequence[AttackWave]

The waves about to be sent

required
capacity WaveCapacity

The capacities those waves were sized against

required
yard Sequence[WodAmount] | None

The courtyard wave, if one is being sent

None
yard_capacity int | None

Its capacity, from yard_capacity

None

Returns:

Type Description
list[str]

One readable line per overfull container, empty when the attack is legal

combat.bonuses

Resolving a commander's bonuses into the multipliers combat uses.

A bonus on an item names an effect; the effect names an effect type, which is what a formula reads, and a cap, which is what it stacks within. This module does that resolution and the client's two-stage capping, so the combat maths can ask for "the melee attack multiplier" and get a number.

See docs/design/combat_effects.md for the full catalogue.

Classes:

Name Description
Bonus

One granted bonus: an id, its strength, and which id space the id is in.

EffectResolver

Turns bonuses into combat multipliers using the game data tables.

Functions:

Name Description
parse_bonus_entries

Parse the bonus encodings the server uses.

effect_value_bonuses

The bonuses of an items row's effects: one per entry with a value.

construction_item_bonuses

Bonuses from the construction items placed on a castle's buildings.

alliance_buff_bonuses

Bonuses from the alliance's researched buffs, at their current levels.

global_effect_bonuses

Bonuses from the global event effects currently running.

sceat_skill_bonuses

Bonuses from unlocked sceat skills (the Hall of Legends trees).

general_skill_bonuses

Bonuses from the skills unlocked on the general leading the attack.

general_passive_bonuses

The general's passive effects, which join its commander's bonuses.

legend_skill_value

Total value of one legend skill effect type.

global_unit_attack_bonuses

Per-unit attack bonuses from the global effects currently running.

commander_bonuses

Every bonus a commander grants, resolved into the right id space.

equipment_set_bonuses

The bonuses of the equipment sets the worn items and their gems complete.

gem_bonuses

The bonuses of the gem slotted in an item.

effect_bonuses

The bonuses of a list of commander effects, such as a gli E or an aci AE.

attack_dialog_bonuses

The one bonus list the attack dialog reads its commander's effects from.

tool_effect_strength

The summed strength of a tool's own effects of one type, with no condition.

attacker_flank_effects

Build a flank's attacker multipliers from resolved bonuses.

support_tool_waves

Extra waves the support tools grant.

logger module-attribute

logger = logging.getLogger(__name__)

KEYED_EFFECT_TYPES module-attribute

KEYED_EFFECT_TYPES = frozenset({CombatEffectType.DEFENSE_SUPPORT_UNITS, CombatEffectType.ATTACK_SUPPORT_UNITS, CombatEffectType.RECRUITMENT_COST_DECREASE, CombatEffectType.RECRUITMENT_SPEED_BOOST, CombatEffectType.TOOL_PRODUCTION_SPEED_BOOST, CombatEffectType.UNIT_SPEED_BONUS, CombatEffectType.ATTACK_BONUS_UNIT, CombatEffectType.SPEED_BOOST_UNIT, CombatEffectType.LOOT_VALUE_BOOST_UNIT, CombatEffectType.FAME_BOOST_UNIT, CombatEffectType.CURRENCY_LOOT_BOOST, CombatEffectType.CRAFTING_QUEUE_PRODUCTION_BOOST, CombatEffectType.RESERVE_UNIT_KILL, CombatEffectType.SPAWN_RESERVE_UNIT, CombatEffectType.MUTATE_RESERVE_UNIT, CombatEffectType.ABILITY_PLUNDER})

UNCAPPED_EQUIPMENT_CAP_ID module-attribute

UNCAPPED_EQUIPMENT_CAP_ID = -1

EquipmentBonusVO.capID of a bonus that overrides its cap (bundle line 20962).

Bonus pydantic-model

Bases: EmpireModel

One granted bonus: an id, its strength, and which id space the id is in.

A relic item's bonus ids index the relic effect table, and any other item's the equipment effect table, rather than the plain effect table. The tables overlap and disagree - id 4 is an economy effect in one and a gate reduction in another - so the space has to travel with the bonus.

Fields:

model_config class-attribute

model_config: ConfigDict = ConfigDict(extra='forbid')

effect_id pydantic-field

effect_id: int

value pydantic-field

value: float

via_relic pydantic-field

via_relic: bool = False

via_equipment pydantic-field

via_equipment: bool = False

The id is an equipment effect id, as on an item that is not a relic.

raw_values pydantic-field

raw_values: tuple[float, ...] = ()

The value array as sent, for the effect types that carry a keyed map.

strength

strength(effect_type_id: int | None) -> float

What this bonus is worth for an effect of this type.

A keyed effect's array is [wod_id, value, ...], so its first number is an id. EffectValueMap.strength returns the first value and EffectValueWodID.strength the value of the first key - the same number - so both read index 1.

EffectResolver

EffectResolver(game_data: GameData)

Turns bonuses into combat multipliers using the game data tables.

Capping follows the client: bonuses are grouped by their effect's cap, each group is summed up to that cap's ceiling, and the capped group totals are then added together with no further ceiling. So a cap limits what stacks within it, never the effect type as a whole. An equipment bonus whose equipment effect row sets ignoreCap goes into a group of its own with no ceiling.

Client: CastleEffectsHelper.getTotalEffectValue (bundle line 4139), EquipmentBonusVO.capID and maxValueStrength (bundle line 20959).

Methods:

Name Description
accumulate

Total strength of one effect type across the given bonuses.

effect_for

The effect a bonus grants, resolved in the bonus's own id space.

overrides_cap

Whether an equipment bonus escapes its effect's cap.

attack_multiplier

The attacker's melee or ranged multiplier for a flank.

flank_unit_bonus

Percentage bonus to units on a side flank.

front_unit_bonus

Percentage bonus to units in the middle.

yard_capacity_bonus

Absolute unit bonus to the courtyard wave.

yard_capacity_boost

Percentage boost to the courtyard wave, applied as a multiplier.

fortification_reductions

Wall, gate and moat reductions as fractions.

Attributes:

Name Type Description
game_data

game_data instance-attribute

game_data = game_data

accumulate

accumulate(bonuses: Iterable[Bonus], effect_type: CombatEffectType | int, *, area_type: int | None = None, player_target: bool | None = None, space_id: int | None = None, relation: str | None = None, raid_boss_id: int | None = None, include_economy: bool = False, ignore_cap: bool = False) -> float

Total strength of one effect type across the given bonuses.

Parameters:

Name Type Description Default
bonuses Iterable[Bonus]

The bonuses a commander (or other source) grants

required
effect_type CombatEffectType | int

Which effect type to total, see CombatEffectType

required
area_type int | None

The target's area type; effects scoped to other areas are dropped, and None keeps all of them

None
player_target bool | None

True for a fight against a player, False for an NPC; None keeps effects flagged for either

None
space_id int | None

The castle space, for effects limited to one

None
relation str | None

Relationship to the target - sameAlliance, allianceInWar or samePlayer

None
raid_boss_id int | None

The raid boss being fought, for boss-scoped effects

None
include_economy bool

Keep economy effects, which combat normally drops

False
ignore_cap bool

Skip capping entirely

False

Returns:

Type Description
float

The summed strength, 0.0 when nothing applies

effect_for

effect_for(bonus: Bonus) -> EffectDef | None

The effect a bonus grants, resolved in the bonus's own id space.

An equipment effect id names the effect in its row's effectID; an id with no row is taken as a plain effect id.

Client: EquipmentXml.getEquippableEffectByEquipmentEffect (bundle line 144136).

overrides_cap

overrides_cap(bonus: Bonus) -> bool

Whether an equipment bonus escapes its effect's cap.

Client: EquipmentBonusVO.parseBasic (bundle line 20942) reads the equipment effect row's ignoreCap.

attack_multiplier

attack_multiplier(bonuses: Iterable[Bonus], *, melee: bool, area_type: int | None = None, player_target: bool | None = None) -> float

The attacker's melee or ranged multiplier for a flank.

Mirrors getFullAttackBonusForLordByFlankAndAreaType: the general attack bonus plus the matching side's plain and offensive bonuses, as a percentage, on top of a base of 1.0.

flank_unit_bonus

flank_unit_bonus(bonuses: Iterable[Bonus], *, area_type: int | None = None, player_target: bool | None = None) -> float

Percentage bonus to units on a side flank.

front_unit_bonus

front_unit_bonus(bonuses: Iterable[Bonus], *, area_type: int | None = None, player_target: bool | None = None) -> float

Percentage bonus to units in the middle.

yard_capacity_bonus

yard_capacity_bonus(bonuses: Iterable[Bonus], *, area_type: int | None = None, player_target: bool | None = None) -> float

Absolute unit bonus to the courtyard wave.

yard_capacity_boost

yard_capacity_boost(bonuses: Iterable[Bonus], *, area_type: int | None = None, player_target: bool | None = None) -> float

Percentage boost to the courtyard wave, applied as a multiplier.

fortification_reductions

fortification_reductions(bonuses: Iterable[Bonus], *, area_type: int | None = None, player_target: bool | None = None) -> tuple[float, float, float]

Wall, gate and moat reductions as fractions.

The client divides each by 100 before subtracting them from the defender's matching bonus.

parse_bonus_entries

parse_bonus_entries(entries: Iterable[Any], *, via_relic: bool = False, via_equipment: bool = False) -> list[Bonus]

Parse the bonus encodings the server uses.

Three shapes occur and all are handled: [effect_id, value] or [effect_id, [value]] as on an item that is not a relic, [effect_id, [value], source_tag] as sent for a commander's own effects, and [relic_effect_id, power, [value]] as on a relic item. The id is always first; the strength is the first number found after it, preferring a nested list, which is where the value sits when one is present.

Unparseable entries are skipped rather than failing the batch.

Client: BasicEquipmentVO.parseBonuses (bundle line 7135), RelicBonusVO.parseRelicFromValueArray (bundle line 45132), LordVO.parseRawEffects (bundle line 26483).

Parameters:

Name Type Description Default
entries Iterable[Any]

Raw bonus entries

required
via_relic bool

The ids index the relic effect table, as they do for the bonuses inside a relic equipment item

False
via_equipment bool

The ids index the equipment effect table, as they do for the bonuses inside any other equipment item

False

effect_value_bonuses

effect_value_bonuses(effects: Iterable[EffectValue | EquipmentEffectValue]) -> list[Bonus]

The bonuses of an items row's effects: one per entry with a value.

A single number is the bonus's value. Anything else is a keyed value, wod_id+value pairs joined by #, which the bonus keeps flattened as EffectValueMap.parseFromValueString reads it (bundle line 31617): a part without a +, or whose value is no number, adds a 0; a part whose id is no number is left out. An equipment effect id is marked via_equipment.

construction_item_bonuses

construction_item_bonuses(game_data: GameData, item_ids: Iterable[int]) -> list[Bonus]

Bonuses from the construction items placed on a castle's buildings.

These are the decorations players call look items, and they carry real combat bonuses - the flank unit limit item is where a +30% flank bonus comes from.

alliance_buff_bonuses

alliance_buff_bonuses(game_data: GameData, buff_ids: Iterable[int]) -> list[Bonus]

Bonuses from the alliance's researched buffs, at their current levels.

global_effect_bonuses

global_effect_bonuses(game_data: GameData, global_effect_ids: Iterable[int]) -> list[Bonus]

Bonuses from the global event effects currently running.

sceat_skill_bonuses

sceat_skill_bonuses(game_data: GameData, skill_ids: Iterable[int]) -> list[Bonus]

Bonuses from unlocked sceat skills (the Hall of Legends trees).

general_skill_bonuses

general_skill_bonuses(game_data: GameData, skill_ids: Iterable[int]) -> list[Bonus]

Bonuses from the skills unlocked on the general leading the attack.

general_passive_bonuses

general_passive_bonuses(game_data: GameData, skill_ids: Iterable[int]) -> list[Bonus]

The general's passive effects, which join its commander's bonuses.

Client: GeneralVO.getPassiveEffects / getPassiveSkills (bundle line 26781): the boni of every unlocked skill that is not an ability skill, one with an EFFECT_TYPE_UNLOCK_ABILITY effect (GeneralSkillVO.isAbilitySkill, bundle line 113404). Skill effects parse as effectID&value (GeneralSkillVO.parseXML, bundle line 113364).

legend_skill_value

legend_skill_value(game_data: GameData, skill_ids: Iterable[int], effect_type: str) -> float

Total value of one legend skill effect type.

Legend skills sit outside the effect and cap pipeline: they name an effect type directly and the client sums their values as plain numbers, so they are returned as a number rather than as bonuses.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables

required
skill_ids Iterable[int]

The player's unlocked legend skill ids

required
effect_type str

The effect type name, e.g. gateReduction

required

Returns:

Type Description
float

The summed value, 0.0 when none match

global_unit_attack_bonuses

global_unit_attack_bonuses(game_data: GameData, global_effects: Iterable[int | Sequence[int]], *, player_level: int | None = None, boosts: Mapping[GlobalEffect | int, float] | None = None) -> dict[int, float]

Per-unit attack bonuses from the global effects currently running.

This is the only thing that buffs a unit's attack value. The client reads it in SoldierUnitVO.buffedMeleeAttack as rawAttack + int(globalEffectData.getBonusByEffectType(ATTACK_BONUS_UNIT, -1, -1, wodId)), and that getter looks only at the active global-effect event: a commander carrying the same effect type does not buff units through this path.

The rows encode a per-unit map, 273&<wod_id>+<strength>#<wod_id>+<strength>. No condition is applied: the client resolves an unset area or kingdom to the castle the player happens to be looking at, and the one effect row this path can reach restricts neither, so there is nothing to check. The strengths in the table are only a fallback: each running effect is a [id, seconds_left, strength] row in the GE of the global effects event's sei entry (Event.GLOBAL_EFFECT), and setEffectStrength writes a strength above -1 onto every unit in the map (GlobalEffectEventVO.parseParamObject, bundle lines 116399-116411). The state reads those rows into GlobalEffectEvent.effects.

A boosted effect - one bie lists - gets the booster event's boost on top (GlobalEffectData.parse_GIE, bundle lines 143676-143680): addBuffStrengthValue writes the effect's strength (its first unit's) plus the boost onto every unit of the map, as an int (GlobalEffectVO, bundle lines 143711-143718), and the effect's bonus is that buffed copy while bie lists it (bundle line 143712). Without a boost every unit keeps its own strength, where the client reads the first unit's for all (EffectValueMap.strength, bundle line 31649); live maps are uniform.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables

required
global_effects Iterable[int | Sequence[int]]

Which global effects are active - either plain ids, or the GE rows of the Event.GLOBAL_EFFECT event, where a strength above -1 replaces the table's

required
player_level int | None

The attacker's level, which some effects are bracketed to; without it the brackets are ignored

None
boosts Mapping[GlobalEffect | int, float] | None

What the booster adds to each boosted effect: the effects bie lists, each with its GlobalEffectBuffEvent.boost_value

None

Returns:

Type Description
dict[int, float]

{wod_id: bonus}, empty when no listed effect is active

commander_bonuses

commander_bonuses(game_data: GameData, commander: Commander, *, area_effects: Sequence[Bonus] | None = None) -> list[Bonus]

Every bonus a commander grants, resolved into the right id space.

Five sources from the gli payload: the commander's own effects (E), its area effects (AE), the bonus list inside each equipped item, the gem slotted in each item (see gem_bonuses), and the alien equipment of AIE/TAE when EQ is empty, whose rows the client builds as EquipmentBonusVO (AlienLordEquipmentVO and AlienLordHeroVO.parseAlienBoniData, bundle lines 67479 and 67502) in the commander's equipment slots. A relic item's bonuses are tagged so they resolve through the relic effect table, and any other item's so they resolve through the equipment effect table.

The bonuses come in the client's order, equipment slots first and then E and AE, which matters once a capped total mixes signs. The gems of alien equipment (GEM) count while AIE/TAE stand in for EQ.

The bonuses of the equipment sets worn (see equipment_set_bonuses) come last, after AE, as the client adds them.

A default commander (commander_id below 0, such as -14, the bought premium commander, or -15, a robber baron attack's) has no slots and no sets: its bonuses are its lords row's effects (see GameData.get_default_lord), which name equipment effect ids, then E and AE. Without a row the client builds no commander, so only area_effects count. Client: LordFactory.createLord (bundle lines 26399-26401) returns CastleLordData.getDefaultLordByID for an id below 0; DefaultLordVO.parseFromXml (bundle lines 101999-102002) reads the row and DefaultLordVO.getUniqueBoni (bundle lines 102005-102011) returns its effects, E and AE, with getCountOfSetId always 0 (bundle line 102012).

A movement's commander resolves the same way. The client builds it from the UM L block with LordFactory.createLord(e.L, true) (BasicMapmovementVO.parseUnitMovement, bundle line 19385), the factory of the gli roster too, and shows its effects through the same LordVO.getUniqueBoni (CastleArmyListDialog.updateLord, bundle line 95640, into LordEffectTooltip.createContent, bundle line 65364) that the attack dialog reads a roster commander's from (CastleEffectsHelper, bundle line 4130). The movement tooltip leaves the general's passive effects out and the attack dialog adds them (see attack_dialog_bonuses); a movement's commander carries its general's general_skill_ids for that. Legend and sceat skills belong to the attacking player, not the commander, and a movement does not carry them.

A movement's commander comes with the area effects (AE) of the castle it was sent from, so it matches a roster commander resolved with that castle's aci AE as area_effects, not its bare gli entry.

Client: LordVO.getUniqueBoni (bundle line 26496). The attack dialog replaces the commander's area effects with the aci AE list (AttackDialogController.updateAreEffects, bundle line 4332), but the areaEffects setter ignores an empty list (bundle line 26652), so the commander keeps its own gli AE when aci sends none.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables, for the gems and equipment sets

required
commander Commander

The commander's gli entry, or a movement's commander

required
area_effects Sequence[Bonus] | None

The aci AE list, from GetAttackInfoResponse.attacker_bonuses(). When it has entries they are used instead of the commander's own AE

None

equipment_set_bonuses

equipment_set_bonuses(game_data: GameData, items: Iterable[Equipment]) -> list[Bonus]

The bonuses of the equipment sets the worn items and their gems complete.

Each item whose set_id is not -1 counts once toward its set, and so does each distinct gem slotted in an item whose gem row names a set_id above

  1. A relic item's relic gem counts toward no set. Every threshold row of a set (GameData.equipment_sets) whose needed_items the count reaches grants its bonuses, which name equipment effect ids.

Client: LordVO.setCounts (bundle line 26576) and LordVO.getUniqueBoni (bundle lines 26510-26518). BasicEquipmentVO.hasSetbonus (bundle line 7213) reads setID != -1; RelicGemVO keeps BasicEquippableVO's setID of 0 (bundle line 4820). The alien equipment of AIE/TAE is left out: the client counts it under set 0, which no set row uses.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables, for the gems and equipment sets

required
items Iterable[Equipment]

The items worn, see Commander.worn_items

required

gem_bonuses

gem_bonuses(game_data: GameData, item: Equipment) -> list[Bonus]

The bonuses of the gem slotted in an item.

A gem id at index 10 other than -1 names a gems row, whose effects are plain effectID&value bonuses; an id missing from the table grants nothing. A relic item that carries index 12 has its relic gem there instead, and that gem's bonuses resolve through the relic effect table.

Client: BasicEquipmentVO.parseEquipFromArray (bundle line 7116), RelicEquipmentVO.parseEquipFromArray (bundle line 25039), which replaces the index 10 gem with the index 12 one, CastleGemVO.parseXML (bundle line 28287) and LordVO.getUniqueBoni (bundle line 26496).

effect_bonuses

effect_bonuses(effects: Iterable[CommanderEffect]) -> list[Bonus]

The bonuses of a list of commander effects, such as a gli E or an aci AE.

attack_dialog_bonuses

attack_dialog_bonuses(game_data: GameData, commander: Commander | None, *, area_effects: Sequence[Bonus] | None = None, general_skill_ids: Iterable[int] | None = None) -> list[Bonus]

The one bonus list the attack dialog reads its commander's effects from.

Client: LordVO.getUniqueBoni with the assigned general included, as CastleEffectsHelper.getAccumulatedEquipmentBonusByEffectTypeForArea calls it (bundle line 4136): the commander's equipment, effects and area effects (see commander_bonuses) plus the general's passive effects. Sceat skills are not part of it.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables

required
commander Commander | None

The commander leading the attack. Without one, only area_effects and the general's skills are counted

required
area_effects Sequence[Bonus] | None

The aci AE list

None
general_skill_ids Iterable[int] | None

The unlocked skills of the commander's general

None

Returns:

Type Description
list[Bonus]

Every bonus, unmerged; EffectResolver.accumulate caps them

tool_effect_strength

tool_effect_strength(game_data: GameData, tool: ToolStats, effect_type: CombatEffectType | int) -> float

The summed strength of a tool's own effects of one type, with no condition.

Client: ToolUnitVO.getEffectValue with NULL_CONDITION (bundle line 6640), as getBonusByEffect reads it for a mapped tool effect type: area, space and wod id are all -1, so no effect is filtered out. The effects parse as effectID&value (ToolUnitVO.parseEffects, bundle line 6644).

attacker_flank_effects

attacker_flank_effects(resolver: 'EffectResolver', bonuses: Iterable[Bonus], *, area_type: int | None = None, player_target: bool | None = None, legend_skill_ids: Iterable[int] = (), legendary: bool = False, support_tools: Sequence[ToolStats] = ()) -> 'AttackerFlankEffects'

Build a flank's attacker multipliers from resolved bonuses.

Client: FightScreenHelper.getAttackerFlankEffectVO (bundle line 19179), in its order: the commander's reductions and multipliers, then the legend skills in a legendary fight, then each tool. The auto-fill starts from an empty wave, so the tools here are only the support tools, one of each AST entry. Tools the solver places later are added by AttackerFlankEffects.apply_tool.

Parameters:

Name Type Description Default
resolver 'EffectResolver'

Resolver over the loaded game data

required
bonuses Iterable[Bonus]

The commander's bonuses, see attack_dialog_bonuses

required
area_type int | None

The target's area type, for scoping, such as a movement's target_type; None or below 0 keeps every effect

None
player_target bool | None

True when attacking a player. The client passes no filter strategy here, which keeps PvP and PvE effects alike; that is what None does

None
legend_skill_ids Iterable[int]

The player's unlocked legend skills

()
legendary bool

AttackDialogHelper.isLegendaryFight, i.e. LegendaryFight.unit_amount. The legend skills count only then

False
support_tools Sequence[ToolStats]

The support tools picked for the attack

()

Returns:

Type Description
'AttackerFlankEffects'

The flank's attacker effects

support_tool_waves

support_tool_waves(game_data: GameData, support_tools: Sequence[ToolStats]) -> float

Extra waves the support tools grant.

Client: CastleFightItemContainer.getTotalBonusByToolEffect(ADDITIONAL_WAVE) (bundle line 20719) over the support container, one of each AST entry. ToolEffectType.ADDITIONAL_WAVE is absolute (bundle line 9615), so the effect's strength is taken as it is; it maps to effect type 156.

combat.capacity

How big a wave may be, ported from the client's CombatConst.

A wave's capacity is a function of one effective level: the attacker's own level, or the target's minimum defense level when that is higher. The client derives every number below from it, so a caller does not have to guess how many troops a flank holds.

Classes:

Name Description
LegendaryFight

Which legend skills an attack gets. The client checks three different rules.

WaveCapacity

The size of one wave at a given effective level.

Functions:

Name Description
owner_id_from_row

The target owner's player id, as the client's map object reads it.

is_npc_player

Client: PlayerHelper.isNPCPlayer (bundle line 4687).

is_npc_pvp_player

An NPC the game fights like a player: an alien invasion or a collector.

max_attackers

Total units a wave holds (CombatConst.getMaxAttackers).

min_soldiers

The fewest units an attack must carry at this level (CombatConst.getMinSoldiers).

min_attack_soldiers

The fewest units the waves of an attack on this target must carry together.

flank_soldier_capacity

Units one side flank holds (getAmountSoldiersFlank).

middle_soldier_capacity

Units the middle holds (getAmountSoldiersMiddle).

flank_tool_capacity

Tools one side flank holds (getTotalAmountTools for a flank).

middle_tool_capacity

Tools the middle holds (getTotalAmountTools for the middle).

boost_to_modifier

Turn a "boost" effect into a multiplier (EffectConst.boostToModifier).

minimum_owner_level

The level a target defends at, whoever owns it.

wave_level

The level a wave is actually sized by.

yard_capacity

Units the courtyard - the final assault - wave holds.

unlocked_slots

How many of a flank's slots exist at this level.

max_wave_count

How many waves an attack may carry (getMaxWaveCountWithBonus).

Attributes:

Name Type Description
WAVE_UNLOCK_LEVELS
CONQUER_ADDITIONAL_WAVES
UNIT_SLOT_LEVELS_FLANK
UNIT_SLOT_LEVELS_MIDDLE
TOOL_SLOT_LEVELS_FLANK
TOOL_SLOT_LEVELS_MIDDLE
TOOL_SLOT_TYPE_MIDDLE
TOOL_SLOT_TYPE_FLANK
LEVEL_CAP

PlayerConst.LEVEL_CAP (dll line 19599).

ALIEN_INVASION_PLAYER_IDS

PlayerHelper.isAlienInvasion (bundle line 4688).

COLLECTOR_PLAYER_IDS

The collector owners PlayerHelper.isCollectorPlayer (bundle line 4690)

LANDMARK_AREA_TYPES

MapObjectHelper.isLandmark (bundle line 38531).

ALIEN_INVASION_AREA_TYPES dict[int, int]

The area types built as an AAlienInvasionMapobjectVO (WorldmapObjectFactory,

OTHER_PLAYER_INFO_AREA_TYPES

Area types whose map object answers hasOtherPlayerInfo with true when

ROW_OWNER_AREA_TYPES

Area types whose map row carries the owner's player id at index 4:

AREA_TYPE_LEVEL_FLOORS dict[int, int]
LANDMARK_FLOOR_AREA_TYPES
YARD_SLOTS

Slots the courtyard wave offers. All eight are open from level 0.

MAX_ATTACKERS_ABOVE_69 module-attribute

MAX_ATTACKERS_ABOVE_69 = 320

WAVE_UNLOCK_LEVELS module-attribute

WAVE_UNLOCK_LEVELS = (0, 13, 26, 51)

CONQUER_ADDITIONAL_WAVES module-attribute

CONQUER_ADDITIONAL_WAVES = 2

UNIT_SLOT_LEVELS_FLANK module-attribute

UNIT_SLOT_LEVELS_FLANK = (0, 13)

UNIT_SLOT_LEVELS_MIDDLE module-attribute

UNIT_SLOT_LEVELS_MIDDLE = (0, 0, 13, 13, 26, 26)

TOOL_SLOT_LEVELS_FLANK module-attribute

TOOL_SLOT_LEVELS_FLANK = (0, 37)

TOOL_SLOT_LEVELS_MIDDLE module-attribute

TOOL_SLOT_LEVELS_MIDDLE = (0, 11, 37)

TOOL_SLOT_TYPE_MIDDLE module-attribute

TOOL_SLOT_TYPE_MIDDLE = 1

TOOL_SLOT_TYPE_FLANK module-attribute

TOOL_SLOT_TYPE_FLANK = 2

LEVEL_CAP module-attribute

LEVEL_CAP = 70

PlayerConst.LEVEL_CAP (dll line 19599).

ALIEN_INVASION_PLAYER_IDS module-attribute

ALIEN_INVASION_PLAYER_IDS = frozenset({NPCOwner.ALIEN_INVASION, NPCOwner.RED_ALIEN_INVASION})

PlayerHelper.isAlienInvasion (bundle line 4688).

COLLECTOR_PLAYER_IDS module-attribute

COLLECTOR_PLAYER_IDS = frozenset({NPCOwner.COLLECTOR_CARNIVAL, NPCOwner.COLLECTOR_CHRISTMAS, NPCOwner.COLLECTOR_CHRISTMAS2, NPCOwner.COLLECTOR_SUMMER, NPCOwner.COLLECTOR_10TH_ANNIVERSARY, NPCOwner.COLLECTOR_ELEMENTAL, NPCOwner.COLLECTOR_HALLOWEEN, NPCOwner.COLLECTOR_HALLOWEEN2, NPCOwner.COLLECTOR_SPRING})

The collector owners PlayerHelper.isCollectorPlayer (bundle line 4690) lists. The other five collectors are not in its switch.

LANDMARK_AREA_TYPES module-attribute

LANDMARK_AREA_TYPES = frozenset({int(MapItemType.METROPOL), int(MapItemType.CAPITAL), int(MapItemType.KINGS_TOWER), int(MapItemType.MONUMENT), int(MapItemType.LABORATORY)})

MapObjectHelper.isLandmark (bundle line 38531).

ALIEN_INVASION_AREA_TYPES module-attribute

ALIEN_INVASION_AREA_TYPES: dict[int, int] = {int(MapItemType.ALIEN_CAMP): int(NPCOwner.ALIEN_INVASION), int(MapItemType.RED_ALIEN_CAMP): int(NPCOwner.RED_ALIEN_INVASION)}

The area types built as an AAlienInvasionMapobjectVO (WorldmapObjectFactory, bundle line 5357; its only subclasses close at bundle lines 65057 and 76481), and the owner each one's constructor sets (_alienPlayerID, bundle lines 65049 and 76471). parseAreaInfo takes the owner from that id, not from the row (bundle line 41545).

OTHER_PLAYER_INFO_AREA_TYPES module-attribute

OTHER_PLAYER_INFO_AREA_TYPES = frozenset({int(MapItemType.CASTLE), int(MapItemType.OUTPOST), int(MapItemType.CAPITAL), int(MapItemType.METROPOL), int(MapItemType.VILLAGE), int(MapItemType.ISLE_RESOURCE), int(MapItemType.KINGS_TOWER), int(MapItemType.MONUMENT), int(MapItemType.LABORATORY), int(MapItemType.FACTION_CAMP), int(MapItemType.ALLIANCE_BATTLE_GROUND_TOWER)})

Area types whose map object answers hasOtherPlayerInfo with true when another player owns it: CastleMapobjectVO (bundle line 18933), OutpostMapobjectVO (18838) and its capital and metropolis subclasses (18760, 21637), VillageMapobjectVO (22675) and the resource isle (34659), KingstowerMapobjectVO (19077), UpgradableLandmarkMapobjectVO (42650) for monuments and laboratories (21675, 25920), FactionCampMapobjectVO (21547) and ABGAllianceTowerMapobjectVO (32307). The base InteractiveMapobjectVO answers false (3684), and so do the Berimond faction capital, tower and village (22776, 22822, 28502).

ROW_OWNER_AREA_TYPES module-attribute

ROW_OWNER_AREA_TYPES = frozenset({int(MapItemType.CASTLE), int(MapItemType.OUTPOST), int(MapItemType.CAPITAL), int(MapItemType.METROPOL), int(MapItemType.VILLAGE), int(MapItemType.ISLE_RESOURCE), int(MapItemType.KINGS_TOWER), int(MapItemType.MONUMENT), int(MapItemType.LABORATORY), int(MapItemType.FACTION_CAMP)})

Area types whose map row carries the owner's player id at index 4: InteractiveMapobjectVO.parseAreaInfo (bundle line 3631), which castles (18910), outposts (through ContainerBuilderMapobjectVO, 6855) and faction camps (FactionCampMapobjectVO.parseAreaInfo calls it through FactionInteractiveMapobjectVO and ContainerBuilderMapobjectVO) use, and the capital (18729), metropolis (21609), village (22623), resource isle (34603), king's tower (19055), monument (21652) and laboratory (25900) overrides.

AREA_TYPE_LEVEL_FLOORS module-attribute

AREA_TYPE_LEVEL_FLOORS: dict[int, int] = {int(MapItemType.KINGS_TOWER): 70, int(MapItemType.MONUMENT): 70, int(MapItemType.LABORATORY): 70}

LANDMARK_FLOOR_AREA_TYPES module-attribute

LANDMARK_FLOOR_AREA_TYPES = frozenset({int(MapItemType.CAPITAL), int(MapItemType.METROPOL)})

YARD_SLOTS module-attribute

YARD_SLOTS = 8

Slots the courtyard wave offers. All eight are open from level 0.

LegendaryFight pydantic-model

Bases: EmpireModel

Which legend skills an attack gets. The client checks three different rules.

"Legend" means a legend level above zero (CastleUserData.isLegend, bundle line 10085; WorldMapOwnerInfoVO.isLegend, bundle line 10892), not a level of 70.

Fields:

model_config class-attribute

model_config: ConfigDict = ConfigDict(extra='forbid')

unit_amount pydantic-field

unit_amount: bool

AttackDialogHelper.isLegendaryFight (bundle line 17378). Gates the additionalUnitAmountOnFlank / OnFront skills.

extra_wave pydantic-field

extra_wave: bool

CastleAttackArmyVO.init (bundle line 55833). Gates the additionalWave skill.

flank_tools pydantic-field

flank_tools: bool

CastleAttackWaveVO constructor (bundle line 99927). Gates the additionalAttackToolAmountFlank skill.

evaluate classmethod

evaluate(*, attacker_level: int, attacker_legend_level: int, target_owner_level: int, wave_level: int, owner_id: int | None, owner_legend_level: int, area_type: int | None, has_other_player_info: bool) -> 'LegendaryFight'

Apply the client's three rules.

Parameters:

Name Type Description Default
attacker_level int

The attacker's level (userData.userLevel)

required
attacker_legend_level int

The attacker's legend level

required
target_owner_level int

CastleAttackInfoVO.targetOwnerLevel, which is the target's minimumOwnerLevel unless it is under conquer control (bundle line 30631)

required
wave_level int

The level a wave is sized by, int(max(level, floor)) in the CastleAttackWaveVO constructor

required
owner_id int | None

The target owner's player id (ownerInfo.playerID), see owner_id_from_row. None, for a target without owner info, grants nothing: the wave and tool rules both require ownerInfo (or an alien invasion object, which always has one)

required
owner_legend_level int

The target owner's legend level (playerLegendLevel, the LL of its owner record)

required
area_type int | None

The target's area type

required
has_other_player_info bool

The target's hasOtherPlayerInfo, see OTHER_PLAYER_INFO_AREA_TYPES

required

WaveCapacity pydantic-model

Bases: EmpireModel

The size of one wave at a given effective level.

Build it with for_level rather than by hand:

capacity = WaveCapacity.for_level(70)
capacity.soldier_capacity(Flank.MIDDLE)   # 192
capacity.unit_slots(Flank.LEFT)           # 2

Fields:

model_config class-attribute

model_config: ConfigDict = ConfigDict(extra='forbid')

level pydantic-field

level: int

flank_soldiers pydantic-field

flank_soldiers: int

middle_soldiers pydantic-field

middle_soldiers: int

flank_tools pydantic-field

flank_tools: int

middle_tools pydantic-field

middle_tools: int

flank_unit_slots pydantic-field

flank_unit_slots: int

middle_unit_slots pydantic-field

middle_unit_slots: int

flank_tool_slots pydantic-field

flank_tool_slots: int

middle_tool_slots pydantic-field

middle_tool_slots: int

for_level classmethod

for_level(level: int, *, flank_bonus_percent: float = 0.0, front_bonus_percent: float = 0.0, tool_bonus: float = 0.0) -> 'WaveCapacity'

Derive a wave's capacity from the effective level and the unit bonuses.

The client resizes the flank containers after building a wave, using two separate effects: a "units on the flank" bonus for the two sides and a "units on the front" bonus for the middle, both read from the selected commander's equipment for the target's area type. Pass them to get the numbers the attack dialog shows; with both at zero these are the unbuffed base capacities.

Parameters:

Name Type Description Default
level int

The target owner's level. The client sizes a wave from attackInfoVO.targetOwnerLevel, so a small defender caps the wave however strong the attacker is

required
flank_bonus_percent float

Total percentage bonus to units on each side flank - commander equipment, the general's skills, and legend skills when the fight is legendary

0.0
front_bonus_percent float

The same for the middle

0.0
tool_bonus float

Extra flank tool capacity, e.g. from the ADDITIONAL_ATTACK_TOOL_AMOUNT_FLANK legend skill

0.0

soldier_capacity

soldier_capacity(flank: Flank) -> int

Units this flank holds.

tool_capacity

tool_capacity(flank: Flank) -> int

Tools this flank holds.

unit_slots

unit_slots(flank: Flank) -> int

Unlocked unit slots on this flank.

tool_slots

tool_slots(flank: Flank) -> int

Unlocked tool slots on this flank.

tool_slot_type

tool_slot_type(flank: Flank) -> int

Which slot type a tool must fit for this flank.

total_soldiers

total_soldiers() -> int

Units the whole wave holds across its three flanks.

min_soldiers

min_soldiers() -> int

The fewest units an attack sized at this level must carry across its waves.

The level a wave is sized by is the one the client checks the minimum at.

owner_id_from_row

owner_id_from_row(row: list[Any] | None) -> int | None

The target owner's player id, as the client's map object reads it.

Parameters:

Name Type Description Default
row list[Any] | None

The target's raw map row (gaa AI entry)

required

Returns:

Type Description
int | None

The owner id, or None for a row that does not name one

is_npc_player

is_npc_player(player_id: int) -> bool

Client: PlayerHelper.isNPCPlayer (bundle line 4687).

is_npc_pvp_player

is_npc_pvp_player(player_id: int) -> bool

An NPC the game fights like a player: an alien invasion or a collector.

Client: PlayerHelper.isNpcPvpPlayer (bundle line 4689).

max_attackers

max_attackers(level: int) -> int

Total units a wave holds (CombatConst.getMaxAttackers).

min_soldiers

min_soldiers(level: int) -> int

The fewest units an attack must carry at this level (CombatConst.getMinSoldiers).

A tenth of what one wave holds, rounded down: 8 at level 16, 26 from level 51 to 69, and 32 from level 70.

Client: CombatConst.getMinSoldiers (ggs.dll line 18895)

min_attack_soldiers

min_attack_soldiers(target_owner_level: int, area_type: int | None = None, *, landmark_min_level: int = 0) -> int

The fewest units the waves of an attack on this target must carry together.

The client refuses to send fewer and shows errorCode_100 with this number. The server refused a player's castle below it with MOVEMENT_HAS_NO_UNITS (100); whether it enforces it on NPC camps is unverified. Only the waves' units count: not their tools, and not the courtyard wave. The level is the target owner's; a capital or metropolis uses its landmark's defense level when that is higher, and a king's tower, monument or laboratory always 70. The result equals min_soldiers of the level the waves are sized by.

Client: AttackDialogStartAttackCheck.onAttack (bundle lines 56306-56313), with CombatConst.getMinSoldiers (ggs.dll line 18895) and OutpostConst's default levels (ggs.dll line 19549)

Parameters:

Name Type Description Default
target_owner_level int

The client's targetOwnerLevel: the owner's level when the target is under conquer control, otherwise the area's own minimum owner level, which is its owner's level for a castle or outpost and its level for a camp or dungeon

required
area_type int | None

The target's area type

None
landmark_min_level int

The capital's or metropolis's minDefenseLevel, which is not in the items payload

0

Returns:

Type Description
int

The fewest units the waves must carry

flank_soldier_capacity

flank_soldier_capacity(level: int, bonus_percent: float = 0.0) -> int

Units one side flank holds (getAmountSoldiersFlank).

bonus_percent is the client's "units on the flank" bonus, which comes from the selected commander's equipment for the target's area type.

middle_soldier_capacity

middle_soldier_capacity(level: int, bonus_percent: float = 0.0) -> int

Units the middle holds (getAmountSoldiersMiddle).

The middle takes whatever the two side flanks leave, so their unbonused size is what is subtracted. bonus_percent is the client's "units on the front" bonus, which is a different effect from the flank bonus - the two scale the middle and the sides independently.

flank_tool_capacity

flank_tool_capacity(level: int, bonus: float = 0.0) -> int

Tools one side flank holds (getTotalAmountTools for a flank).

middle_tool_capacity

middle_tool_capacity(level: int) -> int

Tools the middle holds (getTotalAmountTools for the middle).

boost_to_modifier

boost_to_modifier(boost: float) -> float

Turn a "boost" effect into a multiplier (EffectConst.boostToModifier).

A boost of 0 is a multiplier of 1.0; the client adds it to a base of 100 and scales by 1/100, flooring at zero.

minimum_owner_level

minimum_owner_level(target_level: int, area_type: int | None = None, *, landmark_min_level: int = 0) -> int

The level a target defends at, whoever owns it.

AInteractiveMapobjectVO.minimumOwnerLevel returns the owner's own level, and the landmark subclasses override it with a fixed one: a king's tower, monument and laboratory answer 70, a capital and a metropolis their landmark's minDefenseLevel.

Parameters:

Name Type Description Default
target_level int

The target owner's level

required
area_type int | None

The target's area type

None
landmark_min_level int

The landmark's minDefenseLevel, for a capital or a metropolis; those two read it at runtime and it is not in the items payload

0

Returns:

Type Description
int

The level the area type defends at

wave_level

wave_level(target_level: int, area_type: int | None = None, *, landmark_min_level: int = 0) -> int

The level a wave is actually sized by.

Some targets defend at a floor of their own however low their owner's level is: attack a level 12 player's monument and the wave is built for level 70.

Returns:

Type Description
int

The higher of the owner's level and the area type's own level

yard_capacity

yard_capacity(attacker_level: int, target_level: int, *, bonus: float = 0.0, boost: float = 0.0) -> int

Units the courtyard - the final assault - wave holds.

CombatConst.getMaxUnitsInReinforcementWave: unlike a flank, this one grows with both levels, and the additions are absolute units rather than percentages.

Verified against four captured dialogs from one account, which give an identical implied bonus of 2872 at target levels 1, 13, 45 and 70: 3109, 3349, 3989 and 4489.

Parameters:

Name Type Description Default
attacker_level int

The attacker's own level

required
target_level int

The target owner's level, or the minimum owner level when the target is under conquer control

required
bonus float

Absolute unit bonus, effect type 179

0.0
boost float

Percentage boost, effect type 180, applied as a multiplier last

0.0

Returns:

Type Description
int

The courtyard wave's capacity

unlocked_slots

unlocked_slots(unlock_levels: tuple[int, ...], level: int) -> int

How many of a flank's slots exist at this level.

max_wave_count

max_wave_count(level: int, *, conquer: bool = False, bonus: int = 0) -> int

How many waves an attack may carry (getMaxWaveCountWithBonus).

Parameters:

Name Type Description Default
level int

Effective level

required
conquer bool

A conquest attack gets extra waves

False
bonus int

Extra waves from the ADDITIONAL_WAVE legend skill

0

combat.defense

Building a defender's flank strength from an army composition.

Two sources of composition are supported: an NPC camp, whose defenders the items payload lists offline, and an explicit stack list, which is what a spy report gives for a player's castle.

Functions:

Name Description
camp_level

A camp's level, from its victory count (DungeonConst.getLevel).

invasion_camp_level

The level an invasion event target is fought at, which its row does not carry.

camp_fortification_levels

A camp's wall and gate levels (DungeonConst.getWallUpgradeByLevel).

fortification_bonuses

Wall, gate and moat protection as fractions, from building levels.

tool_defense_bonus

How much a defending tool raises both defender multipliers through its effects.

defender_flank_effects

Aggregate a flank's defenders into strength values.

castellan_defense_multiplier

How much a defending castellan multiplies its own defenders by.

castellan_fortification

What a defending castellan adds to its castle's wall, gate and moat.

spied_castle_defense

What defends a spied castle, per flank.

npc_camp_defense

What defends an NPC camp, per flank, without asking the server.

event_camp_defense

What defends an event camp, per flank.

logger module-attribute

logger = logging.getLogger(__name__)

WALL_WOD_IDS module-attribute

WALL_WOD_IDS = (501, 502, 503, 504, 505, 2542, 1984, 2543, 3182)

GATE_WOD_IDS module-attribute

GATE_WOD_IDS = (450, 451, 452, 453, 469, 2544, 1985, 2545, 3183)

MOAT_WOD_IDS module-attribute

MOAT_WOD_IDS = (455, 830, 1987, 2546, 456, 831, 1988, 2547)

CAMP_LEVEL_FACTOR module-attribute

CAMP_LEVEL_FACTOR = 1.9

CAMP_LEVEL_POWER module-attribute

CAMP_LEVEL_POWER = 0.555

CAMP_KINGDOM_OFFSETS module-attribute

CAMP_KINGDOM_OFFSETS: dict[Kingdom, int] = {Kingdom.GREEN: 1, Kingdom.ICE: 20, Kingdom.SANDS: 35, Kingdom.FIRE: 45}

SAMURAI_INVASION_EVENT_ID module-attribute

SAMURAI_INVASION_EVENT_ID = 80

NOMAD_INVASION_EVENT_ID module-attribute

NOMAD_INVASION_EVENT_ID = 72

DAIMYO_CASTLE_TABLE module-attribute

DAIMYO_CASTLE_TABLE = 'daimyoCastles'

Stacks module-attribute

Stacks = Iterable[tuple[int | None, int]]

LEGEND_DEFENSE_MELEE_BONUS module-attribute

LEGEND_DEFENSE_MELEE_BONUS = 'defenseMeleeBonus'

LEGEND_DEFENSE_RANGE_BONUS module-attribute

LEGEND_DEFENSE_RANGE_BONUS = 'defenseRangeBonus'

LEGEND_WALL_BONUS module-attribute

LEGEND_WALL_BONUS = 'wallBonus'

LEGEND_GATE_BONUS module-attribute

LEGEND_GATE_BONUS = 'gateBonus'

LEGEND_MOAT_BONUS module-attribute

LEGEND_MOAT_BONUS = 'moatBonus'

camp_level

camp_level(victories: int, kingdom_id: Kingdom = GREEN) -> int

A camp's level, from its victory count (DungeonConst.getLevel).

Parameters:

Name Type Description Default
victories int

The camp's victory count, from MapAreaItem.victory_count

required
kingdom_id Kingdom

Kingdom the camp sits in, which shifts the result

GREEN

invasion_camp_level

invasion_camp_level(game_data: GameData, item: MapAreaItem, player_level: int) -> int | None

The level an invasion event target is fought at, which its row does not carry.

The four types do not share a rule, because only three of them are camps:

  • A samurai or nomad camp starts where the attacking player's league band in its event starts and climbs by one for every defeat it has taken.
  • A daimyo castle names a rank instead of a defeat count, and the rank carries the level.
  • A daimyo township is not a camp. The game counts the attacking player as its owner, so it is fought at that player's own level and its rank says nothing about it.

A row that names a difficulty scaling camp overrides the level of the camps, which is how a chosen difficulty raises them for one player.

Parameters:

Name Type Description Default
game_data GameData

Loaded items payload

required
item MapAreaItem

The target's map row

required
player_level int

The attacking player's level

required

Returns:

Type Description
int | None

The level to size the wave at, or None when this is not one of the

int | None

four or the tables do not describe it

camp_fortification_levels

camp_fortification_levels(level: int) -> tuple[int, int]

A camp's wall and gate levels (DungeonConst.getWallUpgradeByLevel).

Returns:

Type Description
tuple[int, int]

(wall_level, gate_level); camps have no moat.

fortification_bonuses

fortification_bonuses(game_data: GameData, *, wall_level: int = 0, gate_level: int = 0, moat_level: int = 0) -> tuple[float, float, float]

Wall, gate and moat protection as fractions, from building levels.

Each level indexes the matching building, whose items column is a percentage; the client divides by 100 before using it as a defense.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables

required
wall_level int

The defender's wall level, 0 for none

0
gate_level int

The defender's gate level

0
moat_level int

The defender's moat level

0

Returns:

Type Description
tuple[float, float, float]

(wall, gate, moat) as fractions

tool_defense_bonus

tool_defense_bonus(game_data: GameData, tool: ToolStats) -> float

How much a defending tool raises both defender multipliers through its effects.

Client: ToolUnitVO.getBonusByEffect(ToolEffectType.DEFENSE_BONUS) (bundle line 6562) with getEffectValue under NULL_CONDITION (bundle line 6640). It sums the value of every effect of type 31 on the tool, with no area, space or cap filter (EffectValueSimple.add, bundle line 17753), and scales by 0.01 because DEFENSE_BONUS is not an absolute bonus (bundle line 9609). Type 31 is the one effect type mapped to ToolEffectType.DEFENSE_BONUS.

defender_flank_effects

defender_flank_effects(stacks: Stacks, game_data: GameData, *, flank: Flank | None = None, melee_bonus: float = 1.0, range_bonus: float = 1.0, wall_bonus: float = 0.0, gate_bonus: float = 0.0, moat_bonus: float = 0.0) -> DefenderFlankEffects

Aggregate a flank's defenders into strength values.

Client: FightScreenHelper.getDefendingUnitStrength (bundle line 19118) and the item loop of getDefenceBonuses (bundle line 19148). A soldier adds both its melee and its ranged defense, times its count, to the melee group when its role is melee and to the ranged group otherwise. A tool adds, once per stack whatever its count, its defMeleeBonus and defRangeBonus to the melee and ranged multipliers, then its DEFENSE_BONUS effect (tool_defense_bonus) to both, and its wall, gate and moat columns to the fortification.

Say which flank this is and the gate is dropped everywhere but the middle, which is what getDefenceBonuses returns - only the middle meets the gate, so gate tools are wasted anywhere else.

Parameters:

Name Type Description Default
stacks Stacks

(wod_id, count) pairs defending this flank

required
game_data GameData

Loaded stats, for the defenders' defense values

required
flank Flank | None

Which flank these defenders hold; without it the gate is left alone

None
melee_bonus float

Defender melee multiplier before the tools (1.0 = unbuffed)

1.0
range_bonus float

Defender ranged multiplier before the tools

1.0
wall_bonus float

Base wall bonus as a fraction

0.0
gate_bonus float

Base gate bonus as a fraction

0.0
moat_bonus float

Base moat bonus as a fraction

0.0

Returns:

Type Description
DefenderFlankEffects

The flank's defender effects

castellan_defense_multiplier

castellan_defense_multiplier(resolver: EffectResolver, bonuses: Sequence[Bonus], *, flank: Flank, melee: bool, area_type: int | None = None) -> float

How much a defending castellan multiplies its own defenders by.

CastleEffectsHelper.getFullDefenseBonusForLordByFlankAndAreaType: the all-flank defense bonus, plus the melee or ranged one, plus whichever position-specific boost applies. The result is a percentage the caller adds to 1.0.

One client quirk is kept: the courtyard boost (type 32) is added on the middle flank, not the courtyard, so it never reaches the courtyard fight through this path. The client reads it as rawValues[0] rather than strength, which for a plain value is the same number - both return _value, and the cap was already applied when the effects were merged.

Parameters:

Name Type Description Default
resolver EffectResolver

Effect resolver over the loaded tables

required
bonuses Sequence[Bonus]

The castellan's bonuses, from commander_bonuses

required
flank Flank

Which flank is being defended

required
melee bool

True for the melee multiplier, False for the ranged one

required
area_type int | None

The target's area type, which scopes the effects

None

Returns:

Type Description
float

The percentage to add to 1.0

castellan_fortification

castellan_fortification(resolver: EffectResolver, bonuses: Sequence[Bonus], *, area_type: int | None = None) -> tuple[float, float, float]

What a defending castellan adds to its castle's wall, gate and moat.

getDefenceBonuses adds these on top of the structures' own protection, as fractions.

spied_castle_defense

spied_castle_defense(game_data: GameData, spy_army: 'SpyArmy', *, wall_bonus: float = 0.0, gate_bonus: float = 0.0, moat_bonus: float = 0.0, melee_bonus: float = 1.0, range_bonus: float = 1.0, castellan: 'Commander | None' = None, defender_legend_skill_ids: Sequence[int] | None = None, area_type: int | None = None) -> dict[Flank, DefenderFlankEffects]

What defends a spied castle, per flank.

Client: FightScreenHelper.getDefendingUnitStrength (bundle line 19118) and getDefenceBonuses (bundle line 19148), which build one of these per flank from that flank's own stacks plus the support stacks. The flanks differ: a defending tool raises the multipliers and fortification of the flank it sits on and no other.

Each value is summed in the client's order: the base, then the flank's items, then the castellan, then the defender's legend skills one at a time. defenseMeleeBonus and defenseRangeBonus skills add a hundredth of their totalEffectValue to the melee and ranged multipliers, and wallBonus, gateBonus and moatBonus skills to the fortification. The gate is then dropped everywhere but the middle.

The keep's stacks become the courtyard flank.

Parameters:

Name Type Description Default
game_data GameData

Loaded stats

required
spy_army 'SpyArmy'

The report's positional army, from aci's S block

required
wall_bonus float

The castle's own wall protection, as a fraction

0.0
gate_bonus float

Its gate protection; only the middle flank keeps it

0.0
moat_bonus float

Its moat protection

0.0
melee_bonus float

Defender melee multiplier before anything is added

1.0
range_bonus float

Defender ranged multiplier before anything is added

1.0
castellan 'Commander | None'

The defending castellan the attack pre-calculation names. Its equipment raises both the fortification and the defenders, and differently per flank

None
defender_legend_skill_ids Sequence[int] | None

The defender's legend skill ids, which the client takes from aci's LS into CastleSpyArmyInfoVO.defenderSkills (bundle line 30699)

None
area_type int | None

The target's area type, which scopes the castellan's effects

None

Returns:

Type Description
dict[Flank, DefenderFlankEffects]

Effects per flank

Raises:

Type Description
ValueError

A legend skill id is not in the items payload. The client looks each one up (CastleLegendSkillData.getSkillByID) and has no fallback for a missing one

npc_camp_defense

npc_camp_defense(game_data: GameData, victories: int, kingdom_id: Kingdom = GREEN) -> dict[Flank, DefenderFlankEffects] | None

What defends an NPC camp, per flank, without asking the server.

The items payload lists a camp's defenders by victory count, so a robber baron camp's defense can be computed offline instead of spying it.

Parameters:

Name Type Description Default
game_data GameData

Loaded stats

required
victories int

The camp's victory count (negative for a robber baron camp)

required
kingdom_id Kingdom

Kingdom the camp sits in

GREEN

Returns:

Type Description
dict[Flank, DefenderFlankEffects] | None

Effects per flank, or None when the payload lists no such camp. The

dict[Flank, DefenderFlankEffects] | None

camp's own wall and gate protection is included, derived from the level

dict[Flank, DefenderFlankEffects] | None

its victory count implies.

event_camp_defense

event_camp_defense(game_data: GameData, table: str, victories: int) -> dict[Flank, DefenderFlankEffects] | None

What defends an event camp, per flank.

Nomad, samurai, faction invasion and alliance invasion camps are described differently from robber baron camps: instead of a per-flank composition they give a defense strength, a unit and tool list, and wall and gate bonuses. The same units defend every flank, so the composition is split evenly and the fortification applied to each.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables

required
table str

One of empire_core.gamedata.CAMP_TABLES

required
victories int

The camp's victory count

required

Returns:

Type Description
dict[Flank, DefenderFlankEffects] | None

Effects per flank, or None when no such camp is listed. Unit counts are

dict[Flank, DefenderFlankEffects] | None

not given by these tables, so the strengths reflect one of each listed

dict[Flank, DefenderFlankEffects] | None

unit; treat the wall and gate bonuses as the reliable part.

combat.effects

Flank effect values, ported from the game client's combat maths.

The client feeds an attacker-side and a defender-side effect object into its auto-fill strategy. Both are plain value holders: the interesting part is how their inputs are aggregated, which lives in empire_core.combat.defense.

Classes:

Name Description
AttackerFlankEffects

The attacker's multipliers for one flank.

DefenderFlankEffects

The defender's strength on one flank.

AttackerFlankEffects pydantic-model

Bases: EmpireModel

The attacker's multipliers for one flank.

Bonuses are multipliers, so 1.0 means an unbuffed attack. Reductions are subtracted from the defender's matching bonus.

Fields:

model_config class-attribute

model_config: ConfigDict = ConfigDict(extra='forbid')

melee_bonus pydantic-field

melee_bonus: float = 1.0

range_bonus pydantic-field

range_bonus: float = 1.0

defender_range_reduction pydantic-field

defender_range_reduction: float = 0.0

defender_melee_reduction pydantic-field

defender_melee_reduction: float = 0.0

gate_reduction pydantic-field

gate_reduction: float = 0.0

wall_reduction pydantic-field

wall_reduction: float = 0.0

moat_reduction pydantic-field

moat_reduction: float = 0.0

apply_tool

apply_tool(tool: ToolStats, count: int, *, range_malus: float = 0.0, melee_malus: float = 0.0) -> 'AttackerFlankEffects'

Fold a placed tool's contribution into these effects.

AttackerFlankEffectVO.updateEffectsWithNewTool: each tool adds its fortification and defense reductions times the number placed, which is why the client places tools before soldiers - the soldiers are then picked against a defense the tools have already dented.

A tool's own effects may add range or melee defense maluses on top of its columns. Resolving those needs the game data and the target's area type, which this layer does not have, so the caller resolves them and passes them in - the same terms the strategy scored the tool on.

Parameters:

Name Type Description Default
tool ToolStats

The tool that was placed

required
count int

How many of it

required
range_malus float

Range-defense malus from the tool's effects, type 217

0.0
melee_malus float

Melee-defense malus from the tool's effects, type 215

0.0

Returns:

Type Description
'AttackerFlankEffects'

A new effects object; the original is unchanged

soldier_stack_attack_value

soldier_stack_attack_value(unit: UnitStats, free_items: int, available: int, attack_bonus: float = 0.0) -> int

What a stack of this unit is worth on this flank.

Mirrors AttackerFlankEffectVO.getSoldierStackAttackValue: the unit's attack times the matching bonus, truncated, times however many of it actually fit. Which of the two attack columns is read follows the unit's role, not which column happens to be filled in.

Parameters:

Name Type Description Default
unit UnitStats

The unit being considered

required
free_items int

Remaining troop capacity of the flank

required
available int

How many of the unit the player owns

required
attack_bonus float

A flat addition to this unit's attack from an active global effect, added before the multiplier as the client does

0.0

Returns:

Type Description
int

The stack's attack value, 0 for a unit with no matching role

DefenderFlankEffects pydantic-model

Bases: EmpireModel

The defender's strength on one flank.

The four strengths are sums over the defending army, split by the defenders' role: melee-role defenders contribute to melee_units_*, ranged-role defenders to range_units_*. Bonuses are multipliers starting at 1.0.

Fields:

model_config class-attribute

model_config: ConfigDict = ConfigDict(extra='forbid')

melee_units_melee_strength pydantic-field

melee_units_melee_strength: float = 0.0

melee_units_range_strength pydantic-field

melee_units_range_strength: float = 0.0

range_units_melee_strength pydantic-field

range_units_melee_strength: float = 0.0

range_units_range_strength pydantic-field

range_units_range_strength: float = 0.0

melee_bonus pydantic-field

melee_bonus: float = 1.0

range_bonus pydantic-field

range_bonus: float = 1.0

wall_bonus pydantic-field

wall_bonus: float = 0.0

gate_bonus pydantic-field

gate_bonus: float = 0.0

moat_bonus pydantic-field

moat_bonus: float = 0.0

has_melee_defenders property

has_melee_defenders: bool

Whether any melee-role defender holds this flank.

has_range_defenders property

has_range_defenders: bool

Whether any ranged-role defender holds this flank.

melee_defense_value

melee_defense_value(melee_reduction: float = 0.0, range_reduction: float = 0.0) -> float

Defense against a melee attack (getMeleeDefenceValue).

range_defense_value

range_defense_value(range_reduction: float = 0.0, melee_reduction: float = 0.0) -> float

Defense against a ranged attack (getRangeDefenceValue).

is_empty

is_empty() -> bool

Whether anything defends this flank at all.

combat.tools

Placing tools in a wave's tool slots.

Ported from AFillFlankStrategy.fillFlankWithTools and checkFlank. The mechanics are here; the strategies that choose which tool are supplied by the caller, because the client's five are still being derived and a plausible substitute would produce waves that look right and are not what the game builds.

Classes:

Name Description
TargetContext

What a strategy needs to know about the target to judge a tool.

FilledTools

What a flank's tool pass produced.

ToolStrategy

Chooses one tool for a slot, or None when it has nothing to offer.

ReduceDefenceBonusStrategy

Picks the tool that best cancels one of the defender's bonuses.

Functions:

Name Description
conditioned_effect_bonus

A tool's contribution from its effects rather than its columns.

can_use_tool_on_target

Whether this tool may be carried against this target at all.

is_tool_usable_against_active_raid_boss

Whether a tool's raid-boss ties, if any, allow it now.

default_tool_strategies

The client's five strategies, in pool order.

fill_flank_with_tools

Fill one flank's tool slots.

check_flank

Drop a flank's tools when it has no units to use them.

Attributes:

Name Type Description
ALIEN_INVASION_AREA_TYPES

logger module-attribute

logger = logging.getLogger(__name__)

ALIEN_INVASION_AREA_TYPES module-attribute

ALIEN_INVASION_AREA_TYPES = frozenset({MapItemType.ALIEN_CAMP, MapItemType.RED_ALIEN_CAMP})

TargetContext dataclass

TargetContext(area_type: int | None = None, space_id: int | None = None, is_player: bool = True, active_raid_boss_id: int | None = None)

What a strategy needs to know about the target to judge a tool.

The client passes the defender's area and space id down into pickToolByStrategy; their None defaults are permissive, matching a tool that names no restriction.

active_raid_boss_id is the boss of the alliance raid-boss event that is running now, None when none is. The client reads it from AllianceRaidbossEventEventVO.getActiveEventVO().raidBossServerDataVO (EffectConditionHelper.isEffectForActiveRaidBoss, bundle line 32868), whatever the target is.

Attributes:

Name Type Description
area_type int | None
space_id int | None
is_player bool
active_raid_boss_id int | None
is_alien_invasion bool

Whether the target is an alien invasion camp.

area_type class-attribute instance-attribute

area_type: int | None = None

space_id class-attribute instance-attribute

space_id: int | None = None

is_player class-attribute instance-attribute

is_player: bool = True

active_raid_boss_id class-attribute instance-attribute

active_raid_boss_id: int | None = None

is_alien_invasion property

is_alien_invasion: bool

Whether the target is an alien invasion camp.

Client: the map object factory builds an AAlienInvasionMapobjectVO subclass for exactly AREA_TYPE_ALIEN_CAMP and AREA_TYPE_RED_ALIEN_CAMP (bundle line 5357; WorldConst dll line 20003).

FilledTools

Bases: NamedTuple

What a flank's tool pass produced.

effects is the attacker's side of the flank after the tools were placed. The client passes one AttackerFlankEffectVO per flank into both passes of fillWave and updateEffectsWithNewTool mutates it, so the soldier pass that follows scores units against the dented defense. The VO is rebuilt for each wave (autoFillWave calls getAttackerEffectVO afresh), so nothing carries from one wave to the next.

Attributes:

Name Type Description
placed list[tuple[int, int]]
effects AttackerFlankEffects

placed instance-attribute

placed: list[tuple[int, int]]

effects instance-attribute

ToolStrategy

Bases: Protocol

Chooses one tool for a slot, or None when it has nothing to offer.

The client keeps five of these in a pool and pops one whenever it fails to produce a tool that fits the slot, so a strategy returning None retires for the rest of the flank.

ReduceDefenceBonusStrategy dataclass

ReduceDefenceBonusStrategy(name: str, tool_bonus: Callable[[ToolStats], float], defender_bonus: Callable[[AttackerFlankEffects, DefenderFlankEffects], float], requires: Callable[[DefenderFlankEffects], bool] | None = None, malus_effect_type: CombatEffectType | int | None = None)

Picks the tool that best cancels one of the defender's bonuses.

Ported from AReduceDefenseBonusStrategy.pickToolByStrategy. Given how much of a defense remains after the attacker's own reduction, it prefers the tool that cancels it outright with the fewest units; failing that, the one that removes the most in the units that fit. A defense already at or below zero needs no tool, and the strategy retires.

The client builds five of these, one per defender bonus; see default_tool_strategies.

Attributes:

Name Type Description
name str
tool_bonus Callable[[ToolStats], float]
defender_bonus Callable[[AttackerFlankEffects, DefenderFlankEffects], float]
requires Callable[[DefenderFlankEffects], bool] | None
malus_effect_type CombatEffectType | int | None

name instance-attribute

name: str

tool_bonus instance-attribute

tool_bonus: Callable[[ToolStats], float]

defender_bonus instance-attribute

requires class-attribute instance-attribute

requires: Callable[[DefenderFlankEffects], bool] | None = None

malus_effect_type class-attribute instance-attribute

malus_effect_type: CombatEffectType | int | None = None

conditioned_effect_bonus

conditioned_effect_bonus(game_data: GameData, tool: ToolStats, effect_type: CombatEffectType | int, area_type: int | None = None) -> float

A tool's contribution from its effects rather than its columns.

AReduceDefenseBonusStrategy.getConditionedEffectBonus: sum a hundredth of each matching effect's strength, keeping only effects that apply to the target's area. Six tools today carry a defense malus this way and no column at all, so without this they look useless to the strategies.

can_use_tool_on_target

can_use_tool_on_target(tool: ToolStats, target: TargetContext, game_data: GameData) -> bool

Whether this tool may be carried against this target at all.

Client: AttackHelper.canUseToolForAttackOnTarget (bundle line 8583). A tool flagged canBeUsedToAttackNPC="0" is only allowed against another player or an alien invasion camp, allowedToAttack restricts a tool to named kingdoms and area types, and a tool tied to raid bosses needs one of them to be the active one.

The client's last branch, an event tool with no inventory left, cannot be reached from the fill path - it needs an amount of zero, which the pick already skips.

Parameters:

Name Type Description Default
tool ToolStats

The tool being considered

required
target TargetContext

Where the attack is going

required
game_data GameData

Loaded effects, to read which raid bosses the tool's effects are tied to

required

Returns:

Type Description
bool

True when the tool may be used

is_tool_usable_against_active_raid_boss

is_tool_usable_against_active_raid_boss(tool: ToolStats, game_data: GameData, active_raid_boss_id: int | None) -> bool

Whether a tool's raid-boss ties, if any, allow it now.

Client: AttackHelper.isToolUsableAgainstActiveRaidBoss (bundle line 8593) with EffectConditionHelper.isEffectForActiveRaidBoss (bundle line 32868). A tool with no effects, or with one effect tied to no raid boss, is usable. Otherwise one of its effects must be tied to the active boss. An effect id the effects table does not know is skipped, as the client skips a BonusVO without an effect, so a tool whose effects are all unknown stays usable.

ToolUnitVO.parseEffects (bundle line 6644) makes one entry per non-empty effects segment and resolves the id before &.

Parameters:

Name Type Description Default
tool ToolStats

The tool being considered

required
game_data GameData

Loaded effects

required
active_raid_boss_id int | None

The active raid-boss event's boss, None when no raid-boss event is running

required

Returns:

Type Description
bool

True when the tool may be used

default_tool_strategies

default_tool_strategies() -> list[ReduceDefenceBonusStrategy]

The client's five strategies, in pool order.

fillToolStrategyPool pushes moat, range, melee, gate then wall, and pops from the end - so wall is tried first and moat last.

fill_flank_with_tools

fill_flank_with_tools(capacity: int, slots: int, slot_type: int, inventory: 'Inventory', game_data: GameData, strategies: Sequence[ToolStrategy], *, attacker: AttackerFlankEffects | None = None, defender: DefenderFlankEffects | None = None, target: TargetContext | None = None, used_per_type: dict[str, int] | None = None) -> FilledTools

Fill one flank's tool slots.

Follows the client: for each free slot, ask the last strategy in the pool for a tool. A tool that does not fit this slot type retires that strategy and the next one is tried; when the pool empties, filling stops. A tool already present in the flank merges into its existing slot rather than taking a new one.

Parameters:

Name Type Description Default
capacity int

The flank's tool capacity

required
slots int

How many unlocked tool slots the flank has

required
slot_type int

The slot type a tool must fit, from WaveCapacity

required
inventory 'Inventory'

Pool to draw from; placed tools are deducted

required
game_data GameData

Loaded tool stats

required
strategies Sequence[ToolStrategy]

The strategy pool, tried from the end

required
attacker AttackerFlankEffects | None

Attacker multipliers, which each placed tool feeds

None
defender DefenderFlankEffects | None

The defender's strength on this flank

None
target TargetContext | None

Where the attack is going, which scopes a tool's effects and decides whether it may be carried at all

None
used_per_type dict[str, int] | None

The wave's per-tool-type budget so far, shared across its three flanks and updated in place

None

Returns:

Type Description
FilledTools

The (wod_id, count) placed per filled slot, and the attacker effects

FilledTools

the soldier pass should then use

Note

One deliberate divergence. The client deducts a stack inside the pick, before it knows whether the tool fits the slot, and drops it on the floor when it does not - the tools are gone from the inventory and never placed. This deducts only what it places. The loss is reachable on a side flank through the range strategy, so a wave built here can carry tools a wave built in the client would have lost.

The effects passed in are not mutated; the updated ones come back in FilledTools.effects.

check_flank

check_flank(tools: list[tuple[int, int]], units: list[tuple[int, int]], inventory: 'Inventory', *, game_data: GameData | None = None, used_per_type: dict[str, int] | None = None) -> bool

Drop a flank's tools when it has no units to use them.

AFillFlankStrategy.checkFlank: tools without units are returned to the inventory rather than sent, since they would be spent for nothing.

The per-wave budget is given back too. The client reads it live off the wave's containers (getSumOfToolsByTool), so emptying one flank frees that allowance for the flanks filled after it.

Parameters:

Name Type Description Default
tools list[tuple[int, int]]

The flank's placed tools

required
units list[tuple[int, int]]

The flank's placed units

required
inventory 'Inventory'

Pool the tools return to

required
game_data GameData | None

Tool stats, to credit the budget back; GameData.require when not given and used_per_type is

None
used_per_type dict[str, int] | None

The wave's per-tool-type budget, updated in place

None

Returns:

Type Description
bool

True when the flank stands, False when its tools were taken back

Raises:

Type Description
GameDataNotLoadedError

Tools are taken back against used_per_type without game_data, and the game data cannot be loaded