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,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%!"}%"
|
|
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>
|