Skip to content

Spy

Spy missions, risk planning and reports behind client.spy.

spy.service

Espionage: spy missions, sabotage and spy reports.

Classes:

Name Description
SpyOutcome

How a spy mission ended.

SpyStep

The command a spy mission was at when it ended.

SpyResult

Outcome of a spy mission.

SpyHandle

A spy mission sent by SpyService.send_instant_spy, to pass to SpyService.await_report.

SpyService

Spy missions planned for risk, sabotage, and reading spy reports.

logger module-attribute

logger = logging.getLogger(__name__)

SpyOutcome

Bases: str, Enum

How a spy mission ended.

Attributes:

Name Type Description
SUCCESS

The report was read.

SENT

The mission was sent; its report is not waited for.

NO_SPIES_AVAILABLE

No spy was free, after polling ssi for returning spies.

RISK_OVER_BUDGET

Even the whole pool stays above the risk ceiling, so nothing was sent.

COMMAND_FAILED

A request failed; SpyResult.error holds why and step which one.

TIMEOUT

No report for this mission arrived before the deadline.

REPORT_TARGET_MISMATCH

Only reports for other areas of the target's owner arrived before the deadline.

DISCONNECTED

The connection dropped while waiting for the report.

CANCELLED

The caller cancelled; SpyResult.mission is set when the spies had

SPY_CAUGHT

The spies were caught or kept away.

NO_SPY_DATA

