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,115 @@
1
+ """
2
+ Models for battle reports and events.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+
11
+ class BattleParticipant(BaseModel):
12
+ """A participant in a battle."""
13
+
14
+ model_config = ConfigDict(extra="ignore")
15
+
16
+ player_id: int
17
+ player_name: str
18
+ alliance_id: Optional[int] = None
19
+ alliance_name: Optional[str] = None
20
+
21
+ # Units before battle
22
+ units_before: Dict[int, int] = Field(default_factory=dict)
23
+ # Units after battle
24
+ units_after: Dict[int, int] = Field(default_factory=dict)
25
+ # Losses
26
+ losses: Dict[int, int] = Field(default_factory=dict)
27
+
28
+
29
+ class BattleReport(BaseModel):
30
+ """Battle report."""
31
+
32
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
33
+
34
+ RID: int = Field(default=-1) # Report ID
35
+ T: int = Field(default=0) # Type
36
+ TS: int = Field(default=0) # Timestamp
37
+ READ: bool = Field(default=False) # Read status
38
+
39
+ # Battle details
40
+ attacker: Optional[BattleParticipant] = None
41
+ defender: Optional[BattleParticipant] = None
42
+
43
+ # Results
44
+ winner: Optional[str] = None # "attacker" or "defender"
45
+ loot: Dict[str, int] = Field(default_factory=dict) # Resources looted
46
+
47
+ # Location
48
+ target_x: int = 0
49
+ target_y: int = 0
50
+ target_name: Optional[str] = None
51
+
52
+ @property
53
+ def report_id(self) -> int:
54
+ return self.RID
55
+
56
+ @property
57
+ def report_type(self) -> int:
58
+ return self.T
59
+
60
+ @property
61
+ def timestamp(self) -> int:
62
+ return self.TS
63
+
64
+ @property
65
+ def is_read(self) -> bool:
66
+ return self.READ
67
+
68
+ @property
69
+ def datetime(self) -> datetime:
70
+ """Convert timestamp to datetime."""
71
+ return datetime.fromtimestamp(self.TS)
72
+
73
+
74
+ class EventReport(BaseModel):
75
+ """Generic event report (building complete, etc.)."""
76
+
77
+ model_config = ConfigDict(extra="ignore")
78
+
79
+ event_id: int
80
+ event_type: str
81
+ timestamp: int
82
+ message: str
83
+ data: Dict[str, Any] = Field(default_factory=dict)
84
+
85
+
86
+ class ReportManager:
87
+ """Manages reports and events."""
88
+
89
+ def __init__(self):
90
+ self.battle_reports: Dict[int, BattleReport] = {}
91
+ self.event_reports: Dict[int, EventReport] = {}
92
+ self.unread_count: int = 0
93
+
94
+ def add_battle_report(self, report: BattleReport):
95
+ """Add a battle report."""
96
+ self.battle_reports[report.report_id] = report
97
+ if not report.is_read:
98
+ self.unread_count += 1
99
+
100
+ def mark_as_read(self, report_id: int):
101
+ """Mark report as read."""
102
+ if report_id in self.battle_reports:
103
+ report = self.battle_reports[report_id]
104
+ if not report.is_read:
105
+ report.READ = True
106
+ self.unread_count = max(0, self.unread_count - 1)
107
+
108
+ def get_unread_reports(self) -> List[BattleReport]:
109
+ """Get all unread reports."""
110
+ return [r for r in self.battle_reports.values() if not r.is_read]
111
+
112
+ def get_recent_reports(self, count: int = 10) -> List[BattleReport]:
113
+ """Get most recent reports."""
114
+ sorted_reports = sorted(self.battle_reports.values(), key=lambda r: r.timestamp, reverse=True)
115
+ return sorted_reports[:count]
@@ -0,0 +1,75 @@
1
+ """
2
+ Models for units and armies.
3
+ """
4
+
5
+ from typing import Dict
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class UnitStats(BaseModel):
11
+ """Unit statistics."""
12
+
13
+ model_config = ConfigDict(extra="ignore")
14
+
15
+ unit_id: int
16
+ attack: int = 0
17
+ defense: int = 0
18
+ health: int = 0
19
+ speed: float = 20.0
20
+ capacity: int = 0 # Loot capacity
21
+ food_consumption: int = 0
22
+
23
+
24
+ class Army(BaseModel):
25
+ """Army composition."""
26
+
27
+ model_config = ConfigDict(extra="ignore")
28
+
29
+ units: Dict[int, int] = Field(default_factory=dict) # {unit_id: count}
30
+
31
+ def add_unit(self, unit_id: int, count: int):
32
+ """Add units to army."""
33
+ if unit_id in self.units:
34
+ self.units[unit_id] += count
35
+ else:
36
+ self.units[unit_id] = count
37
+
38
+ def remove_unit(self, unit_id: int, count: int):
39
+ """Remove units from army."""
40
+ if unit_id in self.units:
41
+ self.units[unit_id] = max(0, self.units[unit_id] - count)
42
+ if self.units[unit_id] == 0:
43
+ del self.units[unit_id]
44
+
45
+ @property
46
+ def total_units(self) -> int:
47
+ """Total number of units."""
48
+ return sum(self.units.values())
49
+
50
+ @property
51
+ def is_empty(self) -> bool:
52
+ """Check if army is empty."""
53
+ return len(self.units) == 0
54
+
55
+
56
+ class UnitProduction(BaseModel):
57
+ """Unit production/training queue."""
58
+
59
+ model_config = ConfigDict(extra="ignore")
60
+
61
+ unit_id: int
62
+ count: int
63
+ finish_time: int # Timestamp
64
+ castle_id: int
65
+
66
+
67
+ # Common unit IDs (may vary by game version)
68
+ UNIT_IDS = {
69
+ "MILITIA": 620,
70
+ "SWORDSMAN": 614,
71
+ "BOWMAN": 611,
72
+ "CAVALRY": 629,
73
+ "ARCHER": 626,
74
+ "KNIGHT": 637,
75
+ }
@@ -0,0 +1,269 @@
1
+ import time
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+ from empire_core.utils.enums import MapObjectType, MovementType
7
+
8
+
9
+ class MapObject(BaseModel):
10
+ """Represents an object on the world map (Castle, Resource, NPC)."""
11
+
12
+ model_config = ConfigDict(extra="ignore")
13
+
14
+ area_id: int = Field(default=-1, alias="AID")
15
+ owner_id: int = Field(default=-1, alias="OID")
16
+ type: MapObjectType = Field(default=MapObjectType.UNKNOWN, alias="T")
17
+ level: int = Field(default=0, alias="L")
18
+
19
+ # Location - sometimes embedded or passed separately
20
+ x: int = Field(default=0, alias="X")
21
+ y: int = Field(default=0, alias="Y")
22
+ kingdom_id: int = Field(default=0, alias="KID")
23
+
24
+ # Metadata
25
+ name: str = Field(default="")
26
+ owner_name: str = Field(default="")
27
+ alliance_id: int = Field(default=-1)
28
+ alliance_name: str = Field(default="")
29
+
30
+ @property
31
+ def is_player(self) -> bool:
32
+ return self.type.is_player
33
+
34
+ @property
35
+ def is_npc(self) -> bool:
36
+ return self.type.is_npc
37
+
38
+ @property
39
+ def is_event(self) -> bool:
40
+ return self.type.is_event
41
+
42
+ @property
43
+ def is_resource(self) -> bool:
44
+ return self.type.is_resource
45
+
46
+ @property
47
+ def category(self) -> str:
48
+ if self.is_player:
49
+ return "Player"
50
+ if self.is_npc:
51
+ return "NPC"
52
+ if self.is_event:
53
+ return "Event"
54
+ if self.is_resource:
55
+ return "Resource"
56
+ return "Other"
57
+
58
+
59
+ class Army(BaseModel):
60
+ """Represents troops in a movement or castle."""
61
+
62
+ model_config = ConfigDict(extra="ignore")
63
+ units: Dict[int, int] = Field(default_factory=dict) # UnitID -> Count
64
+
65
+
66
+ class MovementResources(BaseModel):
67
+ """Resources being transported in a movement."""
68
+
69
+ model_config = ConfigDict(extra="ignore")
70
+
71
+ wood: int = Field(default=0, alias="W")
72
+ stone: int = Field(default=0, alias="S")
73
+ food: int = Field(default=0, alias="F")
74
+
75
+ # Special resources
76
+ iron: int = Field(default=0, alias="I")
77
+ glass: int = Field(default=0, alias="G")
78
+ ash: int = Field(default=0, alias="A")
79
+ honey: int = Field(default=0, alias="HONEY")
80
+ mead: int = Field(default=0, alias="MEAD")
81
+ beef: int = Field(default=0, alias="BEEF")
82
+
83
+ @property
84
+ def total(self) -> int:
85
+ """Total resources in transport."""
86
+ return self.wood + self.stone + self.food + self.iron + self.glass + self.ash
87
+
88
+ @property
89
+ def is_empty(self) -> bool:
90
+ """Check if no resources are being transported."""
91
+ return self.total == 0
92
+
93
+
94
+ class Movement(BaseModel):
95
+ """Represents a movement (Attack, Support, Transport, etc.)."""
96
+
97
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
98
+
99
+ MID: int = Field(default=-1) # Movement ID
100
+ T: int = Field(default=0) # Type (11=return, etc.)
101
+ PT: int = Field(default=0) # Progress Time
102
+ TT: int = Field(default=0) # Total Time
103
+ D: int = Field(default=0) # Direction
104
+ TID: int = Field(default=-1) # Target/Owner ID
105
+ KID: int = Field(default=0) # Kingdom ID
106
+ SID: int = Field(default=-1) # Source ID
107
+ OID: int = Field(default=-1) # Owner ID
108
+ HBW: int = Field(default=-1) # ?
109
+
110
+ # TA = Target Area (array with area details)
111
+ # SA = Source Area (array with area details)
112
+ target_area: Optional[List[Any]] = Field(default=None, alias="TA")
113
+ source_area: Optional[List[Any]] = Field(default=None, alias="SA")
114
+
115
+ # Extracted fields
116
+ target_area_id: int = Field(default=-1)
117
+ source_area_id: int = Field(default=-1)
118
+ target_x: int = Field(default=-1)
119
+ target_y: int = Field(default=-1)
120
+ source_x: int = Field(default=-1)
121
+ source_y: int = Field(default=-1)
122
+
123
+ # Units in movement (UnitID -> Count)
124
+ units: Dict[int, int] = Field(default_factory=dict)
125
+
126
+ # Estimated army size (GS field when army not visible)
127
+ estimated_size: int = Field(default=0)
128
+
129
+ # Resources being transported (for transport/return movements)
130
+ resources: MovementResources = Field(default_factory=MovementResources)
131
+
132
+ # Target/Source names (if available)
133
+ target_name: str = Field(default="")
134
+ source_name: str = Field(default="")
135
+ target_player_name: str = Field(default="")
136
+ source_player_name: str = Field(default="")
137
+ target_alliance_name: str = Field(default="")
138
+ source_alliance_name: str = Field(default="")
139
+
140
+ # Timestamps for tracking
141
+ created_at: float = Field(default_factory=time.time) # When we first saw this movement
142
+ last_updated: float = Field(default_factory=time.time) # Last update time
143
+
144
+ @property
145
+ def movement_id(self) -> int:
146
+ return self.MID
147
+
148
+ @property
149
+ def movement_type(self) -> int:
150
+ return self.T
151
+
152
+ @property
153
+ def movement_type_enum(self) -> MovementType:
154
+ """Get the MovementType enum value."""
155
+ try:
156
+ return MovementType(self.T)
157
+ except ValueError:
158
+ return MovementType.UNKNOWN
159
+
160
+ @property
161
+ def movement_type_name(self) -> str:
162
+ """Get the name of the movement type."""
163
+ try:
164
+ return MovementType(self.T).name
165
+ except ValueError:
166
+ return f"UNKNOWN_{self.T}"
167
+
168
+ @property
169
+ def progress_time(self) -> int:
170
+ return self.PT
171
+
172
+ @property
173
+ def total_time(self) -> int:
174
+ return self.TT
175
+
176
+ @property
177
+ def time_remaining(self) -> int:
178
+ return max(0, self.TT - self.PT)
179
+
180
+ @property
181
+ def progress_percent(self) -> float:
182
+ if self.TT > 0:
183
+ return (self.PT / self.TT) * 100
184
+ return 0.0
185
+
186
+ @property
187
+ def estimated_arrival(self) -> float:
188
+ """Estimated arrival timestamp (Unix time)."""
189
+ return self.last_updated + self.time_remaining
190
+
191
+ @property
192
+ def is_incoming(self) -> bool:
193
+ """Check if this movement is incoming to player."""
194
+ # Type 11 is typically return movement
195
+ return self.T != 11 and self.D == 0
196
+
197
+ @property
198
+ def is_outgoing(self) -> bool:
199
+ """Check if this movement is outgoing from player."""
200
+ return self.T != 11 and self.D == 1
201
+
202
+ @property
203
+ def is_returning(self) -> bool:
204
+ """Check if this is a return movement."""
205
+ return self.T == MovementType.RETURN
206
+
207
+ @property
208
+ def is_attack(self) -> bool:
209
+ """Check if this is an attack movement."""
210
+ # T=0 appears to be a standard attack on player castles
211
+ # T=1 is ATTACK, T=5 is RAID, T=9 is ATTACK_CAMP, T=10 is RAID_CAMP
212
+ return self.T in (
213
+ 0, # Standard attack (observed in gam packets)
214
+ MovementType.ATTACK,
215
+ MovementType.ATTACK_CAMP,
216
+ MovementType.RAID,
217
+ MovementType.RAID_CAMP,
218
+ )
219
+
220
+ @property
221
+ def is_transport(self) -> bool:
222
+ """Check if this is a transport movement."""
223
+ return self.T == MovementType.TRANSPORT
224
+
225
+ @property
226
+ def is_support(self) -> bool:
227
+ """Check if this is a support movement."""
228
+ return self.T == MovementType.SUPPORT
229
+
230
+ @property
231
+ def is_spy(self) -> bool:
232
+ """Check if this is a spy/scout movement."""
233
+ return self.T == MovementType.SPY
234
+
235
+ @property
236
+ def unit_count(self) -> int:
237
+ """Total number of units in this movement (includes all unit types)."""
238
+ return sum(self.units.values())
239
+
240
+ @property
241
+ def troop_count(self) -> int:
242
+ """Count of actual troops only (excludes equipment/tools)."""
243
+ from empire_core.utils.troops import count_troops
244
+
245
+ return count_troops(self.units)
246
+
247
+ def has_arrived(self) -> bool:
248
+ """Check if movement has arrived (time remaining <= 0)."""
249
+ return self.time_remaining <= 0
250
+
251
+ def format_time_remaining(self) -> str:
252
+ """Format time remaining as human-readable string."""
253
+ remaining = self.time_remaining
254
+ if remaining <= 0:
255
+ return "Arrived"
256
+
257
+ hours = remaining // 3600
258
+ minutes = (remaining % 3600) // 60
259
+ seconds = remaining % 60
260
+
261
+ if hours > 0:
262
+ return f"{hours}h {minutes}m {seconds}s"
263
+ elif minutes > 0:
264
+ return f"{minutes}m {seconds}s"
265
+ else:
266
+ return f"{seconds}s"
267
+
268
+ def __repr__(self) -> str:
269
+ return f"Movement(id={self.MID}, type={self.movement_type_name}, from={self.source_area_id}, to={self.target_area_id}, remaining={self.format_time_remaining()})"
@@ -0,0 +1 @@
1
+ """Storage modules."""
@@ -0,0 +1,237 @@
1
+ """
2
+ Asynchronous Database storage using SQLModel and aiosqlite with Write Queue.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ from datetime import datetime
8
+ from typing import Any, Dict, List, Optional, Set, Tuple
9
+
10
+ from sqlalchemy import text
11
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
12
+ from sqlmodel import Field, SQLModel, col, select
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # === Models / Tables ===
18
+
19
+
20
+ class PlayerSnapshot(SQLModel, table=True):
21
+ """Historical snapshot of player progress."""
22
+
23
+ __tablename__ = "player_snapshots"
24
+
25
+ id: Optional[int] = Field(default=None, primary_key=True)
26
+ player_id: int = Field(index=True)
27
+ timestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()))
28
+ level: int
29
+ gold: int
30
+ rubies: int
31
+
32
+
33
+ class MapObjectRecord(SQLModel, table=True):
34
+ """Persistent record of a discovered world object."""
35
+
36
+ __tablename__ = "map_objects"
37
+
38
+ area_id: int = Field(primary_key=True)
39
+ kingdom_id: int = Field(index=True)
40
+ x: int
41
+ y: int
42
+ type: int
43
+ level: int
44
+ name: Optional[str] = None
45
+ owner_id: Optional[int] = None
46
+ owner_name: Optional[str] = None
47
+ alliance_id: Optional[int] = None
48
+ alliance_name: Optional[str] = None
49
+ last_updated: int = Field(default_factory=lambda: int(datetime.now().timestamp()))
50
+
51
+
52
+ class ScannedChunkRecord(SQLModel, table=True):
53
+ """Record of a scanned map chunk."""
54
+
55
+ __tablename__ = "scanned_chunks"
56
+
57
+ kingdom_id: int = Field(primary_key=True)
58
+ chunk_x: int = Field(primary_key=True)
59
+ chunk_y: int = Field(primary_key=True)
60
+ last_scanned: int = Field(default_factory=lambda: int(datetime.now().timestamp()))
61
+
62
+
63
+ # === Database Manager ===
64
+
65
+
66
+ class GameDatabase:
67
+ """Async database manager with serialized write queue."""
68
+
69
+ def __init__(self, db_path: str = "empire_data.db"):
70
+ self.db_url = f"sqlite+aiosqlite:///{db_path}"
71
+ # Set timeout to 30s
72
+ self.engine = create_async_engine(self.db_url, echo=False, connect_args={"timeout": 30})
73
+ self.async_session_factory = async_sessionmaker(self.engine, expire_on_commit=False)
74
+
75
+ # Write Queue
76
+ self._write_queue: asyncio.Queue = asyncio.Queue()
77
+ self._writer_task: Optional[asyncio.Task] = None
78
+ self._running = False
79
+
80
+ async def initialize(self):
81
+ """Create tables and start writer loop."""
82
+ async with self.engine.begin() as conn:
83
+ await conn.execute(text("PRAGMA journal_mode=WAL;"))
84
+ await conn.execute(text("PRAGMA synchronous=NORMAL;"))
85
+ await conn.run_sync(SQLModel.metadata.create_all)
86
+
87
+ logger.info(f"Database initialized: {self.db_url} (WAL Mode)")
88
+ self._start_writer()
89
+
90
+ def _start_writer(self):
91
+ """Start the background writer task."""
92
+ if not self._running:
93
+ self._running = True
94
+ self._writer_task = asyncio.create_task(self._writer_loop())
95
+ logger.debug("Database writer loop started.")
96
+
97
+ async def close(self):
98
+ """Shutdown engine and writer."""
99
+ self._running = False
100
+ if self._writer_task:
101
+ # Wait for queue to drain
102
+ await self._write_queue.join()
103
+ self._writer_task.cancel()
104
+ try:
105
+ await self._writer_task
106
+ except asyncio.CancelledError:
107
+ pass
108
+
109
+ await self.engine.dispose()
110
+
111
+ async def _writer_loop(self):
112
+ """Consumes write operations from the queue and executes them serially."""
113
+ while self._running:
114
+ try:
115
+ # Get batch of operations
116
+ operation = await self._write_queue.get()
117
+ batch = [operation]
118
+
119
+ # Try to grab more if available (up to 50)
120
+ try:
121
+ for _ in range(50):
122
+ batch.append(self._write_queue.get_nowait())
123
+ except asyncio.QueueEmpty:
124
+ pass
125
+
126
+ async with self.async_session_factory() as session:
127
+ try:
128
+ for op_type, data in batch:
129
+ if op_type == "player_snapshot":
130
+ session.add(data)
131
+ elif op_type == "map_objects":
132
+ for obj in data:
133
+ await session.merge(obj)
134
+ elif op_type == "scanned_chunk":
135
+ await session.merge(data)
136
+
137
+ await session.commit()
138
+ except Exception as e:
139
+ logger.error(f"Database write error: {e}")
140
+ await session.rollback()
141
+ finally:
142
+ for _ in batch:
143
+ self._write_queue.task_done()
144
+
145
+ except asyncio.CancelledError:
146
+ break
147
+ except Exception as e:
148
+ logger.error(f"Critical error in writer loop: {e}")
149
+ await asyncio.sleep(1)
150
+
151
+ # === Write Operations (Queued) ===
152
+
153
+ async def save_player_snapshot(self, player: Any):
154
+ """Queue player snapshot save."""
155
+ snapshot = PlayerSnapshot(
156
+ player_id=player.id,
157
+ level=player.level,
158
+ gold=player.gold,
159
+ rubies=player.rubies,
160
+ )
161
+ await self._write_queue.put(("player_snapshot", snapshot))
162
+
163
+ async def save_map_objects(self, objects: List[Any]):
164
+ """Queue map objects save."""
165
+ if not objects:
166
+ return
167
+
168
+ records = [
169
+ MapObjectRecord(
170
+ area_id=obj.area_id,
171
+ kingdom_id=obj.kingdom_id,
172
+ x=obj.x,
173
+ y=obj.y,
174
+ type=int(obj.type),
175
+ level=obj.level,
176
+ name=obj.name,
177
+ owner_id=obj.owner_id,
178
+ owner_name=obj.owner_name,
179
+ alliance_id=obj.alliance_id,
180
+ alliance_name=obj.alliance_name,
181
+ )
182
+ for obj in objects
183
+ ]
184
+ await self._write_queue.put(("map_objects", records))
185
+
186
+ async def mark_chunk_scanned(self, kingdom_id: int, chunk_x: int, chunk_y: int):
187
+ """Queue chunk scanned mark."""
188
+ record = ScannedChunkRecord(kingdom_id=kingdom_id, chunk_x=chunk_x, chunk_y=chunk_y)
189
+ await self._write_queue.put(("scanned_chunk", record))
190
+
191
+ # === Read Operations (Direct) ===
192
+
193
+ async def get_scanned_chunks(self, kingdom_id: int) -> Set[Tuple[int, int]]:
194
+ """Get all scanned chunks for a kingdom."""
195
+ async with self.async_session_factory() as session:
196
+ statement = select(ScannedChunkRecord).where(ScannedChunkRecord.kingdom_id == kingdom_id)
197
+ results = await session.execute(statement)
198
+ return {(r.chunk_x, r.chunk_y) for r in results.scalars().all()}
199
+
200
+ async def find_targets(
201
+ self,
202
+ kingdom_id: int,
203
+ min_level: int = 0,
204
+ max_level: int = 999,
205
+ types: Optional[List[int]] = None,
206
+ ) -> List[MapObjectRecord]:
207
+ """Query world map from DB."""
208
+ async with self.async_session_factory() as session:
209
+ statement = select(MapObjectRecord).where(
210
+ MapObjectRecord.kingdom_id == kingdom_id,
211
+ MapObjectRecord.level >= min_level,
212
+ MapObjectRecord.level <= max_level,
213
+ )
214
+ if types:
215
+ statement = statement.where(col(MapObjectRecord.type).in_(types))
216
+
217
+ results = await session.execute(statement)
218
+ return list(results.scalars().all())
219
+
220
+ async def get_object_count(self) -> int:
221
+ """Total discovered objects."""
222
+ async with self.async_session_factory() as session:
223
+ # Simple way to get count in SQLModel
224
+ statement = select(MapObjectRecord)
225
+ results = await session.execute(statement)
226
+ return len(results.scalars().all())
227
+
228
+ async def get_object_counts_by_type(self) -> Dict[int, int]:
229
+ """Get counts of objects grouped by type."""
230
+ from sqlalchemy import func
231
+
232
+ async with self.async_session_factory() as session:
233
+ statement = select(col(MapObjectRecord.type), func.count(col(MapObjectRecord.area_id))).group_by(
234
+ col(MapObjectRecord.type)
235
+ )
236
+ results = await session.execute(statement)
237
+ return {row[0]: row[1] for row in results.all()}
File without changes