Skip to content

Client

EmpireClient, its configuration, accounts and the account pool, and the base every service shares.

client.client

The client: logs in the way the game client does and carries every service.

The connection runs on its own threads, so the client never competes with a host event loop.

Classes:

Name Description
EmpireClient

Empire client for connecting to GGE game servers.

Attributes:

Name Type Description
logger
LOGIN_SECTION_PUSHES
T

logger module-attribute

logger = logging.getLogger(__name__)

LOGIN_SECTION_PUSHES module-attribute

LOGIN_SECTION_PUSHES = ('sne', 'ahl')

T module-attribute

T = TypeVar('T', bound=BaseResponse)

EmpireClient

EmpireClient(username: str | None = None, password: str | None = None, config: EmpireConfig | None = None, login_token: str | None = None, keep_session: bool = False)

Empire client for connecting to GGE game servers.

This client uses blocking I/O with a background receive thread, making it safe to use from Discord.py without blocking the event loop (run client operations in a thread pool).

Usage
client = EmpireClient(username="user", password="pass")
client.login()
movements = client.movements.get_movements()
client.close()

# Or, cleaning up automatically:
with EmpireClient(username="user", password="pass") as client:
    client.login()
    movements = client.movements.get_movements()
Keeping the session

With keep_session=True (or the attribute set later), a logged-in session that drops is logged in again on a thread of its own, on the same client, so callbacks and listen streams carry on. is_restoring_session is True while it runs, on_session_retry fires for each failed attempt, on_session_restored once it is back, and on_session_lost when it gives up. close ends it, also while it waits between attempts, and a login of your own stops it before it logs in.

The game client does not do this: after a drop (CastleConnectionLostCommand.execute, bundle line 120253) it empties the game and shows a reconnect dialog, and logs in again only when the player clicks it (onReconnect, bundle line 120256). So the delays are library policy, set on the config: the first attempt comes config.relogin_first_delay (5) seconds after the drop, a failed one is retried after twice the last delay, up to config.relogin_max_delay (300) seconds. A longer first delay leaves a person who logged the same account in elsewhere, and so dropped this session, time to play before the re-login drops theirs. login(retry=True) retries a first login on the same delays. A LoginCooldownError counts as a failed attempt too, and is retried no sooner than the seconds the refusal named (see remaining_login_cooldown): after the longer of that cooldown and the delay. A refusal that waiting does not cure (a ban, the wrong server, the client version, credentials, or any other refusal) ends the attempts, logged as an error and reported to on_session_lost; the client then stays logged out. A re-login sends no reCAPTCHA token.

Methods:

Name Description
on_disconnect

Register a callback for the session dropping on its own; close does not fire it.

listen

Stream callback calls to the running event loop: every registration's, or those of sources and names.

close_streams

End every stream of listen still listening, after the events already on their way.

login

Log in the way the game client does.

remaining_login_cooldown

Seconds until the login cooldown the server last named is over; 0 when there is none.

on_session_lost

Register a callback for a keep_session re-login that gives up.

on_session_retry

Register a callback for a failed login attempt that is tried again.

on_session_restored

Register a callback for a keep_session re-login that holds.

close

Disconnect from the server and release background resources.

load_game_data

Load the static game data (unit and tool stats) now; optional.

frame

The request as this session sends it: in its zone and the room it joined.

request_packet

Send a request and return the raw reply packet for response_command.

send

Send a request to the server using protocol models.

request

Send a request and return its typed response.

Attributes:

Name Type Description
alliance AllianceService
castle CastleService
army ArmyService
attack AttackService
commanders CommandersService
equipment EquipmentService
skills SkillsService
spy SpyService
ranking RankingService
map MapService
messages MessagesService
movements MovementsService
defense DefenseService
player PlayerService
events EventsService
rewards RewardsService
config
username
password
login_token
session_id
connection
state
is_logged_in
keep_session
is_restoring_session bool

Whether a keep_session re-login is running: logging in, or waiting between attempts.

game_data GameData | None

The static game data the services read, or None while none is loaded.

alliance instance-attribute

alliance: AllianceService

castle instance-attribute

castle: CastleService

army instance-attribute

attack instance-attribute

attack: AttackService

commanders instance-attribute

commanders: CommandersService

equipment instance-attribute

equipment: EquipmentService

skills instance-attribute

skills: SkillsService

spy instance-attribute

ranking instance-attribute

ranking: RankingService

map instance-attribute

messages instance-attribute

messages: MessagesService

movements instance-attribute

movements: MovementsService

defense instance-attribute

defense: DefenseService

player instance-attribute

player: PlayerService

events instance-attribute

events: EventsService

rewards instance-attribute

rewards: RewardsService

config instance-attribute

config = config or default_config

username instance-attribute

username = username or self.config.username

password instance-attribute

password = password or self.config.password

login_token instance-attribute

login_token = login_token

session_id instance-attribute

session_id = generate_session_id()

connection instance-attribute

connection = Connection(self.config.game_url, keepalive_zone=self.config.default_zone, listeners=self._registry)

state instance-attribute

state = GameState()

is_logged_in instance-attribute

is_logged_in = False

keep_session instance-attribute

keep_session = keep_session

is_restoring_session property

is_restoring_session: bool

Whether a keep_session re-login is running: logging in, or waiting between attempts.

True from just after the on_disconnect callbacks of a logged-in session that dropped until the session is back (before on_session_restored fires), the attempts give up (before on_session_lost fires), close or a login of your own ends them. False during login(retry=True), which is not a restore.

