foothold-sitac 0.1.0__tar.gz

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 (31) hide show
  1. foothold_sitac-0.1.0/PKG-INFO +92 -0
  2. foothold_sitac-0.1.0/README.md +69 -0
  3. foothold_sitac-0.1.0/pyproject.toml +45 -0
  4. foothold_sitac-0.1.0/src/foothold_sitac/__init__.py +0 -0
  5. foothold_sitac-0.1.0/src/foothold_sitac/config.py +67 -0
  6. foothold_sitac-0.1.0/src/foothold_sitac/dependencies.py +38 -0
  7. foothold_sitac-0.1.0/src/foothold_sitac/foothold.py +254 -0
  8. foothold_sitac-0.1.0/src/foothold_sitac/foothold_api_router.py +106 -0
  9. foothold_sitac-0.1.0/src/foothold_sitac/foothold_router.py +91 -0
  10. foothold_sitac-0.1.0/src/foothold_sitac/main.py +31 -0
  11. foothold_sitac-0.1.0/src/foothold_sitac/schemas.py +56 -0
  12. foothold_sitac-0.1.0/src/foothold_sitac/static/css/base.css +140 -0
  13. foothold_sitac-0.1.0/src/foothold_sitac/static/css/cards.css +129 -0
  14. foothold_sitac-0.1.0/src/foothold_sitac/static/css/map.css +374 -0
  15. foothold_sitac-0.1.0/src/foothold_sitac/static/css/modals.css +425 -0
  16. foothold_sitac-0.1.0/src/foothold_sitac/static/css/navbar.css +272 -0
  17. foothold_sitac-0.1.0/src/foothold_sitac/static/css/sitac.css +92 -0
  18. foothold_sitac-0.1.0/src/foothold_sitac/static/favicon.ico +0 -0
  19. foothold_sitac-0.1.0/src/foothold_sitac/static/js/coords.js +186 -0
  20. foothold_sitac-0.1.0/src/foothold_sitac/static/js/map.js +582 -0
  21. foothold_sitac-0.1.0/src/foothold_sitac/static/js/modals.js +174 -0
  22. foothold_sitac-0.1.0/src/foothold_sitac/templater.py +9 -0
  23. foothold_sitac-0.1.0/src/foothold_sitac/templates/base.html +17 -0
  24. foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/map.html +127 -0
  25. foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/partials/ejected.html +48 -0
  26. foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/partials/missions.html +38 -0
  27. foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/partials/players.html +33 -0
  28. foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/partials/zones.html +124 -0
  29. foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/servers.html +47 -0
  30. foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/sitac.html +85 -0
  31. foothold_sitac-0.1.0/src/foothold_sitac/templates/home.html +20 -0
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.1
2
+ Name: foothold-sitac
3
+ Version: 0.1.0
4
+ Summary: Web application displaying tactical maps for DCS World Foothold missions
5
+ License: MIT
6
+ Author: Michel NAUD
7
+ Author-email: michel.naud26400@gmail.com
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Framework :: FastAPI
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Games/Entertainment :: Simulation
15
+ Requires-Dist: fastapi (>=0.121.1,<0.122.0)
16
+ Requires-Dist: jinja2 (>=3.1.6,<4.0.0)
17
+ Requires-Dist: lupa (>=2.6,<3.0)
18
+ Requires-Dist: pydantic (>=2.12.4,<3.0.0)
19
+ Requires-Dist: pyyaml (>=6.0.3,<7.0.0)
20
+ Requires-Dist: uvicorn (>=0.38.0,<0.39.0)
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Foothold Sitac
24
+
25
+ POC about a web sitac for Foothold missions
26
+
27
+ ## Features
28
+
29
+ See [docs/features.md](docs/features.md) for a complete list of features including:
30
+ - Interactive tactical map with zone and player tracking
31
+ - Distance measurement tool (ruler)
32
+ - Player rankings and statistics
33
+ - Campaign progress tracking
34
+ - REST API for programmatic access
35
+
36
+ ## Installation
37
+
38
+ ### Prerequisites
39
+
40
+ - Git
41
+ - Python 3.12+
42
+ - Poetry
43
+
44
+ ### Clone the project
45
+
46
+ ```shell
47
+ git clone git@github.com:VEAF/foothold-sitac.git
48
+ cd foothold-sitac
49
+ ```
50
+
51
+ ### Windows (recommended)
52
+
53
+ 1. Run `install.cmd` (copies config and installs dependencies)
54
+ 2. Edit `config/config.yml` if needed (mandatory: set `dcs.saved_games` path)
55
+ 3. Run `run.cmd` to start the web server
56
+
57
+ ### Linux/Mac
58
+
59
+ 1. Copy `config/config.yml.dist` to `config/config.yml`
60
+ 2. Edit `config/config.yml` (mandatory: set `dcs.saved_games` path)
61
+ 3. Install dependencies:
62
+ ```shell
63
+ poetry install --only main
64
+ ```
65
+ 4. Start web service:
66
+ ```shell
67
+ poetry run python run.py
68
+ ```
69
+
70
+ ## Update
71
+
72
+ ### Windows
73
+
74
+ Run `update.cmd`
75
+
76
+ ### Linux/Mac
77
+
78
+ ```shell
79
+ git pull origin main
80
+ poetry install --only main
81
+ ```
82
+
83
+ ## Run tests
84
+
85
+ ```shell
86
+ poetry run pytest
87
+ ```
88
+
89
+ ## Access to web service
90
+
91
+ Default configuration: [localhost](http://localhost:8080)
92
+
@@ -0,0 +1,69 @@
1
+ # Foothold Sitac
2
+
3
+ POC about a web sitac for Foothold missions
4
+
5
+ ## Features
6
+
7
+ See [docs/features.md](docs/features.md) for a complete list of features including:
8
+ - Interactive tactical map with zone and player tracking
9
+ - Distance measurement tool (ruler)
10
+ - Player rankings and statistics
11
+ - Campaign progress tracking
12
+ - REST API for programmatic access
13
+
14
+ ## Installation
15
+
16
+ ### Prerequisites
17
+
18
+ - Git
19
+ - Python 3.12+
20
+ - Poetry
21
+
22
+ ### Clone the project
23
+
24
+ ```shell
25
+ git clone git@github.com:VEAF/foothold-sitac.git
26
+ cd foothold-sitac
27
+ ```
28
+
29
+ ### Windows (recommended)
30
+
31
+ 1. Run `install.cmd` (copies config and installs dependencies)
32
+ 2. Edit `config/config.yml` if needed (mandatory: set `dcs.saved_games` path)
33
+ 3. Run `run.cmd` to start the web server
34
+
35
+ ### Linux/Mac
36
+
37
+ 1. Copy `config/config.yml.dist` to `config/config.yml`
38
+ 2. Edit `config/config.yml` (mandatory: set `dcs.saved_games` path)
39
+ 3. Install dependencies:
40
+ ```shell
41
+ poetry install --only main
42
+ ```
43
+ 4. Start web service:
44
+ ```shell
45
+ poetry run python run.py
46
+ ```
47
+
48
+ ## Update
49
+
50
+ ### Windows
51
+
52
+ Run `update.cmd`
53
+
54
+ ### Linux/Mac
55
+
56
+ ```shell
57
+ git pull origin main
58
+ poetry install --only main
59
+ ```
60
+
61
+ ## Run tests
62
+
63
+ ```shell
64
+ poetry run pytest
65
+ ```
66
+
67
+ ## Access to web service
68
+
69
+ Default configuration: [localhost](http://localhost:8080)
@@ -0,0 +1,45 @@
1
+ [tool.poetry]
2
+ name = "foothold-sitac"
3
+ version = "0.1.0"
4
+ description = "Web application displaying tactical maps for DCS World Foothold missions"
5
+ authors = ["Michel NAUD <michel.naud26400@gmail.com>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ packages = [{include = "foothold_sitac", from = "src"}]
9
+ include = [
10
+ "src/foothold_sitac/static/**/*",
11
+ "src/foothold_sitac/templates/**/*",
12
+ ]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Framework :: FastAPI",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Games/Entertainment :: Simulation",
18
+ ]
19
+
20
+ [tool.poetry.dependencies]
21
+ python = "^3.12"
22
+ fastapi = "^0.121.1"
23
+ uvicorn = "^0.38.0"
24
+ jinja2 = "^3.1.6"
25
+ pydantic = "^2.12.4"
26
+ lupa = "^2.6"
27
+ pyyaml = "^6.0.3"
28
+
29
+
30
+ [tool.poetry.group.dev.dependencies]
31
+ pytest = "^9.0.1"
32
+ httpx = "^0.28.1"
33
+ ruff = "^0.8"
34
+ mypy = "^1.14"
35
+ types-pyyaml = "^6.0.12.20250915"
36
+
37
+ [build-system]
38
+ requires = ["poetry-core"]
39
+ build-backend = "poetry.core.masonry.api"
40
+
41
+ [tool.pytest.ini_options]
42
+ pythonpath = [".", "src"]
43
+
44
+ [tool.ruff]
45
+ line-length = 120
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
+ )