The mission brought no army back, or the server has no report (bsd

SUCCESS class-attribute instance-attribute

SUCCESS = 'success'

The report was read.

SENT class-attribute instance-attribute

SENT = 'sent'

The mission was sent; its report is not waited for.

NO_SPIES_AVAILABLE class-attribute instance-attribute

NO_SPIES_AVAILABLE = 'no_spies_available'

No spy was free, after polling ssi for returning spies.

RISK_OVER_BUDGET class-attribute instance-attribute

RISK_OVER_BUDGET = 'risk_over_budget'

Even the whole pool stays above the risk ceiling, so nothing was sent.

COMMAND_FAILED class-attribute instance-attribute

COMMAND_FAILED = 'command_failed'

A request failed; SpyResult.error holds why and step which one.

TIMEOUT class-attribute instance-attribute

TIMEOUT = 'timeout'

No report for this mission arrived before the deadline.

REPORT_TARGET_MISMATCH class-attribute instance-attribute

REPORT_TARGET_MISMATCH = 'report_target_mismatch'

Only reports for other areas of the target's owner arrived before the deadline.

DISCONNECTED class-attribute instance-attribute

DISCONNECTED = 'disconnected'

The connection dropped while waiting for the report.

CANCELLED class-attribute instance-attribute

CANCELLED = 'cancelled'

The caller cancelled; SpyResult.mission is set when the spies had already left, and they keep going until recalled.

SPY_CAUGHT class-attribute instance-attribute

SPY_CAUGHT = 'spy_caught'

The spies were caught or kept away.

NO_SPY_DATA class-attribute instance-attribute

NO_SPY_DATA = 'no_spy_data'

The mission brought no army back, or the server has no report (bsd error 130 or 66, then in SpyResult.error); the area is not known to be empty.

SpyStep

Bases: str, Enum

The command a spy mission was at when it ended.

Attributes:

Name Type Description
SSI
CSM
SNE
BSD

SSI class-attribute instance-attribute

SSI = 'ssi'

CSM class-attribute instance-attribute

CSM = 'csm'

SNE class-attribute instance-attribute

SNE = 'sne'

BSD class-attribute instance-attribute

BSD = 'bsd'

SpyResult dataclass

SpyResult(outcome: SpyOutcome, step: SpyStep | None = None, error: EmpireError | None = None, message_id: int | None = None, report: SpyReportResponse | None = None, mission: SendSpyResponse | None = None)

Outcome of a spy mission.

Attributes:

Name Type Description
outcome SpyOutcome

How the mission ended; success is outcome is SpyOutcome.SUCCESS.

step SpyStep | None

The command the mission ended at; None when it ended between commands (no spies, risk) or was read to the end.

error EmpireError | None

The exception that ended the mission, for COMMAND_FAILED and a bsd without a report; branch on it as on any CommandError.

message_id int | None

The report's message id, once a report for the mission arrived.

report SpyReportResponse | None

The spy report, when one was read.

mission SendSpyResponse | None

The csm reply for the mission sent, with its movement and risk.

outcome instance-attribute

outcome: SpyOutcome

step class-attribute instance-attribute

step: SpyStep | None = None

error class-attribute instance-attribute

error: EmpireError | None = None

message_id class-attribute instance-attribute

message_id: int | None = None

report class-attribute instance-attribute

report: SpyReportResponse | None = None

mission class-attribute instance-attribute

mission: SendSpyResponse | None = None

success property

success: bool

Whether the report was read.

army property

army: SpyArmy | None

The report's defenders by position, or None without a report or army.

SpyHandle dataclass

SpyHandle(target_x: int, target_y: int, target_kingdom: Kingdom, spy_type: SpyType, cancel_event: Event = Event(), mission: SendSpyResponse | None = None, arrival_eta: float | None = None, result: SpyResult | None = None)

A spy mission sent by SpyService.send_instant_spy, to pass to SpyService.await_report.

Until it is awaited or cancelled the service listens to sne and keeps the spy logs that may be its report; a handle left alone stops listening 10 minutes after its report was due.

Attributes:

Name Type Description
target_x int

Target X coordinate

target_y int

Target Y coordinate

target_kingdom Kingdom

Target kingdom

spy_type SpyType

MILITARY or ECO

cancel_event Event

Set to cancel the mission's wait, as cancel does; the cancel event given to send_instant_spy, if any

mission SendSpyResponse | None

The csm reply; None when nothing was sent

arrival_eta float | None

time.monotonic() when the spies arrive; None when nothing was sent

result SpyResult | None

How the mission ended: set when nothing was sent, when it was cancelled before sending, and by await_report, which returns it from then on

Methods:

Name Description
cancel

Stop waiting for this mission: await_report returns CANCELLED at once.

target_x instance-attribute

target_x: int

target_y instance-attribute

target_y: int

target_kingdom instance-attribute

target_kingdom: Kingdom

spy_type instance-attribute

spy_type: SpyType

cancel_event class-attribute instance-attribute

cancel_event: Event = field(default_factory=threading.Event)

mission class-attribute instance-attribute

mission: SendSpyResponse | None = None

arrival_eta class-attribute instance-attribute

arrival_eta: float | None = None

result class-attribute instance-attribute

result: SpyResult | None = None

movement_id property

movement_id: int | None

The spy movement's id, for client.movements.recall; None when nothing was sent.

target property

target: MovementRecord | None

The csm reply's movement, whose target area a report is matched against.

cancel

cancel() -> None

Stop waiting for this mission: await_report returns CANCELLED at once.

The spies are not recalled. They arrive and their report comes as usual: until 10s after their arrival the mission keeps its place in the order reports are given out, and the sne subscription, so its report is dropped rather than read as another mission's. To turn them back, recall the movement with client.movements.recall(handle.movement_id), which the client allows only for your own spies still heading to the target.

Client: SpyMapmovementVO.canBeRetreated (bundle line 43759), CastleAskRetreatDialog.onClick (bundle line 33234)

SpyService

SpyService(client: EmpireClient)

Bases: BaseService

Spy missions planned for risk, sabotage, and reading spy reports.

Methods:

Name Description
forward_report

Share a spy report with other players in game.

get_screen_info

What a mission against a target would face: its guards, your free spies and the target's map row.

get_report

Read any spy report in the mailbox by its message id.

auto_spy

Spy a target at once with the auto-spy subscription: no spies, no movement, no mail.

spies_in_use

Spies on your own spy movements, out or on their way home, as the state tracks them.

total_spies

All your spies, home or out, counted as the client does: the gms count plus boosts.

available_spies

Spies at home: total_spies less spies_in_use, as the client counts them.

send_spy_mission

Send one spy mission as given, without planning it or waiting for its report.

send_instant_spy

Plan and send a military or economy spy mission, without waiting for its report.

await_report

Wait for a sent mission's report and read it.

execute_instant_spy

Send a military or economy spy mission and read its report.

send_sabotage

Send a sabotage mission with the fewest spies that reach the pool's lowest risk.

forward_report

forward_report(message_id: int, player_ids: list[int]) -> bool

Share a spy report with other players in game.

Returns False when the server rejects it — a report can age out of the mailbox, and the recipients may no longer be reachable. A recipient who has the report already (error 167) counts as done, as in the client.

Parameters:

Name Type Description Default
message_id int

The report's message id: SpyResult.message_id, or a MessageInfo.message_id of a spy log

required
player_ids list[int]

The recipients. The client offers the members of your alliance other than you, AllianceMember.player_id from client.alliance.get_local_members()

required

Raises:

Type Description
ValueError

player_ids is empty; the client's forward button stays disabled until a recipient is picked

Client: CastleForwardMessageDialog.fillList and sendMessage (bundle lines 60719, 60740), checkEnableForwardButton (bundle line 60753), MFSCommand.executeCommand (bundle line 125409)

get_screen_info

get_screen_info(target_x: int, target_y: int, target_kingdom: Kingdom = GREEN) -> SpyScreenInfoResponse

What a mission against a target would face: its guards, your free spies and the target's map row.

Raises:

Type Description
CommandError

The server refused, e.g. for a target you cannot spy

Client: C2SGetSpyInfo (bundle line 22504), CastleSpyData.parse_SSI (bundle line 139962)

get_report

get_report(message_id: int) -> SpyReportResponse | None

Read any spy report in the mailbox by its message id.

Returns None when the server has no report for it (error 130 NO_SPY_DATA or 66 NO_SUCH_MESSAGE), which the client shows as "no spy data".

Parameters:

Name Type Description Default
message_id int

A spy log's MessageInfo.message_id from an sne push or the mailbox, or SpyResult.message_id

required

Raises:

Type Description
CommandError

The server refused for another reason

Client: CastleSpyData.getSpyLog (bundle line 139963), BSDCommand.executeCommand (bundle line 125223)

auto_spy

auto_spy(target_x: int, target_y: int) -> AutoSpyResponse

Spy a target at once with the auto-spy subscription: no spies, no movement, no mail.

The client offers this only while the subscription covers the target; what the server answers without it is untested.

Raises:

Type Description
CommandError

The server refused

Client: ButtonAutoSpyComponent.onClick (bundle line 109977), SSUCommand.executeCommand (bundle line 128568)

spies_in_use

spies_in_use() -> int

Spies on your own spy movements, out or on their way home, as the state tracks them.

Client: CastleSpyData.getNumAvailableSpies (bundle line 139970), which counts the spyCount of every SpyMapmovementVO you own

total_spies

total_spies(*, research_ids: Iterable[int] | None = None, legend_skill_ids: Iterable[int] | None = None, title_ids: Iterable[int] | None = None, island_title_id: int | None = None, legend_target: bool = False) -> int | None

All your spies, home or out, counted as the client does: the gms count plus boosts.

The count is for the whole account, not one castle. Each boost left as None is read from state: your finished research (rei), legend skills (skl), and the glory, Berimond and Storm Islands titles your points and ranks give (ufa, ufp, uar). Pass a value to count that instead, () for none. A boost reads the game data, loaded on first use; after login the state's titles always count.

Parameters:

Name Type Description Default
research_ids Iterable[int] | None

Your finished research ids, the BR of the rei section

None
legend_skill_ids Iterable[int] | None

SkillList.legend_skill_ids; counted only with legend_target

None
title_ids Iterable[int] | None

Every glory and Berimond title you hold, each one below your current title included, as the client lists them

None
island_title_id int | None

Your Storm Islands title, -1 for none; the titles below it count too

None
legend_target bool

Add legend skills: the client does for a target whose owner is a legend or that is a landmark, for your own legend status, and always in the attack screen's spy alert

False

Returns:

Type Description
int | None

The count, or None before the login gbd brought gms

Raises:

Type Description
GameDataNotLoadedError

A boost counts and the game data cannot be loaded

Client: CastleSpyData.getNumAllSpies (bundle line 139976), called with the target's ownerInfo.isLegend (bundle lines 34325, 34342, 127082) or userData.isLegend (bundle line 102169); CastleTitleSystemHelper.returnTitleEffectValue (bundle line 4420) over CastleTitleData.thisUsersTitles (bundle line 21073)

available_spies

available_spies(*, research_ids: Iterable[int] | None = None, legend_skill_ids: Iterable[int] | None = None, title_ids: Iterable[int] | None = None, island_title_id: int | None = None, legend_target: bool = False) -> int | None

Spies at home: total_spies less spies_in_use, as the client counts them.

The client uses this count to offer the spy button. The spy dialog itself sends with the ssi reply's available_spies, as execute_instant_spy does.

Takes the arguments of total_spies, read from state the same way.

Returns:

Type Description
int | None

The count, which can be negative as in the client, or None before gms arrived

Client: CastleSpyData.getNumAvailableSpies (bundle line 139970)

send_spy_mission

send_spy_mission(source_castle_id: int, target_x: int, target_y: int, target_kingdom: Kingdom = GREEN, *, spy_type: SpyType, spies: int, accuracy_or_damage: int, horse_booster_id: int = -1, feathers: bool = False, slowdown: int = 0, spend_rubies: bool = False) -> SendSpyResponse

Send one spy mission as given, without planning it or waiting for its report.

A horse that costs rubies, unless paid with feathers, and a slowdown spend rubies; see "Spending rubies" in the guides.

Parameters:

Name Type Description Default
source_castle_id int

The castle the spies leave from, one of yours

required
target_x int

Target X coordinate

required
target_y int

Target Y coordinate

required
target_kingdom Kingdom

Target kingdom

GREEN
spy_type SpyType

MILITARY, ECO or SABOTAGE; plague monks are not sent this way

required
spies int

How many spies to send

required
accuracy_or_damage int

Accuracy percent (50-100), or damage percent (10-50) for sabotage

required
horse_booster_id int

A horse's wod id to speed the spies up (-1 = none)

-1
feathers bool

Use the instant spy horse and pay for it with feathers; wins over horse_booster_id

False
slowdown int

Seconds to delay the arrival by; costs rubies

0
spend_rubies bool

Allow a horse or slowdown that costs rubies

False

Raises:

Type Description
ValueError

For SpyType.PLAGUE

ValueError

The horse or slowdown costs rubies and spend_rubies is False

GameDataNotLoadedError

A horse is picked without feathers and the game data cannot be loaded

CommandError

The server refused the mission

Client: CastlePostSpyDialog.spyCastle (bundle line 38459), C2SCreateSpyMovementVO (bundle line 100126)

send_instant_spy

send_instant_spy(source_castle_id: int, target_x: int, target_y: int, target_kingdom: Kingdom = GREEN, risk_tolerance: int | None = None, accuracy: int = MAX_ACCURACY, *, spy_type: SpyType = MILITARY, horse_booster_id: int = -1, feathers: bool = False, slowdown: int = 0, spend_rubies: bool = False, wait_for_spies: float | None = None, cancel: Event | None = None) -> SpyHandle

Plan and send a military or economy spy mission, without waiting for its report.

Plans and sends as execute_instant_spy does, which is this and await_report. The handle listens for the report from before csm is sent, so a report that comes before await_report is called is kept for it.

Parameters:

Name Type Description Default
source_castle_id int

The castle the spies leave from, one of yours

required
target_x int

Target X coordinate

required
target_y int

Target Y coordinate

required
target_kingdom Kingdom

Target kingdom

GREEN
risk_tolerance int | None

Ceiling on the chance of being caught, as a percentage

None
accuracy int

Spy accuracy (50-100)

MAX_ACCURACY
spy_type SpyType

MILITARY or ECO

MILITARY
horse_booster_id int

A horse's wod id to speed the spies up (-1 = none)

-1
feathers bool

Use the instant spy horse and pay for it with feathers

False
slowdown int

Seconds to delay the arrival by; costs rubies

0
spend_rubies bool

Allow a horse or slowdown that costs rubies; see "Spending rubies" in the guides

False
wait_for_spies float | None

Most seconds to keep asking ssi while no spy is at home or the risk is over risk_tolerance; None asks once

None
cancel Event | None

Ends the mission's wait when set, as SpyHandle.cancel does; looked at between requests (see empire_core.utils.cancel)

None

Returns:

Type Description
SpyHandle

The mission's handle; when nothing was sent, handle.result says why.

Raises:

Type Description
ValueError

For a spy type other than MILITARY or ECO, or the horse or slowdown costs rubies and spend_rubies is False, before anything is sent

GameDataNotLoadedError

A horse is picked without feathers and the game data cannot be loaded

await_report

await_report(handle: SpyHandle, max_wait: float | None = None) -> SpyResult

Wait for a sent mission's report and read it.

Blocks until the spies arrive plus 10s for the report, or max_wait if that is shorter; do not call this from a state callback. Returns CANCELLED once the handle is cancelled: at once through SpyHandle.cancel, within a second when its event is set directly. A bsd already asked for is read to the end, so a report read while the cancel comes is returned. The handle stops listening when this returns, and its result is kept in handle.result: a later call returns it at once, and a call made while another waits returns what that one does, or TIMEOUT after its own max_wait.

sne carries no mission id. A spy log whose header names a mission's target (kingdom, owner, area type and name, from the csm reply's movement) goes to the waiting mission for that target whose spies arrive first, and one whose report is for another position is handed on to the next. Two missions to one target so get the reports in the order their spies arrive. A mission that stopped waiting without its report, cancelled or at max_wait, keeps its place until 10s after its spies arrive, and the report given to it is dropped. That is a client-side heuristic: no field ties a report to its mission.

