Skip to content

Multiple accounts

AccountPool hands out one logged-in client per account and refuses to lease the same account twice. Prefer leased(): it releases the account and closes the client even if your code raises.

from empire_core import AccountPool, PoolExhaustedError
from empire_core.accounts import AccountRegistry

registry = AccountRegistry()
registry.load(file_path="accounts.json")
pool = AccountPool(registry)

try:
    with pool.leased(tag="scanning") as client:
        result = client.map.scan_kingdom()
except PoolExhaustedError:
    ...   # no candidate account was free

leased() takes a username to lease one account, or a tag to lease any free account carrying it (tags match case-insensitively). PoolExhaustedError means nothing was tried: no account matched, or every match was busy. LoginError means every candidate was tried and each one failed to log in; the cause is chained onto it.

The pool is safe to use from several threads.

Keeping clients logged in

By default every lease logs in and every release closes the client. A consumer that leases per request pays a login each time, and frequent logins risk a login cooldown. Make the pool with keep_alive=True to keep released clients logged in instead:

pool = AccountPool(registry, keep_alive=True)

with pool.leased(username="scanner1") as client:   # logs in
    ...
with pool.leased(username="scanner1") as client:   # same client, no login
    ...

pool.release_all()   # closes the kept clients too

A kept client pings the server every 60 seconds, as the game client does, so an idle session stays open. If its session dropped while it waited, the next lease closes it and logs in a fresh client; a cooldown on that login moves the lease on to the next candidate as usual. release(client, logout=True) closes a client even in a keep_alive pool.

A lease you hold for the whole process never reaches either path. Set client.keep_session = True on it and the client logs a dropped session in again by itself, honouring login cooldowns (see Keeping the session):

client = pool.lease(username="watcher1")   # None when the account is not available
if client is not None:
    client.keep_session = True
    client.on_session_restored(lambda: print("watcher1 is back"))
    client.on_session_lost(lambda error: print("watcher1 is gone:", error))

A release closes the client or keeps it as before, and closing ends any re-login. A keep_alive pool keeps a client released while it logs back in, and leaves it to finish: until client.is_restoring_session turns False its account is not available, so a lease moves on to the next candidate, and one for that username alone returns None (pool.leased() raises PoolExhaustedError). A restore that gives up leaves the client logged out, and the next lease closes it and logs in a fresh one. To log in afresh without waiting, close the client yourself: pool.get_client(username).close(). While a held client waits out a cooldown, client.remaining_login_cooldown() says for how long: the seconds the server last named, counting down. A lease that fails chains its last failure to the LoginError it raises; a LoginCooldownError there carries the seconds.

The next lease gets the very client object you held, so let go of your handle when you release it. pool.leased() releases only its own lease, but a manual pool.release(client) cannot tell your handle from the next leaseholder's: call it once per lease. Releasing a client the account is not leased with changes nothing and logs a warning.

Callbacks outlive the lease

The next leaseholder gets the client as you left it. Callbacks you registered on it (client.on_disconnect, client.state.on_incoming_attack and the like) keep firing during their lease. Remove them before the with block ends, or release with logout=True. Streams of client.listen() are the exception: release ends them.

What the client announced stays announced, also across close() and login(): an attack or occupation announced to you is not announced again to the next leaseholder. It reads client.state.get_announced_attacks() and get_occupations(), or calls client.state.reannounce(movement_id), with when_listed=True while the movement list has not come yet (see Movements). Release drops the when_listed calls you left waiting, so they do not fire during the next lease.

Where accounts come from

registry.load() reads the file you name plus every EMPIRE_ACCOUNT_* environment variable:

[
    {
        "username": "YOUR_USERNAME",
        "password": "YOUR_PASSWORD"
    }
]
# CSV: username,password,world
export EMPIRE_ACCOUNT_MAIN='your_user,your_pass,EmpireEx_21'
# or JSON, when the password contains a comma
export EMPIRE_ACCOUNT_ALT='{"username": "u", "password": "p"}'

A .env file is read only if you opt in with registry.load(load_env_file=True): importing the library never changes your environment.

Keep accounts.json private

It holds passwords in plain text. Keep it out of version control and chmod 600 it; the library warns when it is group- or world-readable.

Scanning with several accounts

The server limits the request rate per account, so map scans go faster spread over several accounts. Lease them and hand them to scan_kingdom_with, which gives each its own thread and moves a dropped client's chunks to the others:

from contextlib import ExitStack

from empire_core import Kingdom
from empire_core.map import scan_kingdom_with

with ExitStack() as leases:
    clients = [leases.enter_context(pool.leased(tag="scanner")) for _ in range(4)]
    result = scan_kingdom_with(clients, Kingdom.GREEN)

See Map scanning for what it returns and how far threads in one process go.

Runnable example

examples/account_pool.py
"""Drive several accounts through an AccountPool.

The pool hands out one logged-in client per account and refuses to hand the same
account to two callers at once. Prefer the ``leased()`` context manager: it
releases the account (and closes the client) even if your code raises, which the
manual lease/release pair only does if you write the ``try/finally`` yourself.

Accounts come from the AccountRegistry the pool is given: ``registry.load()``
reads the file you name plus every ``EMPIRE_ACCOUNT_*`` environment variable. A
``.env`` file is NOT read unless you opt in with
``registry.load(load_env_file=True)`` — importing empire_core never mutates the
process environment as a side effect.

``accounts.json`` holds passwords in plain text: keep it out of version control
and ``chmod 600`` it.

    python examples/account_pool.py [accounts.json]
"""

import logging
import sys

from empire_core import AccountPool, EmpireError, LoginError, PoolExhaustedError
from empire_core.accounts import AccountRegistry

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


def main() -> int:
    accounts_file = sys.argv[1] if len(sys.argv) > 1 else "accounts.json"

    registry = AccountRegistry()
    registry.load(file_path=accounts_file)

    # Each pool has its own registry: a second pool can use a completely
    # different account set in the same process.
    pool = AccountPool(registry)
    print(f"{len(pool)} account(s) configured, {pool.available_count} available")
    if not len(pool):
        print(f"No accounts found in {accounts_file!r} or the environment.", file=sys.stderr)
        return 2

    try:
        # Lease any free account.
        with pool.leased() as client:
            print(f"leased {client.username}: {len(client.castle.get_all())} castle(s)")

        # Or restrict to accounts carrying a tag (matched case-insensitively).
        try:
            with pool.leased(tag="scanner") as client:
                print(f"leased scanner {client.username}")
        except PoolExhaustedError as e:
            # Nothing was tried: no account matched, or all matches were busy or
            # inactive. Distinct from LoginError, which means every candidate was
            # tried and every one failed - back off rather than check credentials.
            print(f"no scanner available: {e}")

    except LoginError as e:
        # Raised once every candidate account has failed. The underlying cause -
        # bad credentials, a login cooldown, a bug - is chained onto it.
        print(f"pool could not log anything in: {e} (cause: {e.__cause__!r})", file=sys.stderr)
        return 1
    except EmpireError as e:
        print(f"{type(e).__name__}: {e}", file=sys.stderr)
        return 1
    finally:
        # Belt and braces: closes anything still leased.
        pool.release_all()

    return 0


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

API: AccountPool, AccountRegistry