From a5e075c764a44d7a5f025a263ded93a82bf42c70 Mon Sep 17 00:00:00 2001 From: Michel NAUD Date: Tue, 24 Mar 2026 20:43:08 +0100 Subject: [PATCH] WIP on unit names --- README.md | 23 ++++ config/config.yml.dist | 1 + poetry.lock | 82 +++++++++++++- pyproject.toml | 4 + src/foothold_sitac/config.py | 1 + src/foothold_sitac/console.py | 65 +++++++++++ src/foothold_sitac/foothold_api_router.py | 12 ++ src/foothold_sitac/main.py | 48 +++++++- src/foothold_sitac/schemas.py | 1 + src/foothold_sitac/static/js/map.js | 6 +- src/foothold_sitac/unit_names.py | 132 ++++++++++++++++++++++ tests/units/test_unit_names.py | 32 ++++++ 12 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 src/foothold_sitac/console.py create mode 100644 src/foothold_sitac/unit_names.py create mode 100644 tests/units/test_unit_names.py 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 += '
'; html += 'Group ' + group.group_id + '
'; @@ -592,6 +595,7 @@ function loadData() { ejectionsData = data.ejected_pilots || []; missionsData = data.missions || []; farpsData = data.farps || []; + unitDisplayNames = data.unit_display_names || {}; updateConnections(); updatePlayers(); updateEjections(); diff --git a/src/foothold_sitac/unit_names.py b/src/foothold_sitac/unit_names.py new file mode 100644 index 0000000..499e21c --- /dev/null +++ b/src/foothold_sitac/unit_names.py @@ -0,0 +1,132 @@ +"""DCS unit type to display name translation. + +Loads a static JSON mapping of DCS internal unit type codes to +human-readable display names from var/unit_display_names.json. +The file is generated via the CLI: ``foothold-sitac extract-unit-names``. + +Keys are stored in **lowercase** to avoid case-mismatch issues at lookup time. +""" + +import json +import logging +import re +from functools import cache +from pathlib import Path + +logger = logging.getLogger(__name__) + +UNIT_NAMES_PATH = Path("var/unit_display_names.json") + +# --------------------------------------------------------------------------- +# Extraction helpers (used by the CLI command and startup auto-refresh) +# --------------------------------------------------------------------------- + +# Regex patterns for DCS Lua unit definitions +_TYPE_PATTERN = re.compile(r'(?:type|Name)\s*=\s*"([^"]+)"') +_DISPLAY_NAME_PATTERN = re.compile(r'DisplayName\s*=\s*_\(\s*"([^"]+)"\s*\)') + +# Glob patterns that match Lua files likely to contain unit definitions. +# This avoids scanning thousands of irrelevant Lua files (cockpit, UI, …). +_UNIT_FILE_GLOBS = [ + "Scripts/Database/**/*.lua", + "CoreMods/**/Database/**/*.lua", + "CoreMods/**/Entry/**/*.lua", + "CoreMods/**/Entries/**/*.lua", + "CoreMods/aircraft/*/*.lua", + "CoreMods/WWII Units/*/*.lua", + "Mods/tech/**/Database/**/*.lua", + "Mods/tech/**/Entries/**/*.lua", +] + + +def extract_from_file(filepath: Path) -> dict[str, str]: + """Extract type -> DisplayName pairs from a single Lua file. + + Uses a proximity-based approach: for each DisplayName found, + looks backward for the nearest type/Name declaration within + a reasonable range (same logical block). + Keys are lowercased. + """ + try: + content = filepath.read_text(encoding="utf-8", errors="replace") + except OSError: + return {} + + mappings: dict[str, str] = {} + + for dn_match in _DISPLAY_NAME_PATTERN.finditer(content): + display_name = dn_match.group(1) + dn_pos = dn_match.start() + + search_start = max(0, dn_pos - 2000) + search_region = content[search_start:dn_pos] + + type_matches = list(_TYPE_PATTERN.finditer(search_region)) + if type_matches: + type_name = type_matches[-1].group(1) + if len(type_name) < 100 and "/" not in type_name and "\\" not in type_name: + mappings[type_name.lower()] = display_name + + return mappings + + +def extract_all(dcs_path: Path) -> dict[str, str]: + """Scan a DCS installation directory and extract all unit type mappings. + + Returns a sorted dict ``{type_name_lower: display_name, ...}``. + """ + all_mappings: dict[str, str] = {} + seen_files: set[Path] = set() + + for pattern in _UNIT_FILE_GLOBS: + for lua_file in dcs_path.glob(pattern): + if lua_file in seen_files: + continue + seen_files.add(lua_file) + all_mappings.update(extract_from_file(lua_file)) + + logger.info("Scanned %d files, found %d unit type mappings", len(seen_files), len(all_mappings)) + return dict(sorted(all_mappings.items())) + + +def save_unit_display_names(mappings: dict[str, str], output: Path = UNIT_NAMES_PATH) -> None: + """Write the mapping dict as sorted JSON.""" + output.parent.mkdir(parents=True, exist_ok=True) + with open(output, "w", encoding="utf-8") as f: + json.dump(mappings, f, indent=2, ensure_ascii=False) + logger.info("Written %d unit display names to %s", len(mappings), output) + + +def refresh_unit_display_names(dcs_path: Path) -> int: + """Extract unit names from *dcs_path* and save to the default JSON file. + + Returns the number of mappings written. + """ + mappings = extract_all(dcs_path) + if mappings: + save_unit_display_names(mappings) + return len(mappings) + + +# --------------------------------------------------------------------------- +# Runtime lookup (used by the API router) +# --------------------------------------------------------------------------- + + +@cache +def get_unit_display_names() -> dict[str, str]: + """Load unit type -> display name mapping from JSON file. + + Keys are lowercase. Returns an empty dict with a warning if the file + does not exist. + """ + if not UNIT_NAMES_PATH.exists(): + logger.warning( + "Unit display names file not found at %s. Run 'foothold-sitac extract-unit-names' to generate it.", + UNIT_NAMES_PATH, + ) + return {} + with open(UNIT_NAMES_PATH, encoding="utf-8") as f: + mapping: dict[str, str] = json.load(f) + logger.info("Loaded %d unit display names from %s", len(mapping), UNIT_NAMES_PATH) + return mapping diff --git a/tests/units/test_unit_names.py b/tests/units/test_unit_names.py new file mode 100644 index 0000000..b6436be --- /dev/null +++ b/tests/units/test_unit_names.py @@ -0,0 +1,32 @@ +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +from foothold_sitac.unit_names import get_unit_display_names + + +def test_get_unit_display_names_missing_file() -> None: + """Returns empty dict and logs warning when file does not exist.""" + get_unit_display_names.cache_clear() + with patch("foothold_sitac.unit_names.UNIT_NAMES_PATH", Path("/nonexistent/path.json")): + result = get_unit_display_names() + assert result == {} + get_unit_display_names.cache_clear() + + +def test_get_unit_display_names_loads_file() -> None: + """Returns mapping from JSON file when it exists.""" + get_unit_display_names.cache_clear() + mapping = {"T-72B3": "T-72B3", "M1A2C_SEP_V3": "M1A2 SEP V3 Abrams"} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(mapping, f) + tmp_path = Path(f.name) + + try: + with patch("foothold_sitac.unit_names.UNIT_NAMES_PATH", tmp_path): + result = get_unit_display_names() + assert result == mapping + finally: + tmp_path.unlink(missing_ok=True) + get_unit_display_names.cache_clear()