Parameters:

Name Type Description Default
handle SpyHandle

From send_instant_spy

required
max_wait float | None

Most seconds to wait from now; None waits for the trip plus 10s

None

Returns:

Type Description
SpyResult

SpyResult with the report or why there is none; handle.result

SpyResult

as it is when the mission ended before its report wait.

execute_instant_spy

execute_instant_spy(source_castle_id: int, target_x: int, target_y: int, target_kingdom: Kingdom = GREEN, risk_tolerance: int | None = None, accuracy: int = MAX_ACCURACY, *, spy_type: SpyType = MILITARY, horse_booster_id: int = -1, feathers: bool = False, slowdown: int = 0, spend_rubies: bool = False, max_wait: float | None = None, wait_for_spies: float | None = None, cancel: Event | None = None) -> SpyResult

Send a military or economy spy mission and read its report.

This is send_instant_spy then await_report; call those two to do something else while the spies travel.

Nothing is paid unless asked: by default the spies travel without a horse. feathers uses the instant spy horse, paid with feathers, which the client sends as HBW -1 with PTT 1 and which wins over horse_booster_id, as in the client. A horse that costs rubies and a slowdown need spend_rubies; see "Spending rubies" in the guides.

The mission is costed with the client's risk floor for the target: none for an NPC area such as a robber baron camp, 5% for a player's area and for NPCs the game fights like players. Where the ssi reply does not tell whose area it is (see row_risk_flags), the 5% floor is kept: the mission may then be planned at a higher risk than the client shows, and a risk_tolerance under 5 skips it.

