State and freshness¶
client.state is an in-memory picture of your account: the player, castles,
the castle you joined with its mines and resource carts, movements, special
currencies, your spy count, active events, your commanders and skills, your
alliance and its chat, and your progress: research, boosters, might, titles,
achievements, relocation and plague monks; account details such as the
daily reset, the attack counter and the wishing well; and your inventory and
economy: gems, loot boxes, kingdoms, mercenary missions and tax. A background thread
applies the server's packets to it while your code reads it.
Read through the accessors¶
Each accessor takes the state lock and returns a snapshot, so nothing changes underneath you mid-iteration:
player = client.state.get_local_player() # None until the login data arrives
castles = client.state.get_castles()
unlocks = client.state.get_permanent_castle(castle_id) # unlocked units, horses
horse_ids = client.state.get_castle_horse_ids(castle_id) # the castle's horses, Horse members
movements = client.state.get_all_movements()
attacks = client.state.get_incoming_attacks()
announced = client.state.get_announced_attacks() # what on_incoming_attack reported
currencies = client.state.get_special_currencies() # by Currency; a newer key stays a str
spies = client.state.get_max_spies() # spies owned, before boosts
events = client.state.get_events() # the running events, by id
roster = client.state.get_commanders() # commanders and castellans
skills = client.state.get_skills() # legend and sceat skills
alliance = client.state.get_own_alliance() # your alliance's details and members
chat = client.state.get_alliance_chat() # the alliance chat history, oldest first
research = client.state.get_research() # finished and running research
boosts = client.state.get_boosts() # boosters, premium account, slots, festival
might = client.state.get_might()
glory = client.state.get_glory_points()
berimond = client.state.get_faction_points()
ranks = client.state.get_title_ranks() # top-X ranks, Storm Islands title
achievements = client.state.get_achievements()
relocation = client.state.get_relocation()
monks = client.state.get_plague_monks()
quests = client.state.get_quests() # your active quests, by id
book = client.state.get_quest_book() # the quest book's main quests
daily = client.state.get_daily_quests() # daily quest level, today's quests
reset = client.state.get_daily_reset() # reset.remaining_seconds()
counter = client.state.get_attack_counter() # attacks counted, the threshold
training = client.state.get_officer_training() # None when no program runs; .bonus is its effect
boosted = client.state.get_boosted_global_effects() # the GlobalEffect members boosted
gifts = client.state.get_player_gifts() # gift packages to send
well = client.state.get_wishing_well()
relics = client.state.get_new_relics()
gems = client.state.get_gems() # gems and relic gems held
boxes = client.state.get_loot_boxes() # loot boxes and key progress
space = client.state.get_inventory_space() # equipment and gem inventory space
kingdoms = client.state.get_kingdoms() # unlocks, transfers between kingdoms
missions = client.state.get_mercenary_missions() # each mission's rewards are Collectables
tax = client.state.get_tax() # a copy; remaining_seconds as of its packet
expiry = client.state.get_construction_item_expiry()
pool = client.state.get_resource_pool() # the goods the citizen in your castle carries
area = client.state.get_joined_area() # None until a castle is joined
mines = client.state.get_mines() # the joined castle's mines, by object id
carts = client.state.get_resource_carts() # its wood, stone and food carts
The commanders, skills and alliance accessors return copies, and the chat
messages, progress and account models are read-only, so changing what you hold
changes nothing in state, and a newer packet does not change it either. The
timed ones count their times from when they were read (received_at, in
time.monotonic() seconds), so research.remaining_research_seconds(),
booster.is_active() or reset.remaining_seconds() stay right between packets.
The attributes behind them (client.state.local_player,
client.state.castles, ...) stay readable, but they are live and unlocked.
Prefer the accessors whenever you read several fields at once or iterate a
container. client.state.castles is keyed by (kingdom, castle_id), as the
game keeps a castle list per kingdom.
Where state comes from¶
Nothing in state polls the server. It is updated only by incoming packets, and every value is as old as the last packet that carried it:
| State | Refreshed by | To refresh it now |
|---|---|---|
| Castle names, positions, the castle list | gcl, mir (pushed) |
log in again |
| Castle resources, units and details | dcl |
client.castle.get_details(castle_id) |
| Castle units, as new ones arrive | rue (pushed) |
client.castle.get_details(castle_id) |
| Castle resources, after an action that spends or brings them | grc and the replies that carry one (building, recruiting, research, ...) |
client.castle.get_resources(castle_id) |
| Castle open-gate counter | gcl, kik (pushed, resets it on Mondays) |
log in again |
| Joined castle, slum level, builder discount | jaa (the join reply), csl, gab (pushed) |
client.castle.join(castle_id) |
| Joined castle's mines | gsm (pushed), the jaa and cmr replies |
client.castle.join(castle_id) |
| Joined castle's resource carts | rci (pushed), the jaa and rcc replies |
client.castle.join(castle_id) |
| Player identity, level and XP | gpi, gxp, glu |
log in again |
| Coins, rubies, VIP, alliance | gcu, vip, gal |
log in again |
| Honor, beginner protection | gho, uap |
log in again |
| Special currencies | sce (pushed) |
none |
| Spies owned, before boosts | gms (pushed) |
log in again |
| Commanders and castellans | gli, and the replies that carry it (arl, gla, seq, sdi, sti, the attack and conquer info replies) |
client.commanders.get_all() |
| Legend and sceat skills | skl, ego (pushed) |
client.skills.get_skills() |
| Your alliance's details and members | ain, acn, cal, acd, ado, akm, arm; an acm marks its sender online |
client.alliance.get_alliance_info(alliance_id) |
| Alliance chat history | acl, acm (pushed) |
none |
| Research | rei (pushed), the res and msr replies |
log in again |
| Boosters, premium account, production slots | boi (pushed), the booster replies (ovs, bds, ...); a boi updates only the boosters it lists |
log in again |
| Festival | boi, the bfs reply |
log in again |
| Might points | gmu (pushed) |
log in again |
| Glory points | ufa (pushed) |
log in again |
| Berimond points | ufp (pushed; the login data's copy is ignored, as the game ignores it) |
none |
| Top-X ranks, Storm Islands title | uar (pushed) |
log in again |
| Achievements | vli (pushed); finished ones add up, progress per achievement |
log in again |
| Relocation | gri (pushed) |
log in again |
| Plague monks | cpi (pushed), the cpm and sbp replies |
log in again |
| Daily reset | drt (login data only) |
log in again |
| Attack counter | gai (pushed) |
log in again |
| Officers' school training | gatp (any gatp without a running program clears it), gtp (its running program; none clears it) |
log in again |
| Boosted global effects | bie (pushed) |
log in again |
| Gift packages | pgl |
log in again |
| Ruby wishing well | rww |
log in again |
| New relics flag | nrf (pushed) |
log in again |
| Gems and relic gems | ggm, gec (pushed; adds or takes gems away) |
log in again |
| Loot boxes, key progress | gls (pushed, also before the login data); a gls updates only the key progress it lists |
log in again |
| Equipment and gem inventory space | esl (pushed), the bgm, ceq, cge, frc and seq replies |
log in again |
| Kingdoms, transfers between them | kpi, the kgt, kst, kut, msk and fjf replies; a kpi updates only the kingdoms it lists |
log in again |
| Mercenary missions | mpe |
log in again |
| Tax collection | txi, the txs, txc and btx replies |
client.castle.get_tax_info() |
| Construction item expiry | nec (pushed) |
log in again |
| Resource citizen | irc (pushed every 30 seconds or so); joining a castle (jaa) clears it |
none |
| Running events, their scores and ends | sei, tei (pushed), see, tee, pep, the fjf and bst replies, cqs for the campaign |
client.events.refresh() |
| Active quests, the quest book | qli (pushed after login, not in the login data), qst, qfi, the quest popups of msp |
none |
| Daily quests | dql (pushed) |
log in again |
| Movements | gam, abr/asr, your sends' replies |
client.movements.get_movements() |
Every player section is sent inside the login data (gbd) and again as a push
of its own when it changes. A section a reply carries is applied only when the
reply succeeded, as the game applies it.
Only your own alliance is kept: get_alliance_info for another alliance
leaves it alone. The chat history grows with every acl and acm, as the
game's does, and starts over when you leave the alliance or a gal says you
are in none. Each message's sent_at is the wall-clock time it was sent,
taken from its age when it arrived.
Knowing whether state is fresh¶
Castle resources and units are often filled once, at login, and never again unless you ask. State can be stale without being wrong, so check before you trust it:
if client.state.get_castle_last_updated(castle_id) is None:
# Never refreshed: resources and units are defaults, not measurements.
client.castle.get_details(castle_id)
The freshness accessors are get_castle_last_updated and get_castle_age,
get_player_last_updated, and get_last_packet_time or get_packet_times
for each command. They return wall-clock time.time() seconds, and None
means never seen, which is different from seen and empty. Only a dcl stamps
get_castle_last_updated: a grc refreshes a castle's resources but not its
units, so get_last_packet_time("grc") dates it.
The joined castle¶
Logging in joins no castle; client.castle.join(castle_id) does, and its reply
fills get_joined_area() (the castle's kingdom and id, slum level and builder
discount) and, when the server sends them, the mines and resource carts. Their
pushes keep them current while you stay. A map read (gaa, as every
scan sends) drops the mines, as the game drops them on the
world map; get_joined_area() and the resource carts stay, as the game keeps
them, even though the session is no longer in the castle. Mines and carts are
read-only. A mine's next_collect_seconds and a
cart's remaining_seconds count down from when they arrived, as in the game:
import time
client.castle.join(castle_id)
sent_at = client.state.get_last_packet_time("gsm") # None until a gsm arrived
for object_id, mine in client.state.get_mines().items():
if sent_at is not None and 0 <= mine.next_collect_seconds <= time.time() - sent_at:
client.castle.collect_mine(object_id)
The joined castle's buildings are not kept in state. on_building_finished
(fbe), on_building_xp (cbx) and on_buildings_changed (gdb damaged
buildings, gcb changed efficiency) call you back on the callback thread
instead; each one's .remove(callback) unregisters.
Running events¶
client.state.get_events() maps each running event's id to a model of its kind
(the ones in empire_core.events.EVENT_CLASSES, such as InvasionEvent or
KingdomsLeagueEvent), or a plain SpecialEvent for the rest. Each keeps the
fields its game dialog reads, the entries the server sent in raw, and when it
ends (end_time, in time.monotonic() seconds; remaining_seconds() and
is_active() read it):
from empire_core.gamedata import Event
for event_id, event in client.state.get_events().items():
print(event_id, type(event).__name__, round(event.remaining_seconds()))
samurai = client.state.get_event(Event.SAMURAI_INVASION)
if samurai is not None:
print(samurai.parts["A"].league_id, samurai.parts["A"].own_points)
get_event is typed by the event you pass: get_event(Event.SAMURAI_INVASION)
is a SamuraiInvasionEvent | None to mypy and pyright, so its fields complete
in the editor. An event without a model of its own, or a bare id, is a
SpecialEvent | None.
An event starts with the sei (or, for the kingdoms league and the global
effects, tei) entry that names it, and later entries are read over it the way
the game reads them: a field the entry leaves out mostly keeps its value. It
ends with a see or tee, or when its time runs out. Your points come with
the pep pushes. The models never change; a later packet replaces them, so a
snapshot stays as it was.
on_event_added, on_event_removed and on_events_updated (each with its
.remove(callback)) call you back on the callback thread when an event
starts, ends, or a packet updates the events; get_events_last_updated() says
when one last did. The events guide lists the models.
Quests¶
client.state.get_quests() maps each active quest's id to a Quest: its
progress (one counter per condition, in the order the game data lists the
conditions), completed, failed, locked, and its end_time for a quest
with a time limit. The game data holds the quests' conditions and rewards; the
state keeps only what the server sends. get_daily_quests() gives your daily
quest level, today's daily quests and each reward threshold's rewards (each a
Collectable). Quest ids are QuestId and DailyQuestId members, or plain ints
for quests newer than the generated enums; the quest book's main quest ids stay
ints, as their game-data rows have no name.
on_quests_updated fires for every quest list, on_quest_started for each
quest a qst starts, on_quest_progress when a qpg names an active quest
(it changes nothing: the counters come with the next quest list),
on_quest_finished when a qfi or a quest popup finishes one, and
on_daily_quests_updated for every dql.
Disconnects¶
When the connection drops, state is emptied, as the game client empties its
own. The next login's gbd refills the player, castles, commanders, skills,
alliance and chat, and the movement
list the server pushes shortly after refills the movements.
client.on_disconnect(callback) tells you when a session drops; close()
does not fire it.
The callback runs on the receive thread as it shuts down. Keep it short and start a new login from another thread, or let the client do it.
Keeping the session¶
Made with keep_session=True (or with client.keep_session = True set
later), the client logs a dropped session in again by itself, on a thread of
its own. It is the same client, so callbacks and listen() streams carry on.
client.on_session_restored(callback) fires once the session is back and its
login data and movement list have reached state, on the callback thread after
the events they caused. client.on_session_lost(callback) fires, with the
error, when the attempts end without one:
client = EmpireClient(username="user", password="pass", keep_session=True)
client.on_disconnect(lambda: print("connection lost"))
client.on_session_restored(lambda: print("logged in again"))
client.on_session_lost(lambda error: print("gave up:", error))
client.login()
The game client does not log in again by itself: after a drop it shows a
reconnect dialog and waits for a click. So the timing is the library's, set on
the config: the first attempt comes relogin_first_delay seconds after the
drop (5 by default), and a failed one is retried after twice the last wait, up
to relogin_max_delay (300). A drop is often a kick: the same account logged
in elsewhere, by a person in the browser. A re-login 5 seconds later kicks that
person out again, so give them time:
config = EmpireConfig(relogin_first_delay=600, relogin_max_delay=1800)
client = EmpireClient(username="user", password="pass", config=config, keep_session=True)
Both delays must be above 0 and the first no longer than the cap; the config
refuses anything else with a ValidationError, also on assignment.
A refusal with a login cooldown counts
as a failed attempt too, and is retried no sooner than the seconds the server
named: after the longer of that cooldown and the doubled wait. The cooldown is
what client.remaining_login_cooldown() reports, counting down. Live, a kick
was followed by a refusal with a 55 second cooldown; the client waited it out
to the second and the next attempt restored the session. A refusal that
waiting does not cure (a ban, the wrong server, the client version, the
credentials) ends the attempts with an error in the log and in
on_session_lost, and the client stays logged out.
close() ends the attempts, also while one is waiting, and never starts one;
the next login() turns them back on. Live, a close() during the cooldown
wait returned at once, with the re-login thread gone and no login after it.
close() may also be called from an on_disconnect callback. A login() of
your own ends attempts still going before it logs in, so only one login runs;
if it fails it raises, and on_session_lost does not fire. Only a session
that was logged in is restored.
client.is_restoring_session tells a session being restored from one given
up: it is True from just after the on_disconnect callbacks until the session
is back, the attempts give up, or close() or a login() of your own ends
them; it is already False when on_session_restored or on_session_lost
runs, and still False inside an on_disconnect callback, as the re-login
starts after those. To follow a restore, register the callbacks below.
client.on_session_retry(callback) fires for each failed attempt with
the attempts so far, the seconds until the next and the failure, so a long
restore can raise an alert without a timer of your own. An attempt ended by
close() or a login() of your own does not fire it; the log says
re-login attempt for a restore and login attempt for login(retry=True):
def retrying(attempt: int, wait: float, error: Exception) -> None:
if attempt == 5:
print(f"still logged out after {attempt} attempts: {error}")
client.on_session_retry(retrying)
The first login can be retried the same way: client.login(retry=True) waits
and logs in again after a timeout, a network error or a login cooldown, on the
same delays, until a login holds. Each failed attempt fires on_session_retry.
A refusal that waiting does not cure raises at once, and a close() or a
newer login() from another thread ends the attempts and makes login()
raise the last failure.
An attack or occupation announced before the drop is not announced again when
the restored session lists it: on_incoming_attack and on_occupation_started
fire for the ones that are new. client.state.reannounce(movement_id) fires them
again on purpose; with when_listed=True it waits until the restored session
lists the movement, from on_session_restored too, which may come before the
movement list did. The same holds across close() and login(); see
what was announced.
From an asyncio program¶
The client is threaded, and its callbacks run on its own threads. On an event
loop, client.listen() streams them instead: every on_* registration of the
client, its state and its services, or only the ones you pass. Each event is a
ClientEvent whose name is the registration without on_ and whose args
are what a callback there is called with: (old, new) for
incoming_attack_updated and occupation_updated, (movement, captured) for
occupation_ended, (movement_id, movement) for the movement
callbacks, one model for the others, the error for session_lost, none for
disconnect and session_restored:
import asyncio
from empire_core import ClientEvent
async def watch(client):
await asyncio.to_thread(client.login)
async with client.listen(
client.state.on_incoming_attack,
client.state.on_movement_arrived,
client.alliance.on_chat_message,
client.on_disconnect,
) as events:
async for event in events:
match event:
case ClientEvent(name="incoming_attack", args=(movement,)):
print("attack from", movement.source_player_name)
case ClientEvent(name="movement_arrived", args=(movement_id, movement)):
print("arrived", movement_id)
case ClientEvent(name="chat_message", args=(message,)):
print(message.player_name, message.decoded_text)
case ClientEvent(name="disconnect"):
print("connection lost")
The events come on the loop in the order their packets came, across every
registration, through the one callback thread: no thread is started per event.
Service and disconnect events (chat, help, skill lists, new messages,
disconnect) are queued there behind the state callbacks, so a slow state
callback delays them too.
A stream outlives sessions, as callbacks do: a dropped session is a
disconnect event, a session keep_session restores is a session_restored
event, one it gives up on a session_lost event, and after client.close() and a new client.login() the
same stream delivers the new session's events. While the client is closed,
nothing arrives.
A stream of one registration is typed: its events' args are that
registration's callback parameters, so a type checker sees the Movement
pair of on_incoming_attack_updated. Streams of several registrations, and of
the movement callbacks (which take one or two parameters), have untyped args.
async with client.listen(client.state.on_incoming_attack_updated) as updates:
async for update in updates:
old, new = update.args # Movement, Movement
print(old.estimated_arrival, "->", new.estimated_arrival)
Subscriptions kept as data can name the registrations instead, by the event
names: client.listen(names={"incoming_attack", "chat_message"}). A name no
registration has raises ValueError when listen is called, so a typo does
not go unnoticed; names takes a collection, never a single string. A stream
by name has untyped args. Registration methods and names can be given
together, and the stream then has both. Only client.listen() with neither
streams every registration; an empty names streams none.
A stream listens for exactly its async with block: entering subscribes,
leaving (or events.close()) stops listening, and iterating a stream you did
not enter raises RuntimeError. A stream is entered once; call
client.listen() again for another. client.close_streams() ends every open
stream after the events already on their way; client.close() does not. A
pool calls close_streams() when it releases a client, so your streams never
run into the next lease.
Nothing is dropped while your loop keeps up. client.listen(maxsize=n) caps
the events waiting unread instead: one more stops the stream, which raises
EventStreamOverflowError after the events it holds. Read the state you need
again and listen anew.
Blocking calls stay blocking; run them off the loop with asyncio.to_thread,
as above, so a request that waits for its reply does not stall the loop:
Going deeper
State management documents the object-identity and container-swap rules in full.
API: GameState