Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,29 @@ git pull origin main
poetry install --only main
```

## Unit display names (optional)

To display human-readable unit names (on hover) instead of raw DCS type codes, set `dcs.install_path` in `config/config.yml` to your DCS World installation directory:

```yaml
dcs:
install_path: "C:\\Program Files\\Eagle Dynamics\\DCS World"
```

Then extract the unit names:

```shell
poetry run foothold-sitac extract-unit-names
```

This generates `var/unit_display_names.json` (not versioned). If `dcs.install_path` is configured, the extraction also runs automatically at server startup.

You can also specify the path directly:

```shell
poetry run foothold-sitac extract-unit-names --dcs-path "C:\Program Files\Eagle Dynamics\DCS World"
```

## Run tests

```shell
Expand Down
1 change: 1 addition & 0 deletions config/config.yml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ web:

dcs:
saved_games: "C:\\Users\\veaf\\Saved Games" # !! update your setup
# install_path: "C:\\Program Files\\Eagle Dynamics\\DCS World" # for unit display name extraction

map:

Expand Down
82 changes: 81 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jinja2 = "^3.1.6"
pydantic = "^2.12.4"
lupa = "^2.6"
pyyaml = "^6.0.3"
typer = "^0.24.1"


[tool.poetry.group.dev.dependencies]
Expand All @@ -34,6 +35,9 @@ ruff = "^0.8"
mypy = "^1.14"
types-pyyaml = "^6.0.12.20250915"

[tool.poetry.scripts]
foothold-sitac = "foothold_sitac.console:app"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Expand Down
1 change: 1 addition & 0 deletions src/foothold_sitac/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class WebConfig(BaseModel):

class DcsConfig(BaseModel):
saved_games: str = "var" # "DCS Saved Games Path"
install_path: str | None = None # DCS World installation path (for unit name extraction)


class TileLayerConfig(BaseModel):
Expand Down
65 changes: 65 additions & 0 deletions src/foothold_sitac/console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""CLI entry point for Foothold Sitac.

Usage::