The report arrives as an sne push once the spies get there. Every sne in the wait is looked at without being taken from other listeners; only a spy log whose header names this mission's target (kingdom, owner, area type and name, from the csm reply's movement) and whose report, a caught mission's too, is for the target's position counts; the rest are skipped. Two missions to one target at once get their reports in arrival order (see await_report).

Asks ssi once: with no spy at home, or no mission within risk_tolerance, it returns at once, as the client's spy dialog shows the pool as it is. wait_for_spies asks again every 2s for up to that many seconds, for spies still on their way home. Then it blocks until the spies arrive (the csm reply's travel time) plus 10s for the report, or max_wait if that is shorter; do not call this from a state callback.

Setting cancel ends the call with CANCELLED. It is looked at between requests, never during one: a request in flight ends with its own reply or timeout, so no waiter is abandoned and no late reply can reach the next caller of that command. Spies already sent keep going (result.mission is set); client.movements.recall with its movement_id turns them back.

Parameters:

Name Type Description Default
source_castle_id int

The castle the spies leave from, one of yours: CastleInfo.castle_id from client.castle.get_all() or Castle.id from client.state.get_castles()

required
target_x int

Target X coordinate

required
target_y int

Target Y coordinate

required
target_kingdom Kingdom

Target kingdom

GREEN
risk_tolerance int | None

Ceiling on the chance of being caught, as a percentage. Missions always run at the lowest risk the spy pool allows; this only decides whether to send at all, so a target that stays above it is skipped rather than spied badly.

None
accuracy int

Spy accuracy (50-100). Lower values need fewer spies for the same risk but return a less complete report.

MAX_ACCURACY
spy_type SpyType

MILITARY for the army, ECO for the resources. The client offers ECO only for castles and other players' outposts.

MILITARY
horse_booster_id int

A horse's wod id to speed the spies up (-1 = none); sent as -1 whenever feathers are used, as the client does

-1
feathers bool

Use the instant spy horse and pay for it with feathers

False
slowdown int

Seconds to delay the arrival by; costs rubies

0
spend_rubies bool

Allow a horse or slowdown that costs rubies

False
max_wait float | None

Most seconds to wait for the report after the csm reply; None waits for the trip plus 10s

None
wait_for_spies float | None

Most seconds to keep asking ssi while no spy is at home or the risk is over risk_tolerance; None asks once

None
cancel Event | None

Ends the call with CANCELLED when set

None

Client: CastlePostSpyDialog.spyCastle (bundle line 38459), CastleStartSpyVO.setSpyValues (bundle line 140004), CastleSpyDialogSpyState.fillSpyTypeList (bundle line 72238), HorseTravelboosterVO.isPayedWithPegasusTickets (bundle line 118825)

Returns:

Type Description
SpyResult

SpyResult with the report or why there is none.

Raises:

Type Description
ValueError / GameDataNotLoadedError

See send_instant_spy

send_sabotage

send_sabotage(source_castle_id: int, target_x: int, target_y: int, target_kingdom: Kingdom = GREEN, damage: int = MIN_DAMAGE, risk_tolerance: int | None = None, *, horse_booster_id: int = -1, feathers: bool = False, slowdown: int = 0, spend_rubies: bool = False) -> SpyResult

Send a sabotage mission with the fewest spies that reach the pool's lowest risk.

Sabotage spies travel at a ninth of a spy's speed (TRAVELSPEED_SABOTAGE 50 against 450). The report is not waited for: the outcome is SENT with the csm reply in mission. The damage is checked against what the target's owner level allows when the ssi reply carries the owner's record; the client's other refusals (target kind, protection, cooldown) are left to the server.

Parameters:

Name Type Description Default
source_castle_id int

The castle the spies leave from, one of yours

required
target_x int

Target X coordinate

required
target_y int

Target Y coordinate

required
target_kingdom Kingdom

Target kingdom

GREEN
damage int

Damage percent, 10 up to max_sabotage_damage(owner_level)

MIN_DAMAGE
risk_tolerance int | None

Ceiling on the chance of being caught, as a percentage

None
horse_booster_id int

A horse's wod id to speed the spies up (-1 = none)

-1
feathers bool

Use the instant spy horse and pay for it with feathers

False
slowdown int

Seconds to delay the arrival by; costs rubies

0
spend_rubies bool

Allow a horse or slowdown that costs rubies; see "Spending rubies" in the guides

False

Raises:

Type Description
ValueError

A damage the client would not send, or the horse or slowdown costs rubies and spend_rubies is False, before anything is sent

GameDataNotLoadedError

A horse is picked without feathers and the game data cannot be loaded

Client: CastleSpyDialogSabotageState.updateSpyVO and spyCastle (bundle lines 72203-72205), ACastleSpyDialogState.updateSliderForDamage (bundle line 34360), CastleStartSpyVO.setSabotageValues (bundle line 140006)

spy.pool

How many spies you have: the gms count plus research, legend skill and title boosts.

Client: CastleSpyData.getNumAllSpies and getNumAvailableSpies (bundle lines 139970-139978)

Functions:

Name Description
island_title_chain

The island titles you hold with one: it and every title below it, lowest first.

total_spies

The client's count of all your spies, home or out.

research_spy_bonus

Extra spies from your finished research, uncapped as the client totals it.

legend_spy_bonus

Extra spies from your legend skills.

title_spy_percent

Your titles' spy boost in percent, uncapped as the client totals it.

island_title_chain

