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,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
|