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,252 @@
1
+ """
2
+ Helper functions for common game operations.
3
+ """
4
+
5
+ from typing import Dict, List, Optional
6
+
7
+ from empire_core.state.models import Castle, Player
8
+ from empire_core.state.world_models import Movement
9
+ from empire_core.utils.enums import MovementType
10
+
11
+
12
+ class CastleHelper:
13
+ """Helper for castle operations."""
14
+
15
+ @staticmethod
16
+ def has_sufficient_resources(castle: Castle, wood: int = 0, stone: int = 0, food: int = 0) -> bool:
17
+ """Check if castle has sufficient resources."""
18
+ return castle.resources.wood >= wood and castle.resources.stone >= stone and castle.resources.food >= food
19
+
20
+ @staticmethod
21
+ def get_resource_overflow(castle: Castle) -> Dict[str, int]:
22
+ """Get resources exceeding capacity."""
23
+ overflow = {}
24
+
25
+ if castle.resources.wood > castle.resources.wood_cap:
26
+ overflow["wood"] = castle.resources.wood - castle.resources.wood_cap
27
+
28
+ if castle.resources.stone > castle.resources.stone_cap:
29
+ overflow["stone"] = castle.resources.stone - castle.resources.stone_cap
30
+
31
+ if castle.resources.food > castle.resources.food_cap:
32
+ overflow["food"] = castle.resources.food - castle.resources.food_cap
33
+
34
+ return overflow
35
+
36
+ @staticmethod
37
+ def can_upgrade_building(
38
+ castle: Castle,
39
+ building_id: int,
40
+ cost_wood: int = 0,
41
+ cost_stone: int = 0,
42
+ cost_food: int = 0,
43
+ ) -> bool:
44
+ """Check if building can be upgraded."""
45
+ return CastleHelper.has_sufficient_resources(castle, cost_wood, cost_stone, cost_food)
46
+
47
+
48
+ class MovementHelper:
49
+ """Helper for movement operations."""
50
+
51
+ @staticmethod
52
+ def get_incoming_attacks(movements: Dict[int, Movement]) -> List[Movement]:
53
+ """Get all incoming attacks."""
54
+ return [m for m in movements.values() if m.is_incoming and m.is_attack]
55
+
56
+ @staticmethod
57
+ def get_outgoing_attacks(movements: Dict[int, Movement]) -> List[Movement]:
58
+ """Get all outgoing attacks."""
59
+ return [m for m in movements.values() if m.is_outgoing and m.is_attack]
60
+
61
+ @staticmethod
62
+ def get_returning_movements(movements: Dict[int, Movement]) -> List[Movement]:
63
+ """Get all returning movements."""
64
+ return [m for m in movements.values() if m.is_returning]
65
+
66
+ @staticmethod
67
+ def get_movements_to_area(movements: Dict[int, Movement], area_id: int) -> List[Movement]:
68
+ """Get all movements to specific area."""
69
+ return [m for m in movements.values() if m.target_area_id == area_id]
70
+
71
+ @staticmethod
72
+ def get_movements_from_area(movements: Dict[int, Movement], area_id: int) -> List[Movement]:
73
+ """Get all movements from specific area."""
74
+ return [m for m in movements.values() if m.source_area_id == area_id]
75
+
76
+ @staticmethod
77
+ def get_movements_by_type(movements: Dict[int, Movement], movement_type: MovementType) -> List[Movement]:
78
+ """Get all movements of a specific type."""
79
+ return [m for m in movements.values() if m.T == movement_type]
80
+
81
+ @staticmethod
82
+ def estimate_arrival_time(movement: Movement) -> float:
83
+ """
84
+ Estimate arrival timestamp (Unix time).
85
+
86
+ Returns:
87
+ Unix timestamp of estimated arrival
88
+ """
89
+ return movement.last_updated + movement.time_remaining
90
+
91
+ @staticmethod
92
+ def get_soonest_arrival(movements: Dict[int, Movement]) -> Optional[Movement]:
93
+ """Get the movement arriving soonest."""
94
+ if not movements:
95
+ return None
96
+ return min(movements.values(), key=lambda m: m.time_remaining)
97
+
98
+ @staticmethod
99
+ def get_soonest_incoming_attack(
100
+ movements: Dict[int, Movement],
101
+ ) -> Optional[Movement]:
102
+ """Get the soonest incoming attack."""
103
+ attacks = MovementHelper.get_incoming_attacks(movements)
104
+ if not attacks:
105
+ return None
106
+ return min(attacks, key=lambda m: m.time_remaining)
107
+
108
+ @staticmethod
109
+ def sort_by_arrival(movements: List[Movement], ascending: bool = True) -> List[Movement]:
110
+ """Sort movements by arrival time."""
111
+ return sorted(movements, key=lambda m: m.time_remaining, reverse=not ascending)
112
+
113
+ @staticmethod
114
+ def get_movements_arriving_within(movements: Dict[int, Movement], seconds: int) -> List[Movement]:
115
+ """Get all movements arriving within specified seconds."""
116
+ return [m for m in movements.values() if m.time_remaining <= seconds]
117
+
118
+ @staticmethod
119
+ def get_total_units_in_movements(movements: List[Movement]) -> Dict[int, int]:
120
+ """Get total units across all movements."""
121
+ totals: Dict[int, int] = {}
122
+ for m in movements:
123
+ for unit_id, count in m.units.items():
124
+ totals[unit_id] = totals.get(unit_id, 0) + count
125
+ return totals
126
+
127
+ @staticmethod
128
+ def get_total_resources_in_movements(movements: List[Movement]) -> Dict[str, int]:
129
+ """Get total resources across all movements (transports/returns)."""
130
+ totals = {"wood": 0, "stone": 0, "food": 0}
131
+ for m in movements:
132
+ totals["wood"] += m.resources.wood
133
+ totals["stone"] += m.resources.stone
134
+ totals["food"] += m.resources.food
135
+ return totals
136
+
137
+ @staticmethod
138
+ def format_movement(movement: Movement) -> str:
139
+ """Format a movement for display."""
140
+ direction = "→" if movement.is_outgoing else "←" if movement.is_incoming else "↺"
141
+ if movement.is_returning:
142
+ direction = "↩"
143
+
144
+ return (
145
+ f"[{movement.MID}] {movement.movement_type_name} {direction} "
146
+ f"{movement.source_area_id} → {movement.target_area_id} "
147
+ f"({movement.format_time_remaining()}, {movement.unit_count} units)"
148
+ )
149
+
150
+ @staticmethod
151
+ def format_movements_table(movements: List[Movement]) -> str:
152
+ """Format a list of movements as a table."""
153
+ if not movements:
154
+ return "No movements."
155
+
156
+ lines = [f"{'ID':<10} {'Type':<12} {'From':<10} {'To':<10} {'Units':<8} {'Time':<12}"]
157
+ lines.append("-" * 65)
158
+
159
+ for m in sorted(movements, key=lambda x: x.time_remaining):
160
+ lines.append(
161
+ f"{m.MID:<10} {m.movement_type_name:<12} "
162
+ f"{m.source_area_id:<10} {m.target_area_id:<10} "
163
+ f"{m.unit_count:<8} {m.format_time_remaining():<12}"
164
+ )
165
+
166
+ return "\n".join(lines)
167
+
168
+ @staticmethod
169
+ def is_attack_imminent(movements: Dict[int, Movement], threshold_seconds: int = 60) -> bool:
170
+ """Check if any attack is arriving within threshold."""
171
+ attacks = MovementHelper.get_incoming_attacks(movements)
172
+ return any(a.time_remaining <= threshold_seconds for a in attacks)
173
+
174
+ @staticmethod
175
+ def count_movements_by_type(movements: Dict[int, Movement]) -> Dict[str, int]:
176
+ """Count movements grouped by type."""
177
+ counts: Dict[str, int] = {}
178
+ for m in movements.values():
179
+ type_name = m.movement_type_name
180
+ counts[type_name] = counts.get(type_name, 0) + 1
181
+ return counts
182
+
183
+
184
+ class ResourceHelper:
185
+ """Helper for resource management."""
186
+
187
+ @staticmethod
188
+ def calculate_production_until_full(castle: Castle) -> Dict[str, float]:
189
+ """Calculate hours until resources are full."""
190
+ result = {}
191
+
192
+ if castle.resources.wood_rate > 0:
193
+ space = castle.resources.wood_cap - castle.resources.wood
194
+ if space > 0:
195
+ result["wood"] = space / castle.resources.wood_rate
196
+
197
+ if castle.resources.stone_rate > 0:
198
+ space = castle.resources.stone_cap - castle.resources.stone
199
+ if space > 0:
200
+ result["stone"] = space / castle.resources.stone_rate
201
+
202
+ if castle.resources.food_rate > 0:
203
+ space = castle.resources.food_cap - castle.resources.food
204
+ if space > 0:
205
+ result["food"] = space / castle.resources.food_rate
206
+
207
+ return result
208
+
209
+ @staticmethod
210
+ def get_optimal_transport_amount(source: Castle, target_capacity: int, resource_type: str = "wood") -> int:
211
+ """Calculate optimal amount to transport."""
212
+ if resource_type == "wood":
213
+ available = source.resources.wood
214
+ safe = source.resources.wood_safe
215
+ elif resource_type == "stone":
216
+ available = source.resources.stone
217
+ safe = source.resources.stone_safe
218
+ elif resource_type == "food":
219
+ available = source.resources.food
220
+ safe = source.resources.food_safe
221
+ else:
222
+ return 0
223
+
224
+ # Transport excess over safe storage, up to capacity
225
+ excess = max(0, available - safe)
226
+ return min(int(excess), target_capacity)
227
+
228
+
229
+ class PlayerHelper:
230
+ """Helper for player operations."""
231
+
232
+ @staticmethod
233
+ def get_total_resources(player: Player) -> Dict[str, int]:
234
+ """Get total resources across all castles."""
235
+ totals = {"wood": 0, "stone": 0, "food": 0}
236
+
237
+ for castle in player.castles.values():
238
+ totals["wood"] += castle.resources.wood
239
+ totals["stone"] += castle.resources.stone
240
+ totals["food"] += castle.resources.food
241
+
242
+ return totals
243
+
244
+ @staticmethod
245
+ def get_total_population(player: Player) -> int:
246
+ """Get total population across all castles."""
247
+ return sum(c.population for c in player.castles.values())
248
+
249
+ @staticmethod
250
+ def get_total_buildings(player: Player) -> int:
251
+ """Get total buildings across all castles."""
252
+ return sum(len(c.buildings) for c in player.castles.values())
@@ -0,0 +1,153 @@
1
+ """
2
+ Response awaiter for waiting on server responses to commands.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import Any, Dict
9
+
10
+ from empire_core.exceptions import TimeoutError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ResponseAwaiter:
16
+ """
17
+ Manages waiting for server responses to commands.
18
+
19
+ Usage:
20
+ awaiter = ResponseAwaiter()
21
+
22
+ # Start waiting for a response
23
+ response_future = awaiter.create_waiter('att')
24
+
25
+ # Send command
26
+ await connection.send(packet)
27
+
28
+ # Wait for response
29
+ response = await awaiter.wait_for('att', timeout=5.0)
30
+ """
31
+
32
+ def __init__(self):
33
+ self.pending: Dict[str, asyncio.Future] = {}
34
+ self.sequence = 0
35
+
36
+ def create_waiter(self, command_id: str) -> str:
37
+ """
38
+ Create a waiter for a specific command response.
39
+
40
+ Args:
41
+ command_id: Command to wait for (e.g., 'att', 'tra', 'bui')
42
+
43
+ Returns:
44
+ str: Unique waiter ID
45
+ """
46
+ self.sequence += 1
47
+ waiter_id = f"{command_id}_{self.sequence}_{time.time()}"
48
+
49
+ future: asyncio.Future = asyncio.Future()
50
+ self.pending[waiter_id] = future
51
+
52
+ logger.debug(f"Created waiter: {waiter_id}")
53
+ return waiter_id
54
+
55
+ def set_response(self, command_id: str, response: Any) -> bool:
56
+ """
57
+ Set response for waiting command.
58
+
59
+ Args:
60
+ command_id: Command ID that responded
61
+ response: Response data
62
+
63
+ Returns:
64
+ bool: True if waiter was found and notified
65
+ """
66
+ # Find matching waiter (most recent for this command)
67
+ matching_waiters = [wid for wid in self.pending.keys() if wid.startswith(f"{command_id}_")]
68
+
69
+ if not matching_waiters:
70
+ logger.debug(f"No waiter found for response: {command_id}")
71
+ return False
72
+
73
+ # Get most recent waiter
74
+ waiter_id = matching_waiters[-1]
75
+ future = self.pending.pop(waiter_id)
76
+
77
+ if not future.done():
78
+ future.set_result(response)
79
+ logger.debug(f"Set response for waiter: {waiter_id}")
80
+ return True
81
+
82
+ return False
83
+
84
+ async def wait_for(self, command_id: str, timeout: float = 5.0) -> Any:
85
+ """
86
+ Wait for response to a specific command.
87
+
88
+ Args:
89
+ command_id: Command to wait for
90
+ timeout: Max seconds to wait
91
+
92
+ Returns:
93
+ Response data
94
+
95
+ Raises:
96
+ TimeoutError: If timeout exceeded
97
+ """
98
+ # Find waiter
99
+ matching_waiters = [wid for wid in self.pending.keys() if wid.startswith(f"{command_id}_")]
100
+
101
+ if not matching_waiters:
102
+ raise ValueError(f"No waiter created for command: {command_id}")
103
+
104
+ waiter_id = matching_waiters[-1]
105
+ future = self.pending[waiter_id]
106
+
107
+ try:
108
+ response = await asyncio.wait_for(future, timeout)
109
+ logger.debug(f"Received response for: {waiter_id}")
110
+ return response
111
+ except asyncio.TimeoutError:
112
+ # Clean up
113
+ self.pending.pop(waiter_id, None)
114
+ logger.warning(f"Timeout waiting for response: {command_id}")
115
+ raise TimeoutError(f"No response received for command: {command_id} (timeout: {timeout}s)")
116
+
117
+ def cancel_all(self):
118
+ """Cancel all pending waiters."""
119
+ for waiter_id, future in self.pending.items():
120
+ if not future.done():
121
+ future.cancel()
122
+ logger.debug(f"Cancelled waiter: {waiter_id}")
123
+
124
+ self.pending.clear()
125
+
126
+ def cancel_command(self, command_id: str) -> int:
127
+ """
128
+ Cancel all waiters for a specific command.
129
+
130
+ Args:
131
+ command_id: Command to cancel waiters for
132
+
133
+ Returns:
134
+ int: Number of waiters cancelled
135
+ """
136
+ matching = [wid for wid in self.pending.keys() if wid.startswith(f"{command_id}_")]
137
+
138
+ count = 0
139
+ for waiter_id in matching:
140
+ future = self.pending.pop(waiter_id)
141
+ if not future.done():
142
+ future.cancel()
143
+ count += 1
144
+
145
+ if count > 0:
146
+ logger.debug(f"Cancelled {count} waiters for: {command_id}")
147
+
148
+ return count
149
+
150
+ @property
151
+ def pending_count(self) -> int:
152
+ """Get number of pending waiters."""
153
+ return len(self.pending)
@@ -0,0 +1,93 @@
1
+ """
2
+ Troop metadata fetcher - gets valid troop IDs from GGE CDN.
3
+
4
+ Units with slotTypes are equipment, not troops.
5
+ This filters to get only actual combat units.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional, Set
10
+
11
+ import requests
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Cached troop IDs
16
+ _troop_ids: Optional[Set[int]] = None
17
+
18
+
19
+ def get_items_version() -> str:
20
+ """Fetch the current items version from GGE CDN."""
21
+ url = "https://empire-html5.goodgamestudios.com/default/items/ItemsVersion.properties"
22
+ response = requests.get(url, timeout=10)
23
+ response.raise_for_status()
24
+ return response.text.split("=")[-1].strip()
25
+
26
+
27
+ def fetch_items_data(version: str) -> dict:
28
+ """Fetch items data for a specific version."""
29
+ url = f"https://empire-html5.goodgamestudios.com/default/items/items_v{version}.json"
30
+ response = requests.get(url, timeout=30)
31
+ response.raise_for_status()
32
+ return response.json()
33
+
34
+
35
+ def get_troop_ids(force_refresh: bool = False) -> Set[int]:
36
+ """
37
+ Get the set of valid troop unit IDs.
38
+
39
+ Troops are units without slotTypes (equipment has slotTypes).
40
+
41
+ Args:
42
+ force_refresh: Force re-fetch from CDN
43
+
44
+ Returns:
45
+ Set of wodID values for valid troops
46
+ """
47
+ global _troop_ids
48
+
49
+ if _troop_ids is not None and not force_refresh:
50
+ return _troop_ids
51
+
52
+ try:
53
+ version = get_items_version()
54
+ items_data = fetch_items_data(version)
55
+
56
+ units = items_data.get("units", [])
57
+ # Filter units without slotTypes (those are actual troops)
58
+ troop_ids = set()
59
+ for unit in units:
60
+ if not unit.get("slotTypes"):
61
+ wod_id = unit.get("wodID")
62
+ if wod_id:
63
+ troop_ids.add(wod_id)
64
+
65
+ _troop_ids = troop_ids
66
+ logger.info(f"Loaded {len(troop_ids)} troop IDs from GGE CDN (v{version})")
67
+ return troop_ids
68
+
69
+ except Exception as e:
70
+ logger.error(f"Failed to fetch troop metadata: {e}")
71
+ # Return empty set on failure - will count all units
72
+ return set()
73
+
74
+
75
+ def count_troops(units: dict[int, int], troop_ids: Optional[Set[int]] = None) -> int:
76
+ """
77
+ Count only actual troops in a unit dict, excluding equipment.
78
+
79
+ Args:
80
+ units: Dict of {unit_id: count}
81
+ troop_ids: Optional set of valid troop IDs (fetched if not provided)
82
+
83
+ Returns:
84
+ Total count of actual troops
85
+ """
86
+ if troop_ids is None:
87
+ troop_ids = get_troop_ids()
88
+
89
+ # If we couldn't get troop IDs, count everything
90
+ if not troop_ids:
91
+ return sum(units.values())
92
+
93
+ return sum(count for uid, count in units.items() if uid in troop_ids)
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: empire-core
3
+ Version: 0.7.3
4
+ Summary: Fully typed Python API for Goodgame Empire
5
+ Project-URL: Repository, https://github.com/eschnitzler/EmpireCore
6
+ Author-email: E Joseph <east1499@gmail.com>
7
+ License: MIT
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: aiohttp>=3.9
10
+ Requires-Dist: aiosqlite>=0.19.0
11
+ Requires-Dist: pydantic>=2.5
12
+ Requires-Dist: python-dotenv>=1.0
13
+ Requires-Dist: sqlmodel>=0.0.14
14
+ Requires-Dist: tabulate>=0.9.0
15
+ Requires-Dist: tqdm>=4.66.0
16
+ Requires-Dist: typer>=0.9.0
17
+ Requires-Dist: websocket-client>=1.9.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: mypy>=1.8; extra == 'dev'
20
+ Requires-Dist: pre-commit>=3.5.0; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
22
+ Requires-Dist: pytest>=7.0; extra == 'dev'
23
+ Requires-Dist: python-semantic-release>=9.0.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
25
+ Requires-Dist: sqlalchemy[mypy]; extra == 'dev'
26
+ Requires-Dist: types-requests; extra == 'dev'
27
+ Requires-Dist: types-tabulate; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ <p align="center">
31
+ <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="Python 3.10+">
32
+ <img src="https://img.shields.io/badge/pydantic-v2-purple.svg" alt="Pydantic v2">
33
+ <img src="https://img.shields.io/badge/tool-uv-orange.svg" alt="UV">
34
+ <img src="https://img.shields.io/badge/status-WIP-red.svg" alt="Work in Progress">
35
+ </p>
36
+
37
+ <h1 align="center">EmpireCore</h1>
38
+
39
+ <p align="center">
40
+ <strong>Fully typed Python library for Goodgame Empire</strong>
41
+ </p>
42
+
43
+ <p align="center">
44
+ <a href="#features">Features</a> •
45
+ <a href="#installation">Installation</a> •
46
+ <a href="#quick-start">Quick Start</a> •
47
+ <a href="#services">Services</a> •
48
+ <a href="#contributing">Contributing</a>
49
+ </p>
50
+
51
+ ---
52
+
53
+ > **Warning: Work in Progress**
54
+ >
55
+ > This library is under active development. APIs may change, and some features are incomplete or untested.
56
+
57
+ ---
58
+
59
+ ## Features
60
+
61
+ | Category | Description |
62
+ |----------|-------------|
63
+ | **Connection** | WebSocket with background threads, auto-reconnect, keepalive |
64
+ | **Protocol Models** | Pydantic models for all GGE commands with type-safe request/response handling |
65
+ | **Services** | High-level APIs for alliance, castle, and more - auto-attached to client |
66
+ | **State Tracking** | Player, castles, resources, movements |
67
+
68
+ ## Installation
69
+
70
+ ```bash
71
+ # Using uv (recommended)
72
+ uv add empire-core
73
+
74
+ # Or with pip
75
+ pip install empire-core
76
+ ```
77
+
78
+ For development:
79
+
80
+ ```bash
81
+ git clone https://github.com/eschnitzler/EmpireCore.git
82
+ cd EmpireCore
83
+ uv sync
84
+ ```
85
+
86
+ ## Quick Start
87
+
88
+ ```python
89
+ from empire_core import EmpireClient
90
+
91
+ client = EmpireClient(username="your_user", password="your_pass")
92
+ client.login()
93
+
94
+ # Services are auto-attached to the client
95
+ client.alliance.send_chat("Hello alliance!")
96
+ client.alliance.help_all()
97
+
98
+ castles = client.castle.get_all()
99
+ for c in castles:
100
+ print(f"{c.castle_name} at ({c.x}, {c.y})")
101
+
102
+ client.close()
103
+ ```
104
+
105
+ ## Services
106
+
107
+ Services provide high-level APIs and are automatically attached to the client.
108
+
109
+ ### AllianceService (`client.alliance`)
110
+
111
+ ```python
112
+ # Send chat message
113
+ client.alliance.send_chat("Hello!")
114
+
115
+ # Get chat history
116
+ history = client.alliance.get_chat_log()
117
+ for entry in history:
118
+ print(f"{entry.player_name}: {entry.decoded_text}")
119
+
120
+ # Help all members
121
+ response = client.alliance.help_all()
122
+ print(f"Helped {response.helped_count} members")
123
+
124
+ # Subscribe to incoming messages
125
+ def on_message(msg):
126
+ print(f"[{msg.player_name}] {msg.decoded_text}")
127
+
128
+ client.alliance.on_chat_message(on_message)
129
+ ```
130
+
131
+ ### CastleService (`client.castle`)
132
+
133
+ ```python
134
+ # Get all castles
135
+ castles = client.castle.get_all()
136
+
137
+ # Get detailed info
138
+ details = client.castle.get_details(castle_id=12345)
139
+ print(f"Buildings: {len(details.buildings)}")
140
+
141
+ # Select a castle
142
+ client.castle.select(castle_id=12345)
143
+
144
+ # Get resources
145
+ resources = client.castle.get_resources(castle_id=12345)
146
+ print(f"Wood: {resources.wood}, Stone: {resources.stone}")
147
+ ```
148
+
149
+ ## Protocol Models
150
+
151
+ For lower-level access, use protocol models directly:
152
+
153
+ ```python
154
+ from empire_core.protocol.models import (
155
+ AllianceChatMessageRequest,
156
+ GetCastlesRequest,
157
+ parse_response,
158
+ )
159
+
160
+ # Build a request
161
+ request = AllianceChatMessageRequest.create("Hello 100%!")
162
+ packet = request.to_packet()
163
+ # -> "%xt%EmpireEx_21%acm%1%{"M": "Hello 100&percnt;!"}%"
164
+
165
+ # Send via client
166
+ client.send(request)
167
+
168
+ # Or wait for response
169
+ response = client.send(GetCastlesRequest(), wait=True)
170
+ ```
171
+
172
+ ## Contributing
173
+
174
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for:
175
+
176
+ - Adding new protocol commands
177
+ - Creating new services
178
+ - Protocol model conventions
179
+ - Testing guidelines
180
+
181
+ ## Architecture
182
+
183
+ ```
184
+ empire_core/
185
+ ├── client/ # EmpireClient - main entry point
186
+ ├── protocol/
187
+ │ └── models/ # Pydantic models for GGE commands
188
+ ├── services/ # High-level service APIs
189
+ ├── state/ # Game state models
190
+ └── network/ # WebSocket connection
191
+ ```
192
+
193
+ ---
194
+
195
+ <p align="center">
196
+ <sub>For educational purposes only. Use responsibly.</sub>
197
+ </p>