diff --git a/README.md b/README.md index 4527551..2b09203 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/config.yml.dist b/config/config.yml.dist index 2dee1f0..76c9778 100644 --- a/config/config.yml.dist +++ b/config/config.yml.dist @@ -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: diff --git a/poetry.lock b/poetry.lock index a381209..76d73c0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -378,6 +378,29 @@ files = [ {file = "lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9"}, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + [[package]] name = "markupsafe" version = "3.0.3" @@ -476,6 +499,17 @@ files = [ {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "mypy" version = "1.19.0" @@ -855,6 +889,24 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "rich" +version = "14.3.3" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, + {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "ruff" version = "0.8.6" @@ -882,6 +934,17 @@ files = [ {file = "ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5"}, ] +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "starlette" version = "0.50.0" @@ -900,6 +963,23 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\"" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +[[package]] +name = "typer" +version = "0.24.1" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.10" +files = [ + {file = "typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e"}, + {file = "typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +click = ">=8.2.1" +rich = ">=12.3.0" +shellingham = ">=1.3.0" + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -957,4 +1037,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "6a14372e0149b4bd599a7c9f2bf2920d05f8a9e5c0a0cd30ff76b819b99a963f" +content-hash = "0bab27931584db4dca4d9a8d33a12c07a02d2d7475f11a3849d08187966913ae" diff --git a/pyproject.toml b/pyproject.toml index 38f8b90..a90f639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] @@ -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" diff --git a/src/foothold_sitac/config.py b/src/foothold_sitac/config.py index b0515f8..3afef41 100644 --- a/src/foothold_sitac/config.py +++ b/src/foothold_sitac/config.py @@ -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): diff --git a/src/foothold_sitac/console.py b/src/foothold_sitac/console.py new file mode 100644 index 0000000..5047511 --- /dev/null +++ b/src/foothold_sitac/console.py @@ -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}") diff --git a/src/foothold_sitac/foothold_api_router.py b/src/foothold_sitac/foothold_api_router.py index 617753c..faf6af1 100644 --- a/src/foothold_sitac/foothold_api_router.py +++ b/src/foothold_sitac/foothold_api_router.py @@ -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, @@ -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, @@ -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, ) diff --git a/src/foothold_sitac/main.py b/src/foothold_sitac/main.py index f514d25..f68fce7 100644 --- a/src/foothold_sitac/main.py +++ b/src/foothold_sitac/main.py @@ -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 @@ -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: + 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") diff --git a/src/foothold_sitac/schemas.py b/src/foothold_sitac/schemas.py index 8963139..4d3ce52 100644 --- a/src/foothold_sitac/schemas.py +++ b/src/foothold_sitac/schemas.py @@ -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) diff --git a/src/foothold_sitac/static/js/map.js b/src/foothold_sitac/static/js/map.js index 961a527..83a8731 100644 --- a/src/foothold_sitac/static/js/map.js +++ b/src/foothold_sitac/static/js/map.js @@ -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; @@ -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('' + unitType + (count > 1 ? ' x' + count : '') + ''); } html += '