It is still False inside the on_disconnect callbacks, as the re-login starts after them. To follow a restore, register on_session_retry, on_session_restored and on_session_lost instead.

Library bookkeeping: the client restores no session by itself.

game_data property writable

game_data: GameData | None

The static game data the services read, or None while none is loaded.

The data the process loaded (GameData.loaded, which load_game_data, every GameData.load and the first API that needs the data fill), the same instance the troop counts read. Reading it never loads anything. Setting it attaches data of your own, e.g. GameData.parse of a saved payload, which this client then reads instead; setting None goes back to the process's data.

on_disconnect

on_disconnect(callback: Callable[[], object]) -> None

Register a callback for the session dropping on its own; close does not fire it.

Runs on the receive thread as it shuts down, after is_logged_in is cleared, and fires once per dropped session. Keep it short and hand a re-login to another thread, or leave it to keep_session, which starts its own once these callbacks have run. Registering the same callback twice is a no-op, and so is removing one not registered.

The callbacks are the connection's disconnect listeners (:meth:Connection.add_disconnect_listener <empire_core.network.connection.Connection.add_disconnect_listener>): one list, run in the order registered through either, and either on_disconnect.remove or remove_disconnect_listener takes one out.

Unregister a callback with on_disconnect.remove(callback).

listen

listen(source: Callable[[Callable[[Unpack[Args]], Any]], object], /, *, maxsize: int = 0) -> EventStream[Unpack[Args]]
listen(*sources: Callable[[Callable[..., Any]], object], names: Iterable[str] | None = None, maxsize: int = 0) -> EventStream[Unpack[tuple[Any, ...]]]
listen(*sources: Callable[..., object], names: Iterable[str] | None = None, maxsize: int = 0) -> EventStream[Unpack[tuple[Any, ...]]]

Stream callback calls to the running event loop: every registration's, or those of sources and names.

Call it from a coroutine; the stream delivers on that coroutine's loop. sources are the registration methods themselves (client.state.on_incoming_attack, client.alliance.on_chat_message, ...): any on_<name> event of the client, its state or a service. Each event is a ClientEvent named after its registration. A stream of one registration types its events' args as that registration's callback parameters: client.listen(client.state.on_incoming_attack_updated) yields ClientEvent[Movement, Movement]. The movement callbacks, which take either (movement_id) or (movement_id, movement), and streams of several registrations yield untyped args.

names picks registrations by the event names instead, for subscriptions kept as data: client.listen(names={"incoming_attack", "chat_message"}). A name is the registration's without on_, as ClientEvent has it, and an unknown one raises ValueError here, not later. A stream by name has untyped args. sources and names can be given together; the stream then has the registrations of both. Only with neither does it have every registration: an empty names alone picks none.

The stream listens inside async with only: entering it subscribes, leaving it stops listening, and iterating a stream that was not entered raises RuntimeError. Inside the block it outlives sessions, as callbacks do: a dropped session is a "disconnect" event, and after close and a new login the stream delivers the new session's events. Only leaving the block, stream.close() and close_streams end it.

Nothing is dropped while the loop keeps up. A maxsize above 0 caps the events waiting unread: one more stops the stream, which raises EventStreamOverflowError once the consumer reaches it.

Raises:

Type Description
RuntimeError

No event loop is running.

ValueError

A source is not a callback registration of this client, a name is not one's, or names is a single string.

Example::

async with client.listen(client.state.on_incoming_attack) as attacks:
    async for attack in attacks:
        (movement,) = attack.args

close_streams

close_streams() -> None

End every stream of listen still listening, after the events already on their way.

close leaves streams listening, as it leaves callbacks registered. A client handed on to other code, as an AccountPool release does, calls this so no stream of the previous holder keeps queueing.

login

login(recaptcha_token: str | Callable[[], str] | None = None, *, retry: bool = False) -> None

Log in the way the game client does.

  1. Connect the WebSocket
  2. verChk, answered by apiOK
  3. Zone login with the build number, answered by rlu
  4. autoJoin, answered by joinOK with the room id
  5. roundTrip and the version check vck
  6. lli, with the measured connection and round-trip times

With password set it logs in by password; without one, by the login_token an earlier login got. After a login the server pushes a fresh token, kept in login_token; the push comes after gbd, so it lands shortly after this method returns.

A keep_session re-login still under way, or waiting between attempts, is ended first, so only this login runs: is_restoring_session turns False, and neither on_session_restored nor on_session_lost fires for it.

Parameters:

Name Type Description Default
recaptcha_token str | Callable[[], str] | None

A reCAPTCHA v3 token for the action login, or a function returning one, sent as RCT. The game client always attaches one; the library cannot make one itself, and logins are accepted without it today. A function is called again for each attempt.

None
retry bool

Retry a failed login, as keep_session retries a re-login: after a timeout, a network error, a login cooldown or, with config.client_version None, a version refusal, wait and log in again, until a login holds. Each failed attempt fires on_session_retry. Any other failure raises at once. A close, or a newer login, from another thread ends the attempts, also during a wait, and this login raises the last attempt's failure. The game client retries no login by itself (LLICommand.executeCommand, bundle line 120651, shows a dialog), so the waits are library policy.

False

Raises:

Type Description
NetworkError

The WebSocket connection could not be established, or the live client's version could not be downloaded (config.client_version None)

EmpireTimeoutError

A required login step timed out

ClientVersionError

The version check says the client version is too low or too high

LoginCooldownError

The server is rate-limiting this account

AccountBannedError

The account is banned or deleted

