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,300 @@
1
+ """
2
+ Defense protocol models.
3
+
4
+ Commands:
5
+ - dfc: Get defense configuration
6
+ - dfk: Change keep defense
7
+ - dfw: Change wall defense
8
+ - dfm: Change moat defense
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pydantic import ConfigDict, Field
14
+
15
+ from .base import BaseRequest, BaseResponse, UnitCount
16
+
17
+ # =============================================================================
18
+ # DFC - Get Defense Configuration
19
+ # =============================================================================
20
+
21
+
22
+ class GetDefenseRequest(BaseRequest):
23
+ """
24
+ Get defense configuration for a castle.
25
+
26
+ Command: dfc
27
+ Payload: {"CID": castle_id}
28
+ """
29
+
30
+ command = "dfc"
31
+
32
+ castle_id: int = Field(alias="CID")
33
+
34
+
35
+ class DefenseConfiguration(BaseResponse):
36
+ """Defense configuration for a location (keep, wall, moat)."""
37
+
38
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
39
+
40
+ units: list[UnitCount] = Field(alias="U", default_factory=list)
41
+ tools: list[UnitCount] = Field(alias="T", default_factory=list)
42
+
43
+
44
+ class GetDefenseResponse(BaseResponse):
45
+ """
46
+ Response containing defense configuration.
47
+
48
+ Command: dfc
49
+ """
50
+
51
+ command = "dfc"
52
+
53
+ keep: DefenseConfiguration | None = Field(alias="K", default=None)
54
+ wall: DefenseConfiguration | None = Field(alias="W", default=None)
55
+ moat: DefenseConfiguration | None = Field(alias="M", default=None)
56
+ courtyard: DefenseConfiguration | None = Field(alias="C", default=None)
57
+
58
+
59
+ # =============================================================================
60
+ # DFK - Change Keep Defense
61
+ # =============================================================================
62
+
63
+
64
+ class ChangeKeepDefenseRequest(BaseRequest):
65
+ """
66
+ Change keep defense configuration.
67
+
68
+ Command: dfk
69
+ Payload: {
70
+ "CID": castle_id,
71
+ "U": [{"UID": unit_id, "C": count}, ...],
72
+ "T": [{"TID": tool_id, "C": count}, ...]
73
+ }
74
+ """
75
+
76
+ command = "dfk"
77
+
78
+ castle_id: int = Field(alias="CID")
79
+ units: list[UnitCount] = Field(alias="U", default_factory=list)
80
+ tools: list[UnitCount] = Field(alias="T", default_factory=list)
81
+
82
+
83
+ class ChangeKeepDefenseResponse(BaseResponse):
84
+ """
85
+ Response to changing keep defense.
86
+
87
+ Command: dfk
88
+ """
89
+
90
+ command = "dfk"
91
+
92
+ success: bool = Field(default=True)
93
+ error_code: int = Field(alias="E", default=0)
94
+
95
+
96
+ # =============================================================================
97
+ # DFW - Change Wall Defense
98
+ # =============================================================================
99
+
100
+
101
+ class ChangeWallDefenseRequest(BaseRequest):
102
+ """
103
+ Change wall defense configuration.
104
+
105
+ Command: dfw
106
+ Payload: {
107
+ "CID": castle_id,
108
+ "U": [{"UID": unit_id, "C": count}, ...],
109
+ "T": [{"TID": tool_id, "C": count}, ...]
110
+ }
111
+ """
112
+
113
+ command = "dfw"
114
+
115
+ castle_id: int = Field(alias="CID")
116
+ units: list[UnitCount] = Field(alias="U", default_factory=list)
117
+ tools: list[UnitCount] = Field(alias="T", default_factory=list)
118
+
119
+
120
+ class ChangeWallDefenseResponse(BaseResponse):
121
+ """
122
+ Response to changing wall defense.
123
+
124
+ Command: dfw
125
+ """
126
+
127
+ command = "dfw"
128
+
129
+ success: bool = Field(default=True)
130
+ error_code: int = Field(alias="E", default=0)
131
+
132
+
133
+ # =============================================================================
134
+ # DFM - Change Moat Defense
135
+ # =============================================================================
136
+
137
+
138
+ class ChangeMoatDefenseRequest(BaseRequest):
139
+ """
140
+ Change moat defense configuration.
141
+
142
+ Command: dfm
143
+ Payload: {
144
+ "CID": castle_id,
145
+ "U": [{"UID": unit_id, "C": count}, ...],
146
+ "T": [{"TID": tool_id, "C": count}, ...]
147
+ }
148
+ """
149
+
150
+ command = "dfm"
151
+
152
+ castle_id: int = Field(alias="CID")
153
+ units: list[UnitCount] = Field(alias="U", default_factory=list)
154
+ tools: list[UnitCount] = Field(alias="T", default_factory=list)
155
+
156
+
157
+ class ChangeMoatDefenseResponse(BaseResponse):
158
+ """
159
+ Response to changing moat defense.
160
+
161
+ Command: dfm
162
+ """
163
+
164
+ command = "dfm"
165
+
166
+ success: bool = Field(default=True)
167
+ error_code: int = Field(alias="E", default=0)
168
+
169
+
170
+ # =============================================================================
171
+ # SDI - Support Defense Info (Alliance Member Castle Defense)
172
+ # =============================================================================
173
+
174
+
175
+ class GetSupportDefenseRequest(BaseRequest):
176
+ """
177
+ Get defense info for an alliance member's castle.
178
+
179
+ Command: sdi
180
+ Payload: {"TX": target_x, "TY": target_y, "SX": source_x, "SY": source_y}
181
+
182
+ Note: Can only query castles of players in the same alliance.
183
+ TX/TY = Target castle coordinates (the one being attacked)
184
+ SX/SY = Source castle coordinates (your castle sending support)
185
+ """
186
+
187
+ command = "sdi"
188
+
189
+ target_x: int = Field(alias="TX")
190
+ target_y: int = Field(alias="TY")
191
+ source_x: int = Field(alias="SX")
192
+ source_y: int = Field(alias="SY")
193
+
194
+
195
+ class GetSupportDefenseResponse(BaseResponse):
196
+ """
197
+ Response containing defense information for an alliance member's castle.
198
+
199
+ Command: sdi
200
+
201
+ The response contains:
202
+ - SCID: Castle ID queried
203
+ - S: List of 6 defense positions, each containing [[unit_id, count], ...] pairs
204
+ - B: Commander/Lord info
205
+ - gui: Unit inventory
206
+ - gli: Lords info
207
+ - UYL: Total yard limit (max troops in courtyard)
208
+ - AUYL: Available yard limit
209
+ - UWL: Wall limit
210
+
211
+ To get total defenders, sum all unit counts across all positions in S.
212
+ """
213
+
214
+ command = "sdi"
215
+
216
+ castle_id: int = Field(alias="SCID", default=0)
217
+
218
+ # S contains 6 arrays (defense positions), each with [unit_id, count] pairs
219
+ # e.g. [[[487, 5174], [488, 20]], [[487, 347]], ...]
220
+ defense_positions: list = Field(alias="S", default_factory=list)
221
+
222
+ # Commander/Lord info (optional, not always present)
223
+ commander_info: dict | None = Field(alias="B", default=None)
224
+
225
+ # Unit inventory info
226
+ unit_inventory: dict | None = Field(alias="gui", default=None)
227
+
228
+ # Lords info
229
+ lords_info: dict | None = Field(alias="gli", default=None)
230
+
231
+ # Capacity limits
232
+ yard_limit: int = Field(alias="UYL", default=0) # Total yard/courtyard limit
233
+ available_yard_limit: int = Field(alias="AUYL", default=0) # Available yard space
234
+ wall_limit: int = Field(alias="UWL", default=0) # Wall limit
235
+
236
+ def get_total_defenders(self) -> int:
237
+ """
238
+ Calculate total number of defending troops.
239
+
240
+ Returns:
241
+ Total count of all units across all defense positions.
242
+ """
243
+ total = 0
244
+ for position in self.defense_positions:
245
+ if isinstance(position, list):
246
+ for unit_pair in position:
247
+ if isinstance(unit_pair, list) and len(unit_pair) >= 2:
248
+ # unit_pair is [unit_id, count]
249
+ total += unit_pair[1]
250
+ return total
251
+
252
+ def get_max_defense(self) -> int:
253
+ """
254
+ Get the maximum defense capacity for this castle.
255
+
256
+ UYL (yard_limit) represents the total capacity including
257
+ courtyard limit plus room for alliance support.
258
+
259
+ Returns:
260
+ Maximum number of troops that can defend this castle.
261
+ """
262
+ return self.yard_limit
263
+
264
+ def get_units_by_position(self) -> list[dict[int, int]]:
265
+ """
266
+ Get unit counts grouped by defense position.
267
+
268
+ Returns:
269
+ List of 6 dicts, each mapping unit_id -> count for that position.
270
+ """
271
+ result = []
272
+ for position in self.defense_positions:
273
+ units: dict[int, int] = {}
274
+ if isinstance(position, list):
275
+ for unit_pair in position:
276
+ if isinstance(unit_pair, list) and len(unit_pair) >= 2:
277
+ unit_id, count = unit_pair[0], unit_pair[1]
278
+ units[unit_id] = units.get(unit_id, 0) + count
279
+ result.append(units)
280
+ return result
281
+
282
+
283
+ __all__ = [
284
+ # DFC - Get Defense
285
+ "GetDefenseRequest",
286
+ "GetDefenseResponse",
287
+ "DefenseConfiguration",
288
+ # DFK - Keep Defense
289
+ "ChangeKeepDefenseRequest",
290
+ "ChangeKeepDefenseResponse",
291
+ # DFW - Wall Defense
292
+ "ChangeWallDefenseRequest",
293
+ "ChangeWallDefenseResponse",
294
+ # DFM - Moat Defense
295
+ "ChangeMoatDefenseRequest",
296
+ "ChangeMoatDefenseResponse",
297
+ # SDI - Support Defense Info
298
+ "GetSupportDefenseRequest",
299
+ "GetSupportDefenseResponse",
300
+ ]
@@ -0,0 +1,269 @@
1
+ """
2
+ Map protocol models.
3
+
4
+ Commands:
5
+ - gaa: Get map area/chunk
6
+ - gam: Get active movements
7
+ - fnm: Find NPC on map
8
+ - adi: Get area/target detailed info
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pydantic import ConfigDict, Field
14
+
15
+ from .base import BaseRequest, BaseResponse, PlayerInfo, Position
16
+
17
+ # =============================================================================
18
+ # GAA - Get Map Area
19
+ # =============================================================================
20
+
21
+
22
+ class GetMapAreaRequest(BaseRequest):
23
+ """
24
+ Get a chunk of the map.
25
+
26
+ Command: gaa
27
+ Payload: {"X": x, "Y": y, "W": width, "H": height, "KID": kingdom_id}
28
+
29
+ Returns information about all objects in the specified area.
30
+ """
31
+
32
+ command = "gaa"
33
+
34
+ x: int = Field(alias="X")
35
+ y: int = Field(alias="Y")
36
+ width: int = Field(alias="W", default=10)
37
+ height: int = Field(alias="H", default=10)
38
+ kingdom_id: int = Field(alias="KID", default=0)
39
+
40
+
41
+ class MapObject(BaseResponse):
42
+ """An object on the map (castle, NPC, resource, etc.)."""
43
+
44
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
45
+
46
+ x: int = Field(alias="X")
47
+ y: int = Field(alias="Y")
48
+ object_type: int = Field(alias="OT") # Type of object
49
+ object_id: int = Field(alias="OID", default=0)
50
+ owner_id: int | None = Field(alias="PID", default=None)
51
+ owner_name: str | None = Field(alias="PN", default=None)
52
+ alliance_id: int | None = Field(alias="AID", default=None)
53
+ alliance_name: str | None = Field(alias="AN", default=None)
54
+ level: int = Field(alias="L", default=0)
55
+ name: str | None = Field(alias="N", default=None)
56
+
57
+ @property
58
+ def position(self) -> Position:
59
+ """Get object position."""
60
+ return Position(X=self.x, Y=self.y)
61
+
62
+
63
+ class GetMapAreaResponse(BaseResponse):
64
+ """
65
+ Response containing map area data.
66
+
67
+ Command: gaa
68
+ """
69
+
70
+ command = "gaa"
71
+
72
+ objects: list[MapObject] = Field(alias="O", default_factory=list)
73
+
74
+
75
+ # =============================================================================
76
+ # GAM - Get Active Movements
77
+ # =============================================================================
78
+
79
+
80
+ class GetMovementsRequest(BaseRequest):
81
+ """
82
+ Get all active troop movements.
83
+
84
+ Command: gam
85
+ Payload: {} (empty) or {"CID": castle_id}
86
+ """
87
+
88
+ command = "gam"
89
+
90
+ castle_id: int | None = Field(alias="CID", default=None)
91
+
92
+
93
+ class Movement(BaseResponse):
94
+ """An active troop movement."""
95
+
96
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
97
+
98
+ movement_id: int = Field(alias="MID")
99
+ movement_type: int = Field(alias="MT") # 1=attack, 2=support, 3=spy, 4=trade, etc.
100
+
101
+ # Source
102
+ source_x: int = Field(alias="SX")
103
+ source_y: int = Field(alias="SY")
104
+ source_castle_id: int = Field(alias="SCID", default=0)
105
+ source_player_id: int = Field(alias="SPID", default=0)
106
+
107
+ # Target
108
+ target_x: int = Field(alias="TX")
109
+ target_y: int = Field(alias="TY")
110
+ target_castle_id: int | None = Field(alias="TCID", default=None)
111
+ target_player_id: int | None = Field(alias="TPID", default=None)
112
+
113
+ # Timing
114
+ start_time: int = Field(alias="ST") # Unix timestamp
115
+ arrival_time: int = Field(alias="AT") # Unix timestamp
116
+ return_time: int | None = Field(alias="RT", default=None)
117
+
118
+ # Status
119
+ is_returning: bool = Field(alias="IR", default=False)
120
+
121
+ @property
122
+ def source_position(self) -> Position:
123
+ """Get source position."""
124
+ return Position(X=self.source_x, Y=self.source_y)
125
+
126
+ @property
127
+ def target_position(self) -> Position:
128
+ """Get target position."""
129
+ return Position(X=self.target_x, Y=self.target_y)
130
+
131
+
132
+ class GetMovementsResponse(BaseResponse):
133
+ """
134
+ Response containing active movements.
135
+
136
+ Command: gam
137
+ """
138
+
139
+ command = "gam"
140
+
141
+ movements: list[Movement] = Field(alias="M", default_factory=list)
142
+
143
+
144
+ # =============================================================================
145
+ # FNM - Find NPC
146
+ # =============================================================================
147
+
148
+
149
+ class FindNPCRequest(BaseRequest):
150
+ """
151
+ Find NPC targets on the map.
152
+
153
+ Command: fnm
154
+ Payload: {"NT": npc_type, "L": level, "KID": kingdom_id}
155
+
156
+ NPC types vary by game version.
157
+ """
158
+
159
+ command = "fnm"
160
+
161
+ npc_type: int = Field(alias="NT")
162
+ level: int | None = Field(alias="L", default=None)
163
+ kingdom_id: int = Field(alias="KID", default=0)
164
+
165
+
166
+ class NPCLocation(BaseResponse):
167
+ """An NPC location on the map."""
168
+
169
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
170
+
171
+ x: int = Field(alias="X")
172
+ y: int = Field(alias="Y")
173
+ npc_type: int = Field(alias="NT")
174
+ level: int = Field(alias="L")
175
+ npc_id: int = Field(alias="NID", default=0)
176
+
177
+ @property
178
+ def position(self) -> Position:
179
+ """Get NPC position."""
180
+ return Position(X=self.x, Y=self.y)
181
+
182
+
183
+ class FindNPCResponse(BaseResponse):
184
+ """
185
+ Response containing NPC locations.
186
+
187
+ Command: fnm
188
+ """
189
+
190
+ command = "fnm"
191
+
192
+ npcs: list[NPCLocation] = Field(alias="N", default_factory=list)
193
+
194
+
195
+ # =============================================================================
196
+ # ADI - Get Area/Target Detailed Info
197
+ # =============================================================================
198
+
199
+
200
+ class GetTargetInfoRequest(BaseRequest):
201
+ """
202
+ Get detailed info about a specific map location/target.
203
+
204
+ Command: adi
205
+ Payload: {"X": x, "Y": y, "KID": kingdom_id}
206
+ """
207
+
208
+ command = "adi"
209
+
210
+ x: int = Field(alias="X")
211
+ y: int = Field(alias="Y")
212
+ kingdom_id: int = Field(alias="KID", default=0)
213
+
214
+
215
+ class TargetInfo(BaseResponse):
216
+ """Detailed information about a target location."""
217
+
218
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
219
+
220
+ x: int = Field(alias="X")
221
+ y: int = Field(alias="Y")
222
+ object_type: int = Field(alias="OT")
223
+ object_id: int = Field(alias="OID", default=0)
224
+
225
+ # Owner info (if owned)
226
+ owner: PlayerInfo | None = Field(alias="O", default=None)
227
+
228
+ # Castle-specific
229
+ castle_name: str | None = Field(alias="CN", default=None)
230
+ castle_level: int | None = Field(alias="CL", default=None)
231
+
232
+ # NPC-specific
233
+ npc_type: int | None = Field(alias="NT", default=None)
234
+ npc_level: int | None = Field(alias="NL", default=None)
235
+
236
+ # Resources (for resource nodes)
237
+ resources: int | None = Field(alias="R", default=None)
238
+
239
+
240
+ class GetTargetInfoResponse(BaseResponse):
241
+ """
242
+ Response containing target information.
243
+
244
+ Command: adi
245
+ """
246
+
247
+ command = "adi"
248
+
249
+ target: TargetInfo | None = Field(alias="T", default=None)
250
+
251
+
252
+ __all__ = [
253
+ # GAA - Map Area
254
+ "GetMapAreaRequest",
255
+ "GetMapAreaResponse",
256
+ "MapObject",
257
+ # GAM - Movements
258
+ "GetMovementsRequest",
259
+ "GetMovementsResponse",
260
+ "Movement",
261
+ # FNM - Find NPC
262
+ "FindNPCRequest",
263
+ "FindNPCResponse",
264
+ "NPCLocation",
265
+ # ADI - Target Info
266
+ "GetTargetInfoRequest",
267
+ "GetTargetInfoResponse",
268
+ "TargetInfo",
269
+ ]
@@ -0,0 +1,104 @@
1
+ import json
2
+ import xml.etree.ElementTree as ET
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+
7
+ @dataclass
8
+ class Packet:
9
+ """
10
+ Base representation of a SmartFoxServer packet.
11
+ Can be either XML (Handshake) or XT (Extended/JSON).
12
+ """
13
+
14
+ raw_data: str
15
+ is_xml: bool
16
+
17
+ command_id: Optional[str] = None
18
+ request_id: int = -1
19
+ error_code: int = 0 # New field for XT status/error code
20
+ payload: Union[Dict[str, Any], ET.Element, None] = None
21
+
22
+ @staticmethod
23
+ def build_xt(zone: str, command: str, payload: Dict[str, Any], request_id: int = 1) -> str:
24
+ """
25
+ Build an XT (Extended) packet string.
26
+
27
+ Args:
28
+ zone: Game zone (e.g., "EmpireEx_21")
29
+ command: Command ID (e.g., "att", "tra", "bui")
30
+ payload: Dictionary payload to JSON encode
31
+ request_id: Request ID (default 1)
32
+
33
+ Returns:
34
+ Formatted XT packet string
35
+ """
36
+ return f"%xt%{zone}%{command}%{request_id}%{json.dumps(payload)}%"
37
+
38
+ @classmethod
39
+ def from_bytes(cls, data: bytes) -> "Packet":
40
+ decoded = data.decode("utf-8").rstrip("\x00")
41
+ if not decoded:
42
+ raise ValueError("Empty packet")
43
+
44
+ if decoded.startswith("<"):
45
+ return cls._parse_xml(decoded)
46
+ elif decoded.startswith("%xt%"):
47
+ return cls._parse_xt(decoded)
48
+
49
+ # Unknown or junk, return raw wrapper
50
+ return cls(raw_data=decoded, is_xml=False)
51
+
52
+ @classmethod
53
+ def _parse_xml(cls, data: str) -> "Packet":
54
+ try:
55
+ root = ET.fromstring(data)
56
+ # Structure: <msg t='sys'><body action='verChk' ...>
57
+ body = root.find("body")
58
+ cmd = body.get("action") if body is not None else None
59
+
60
+ # Fallback: Use root tag if no action (e.g. <cross-domain-policy>)
61
+ if cmd is None:
62
+ cmd = root.tag
63
+
64
+ return cls(raw_data=data, is_xml=True, command_id=cmd, payload=root)
65
+ except ET.ParseError:
66
+ return cls(raw_data=data, is_xml=True)
67
+
68
+ @classmethod
69
+ def _parse_xt(cls, data: str) -> "Packet":
70
+ # Format: %xt%{Command}%{RequestId}%{Status}%{Payload}%
71
+ parts = data.split("%")
72
+ if len(parts) < 5:
73
+ return cls(raw_data=data, is_xml=False)
74
+
75
+ cmd = parts[2]
76
+ req_id = int(parts[3]) if parts[3].isdigit() else -1
77
+
78
+ error_code = 0
79
+ if parts[4].isdigit() or (parts[4].startswith("-") and parts[4][1:].isdigit()):
80
+ error_code = int(parts[4])
81
+
82
+ raw_payload = parts[5] if len(parts) > 5 else ""
83
+
84
+ # Optimization: Only parse JSON if it looks like JSON
85
+ payload_data = {}
86
+ if raw_payload.startswith("{") or raw_payload.startswith("["):
87
+ try:
88
+ payload_data = json.loads(raw_payload)
89
+ except json.JSONDecodeError:
90
+ payload_data = {"raw": raw_payload}
91
+ else:
92
+ payload_data = {"raw": raw_payload}
93
+
94
+ return cls(
95
+ raw_data=data,
96
+ is_xml=False,
97
+ command_id=cmd,
98
+ request_id=req_id,
99
+ error_code=error_code,
100
+ payload=payload_data,
101
+ )
102
+
103
+ def to_bytes(self) -> bytes:
104
+ return (self.raw_data + "\x00").encode("utf-8")
@@ -0,0 +1,31 @@
1
+ """
2
+ Service layer for EmpireCore.
3
+
4
+ Services provide high-level APIs for different game domains (alliance, castle, etc.)
5
+ and are auto-registered with the EmpireClient.
6
+
7
+ Usage:
8
+ @register_service("alliance")
9
+ class AllianceService(BaseService):
10
+ def send_chat(self, message: str):
11
+ request = AllianceChatMessageRequest.create(message)
12
+ self.send(request)
13
+
14
+ # Client auto-discovers services:
15
+ client = EmpireClient(...)
16
+ client.alliance.send_chat("Hello!")
17
+ """
18
+
19
+ # Import services to trigger registration
20
+ from .alliance import AllianceService
21
+ from .base import BaseService, get_registered_services, register_service
22
+ from .castle import CastleService
23
+
24
+ __all__ = [
25
+ "BaseService",
26
+ "register_service",
27
+ "get_registered_services",
28
+ # Services
29
+ "AllianceService",
30
+ "CastleService",
31
+ ]