island_title_chain(game_data: GameData, island_title_id: int) -> list[Title | int]

The island titles you hold with one: it and every title below it, lowest first.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables

required
island_title_id int

Your current Storm Islands title, -1 or an unknown id for none

required

Client: CastleTitleData.getUsersTitleVectorFromSystem for ISLAND_TITLE (bundle line 21119)

total_spies

total_spies(max_spies: int, *, research_bonus: float = 0, legend_bonus: float = 0, title_percent: float = 0) -> int

The client's count of all your spies, home or out.

int((max_spies + int(research_bonus) + int(legend_bonus)) * (1 + title_percent / 100)), each int truncating. The client adds legend_bonus only for a target whose owner is a legend; pass 0 otherwise.

Parameters:

Name Type Description Default
max_spies int

MaxSpiesResponse.max_spies, from client.state.get_max_spies()

required
research_bonus float

research_spy_bonus

0
legend_bonus float

legend_spy_bonus

0
title_percent float

title_spy_percent

0

Client: CastleSpyData.getNumAllSpies (bundle line 139976)

research_spy_bonus

research_spy_bonus(game_data: GameData, research_ids: Iterable[int]) -> float

Extra spies from your finished research, uncapped as the client totals it.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables

required
research_ids Iterable[int]

Your finished research ids, the BR of the rei section

required

Client: CastleResearchData.getResearchEffectValue and getResearchEffectsByType (bundle lines 139343-139357), CastleEffectsHelper.getTotalEffectValue with ignoreCap (bundle line 4139)

legend_spy_bonus

legend_spy_bonus(game_data: GameData, legend_skill_ids: Iterable[int]) -> float

Extra spies from your legend skills.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables

required
legend_skill_ids Iterable[int]

SkillList.legend_skill_ids, from client.skills.get_skills()

required

Client: CastleLegendSkillData.getTotalValueOfLegendSkillEffect (bundle line 112086)

title_spy_percent

title_spy_percent(game_data: GameData, title_ids: Iterable[int]) -> float

Your titles' spy boost in percent, uncapped as the client totals it.

Each title counts its first spy boost only.

Parameters:

Name Type Description Default
game_data GameData

Loaded tables

required
title_ids Iterable[int]

Every title you hold; for a Storm Islands title, island_title_chain

required

Client: CastleTitleSystemHelper.returnTitleEffectValue and returnTitleEffectsByType (bundle lines 4420-4428), EffectsHandlerVO.getBonusVOByEffectType (bundle line 25098)

spy.risk

Espionage risk model, ported from the game client's SpyConst.

Constants and formulas are taken verbatim from SpyConst (dll lines 19738-19757), so a mission can be costed before it is sent. getSpyRisk reads:

ratioGuardSpy = MAX_GUARD / MAX_SPY
ratioAccuracy = 50 - (MAX_ACCURACY + MIN_ACCURACY) / 2
r = clamp(-(ratioGuardSpy * spies - guards) + ratioAccuracy + accuracy, floor, MAX_RISK_SPY)
o = clamp(floor(guards / spies / ratioGuardSpy * (ratioAccuracy + accuracy)), floor, MAX_RISK_SPY)
risk = round((r + o) / 2)

The floor is MIN_RISK_SPY_PLAYER unless the target is a dungeon or not a player's, then MIN_RISK_SPY_DUNGEON; see risk_target_flags. getSabotageRisk is the same with the damage for the accuracy, its own ratio and the fixed MIN_RISK_SABOTAGE..MAX_RISK_SABOTAGE range.

Classes:

Name Description
SpyPlan

How a mission should be sent: spies, the risk it buys, and at what detail.

SabotagePlan

How a sabotage should be sent: spies, the risk it buys, and the damage.

Functions:

Name Description
spy_risk

The percentage chance of a military or economy mission being caught, as the client shows it.

sabotage_risk

The percentage chance of a sabotage mission being caught, as the client shows it.

max_damaged_buildings

How many buildings a sabotage of an owner at this level can damage.

max_sabotage_damage

The most damage the client lets a sabotage of an owner at this level do, 0 when it offers no sabotage.

risk_target_flags

The client's (isDungeon, isPlayer) for a target, in the order spy_risk takes them.

row_risk_flags

The client's (isDungeon, isPlayer) for a target's map row, or None where its owner is not traced here.

plan_mission

The library's cheapest military or economy mission inside the risk ceiling.

plan_sabotage

The library's cheapest sabotage at this damage inside the risk ceiling.

Attributes:

Name Type Description
SPY_VALIDITY

SpyConst.SPY_VALIDITY (dll line 19739): seconds a spy report's army stays current.

MAX_GUARD
MAX_SPY
MIN_RISK_SPY_PLAYER
MIN_RISK_SPY_DUNGEON
MAX_RISK_SPY
MIN_ACCURACY
MAX_ACCURACY
MIN_RISK_SABOTAGE
MAX_RISK_SABOTAGE
MIN_DAMAGE
MAX_DAMAGE
DAMAGE_PER_BUILDING
MAX_SABOTAGE_COOLDOWN
SABOTAGE_PROTECTION_WINDOW
TRAVELSPEED_SPY
TRAVELSPEED_SABOTAGE
DAMAGE_PER_SPY
SABOTAGE_PROTECTION_THRESHOLD

SpyConst.SABTOAGE_PROTECTION_THRESHOLD, spelled that way in the client.

MAX_DEPLOYABLE_PLAGUEMONKS
MAX_OWNABLE_PLAGUEMONKS
OUTPOST_AREA_TYPES

Area types built as an OutpostMapobjectVO: the outpost and its capital and

SPY_VALIDITY module-attribute

SPY_VALIDITY = 172800

SpyConst.SPY_VALIDITY (dll line 19739): seconds a spy report's army stays current.

MAX_GUARD module-attribute

MAX_GUARD = 180

MAX_SPY module-attribute

MAX_SPY = 15

MIN_RISK_SPY_PLAYER module-attribute

MIN_RISK_SPY_PLAYER = 5

MIN_RISK_SPY_DUNGEON module-attribute

MIN_RISK_SPY_DUNGEON = 0

MAX_RISK_SPY module-attribute

MAX_RISK_SPY = 95

MIN_ACCURACY module-attribute

MIN_ACCURACY = 50

