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,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