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.
- foothold_sitac-0.1.0/PKG-INFO +92 -0
- foothold_sitac-0.1.0/README.md +69 -0
- foothold_sitac-0.1.0/pyproject.toml +45 -0
- foothold_sitac-0.1.0/src/foothold_sitac/__init__.py +0 -0
- foothold_sitac-0.1.0/src/foothold_sitac/config.py +67 -0
- foothold_sitac-0.1.0/src/foothold_sitac/dependencies.py +38 -0
- foothold_sitac-0.1.0/src/foothold_sitac/foothold.py +254 -0
- foothold_sitac-0.1.0/src/foothold_sitac/foothold_api_router.py +106 -0
- foothold_sitac-0.1.0/src/foothold_sitac/foothold_router.py +91 -0
- foothold_sitac-0.1.0/src/foothold_sitac/main.py +31 -0
- foothold_sitac-0.1.0/src/foothold_sitac/schemas.py +56 -0
- foothold_sitac-0.1.0/src/foothold_sitac/static/css/base.css +140 -0
- foothold_sitac-0.1.0/src/foothold_sitac/static/css/cards.css +129 -0
- foothold_sitac-0.1.0/src/foothold_sitac/static/css/map.css +374 -0
- foothold_sitac-0.1.0/src/foothold_sitac/static/css/modals.css +425 -0
- foothold_sitac-0.1.0/src/foothold_sitac/static/css/navbar.css +272 -0
- foothold_sitac-0.1.0/src/foothold_sitac/static/css/sitac.css +92 -0
- foothold_sitac-0.1.0/src/foothold_sitac/static/favicon.ico +0 -0
- foothold_sitac-0.1.0/src/foothold_sitac/static/js/coords.js +186 -0
- foothold_sitac-0.1.0/src/foothold_sitac/static/js/map.js +582 -0
- foothold_sitac-0.1.0/src/foothold_sitac/static/js/modals.js +174 -0
- foothold_sitac-0.1.0/src/foothold_sitac/templater.py +9 -0
- foothold_sitac-0.1.0/src/foothold_sitac/templates/base.html +17 -0
- foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/map.html +127 -0
- foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/partials/ejected.html +48 -0
- foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/partials/missions.html +38 -0
- foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/partials/players.html +33 -0
- foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/partials/zones.html +124 -0
- foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/servers.html +47 -0
- foothold_sitac-0.1.0/src/foothold_sitac/templates/foothold/sitac.html +85 -0
- 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
|
+
)
|