MAX_ACCURACY module-attribute

MAX_ACCURACY = 100

MIN_RISK_SABOTAGE module-attribute

MIN_RISK_SABOTAGE = 10

MAX_RISK_SABOTAGE module-attribute

MAX_RISK_SABOTAGE = 90

MIN_DAMAGE module-attribute

MIN_DAMAGE = 10

MAX_DAMAGE module-attribute

MAX_DAMAGE = 50

DAMAGE_PER_BUILDING module-attribute

DAMAGE_PER_BUILDING = 10

MAX_SABOTAGE_COOLDOWN module-attribute

MAX_SABOTAGE_COOLDOWN = 43200

SABOTAGE_PROTECTION_WINDOW module-attribute

SABOTAGE_PROTECTION_WINDOW = 345600

TRAVELSPEED_SPY module-attribute

TRAVELSPEED_SPY = 450

TRAVELSPEED_SABOTAGE module-attribute

TRAVELSPEED_SABOTAGE = 50

DAMAGE_PER_SPY module-attribute

DAMAGE_PER_SPY = 0.02

SABOTAGE_PROTECTION_THRESHOLD module-attribute

SABOTAGE_PROTECTION_THRESHOLD = 12

SpyConst.SABTOAGE_PROTECTION_THRESHOLD, spelled that way in the client.

MAX_DEPLOYABLE_PLAGUEMONKS module-attribute

MAX_DEPLOYABLE_PLAGUEMONKS = 20

MAX_OWNABLE_PLAGUEMONKS module-attribute

MAX_OWNABLE_PLAGUEMONKS = 200

RATIO_GUARD_SPY module-attribute

RATIO_GUARD_SPY = MAX_GUARD / MAX_SPY

RATIO_ACCURACY module-attribute

RATIO_ACCURACY = 50 - (MAX_ACCURACY + MIN_ACCURACY) / 2

RATIO_DAMAGE module-attribute

RATIO_DAMAGE = 50 - (MAX_DAMAGE + MIN_DAMAGE) / 2

OUTPOST_AREA_TYPES module-attribute

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

Area types built as an OutpostMapobjectVO: the outpost and its capital and metropolis subclasses (WorldmapObjectFactory.mapObjectVOs, bundle line 5357; CapitalMapobjectVO bundle line 18760, MetropolMapobjectVO 21637).

SpyPlan dataclass

SpyPlan(spies: int, risk: int, accuracy: int = MAX_ACCURACY)

How a mission should be sent: spies, the risk it buys, and at what detail.

Attributes:

Name Type Description
spies int
risk int
accuracy int

spies instance-attribute

spies: int

risk instance-attribute

risk: int

accuracy class-attribute instance-attribute

accuracy: int = MAX_ACCURACY

SabotagePlan dataclass

SabotagePlan(spies: int, risk: int, damage: int)

How a sabotage should be sent: spies, the risk it buys, and the damage.

Attributes:

Name Type Description
spies int
risk int
damage int

spies instance-attribute

spies: int

risk instance-attribute

risk: int

damage instance-attribute

damage: int

spy_risk

spy_risk(spies: int, guards: int, accuracy: int | float = MAX_ACCURACY, *, player_target: bool = True, dungeon: bool = False) -> int

The percentage chance of a military or economy mission being caught, as the client shows it.

Parameters:

Name Type Description Default
spies int

Spies sent, at least 1

required
guards int

Guards at the target, SpyScreenInfoResponse.guard_count

required
accuracy int | float

Accuracy percent, 50-100

MAX_ACCURACY
player_target bool

The client's isPlayer; see risk_target_flags

True
dungeon bool

The client's isDungeon; see risk_target_flags

False

Client: SpyConst.getSpyRisk (dll lines 19745-19748)

sabotage_risk

sabotage_risk(spies: int, guards: int, damage: int | float) -> int

The percentage chance of a sabotage mission being caught, as the client shows it.

The client costs a plague monk mission with the same formula.

Parameters:

Name Type Description Default
spies int

Spies sent, at least 1

required
guards int

Guards at the target, SpyScreenInfoResponse.guard_count

required
damage int | float

Damage percent, 10-50

required

Client: SpyConst.getSabotageRisk (dll line 19749), CastleStartSpyVO.setSabotageValues and setPlagueValues (bundle lines 140006-140007)

max_damaged_buildings

max_damaged_buildings(owner_level: int) -> int

How many buildings a sabotage of an owner at this level can damage.

Client: CombatConst.getMaxDamagedBuildings (dll line 18896)

max_sabotage_damage

max_sabotage_damage(owner_level: int) -> int

The most damage the client lets a sabotage of an owner at this level do, 0 when it offers no sabotage.

The damage slider runs from MIN_DAMAGE to MAX_DAMAGE and stops at ten per building the target can lose; the sabotage tab is disabled when that is no building.

Parameters:

Name Type Description Default
owner_level int

The target owner's level

required

Client: ACastleSpyDialogState.updateSliderForDamage (bundle line 34360), the sabotage tab check (bundle line 34338)

risk_target_flags

risk_target_flags(owner_id: int, area_type: int | None) -> tuple[bool, bool]

The client's (isDungeon, isPlayer) for a target, in the order spy_risk takes them.

isDungeon is an owner below 0 that is no alien invasion or collector; isPlayer is anything but an outpost, or an outpost a player owns. Pass them as spy_risk(..., dungeon=..., player_target=...).

Parameters:

Name Type Description Default
owner_id int

The target owner's player id, e.g. MapObject.owner_id or a map row's owner

required
area_type int | None

The target's area type

required

Client: CastleStartSpyVO.setSpyValues (bundle line 140004), PlayerHelper.isNpcPvpPlayer (bundle line 4689)

row_risk_flags

row_risk_flags(row: list[Any] | None) -> tuple[bool, bool] | None

The client's (isDungeon, isPlayer) for a target's map row, or None where its owner is not traced here.

NPC dungeons and camps have fixed owners below 0 that the game does not fight like players; a faction invasion camp names its owner in the row; alien camps and the areas whose row names the owner are read with owner_id_from_row. Not traced: treasure dungeons and camps, shadow areas (whose owner may be a collector) and any other type; a samurai alien camp has no map object in the client.

Parameters:

