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,398 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GameState - Tracks game state from server packets.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional, Set
|
|
9
|
+
|
|
10
|
+
from empire_core.state.models import Alliance, Castle, Player
|
|
11
|
+
from empire_core.state.unit_models import Army
|
|
12
|
+
from empire_core.state.world_models import MapObject, Movement, MovementResources
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GameState:
|
|
18
|
+
"""
|
|
19
|
+
Manages game state parsed from server packets.
|
|
20
|
+
|
|
21
|
+
This is a simplified sync version - no async, no event emission.
|
|
22
|
+
State is updated directly and can be queried at any time.
|
|
23
|
+
|
|
24
|
+
Callbacks are dispatched in a thread pool to avoid blocking the receive loop.
|
|
25
|
+
This allows callbacks to make blocking calls (like waiting for responses).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.local_player: Optional[Player] = None
|
|
30
|
+
self.players: Dict[int, Player] = {}
|
|
31
|
+
self.castles: Dict[int, Castle] = {}
|
|
32
|
+
|
|
33
|
+
# World State
|
|
34
|
+
self.map_objects: Dict[int, MapObject] = {} # AreaID -> MapObject
|
|
35
|
+
self.movements: Dict[int, Movement] = {} # MovementID -> Movement
|
|
36
|
+
|
|
37
|
+
# Track movement IDs we've seen (for delta detection)
|
|
38
|
+
self._previous_movement_ids: Set[int] = set()
|
|
39
|
+
|
|
40
|
+
# Armies (castle_id -> Army)
|
|
41
|
+
self.armies: Dict[int, Army] = {}
|
|
42
|
+
|
|
43
|
+
# Callbacks for specific events (optional)
|
|
44
|
+
self.on_incoming_attack: Optional[Callable[[Movement], None]] = None
|
|
45
|
+
self.on_movement_recalled: Optional[Callable[[Movement], None]] = None
|
|
46
|
+
|
|
47
|
+
# Track movements that arrived normally (vs recalled)
|
|
48
|
+
self._arrived_movement_ids: Set[int] = set()
|
|
49
|
+
|
|
50
|
+
# Thread pool for dispatching callbacks (avoids blocking receive loop)
|
|
51
|
+
self._callback_executor: ThreadPoolExecutor = ThreadPoolExecutor(
|
|
52
|
+
max_workers=4, thread_name_prefix="gge_callback"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def shutdown(self) -> None:
|
|
56
|
+
"""Shutdown the callback executor. Call on disconnect."""
|
|
57
|
+
self._callback_executor.shutdown(wait=False)
|
|
58
|
+
|
|
59
|
+
def _dispatch_callback(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
|
|
60
|
+
"""Dispatch a callback in the thread pool."""
|
|
61
|
+
|
|
62
|
+
def wrapped():
|
|
63
|
+
try:
|
|
64
|
+
callback(*args, **kwargs)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Callback error: {e}")
|
|
67
|
+
|
|
68
|
+
self._callback_executor.submit(wrapped)
|
|
69
|
+
|
|
70
|
+
def update_from_packet(self, cmd_id: str, payload: Dict[str, Any]) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Central update router - parses packet and updates state.
|
|
73
|
+
"""
|
|
74
|
+
if cmd_id == "gbd":
|
|
75
|
+
self._handle_gbd(payload)
|
|
76
|
+
elif cmd_id == "lli":
|
|
77
|
+
# Login response contains the same data as gbd
|
|
78
|
+
self._handle_gbd(payload)
|
|
79
|
+
elif cmd_id == "gam":
|
|
80
|
+
self._handle_gam(payload)
|
|
81
|
+
elif cmd_id == "dcl":
|
|
82
|
+
self._handle_dcl(payload)
|
|
83
|
+
elif cmd_id == "mov":
|
|
84
|
+
self._handle_mov(payload)
|
|
85
|
+
elif cmd_id == "atv":
|
|
86
|
+
self._handle_atv(payload)
|
|
87
|
+
elif cmd_id == "ata":
|
|
88
|
+
self._handle_ata(payload)
|
|
89
|
+
elif cmd_id == "mrm":
|
|
90
|
+
self._handle_mrm(payload)
|
|
91
|
+
|
|
92
|
+
def _handle_gbd(self, data: Dict[str, Any]) -> None:
|
|
93
|
+
"""Handle 'Get Big Data' packet - initial login data."""
|
|
94
|
+
# Player Info
|
|
95
|
+
gpi = data.get("gpi", {})
|
|
96
|
+
if gpi:
|
|
97
|
+
pid = gpi.get("PID")
|
|
98
|
+
if pid:
|
|
99
|
+
if pid not in self.players:
|
|
100
|
+
self.players[pid] = Player(**gpi)
|
|
101
|
+
self.local_player = self.players[pid]
|
|
102
|
+
logger.info(f"Local player: {self.local_player.name} (ID: {pid})")
|
|
103
|
+
|
|
104
|
+
# XP/Level
|
|
105
|
+
gxp = data.get("gxp", {})
|
|
106
|
+
if self.local_player and gxp:
|
|
107
|
+
self.local_player.LVL = gxp.get("LVL", self.local_player.LVL)
|
|
108
|
+
self.local_player.XP = gxp.get("XP", self.local_player.XP)
|
|
109
|
+
|
|
110
|
+
# Currencies
|
|
111
|
+
gcu = data.get("gcu", {})
|
|
112
|
+
if self.local_player and gcu:
|
|
113
|
+
self.local_player.gold = gcu.get("C1", 0)
|
|
114
|
+
self.local_player.rubies = gcu.get("C2", 0)
|
|
115
|
+
|
|
116
|
+
# Alliance
|
|
117
|
+
gal = data.get("gal", {})
|
|
118
|
+
if gal and self.local_player and gal.get("AID"):
|
|
119
|
+
try:
|
|
120
|
+
self.local_player.alliance = Alliance(**gal)
|
|
121
|
+
self.local_player.AID = gal.get("AID")
|
|
122
|
+
logger.info(f"Alliance: {self.local_player.alliance.name}")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.warning(f"Could not parse alliance: {e}")
|
|
125
|
+
|
|
126
|
+
# Castles
|
|
127
|
+
gcl = data.get("gcl", {})
|
|
128
|
+
if gcl and self.local_player:
|
|
129
|
+
kingdoms = gcl.get("C", [])
|
|
130
|
+
for k_data in kingdoms:
|
|
131
|
+
kid = k_data.get("KID", 0)
|
|
132
|
+
area_infos = k_data.get("AI", [])
|
|
133
|
+
for area_entry in area_infos:
|
|
134
|
+
raw_ai = area_entry.get("AI")
|
|
135
|
+
if isinstance(raw_ai, list) and len(raw_ai) > 10:
|
|
136
|
+
# AI array format from lli: [type, x, y, area_id, owner_id, ...]
|
|
137
|
+
# Index 10 contains the castle name
|
|
138
|
+
x = raw_ai[1]
|
|
139
|
+
y = raw_ai[2]
|
|
140
|
+
area_id = raw_ai[3]
|
|
141
|
+
owner_id = raw_ai[4]
|
|
142
|
+
name = raw_ai[10]
|
|
143
|
+
|
|
144
|
+
if owner_id == self.local_player.id:
|
|
145
|
+
castle = Castle(OID=area_id, N=name, KID=kid, X=x, Y=y)
|
|
146
|
+
self.castles[area_id] = castle
|
|
147
|
+
self.local_player.castles[area_id] = castle
|
|
148
|
+
|
|
149
|
+
logger.info(f"Parsed {len(self.local_player.castles)} castles")
|
|
150
|
+
|
|
151
|
+
def _handle_gam(self, data: Dict[str, Any]) -> None:
|
|
152
|
+
"""Handle 'Get Army Movements' response."""
|
|
153
|
+
movements_list = data.get("M", [])
|
|
154
|
+
owners_list = data.get("O", []) # Owner info array
|
|
155
|
+
current_ids: Set[int] = set()
|
|
156
|
+
|
|
157
|
+
# Build owner lookup: OID -> {name, alliance_name}
|
|
158
|
+
owner_info: Dict[int, Dict[str, str]] = {}
|
|
159
|
+
for owner in owners_list:
|
|
160
|
+
if isinstance(owner, dict):
|
|
161
|
+
oid = owner.get("OID")
|
|
162
|
+
if oid:
|
|
163
|
+
owner_info[oid] = {
|
|
164
|
+
"name": owner.get("N", ""),
|
|
165
|
+
"alliance_name": owner.get("AN", ""),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for m_wrapper in movements_list:
|
|
169
|
+
if not isinstance(m_wrapper, dict):
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
m_data = m_wrapper.get("M", {})
|
|
173
|
+
if not m_data:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
mid = m_data.get("MID")
|
|
177
|
+
if not mid:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
current_ids.add(mid)
|
|
181
|
+
mov = self._parse_movement(m_data, m_wrapper, owner_info)
|
|
182
|
+
if not mov:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
is_new = mid not in self._previous_movement_ids
|
|
186
|
+
logger.debug(
|
|
187
|
+
f"gam: MID={mid}, is_new={is_new}, T={mov.T}, is_attack={mov.is_attack}, "
|
|
188
|
+
f"callback_set={self.on_incoming_attack is not None}"
|
|
189
|
+
)
|
|
190
|
+
if is_new:
|
|
191
|
+
mov.created_at = time.time()
|
|
192
|
+
|
|
193
|
+
# Trigger callback for attacks (server pushes gam for alliance attacks)
|
|
194
|
+
# Don't filter by is_incoming - that's relative to local player
|
|
195
|
+
# Dispatch in thread pool to avoid blocking receive loop
|
|
196
|
+
if mov.is_attack and self.on_incoming_attack:
|
|
197
|
+
logger.info(f"gam: Dispatching callback for MID={mid}")
|
|
198
|
+
self._dispatch_callback(self.on_incoming_attack, mov)
|
|
199
|
+
|
|
200
|
+
self.movements[mid] = mov
|
|
201
|
+
|
|
202
|
+
# Don't remove movements here - wait for explicit arrival (atv/ata) or recall (mrm)
|
|
203
|
+
# packets so we can properly dispatch callbacks with full movement data
|
|
204
|
+
self._previous_movement_ids = current_ids
|
|
205
|
+
|
|
206
|
+
def _handle_dcl(self, data: Dict[str, Any]) -> None:
|
|
207
|
+
"""Handle 'Detailed Castle List' response."""
|
|
208
|
+
kingdoms = data.get("C", [])
|
|
209
|
+
|
|
210
|
+
for k_data in kingdoms:
|
|
211
|
+
area_infos = k_data.get("AI", [])
|
|
212
|
+
for castle_data in area_infos:
|
|
213
|
+
if not isinstance(castle_data, dict):
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
aid = castle_data.get("AID")
|
|
217
|
+
if aid and aid in self.castles:
|
|
218
|
+
castle = self.castles[aid]
|
|
219
|
+
|
|
220
|
+
# Update resources
|
|
221
|
+
res = castle.resources
|
|
222
|
+
res.wood = int(castle_data.get("W", res.wood))
|
|
223
|
+
res.stone = int(castle_data.get("S", res.stone))
|
|
224
|
+
res.food = int(castle_data.get("F", res.food))
|
|
225
|
+
|
|
226
|
+
def _handle_mov(self, data: Dict[str, Any]) -> None:
|
|
227
|
+
"""Handle real-time movement update."""
|
|
228
|
+
m_data = data.get("M", data)
|
|
229
|
+
|
|
230
|
+
if isinstance(m_data, list):
|
|
231
|
+
for item in m_data:
|
|
232
|
+
if isinstance(item, dict):
|
|
233
|
+
self._update_single_movement(item)
|
|
234
|
+
elif isinstance(m_data, dict):
|
|
235
|
+
self._update_single_movement(m_data)
|
|
236
|
+
|
|
237
|
+
def _handle_atv(self, data: Dict[str, Any]) -> None:
|
|
238
|
+
"""Handle movement arrival."""
|
|
239
|
+
mid = data.get("MID")
|
|
240
|
+
if mid:
|
|
241
|
+
self._arrived_movement_ids.add(mid) # Mark as arrived, not recalled
|
|
242
|
+
self.movements.pop(mid, None)
|
|
243
|
+
self._previous_movement_ids.discard(mid)
|
|
244
|
+
|
|
245
|
+
def _handle_ata(self, data: Dict[str, Any]) -> None:
|
|
246
|
+
"""Handle attack arrival."""
|
|
247
|
+
mid = data.get("MID")
|
|
248
|
+
if mid:
|
|
249
|
+
self._arrived_movement_ids.add(mid) # Mark as arrived, not recalled
|
|
250
|
+
self.movements.pop(mid, None)
|
|
251
|
+
self._previous_movement_ids.discard(mid)
|
|
252
|
+
|
|
253
|
+
def _handle_mrm(self, data: Dict[str, Any]) -> None:
|
|
254
|
+
"""Handle movement recall (mrm = Move Recall Movement)."""
|
|
255
|
+
mid = data.get("MID")
|
|
256
|
+
if mid:
|
|
257
|
+
recalled_mov = self.movements.get(mid)
|
|
258
|
+
if recalled_mov and self.on_movement_recalled:
|
|
259
|
+
self._dispatch_callback(self.on_movement_recalled, recalled_mov)
|
|
260
|
+
self.movements.pop(mid, None)
|
|
261
|
+
self._previous_movement_ids.discard(mid)
|
|
262
|
+
|
|
263
|
+
def _parse_movement(
|
|
264
|
+
self,
|
|
265
|
+
m_data: Dict[str, Any],
|
|
266
|
+
m_wrapper: Optional[Dict[str, Any]] = None,
|
|
267
|
+
owner_info: Optional[Dict[int, Dict[str, str]]] = None,
|
|
268
|
+
) -> Optional[Movement]:
|
|
269
|
+
"""Parse a Movement from packet data."""
|
|
270
|
+
mid = m_data.get("MID")
|
|
271
|
+
if not mid:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
mov = Movement(**m_data)
|
|
276
|
+
mov.last_updated = time.time()
|
|
277
|
+
|
|
278
|
+
# Extract target coords
|
|
279
|
+
if mov.target_area and isinstance(mov.target_area, list) and len(mov.target_area) >= 5:
|
|
280
|
+
mov.target_x = mov.target_area[1]
|
|
281
|
+
mov.target_y = mov.target_area[2]
|
|
282
|
+
mov.target_area_id = mov.target_area[3]
|
|
283
|
+
if len(mov.target_area) > 10:
|
|
284
|
+
mov.target_name = str(mov.target_area[10]) if mov.target_area[10] else ""
|
|
285
|
+
|
|
286
|
+
# Extract source coords
|
|
287
|
+
if mov.source_area and isinstance(mov.source_area, list) and len(mov.source_area) >= 3:
|
|
288
|
+
mov.source_x = mov.source_area[1]
|
|
289
|
+
mov.source_y = mov.source_area[2]
|
|
290
|
+
if len(mov.source_area) >= 4:
|
|
291
|
+
mov.source_area_id = mov.source_area[3]
|
|
292
|
+
|
|
293
|
+
# Extract units from wrapper (GA = Garrison Army at wrapper level)
|
|
294
|
+
if m_wrapper:
|
|
295
|
+
ga_data = m_wrapper.get("GA", {})
|
|
296
|
+
|
|
297
|
+
# GA contains unit arrays in L (left), M (melee), R (ranged), RW (ranged wall)
|
|
298
|
+
# Each is a list of [unit_id, count] pairs
|
|
299
|
+
for key in ("L", "M", "R", "RW"):
|
|
300
|
+
unit_list = ga_data.get(key, [])
|
|
301
|
+
if isinstance(unit_list, list):
|
|
302
|
+
for item in unit_list:
|
|
303
|
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
304
|
+
try:
|
|
305
|
+
unit_id = int(item[0])
|
|
306
|
+
count = int(item[1])
|
|
307
|
+
mov.units[unit_id] = mov.units.get(unit_id, 0) + count
|
|
308
|
+
except (ValueError, TypeError):
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
# Extract resources or estimated size from GS field
|
|
312
|
+
# GS is an int when army not visible (estimated size)
|
|
313
|
+
# GS is a dict when transporting resources
|
|
314
|
+
gs_data = m_wrapper.get("GS")
|
|
315
|
+
if isinstance(gs_data, int):
|
|
316
|
+
mov.estimated_size = gs_data
|
|
317
|
+
elif isinstance(gs_data, dict):
|
|
318
|
+
mov.resources = MovementResources(
|
|
319
|
+
W=gs_data.get("W", 0),
|
|
320
|
+
S=gs_data.get("S", 0),
|
|
321
|
+
F=gs_data.get("F", 0),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Extract owner names and alliances from owner_info
|
|
325
|
+
if owner_info:
|
|
326
|
+
# Attacker info (OID = owner of the movement)
|
|
327
|
+
attacker_id = mov.OID
|
|
328
|
+
if attacker_id in owner_info:
|
|
329
|
+
mov.source_player_name = owner_info[attacker_id].get("name", "")
|
|
330
|
+
mov.source_alliance_name = owner_info[attacker_id].get("alliance_name", "")
|
|
331
|
+
|
|
332
|
+
# Defender info (TID = target player)
|
|
333
|
+
defender_id = mov.TID
|
|
334
|
+
if defender_id in owner_info:
|
|
335
|
+
mov.target_player_name = owner_info[defender_id].get("name", "")
|
|
336
|
+
mov.target_alliance_name = owner_info[defender_id].get("alliance_name", "")
|
|
337
|
+
|
|
338
|
+
return mov
|
|
339
|
+
except Exception as e:
|
|
340
|
+
logger.debug(f"Failed to parse movement {mid}: {e}")
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
def _update_single_movement(self, m_data: Dict[str, Any]) -> None:
|
|
344
|
+
"""Update a single movement from real-time packet."""
|
|
345
|
+
mid = m_data.get("MID")
|
|
346
|
+
if not mid:
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
existing = self.movements.get(mid)
|
|
350
|
+
mov = self._parse_movement(m_data)
|
|
351
|
+
if not mov:
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
is_new = existing is None
|
|
355
|
+
if is_new:
|
|
356
|
+
mov.created_at = time.time()
|
|
357
|
+
self._previous_movement_ids.add(mid)
|
|
358
|
+
|
|
359
|
+
# Trigger callback for new incoming attacks
|
|
360
|
+
# Dispatch in thread pool to avoid blocking receive loop
|
|
361
|
+
if mov.is_incoming and mov.is_attack and self.on_incoming_attack:
|
|
362
|
+
self._dispatch_callback(self.on_incoming_attack, mov)
|
|
363
|
+
elif existing:
|
|
364
|
+
# Preserve metadata from existing movement that real-time packets don't include
|
|
365
|
+
mov.created_at = existing.created_at
|
|
366
|
+
mov.source_player_name = existing.source_player_name or mov.source_player_name
|
|
367
|
+
mov.source_alliance_name = existing.source_alliance_name or mov.source_alliance_name
|
|
368
|
+
mov.target_player_name = existing.target_player_name or mov.target_player_name
|
|
369
|
+
mov.target_alliance_name = existing.target_alliance_name or mov.target_alliance_name
|
|
370
|
+
# Preserve units if the update doesn't have them
|
|
371
|
+
if not mov.units and existing.units:
|
|
372
|
+
mov.units = existing.units
|
|
373
|
+
|
|
374
|
+
self.movements[mid] = mov
|
|
375
|
+
|
|
376
|
+
# ============================================================
|
|
377
|
+
# Query Methods
|
|
378
|
+
# ============================================================
|
|
379
|
+
|
|
380
|
+
def get_all_movements(self) -> List[Movement]:
|
|
381
|
+
"""Get all tracked movements."""
|
|
382
|
+
return list(self.movements.values())
|
|
383
|
+
|
|
384
|
+
def get_incoming_movements(self) -> List[Movement]:
|
|
385
|
+
"""Get all incoming movements."""
|
|
386
|
+
return [m for m in self.movements.values() if m.is_incoming]
|
|
387
|
+
|
|
388
|
+
def get_outgoing_movements(self) -> List[Movement]:
|
|
389
|
+
"""Get all outgoing movements."""
|
|
390
|
+
return [m for m in self.movements.values() if m.is_outgoing]
|
|
391
|
+
|
|
392
|
+
def get_incoming_attacks(self) -> List[Movement]:
|
|
393
|
+
"""Get all incoming attack movements."""
|
|
394
|
+
return [m for m in self.movements.values() if m.is_incoming and m.is_attack]
|
|
395
|
+
|
|
396
|
+
def get_movement_by_id(self, movement_id: int) -> Optional[Movement]:
|
|
397
|
+
"""Get a specific movement by ID."""
|
|
398
|
+
return self.movements.get(movement_id)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Coordinate(BaseModel):
|
|
7
|
+
x: int
|
|
8
|
+
y: int
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Resources(BaseModel):
|
|
12
|
+
"""Resources in a castle."""
|
|
13
|
+
|
|
14
|
+
# Basic resources
|
|
15
|
+
wood: int = 0
|
|
16
|
+
stone: int = 0
|
|
17
|
+
food: int = 0
|
|
18
|
+
|
|
19
|
+
# Capacity (from dcl packet)
|
|
20
|
+
wood_cap: int = 0 # MRW (Max Resource Wood)
|
|
21
|
+
stone_cap: int = 0 # MRS (Max Resource Stone)
|
|
22
|
+
food_cap: int = 0 # MRF (Max Resource Food)
|
|
23
|
+
|
|
24
|
+
# Production rates (from dcl packet)
|
|
25
|
+
wood_rate: float = 0.0 # RS1
|
|
26
|
+
stone_rate: float = 0.0 # RS2
|
|
27
|
+
food_rate: float = 0.0 # RS3
|
|
28
|
+
|
|
29
|
+
# Safe storage
|
|
30
|
+
wood_safe: float = 0.0 # SAFE_W
|
|
31
|
+
stone_safe: float = 0.0 # SAFE_S
|
|
32
|
+
food_safe: float = 0.0 # SAFE_F
|
|
33
|
+
|
|
34
|
+
# Special resources
|
|
35
|
+
iron: int = 0 # MRI
|
|
36
|
+
honey: int = 0 # MRHONEY
|
|
37
|
+
mead: int = 0 # MRMEAD
|
|
38
|
+
beef: int = 0 # MRBEEF
|
|
39
|
+
glass: int = 0 # MRG
|
|
40
|
+
ash: int = 0 # MRA
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Troop(BaseModel):
|
|
44
|
+
"""Represents a troop type definition or a specific troop count."""
|
|
45
|
+
|
|
46
|
+
# This might be too generic. Renaming to Unit
|
|
47
|
+
unit_id: int
|
|
48
|
+
count: int = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Building(BaseModel):
|
|
52
|
+
"""Represents a building in a castle."""
|
|
53
|
+
|
|
54
|
+
id: int
|
|
55
|
+
level: int = 0
|
|
56
|
+
|
|
57
|
+
# Building status (if available)
|
|
58
|
+
upgrading: bool = False
|
|
59
|
+
upgrade_finish_time: Optional[int] = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Alliance(BaseModel):
|
|
63
|
+
"""Represents an alliance/guild."""
|
|
64
|
+
|
|
65
|
+
model_config = ConfigDict(extra="ignore")
|
|
66
|
+
|
|
67
|
+
AID: int = Field(default=-1) # Alliance ID
|
|
68
|
+
N: str = Field(default="") # Alliance Name
|
|
69
|
+
SA: str = Field(default="") # Short/Abbreviation (server sends 0 if none)
|
|
70
|
+
R: int = Field(default=0) # Rank
|
|
71
|
+
|
|
72
|
+
@field_validator("SA", mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def coerce_sa_to_str(cls, v: Any) -> str:
|
|
75
|
+
"""Server sends 0 when there's no abbreviation."""
|
|
76
|
+
if v is None or v == 0:
|
|
77
|
+
return ""
|
|
78
|
+
return str(v)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def id(self) -> int:
|
|
82
|
+
return self.AID
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def name(self) -> str:
|
|
86
|
+
return self.N
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def abbreviation(self) -> str:
|
|
90
|
+
return self.SA
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def rank(self) -> int:
|
|
94
|
+
return self.R
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Castle(BaseModel):
|
|
98
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
99
|
+
|
|
100
|
+
OID: int = Field(default=-1) # Object ID / Area ID (AID)
|
|
101
|
+
N: str = Field(default="Unknown") # Name
|
|
102
|
+
KID: int = Field(default=0) # Kingdom ID
|
|
103
|
+
X: int = Field(default=0) # X coordinate
|
|
104
|
+
Y: int = Field(default=0) # Y coordinate
|
|
105
|
+
|
|
106
|
+
# Castle details (from dcl packet)
|
|
107
|
+
P: int = Field(default=0) # Population
|
|
108
|
+
NDP: int = Field(default=0) # Next Day Population
|
|
109
|
+
MC: int = Field(default=0) # Max Castellans
|
|
110
|
+
B: int = Field(default=0) # Has Barracks
|
|
111
|
+
WS: int = Field(default=0) # Has Workshop
|
|
112
|
+
DW: int = Field(default=0) # Has Dwelling
|
|
113
|
+
H: int = Field(default=0) # Has Harbour
|
|
114
|
+
|
|
115
|
+
# Python-friendly aliases
|
|
116
|
+
@property
|
|
117
|
+
def id(self) -> int:
|
|
118
|
+
return self.OID
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def name(self) -> str:
|
|
122
|
+
return self.N
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def x(self) -> int:
|
|
126
|
+
return self.X
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def y(self) -> int:
|
|
130
|
+
return self.Y
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def kingdom_id(self) -> int:
|
|
134
|
+
return self.KID
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def population(self) -> int:
|
|
138
|
+
return self.P
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def max_castellans(self) -> int:
|
|
142
|
+
return self.MC
|
|
143
|
+
|
|
144
|
+
resources: Resources = Field(default_factory=Resources)
|
|
145
|
+
buildings: List[Building] = Field(default_factory=list)
|
|
146
|
+
units: Dict[int, int] = Field(default_factory=dict)
|
|
147
|
+
|
|
148
|
+
raw_data: Dict[str, Any] = Field(default_factory=dict, exclude=True)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_game_data(cls, data: Dict[str, Any]) -> "Castle":
|
|
152
|
+
# Mapping logic for 'gcl' (Global Castle List) / 'gbd' payload
|
|
153
|
+
return cls(**data)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Player(BaseModel):
|
|
157
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
158
|
+
|
|
159
|
+
PID: int = Field(default=-1)
|
|
160
|
+
PN: str = Field(default="Unknown")
|
|
161
|
+
AID: Optional[int] = Field(default=None)
|
|
162
|
+
|
|
163
|
+
# Levels
|
|
164
|
+
LVL: int = Field(default=0)
|
|
165
|
+
XP: int = Field(default=0)
|
|
166
|
+
LL: int = Field(default=0) # Legendary Level
|
|
167
|
+
XPFCL: int = Field(default=0) # XP for current level
|
|
168
|
+
XPTNL: int = Field(default=0) # XP to next level
|
|
169
|
+
|
|
170
|
+
# Resources
|
|
171
|
+
gold: int = 0 # C1 from gcu
|
|
172
|
+
rubies: int = 0 # C2 from gcu
|
|
173
|
+
|
|
174
|
+
# Alliance
|
|
175
|
+
alliance: Optional[Alliance] = None
|
|
176
|
+
|
|
177
|
+
# Python-friendly properties
|
|
178
|
+
@property
|
|
179
|
+
def id(self) -> int:
|
|
180
|
+
return self.PID
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def name(self) -> str:
|
|
184
|
+
return self.PN
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def alliance_id(self) -> Optional[int]:
|
|
188
|
+
return self.AID
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def level(self) -> int:
|
|
192
|
+
return self.LVL
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def xp(self) -> int:
|
|
196
|
+
return self.XP
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def legendary_level(self) -> int:
|
|
200
|
+
return self.LL
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def xp_progress(self) -> float:
|
|
204
|
+
"""Returns XP progress as a percentage (0-100)."""
|
|
205
|
+
if self.XPTNL > 0:
|
|
206
|
+
return (self.XPFCL / self.XPTNL) * 100
|
|
207
|
+
return 0.0
|
|
208
|
+
|
|
209
|
+
castles: Dict[int, Castle] = Field(default_factory=dict)
|
|
210
|
+
|
|
211
|
+
E: Optional[str] = Field(default=None)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def email(self) -> Optional[str]:
|
|
215
|
+
return self.E
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Models for quests and achievements.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, List
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Quest(BaseModel):
|
|
11
|
+
"""Represents a game quest."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
14
|
+
|
|
15
|
+
QID: int = Field(default=-1) # Quest ID
|
|
16
|
+
P: List[int] = Field(default_factory=list) # Progress
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def quest_id(self) -> int:
|
|
20
|
+
return self.QID
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def progress(self) -> List[int]:
|
|
24
|
+
return self.P
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class QuestReward(BaseModel):
|
|
28
|
+
"""Quest reward."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(extra="ignore")
|
|
31
|
+
|
|
32
|
+
type: str # "U" for units, "F" for food, etc.
|
|
33
|
+
value: Any # Depends on type
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DailyQuest(BaseModel):
|
|
37
|
+
"""Daily quest with progress and rewards."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
40
|
+
|
|
41
|
+
PQL: int = Field(default=0) # Player Quest Level?
|
|
42
|
+
RDQ: List[Quest] = Field(default_factory=list) # Running Daily Quests
|
|
43
|
+
FDQ: List[int] = Field(default_factory=list) # Finished Daily Quests
|
|
44
|
+
RS: List[List[Any]] = Field(default_factory=list) # Rewards
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def level(self) -> int:
|
|
48
|
+
return self.PQL
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def active_quests(self) -> List[Quest]:
|
|
52
|
+
return self.RDQ
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def finished_quests(self) -> List[int]:
|
|
56
|
+
return self.FDQ
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def rewards(self) -> List[List[Any]]:
|
|
60
|
+
return self.RS
|