Skip to content

Filling waves

client.attack.fill_attack fills an attack the way the game's own "Fill waves" button does. Give it your castle, a target and a commander; it reads everything else itself.

client.load_game_data()    # optional: takes the first-use download up front

castle = client.castle.get_all()[0]
commander = client.commanders.get_commanders()[1]

attack = client.attack.fill_attack(
    castle.castle_id,
    target_x=624, target_y=247,          # a target is all it needs
    commander=commander,
)

client.attack.send_attack(
    source_x=castle.x, source_y=castle.y,
    target_x=624, target_y=247,
    waves=attack.waves, yard_wave=attack.yard,
    commander_id=commander.commander_id,
    min_soldiers=attack.min_soldiers,
)

fill_attack returns a FilledAttack: the waves, the courtyard wave yard, and min_soldiers, the fewest units the waves must carry.

What it reads

From the coordinates it reads the target's area type and structures, the defenders each flank holds and the castellan holding it, the area effects that widen your flanks, your general's skills, and your own legend and Hall of Legends skills. A camp's level comes from the victory count in its map row; a player's from the owner records beside it.

Every one of those can be passed instead, and passing one skips the request that would have found it. The keyword arguments of fill_attack list them all.

The reads are best effort against a refusal: the server refuses the pre-calculation for a target you may not hit, and the fill goes on with what the map tile says. Each refused read is named in FilledAttack.unread, keyed by TargetRead with the server's CommandError, and logged under empire_core.attack: a warning, or info for the pre-calculation's INVALID_AREA. A timeout, a dropped connection or an unreadable reply raises, and so does a target whose area type has no pre-calculation modelled (ValueError).

attack = client.attack.fill_attack(castle_id, target_x=700, target_y=710)
for read, refusal in attack.unread.items():
    print("filled without", read.value, refusal.error)  # e.g. precalculation GGEError.INVALID_AREA
flowchart LR
    T([Target x, y]) --> R[Attack pre-calculation:<br/>map row, defenders,<br/>area effects]
    S[General and player<br/>skills: gie, skl] --> C
    R --> C[Wave capacity<br/>from the target owner's level]
    R --> D[Defenders, wall, gate<br/>and moat per flank]
    C --> F
    D --> F
    subgraph F [Each flank]
        direction TB
        F1[Tools first] --> F2[Then units, against<br/>the defense left]
    end
    F --> Y[Courtyard wave]
    Y --> A([FilledAttack])

How the waves are sized

Each wave is sized the way the game sizes it, which is by the target owner's level rather than the attacker's: a level 13 castle holds far fewer troops than a level 70 one, whatever the attacker's level. Some targets defend at a level of their own; a monument is built for level 70 however low its owner is. On top come the commander's own equipment, its general's unit-limit skills, the Hall of Legends skills, and the legend skills when both sides are at the level cap.

How each flank is filled

Each flank takes tools first and then units, because a placed tool reduces the defense the units are then chosen against.

  • Units are picked to counter whichever of the target's defenses is proportionally weaker.
  • Tools are picked to cancel the target's wall, gate, moat and defender bonuses in as few units as possible, and are skipped entirely where the commander's own reductions already erase them. A flank that ends up with tools but no units gives the tools back.
  • Fortification is per flank, not per castle: a defending tool raises only the flank it stands on, and only the middle flank meets the gate at all.
  • Tools are filtered by the target: many may only be carried against particular kingdoms and area types, or not against camps.
  • Global effects buff units: the fill methods read the running ones from state, the timers of the Event.GLOBAL_EFFECT event, or take your own as global_effects (GlobalEffectTimers). A timer that has ended counts for nothing. The booster's boost to the effects bie lists is added from state: each such effect's strength plus its GlobalEffectBuffEvent.boost_value.

The courtyard wave

Alongside the waves comes the courtyard wave, the final assault that rides in the same request. It holds units only, is sized from both levels rather than the target's alone, and is filled against the defenders of the keep.

The minimum

An attack's waves must carry at least a tenth of what one wave holds at the target's level (8 units against a level 16 castle, 32 from level 70), as the game client requires. The server refused fewer on a player's castle with MOVEMENT_HAS_NO_UNITS (100); whether it enforces this on NPC camps is not yet known.

  • fill_attack raises AttackBelowMinimumError when the castle cannot reach it.
  • send_attack checks it before sending when given min_soldiers or a capacity.
  • empire_core.combat.min_attack_soldiers(target_owner_level, area_type) works it out for any target.

Runnable example

examples/fill_waves.py
"""Fill an attack the way the game's own "Fill Waves" button does, and send it.

Give it a target's coordinates. Everything else is read for you: the target's
area type and structures, the defenders each flank holds, the castellan holding
it, the area effects that widen your flanks, your general's skills and your own.

    export GGE_USERNAME=your_user
    export GGE_PASSWORD=your_pass
    python examples/fill_waves.py 700 710 [--send]

The script prints what it would send and only sends with ``--send``.
"""

import logging
import os
import sys

from empire_core import EmpireClient, EmpireError

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logging.getLogger("websocket").setLevel(logging.WARNING)


def main() -> int:
    username = os.getenv("GGE_USERNAME")
    password = os.getenv("GGE_PASSWORD")
    if not username or not password:
        print("Set GGE_USERNAME and GGE_PASSWORD first.", file=sys.stderr)
        return 2

    args = [a for a in sys.argv[1:] if a != "--send"]
    if len(args) != 2:
        print("Usage: fill_waves.py TARGET_X TARGET_Y [--send]", file=sys.stderr)
        return 2
    target_x, target_y = int(args[0]), int(args[1])
    really_send = "--send" in sys.argv

    client = EmpireClient(username=username, password=password)
    try:
        client.login()
        # The items payload is a large download, so it is never implicit.
        client.load_game_data()

        castles = client.castle.get_all()
        commanders = [c for c in client.commanders.get_commanders() if c.commander_id >= 0]
        if not castles or not commanders:
            print("Need at least one castle and one commander.", file=sys.stderr)
            return 1
        source, leader = castles[0], commanders[0]

        # Coordinates are enough: the pre-calculation and the map supply the
        # rest. Anything passed here would skip the request that finds it.
        filled = client.attack.fill_attack(
            source.castle_id,
            target_x=target_x,
            target_y=target_y,
            commander=leader,
        )

        print(f"\n{len(filled.waves)} wave(s), {filled.unit_count()} units, led by {leader.commander_id}:")
        for index, wave in enumerate(filled.waves):
            for name, flank in (("left", wave.left), ("middle", wave.middle), ("right", wave.right)):
                units = [(unit, amount) for unit, amount in flank.units if unit is not None]
                tools = [(tool, amount) for tool, amount in flank.tools if tool is not None]
                if units or tools:
                    print(f"  wave {index} {name}: units {units} tools {tools}")
        placed = [(unit, amount) for unit, amount in filled.yard if unit is not None]
        print(f"  courtyard: {placed or 'empty'}")

        if not really_send:
            print("\nDry run. Re-run with --send to actually send it.")
            return 0

        accepted = client.attack.send_attack(
            source_x=source.x,
            source_y=source.y,
            target_x=target_x,
            target_y=target_y,
            waves=filled.waves,
            yard_wave=filled.yard,
            kingdom_id=source.kingdom_id,
            commander_id=leader.commander_id,
        )
        print("Attack sent." if accepted else "Server rejected the attack.")

    except EmpireError as e:
        print(f"{type(e).__name__}: {e}", file=sys.stderr)
        return 1
    finally:
        client.close()

    return 0


if __name__ == "__main__":
    sys.exit(main())

API: AttackService.fill_attack, combat