empire-core 0.7.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- empire_core/__init__.py +36 -0
- empire_core/_archive/actions.py +511 -0
- empire_core/_archive/automation/__init__.py +24 -0
- empire_core/_archive/automation/alliance_tools.py +266 -0
- empire_core/_archive/automation/battle_reports.py +196 -0
- empire_core/_archive/automation/building_queue.py +242 -0
- empire_core/_archive/automation/defense_manager.py +124 -0
- empire_core/_archive/automation/map_scanner.py +370 -0
- empire_core/_archive/automation/multi_account.py +296 -0
- empire_core/_archive/automation/quest_automation.py +94 -0
- empire_core/_archive/automation/resource_manager.py +380 -0
- empire_core/_archive/automation/target_finder.py +153 -0
- empire_core/_archive/automation/tasks.py +224 -0
- empire_core/_archive/automation/unit_production.py +719 -0
- empire_core/_archive/cli.py +68 -0
- empire_core/_archive/client_async.py +469 -0
- empire_core/_archive/commands.py +201 -0
- empire_core/_archive/connection_async.py +228 -0
- empire_core/_archive/defense.py +156 -0
- empire_core/_archive/events/__init__.py +35 -0
- empire_core/_archive/events/base.py +153 -0
- empire_core/_archive/events/manager.py +85 -0
- empire_core/accounts.py +190 -0
- empire_core/client/__init__.py +0 -0
- empire_core/client/client.py +459 -0
- empire_core/config.py +87 -0
- empire_core/exceptions.py +42 -0
- empire_core/network/__init__.py +0 -0
- empire_core/network/connection.py +378 -0
- empire_core/protocol/__init__.py +0 -0
- empire_core/protocol/models/__init__.py +339 -0
- empire_core/protocol/models/alliance.py +186 -0
- empire_core/protocol/models/army.py +444 -0
- empire_core/protocol/models/attack.py +229 -0
- empire_core/protocol/models/auth.py +216 -0
- empire_core/protocol/models/base.py +403 -0
- empire_core/protocol/models/building.py +455 -0
- empire_core/protocol/models/castle.py +317 -0
- empire_core/protocol/models/chat.py +150 -0
- empire_core/protocol/models/defense.py +300 -0
- empire_core/protocol/models/map.py +269 -0
- empire_core/protocol/packet.py +104 -0
- empire_core/services/__init__.py +31 -0
- empire_core/services/alliance.py +222 -0
- empire_core/services/base.py +107 -0
- empire_core/services/castle.py +221 -0
- empire_core/state/__init__.py +0 -0
- empire_core/state/manager.py +398 -0
- empire_core/state/models.py +215 -0
- empire_core/state/quest_models.py +60 -0
- empire_core/state/report_models.py +115 -0
- empire_core/state/unit_models.py +75 -0
- empire_core/state/world_models.py +269 -0
- empire_core/storage/__init__.py +1 -0
- empire_core/storage/database.py +237 -0
- empire_core/utils/__init__.py +0 -0
- empire_core/utils/battle_sim.py +172 -0
- empire_core/utils/calculations.py +170 -0
- empire_core/utils/crypto.py +8 -0
- empire_core/utils/decorators.py +69 -0
- empire_core/utils/enums.py +111 -0
- empire_core/utils/helpers.py +252 -0
- empire_core/utils/response_awaiter.py +153 -0
- empire_core/utils/troops.py +93 -0
- empire_core-0.7.3.dist-info/METADATA +197 -0
- empire_core-0.7.3.dist-info/RECORD +67 -0
- empire_core-0.7.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EmpireCore CLI
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from empire_core import accounts
|
|
10
|
+
|
|
11
|
+
# Configure logging for CLI
|
|
12
|
+
logging.basicConfig(level=logging.WARNING) # cleaner output
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="EmpireCore Command Line Interface")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def status():
|
|
19
|
+
"""Show configured accounts."""
|
|
20
|
+
all_accounts = accounts.get_all()
|
|
21
|
+
if not all_accounts:
|
|
22
|
+
typer.echo("No accounts found in accounts.json or environment.")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
typer.echo(f"Found {len(all_accounts)} accounts:")
|
|
26
|
+
for acc in all_accounts:
|
|
27
|
+
active = "✅" if acc.active else "❌"
|
|
28
|
+
typer.echo(f"{active} {acc.username} ({acc.world}) [Tags: {', '.join(acc.tags)}]")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def login(username: str = typer.Option(None, help="Username to login with. Defaults to first.")):
|
|
33
|
+
"""Test login for an account."""
|
|
34
|
+
|
|
35
|
+
async def _run():
|
|
36
|
+
if username:
|
|
37
|
+
acc = accounts.get_by_username(username)
|
|
38
|
+
else:
|
|
39
|
+
acc = accounts.get_default()
|
|
40
|
+
|
|
41
|
+
if not acc:
|
|
42
|
+
typer.echo("Account not found.")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
typer.echo(f"Logging in as {acc.username}...")
|
|
46
|
+
client = acc.get_client()
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
await client.login()
|
|
50
|
+
typer.echo("✅ Login successful!")
|
|
51
|
+
|
|
52
|
+
# Show quick stats
|
|
53
|
+
player = client.state.local_player
|
|
54
|
+
if player:
|
|
55
|
+
typer.echo(f" Level: {player.level}")
|
|
56
|
+
typer.echo(f" Gold: {player.gold:,}")
|
|
57
|
+
typer.echo(f" Castles: {len(player.castles)}")
|
|
58
|
+
|
|
59
|
+
except Exception as e:
|
|
60
|
+
typer.echo(f"❌ Login failed: {e}")
|
|
61
|
+
finally:
|
|
62
|
+
await client.close()
|
|
63
|
+
|
|
64
|
+
asyncio.run(_run())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
app()
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import TYPE_CHECKING, Awaitable, Callable, Dict, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from empire_core.config import (
|
|
8
|
+
LOGIN_DEFAULTS,
|
|
9
|
+
MAP_CHUNK_SIZE,
|
|
10
|
+
EmpireConfig,
|
|
11
|
+
ServerError,
|
|
12
|
+
default_config,
|
|
13
|
+
)
|
|
14
|
+
from empire_core.network.connection import SFSConnection
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from empire_core.accounts import Account
|
|
18
|
+
|
|
19
|
+
from empire_core.automation.alliance_tools import AllianceService, ChatService
|
|
20
|
+
from empire_core.automation.battle_reports import BattleReportService
|
|
21
|
+
from empire_core.automation.building_queue import BuildingManager
|
|
22
|
+
from empire_core.automation.defense_manager import DefenseManager
|
|
23
|
+
from empire_core.automation.map_scanner import MapScanner
|
|
24
|
+
from empire_core.automation.quest_automation import QuestService
|
|
25
|
+
from empire_core.automation.resource_manager import ResourceManager
|
|
26
|
+
from empire_core.automation.unit_production import UnitManager
|
|
27
|
+
from empire_core.client.actions import GameActionsMixin
|
|
28
|
+
from empire_core.client.commands import GameCommandsMixin
|
|
29
|
+
from empire_core.client.defense import DefenseService
|
|
30
|
+
from empire_core.events.base import PacketEvent
|
|
31
|
+
from empire_core.events.manager import EventManager
|
|
32
|
+
from empire_core.exceptions import LoginError, TimeoutError
|
|
33
|
+
from empire_core.protocol.packet import Packet
|
|
34
|
+
from empire_core.state.manager import GameState
|
|
35
|
+
from empire_core.state.world_models import Movement
|
|
36
|
+
from empire_core.storage.database import GameDatabase
|
|
37
|
+
from empire_core.utils.decorators import handle_errors
|
|
38
|
+
from empire_core.utils.response_awaiter import ResponseAwaiter
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EmpireClient(
|
|
44
|
+
GameActionsMixin,
|
|
45
|
+
GameCommandsMixin,
|
|
46
|
+
):
|
|
47
|
+
def __init__(self, config: Union[EmpireConfig, "Account", None] = None):
|
|
48
|
+
if config is None:
|
|
49
|
+
self.config = default_config
|
|
50
|
+
elif hasattr(config, "to_empire_config"):
|
|
51
|
+
# Handle Account object
|
|
52
|
+
self.config = config.to_empire_config()
|
|
53
|
+
else:
|
|
54
|
+
# Handle EmpireConfig object
|
|
55
|
+
self.config = config
|
|
56
|
+
|
|
57
|
+
self.connection = SFSConnection(self.config.game_url)
|
|
58
|
+
self.username: Optional[str] = self.config.username
|
|
59
|
+
self.is_logged_in = False
|
|
60
|
+
|
|
61
|
+
self.events = EventManager()
|
|
62
|
+
self.state = GameState()
|
|
63
|
+
self.db = GameDatabase()
|
|
64
|
+
|
|
65
|
+
# Services (Composition)
|
|
66
|
+
self.scanner = MapScanner(self)
|
|
67
|
+
self.resources = ResourceManager(self)
|
|
68
|
+
self.buildings = BuildingManager(self)
|
|
69
|
+
self.units = UnitManager(self)
|
|
70
|
+
self.quests = QuestService(self)
|
|
71
|
+
self.defense_manager = DefenseManager(self)
|
|
72
|
+
|
|
73
|
+
# Core Services
|
|
74
|
+
self.defense = DefenseService(self)
|
|
75
|
+
self.reports = BattleReportService(self)
|
|
76
|
+
self.alliance = AllianceService(self)
|
|
77
|
+
self.chat = ChatService(self)
|
|
78
|
+
|
|
79
|
+
self.response_awaiter = ResponseAwaiter()
|
|
80
|
+
self.connection.packet_handler = self._on_packet
|
|
81
|
+
self.connection.on_close = self._handle_disconnect
|
|
82
|
+
|
|
83
|
+
self._reconnect_task: Optional[asyncio.Task] = None
|
|
84
|
+
self._auto_reconnect = True
|
|
85
|
+
self._max_reconnect_attempts = 10
|
|
86
|
+
self._reconnect_delay = 5.0 # Initial delay
|
|
87
|
+
self._is_reconnecting = False
|
|
88
|
+
|
|
89
|
+
async def _handle_disconnect(self):
|
|
90
|
+
"""Called when the underlying connection is lost."""
|
|
91
|
+
self.is_logged_in = False
|
|
92
|
+
logger.warning("Client: Connection lost. Resetting logged_in state.")
|
|
93
|
+
|
|
94
|
+
if self._auto_reconnect and not self._reconnect_task:
|
|
95
|
+
self._is_reconnecting = True
|
|
96
|
+
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
|
97
|
+
|
|
98
|
+
async def _reconnect_loop(self):
|
|
99
|
+
"""Internal loop to handle reconnection attempts."""
|
|
100
|
+
attempt = 0
|
|
101
|
+
delay = self._reconnect_delay
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
while attempt < self._max_reconnect_attempts:
|
|
105
|
+
if self.is_logged_in:
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
attempt += 1
|
|
109
|
+
logger.info(f"Client: Reconnection attempt {attempt}/{self._max_reconnect_attempts} in {delay}s...")
|
|
110
|
+
await asyncio.sleep(delay)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
# Try to re-login (login() handles connect() + handshake)
|
|
114
|
+
await self.login()
|
|
115
|
+
logger.info("Client: Reconnection successful!")
|
|
116
|
+
return
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"Client: Reconnection attempt {attempt} failed: {e}")
|
|
119
|
+
# Exponential backoff
|
|
120
|
+
delay = min(delay * 2, 60.0)
|
|
121
|
+
|
|
122
|
+
logger.critical("Client: Maximum reconnection attempts reached. Giving up.")
|
|
123
|
+
finally:
|
|
124
|
+
self._reconnect_task = None
|
|
125
|
+
self._is_reconnecting = False
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def event(self):
|
|
129
|
+
"""Decorator for registering event handlers."""
|
|
130
|
+
return self.events.listen
|
|
131
|
+
|
|
132
|
+
@handle_errors(log_msg="Error processing packet", re_raise=False)
|
|
133
|
+
async def _on_packet(self, packet: Packet):
|
|
134
|
+
"""Global packet handler called by connection."""
|
|
135
|
+
logger.debug(f"Client received packet: {packet.command_id}")
|
|
136
|
+
|
|
137
|
+
if packet.command_id == "gaa":
|
|
138
|
+
logger.debug(f"GAA Payload: {packet.payload}")
|
|
139
|
+
|
|
140
|
+
# Update State
|
|
141
|
+
if packet.command_id and isinstance(packet.payload, dict):
|
|
142
|
+
await self.state.update_from_packet(packet.command_id, packet.payload)
|
|
143
|
+
|
|
144
|
+
# Notify response awaiter - Pass FULL packet so error_code is available
|
|
145
|
+
if packet.command_id:
|
|
146
|
+
self.response_awaiter.set_response(packet.command_id, packet)
|
|
147
|
+
|
|
148
|
+
pkt_event = PacketEvent(
|
|
149
|
+
command_id=packet.command_id or "unknown",
|
|
150
|
+
payload=packet.payload,
|
|
151
|
+
is_xml=packet.is_xml,
|
|
152
|
+
)
|
|
153
|
+
await self.events.emit(pkt_event)
|
|
154
|
+
|
|
155
|
+
@handle_errors(log_msg="Login failed")
|
|
156
|
+
async def login(self, username: Optional[str] = None, password: Optional[str] = None):
|
|
157
|
+
"""
|
|
158
|
+
Performs the full login sequence:
|
|
159
|
+
Connect -> Version Check -> XML Login (Zone) -> AutoJoin -> XT Login (Auth)
|
|
160
|
+
"""
|
|
161
|
+
username = username or self.config.username
|
|
162
|
+
password = password or self.config.password
|
|
163
|
+
|
|
164
|
+
if not username or not password:
|
|
165
|
+
raise ValueError("Username and password must be provided")
|
|
166
|
+
|
|
167
|
+
self.username = username
|
|
168
|
+
|
|
169
|
+
# 0. Initialize Database & Services
|
|
170
|
+
await self.db.initialize()
|
|
171
|
+
await self.scanner.initialize()
|
|
172
|
+
|
|
173
|
+
if not self.connection.connected:
|
|
174
|
+
await self.connection.connect()
|
|
175
|
+
|
|
176
|
+
# 1. Version Check
|
|
177
|
+
logger.info("Handshake: Sending Version Check...")
|
|
178
|
+
ver_waiter = self.connection.create_waiter("apiOK", predicate=lambda p: p.is_xml and p.command_id == "apiOK")
|
|
179
|
+
|
|
180
|
+
ver_packet = f"<msg t='sys'><body action='verChk' r='0'><ver v='{self.config.game_version}' /></body></msg>"
|
|
181
|
+
await self.connection.send(ver_packet)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
await asyncio.wait_for(ver_waiter, timeout=self.config.request_timeout)
|
|
185
|
+
logger.info("Handshake: Version OK.")
|
|
186
|
+
except asyncio.TimeoutError:
|
|
187
|
+
raise TimeoutError("Handshake: Version Check timed out.")
|
|
188
|
+
|
|
189
|
+
# 2. XML Login (Zone Entry)
|
|
190
|
+
logger.info(f"Handshake: Entering Zone {self.config.default_zone}...")
|
|
191
|
+
|
|
192
|
+
login_packet = (
|
|
193
|
+
f"<msg t='sys'><body action='login' r='0'>"
|
|
194
|
+
f"<login z='{self.config.default_zone}'>"
|
|
195
|
+
f"<nick><![CDATA[]]></nick>"
|
|
196
|
+
f"<pword><![CDATA[undefined%en%0]]></pword>"
|
|
197
|
+
f"</login></body></msg>"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Wait for 'rlu' (Room List Update)
|
|
201
|
+
rlu_waiter = self.connection.create_waiter("rlu")
|
|
202
|
+
|
|
203
|
+
await self.connection.send(login_packet)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
await asyncio.wait_for(rlu_waiter, timeout=self.config.login_timeout)
|
|
207
|
+
logger.info("Handshake: Received Room List (Zone Entered).")
|
|
208
|
+
except asyncio.TimeoutError:
|
|
209
|
+
raise TimeoutError("Handshake: Zone Login (rlu) timed out.")
|
|
210
|
+
|
|
211
|
+
# 3. AutoJoin (Room Join)
|
|
212
|
+
logger.info("Handshake: Joining Room (AutoJoin)...")
|
|
213
|
+
join_packet = "<msg t='sys'><body action='autoJoin' r='-1'></body></msg>"
|
|
214
|
+
|
|
215
|
+
join_ok_waiter = self.connection.create_waiter(
|
|
216
|
+
"joinOK", predicate=lambda p: p.is_xml and p.command_id == "joinOK"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
await self.connection.send(join_packet)
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
await asyncio.wait_for(join_ok_waiter, timeout=self.config.request_timeout)
|
|
223
|
+
logger.info("Handshake: Room Joined (joinOK received).")
|
|
224
|
+
except asyncio.TimeoutError:
|
|
225
|
+
logger.warning("Handshake: joinOK timed out, but proceeding...")
|
|
226
|
+
|
|
227
|
+
# 4. XT Login (Real Auth)
|
|
228
|
+
logger.info(f"Handshake: Authenticating as {username}...")
|
|
229
|
+
|
|
230
|
+
xt_login_payload = {
|
|
231
|
+
**LOGIN_DEFAULTS,
|
|
232
|
+
"NOM": username,
|
|
233
|
+
"PW": password,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
xt_packet = f"%xt%{self.config.default_zone}%lli%1%{json.dumps(xt_login_payload)}%"
|
|
237
|
+
|
|
238
|
+
# Wait for lli response
|
|
239
|
+
lli_waiter = self.connection.create_waiter("lli")
|
|
240
|
+
await self.connection.send(xt_packet)
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
lli_packet = await asyncio.wait_for(lli_waiter, timeout=self.config.login_timeout)
|
|
244
|
+
|
|
245
|
+
# Check status
|
|
246
|
+
if lli_packet.error_code != 0:
|
|
247
|
+
# Check for cooldown
|
|
248
|
+
if lli_packet.error_code == ServerError.LOGIN_COOLDOWN:
|
|
249
|
+
cooldown = 0
|
|
250
|
+
if isinstance(lli_packet.payload, dict):
|
|
251
|
+
cooldown = int(lli_packet.payload.get("CD", 0))
|
|
252
|
+
|
|
253
|
+
logger.warning(f"Handshake: Login Slowdown active. Wait {cooldown}s.")
|
|
254
|
+
from empire_core.exceptions import LoginCooldownError
|
|
255
|
+
|
|
256
|
+
raise LoginCooldownError(cooldown)
|
|
257
|
+
|
|
258
|
+
logger.error(f"Handshake: Auth Failed with status {lli_packet.error_code}")
|
|
259
|
+
raise LoginError(f"Auth Failed with status {lli_packet.error_code}")
|
|
260
|
+
|
|
261
|
+
logger.info("Handshake: Authenticated.")
|
|
262
|
+
except asyncio.TimeoutError:
|
|
263
|
+
raise TimeoutError("XT Login timed out.")
|
|
264
|
+
|
|
265
|
+
self.is_logged_in = True
|
|
266
|
+
logger.info("Handshake: Ready.")
|
|
267
|
+
|
|
268
|
+
@handle_errors(log_msg="Error getting map chunk")
|
|
269
|
+
async def get_map_chunk(self, kingdom: int, x: int, y: int):
|
|
270
|
+
"""
|
|
271
|
+
Requests a chunk of the map.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
kingdom: Kingdom ID (0=Green, 2=Ice, 1=Sands, 3=Fire)
|
|
275
|
+
x: Top-Left X
|
|
276
|
+
y: Top-Left Y
|
|
277
|
+
"""
|
|
278
|
+
# Command: gaa (Get Area)
|
|
279
|
+
payload = {
|
|
280
|
+
"KID": kingdom,
|
|
281
|
+
"AX1": x,
|
|
282
|
+
"AY1": y,
|
|
283
|
+
"AX2": x + MAP_CHUNK_SIZE,
|
|
284
|
+
"AY2": y + MAP_CHUNK_SIZE,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
packet = Packet.build_xt(self.config.default_zone, "gaa", payload)
|
|
288
|
+
await self.connection.send(packet)
|
|
289
|
+
|
|
290
|
+
@handle_errors(log_msg="Error getting movements")
|
|
291
|
+
async def get_movements(self):
|
|
292
|
+
"""
|
|
293
|
+
Requests list of army movements.
|
|
294
|
+
"""
|
|
295
|
+
packet = Packet.build_xt(self.config.default_zone, "gam", {})
|
|
296
|
+
await self.connection.send(packet)
|
|
297
|
+
|
|
298
|
+
@handle_errors(log_msg="Error getting detailed castle info")
|
|
299
|
+
async def get_detailed_castle_info(self):
|
|
300
|
+
"""
|
|
301
|
+
Requests detailed information for all own castles (Resources, Units, etc.).
|
|
302
|
+
"""
|
|
303
|
+
packet = Packet.build_xt(self.config.default_zone, "dcl", {})
|
|
304
|
+
await self.connection.send(packet)
|
|
305
|
+
|
|
306
|
+
@handle_errors(log_msg="Error closing client", re_raise=False)
|
|
307
|
+
async def close(self):
|
|
308
|
+
self._auto_reconnect = False
|
|
309
|
+
if self._reconnect_task:
|
|
310
|
+
self._reconnect_task.cancel()
|
|
311
|
+
await self.connection.disconnect()
|
|
312
|
+
await self.db.close()
|
|
313
|
+
|
|
314
|
+
async def wait_until_ready(self, timeout: float = 60.0):
|
|
315
|
+
"""Wait until the client is logged in and ready."""
|
|
316
|
+
if self.is_logged_in:
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
start = time.time()
|
|
320
|
+
while not self.is_logged_in:
|
|
321
|
+
if (time.time() - start) > timeout:
|
|
322
|
+
raise TimeoutError("Timed out waiting for client to be ready")
|
|
323
|
+
|
|
324
|
+
if not self._is_reconnecting and not self.connection.connected:
|
|
325
|
+
# If we are not logged in, not reconnecting, and not connected,
|
|
326
|
+
# then we've either given up or auto-reconnect is off.
|
|
327
|
+
raise RuntimeError("Client not connected and not attempting reconnection.")
|
|
328
|
+
|
|
329
|
+
await asyncio.sleep(0.5)
|
|
330
|
+
|
|
331
|
+
# ============================================================
|
|
332
|
+
# Movement Tracking Methods
|
|
333
|
+
# ============================================================
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def movements(self) -> Dict[int, Movement]:
|
|
337
|
+
"""Get all currently tracked movements."""
|
|
338
|
+
return self.state.movements
|
|
339
|
+
|
|
340
|
+
def get_all_movements(self) -> List[Movement]:
|
|
341
|
+
"""Get all tracked movements as a list."""
|
|
342
|
+
return self.state.get_all_movements()
|
|
343
|
+
|
|
344
|
+
def get_incoming_movements(self) -> List[Movement]:
|
|
345
|
+
"""Get all incoming movements (attacks, supports, transports to us)."""
|
|
346
|
+
return self.state.get_incoming_movements()
|
|
347
|
+
|
|
348
|
+
def get_outgoing_movements(self) -> List[Movement]:
|
|
349
|
+
"""Get all outgoing movements (our attacks, transports, etc.)."""
|
|
350
|
+
return self.state.get_outgoing_movements()
|
|
351
|
+
|
|
352
|
+
def get_returning_movements(self) -> List[Movement]:
|
|
353
|
+
"""Get all returning movements (armies coming back)."""
|
|
354
|
+
return self.state.get_returning_movements()
|
|
355
|
+
|
|
356
|
+
def get_incoming_attacks(self) -> List[Movement]:
|
|
357
|
+
"""Get all incoming attack movements (high priority)."""
|
|
358
|
+
return self.state.get_incoming_attacks()
|
|
359
|
+
|
|
360
|
+
def get_movements_to_castle(self, castle_id: int) -> List[Movement]:
|
|
361
|
+
"""Get all movements targeting a specific castle."""
|
|
362
|
+
return self.state.get_movements_to_castle(castle_id)
|
|
363
|
+
|
|
364
|
+
def get_movements_from_castle(self, castle_id: int) -> List[Movement]:
|
|
365
|
+
"""Get all movements originating from a specific castle."""
|
|
366
|
+
return self.state.get_movements_from_castle(castle_id)
|
|
367
|
+
|
|
368
|
+
def get_next_arrival(self) -> Optional[Movement]:
|
|
369
|
+
"""Get the movement that will arrive soonest."""
|
|
370
|
+
return self.state.get_next_arrival()
|
|
371
|
+
|
|
372
|
+
def get_movement(self, movement_id: int) -> Optional[Movement]:
|
|
373
|
+
"""Get a specific movement by ID."""
|
|
374
|
+
return self.state.get_movement_by_id(movement_id)
|
|
375
|
+
|
|
376
|
+
@handle_errors(log_msg="Error refreshing movements")
|
|
377
|
+
async def refresh_movements(self, wait: bool = True, timeout: float = 5.0) -> List[Movement]:
|
|
378
|
+
"""
|
|
379
|
+
Refresh movement data from server.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
wait: Wait for response before returning
|
|
383
|
+
timeout: Timeout in seconds when waiting
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
List of all current movements
|
|
387
|
+
"""
|
|
388
|
+
if wait:
|
|
389
|
+
self.response_awaiter.create_waiter("gam")
|
|
390
|
+
|
|
391
|
+
await self.get_movements()
|
|
392
|
+
|
|
393
|
+
if wait:
|
|
394
|
+
try:
|
|
395
|
+
await self.response_awaiter.wait_for("gam", timeout=timeout)
|
|
396
|
+
except asyncio.TimeoutError:
|
|
397
|
+
logger.warning("Refresh movements timed out")
|
|
398
|
+
|
|
399
|
+
return self.get_all_movements()
|
|
400
|
+
|
|
401
|
+
async def watch_movements(
|
|
402
|
+
self,
|
|
403
|
+
interval: float = 5.0,
|
|
404
|
+
callback: Optional[Callable[[List[Movement]], Awaitable[None]]] = None,
|
|
405
|
+
stop_event: Optional[asyncio.Event] = None,
|
|
406
|
+
):
|
|
407
|
+
"""
|
|
408
|
+
Continuously poll for movement updates.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
interval: Polling interval in seconds
|
|
412
|
+
callback: Optional async callback to call with movements list
|
|
413
|
+
stop_event: Optional event to signal stopping
|
|
414
|
+
"""
|
|
415
|
+
stop = stop_event or asyncio.Event()
|
|
416
|
+
|
|
417
|
+
while not stop.is_set():
|
|
418
|
+
try:
|
|
419
|
+
movements = await self.refresh_movements(wait=True, timeout=interval)
|
|
420
|
+
|
|
421
|
+
if callback:
|
|
422
|
+
await callback(movements)
|
|
423
|
+
|
|
424
|
+
# Check for incoming attacks
|
|
425
|
+
attacks = self.get_incoming_attacks()
|
|
426
|
+
if attacks:
|
|
427
|
+
for attack in attacks:
|
|
428
|
+
logger.warning(
|
|
429
|
+
f"Incoming attack! ID: {attack.MID}, "
|
|
430
|
+
f"Target: {attack.target_area_id}, "
|
|
431
|
+
f"Time: {attack.format_time_remaining()}"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
except Exception as e:
|
|
435
|
+
logger.error(f"Error in movement watch: {e}")
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
await asyncio.wait_for(stop.wait(), timeout=interval)
|
|
439
|
+
break
|
|
440
|
+
except asyncio.TimeoutError:
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
def format_movements_summary(self) -> str:
|
|
444
|
+
"""Get a formatted summary of all movements."""
|
|
445
|
+
lines = []
|
|
446
|
+
|
|
447
|
+
incoming = self.get_incoming_movements()
|
|
448
|
+
outgoing = self.get_outgoing_movements()
|
|
449
|
+
returning = self.get_returning_movements()
|
|
450
|
+
|
|
451
|
+
if not incoming and not outgoing and not returning:
|
|
452
|
+
return "No active movements."
|
|
453
|
+
|
|
454
|
+
if incoming:
|
|
455
|
+
lines.append(f"Incoming ({len(incoming)}):")
|
|
456
|
+
for m in sorted(incoming, key=lambda x: x.time_remaining):
|
|
457
|
+
lines.append(f" - {m.movement_type_name} from {m.source_area_id}: {m.format_time_remaining()}")
|
|
458
|
+
|
|
459
|
+
if outgoing:
|
|
460
|
+
lines.append(f"Outgoing ({len(outgoing)}):")
|
|
461
|
+
for m in sorted(outgoing, key=lambda x: x.time_remaining):
|
|
462
|
+
lines.append(f" - {m.movement_type_name} to {m.target_area_id}: {m.format_time_remaining()}")
|
|
463
|
+
|
|
464
|
+
if returning:
|
|
465
|
+
lines.append(f"Returning ({len(returning)}):")
|
|
466
|
+
for m in sorted(returning, key=lambda x: x.time_remaining):
|
|
467
|
+
lines.append(f" - From {m.source_area_id}: {m.format_time_remaining()}")
|
|
468
|
+
|
|
469
|
+
return "\n".join(lines)
|