Name Type Description Default
row list[Any] | None

The target's raw map row, e.g. SpyScreenInfoResponse.target_row().raw_data

required

Client: WorldmapObjectFactory.parseWorldMapArea (bundle line 5343) and the parseAreaInfo of each map object

plan_mission

plan_mission(guards: int, available: int, accuracy: int = MAX_ACCURACY, max_risk: int = MAX_RISK_SPY, *, player_target: bool = True, dungeon: bool = False) -> SpyPlan | None

The library's cheapest military or economy mission inside the risk ceiling.

Risk falls as accuracy falls, so accuracy is walked down from accuracy to MIN_ACCURACY until the whole pool's risk fits the ceiling. The most detailed report that fits is chosen, then the fewest spies that reach the same risk, leaving the rest of the pool for other targets. This search is the library's own: the client's dialog lets the player pick both values and only colours its slider by risk (ACastleSpyDialogState.getAccuracyByRisk, bundle line 34363).

None means even the least accurate mission with every spy available stays above the ceiling: a target to skip rather than spy badly.

plan_sabotage

plan_sabotage(guards: int, available: int, damage: int, max_risk: int = MAX_RISK_SABOTAGE) -> SabotagePlan | None

The library's cheapest sabotage at this damage inside the risk ceiling.

The damage is kept as asked; the fewest spies that reach the whole pool's risk are sent. None when even the whole pool stays above the ceiling.

Parameters:

Name Type Description Default
guards int

Guards at the target

required
available int

Spies at hand

required
damage int

Damage percent, MIN_DAMAGE up to max_sabotage_damage of the target's owner

required
max_risk int

Ceiling on the risk, percent

MAX_RISK_SABOTAGE

spy.models

Spy protocol models.

Commands:

  • csm: Send spy mission
  • ssi: Spy screen info
  • ssu: Auto-spy, a report without sending spies
  • gms: Maximum spies, a login section and push

Classes:

Name Description
SendSpyRequest

Send a spy mission to a target.

SendSpyResponse

The spy mission the server created.

SpyScreenInfoRequest

Get what a spy mission against a target would face: its guards and the spies at hand.

SpyTargetArea

The target's map rows and owner records, and your own protection: the gaa block of ssi.

SpyScreenInfoResponse

What a spy mission against a target would face, and the spies at hand.

AutoSpyRequest

Spy a target at once, without sending spies: the auto-spy subscription's button.

AutoSpyResponse

An auto-spy report, the same shape as a bsd spy report.

MaxSpiesResponse

How many spies you have, before research, title and legend skill boosts.

PlagueMonkInfoResponse

Your plague monks.

logger module-attribute

logger = logging.getLogger(__name__)

SendSpyRequest pydantic-model

Bases: BaseRequest

Send a spy mission to a target.

Command: csm Payload: {"SID": castle_id, "TX": target_x, "TY": target_y, "SC": spy_count, "ST": spy_type, "SE": accuracy_or_damage, "HBW": horse_booster_id, "KID": target_kingdom, "PTT": feathers, "SD": slowdown}

The keys follow the client's order. SE is the damage (10-50) for a sabotage mission and the accuracy (50-100) for any other; the client sends the slider's value unrounded. A horse paid with feathers goes out as HBW -1 with PTT 1: with feathers 1 the horse is sent as -1, as the client's constructor does. Plague monks are not sent with csm.

Client: C2SCreateSpyMovementVO (bundle lines 100126-100127), built by CastlePostSpyDialog.spyCastle (bundle line 38459)

Fields:

Validators:

  • _feathers_send_no_horse

castle_id pydantic-field

castle_id: int

Wire key SID

One of your castles, CastleInfo.castle_id from client.castle.get_all() or Castle.id from client.state.get_castles()

target_x pydantic-field

target_x: int

Wire key TX

Target map x

target_y pydantic-field

target_y: int

Wire key TY

Target map y

spy_count pydantic-field

spy_count: int = 1

Wire key SC

How many spies to send

spy_type pydantic-field

spy_type: SpyType = SpyType.MILITARY

Wire key ST

What the spies are sent to do

accuracy_or_damage pydantic-field

accuracy_or_damage: int = 100

Wire key SE

Damage percent for a sabotage mission, accuracy percent for any other

horse_booster_id pydantic-field

horse_booster_id: int = -1

Wire key HBW

The horse booster's wod id, -1 for none or when paid with feathers

target_kingdom pydantic-field

target_kingdom: Kingdom = Kingdom.GREEN

Wire key KID

The target's kingdom

feathers pydantic-field

feathers: int = 0

Wire key PTT

1 when the horse is paid with feathers

slowdown pydantic-field

slowdown: int = 0

Wire key SD

Seconds the arrival is delayed by

accepts_reply

accepts_reply(payload: Any) -> bool

Whether a csm reply is the movement this sent: its target area (A.M.TA) is TX/TY.

Client: CSMCommand (bundle line 125996) reads the new movement from A, whose TA is the target area (BasicMapmovementVO). The spies' way home, which the server pushes as a csm too (seen live), targets your own castle and is not taken.

SendSpyResponse pydantic-model

Bases: BaseResponse

The spy mission the server created.

Command: csm Payload:

{"A": {"M": {movement}, "S": {"ST": spy_type, "SA": accuracy, "SC": spies, "SR": risk}},
 "O": [owner record, ...], "gcu": {currencies}}

A is read like a gam entry; gcu may be missing.

Client: CSMCommand.executeCommand (bundle line 125997), which passes [i.A] to CastleArmyData.parseMapMovementArray (bundle line 133626), i.O to CastleOtherPlayerData.parseOwnerInfoArray (bundle line 139005) and i.gcu to CurrencyData.parseGCU (bundle line 141191); SpyMapmovementVO.loadFromParamObject (bundle line 43747)

Fields:

Validators:

spy_movement pydantic-field

spy_movement: MovementWrapper | None = None

Wire key A

The spy movement; None when there is none

owners pydantic-field

Wire key O

Owner records for the movement's areas

currencies pydantic-field

currencies: CurrencyBlock = None

Wire key gcu

Coins and rubies after the send; None when the reply has none

movement_id property

movement_id: int | None

The spy movement's id, or None when the reply has no movement.

seconds_until_arrival property

seconds_until_arrival: int | None

