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.
Files changed (67) hide show
  1. empire_core/__init__.py +36 -0
  2. empire_core/_archive/actions.py +511 -0
  3. empire_core/_archive/automation/__init__.py +24 -0
  4. empire_core/_archive/automation/alliance_tools.py +266 -0
  5. empire_core/_archive/automation/battle_reports.py +196 -0
  6. empire_core/_archive/automation/building_queue.py +242 -0
  7. empire_core/_archive/automation/defense_manager.py +124 -0
  8. empire_core/_archive/automation/map_scanner.py +370 -0
  9. empire_core/_archive/automation/multi_account.py +296 -0
  10. empire_core/_archive/automation/quest_automation.py +94 -0
  11. empire_core/_archive/automation/resource_manager.py +380 -0
  12. empire_core/_archive/automation/target_finder.py +153 -0
  13. empire_core/_archive/automation/tasks.py +224 -0
  14. empire_core/_archive/automation/unit_production.py +719 -0
  15. empire_core/_archive/cli.py +68 -0
  16. empire_core/_archive/client_async.py +469 -0
  17. empire_core/_archive/commands.py +201 -0
  18. empire_core/_archive/connection_async.py +228 -0
  19. empire_core/_archive/defense.py +156 -0
  20. empire_core/_archive/events/__init__.py +35 -0
  21. empire_core/_archive/events/base.py +153 -0
  22. empire_core/_archive/events/manager.py +85 -0
  23. empire_core/accounts.py +190 -0
  24. empire_core/client/__init__.py +0 -0
  25. empire_core/client/client.py +459 -0
  26. empire_core/config.py +87 -0
  27. empire_core/exceptions.py +42 -0
  28. empire_core/network/__init__.py +0 -0
  29. empire_core/network/connection.py +378 -0
  30. empire_core/protocol/__init__.py +0 -0
  31. empire_core/protocol/models/__init__.py +339 -0
  32. empire_core/protocol/models/alliance.py +186 -0
  33. empire_core/protocol/models/army.py +444 -0
  34. empire_core/protocol/models/attack.py +229 -0
  35. empire_core/protocol/models/auth.py +216 -0
  36. empire_core/protocol/models/base.py +403 -0
  37. empire_core/protocol/models/building.py +455 -0
  38. empire_core/protocol/models/castle.py +317 -0
  39. empire_core/protocol/models/chat.py +150 -0
  40. empire_core/protocol/models/defense.py +300 -0
  41. empire_core/protocol/models/map.py +269 -0
  42. empire_core/protocol/packet.py +104 -0
  43. empire_core/services/__init__.py +31 -0
  44. empire_core/services/alliance.py +222 -0
  45. empire_core/services/base.py +107 -0
  46. empire_core/services/castle.py +221 -0
  47. empire_core/state/__init__.py +0 -0
  48. empire_core/state/manager.py +398 -0
  49. empire_core/state/models.py +215 -0
  50. empire_core/state/quest_models.py +60 -0
  51. empire_core/state/report_models.py +115 -0
  52. empire_core/state/unit_models.py +75 -0
  53. empire_core/state/world_models.py +269 -0
  54. empire_core/storage/__init__.py +1 -0
  55. empire_core/storage/database.py +237 -0
  56. empire_core/utils/__init__.py +0 -0
  57. empire_core/utils/battle_sim.py +172 -0
  58. empire_core/utils/calculations.py +170 -0
  59. empire_core/utils/crypto.py +8 -0
  60. empire_core/utils/decorators.py +69 -0
  61. empire_core/utils/enums.py +111 -0
  62. empire_core/utils/helpers.py +252 -0
  63. empire_core/utils/response_awaiter.py +153 -0
  64. empire_core/utils/troops.py +93 -0
  65. empire_core-0.7.3.dist-info/METADATA +197 -0
  66. empire_core-0.7.3.dist-info/RECORD +67 -0
  67. empire_core-0.7.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,459 @@
