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.
- foothold_sitac/__init__.py +0 -0
- foothold_sitac/config.py +67 -0
- foothold_sitac/dependencies.py +38 -0
- foothold_sitac/foothold.py +254 -0
- foothold_sitac/foothold_api_router.py +106 -0
- foothold_sitac/foothold_router.py +91 -0
- foothold_sitac/main.py +31 -0
- foothold_sitac/schemas.py +56 -0
- foothold_sitac/static/css/base.css +140 -0
- foothold_sitac/static/css/cards.css +129 -0
- foothold_sitac/static/css/map.css +374 -0
- foothold_sitac/static/css/modals.css +425 -0
- foothold_sitac/static/css/navbar.css +272 -0
- foothold_sitac/static/css/sitac.css +92 -0
- foothold_sitac/static/favicon.ico +0 -0
- foothold_sitac/static/js/coords.js +186 -0
- foothold_sitac/static/js/map.js +582 -0
- foothold_sitac/static/js/modals.js +174 -0
- foothold_sitac/templater.py +9 -0
- foothold_sitac/templates/base.html +17 -0
- foothold_sitac/templates/foothold/map.html +127 -0
- foothold_sitac/templates/foothold/partials/ejected.html +48 -0
- foothold_sitac/templates/foothold/partials/missions.html +38 -0
- foothold_sitac/templates/foothold/partials/players.html +33 -0
- foothold_sitac/templates/foothold/partials/zones.html +124 -0
- foothold_sitac/templates/foothold/servers.html +47 -0
- foothold_sitac/templates/foothold/sitac.html +85 -0
- foothold_sitac/templates/home.html +20 -0
- foothold_sitac-0.1.0.dist-info/METADATA +92 -0
- foothold_sitac-0.1.0.dist-info/RECORD +31 -0
- foothold_sitac-0.1.0.dist-info/WHEEL +4 -0
|
File without changes
|
foothold_sitac/config.py
ADDED
|
@@ -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
|