Seconds from the reply until the spies arrive: TT - PT, never below 0.

Client: BasicMapmovementVO.loadFromParamObject (bundle line 19382)

spy property

spy: MovementSpy | None

The mission's spy type, accuracy or damage, spy count and risk.

SpyScreenInfoRequest pydantic-model

Bases: BaseRequest

Get what a spy mission against a target would face: its guards and the spies at hand.

Command: ssi Payload: {"TX": target_x, "TY": target_y, "KID": target_kingdom}

Client: C2SGetSpyInfo (bundle lines 22504-22505), sent with the target's absAreaPos and kingdomID when the spy dialog opens (bundle line 14800)

Fields:

target_x pydantic-field

target_x: int

Wire key TX

Target map x

target_y pydantic-field

target_y: int

Wire key TY

Target map y

target_kingdom pydantic-field

target_kingdom: Kingdom = Kingdom.GREEN

Wire key KID

The target's kingdom

accepts_reply

accepts_reply(payload: Any) -> bool

Whether an ssi reply is about this target: its TX/TY, when both are set, are the ones asked for.

Client: CastleSpyData.parse_SSI (bundle line 139962) passes the reply's position only when TX and TY are both truthy, and CastleSpyDialog.onPreSpyInfoUpdate (bundle line 16398) ignores a reply whose position is not the target's.

SpyTargetArea pydantic-model

Bases: BasePayload

The target's map rows and owner records, and your own protection: the gaa block of ssi.

Client: CastleSpyData.parse_SSI (bundle line 139962), which reads uap with parse_UAP, OI with parseOwnerInfoArray and AI with parseAreaInfos, as a map area reply

Fields:

Validators:

kingdom_id pydantic-field

kingdom_id: int | None = None

Wire key KID

The target's kingdom

protection pydantic-field

protection: KingdomProtection | None = None

Wire key uap

Your own protection

owners pydantic-field

owners: list[MapObject]

Wire key OI

Owner records of the rows

rows pydantic-field

Wire key AI

The target's map rows

SpyScreenInfoResponse pydantic-model

Bases: BaseResponse

What a spy mission against a target would face, and the spies at hand.

Command: ssi Payload:

{"AS": available_spies, "GC": guards, "APM": available_plague_monks,
 "TPM": total_plague_monks, "TX": x, "TY": y,
 "gaa": {"KID": kingdom, "uap": {protection}, "OI": [owner records], "AI": [map rows]}}

The client reads gaa.uap without a check; a reply without gaa still parses here.

Client: SSICommand.executeCommand (bundle line 128554), CastleSpyData.parse_SSI (bundle line 139962)

Fields:

Validators:

  • _position_needs_both
  • _area_needs_an_object → target_area

available_spies pydantic-field

available_spies: ClientInt = 0

Wire key AS

Spies free to send

guard_count pydantic-field

guard_count: ClientInt = 0

Wire key GC

Guards at the target

available_plague_monks pydantic-field

available_plague_monks: ClientInt = 0

Wire key APM

Plague monks free to send

total_plague_monks pydantic-field

total_plague_monks: ClientInt = 0

Wire key TPM

Plague monks owned

target_x pydantic-field

target_x: int | None = None

Wire key TX

The target's map x; None when not sent

target_y pydantic-field

target_y: int | None = None

Wire key TY

The target's map y; None when not sent

target_area pydantic-field

target_area: SpyTargetArea

Wire key gaa

The target's map rows and owner records

target_row

target_row(x: int | None = None, y: int | None = None) -> MapAreaItem | None

The target's map row: the one at x/y, by default the reply's own position.

Falls back to the only row when there is exactly one, and None otherwise.

target_owner

target_owner(x: int | None = None, y: int | None = None) -> MapObject | None

The owner record of the target's owner, when the target row names a player that has one.

risk_flags

risk_flags(x: int | None = None, y: int | None = None) -> tuple[bool, bool] | None

The client's (isDungeon, isPlayer) for the target, or None when its owner cannot be told.

See row_risk_flags.

AutoSpyRequest pydantic-model

Bases: BaseRequest

Spy a target at once, without sending spies: the auto-spy subscription's button.

Command: ssu Payload: {"TX": target_x, "TY": target_y}

The client offers it only while subscriptionData.isAutoSpyActiveForArea holds for the target.

Client: C2SSpySpyUnits (bundle lines 42828-42829), sent by ButtonAutoSpyComponent.onClick (bundle line 109977) and CastleMapobjectInfoComponent.onClickSpyIcon (bundle line 66479)

Fields:

target_x pydantic-field

target_x: int

Wire key TX

Target map x

target_y pydantic-field

target_y: int

Wire key TY

Target map y

AutoSpyResponse pydantic-model

Bases: SpyReportResponse

An auto-spy report, the same shape as a bsd spy report.

Command: ssu

The client builds it with CastleSpyLogVO.parseSpyLog as bsd does, but does not store the OI and SO owner records.

Client: SSUCommand.executeCommand (bundle lines 128568-128573)

Fields:

Validators:

MaxSpiesResponse pydantic-model

Bases: BaseResponse

How many spies you have, before research, title and legend skill boosts.

Command: gms, as a login section of gbd and as a push. The client never sends a gms request.

Client: GMSCommand.executeCommand (bundle line 120555), CastleSpyData.parse_GMS (bundle line 139979), read from gbd (bundle line 129381)

Fields:

max_spies pydantic-field

max_spies: ClientInt = 0

Wire key MS

Spies owned, before boosts

bonus_spies pydantic-field

bonus_spies: ClientInt = 0

Wire key BS

Bonus spies, not part of the spy count

PlagueMonkInfoResponse pydantic-model

Bases: BaseResponse

Your plague monks.

Command: cpi, as a login section of gbd, a push, and inside the cpm and sbp replies.

Client: CPICommand (bundle line 128524), CastleSpyData.parse_CPI (bundle line 139967), which skips a falsy section

Fields:

model_config class-attribute

model_config: ConfigDict = ConfigDict(frozen=True)

available_plague_monks pydantic-field

available_plague_monks: ClientInt = 0

Wire key APM

Plague monks at home

total_plague_monks pydantic-field

total_plague_monks: ClientInt = 0

Wire key TPM

All your plague monks