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,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: % -> %, " -> ", etc.
|
|
287
|
+
encoded_message = (
|
|
288
|
+
message.replace("%", "%")
|
|
289
|
+
.replace('"', """)
|
|
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
|