foothold-sitac 0.1.0__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.
File without changes
@@ -0,0 +1,67 @@
1
+ import os
2
+ from typing import Annotated, Any
3
+ from functools import cache
4
+ import yaml
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class WebConfig(BaseModel):
9
+ host: str = "0.0.0.0"
10
+ port: int = 8080
11
+ title: str = "Foothold Sitac Server"
12
+ reload: bool = False
13
+ refresh_interval: int = 60
14
+
15
+
16
+ class DcsConfig(BaseModel):
17
+ saved_games: str = "var" # "DCS Saved Games Path"
18
+
19
+
20
+ class TileLayerConfig(BaseModel):
21
+ name: str
22
+ url: str
23
+
24
+
25
+ class MapConfig(BaseModel):
26
+ url_tiles: str = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
27
+ alternative_tiles: Annotated[list[TileLayerConfig], Field(default_factory=list)]
28
+
29
+ min_zoom: int = 8
30
+ max_zoom: int = 11
31
+
32
+
33
+ class AppConfig(BaseModel):
34
+ web: Annotated[WebConfig, Field(default_factory=WebConfig)]
35
+ dcs: Annotated[DcsConfig, Field(default_factory=DcsConfig)]
36
+ map: Annotated[MapConfig, Field(default_factory=MapConfig)]
37
+
38
+
39
+ def _expand_env_vars(value: Any) -> Any:
40
+ """Recursively expand environment vars like "${VAR}" or "$VAR"."""
41
+ if isinstance(value, str):
42
+ return os.path.expandvars(value)
43
+ if isinstance(value, dict):
44
+ return {k: _expand_env_vars(v) for k, v in value.items()}
45
+ if isinstance(value, list):
46
+ return [_expand_env_vars(v) for v in value]
47
+ return value
48
+
49
+
50
+ def load_config_str(raw_config: dict[Any, Any]) -> AppConfig:
51
+ expanded = _expand_env_vars(raw_config)
52
+ return AppConfig.model_validate(expanded)
53
+
54
+
55
+ def load_config(path: str) -> AppConfig:
56
+ with open(path, "r") as f:
57
+ raw_config = yaml.safe_load(f)
58
+
59
+ return load_config_str(raw_config)
60
+
61
+
62
+ @cache
63
+ def get_config() -> AppConfig:
64
+ config_path = "config/config.yml"
65
+ if not os.path.exists(config_path):
66
+ return AppConfig(web=WebConfig(), dcs=DcsConfig(), map=MapConfig(alternative_tiles=[]))
67
+ return load_config(config_path)
@@ -0,0 +1,38 @@
1
+ from fastapi import HTTPException, status
2
+ from foothold_sitac.foothold import (
3
+ Sitac,
4
+ detect_foothold_mission_path,
5
+ get_server_path_by_name,
6
+ load_sitac,
7
+ )
8
+
9
+
10
+ def get_sitac_or_none(server: str) -> Sitac | None:
11
+ """Load sitac for a server, return None if not available"""
12
+ server_path = get_server_path_by_name(server)
13
+
14
+ if not server_path.is_dir():
15
+ return None
16
+
17
+ mission_path = detect_foothold_mission_path(server)
18
+
19
+ if not mission_path:
20
+ return None
21
+
22
+ return load_sitac(mission_path)
23
+
24
+
25
+ def get_active_sitac(server: str) -> Sitac:
26
+ """Dependency injection of sitac by server name"""
27
+
28
+ server_path = get_server_path_by_name(server)
29
+
30
+ if not server_path.is_dir():
31
+ raise HTTPException(status.HTTP_404_NOT_FOUND, f"server {server} not found")
32
+
33
+ mission_path = detect_foothold_mission_path(server)
34
+
35
+ if not mission_path:
36
+ raise HTTPException(status.HTTP_404_NOT_FOUND, f"mission not found for server {server}")
37
+
38
+ return load_sitac(mission_path)
@@ -0,0 +1,254 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from lupa import LuaRuntime # type: ignore[import-untyped]
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+ from foothold_sitac.config import get_config
9
+
10
+
11
+ class ConfigError(Exception): ...
12
+
13
+
14
+ class Position(BaseModel):
15
+ latitude: float
16
+ longitude: float
17
+ altitude: int | None = None # not used anymore
18
+
19
+
20
+ class Zone(BaseModel):
21
+ upgrades_used: int = Field(alias="upgradesUsed")
22
+ side: int
23
+ active: bool
24
+ destroyed: dict[int, str] | list[str] | dict[Any, Any]
25
+ extra_upgrade: dict[Any, Any] = Field(alias="extraUpgrade")
26
+ remaining_units: dict[int, dict[int, str]] | dict[Any, Any] = Field(alias="remainingUnits")
27
+ first_capture_by_red: bool = Field(alias="firstCaptureByRed")
28
+ level: int
29
+ wasBlue: bool
30
+ triggers: dict[str, int]
31
+ position: Position = Field(alias="lat_long")
32
+ hidden: bool = False
33
+ flavor_text: str | None = Field(alias="flavorText", default=None)
34
+
35
+ @property
36
+ def side_color(self) -> str:
37
+ if not self.active:
38
+ return "darkgray"
39
+ if self.side == 1:
40
+ return "red"
41
+ elif self.side == 2:
42
+ return "blue"
43
+ return "lightgray"
44
+
45
+ @property
46
+ def side_str(self) -> str:
47
+ if not self.active:
48
+ return "disabled"
49
+ if self.side == 1:
50
+ return "red"
51
+ elif self.side == 2:
52
+ return "blue"
53
+ return "neutral"
54
+
55
+ @property
56
+ def total_units(self) -> int:
57
+ return sum([len(group_units) for group_units in self.remaining_units.values()])
58
+
59
+
60
+ class Mission(BaseModel):
61
+ is_escort_mission: bool = Field(alias="isEscortMission")
62
+ description: str
63
+ title: str
64
+ is_running: bool = Field(alias="isRunning")
65
+
66
+
67
+ class Connection(BaseModel):
68
+ from_zone: str = Field(alias="from")
69
+ to_zone: str = Field(alias="to")
70
+
71
+
72
+ class Player(BaseModel):
73
+ coalition: str
74
+ unit_type: str = Field(alias="unitType")
75
+ player_name: str = Field(alias="playerName")
76
+ latitude: float
77
+ longitude: float
78
+ altitude: float | None = None
79
+
80
+ @property
81
+ def side_color(self) -> str:
82
+ if self.coalition == "red":
83
+ return "red"
84
+ elif self.coalition == "blue":
85
+ return "blue"
86
+ return "gray"
87
+
88
+
89
+ class EjectedPilot(BaseModel):
90
+ player_name: str = Field(alias="playerName")
91
+ latitude: float
92
+ longitude: float
93
+ altitude: float = 0
94
+ lost_credits: float = Field(alias="lostCredits", default=0)
95
+
96
+
97
+ class PlayerStats(BaseModel):
98
+ air: int = Field(alias="Air", default=0)
99
+ SAM: int = Field(alias="SAM", default=0)
100
+ points: float = Field(alias="Points", default=0)
101
+ deaths: int = Field(alias="Deaths", default=0)
102
+ zone_capture: int = Field(alias="Zone capture", default=0)
103
+ zone_upgrade: int = Field(alias="Zone upgrade", default=0)
104
+ CAS_mission: int = Field(alias="CAS mission", default=0)
105
+ points_spent: int = Field(alias="Points spent", default=0)
106
+ infantry: int = Field(alias="Infantry", default=0)
107
+ ground_units: int = Field(alias="Ground Units", default=0)
108
+ helo: int = Field(alias="Helo", default=0)
109
+
110
+
111
+ class Sitac(BaseModel):
112
+ updated_at: datetime
113
+ zones: dict[str, Zone]
114
+ player_stats: dict[str, PlayerStats] = Field(alias="playerStats")
115
+ missions: list[Mission] = Field(default_factory=list)
116
+ connections: list[Connection] = Field(default_factory=list)
117
+ players: list[Player] = Field(default_factory=list)
118
+ ejected_pilots: list[EjectedPilot] = Field(alias="ejectedPilots", default_factory=list)
119
+
120
+ @field_validator("missions", "connections", "players", "ejected_pilots", mode="before")
121
+ @classmethod
122
+ def convert_lua_table_to_list(cls, v: Any) -> list[Any]:
123
+ """Convert Lua table (dict with numeric keys) to list."""
124
+ if v is None:
125
+ return []
126
+ if isinstance(v, dict):
127
+ return list(v.values())
128
+ return list(v) if not isinstance(v, list) else v
129
+
130
+ @property
131
+ def campaign_progress(self) -> float:
132
+ """Return the campaign progress percentage (0-100).
133
+
134
+ Progress is calculated as:
135
+ (visible_zones - red_zones) / visible_zones * 100
136
+
137
+ Hidden zones (hidden=True) and inactive zones (active=False) are
138
+ excluded.
139
+ """
140
+ visible_zones = [z for z in self.zones.values() if not z.hidden and z.active]
141
+ if not visible_zones:
142
+ return 0.0
143
+ red_zones = sum(1 for z in visible_zones if z.side == 1)
144
+ return (len(visible_zones) - red_zones) / len(visible_zones) * 100
145
+
146
+
147
+ def lua_to_dict(lua_table: Any) -> dict[Any, Any] | None:
148
+ if lua_table is None:
149
+ return None
150
+ result: dict[Any, Any] = {}
151
+ for k, v in lua_table.items():
152
+ if hasattr(v, "items"):
153
+ v = lua_to_dict(v)
154
+ result[k] = v
155
+ return result
156
+
157
+
158
+ def load_sitac(file: Path) -> Sitac:
159
+ lua = LuaRuntime(unpack_returned_tuples=True)
160
+
161
+ with open(file.absolute(), "r", encoding="utf-8") as f:
162
+ lua_code = f.read()
163
+
164
+ lua.execute(lua_code)
165
+
166
+ zone_persistance = lua.globals().zonePersistance
167
+ zone_persistance_dict = lua_to_dict(zone_persistance)
168
+
169
+ # Merge zonesDetails into zones (new format support)
170
+ # In new format, flavorText is stored in zonesDetails instead of directly in zones
171
+ zones_details = zone_persistance_dict.get("zonesDetails", {}) # type: ignore[union-attr]
172
+ if zones_details and "zones" in zone_persistance_dict: # type: ignore[operator]
173
+ for zone_name, details in zones_details.items():
174
+ if zone_name in zone_persistance_dict["zones"]: # type: ignore[index]
175
+ zone_persistance_dict["zones"][zone_name].update(details) # type: ignore[index]
176
+
177
+ return Sitac(
178
+ **zone_persistance_dict, # type: ignore[arg-type]
179
+ updated_at=datetime.fromtimestamp(file.stat().st_mtime),
180
+ )
181
+
182
+
183
+ def detect_foothold_mission_path(server_name: str) -> Path | None:
184
+ file_status = get_foothold_server_status_path(server_name)
185
+
186
+ if not file_status.is_file():
187
+ return None
188
+
189
+ with open(file_status) as f:
190
+ mission_file_path = Path(f.readline().strip())
191
+
192
+ print(mission_file_path)
193
+ return mission_file_path
194
+
195
+
196
+ def get_server_path_by_name(server_name: str) -> Path:
197
+ return Path(get_config().dcs.saved_games) / server_name
198
+
199
+
200
+ def get_foothold_server_saves_path(server_name: str) -> Path:
201
+ return get_server_path_by_name(server_name) / "Missions" / "Saves"
202
+
203
+
204
+ def get_foothold_server_status_path(server_name: str) -> Path:
205
+ return get_foothold_server_saves_path(server_name) / "foothold.status"
206
+
207
+
208
+ def is_foothold_path(server_name: str) -> bool:
209
+ """Check if server_name (directory in DCS Saved Games) is a Foothold server path"""
210
+
211
+ path = get_foothold_server_status_path(server_name)
212
+
213
+ return path.is_file()
214
+
215
+
216
+ def list_servers() -> list[str]:
217
+ base_path = Path(get_config().dcs.saved_games)
218
+
219
+ if not base_path.is_dir():
220
+ raise ConfigError(f"config:dcs.saved_games '{get_config().dcs.saved_games}' is not a valid dir")
221
+
222
+ return sorted(
223
+ [file.name for file in base_path.iterdir() if not file.name.startswith(".") and is_foothold_path(file.name)]
224
+ )
225
+
226
+
227
+ def get_sitac_range(sitac: Sitac) -> tuple[Position, Position]:
228
+ if not sitac.zones:
229
+ raise ValueError("sitac without zones")
230
+ first_zone = sitac.zones[next(iter(sitac.zones))]
231
+
232
+ min_lat, max_lat = first_zone.position.latitude, first_zone.position.latitude
233
+ min_long, max_long = first_zone.position.longitude, first_zone.position.longitude
234
+
235
+ for zone in sitac.zones.values():
236
+ min_lat, max_lat = (
237
+ min(min_lat, zone.position.latitude),
238
+ max(max_lat, zone.position.latitude),
239
+ )
240
+ min_long, max_long = (
241
+ min(min_long, zone.position.longitude),
242
+ max(max_long, zone.position.longitude),
243
+ )
244
+
245
+ return Position(latitude=min_lat, longitude=min_long), Position(latitude=max_lat, longitude=max_long)
246
+
247
+
248
+ def get_sitac_center(sitac: Sitac) -> Position:
249
+ min_pos, max_pos = get_sitac_range(sitac)
250
+
251
+ return Position(
252
+ latitude=(max_pos.latitude + min_pos.latitude) / 2,
253
+ longitude=(max_pos.longitude + min_pos.longitude) / 2,
254
+ )
@@ -0,0 +1,106 @@
1
+ from datetime import datetime
2
+ from typing import Annotated, Any
3
+ from fastapi import APIRouter, Depends
4
+ from foothold_sitac.dependencies import get_active_sitac
5
+ from foothold_sitac.foothold import Sitac, list_servers
6
+ from foothold_sitac.schemas import MapConnection, MapData, MapEjectedPilot, MapPlayer, MapZone, Server
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ @router.get("", response_model=list[Server], description="List foothold servers")
12
+ async def foothold_list_servers() -> Any:
13
+ return [Server.model_validate({"name": server}) for server in list_servers()]
14
+
15
+
16
+ @router.get("/{server}/sitac", response_model=Sitac)
17
+ async def foothold_get_sitac(sitac: Annotated[Sitac, Depends(get_active_sitac)]) -> Any:
18
+ return sitac
19
+
20
+
21
+ @router.get("/{server}/map.json", response_model=MapData)
22
+ async def foothold_get_map_data(
23
+ sitac: Annotated[Sitac, Depends(get_active_sitac)],
24
+ ) -> Any:
25
+ zones = [
26
+ MapZone.model_validate(
27
+ {
28
+ "name": zone_name,
29
+ "lat": zone.position.latitude,
30
+ "lon": zone.position.longitude,
31
+ "side": zone.side_str,
32
+ "color": zone.side_color,
33
+ "units": zone.total_units,
34
+ "level": zone.level,
35
+ "flavor_text": zone.flavor_text,
36
+ }
37
+ )
38
+ for zone_name, zone in sitac.zones.items()
39
+ if zone.position and not zone.hidden
40
+ ]
41
+
42
+ # Build connections with resolved coordinates
43
+ connections = []
44
+ for conn in sitac.connections:
45
+ from_zone = sitac.zones.get(conn.from_zone)
46
+ to_zone = sitac.zones.get(conn.to_zone)
47
+ # Only include connection if both zones exist, have positions, and are not hidden
48
+ if (
49
+ from_zone
50
+ and to_zone
51
+ and from_zone.position
52
+ and to_zone.position
53
+ and not from_zone.hidden
54
+ and not to_zone.hidden
55
+ ):
56
+ connections.append(
57
+ MapConnection(
58
+ from_zone=conn.from_zone,
59
+ to_zone=conn.to_zone,
60
+ from_lat=from_zone.position.latitude,
61
+ from_lon=from_zone.position.longitude,
62
+ to_lat=to_zone.position.latitude,
63
+ to_lon=to_zone.position.longitude,
64
+ color=from_zone.side_color,
65
+ )
66
+ )
67
+
68
+ age_seconds = (datetime.now() - sitac.updated_at).total_seconds()
69
+
70
+ # Build players list
71
+ players = [
72
+ MapPlayer(
73
+ player_name=player.player_name,
74
+ lat=player.latitude,
75
+ lon=player.longitude,
76
+ coalition=player.coalition,
77
+ unit_type=player.unit_type,
78
+ color=player.side_color,
79
+ )
80
+ for player in sitac.players
81
+ ]
82
+
83
+ # Build ejected pilots list (exclude "Unknown" pilots)
84
+ ejected_pilots = [
85
+ MapEjectedPilot(
86
+ player_name=pilot.player_name,
87
+ lat=pilot.latitude,
88
+ lon=pilot.longitude,
89
+ altitude=pilot.altitude,
90
+ lost_credits=pilot.lost_credits,
91
+ )
92
+ for pilot in sitac.ejected_pilots
93
+ # if pilot.player_name != "Unknown" # don't hide Unknown pilots, real pilots have this name
94
+ ]
95
+
96
+ return MapData(
97
+ updated_at=sitac.updated_at,
98
+ age_seconds=age_seconds,
99
+ zones=zones,
100
+ connections=connections,
101
+ players=players,
102
+ ejected_pilots=ejected_pilots,
103
+ progress=sitac.campaign_progress,
104
+ missions_count=len(sitac.missions),
105
+ ejected_pilots_count=len(ejected_pilots),
106
+ )
@@ -0,0 +1,91 @@
1
+ from datetime import datetime
2
+ from typing import Annotated
3
+ from fastapi import APIRouter, Depends, Request
4
+ from fastapi.responses import HTMLResponse
5
+ from pydantic import BaseModel
6
+ from foothold_sitac.foothold import Sitac, get_sitac_center, list_servers
7
+ from foothold_sitac.templater import env
8
+ from foothold_sitac.dependencies import get_active_sitac, get_sitac_or_none
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ class ServerInfo(BaseModel):
14
+ name: str
15
+ updated_at: datetime | None
16
+
17
+
18
+ @router.get("", response_class=HTMLResponse)
19
+ async def foothold_servers(request: Request) -> str:
20
+ servers_data: list[ServerInfo] = []
21
+ for server_name in list_servers():
22
+ sitac = get_sitac_or_none(server_name)
23
+ servers_data.append(
24
+ ServerInfo(
25
+ name=server_name,
26
+ updated_at=sitac.updated_at if sitac else None,
27
+ )
28
+ )
29
+
30
+ template = env.get_template("foothold/servers.html")
31
+ return template.render(
32
+ {
33
+ "request": request,
34
+ "servers": servers_data,
35
+ "now": datetime.now(),
36
+ }
37
+ )
38
+
39
+
40
+ @router.get("/sitac/{server}", response_class=HTMLResponse)
41
+ async def foothold_sitac(request: Request, sitac: Annotated[Sitac, Depends(get_active_sitac)]) -> str:
42
+ template = env.get_template("foothold/sitac.html")
43
+ return template.render(
44
+ {
45
+ "request": request,
46
+ "sitac": sitac,
47
+ }
48
+ )
49
+
50
+
51
+ @router.get("/map/{server}", response_class=HTMLResponse)
52
+ async def foothold_map(request: Request, server: str, sitac: Annotated[Sitac, Depends(get_active_sitac)]) -> str:
53
+ template = env.get_template("foothold/map.html")
54
+ map_center = get_sitac_center(sitac)
55
+
56
+ return template.render(
57
+ {
58
+ "request": request,
59
+ "sitac": sitac,
60
+ "server": server,
61
+ "center": [map_center.latitude, map_center.longitude],
62
+ "progress": sitac.campaign_progress,
63
+ }
64
+ )
65
+
66
+
67
+ @router.get("/map/{server}/players", response_class=HTMLResponse)
68
+ async def foothold_players_modal(sitac: Annotated[Sitac, Depends(get_active_sitac)]) -> str:
69
+ template = env.get_template("foothold/partials/players.html")
70
+ return template.render({"sitac": sitac})
71
+
72
+
73
+ @router.get("/map/{server}/zones", response_class=HTMLResponse)
74
+ async def foothold_zones_modal(sitac: Annotated[Sitac, Depends(get_active_sitac)]) -> str:
75
+ template = env.get_template("foothold/partials/zones.html")
76
+ return template.render({"sitac": sitac, "progress": sitac.campaign_progress})
77
+
78
+
79
+ @router.get("/map/{server}/missions", response_class=HTMLResponse)
80
+ async def foothold_missions_modal(sitac: Annotated[Sitac, Depends(get_active_sitac)]) -> str:
81
+ template = env.get_template("foothold/partials/missions.html")
82
+ return template.render({"missions": sitac.missions})
83
+
84
+
85
+ @router.get("/map/{server}/ejected", response_class=HTMLResponse)
86
+ async def foothold_ejected_modal(sitac: Annotated[Sitac, Depends(get_active_sitac)]) -> str:
87
+ template = env.get_template("foothold/partials/ejected.html")
88
+ # Filter out "Unknown" pilots
89
+ # ejected_pilots = [p for p in sitac.ejected_pilots if p.player_name != "Unknown"]
90
+ # return template.render({"ejected_pilots": ejected_pilots})
91
+ return template.render({"ejected_pilots": sitac.ejected_pilots})
foothold_sitac/main.py ADDED
@@ -0,0 +1,31 @@
1
+ from importlib.resources import files
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.responses import HTMLResponse, RedirectResponse
5
+ from fastapi.staticfiles import StaticFiles
6
+
7
+ from foothold_sitac.config import get_config
8
+ from foothold_sitac.foothold_api_router import router as foothold_api_router
9
+ from foothold_sitac.foothold_router import router as foothold_router
10
+ from foothold_sitac.templater import env
11
+
12
+ config = get_config()
13
+
14
+ static_path = files("foothold_sitac") / "static"
15
+ app = FastAPI(title=config.web.title, version="0.1.0", description="Foothold Web Sitac")
16
+ app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
17
+
18
+
19
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
20
+ async def home(request: Request) -> str:
21
+ template = env.get_template("home.html")
22
+ return template.render(request=request)
23
+
24
+
25
+ @app.get("/favicon.ico", include_in_schema=False)
26
+ async def favicon() -> RedirectResponse:
27
+ return RedirectResponse(url="/static/favicon.ico")
28
+
29
+
30
+ app.include_router(foothold_router, prefix="/foothold", include_in_schema=False)
31
+ app.include_router(foothold_api_router, prefix="/api/foothold", tags=["foothold"])
@@ -0,0 +1,56 @@
1
+ from datetime import datetime
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class Server(BaseModel):
6
+ name: str
7
+
8
+
9
+ class MapZone(BaseModel):
10
+ name: str
11
+ lat: float
12
+ lon: float
13
+ side: str
14
+ color: str
15
+ units: int
16
+ level: int
17
+ flavor_text: str | None = None
18
+
19
+
20
+ class MapConnection(BaseModel):
21
+ from_zone: str
22
+ to_zone: str
23
+ from_lat: float
24
+ from_lon: float
25
+ to_lat: float
26
+ to_lon: float
27
+ color: str
28
+
29
+
30
+ class MapPlayer(BaseModel):
31
+ player_name: str
32
+ lat: float
33
+ lon: float
34
+ coalition: str
35
+ unit_type: str
36
+ color: str
37
+
38
+
39
+ class MapEjectedPilot(BaseModel):
40
+ player_name: str
41
+ lat: float
42
+ lon: float
43
+ altitude: float
44
+ lost_credits: float
45
+
46
+
47
+ class MapData(BaseModel):
48
+ updated_at: datetime
49
+ age_seconds: float
50
+ zones: list[MapZone]
51
+ connections: list[MapConnection]
52
+ players: list[MapPlayer] = Field(default_factory=list)
53
+ ejected_pilots: list[MapEjectedPilot] = Field(default_factory=list)
54
+ progress: float
55
+ missions_count: int
56
+ ejected_pilots_count: int = 0