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,68 @@
1
+ """
2
+ EmpireCore CLI
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+
8
+ import typer
9
+ from empire_core import accounts
10
+
11
+ # Configure logging for CLI
12
+ logging.basicConfig(level=logging.WARNING) # cleaner output
13
+
14
+ app = typer.Typer(help="EmpireCore Command Line Interface")
15
+
16
+
17
+ @app.command()
18
+ def status():
19
+ """Show configured accounts."""
20
+ all_accounts = accounts.get_all()
21
+ if not all_accounts:
22
+ typer.echo("No accounts found in accounts.json or environment.")
23
+ return
24
+
25
+ typer.echo(f"Found {len(all_accounts)} accounts:")
26
+ for acc in all_accounts:
27
+ active = "✅" if acc.active else "❌"
28
+ typer.echo(f"{active} {acc.username} ({acc.world}) [Tags: {', '.join(acc.tags)}]")
29
+
30
+
31
+ @app.command()
32
+ def login(username: str = typer.Option(None, help="Username to login with. Defaults to first.")):
33
+ """Test login for an account."""
34
+
35
+ async def _run():
36
+ if username:
37
+ acc = accounts.get_by_username(username)
38
+ else:
39
+ acc = accounts.get_default()
40
+
41
+ if not acc:
42
+ typer.echo("Account not found.")
43
+ return
44
+
45
+ typer.echo(f"Logging in as {acc.username}...")
46
+ client = acc.get_client()
47
+
48
+ try:
49
+ await client.login()
50
+ typer.echo("✅ Login successful!")
51
+
52
+ # Show quick stats
53
+ player = client.state.local_player
54
+ if player:
55
+ typer.echo(f" Level: {player.level}")
56
+ typer.echo(f" Gold: {player.gold:,}")
57
+ typer.echo(f" Castles: {len(player.castles)}")
58
+
59
+ except Exception as e:
60
+ typer.echo(f"❌ Login failed: {e}")
61
+ finally:
62
+ await client.close()
63
+
64
+ asyncio.run(_run())
65
+
66
+
67
+ if __name__ == "__main__":
68
+ app()
@@ -0,0 +1,469 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import time
5
+ from typing import TYPE_CHECKING, Awaitable, Callable, Dict, List, Optional, Union
6
+
7
+ from empire_core.config import (
8
+ LOGIN_DEFAULTS,
9
+ MAP_CHUNK_SIZE,
10
+ EmpireConfig,
11
+ ServerError,
12
+ default_config,
13
+ )
14
+ from empire_core.network.connection import SFSConnection
15
+
16
+ if TYPE_CHECKING:
17
+ from empire_core.accounts import Account
18
+
19
+ from empire_core.automation.alliance_tools import AllianceService, ChatService
20
+ from empire_core.automation.battle_reports import BattleReportService
21
+ from empire_core.automation.building_queue import BuildingManager
22
+ from empire_core.automation.defense_manager import DefenseManager
23
+ from empire_core.automation.map_scanner import MapScanner
24
+ from empire_core.automation.quest_automation import QuestService
25
+ from empire_core.automation.resource_manager import ResourceManager
26
+ from empire_core.automation.unit_production import UnitManager
27
+ from empire_core.client.actions import GameActionsMixin
28
+ from empire_core.client.commands import GameCommandsMixin
29
+ from empire_core.client.defense import DefenseService
30
+ from empire_core.events.base import PacketEvent
31
+ from empire_core.events.manager import EventManager
32
+ from empire_core.exceptions import LoginError, TimeoutError
33
+ from empire_core.protocol.packet import Packet
34
+ from empire_core.state.manager import GameState
35
+ from empire_core.state.world_models import Movement
36
+ from empire_core.storage.database import GameDatabase
37
+ from empire_core.utils.decorators import handle_errors
38
+ from empire_core.utils.response_awaiter import ResponseAwaiter
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class EmpireClient(
44
+ GameActionsMixin,
45
+ GameCommandsMixin,
46
+ ):
47
+ def __init__(self, config: Union[EmpireConfig, "Account", None] = None):
48
+ if config is None:
49
+ self.config = default_config
50
+ elif hasattr(config, "to_empire_config"):
51
+ # Handle Account object
52
+ self.config = config.to_empire_config()
53
+ else:
54
+ # Handle EmpireConfig object
55
+ self.config = config
56
+
57
+ self.connection = SFSConnection(self.config.game_url)
58
+ self.username: Optional[str] = self.config.username
59
+ self.is_logged_in = False
60
+
61
+ self.events = EventManager()
62
+ self.state = GameState()
63
+ self.db = GameDatabase()
64
+
65
+ # Services (Composition)
66
+ self.scanner = MapScanner(self)
67
+ self.resources = ResourceManager(self)
68
+ self.buildings = BuildingManager(self)
69
+ self.units = UnitManager(self)
70
+ self.quests = QuestService(self)
71
+ self.defense_manager = DefenseManager(self)
72
+
73
+ # Core Services
74
+ self.defense = DefenseService(self)
75
+ self.reports = BattleReportService(self)
76
+ self.alliance = AllianceService(self)
77
+ self.chat = ChatService(self)
78
+
79
+ self.response_awaiter = ResponseAwaiter()
80
+ self.connection.packet_handler = self._on_packet
81
+ self.connection.on_close = self._handle_disconnect
82
+
83
+ self._reconnect_task: Optional[asyncio.Task] = None
84
+ self._auto_reconnect = True
85
+ self._max_reconnect_attempts = 10
86
+ self._reconnect_delay = 5.0 # Initial delay
87
+ self._is_reconnecting = False
88
+
89
+ async def _handle_disconnect(self):
90
+ """Called when the underlying connection is lost."""
91
+ self.is_logged_in = False
92
+ logger.warning("Client: Connection lost. Resetting logged_in state.")
93
+
94
+ if self._auto_reconnect and not self._reconnect_task:
95
+ self._is_reconnecting = True
96
+ self._reconnect_task = asyncio.create_task(self._reconnect_loop())
97
+
98
+ async def _reconnect_loop(self):
99
+ """Internal loop to handle reconnection attempts."""
100
+ attempt = 0
101
+ delay = self._reconnect_delay
102
+
103
+ try:
104
+ while attempt < self._max_reconnect_attempts:
105
+ if self.is_logged_in:
106
+ break
107
+
108
+ attempt += 1
109
+ logger.info(f"Client: Reconnection attempt {attempt}/{self._max_reconnect_attempts} in {delay}s...")
110
+ await asyncio.sleep(delay)
111
+
112
+ try:
113
+ # Try to re-login (login() handles connect() + handshake)
114
+ await self.login()
115
+ logger.info("Client: Reconnection successful!")
116
+ return
117
+ except Exception as e:
118
+ logger.error(f"Client: Reconnection attempt {attempt} failed: {e}")
119
+ # Exponential backoff
120
+ delay = min(delay * 2, 60.0)
121
+
122
+ logger.critical("Client: Maximum reconnection attempts reached. Giving up.")
123
+ finally:
124
+ self._reconnect_task = None
125
+ self._is_reconnecting = False
126
+
127
+ @property
128
+ def event(self):
129
+ """Decorator for registering event handlers."""
130
+ return self.events.listen
131
+
132
+ @handle_errors(log_msg="Error processing packet", re_raise=False)
133
+ async def _on_packet(self, packet: Packet):
134
+ """Global packet handler called by connection."""
135
+ logger.debug(f"Client received packet: {packet.command_id}")
136
+
137
+ if packet.command_id == "gaa":
138
+ logger.debug(f"GAA Payload: {packet.payload}")
139
+
140
+ # Update State
141
+ if packet.command_id and isinstance(packet.payload, dict):
142
+ await self.state.update_from_packet(packet.command_id, packet.payload)
143
+
144
+ # Notify response awaiter - Pass FULL packet so error_code is available
145
+ if packet.command_id:
146
+ self.response_awaiter.set_response(packet.command_id, packet)
147
+
148
+ pkt_event = PacketEvent(
149
+ command_id=packet.command_id or "unknown",
150
+ payload=packet.payload,
151
+ is_xml=packet.is_xml,
152
+ )
153
+ await self.events.emit(pkt_event)
154
+
155
+ @handle_errors(log_msg="Login failed")
156
+ async def login(self, username: Optional[str] = None, password: Optional[str] = None):
157
+ """
158
+ Performs the full login sequence:
159
+ Connect -> Version Check -> XML Login (Zone) -> AutoJoin -> XT Login (Auth)
160
+ """
161
+ username = username or self.config.username
162
+ password = password or self.config.password
163
+
164
+ if not username or not password:
165
+ raise ValueError("Username and password must be provided")
166
+
167
+ self.username = username
168
+
169
+ # 0. Initialize Database & Services
170
+ await self.db.initialize()
171
+ await self.scanner.initialize()
172
+
173
+ if not self.connection.connected:
174
+ await self.connection.connect()
175
+
176
+ # 1. Version Check
177
+ logger.info("Handshake: Sending Version Check...")
178
+ ver_waiter = self.connection.create_waiter("apiOK", predicate=lambda p: p.is_xml and p.command_id == "apiOK")
179
+
180
+ ver_packet = f"<msg t='sys'><body action='verChk' r='0'><ver v='{self.config.game_version}' /></body></msg>"
181
+ await self.connection.send(ver_packet)
182
+
183
+ try:
184
+ await asyncio.wait_for(ver_waiter, timeout=self.config.request_timeout)
185
+ logger.info("Handshake: Version OK.")
186
+ except asyncio.TimeoutError:
187
+ raise TimeoutError("Handshake: Version Check timed out.")
188
+
189
+ # 2. XML Login (Zone Entry)
190
+ logger.info(f"Handshake: Entering Zone {self.config.default_zone}...")
191
+
192
+ login_packet = (
193
+ f"<msg t='sys'><body action='login' r='0'>"
194
+ f"<login z='{self.config.default_zone}'>"
195
+ f"<nick><![CDATA[]]></nick>"
196
+ f"<pword><![CDATA[undefined%en%0]]></pword>"
197
+ f"</login></body></msg>"
198
+ )
199
+
200
+ # Wait for 'rlu' (Room List Update)
201
+ rlu_waiter = self.connection.create_waiter("rlu")
202
+
203
+ await self.connection.send(login_packet)
204
+
205
+ try:
206
+ await asyncio.wait_for(rlu_waiter, timeout=self.config.login_timeout)
207
+ logger.info("Handshake: Received Room List (Zone Entered).")
208
+ except asyncio.TimeoutError:
209
+ raise TimeoutError("Handshake: Zone Login (rlu) timed out.")
210
+
211
+ # 3. AutoJoin (Room Join)
212
+ logger.info("Handshake: Joining Room (AutoJoin)...")
213
+ join_packet = "<msg t='sys'><body action='autoJoin' r='-1'></body></msg>"
214
+
215
+ join_ok_waiter = self.connection.create_waiter(
216
+ "joinOK", predicate=lambda p: p.is_xml and p.command_id == "joinOK"
217
+ )
218
+
219
+ await self.connection.send(join_packet)
220
+
221
+ try:
222
+ await asyncio.wait_for(join_ok_waiter, timeout=self.config.request_timeout)
223
+ logger.info("Handshake: Room Joined (joinOK received).")
224
+ except asyncio.TimeoutError:
225
+ logger.warning("Handshake: joinOK timed out, but proceeding...")
226
+
227
+ # 4. XT Login (Real Auth)
228
+ logger.info(f"Handshake: Authenticating as {username}...")
229
+
230
+ xt_login_payload = {
231
+ **LOGIN_DEFAULTS,
232
+ "NOM": username,
233
+ "PW": password,
234
+ }
235
+
236
+ xt_packet = f"%xt%{self.config.default_zone}%lli%1%{json.dumps(xt_login_payload)}%"
237
+
238
+ # Wait for lli response
239
+ lli_waiter = self.connection.create_waiter("lli")
240
+ await self.connection.send(xt_packet)
241
+
242
+ try:
243
+ lli_packet = await asyncio.wait_for(lli_waiter, timeout=self.config.login_timeout)
244
+
245
+ # Check status
246
+ if lli_packet.error_code != 0:
247
+ # Check for cooldown
248
+ if lli_packet.error_code == ServerError.LOGIN_COOLDOWN:
249
+ cooldown = 0
250
+ if isinstance(lli_packet.payload, dict):
251
+ cooldown = int(lli_packet.payload.get("CD", 0))
252
+
253
+ logger.warning(f"Handshake: Login Slowdown active. Wait {cooldown}s.")
254
+ from empire_core.exceptions import LoginCooldownError
255
+
256
+ raise LoginCooldownError(cooldown)
257
+
258
+ logger.error(f"Handshake: Auth Failed with status {lli_packet.error_code}")
259
+ raise LoginError(f"Auth Failed with status {lli_packet.error_code}")
260
+
261
+ logger.info("Handshake: Authenticated.")
262
+ except asyncio.TimeoutError:
263
+ raise TimeoutError("XT Login timed out.")
264
+
265
+ self.is_logged_in = True
266
+ logger.info("Handshake: Ready.")
267
+
268
+ @handle_errors(log_msg="Error getting map chunk")
269
+ async def get_map_chunk(self, kingdom: int, x: int, y: int):
270
+ """
271
+ Requests a chunk of the map.
272
+
273
+ Args:
274
+ kingdom: Kingdom ID (0=Green, 2=Ice, 1=Sands, 3=Fire)
275
+ x: Top-Left X
276
+ y: Top-Left Y
277
+ """
278
+ # Command: gaa (Get Area)
279
+ payload = {
280
+ "KID": kingdom,
281
+ "AX1": x,
282
+ "AY1": y,
283
+ "AX2": x + MAP_CHUNK_SIZE,
284
+ "AY2": y + MAP_CHUNK_SIZE,
285
+ }
286
+
287
+ packet = Packet.build_xt(self.config.default_zone, "gaa", payload)
288
+ await self.connection.send(packet)
289
+
290
+ @handle_errors(log_msg="Error getting movements")
291
+ async def get_movements(self):
292
+ """
293
+ Requests list of army movements.
294
+ """
295
+ packet = Packet.build_xt(self.config.default_zone, "gam", {})
296
+ await self.connection.send(packet)
297
+
298
+ @handle_errors(log_msg="Error getting detailed castle info")
299
+ async def get_detailed_castle_info(self):
300
+ """
301
+ Requests detailed information for all own castles (Resources, Units, etc.).
302
+ """
303
+ packet = Packet.build_xt(self.config.default_zone, "dcl", {})
304
+ await self.connection.send(packet)
305
+
306
+ @handle_errors(log_msg="Error closing client", re_raise=False)
307
+ async def close(self):
308
+ self._auto_reconnect = False
309
+ if self._reconnect_task:
310
+ self._reconnect_task.cancel()
311
+ await self.connection.disconnect()
312
+ await self.db.close()
313
+
314
+ async def wait_until_ready(self, timeout: float = 60.0):
315
+ """Wait until the client is logged in and ready."""
316
+ if self.is_logged_in:
317
+ return
318
+
319
+ start = time.time()
320
+ while not self.is_logged_in:
321
+ if (time.time() - start) > timeout:
322
+ raise TimeoutError("Timed out waiting for client to be ready")
323
+
324
+ if not self._is_reconnecting and not self.connection.connected:
325
+ # If we are not logged in, not reconnecting, and not connected,
326
+ # then we've either given up or auto-reconnect is off.
327
+ raise RuntimeError("Client not connected and not attempting reconnection.")
328
+
329
+ await asyncio.sleep(0.5)
330
+
331
+ # ============================================================
332
+ # Movement Tracking Methods
333
+ # ============================================================
334
+
335
+ @property
336
+ def movements(self) -> Dict[int, Movement]:
337
+ """Get all currently tracked movements."""
338
+ return self.state.movements
339
+
340
+ def get_all_movements(self) -> List[Movement]:
341
+ """Get all tracked movements as a list."""
342
+ return self.state.get_all_movements()
343
+
344
+ def get_incoming_movements(self) -> List[Movement]:
345
+ """Get all incoming movements (attacks, supports, transports to us)."""
346
+ return self.state.get_incoming_movements()
347
+
348
+ def get_outgoing_movements(self) -> List[Movement]:
349
+ """Get all outgoing movements (our attacks, transports, etc.)."""
350
+ return self.state.get_outgoing_movements()
351
+
352
+ def get_returning_movements(self) -> List[Movement]:
353
+ """Get all returning movements (armies coming back)."""
354
+ return self.state.get_returning_movements()
355
+
356
+ def get_incoming_attacks(self) -> List[Movement]:
357
+ """Get all incoming attack movements (high priority)."""
358
+ return self.state.get_incoming_attacks()
359
+
360
+ def get_movements_to_castle(self, castle_id: int) -> List[Movement]:
361
+ """Get all movements targeting a specific castle."""
362
+ return self.state.get_movements_to_castle(castle_id)
363
+
364
+ def get_movements_from_castle(self, castle_id: int) -> List[Movement]:
365
+ """Get all movements originating from a specific castle."""
366
+ return self.state.get_movements_from_castle(castle_id)
367
+
368
+ def get_next_arrival(self) -> Optional[Movement]:
369
+ """Get the movement that will arrive soonest."""
370
+ return self.state.get_next_arrival()
371
+
372
+ def get_movement(self, movement_id: int) -> Optional[Movement]:
373
+ """Get a specific movement by ID."""
374
+ return self.state.get_movement_by_id(movement_id)
375
+
376
+ @handle_errors(log_msg="Error refreshing movements")
377
+ async def refresh_movements(self, wait: bool = True, timeout: float = 5.0) -> List[Movement]:
378
+ """
379
+ Refresh movement data from server.
380
+
381
+ Args:
382
+ wait: Wait for response before returning
383
+ timeout: Timeout in seconds when waiting
384
+
385
+ Returns:
386
+ List of all current movements
387
+ """
388
+ if wait:
389
+ self.response_awaiter.create_waiter("gam")
390
+
391
+ await self.get_movements()
392
+
393
+ if wait:
394
+ try:
395
+ await self.response_awaiter.wait_for("gam", timeout=timeout)
396
+ except asyncio.TimeoutError:
397
+ logger.warning("Refresh movements timed out")
398
+
399
+ return self.get_all_movements()
400
+
401
+ async def watch_movements(
402
+ self,
403
+ interval: float = 5.0,
404
+ callback: Optional[Callable[[List[Movement]], Awaitable[None]]] = None,
405
+ stop_event: Optional[asyncio.Event] = None,
406
+ ):
407
+ """
408
+ Continuously poll for movement updates.
409
+
410
+ Args:
411
+ interval: Polling interval in seconds
412
+ callback: Optional async callback to call with movements list
413
+ stop_event: Optional event to signal stopping
414
+ """
415
+ stop = stop_event or asyncio.Event()
416
+
417
+ while not stop.is_set():
418
+ try:
419
+ movements = await self.refresh_movements(wait=True, timeout=interval)
420
+
421
+ if callback:
422
+ await callback(movements)
423
+
424
+ # Check for incoming attacks
425
+ attacks = self.get_incoming_attacks()
426
+ if attacks:
427
+ for attack in attacks:
428
+ logger.warning(
429
+ f"Incoming attack! ID: {attack.MID}, "
430
+ f"Target: {attack.target_area_id}, "
431
+ f"Time: {attack.format_time_remaining()}"
432
+ )
433
+
434
+ except Exception as e:
435
+ logger.error(f"Error in movement watch: {e}")
436
+
437
+ try:
438
+ await asyncio.wait_for(stop.wait(), timeout=interval)
439
+ break
440
+ except asyncio.TimeoutError:
441
+ continue
442
+
443
+ def format_movements_summary(self) -> str:
444
+ """Get a formatted summary of all movements."""
445
+ lines = []
446
+
447
+ incoming = self.get_incoming_movements()
448
+ outgoing = self.get_outgoing_movements()
449
+ returning = self.get_returning_movements()
450
+
451
+ if not incoming and not outgoing and not returning:
452
+ return "No active movements."
453
+
454
+ if incoming:
455
+ lines.append(f"Incoming ({len(incoming)}):")
456
+ for m in sorted(incoming, key=lambda x: x.time_remaining):
457
+ lines.append(f" - {m.movement_type_name} from {m.source_area_id}: {m.format_time_remaining()}")
458
+
459
+ if outgoing:
460
+ lines.append(f"Outgoing ({len(outgoing)}):")
461
+ for m in sorted(outgoing, key=lambda x: x.time_remaining):
462
+ lines.append(f" - {m.movement_type_name} to {m.target_area_id}: {m.format_time_remaining()}")
463
+
464
+ if returning:
465
+ lines.append(f"Returning ({len(returning)}):")
466
+ for m in sorted(returning, key=lambda x: x.time_remaining):
467
+ lines.append(f" - From {m.source_area_id}: {m.format_time_remaining()}")
468
+
469
+ return "\n".join(lines)