WrongServerError

The account is on another server

LoginError

Username and password or token missing, the live client's files name no version, the version check failed, or the server rejected the login

Every failure mode is an EmpireError subclass, so except EmpireError catches all of them.

On any of these, the connection and its background threads are closed before the error propagates, so a failed login leaks nothing.

Client: BasicSmartfoxClient (dll line 7130), BasicJoinedRoomCommand (dll line 33011), CastleLoginCommand (bundle line 131762)

remaining_login_cooldown

remaining_login_cooldown() -> float

Seconds until the login cooldown the server last named is over; 0 when there is none.

Only what the server last said: the CD of the last refused login of this client, less the time since, cleared by a login that holds. The server may start a cooldown this client has not been told about yet.

Client: LLICommand.executeCommand (bundle line 120673) shows the refusal's REMAINING_COOLDOWN in a timer dialog.

on_session_lost

on_session_lost(callback: Callable[[Exception], object]) -> None

Register a callback for a keep_session re-login that gives up.

callback(error) fires once, with the refusal that ended the attempts (a ban, the wrong server, the client version, credentials, or any other refusal waiting does not cure); the client then stays logged out until a login of your own. Neither close nor a login of your own that stops the re-login fires it; that login raises its own failure. Runs on the callback thread (see GameState). Registering the same callback twice is a no-op, and so is removing one not registered.

Unregister a callback with on_session_lost.remove(callback).

on_session_retry

on_session_retry(callback: Callable[[int, float, Exception], object]) -> None

Register a callback for a failed login attempt that is tried again.