1
+ """
2
+ EmpireClient for EmpireCore.
3
+
4
+ Uses a threaded Connection class, designed to work well with Discord.py
5
+ by not competing for the event loop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from typing import TYPE_CHECKING, Callable, List, Optional, TypeVar
13
+
14
+ from empire_core.config import (
15
+ LOGIN_DEFAULTS,
16
+ EmpireConfig,
17
+ ServerError,
18
+ default_config,
19
+ )
20
+ from empire_core.exceptions import LoginCooldownError, LoginError, TimeoutError
21
+ from empire_core.network.connection import Connection
22
+ from empire_core.protocol.models import BaseRequest, BaseResponse, parse_response
23
+ from empire_core.protocol.models.defense import (
24
+ GetSupportDefenseRequest,
25
+ GetSupportDefenseResponse,
26
+ )
27
+ from empire_core.protocol.packet import Packet
28
+ from empire_core.services import get_registered_services
29
+ from empire_core.state.manager import GameState
30
+ from empire_core.state.world_models import Movement
31
+
32
+ if TYPE_CHECKING:
33
+ from empire_core.services import BaseService
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ T = TypeVar("T", bound=BaseResponse)
38
+
39
+
40
+ class EmpireClient:
41
+ """
42
+ Empire client for connecting to GGE game servers.
43
+
44
+ This client uses blocking I/O with a background receive thread,
45
+ making it safe to use from Discord.py without blocking the event loop
46
+ (run client operations in a thread pool).
47
+
48
+ Usage:
49
+ client = EmpireClient(username="user", password="pass")
50
+ client.login()
51
+ movements = client.get_movements()
52
+ client.close()
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ username: Optional[str] = None,
58
+ password: Optional[str] = None,
59
+ config: Optional[EmpireConfig] = None,
60
+ ):
61
+ self.config = config or default_config
62
+ self.username = username or self.config.username
63
+ self.password = password or self.config.password
64
+
65
+ self.connection = Connection(self.config.game_url)
66
+ self.state = GameState()
67
+ self.is_logged_in = False
68
+
69
+ # Command -> handlers mapping for efficient dispatch
70
+ # Only commands with handlers will be parsed
71
+ self._handlers: dict[str, list[Callable[[BaseResponse], None]]] = {}
72
+
73
+ # Wire up packet handler for state updates
74
+ self.connection.on_packet = self._on_packet
75
+ self.connection.on_disconnect = self._on_disconnect
76
+
77
+ # Auto-attach registered services
78
+ self._services: dict[str, "BaseService"] = {}
79
+ for name, service_cls in get_registered_services().items():
80
+ service = service_cls(self)
81
+ self._services[name] = service
82
+ setattr(self, name, service)
83
+
84
+ def _register_handler(self, command: str, handler: Callable[[BaseResponse], None]) -> None:
85
+ """
86
+ Register a handler for a specific command.
87
+
88
+ Called by services to register interest in specific responses.
89
+ Only commands with handlers will be parsed and dispatched.
90
+ """
91
+ if command not in self._handlers:
92
+ self._handlers[command] = []
93
+ self._handlers[command].append(handler)
94
+
95
+ def _on_packet(self, packet: Packet) -> None:
96
+ """Handle incoming packets for state updates and service dispatch."""
97
+ cmd = packet.command_id
98
+ if not cmd or not isinstance(packet.payload, dict):
99
+ return
100
+
101
+ # Update internal state (always runs for state-tracked commands)
102
+ self._update_state(cmd, packet.payload)
103
+
104
+ # Only parse and dispatch if handlers are registered
105
+ handlers = self._handlers.get(cmd)
106
+ if handlers:
107
+ response = parse_response(cmd, packet.payload)
108
+ if response:
109
+ # Copy list to avoid issues if handlers are added during iteration
110
+ for handler in list(handlers):
111
+ try:
112
+ handler(response)
113
+ except Exception:
114
+ pass
115
+
116
+ def _update_state(self, cmd: str, payload: dict) -> None:
117
+ """Sync state update from packet - delegates to GameState."""
118
+ self.state.update_from_packet(cmd, payload)
119
+
120
+ def _on_disconnect(self) -> None:
121
+ """Handle disconnect."""
122
+ self.is_logged_in = False
123
+ self.state.shutdown()
124
+ logger.warning("Client disconnected")
125
+
126
+ def login(self) -> None:
127
+ """
128
+ Perform the full login sequence:
129
+ 1. Connect WebSocket
130
+ 2. Version Check
131
+ 3. Zone Login (XML)
132
+ 4. AutoJoin Room
133
+ 5. XT Login (Auth)
134
+ """
135
+ if not self.username or not self.password:
136
+ raise ValueError("Username and password are required")
137
+
138
+ logger.info(f"Logging in as {self.username}...")
139
+
140
+ # Connect if not already connected
141
+ if not self.connection.connected:
142
+ self.connection.connect(timeout=self.config.connection_timeout)
143
+
144
+ # 1. Version Check
145
+ ver_packet = f"<msg t='sys'><body action='verChk' r='0'><ver v='{self.config.game_version}' /></body></msg>"
146
+ self.connection.send(ver_packet)
147
+
148
+ try:
149
+ response = self.connection.wait_for("apiOK", timeout=self.config.request_timeout)
150
+ except TimeoutError:
151
+ raise TimeoutError("Version check timed out")
152
+
153
+ # 2. Zone Login (XML)
154
+ login_packet = (
155
+ f"<msg t='sys'><body action='login' r='0'>"
156
+ f"<login z='{self.config.default_zone}'>"
157
+ f"<nick><![CDATA[]]></nick>"
158
+ f"<pword><![CDATA[undefined%en%0]]></pword>"
159
+ f"</login></body></msg>"
160
+ )
161
+ self.connection.send(login_packet)
162
+
163
+ try:
164
+ self.connection.wait_for("rlu", timeout=self.config.login_timeout)
165
+ except TimeoutError:
166
+ raise TimeoutError("Zone login timed out")
167
+
168
+ # 3. AutoJoin Room
169
+ join_packet = "<msg t='sys'><body action='autoJoin' r='-1'></body></msg>"
170
+ self.connection.send(join_packet)
171
+
172
+ try:
173
+ self.connection.wait_for("joinOK", timeout=self.config.request_timeout)
174
+ except TimeoutError:
175
+ # joinOK sometimes doesn't come, proceed anyway
176
+ pass
177
+
178
+ # 4. XT Login (Real Auth)
179
+ xt_payload = {
180
+ **LOGIN_DEFAULTS,
181
+ "NOM": self.username,
182
+ "PW": self.password,
183
+ }
184
+ xt_packet = f"%xt%{self.config.default_zone}%lli%1%{json.dumps(xt_payload)}%"
185
+ self.connection.send(xt_packet)
186
+
187
+ try:
188
+ lli_response = self.connection.wait_for("lli", timeout=self.config.login_timeout)
189
+
190
+ if lli_response.error_code != 0:
191
+ if lli_response.error_code == ServerError.LOGIN_COOLDOWN:
192
+ cooldown = 0
193
+ if isinstance(lli_response.payload, dict):
194
+ cooldown = int(lli_response.payload.get("CD", 0))
195
+ raise LoginCooldownError(cooldown)
196
+
197
+ raise LoginError(f"Auth failed with code {lli_response.error_code}")
198
+
199
+ logger.info(f"Logged in as {self.username}")
200
+ self.is_logged_in = True
201
+
202
+ except TimeoutError:
203
+ raise TimeoutError("XT login timed out")
204
+
205
+ def close(self) -> None:
206
+ """Disconnect from the server."""
207
+ self.is_logged_in = False
208
+ self.state.shutdown()
209
+ self.connection.disconnect()
210
+
211
+ def send(
212
+ self,
213
+ request: BaseRequest,
214
+ wait: bool = False,
215
+ timeout: float = 5.0,
216
+ ) -> BaseResponse | None:
217
+ """
218
+ Send a request to the server using protocol models.
219
+
220
+ Args:
221
+ request: The request model to send
222
+ wait: Whether to wait for a response
223
+ timeout: Timeout in seconds when waiting
224
+
225
+ Returns:
226
+ The parsed response if wait=True, otherwise None
227
+
228
+ Example:
229
+ from empire_core.protocol.models import AllianceChatMessageRequest
230
+
231
+ request = AllianceChatMessageRequest.create("Hello!")
232
+ client.send(request)
233
+
234
+ # Or wait for response:
235
+ response = client.send(GetCastlesRequest(), wait=True)
236
+ """
237
+ packet = request.to_packet(zone=self.config.default_zone)
238
+ self.connection.send(packet)
239
+
240
+ if wait:
241
+ command = request.get_command()
242
+ try:
243
+ response_packet = self.connection.wait_for(command, timeout=timeout)
244
+ if response_packet and isinstance(response_packet.payload, dict):
245
+ return parse_response(command, response_packet.payload)
246
+ except Exception:
247
+ return None
248
+
249
+ return None
250
+
251
+ # ============================================================
252
+ # Game Commands
253
+ # ============================================================
254
+
255
+ def get_movements(self, wait: bool = True, timeout: float = 5.0) -> List[Movement]:
256
+ """
257
+ Request army movements from server.
258
+
259
+ Args:
260
+ wait: If True, wait for response before returning
261
+ timeout: Timeout in seconds when waiting
262
+
263
+ Returns:
264
+ List of Movement objects
265
+ """
266
+ packet = Packet.build_xt(self.config.default_zone, "gam", {})
267
+ self.connection.send(packet)
268
+
269
+ if wait:
270
+ try:
271
+ self.connection.wait_for("gam", timeout=timeout)
272
+ except TimeoutError:
273
+ pass
274
+
275
+ return list(self.state.movements.values())
276
+
277
+ def send_alliance_chat(self, message: str) -> None:
278
+ """
279
+ Send a message to alliance chat.
280
+
281
+ Args:
282
+ message: The message to send
283
+ """
284
+ # Alliance chat command: acm (Alliance Chat Message)
285
+ # Payload format: {"M": "message text"}
286
+ # Note: Special chars need encoding: % -> &percnt;, " -> &quot;, etc.
287
+ encoded_message = (
288
+ message.replace("%", "&percnt;")
289
+ .replace('"', "&quot;")
290
+ .replace("'", "&145;")
291
+ .replace("\n", "<br />")
292
+ .replace("\\", "%5C")
293
+ )
294
+ payload = {"M": encoded_message}
295
+ packet = Packet.build_xt(self.config.default_zone, "acm", payload)
296
+ self.connection.send(packet)
297
+
298
+ def get_player_info(self, player_id: int, wait: bool = True, timeout: float = 5.0) -> Optional[dict]:
299
+ """
300
+ Get info about a player.
301
+
302
+ Args:
303
+ player_id: The player's ID
304
+ wait: If True, wait for response
305
+ timeout: Timeout in seconds
306
+
307
+ Returns:
308
+ Player info dict or None
309
+ """
310
+ payload = {"PID": player_id}
311
+ packet = Packet.build_xt(self.config.default_zone, "gpi", payload)
312
+ self.connection.send(packet)
313
+
314
+ if wait:
315
+ try:
316
+ response = self.connection.wait_for("gpi", timeout=timeout)
317
+ return response.payload if isinstance(response.payload, dict) else None
318
+ except TimeoutError:
319
+ return None
320
+
321
+ return None
322
+
323
+ def get_alliance_info(self, alliance_id: int, wait: bool = True, timeout: float = 5.0) -> Optional[dict]:
324
+ """
325
+ Get info about an alliance.
326
+
327
+ Args:
328
+ alliance_id: The alliance ID
329
+ wait: If True, wait for response
330
+ timeout: Timeout in seconds
331
+
332
+ Returns:
333
+ Alliance info dict or None
334
+ """
335
+ payload = {"AID": alliance_id}
336
+ packet = Packet.build_xt(self.config.default_zone, "gia", payload)
337
+ self.connection.send(packet)
338
+
339
+ if wait:
340
+ try:
341
+ response = self.connection.wait_for("gia", timeout=timeout)
342
+ return response.payload if isinstance(response.payload, dict) else None
343
+ except TimeoutError:
344
+ return None
345
+
346
+ return None
347
+
348
+ # ============================================================
349
+ # Movement Helpers
350
+ # ============================================================
351
+
352
+ def get_incoming_attacks(self) -> List[Movement]:
353
+ """Get all incoming attack movements."""
354
+ return [m for m in self.state.movements.values() if m.is_incoming and m.is_attack]
355
+
356
+ def get_incoming_movements(self) -> List[Movement]:
357
+ """Get all incoming movements."""
358
+ return [m for m in self.state.movements.values() if m.is_incoming]
359
+
360
+ def get_outgoing_movements(self) -> List[Movement]:
361
+ """Get all outgoing movements."""
362
+ return [m for m in self.state.movements.values() if m.is_outgoing]
363
+
364
+ # ============================================================
365
+ # Chat Subscription
366
+ # ============================================================
367
+
368
+ def get_alliance_chat(self, wait: bool = True, timeout: float = 5.0) -> Optional[dict]:
369
+ """
370
+ Get alliance chat history.
371
+
372
+ Args:
373
+ wait: If True, wait for response
374
+ timeout: Timeout in seconds
375
+
376
+ Returns:
377
+ Chat history dict or None
378
+ """
379
+ # Alliance chat list command: acl
380
+ packet = Packet.build_xt(self.config.default_zone, "acl", {})
381
+ self.connection.send(packet)
382
+
383
+ if wait:
384
+ try:
385
+ response = self.connection.wait_for("acl", timeout=timeout)
386
+ return response.payload if isinstance(response.payload, dict) else None
387
+ except TimeoutError:
388
+ return None
389
+
390
+ return None
391
+
392
+ def subscribe_alliance_chat(self, callback) -> None:
393
+ """
394
+ Subscribe to alliance chat messages.
395
+
396
+ Args:
397
+ callback: Function to call with each chat packet.
398
+ Packet payload will have format:
399
+ {"CM": {"PN": "player_name", "MT": "message_text", ...}}
400
+ """
401
+ # Alliance chat messages come via 'acm' command (not 'aci')
402
+ self.connection.subscribe("acm", callback)
403
+
404
+ def unsubscribe_alliance_chat(self, callback) -> None:
405
+ """Unsubscribe from alliance chat."""
406
+ self.connection.unsubscribe("acm", callback)
407
+
408
+ # ============================================================
409
+ # Defense Info
410
+ # ============================================================
411
+
412
+ def get_castle_defense(
413
+ self,
414
+ target_x: int,
415
+ target_y: int,
416
+ source_x: int | None = None,
417
+ source_y: int | None = None,
418
+ wait: bool = True,
419
+ timeout: float = 5.0,
420
+ ) -> GetSupportDefenseResponse | None:
421
+ """
422
+ Get defense info for an alliance member's castle.
423
+
424
+ Uses the SDI (Support Defense Info) command to query the total
425
+ troops defending a castle. Can only query castles of players
426
+ in the same alliance as the bot.
427
+
428
+ Args:
429
+ target_x: Target castle X coordinate
430
+ target_y: Target castle Y coordinate
431
+ source_x: Source castle X coordinate (defaults to bot's main castle)
432
+ source_y: Source castle Y coordinate (defaults to bot's main castle)
433
+ wait: If True, wait for response
434
+ timeout: Timeout in seconds
435
+
436
+ Returns:
437
+ GetSupportDefenseResponse with defense info, or None on failure.
438
+ Use response.get_total_defenders() to get total troop count.
439
+ """
440
+ # Default to bot's main castle as source
441
+ if source_x is None or source_y is None:
442
+ if self.state.castles:
443
+ main_castle = list(self.state.castles.values())[0]
444
+ source_x = main_castle.x
445
+ source_y = main_castle.y
446
+ logger.info(f"SDI: Using source castle at {source_x}:{source_y}")
447
+ else:
448
+ logger.warning("SDI: No castles available for source coordinates")
449
+ return None
450
+
451
+ logger.info(f"SDI: Sending request TX={target_x}, TY={target_y}, SX={source_x}, SY={source_y}")
452
+ request = GetSupportDefenseRequest(TX=target_x, TY=target_y, SX=source_x, SY=source_y)
453
+ logger.info(f"SDI: Request packet = {request.to_packet(zone=self.config.default_zone)}")
454
+ response = self.send(request, wait=wait, timeout=timeout)
455
+ logger.info(f"SDI: Response = {response}")
456
+
457
+ if isinstance(response, GetSupportDefenseResponse):
458
+ return response
459
+ return None
empire_core/config.py ADDED
@@ -0,0 +1,87 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from pydantic import BaseModel
4
+
5
+ # ============================================================
6
+ # Constants
7
+ # ============================================================
8
+
9
+ # Map chunk size for gaa requests
10
+ MAP_CHUNK_SIZE = 12
11
+
12
+
13
+ # Server error codes
14
+ class ServerError:
15
+ """Server error code constants."""
16
+
17
+ LOGIN_COOLDOWN = 453
18
+ INVALID_CREDENTIALS = 401
19
+ SESSION_EXPIRED = 440
20
+
21
+
22
+ # Resource type IDs used in transport payloads
23
+ class ResourceType:
24
+ """Resource type ID constants for protocol payloads."""
25
+
26
+ WOOD = "1"
27
+ STONE = "2"
28
+ FOOD = "3"
29
+
30
+
31
+ # Troop action types
32
+ class TroopActionType:
33
+ """Troop action type constants."""
34
+
35
+ ATTACK = 1
36
+ SUPPORT = 2
37
+ TRANSPORT = 3
38
+ SPY = 4
39
+
40
+
41
+ # Default login payload values
42
+ LOGIN_DEFAULTS: Dict[str, Any] = {
43
+ "CONM": 175,
44
+ "RTM": 24,
45
+ "ID": 0,
46
+ "PL": 1,
47
+ "LT": None,
48
+ "LANG": "en",
49
+ "DID": "0",
50
+ "AID": "1745592024940879420",
51
+ "KID": "",
52
+ "REF": "https://empire.goodgamestudios.com",
53
+ "GCI": "",
54
+ "SID": 9,
55
+ "PLFID": 1,
56
+ }
57
+
58
+
59
+ # ============================================================
60
+ # Configuration
61
+ # ============================================================
62
+
63
+
64
+ class EmpireConfig(BaseModel):
65
+ """
66
+ Configuration for EmpireCore.
67
+ Defaults can be overridden by passing arguments to EmpireClient
68
+ or (in the future) loading from environment variables/files.
69
+ """
70
+
71
+ # Connection
72
+ game_url: str = "wss://ep-live-us1-game.goodgamestudios.com/"
73
+ default_zone: str = "EmpireEx_21"
74
+ game_version: str = "166"
75
+
76
+ # Timeouts
77
+ connection_timeout: float = 10.0
78
+ login_timeout: float = 15.0
79
+ request_timeout: float = 5.0
80
+
81
+ # User (Optional defaults)
82
+ username: Optional[str] = None
83
+ password: Optional[str] = None
84
+
85
+
86
+ # Global default instance
87
+ default_config = EmpireConfig()
@@ -0,0 +1,42 @@
1
+ class EmpireError(Exception):
2
+ """Base class for all EmpireCore exceptions."""
3
+
4
+ pass
5
+
6
+
7
+ class NetworkError(EmpireError):
8
+ """Raised when a network operation fails."""
9
+
10
+ pass
11
+
12
+
13
+ class LoginError(EmpireError):
14
+ """Raised when the login sequence fails."""
15
+
16
+ pass
17
+
18
+
19
+ class LoginCooldownError(LoginError):
20
+ """Raised when the server rejects login due to rate limiting."""
21
+
22
+ def __init__(self, cooldown: int, message: str = "Login cooldown active"):
23
+ self.cooldown = cooldown
24
+ super().__init__(f"{message}: Retry in {cooldown}s")
25
+
26
+
27
+ class PacketError(EmpireError):
28
+ """Raised when packet parsing fails."""
29
+
30
+ pass
31
+
32
+
33
+ class TimeoutError(EmpireError):
34
+ """Raised when an operation times out."""
35
+
36
+ pass
37
+
38
+
39
+ class ActionError(EmpireError):
40
+ """Raised when a game action fails."""
41
+
42
+ pass
File without changes