foothold-sitac serve
foothold-sitac extract-unit-names --dcs-path "C:\\..."
"""

from pathlib import Path
from typing import Annotated, Optional

import typer
import uvicorn

from foothold_sitac.config import get_config

app = typer.Typer(name="foothold-sitac", help="Foothold Sitac — tactical map server for DCS World")


@app.command()
def serve() -> None:
"""Start the Foothold Sitac web server."""
config = get_config()
uvicorn.run(
"foothold_sitac.main:app",
host=config.web.host,
port=config.web.port,
reload=config.web.reload,
)


@app.command()
def extract_unit_names(
dcs_path: Annotated[
Optional[str],
typer.Option(help="Path to DCS World installation (reads dcs.install_path from config if not set)"),
] = None,
output: Annotated[
Optional[Path],
typer.Option(help="Output JSON file path"),
] = None,
) -> None:
"""Extract unit type DisplayNames from a DCS World installation."""
from foothold_sitac.unit_names import UNIT_NAMES_PATH, extract_all, save_unit_display_names

resolved_path = dcs_path or get_config().dcs.install_path
if not resolved_path:
typer.echo("Error: No DCS path provided. Use --dcs-path or set dcs.install_path in config.yml", err=True)
raise typer.Exit(code=1)

dcs = Path(resolved_path)
if not dcs.exists():
typer.echo(f"Error: DCS path does not exist: {dcs}", err=True)
raise typer.Exit(code=1)

typer.echo(f"Extracting unit names from: {dcs}")
mappings = extract_all(dcs)

if not mappings:
typer.echo("Warning: No unit mappings found. Check the DCS path.", err=True)
raise typer.Exit(code=1)

out = output or UNIT_NAMES_PATH
save_unit_display_names(mappings, out)
typer.echo(f"Written {len(mappings)} unit display names to {out}")
12 changes: 12 additions & 0 deletions src/foothold_sitac/foothold_api_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from foothold_sitac.config import get_config
from foothold_sitac.dependencies import get_active_sitac
from foothold_sitac.foothold import Sitac, list_servers, parse_coordinates_from_text
from foothold_sitac.unit_names import get_unit_display_names
from foothold_sitac.schemas import (
MapConnection,
MapData,
Expand Down Expand Up @@ -133,6 +134,16 @@ async def foothold_get_map_data(
# Build FARPs list
farps = [MapFarp(name=farp.name, lat=farp.latitude, lon=farp.longitude) for farp in sitac.farps]

# Build unit display names (filtered to only types present in this sitac)
# Keys in the mapping are lowercase; lookup uses .lower()
all_unit_types: set[str] = set()
if show_forces:
for zone in sitac.zones.values():
for units_dict in zone.remaining_units.values():
all_unit_types.update(units_dict.values())
full_mapping = get_unit_display_names()
unit_display_names = {t: full_mapping[t.lower()] for t in all_unit_types if t.lower() in full_mapping}

return MapData(
updated_at=sitac.updated_at,
age_seconds=age_seconds,
Expand All @@ -149,4 +160,5 @@ async def foothold_get_map_data(
red_credits=sitac.accounts.red,
blue_credits=sitac.accounts.blue,
show_zone_forces=show_forces,
unit_display_names=unit_display_names,
)
48 changes: 47 additions & 1 deletion src/foothold_sitac/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from importlib.resources import files
from pathlib import Path

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
Expand All @@ -8,11 +12,53 @@
from foothold_sitac.foothold_api_router import router as foothold_api_router
from foothold_sitac.foothold_router import router as foothold_router
from foothold_sitac.templater import env
from foothold_sitac.unit_names import UNIT_NAMES_PATH, get_unit_display_names, refresh_unit_display_names

logger = logging.getLogger(__name__)


def _configure_logging() -> None:
"""Ensure the foothold_sitac logger is visible alongside uvicorn output."""
root = logging.getLogger("foothold_sitac")
if not root.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
root.addHandler(handler)
root.setLevel(logging.INFO)


@asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncGenerator[None]:
"""Startup / shutdown lifecycle hook."""
_configure_logging()
config = get_config()
dcs_install = config.dcs.install_path

if dcs_install and Path(dcs_install).exists():
logger.info("DCS install path configured: %s", dcs_install)
count = refresh_unit_display_names(Path(dcs_install))
if count:
logger.info("Refreshed %d unit display names at startup", count)
get_unit_display_names.cache_clear()
else:
logger.warning("No unit mappings extracted from %s", dcs_install)
elif dcs_install:
Comment on lines +37 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): Full unit-name extraction on every startup may be very slow for large DCS installs.

Calling refresh_unit_display_names on every startup will repeatedly scan a large portion of the DCS install tree (potentially thousands of Lua files), which can noticeably slow startup or cause timeouts in constrained environments. Consider limiting this to cases where the JSON file is missing, making it configurable, or moving the heavy extraction to an explicit CLI-only command while startup only loads the existing JSON file.

Suggested change
if dcs_install and Path(dcs_install).exists():
logger.info("DCS install path configured: %s", dcs_install)
count = refresh_unit_display_names(Path(dcs_install))
if count:
logger.info("Refreshed %d unit display names at startup", count)
get_unit_display_names.cache_clear()
else:
logger.warning("No unit mappings extracted from %s", dcs_install)
elif dcs_install:
if dcs_install and Path(dcs_install).exists():
logger.info("DCS install path configured: %s", dcs_install)
if UNIT_NAMES_PATH.exists():
names = get_unit_display_names()
logger.info("Using existing unit display names file (%d entries)", len(names))
else:
count = refresh_unit_display_names(Path(dcs_install))
if count:
logger.info("Refreshed %d unit display names at startup", count)
get_unit_display_names.cache_clear()
else:
logger.warning("No unit mappings extracted from %s", dcs_install)
elif dcs_install:

logger.warning("DCS install path configured but does not exist: %s", dcs_install)
else:
logger.info("Répertoire DCS non configuré (dcs.install_path)")
if UNIT_NAMES_PATH.exists():
names = get_unit_display_names()
logger.info("Using existing unit display names file (%d entries)", len(names))
else:
logger.info("No unit display names available — unit codes will be shown as-is")

yield


config = get_config()

static_path = files("foothold_sitac") / "static"
app = FastAPI(title=config.web.title, version="0.1.0", description="Foothold Web Sitac")
app = FastAPI(title=config.web.title, version="0.1.0", description="Foothold Web Sitac", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=str(static_path)), name="static")


Expand Down
1 change: 1 addition & 0 deletions src/foothold_sitac/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,4 @@ class MapData(BaseModel):
red_credits: float = 0
blue_credits: float = 0
show_zone_forces: bool = True
unit_display_names: dict[str, str] = Field(default_factory=dict)
6 changes: 5 additions & 1 deletion src/foothold_sitac/static/js/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ var ejectionsData = [];
var markpointsData = [];
var missionsData = [];
var farpsData = [];
var unitDisplayNames = {};

// Freshness widget state (REFRESH_INTERVAL is set by the page from config)
var REFRESH_INTERVAL = 60;
Expand Down Expand Up @@ -178,7 +179,9 @@ function openZoneModal(zone) {
var unitList = [];
for (var unitType in group.units) {
var count = group.units[unitType];
unitList.push(unitType + (count > 1 ? ' x' + count : ''));
var displayName = unitDisplayNames[unitType];
var titleAttr = displayName ? ' title="' + displayName + '"' : '';
unitList.push('<span' + titleAttr + '>' + unitType + (count > 1 ? ' x' + count : '') + '</span>');
}
html += '<div style="padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.05);">';
html += '<span style="color: #8892a0; font-size: 12px;">Group ' + group.group_id + '</span><br>';
Expand Down Expand Up @@ -592,6 +595,7 @@ function loadData() {
ejectionsData = data.ejected_pilots || [];
missionsData = data.missions || [];
farpsData = data.farps || [];
unitDisplayNames = data.unit_display_names || {};
updateConnections();
updatePlayers();
updateEjections();
Expand Down
Loading