callback(attempt, wait, error) fires for each failed attempt of a keep_session re-login or of login(retry=True): the attempts so far, the seconds until the next (the backoff delay, or the server's login cooldown if that is longer), and the attempt's failure. It fires once the failed attempt's connection is closed, on the callback thread (see GameState), which the attempts keep, so the callbacks run in order. An attempt that close or a login of your own ended does not fire it. Registering the same callback twice is a no-op, and so is removing one not registered.

Unregister a callback with on_session_retry.remove(callback).

on_session_restored

on_session_restored(callback: Callable[[], object]) -> None

Register a callback for a keep_session re-login that holds.

Fires once per restored session, after the login data and the movement list the server pushes after it have reached state (or that list did not come within config.request_timeout), so the events of that state come first. Runs on the callback thread (see GameState). A login of your own does not fire it. Registering the same callback twice is a no-op, and so is removing one not registered.

Unregister a callback with on_session_restored.remove(callback).

close

close() -> None

Disconnect from the server and release background resources.

Safe to call more than once, and safe to call after a failed login. Registered callbacks and the streams of listen stay, as on a dropped session: they keep working after the next login.

Ends a keep_session re-login, also one waiting between attempts, and returns once it has stopped: no re-login follows a close until the next login. The game client's logout likewise marks the disconnect as the user's (userForcedDisconnect, ggs.dll line 7170), and with it set does not log in again by the stored token (VCKLegacyCommand.executeCommand, bundle line 121066).

load_game_data

load_game_data(*, refresh: bool = False, cache_dir: str | Path | None = None) -> GameData

Load the static game data (unit and tool stats) now; optional.

The APIs that need the data load it on first use, so the first of them may wait on the disk cache or, on a cache miss, a large download. Call this before timing-sensitive work to take that cost up front, or with refresh=True to download again. It is GameData.load, so the data is kept for the process and cached on disk per game version, and this is cheap after the first call; client.game_data and the troop counts read what it loaded.

Parameters:

Name Type Description Default
refresh bool

Ignore any cached copy and any failure backoff, and re-download

False
cache_dir str | Path | None

Where to keep trimmed data (default: XDG cache dir)

None

Returns:

Type Description
GameData

The loaded data, also available as client.game_data unless data of

GameData

your own is attached there

Raises:

Type Description
NetworkError

Nothing is loaded (or refresh is set) and the CDN could not be reached, now or less than five minutes ago

frame

frame(request: BaseRequest) -> str

The request as this session sends it: in its zone and the room it joined.

Client: BasicSmartfoxClient.sendMessage and sendCommand (ggs.dll lines 7174 and 7198)

request_packet

request_packet(request: BaseRequest, response_command: str, timeout: float = 5.0) -> Packet

Send a request and return the raw reply packet for response_command.

For replies whose command id the response registry gives to another model (hgh answers both alliance search and highscores), or that a caller reads itself. Raises as Connection.request does.

send

send(request: BaseRequest, wait: bool = False, timeout: float = 5.0) -> BaseResponse | None

Send a request to the server using protocol models.

Parameters:

Name Type Description Default
request BaseRequest

The request model to send

required
wait bool

Whether to wait for a response

False
timeout float

Timeout in seconds when waiting

5.0

Returns:

Type Description
BaseResponse | None

The parsed response if wait=True, otherwise None

With wait=True, requests for one command run one at a time across threads, and the wait for an earlier one counts against timeout. With wait=False the reply, if the command has one, is not paired with this send: it can answer a concurrent waiting request for that command that has no reply check (see Connection.request).

Raises:

Type Description
CommandError

The server answered with a non-zero error code

PacketError

The response payload did not match the response model

EmpireTimeoutError

No response within timeout

ConnectionClosedError

Connection dropped while waiting

NetworkError

The send itself failed

ReceiveThreadError

Called with wait=True on the receive thread

Example
from empire_core.alliance import AllianceChatMessageRequest

request = AllianceChatMessageRequest.create("Hello!")
client.send(request)

# Or wait for response:
response = client.send(GetCastlesRequest(), wait=True)

request

request(request: BaseRequest, response_type: type[T], timeout: float = 5.0) -> T

Send a request and return its typed response.

Like send(request, wait=True) but verifies the parsed response is of the expected type, so callers get a non-optional result.

Raises:

Type Description
PacketError

The response could not be parsed as response_type

CommandError / EmpireTimeoutError / ConnectionClosedError / NetworkError

See send.

client.session

client.stream

Every client callback as one asyncio stream, for code that runs on an event loop.

The library stays threaded; a stream of EmpireClient.listen subscribes to the callbacks inside async with and hands each call to a loop. The game client decides none of this: it is library threading.

Classes:

Name Description
ClientEvent

One callback call, as an EventStream delivers it.

CallbackSource

An event the client, its state or a service declares as on_<name>.

EventStream

The calls of some callback registrations, delivered on an event loop in packet order.

Functions:

Name Description
callback_sources

Every event the client, its state and its services declare (Event), by name.

Attributes:

Name Type Description
Args

Args module-attribute

Args = TypeVarTuple('Args')

ClientEvent dataclass

ClientEvent(name: str, args: tuple[Unpack[Args]])

Bases: Generic[Unpack[Args]]

One callback call, as an EventStream delivers it.

name is the registration's name without on_: "incoming_attack" for client.state.on_incoming_attack, "chat_message" for client.alliance.on_chat_message. args are what a callback registered there is called with: one model for most, (old, new) for "incoming_attack_updated" and "occupation_updated", (movement, captured) for "occupation_ended", (movement_id, movement) for the movement callbacks, (error,) for "session_lost", (attempt, wait, error) for "session_retry", none for "disconnect" and "session_restored". From a stream of one registration, args is typed as that registration's callback parameters.

Attributes:

Name Type Description
name str
args tuple[Unpack[Args]]

name instance-attribute

name: str

args instance-attribute

args: tuple[Unpack[Args]]

CallbackSource dataclass

CallbackSource(name: str, register: Callable[[Callable[..., Any]], object], unregister: Callable[[Callable[..., Any]], None], on_callback_thread: bool)

An event the client, its state or a service declares as on_<name>.

Attributes:

Name Type Description
name str
register Callable[[Callable[..., Any]], object]
unregister Callable[[Callable[..., Any]], None]
on_callback_thread bool

name instance-attribute

name: str

register instance-attribute

register: Callable[[Callable[..., Any]], object]

unregister instance-attribute

unregister: Callable[[Callable[..., Any]], None]

on_callback_thread instance-attribute

on_callback_thread: bool

EventStream

EventStream(client: EmpireClient, sources: list[CallbackSource], loop: AbstractEventLoop, maxsize: int)

Bases: Generic[Unpack[Args]]

The calls of some callback registrations, delivered on an event loop in packet order.

Made by EmpireClient.listen. Iterate it with async for; each item is a ClientEvent. Every call reaches the loop through the state callback thread, the state's own callbacks as they run there and the others queued behind them from the receive thread, so the events come in the order their packets came, whatever registration they belong to.

It listens for the length of one async with: entering subscribes, leaving (or close) stops listening, and a stream not entered refuses to be iterated. EmpireClient.close_streams ends it after the events already on their way. Sessions do not: a session that drops is a "disconnect" event, and after EmpireClient.close the stream keeps listening, so it delivers the next login's events, as the callbacks do.

Methods:

Name Description
close

Stop listening and end the stream after the events it already holds. Safe from any thread.

close

close() -> None

Stop listening and end the stream after the events it already holds. Safe from any thread.

callback_sources

callback_sources(client: EmpireClient) -> dict[str, CallbackSource]

Every event the client, its state and its services declare (Event), by name.

config

Client configuration and the game's server list.

Classes:

Name Description
NetworkInstance

One game server (world) of a network, as network.xml lists it.

EmpireConfig

Configuration for EmpireCore.

Functions:

Name Description
fetch_client_version

The live game client's version, read from the client's own files.

network_config_url

The URL of a network's network.xml.

parse_network_instances

The servers a network.xml lists under instances, and under test-instances too with include_test.

fetch_network_instances

Download a network's network.xml and read its servers.

instance_for_zone

The server a world (zone) lives on, from the network's network.xml, read once per process.

Attributes:

Name Type Description
GAME_CLIENT_URL

Where the HTML5 game client is served from: its index.html and bundles.

EMPIRE_GAME_ID

Goodgame Empire's game id, as in its games-netconf and language file URLs.

EMPIRE_NETWORK_ID

The network of Goodgame Empire's international worlds (EmpireEx_*).

CDN_SUB_DOMAIN

The CDN host the client loads files from: files- and the active CDN's abbreviation (Akamai, ak).

FALLBACK_CDN_SUB_DOMAIN

The host the client falls back to when its CDN fails (_useFallbackCDN, dll line 34406).

AID module-attribute

AID: str = resolve_aid()

GAME_CLIENT_URL module-attribute

GAME_CLIENT_URL = 'https://empire-html5.goodgamestudios.com/default'

Where the HTML5 game client is served from: its index.html and bundles.

LOGIN_DEFAULTS module-attribute

LOGIN_DEFAULTS: dict[str, Any] = {'ID': 0, 'PL': 1, 'LANG': 'en', 'DID': '0', 'AID': AID, 'KID': '', 'REF': 'https://empire.goodgamestudios.com', 'GCI': '', 'SID': 9, 'PLFID': 1}

EMPIRE_GAME_ID module-attribute

EMPIRE_GAME_ID = 12

Goodgame Empire's game id, as in its games-netconf and language file URLs.

EMPIRE_NETWORK_ID module-attribute

EMPIRE_NETWORK_ID = 1

The network of Goodgame Empire's international worlds (EmpireEx_*).

CDN_SUB_DOMAIN module-attribute

CDN_SUB_DOMAIN = 'files-ak'

The CDN host the client loads files from: files- and the active CDN's abbreviation (Akamai, ak).

Client: cdnSubDomain (dll line 34406), CDN.abbreviation (dll lines 10973-10978)

FALLBACK_CDN_SUB_DOMAIN module-attribute

FALLBACK_CDN_SUB_DOMAIN = 'media'

The host the client falls back to when its CDN fails (_useFallbackCDN, dll line 34406).

default_config module-attribute

default_config = _FrozenEmpireConfig()

NetworkInstance pydantic-model

Bases: EmpireModel

One game server (world) of a network, as network.xml lists it.

Client: NetworkXMLParser (dll line 20264)

Fields:

model_config class-attribute

model_config: ConfigDict = ConfigDict(frozen=True)

instance_id pydantic-field

instance_id: int

The instance's id

server pydantic-field

server: str

Host name of the game server

port pydantic-field

port: int

Port of the game server

zone pydantic-field

zone: str

Zone the login and every command name

zone_id pydantic-field

zone_id: int

The zone's id

instance_number pydantic-field

instance_number: int

Number of the world within its network

is_international pydantic-field

is_international: bool

The world is open to every country

is_favorite pydantic-field

is_favorite: bool

The world is marked as a favourite

default_country pydantic-field

default_country: str

Code of the world's default country

instance_loca_id pydantic-field

instance_loca_id: str

Localization key of the world's name

countries pydantic-field

countries: list[str]

Codes of the countries the world is for

game_url property

game_url: str

The WebSocket URL of this server; the client always connects on port 443.

EmpireConfig pydantic-model

Bases: EmpireModel

Configuration for EmpireCore. Defaults can be overridden by passing arguments to EmpireClient or (in the future) loading from environment variables/files.

Instances are mutable: build one and assign to it, or pass field values to the constructor. The one exception is the shared default_config below, which is frozen. An assignment is validated as the constructor validates, and a value the field does not take raises ValidationError.

Fields:

Validators:

model_config class-attribute

model_config: ConfigDict = ConfigDict(validate_assignment=True)

game_url pydantic-field

game_url: str = 'wss://ep-live-us1-game.goodgamestudios.com/'

default_zone pydantic-field

default_zone: str = 'EmpireEx_21'

game_version pydantic-field

game_version: str = '166'

SmartFox API version the verChk handshake sends

client_version pydantic-field

client_version: str | None = None

Game client version whose build number the login and version check send; None reads the live client's version at each login (fetch_client_version)

connection_timeout pydantic-field

connection_timeout: float = 10.0

login_timeout pydantic-field

login_timeout: float = 15.0

request_timeout pydantic-field

request_timeout: float = 5.0

map_tiles_per_second pydantic-field

map_tiles_per_second: float = 25000.0

Map tiles an account reads per second at most; the server bans an account for 12 hours past about 810,000 tiles in 30 seconds, about 27,000 a second (see empire_core.map.pacing)

relogin_first_delay pydantic-field

relogin_first_delay: float = 5.0

Seconds from a drop to the first keep_session re-login, and from a failed login(retry=True) attempt to the next; above 0 and at most relogin_max_delay

relogin_max_delay pydantic-field

relogin_max_delay: float = 300.0

The longest wait between two login attempts in seconds; each failure doubles it. At least relogin_first_delay: raise this one first when raising both on an existing config

username pydantic-field

username: str | None = None

password pydantic-field

password: str | None = None

relogin_delays_in_order pydantic-validator

relogin_delays_in_order() -> EmpireConfig

Refuse a first re-login delay longer than the cap on the delays.

for_instance classmethod

for_instance(instance: NetworkInstance, **fields: Any) -> EmpireConfig

A config for one server of network.xml: its URL and zone, and fields for the rest.

generate_aid

generate_aid() -> str

A fresh account (install) id, made as the client makes one for a new install.

Epoch milliseconds followed by a random number below 999999, not padded.

Client: AccountCookie (dll line 9088)

resolve_aid

resolve_aid() -> str

Return the AID to send to the server.

Reads $EMPIRE_AID if it is set to something non-blank, otherwise generates one. The server treats this as a stable per-install identifier, so a long-lived deployment should generate one once, store it, and pin it via EMPIRE_AID; otherwise every process start looks like a brand new device. Persisting it here is deliberately not done: a library must not write files or touch os.environ as an import side effect.

generate_session_id

generate_session_id() -> str

A session id as the client makes one per page load; the version check sends it.

Client: BasicEnvironmentGlobals.sessionId (dll line 34455)

build_number

build_number(version: str) -> str

The build number of a client version: "1.169.11" is "1169011".

The minor and patch parts are padded to three digits; a patch suffix after - is dropped.

Client: CastleVersionInformation.buildNumberGame (bundle line 10446)

fetch_client_version

fetch_client_version(timeout: float = 10.0) -> str

The live game client's version, read from the client's own files.

Downloads index.html every call, so a game update is picked up at once, and the small CacheBreaker bundle it names only when the bundle is new to this process.

Client: index.html preloads CacheBreaker.bundle.<hash>.js, which CastleLoader loads first (loadCacheBreaker); in it CastleVersionInformation reads appVersion from the TranspilationEmpire package (module 858), the version buildNumberGame is made from

Raises:

Type Description
NetworkError

index.html or the bundle could not be downloaded

LoginError

The files name no bundle or no version

network_config_url

network_config_url(game_id: int, network_id: int, *, test_servers: bool = False, cdn_sub_domain: str = CDN_SUB_DOMAIN, domain: str = 'goodgamestudios.com') -> str

The URL of a network's network.xml.

https://{cdn_sub_domain}.{domain}/games-netconf/{game_id}/{network_id}.xml, or with test_servers (the client's forceToShowTestServers) https://files.{domain}/games-netconf-test/{game_id}/{network_id}.xml. The ids come from the page the game is embedded in, not from the client; the host defaults are the client's.

Client: LiveEnvironment.initPatterns (dll line 3894-3900); cdnSubDomain and domain from BasicEnvironmentGlobals (dll line 34178, 34223)

parse_network_instances

parse_network_instances(xml_text: str, include_test: bool = False) -> list[NetworkInstance]

The servers a network.xml lists under instances, and under test-instances too with include_test.

Country codes are kept as the file gives them (the client also drops the ones it has no country for), and a countries list that is not JSON reads as none, where the client would fail the whole file.

Raises:

Type Description
ValueError

The text is not XML, or declares a DTD

Client: NetworkXMLParser.parseInstances and parseTestInstances (dll line 20254), NetworkInstancesController.loadNetworkInstances (dll line 20233)

fetch_network_instances

fetch_network_instances(game_id: int, network_id: int, include_test: bool = False, timeout: float = 10.0, **url_parts: Any) -> list[NetworkInstance]

Download a network's network.xml and read its servers.

url_parts go to network_config_url (test_servers, cdn_sub_domain, domain).

Raises:

Type Description
NetworkError

The file could not be downloaded

ValueError

The file is not XML

instance_for_zone

instance_for_zone(zone: str, *, game_id: int = EMPIRE_GAME_ID, network_id: int = EMPIRE_NETWORK_ID) -> NetworkInstance

The server a world (zone) lives on, from the network's network.xml, read once per process.

Raises:

Type Description
NetworkError

The server list could not be downloaded

ValueError

The network lists no world of that zone

accounts

Accounts, loaded from a file and from the environment. Handles loading credentials from files, environment variables, and provides a robust interface for selecting accounts based on aliases or tags.

Classes:

Name Description
Account

Represents a single game account configuration.

AccountRegistry

Central registry for managing game accounts.

Attributes:

Name Type Description
account_registry

The shared registry, loaded from the environment and accounts.json on first use.

logger module-attribute

logger = logging.getLogger(__name__)

DEFAULT_ACCOUNTS_FILE module-attribute

DEFAULT_ACCOUNTS_FILE = 'accounts.json'

The file AccountRegistry.load reads when given no path; it may be absent.

account_registry module-attribute

account_registry = AccountRegistry()

The shared registry, loaded from the environment and accounts.json on first use.

Account pydantic-model

Bases: EmpireModel

Represents a single game account configuration. Wraps credentials and metadata.

Fields:

username pydantic-field

username: str

password pydantic-field

password: str

Plain text password

world pydantic-field

world: str = 'EmpireEx_21'

Game world/zone (e.g., EmpireEx_21)

alias pydantic-field

alias: str | None = None

Short name for this account (e.g., 'main', 'farmer1')

tags pydantic-field

tags: list[str]

Categorization tags (e.g., ['farmer', 'k1'])

active pydantic-field

active: bool = True

Whether this account should be used

client_version pydantic-field

client_version: str | None = None

Game client version to log in with; None reads the live client's at each login

to_empire_config

to_empire_config() -> EmpireConfig

A config that logs in to this account's world: its server's URL and its zone.

Each world lives on its own server, so the zone alone would log in to the default server, which then never answers for a zone it does not serve. A world other than the default one is looked up in network.xml (once per process).

Raises:

Type Description
NetworkError

The server list could not be downloaded

ValueError

No world of that name is listed

has_tag

has_tag(tag: str) -> bool

Check if account has a specific tag (case-insensitive).

get_client

get_client() -> EmpireClient

Create and return an EmpireClient for this account.

AccountRegistry

AccountRegistry()

Central registry for managing game accounts. Sources accounts from:

  1. accounts.json (local development)
  2. Environment variables (production/CI)
Security

accounts.json holds passwords in plain text. Keep it out of version control and readable only by its owner (chmod 600 accounts.json). This is not enforced; the file is read whatever its permissions are.

Thread Safety

The implicit lazy load performed by the query methods is guarded by a lock, so concurrent first-time readers cannot observe a partially loaded registry. Explicit calls to load are not synchronized.

Methods:

Name Description
load

Load accounts from all sources.

get_all

Get all active accounts.

get_by_alias

Find an account by its alias.

get_by_username

Find an account by username.

get_by_tag

Get all accounts with a specific tag.

get_default

Get the first available account (default).

load

load(file_path: str | PathLike[str] | None = None, load_env_file: bool = False) -> None

Load accounts from all sources. Prioritizes environment variables, then file: an account whose username appears in both sources keeps the env definition.

Parameters:

Name Type Description Default
file_path str | PathLike[str] | None

Path to the accounts JSON file, relative to the current working directory unless absolute; it must exist. When None, accounts.json in the current working directory is read if it is there.

None
load_env_file bool

When True, first merge a .env file found from the current working directory upwards into os.environ (existing variables are never overridden). Defaults to False so that merely importing empire_core never mutates the caller's environment.

False

Raises:

Type Description
FileNotFoundError

file_path was given and no file is there

ValueError

The file is not a JSON list of accounts

get_all

get_all() -> list[Account]

Get all active accounts.

get_by_alias

get_by_alias(alias: str) -> Account | None

Find an account by its alias.

get_by_username

get_by_username(username: str) -> Account | None

Find an account by username.

get_by_tag

get_by_tag(tag: str) -> list[Account]

Get all accounts with a specific tag.

get_default

get_default() -> Account | None

Get the first available account (default).

pool

A pool that leases one logged-in client per account.

Provides lease/release semantics for account management, automatic cooldown handling, and tag-based filtering for different use cases (e.g., tracking, scanning, alerts).

Classes:

Name Description
PoolExhaustedError

Raised when the pool has no candidate account to hand out.

AccountPool

Manages a pool of accounts for concurrent GGE operations.

logger module-attribute

logger = logging.getLogger(__name__)

PoolExhaustedError

Bases: EmpireError

Raised when the pool has no candidate account to hand out.

Distinct from LoginError: nothing was tried and nothing failed, there was simply nothing free (none configured, all busy or logging back in, all inactive, or none matching the requested username/tag). Callers should back off and retry rather than treat it as a credential problem.

AccountPool

AccountPool(registry: AccountRegistry, keep_alive: bool = False)

Manages a pool of accounts for concurrent GGE operations.

Allows callers to 'lease' accounts so they aren't used by multiple operations simultaneously. Implements automatic cycling and cooldown handling.

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

# Scoped lease: released even if the body raises (preferred)
with pool.leased(tag="tracking") as client:
    ...

# Manual lease/release, for callers that cannot use a with-block
client = pool.lease()
try:
    ...
finally:
    pool.release(client)
Keeping clients logged in

With keep_alive=True, release hands the client back to the pool still logged in, and the next lease of that account reuses it without a login. A kept client whose session dropped meanwhile is closed and replaced by a fresh login, so a cooldown on that login sends the lease on to the next candidate as usual. One still logging back in by itself (EmpireClient.is_restoring_session, with keep_session) is left to it: its account is not available until the restore ends, so a lease moves on to the next candidate, and a release keeps it. To force a fresh login instead, close it yourself: pool.get_client(username).close(); the next lease of the account then logs in a new client. While kept, the connection pings every 60 seconds as the game client does, which keeps an idle session open. release_all closes kept clients too. Client: BasicSmartfoxClient.onJoinRoom (dll line 7165) and activatePing (dll line 7167).

Two moments still treat a client that is logging back in as dropped, closing it and logging in a fresh one: between its drop and the start of its re-login (while its on_disconnect callbacks run, it is neither logged in nor restoring), and when it drops after a lease saw it logged in but before the lease takes it over.

The next leaseholder gets the same client object. leased releases only its own lease, but release cannot tell one holder's handle from the next one's: release a manual handle once, and never after its lease ended.

Release ends the streams a leaseholder opened with EmpireClient.listen. Otherwise the client is handed over as it is: callbacks a leaseholder registered (on_disconnect, on_incoming_attack and the like) stay registered and fire during the next lease. Remove them before releasing, or release with logout=True to close the client.

Thread Safety

Every method may be called from any thread. An account is marked busy before its login starts, so two concurrent leases never get the same account; the login itself runs outside the pool's lock, so leases of different accounts log in in parallel.

Parameters:

Name Type Description Default
registry AccountRegistry

Where this pool's accounts come from.

required
keep_alive bool

Keep released clients logged in for the next lease instead of closing them.

False

Methods:

Name Description
get_available

Get list of available accounts: not leased, and not waiting for a kept client to log back in.

lease

Lease an account from the pool.

leased

Lease an account for the duration of a with block.

release

Release an account back to the pool.

release_all

Release all leased accounts and, with logout, close the clients a keep_alive pool kept.

get_client

Get a leased or kept client by username.

Attributes:

Name Type Description
registry
keep_alive
all_accounts list[Account]

Get all configured accounts.

busy_count int

Number of currently leased accounts.

available_count int

Number of available accounts.

registry instance-attribute

registry = registry

keep_alive instance-attribute

keep_alive = keep_alive

all_accounts property

all_accounts: list[Account]

Get all configured accounts.

busy_count property

busy_count: int

Number of currently leased accounts.

available_count property

available_count: int

Number of available accounts.

get_available

get_available(tag: str | None = None) -> list[Account]

Get list of available accounts: not leased, and not waiting for a kept client to log back in.

Parameters:

Name Type Description Default
tag str | None

Optional tag to filter accounts.

None

Returns:

Type Description
list[Account]

List of available accounts, ordered for round-robin cycling.

lease

lease(username: str | None = None, tag: str | None = None, login: bool = True) -> EmpireClient | None

Lease an account from the pool.

Marks the account as busy and optionally logs in. If a specific account is on cooldown, automatically tries the next available account. A client kept by a keep_alive pool is reused while it is still logged in, also when login is False; while it is logging back in by itself its account is skipped.

Parameters:

Name Type Description Default
username str | None

Specific username to lease (optional).

None
tag str | None

Tag to filter accounts (optional).

None
login bool

Whether to login the client (default True).

True

Returns:

Type Description
EmpireClient | None

Connected EmpireClient, or None if there were no candidate accounts

EmpireClient | None

to try (none configured, all busy or logging back in, or none

EmpireClient | None

matching username/tag).

Raises:

Type Description
LoginError

Every candidate was tried and every one failed. The last failure is attached as __cause__, so credential problems, cooldowns and outright bugs stay distinguishable instead of collapsing into a None that means 'nothing configured'.

leased

leased(username: str | None = None, tag: str | None = None, login: bool = True) -> Iterator[EmpireClient]

Lease an account for the duration of a with block.

Preferred over lease/release: the release happens in a finally, so a caller exception cannot leak the busy slot and the live client. The pool has no lease timeout or reaper, so a leaked slot means the account is unavailable until the process restarts.

Usage::

with pool.leased(tag="scanner") as client:
    client.map.scan_kingdom(Kingdom.GREEN)

Parameters:

Name Type Description Default
username str | None

Specific username to lease (optional).

None
tag str | None

Tag to filter accounts (optional).

None
login bool

Whether to login the client (default True).

True

Yields:

Type Description
EmpireClient

The leased, connected client.

Raises:

Type Description
PoolExhaustedError

No candidate account was available to try.

LoginError

Every candidate was tried and every one failed.

release

release(client: EmpireClient, logout: bool | None = None) -> None

Release an account back to the pool.

Only a client the account is leased with now is released. Any other leaves the pool as it is, with a warning: a client the pool did not hand out, or one whose lease already ended. Such a client is still closed as logout says, unless it is the client a keep_alive pool holds for its account, which a later leaseholder may be using.

Parameters:

Name Type Description Default
client EmpireClient

The client to release.

required
logout bool | None

Whether to close the client. The default closes it, unless the pool was made with keep_alive. A keep_alive pool keeps a client released with logout=False or the default for the next lease while it is logged in or logging back in (EmpireClient.is_restoring_session); any other is closed.

None

release_all

release_all(logout: bool = True) -> None

Release all leased accounts and, with logout, close the clients a keep_alive pool kept.

get_client

get_client(username: str) -> EmpireClient | None

Get a leased or kept client by username.

Closing a kept client that is logging back in ends its restore; the next lease of its account then logs in a new client.

services.base

The base every service builds on: typed requests and action results.

Classes:

Name Description
BaseService

Base class for all services.

logger module-attribute

logger = logging.getLogger(__name__)

R module-attribute

R = TypeVar('R', bound=BaseResponse)

BaseService

BaseService(client: 'EmpireClient')

Base class for all services.

Services provide high-level APIs for game domains and use protocol models for type-safe request/response handling. The client builds one of each in EmpireClient.__init__.

Every service method reports failure by one rule:

  • transport errors and unexpected server errors raise (EmpireTimeoutError, ConnectionClosedError, CommandError);
  • None only for a not-found (or nothing-to-do) outcome mapped from a named GGEError, matched on CommandError.error and documented on the method;
  • an empty collection only when the collection really is empty;
  • invalid arguments, and a call the client would not make, raise ValueError or a typed error from empire_core.exceptions;
  • a malformed reply raises PacketError;
  • an action the server refuses returns False (see execute).

Four kinds of method follow rules of their own, each documented on the method:

  • state read without a request returns None while the login data or push it comes from has not arrived yet (client.castle.get_horses before a gpc);
  • a call made of several requests returns a result object that names each outcome, such as SpyOutcome.COMMAND_FAILED on a SpyResult or the failed and timed_out players of get_player_details_bulk;
  • a best-effort fill goes on without a read the server refuses and names it, as fill_attack does in FilledAttack.unread;
  • a request the server never answers, waiting for a push instead, returns None when no push came in time (open_activity_chest).

Methods:

Name Description
send

Send a request to the server.

request

Send a request and return its typed response.

execute

Send an action request and report whether the server accepted it.

on_response

Register a handler for a specific response type.

Attributes:

Name Type Description
client
zone str

Get the game zone from client config.

client instance-attribute

client = client

zone property

zone: str

Get the game zone from client config.

send

send(request: BaseRequest, wait: bool = False, timeout: float = 5.0) -> BaseResponse | None

Send a request to the server.

With wait=True, raises the same typed exceptions as EmpireClient.send (CommandError, EmpireTimeoutError, ...).

request

request(request: BaseRequest, response_type: type[R], timeout: float = 5.0) -> R

Send a request and return its typed response.

Raises:

Type Description
CommandError

The server answered with a non-zero error code

EmpireTimeoutError / ConnectionClosedError / NetworkError

transport failures

PacketError

The response could not be parsed as response_type

execute

execute(request: BaseRequest, timeout: float = 5.0, *, raise_on: Collection[GGEError] = ()) -> bool

Send an action request and report whether the server accepted it.

Returns False when the server rejects the action with an error code (logged at warning level). Transport failures (timeout, disconnect) still raise, so infrastructure problems are never mistaken for a game-rule rejection.

Parameters:

Name Type Description Default
raise_on Collection[GGEError]

Errors that raise their CommandError instead, for a caller that maps them

()

on_response

on_response(command: str, handler: Callable[[BaseResponse], None]) -> None

Register a handler for a specific response type.

Handlers are registered with the client for efficient routing. Only commands with registered handlers will be parsed.

Handlers run on the receive thread, which routes every reply: they must not block, and a call that waits for a reply raises ReceiveThreadError.

Parameters:

Name Type Description Default
command str

The command code to handle (e.g., "acm")

required
handler Callable[[BaseResponse], None]

Callback function that receives the parsed response

required