diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51319c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main, "claude/**"] + pull_request: + branches: [main] + +jobs: + test: + name: Smoke tests — Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Run tests + run: python -m pytest tests/ -v --tb=short + + lint: + name: Import sanity check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Check all modules import cleanly + run: | + python -c " + import sys, importlib, pathlib + sys.path.insert(0, '.') + failures = [] + for f in pathlib.Path('src').rglob('*.py'): + mod = str(f).replace('/', '.').replace('.py', '') + try: + importlib.import_module(mod) + except Exception as e: + failures.append(f'{mod}: {e}') + if failures: + print('IMPORT FAILURES:') + for fail in failures: + print(' ', fail) + sys.exit(1) + print(f'All {len(list(pathlib.Path(\"src\").rglob(\"*.py\")))} modules import cleanly.') + " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1d7150 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +dist/ +build/ +*.spec +output/ +mission_history.json +*.miz +*.txt.bak diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..542363d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# DCS Mission Manager — Claude Context + +## Project Overview + +DCS Mission Generator v2.0 is a Python tool that generates realistic DCS World `.miz` mission files from plain-English descriptions. Users describe a mission (e.g., "SEAD mission in the F-16 on Caucasus with SA-11s") and the tool builds a complete, playable mission with waypoints, threat placement, friendly packages, briefings, and win/loss conditions. + +**No external Python dependencies** — stdlib only, with optional Ollama (local LLM) for natural language parsing. + +## Running the Project + +```bash +# Console interface +python main.py + +# Headless generation (no interactive prompt) +python main.py --generate "SEAD in the F-16 on Persian Gulf, hard" + +# GUI interface (tkinter) +python gui.py + +# Run tests +python -m pytest tests/ -v + +# Build standalone Windows EXE +build_exe.bat +``` + +**Console commands:** `quick `, `maps`, `examples`, `history`, `campaign`, `settings`, `quit` + +## Architecture + +``` +main.py Console entry point +gui.py GUI entry point (tkinter) +tests/ + test_smoke.py 22-test smoke suite (units, maps, builder, kneeboard) +src/ + generators/ + mission_builder.py Core: plan → mission data structures + lua_generator.py Mission data → DCS Lua table files + miz_packager.py Lua files → .miz zip archive (incl. kneeboard PNG) + briefing_generator.py 13-section tactical briefing generator + kneeboard_generator.py Pure-stdlib PNG renderer for DCS kneeboard cards + llm/ + ollama_client.py Ollama REST API client + mission_parser.py Natural language → structured plan (LLM + fallback) + maps/ + caucasus.py Caucasus map + syria.py Syria map + cold_war_germany.py Cold War Germany map + persian_gulf.py Persian Gulf map (UAE, Iran, Strait of Hormuz) + mariana_islands.py Mariana Islands map (Guam, Saipan, Pacific carrier ops) + units.py Aircraft, SAM, ground unit databases + difficulty.py Easy/Medium/Hard scaling logic + naming.py Operation name generator + callsigns.py Callsign and frequency assignment + flight_profile.py Altitude/speed/fuel calculations + mission_events.py Radio messages, win/loss, reinforcements + campaign.py Campaign system with debrief logic + custom_mods.py JSON mod loader for custom aircraft + dcs_detect.py DCS install auto-detection and deploy + validator.py Pre-flight mission validation + history.py Mission log tracker (JSON) +custom_aircraft/ User-extensible aircraft mod configs (JSON) +``` + +## Key Concepts + +- **Mission plan**: A structured dict produced by `mission_parser.py` from user input — contains aircraft, map, task type, difficulty, threat types, etc. +- **Mission builder**: `src/generators/mission_builder.py` takes a plan and produces DCS-ready data structures (waypoints, units, groups, etc.). +- **Lua generator + packager**: Converts data structures to DCS Lua tables, then zips them into a `.miz` file. The packager also renders a kneeboard PNG card and places it inside `KNEEBOARD//` in the archive. +- **Custom aircraft**: Drop a JSON file in `custom_aircraft/` following `_template.json` to add modded aircraft without editing source. +- **Multiplayer**: Set `player_count` > 1 to generate co-op slots (slot 1 = `Player` skill, additional slots = `Client` skill for DCS multiplayer). + +## Player Aircraft + +F-16C Viper, F/A-18C Hornet, F-15C Eagle, F-15E Strike Eagle, AV-8B Harrier II, Mirage 2000C, A-10C II Thunderbolt, JF-17 Thunder, AH-64D Apache — plus any custom mods in `custom_aircraft/`. + +## Maps + +Caucasus, Syria, Cold War Germany, Persian Gulf, Mariana Islands. + +## Mission Types + +SEAD, CAS, CAP, Strike, Anti-Ship, Escort, Convoy Attack, Convoy Defense, CSAR, FAC(A). + +## Code Style + +- Python 3.10+, snake_case functions/variables, CamelCase classes +- Type hints used throughout (e.g., `str | None`, `list[dict]`) +- Module-level docstrings on all source files +- No linting or formatting tools are configured — follow existing style + +## Testing + +```bash +python -m pytest tests/ -v +``` + +22 smoke tests cover: unit registry, map registry, flight profiles, mission builder (incl. multiplayer slots), kneeboard PNG generation, naming, and difficulty scaling. Run these after any change to verify nothing is broken. + +## Output + +Generated `.miz` files are saved to the current directory (or auto-deployed to DCS Saved Games if DCS is detected). Each `.miz` includes an embedded kneeboard card PNG. `mission_history.json` is auto-created to log generated missions. diff --git a/README.md b/README.md index 1e01fe4..db985c6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Generate realistic DCS World `.miz` mission files from plain English description ## Quick Start ``` -cd dcs-mission-gen +cd DCS-Mission-Manager python main.py DCS-MG> SEAD mission in the F-16 on Caucasus with SA-11s. Hard difficulty. ``` @@ -172,14 +172,13 @@ Run `python gui.py` for a dark military-themed desktop interface with: ## Project Structure ``` -dcs-mission-gen/ +DCS-Mission-Manager/ ├── main.py Console entry point ├── gui.py GUI entry point (tkinter) ├── build_exe.bat Windows EXE builder ├── custom_aircraft/ Drop mod JSON configs here │ ├── _template.json Template with instructions │ └── F-22A.json F-22 Raptor mod (included) -├── output/ Generated .miz files └── src/ ├── units.py Aircraft, SAM, ground unit databases ├── difficulty.py Easy/medium/hard scaling @@ -205,3 +204,11 @@ dcs-mission-gen/ ├── miz_packager.py Lua files → .miz zip archive └── briefing_generator.py Full 13-section tactical briefing ``` + +## Output + +Generated `.miz` files are saved to the `output/` directory by default. If DCS is detected, the tool offers to auto-deploy directly to your `Saved Games/DCS/Missions/Generated/` folder. + +## License + +MIT License — © 2026 2DoorDevelopment diff --git a/gui.py b/gui.py index c88c73c..f08ad3e 100644 --- a/gui.py +++ b/gui.py @@ -7,17 +7,14 @@ import tkinter as tk from tkinter import ttk, scrolledtext, messagebox import threading -import time import os import sys from pathlib import Path # Add project root to path if getattr(sys, 'frozen', False): - # Running as PyInstaller bundle BASE_DIR = Path(sys.executable).parent sys.path.insert(0, str(BASE_DIR)) - # PyInstaller extracts data files to a temp dir BUNDLE_DIR = Path(sys._MEIPASS) if hasattr(sys, '_MEIPASS') else BASE_DIR else: BASE_DIR = Path(os.path.dirname(os.path.abspath(__file__))) @@ -40,43 +37,90 @@ OUTPUT_DIR = BASE_DIR / "output" OUTPUT_DIR.mkdir(exist_ok=True) -# ══════════════════════════════════════════════════════════ -# COLORS — dark military theme -# ══════════════════════════════════════════════════════════ -COLORS = { - "bg_dark": "#0a0e14", - "bg_panel": "#111820", - "bg_input": "#1a2230", - "bg_button": "#1e3a2f", - "bg_button_hover": "#2a5440", - "bg_button_generate": "#2d5a1e", - "bg_button_generate_hover": "#3d7a2e", - "bg_accent": "#1a3045", - "fg_main": "#c8d6e5", - "fg_dim": "#6b7b8d", - "fg_bright": "#e8f0f8", - "fg_accent": "#4ecdc4", - "fg_warning": "#f0a030", - "fg_success": "#4ecdc4", - "fg_error": "#e74c3c", - "border": "#2a3a4a", - "highlight": "#3a5a3a", +# ────────────────────────────────────────────────────────────── +# PALETTE — dark military, slightly modernized +# ────────────────────────────────────────────────────────────── +C = { + "bg": "#0d1117", # near-black page background + "surface": "#161b22", # card / panel surface + "surface2": "#1c2333", # slightly lighter surface (inputs, hover) + "border": "#30363d", # subtle border + "accent": "#4ecdc4", # teal — primary accent + "accent2": "#3ab8b0", # slightly darker teal for hover + "green": "#2ea043", # generate button + "green_h": "#3fb452", # generate button hover + "blue_btn": "#1f6feb", # secondary blue button + "blue_h": "#388bfd", # secondary blue hover + "fg": "#c9d1d9", # primary text + "fg_dim": "#8b949e", # dimmed text / labels + "fg_bright": "#f0f6fc", # bright / highlighted text + "fg_success": "#56d364", # success green + "fg_warning": "#e3b341", # warning amber + "fg_error": "#f85149", # error red + "tag_head": "#4ecdc4", # output section headers + "tag_ok": "#56d364", + "tag_err": "#f85149", + "tag_dim": "#8b949e", + "select": "#264f78", # text selection } +FONT_MONO = ("Consolas", 10) +FONT_MONO_SM = ("Consolas", 9) +FONT_MONO_LG = ("Consolas", 13, "bold") +FONT_MONO_MD = ("Consolas", 11, "bold") + + +# ────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────── + +def _sep(parent, color=None): + """Thin horizontal separator line.""" + tk.Frame(parent, bg=color or C["border"], height=1).pack(fill="x") + + +def _hover(btn: tk.Button, normal: str, hover: str): + btn.bind("", lambda e: btn.configure(bg=hover)) + btn.bind("", lambda e: btn.configure(bg=normal)) + + +def _card(parent, title: str = "", accent: str = None) -> tk.Frame: + """ + A card-style container: thin colored top bar, section title, content frame. + Returns the inner content frame to pack widgets into. + """ + outer = tk.Frame(parent, bg=C["border"], bd=0) + outer.pack(fill="x", padx=12, pady=(0, 10)) + + # Accent bar at top + tk.Frame(outer, bg=accent or C["accent"], height=2).pack(fill="x") + + # Header row + if title: + header = tk.Frame(outer, bg=C["surface"]) + header.pack(fill="x") + tk.Label(header, text=title.upper(), bg=C["surface"], fg=C["fg_dim"], + font=("Consolas", 8, "bold"), padx=10, pady=5).pack(side="left") + + # Content area + inner = tk.Frame(outer, bg=C["surface"]) + inner.pack(fill="x") + return inner + class DCSMissionGeneratorGUI: - def __init__(self, root): + def __init__(self, root: tk.Tk): self.root = root - self.root.title("DCS Mission Generator v2.0") - self.root.geometry("900x700") - self.root.minsize(800, 600) - self.root.configure(bg=COLORS["bg_dark"]) + self.root.title("DCS Mission Generator v2.0") + self.root.geometry("980x720") + self.root.minsize(820, 600) + self.root.configure(bg=C["bg"]) - # State self.client = OllamaClient(model="llama3.1:8b") self.ollama_connected = False self.dcs_folder = find_dcs_missions_folder() self.last_miz_path = None + self._progress_running = False # Load custom aircraft custom_dir = ensure_custom_dir() @@ -84,275 +128,317 @@ def __init__(self, root): if custom: register_custom_aircraft(custom) - # Build aircraft and mission type lists from current state - self.aircraft_list = list(PLAYER_AIRCRAFT.keys()) + self.aircraft_list = list(PLAYER_AIRCRAFT.keys()) self.aircraft_display = [PLAYER_AIRCRAFT[k].get("display_name", k) for k in self.aircraft_list] - self.mission_types = list(MISSION_TEMPLATES.keys()) - self.map_list = list(MAP_REGISTRY.keys()) - self.map_display = [MAP_REGISTRY[k]["display_name"] for k in self.map_list] + self.mission_types = list(MISSION_TEMPLATES.keys()) + self.map_list = list(MAP_REGISTRY.keys()) + self.map_display = [MAP_REGISTRY[k]["display_name"] for k in self.map_list] - # Style self._setup_styles() self._build_ui() self._check_ollama() + # ── ttk styles ──────────────────────────────────────────── + def _setup_styles(self): style = ttk.Style() style.theme_use("clam") - style.configure("Dark.TFrame", background=COLORS["bg_dark"]) - style.configure("Panel.TFrame", background=COLORS["bg_panel"]) - style.configure("Dark.TLabel", background=COLORS["bg_dark"], - foreground=COLORS["fg_main"], font=("Consolas", 10)) - style.configure("Header.TLabel", background=COLORS["bg_dark"], - foreground=COLORS["fg_accent"], font=("Consolas", 14, "bold")) - style.configure("Status.TLabel", background=COLORS["bg_panel"], - foreground=COLORS["fg_dim"], font=("Consolas", 9)) - style.configure("Dark.TLabelframe", background=COLORS["bg_panel"], - foreground=COLORS["fg_accent"]) - style.configure("Dark.TLabelframe.Label", background=COLORS["bg_panel"], - foreground=COLORS["fg_accent"], font=("Consolas", 10, "bold")) - style.configure("Dark.TCombobox", fieldbackground=COLORS["bg_input"], - background=COLORS["bg_input"], foreground=COLORS["fg_main"], - selectbackground=COLORS["highlight"], - arrowcolor=COLORS["fg_accent"]) + style.configure("Dark.TCombobox", + fieldbackground=C["surface2"], + background=C["surface2"], + foreground=C["fg"], + selectbackground=C["select"], + arrowcolor=C["accent"], + bordercolor=C["border"], + lightcolor=C["surface2"], + darkcolor=C["surface2"]) style.map("Dark.TCombobox", - fieldbackground=[("readonly", COLORS["bg_input"]), - ("focus", COLORS["bg_input"])], - foreground=[("readonly", COLORS["fg_main"])], - selectbackground=[("readonly", COLORS["highlight"])], - selectforeground=[("readonly", COLORS["fg_bright"])]) - style.configure("Dark.TCheckbutton", background=COLORS["bg_panel"], - foreground=COLORS["fg_main"], font=("Consolas", 10)) - style.map("Dark.TCheckbutton", - background=[("active", COLORS["bg_panel"])]) - - # Fix the dropdown listbox colors on Windows - # This targets the Tk popdown listbox that comboboxes use - self.root.option_add("*TCombobox*Listbox.background", COLORS["bg_input"]) - self.root.option_add("*TCombobox*Listbox.foreground", COLORS["fg_main"]) - self.root.option_add("*TCombobox*Listbox.selectBackground", COLORS["highlight"]) - self.root.option_add("*TCombobox*Listbox.selectForeground", COLORS["fg_bright"]) - self.root.option_add("*TCombobox*Listbox.font", ("Consolas", 10)) + fieldbackground=[("readonly", C["surface2"]), ("focus", C["surface2"])], + foreground=[("readonly", C["fg"])], + selectbackground=[("readonly", C["select"])], + selectforeground=[("readonly", C["fg_bright"])], + bordercolor=[("focus", C["accent"])]) + + style.configure("Accent.TProgressbar", + troughcolor=C["surface2"], + background=C["accent"], + bordercolor=C["surface2"], + lightcolor=C["accent"], + darkcolor=C["accent2"]) + + # Dropdown listbox colors (applied lazily via _style_combobox too) + self.root.option_add("*TCombobox*Listbox.background", C["surface2"]) + self.root.option_add("*TCombobox*Listbox.foreground", C["fg"]) + self.root.option_add("*TCombobox*Listbox.selectBackground", C["select"]) + self.root.option_add("*TCombobox*Listbox.selectForeground", C["fg_bright"]) + self.root.option_add("*TCombobox*Listbox.font", "Consolas 10") + + def _style_combobox(self, cb: ttk.Combobox): + """Also configure the lazy-created popdown listbox directly via Tcl.""" + def _apply(event=None): + try: + popdown = self.root.tk.eval(f"ttk::combobox::PopdownWindow {cb}") + self.root.tk.eval( + f"{popdown}.f.l configure" + f" -background {C['surface2']}" + f" -foreground {C['fg']}" + f" -selectbackground {C['select']}" + f" -selectforeground {C['fg_bright']}" + f" -font {{Consolas 10}}" + ) + except Exception: + pass + cb.bind("", _apply) + + # ── UI construction ─────────────────────────────────────── def _build_ui(self): - # ── Title bar ── - title_frame = tk.Frame(self.root, bg=COLORS["bg_dark"], pady=8) - title_frame.pack(fill="x") - - tk.Label(title_frame, text="DCS MISSION GENERATOR", - bg=COLORS["bg_dark"], fg=COLORS["fg_accent"], - font=("Consolas", 16, "bold")).pack(side="left", padx=15) - - self.status_label = tk.Label(title_frame, text="", - bg=COLORS["bg_dark"], fg=COLORS["fg_dim"], - font=("Consolas", 9)) - self.status_label.pack(side="right", padx=15) - - # ── Main content — two columns ── - main_frame = tk.Frame(self.root, bg=COLORS["bg_dark"]) - main_frame.pack(fill="both", expand=True, padx=10, pady=5) - - # Left column — controls - left = tk.Frame(main_frame, bg=COLORS["bg_panel"], relief="flat", - bd=1, highlightbackground=COLORS["border"], highlightthickness=1) - left.pack(side="left", fill="both", expand=False, padx=(0, 5)) - left.configure(width=340) - left.pack_propagate(False) - - # Right column — output - right = tk.Frame(main_frame, bg=COLORS["bg_panel"], relief="flat", - bd=1, highlightbackground=COLORS["border"], highlightthickness=1) - right.pack(side="right", fill="both", expand=True) - - self._build_controls(left) - self._build_output(right) - - # ── Bottom bar ── - bottom = tk.Frame(self.root, bg=COLORS["bg_dark"], pady=5) - bottom.pack(fill="x") - - self.bottom_status = tk.Label(bottom, text="Ready", - bg=COLORS["bg_dark"], fg=COLORS["fg_dim"], - font=("Consolas", 9)) - self.bottom_status.pack(side="left", padx=15) + self._build_titlebar() + + # ── Two-column body ── + body = tk.Frame(self.root, bg=C["bg"]) + body.pack(fill="both", expand=True, padx=0, pady=0) + + # Left panel — fixed width controls + self._left_panel = tk.Frame(body, bg=C["bg"], width=340) + self._left_panel.pack(side="left", fill="y", padx=(12, 6), pady=8) + self._left_panel.pack_propagate(False) + + # Right panel — expanding output + self._right_panel = tk.Frame(body, bg=C["surface"], + highlightbackground=C["border"], highlightthickness=1) + self._right_panel.pack(side="right", fill="both", expand=True, padx=(6, 12), pady=8) + + self._build_controls(self._left_panel) + self._build_output(self._right_panel) + self._build_statusbar() + + def _build_titlebar(self): + bar = tk.Frame(self.root, bg=C["bg"]) + bar.pack(fill="x", padx=12, pady=(10, 4)) + + tk.Label(bar, text="DCS MISSION GENERATOR", + bg=C["bg"], fg=C["accent"], font=FONT_MONO_LG).pack(side="left") + + tk.Label(bar, text="v2.0", bg=C["bg"], fg=C["fg_dim"], + font=("Consolas", 9)).pack(side="left", padx=(6, 0), pady=(4, 0)) + + # Indicator dots (updated after Ollama check) + self._ind_frame = tk.Frame(bar, bg=C["bg"]) + self._ind_frame.pack(side="right") + + self._ollama_dot = tk.Label(self._ind_frame, text="●", bg=C["bg"], + fg=C["fg_dim"], font=("Consolas", 11)) + self._ollama_dot.pack(side="right", padx=(0, 2)) + tk.Label(self._ind_frame, text="Ollama", bg=C["bg"], fg=C["fg_dim"], + font=FONT_MONO_SM).pack(side="right", padx=(6, 0)) + + self._dcs_dot = tk.Label(self._ind_frame, text="●", bg=C["bg"], + fg=C["fg_success"] if self.dcs_folder else C["fg_dim"], + font=("Consolas", 11)) + self._dcs_dot.pack(side="right", padx=(16, 2)) + tk.Label(self._ind_frame, text="DCS", bg=C["bg"], fg=C["fg_dim"], + font=FONT_MONO_SM).pack(side="right", padx=(6, 0)) + + _sep(self.root) def _build_controls(self, parent): - pad = {"padx": 10, "pady": 3} - - # ── Natural Language Input ── - nl_frame = tk.LabelFrame(parent, text=" DESCRIBE YOUR MISSION ", - bg=COLORS["bg_panel"], fg=COLORS["fg_accent"], - font=("Consolas", 9, "bold"), - relief="flat", bd=1) - nl_frame.pack(fill="x", padx=8, pady=(8, 4)) - - self.nl_input = tk.Text(nl_frame, height=4, wrap="word", - bg=COLORS["bg_input"], fg=COLORS["fg_bright"], - insertbackground=COLORS["fg_accent"], - font=("Consolas", 10), relief="flat", bd=0) - self.nl_input.pack(fill="x", padx=6, pady=6) + # ── Natural language input card ── + nl_inner = _card(parent, "Describe your mission") + self.nl_input = tk.Text(nl_inner, height=4, wrap="word", + bg=C["surface2"], fg=C["fg_bright"], + insertbackground=C["accent"], + font=FONT_MONO, relief="flat", bd=0, + padx=10, pady=8) + self.nl_input.pack(fill="x", padx=1, pady=(0, 1)) self.nl_input.insert("1.0", "SEAD mission in the F-16 on Caucasus with SA-6 and SA-11") + self.nl_input.bind("", lambda e: self.nl_input.configure( + highlightbackground=C["accent"], + highlightthickness=1, highlightcolor=C["accent"])) + self.nl_input.bind("", lambda e: self.nl_input.configure(highlightthickness=0)) - # ── OR: Manual Selection ── - manual_frame = tk.LabelFrame(parent, text=" OR SELECT MANUALLY ", - bg=COLORS["bg_panel"], fg=COLORS["fg_accent"], - font=("Consolas", 9, "bold"), relief="flat", bd=1) - manual_frame.pack(fill="x", padx=8, pady=4) + # ── Manual selection card ── + sel_inner = _card(parent, "Or select manually") + self._build_selectors(sel_inner) + + # ── Options card ── + opt_inner = _card(parent, "Options") + self._build_options(opt_inner) + + # ── Buttons ── + self._build_buttons(parent) + + def _row(self, parent, label: str, row: int) -> tk.Frame: + """Helper: add a label in column 0, return the frame for column 1.""" + tk.Label(parent, text=label, bg=C["surface"], fg=C["fg_dim"], + font=FONT_MONO_SM, anchor="w").grid( + row=row, column=0, sticky="w", padx=(10, 4), pady=4) + cell = tk.Frame(parent, bg=C["surface"]) + cell.grid(row=row, column=1, sticky="ew", padx=(0, 10), pady=4) + return cell + + def _combo(self, parent, var: tk.StringVar, values: list) -> ttk.Combobox: + cb = ttk.Combobox(parent, textvariable=var, values=values, + state="readonly", style="Dark.TCombobox") + cb.pack(fill="x") + self._style_combobox(cb) + return cb + + def _build_selectors(self, parent): + parent.columnconfigure(1, weight=1) - # Aircraft - tk.Label(manual_frame, text="Aircraft:", bg=COLORS["bg_panel"], - fg=COLORS["fg_main"], font=("Consolas", 9)).grid( - row=0, column=0, sticky="w", **pad) self.aircraft_var = tk.StringVar(value=self.aircraft_display[0]) - aircraft_cb = ttk.Combobox(manual_frame, textvariable=self.aircraft_var, - values=self.aircraft_display, state="readonly", - width=22, style="Dark.TCombobox") - aircraft_cb.grid(row=0, column=1, sticky="ew", **pad) - - # Map - tk.Label(manual_frame, text="Map:", bg=COLORS["bg_panel"], - fg=COLORS["fg_main"], font=("Consolas", 9)).grid( - row=1, column=0, sticky="w", **pad) + self._combo(self._row(parent, "Aircraft", 0), self.aircraft_var, self.aircraft_display) + self.map_var = tk.StringVar(value=self.map_display[0]) - map_cb = ttk.Combobox(manual_frame, textvariable=self.map_var, - values=self.map_display, state="readonly", - width=22, style="Dark.TCombobox") - map_cb.grid(row=1, column=1, sticky="ew", **pad) - - # Mission type - tk.Label(manual_frame, text="Mission:", bg=COLORS["bg_panel"], - fg=COLORS["fg_main"], font=("Consolas", 9)).grid( - row=2, column=0, sticky="w", **pad) + self._combo(self._row(parent, "Map", 1), self.map_var, self.map_display) + self.mission_var = tk.StringVar(value="SEAD") - mission_cb = ttk.Combobox(manual_frame, textvariable=self.mission_var, - values=self.mission_types, state="readonly", - width=22, style="Dark.TCombobox") - mission_cb.grid(row=2, column=1, sticky="ew", **pad) - - # Difficulty - tk.Label(manual_frame, text="Difficulty:", bg=COLORS["bg_panel"], - fg=COLORS["fg_main"], font=("Consolas", 9)).grid( - row=3, column=0, sticky="w", **pad) + self._combo(self._row(parent, "Mission", 2), self.mission_var, self.mission_types) + self.difficulty_var = tk.StringVar(value="medium") - diff_cb = ttk.Combobox(manual_frame, textvariable=self.difficulty_var, - values=["easy", "medium", "hard"], state="readonly", - width=22, style="Dark.TCombobox") - diff_cb.grid(row=3, column=1, sticky="ew", **pad) - - # Weather - tk.Label(manual_frame, text="Weather:", bg=COLORS["bg_panel"], - fg=COLORS["fg_main"], font=("Consolas", 9)).grid( - row=4, column=0, sticky="w", **pad) + self._combo(self._row(parent, "Difficulty", 3), self.difficulty_var, + ["easy", "medium", "hard"]) + self.weather_var = tk.StringVar(value="clear") - weather_cb = ttk.Combobox(manual_frame, textvariable=self.weather_var, - values=["clear", "scattered", "overcast", "rain", "storm"], - state="readonly", width=22, style="Dark.TCombobox") - weather_cb.grid(row=4, column=1, sticky="ew", **pad) - - # Time - tk.Label(manual_frame, text="Time:", bg=COLORS["bg_panel"], - fg=COLORS["fg_main"], font=("Consolas", 9)).grid( - row=5, column=0, sticky="w", **pad) - self.time_var = tk.StringVar(value="morning") - time_cb = ttk.Combobox(manual_frame, textvariable=self.time_var, - values=["morning", "afternoon", "evening", "night"], - state="readonly", width=22, style="Dark.TCombobox") - time_cb.grid(row=5, column=1, sticky="ew", **pad) - - manual_frame.columnconfigure(1, weight=1) - - # ── Options ── - opt_frame = tk.LabelFrame(parent, text=" OPTIONS ", - bg=COLORS["bg_panel"], fg=COLORS["fg_accent"], - font=("Consolas", 9, "bold"), relief="flat", bd=1) - opt_frame.pack(fill="x", padx=8, pady=4) - - self.wingman_var = tk.BooleanVar(value=True) - tk.Checkbutton(opt_frame, text="Include wingman", - variable=self.wingman_var, - bg=COLORS["bg_panel"], fg=COLORS["fg_main"], - selectcolor=COLORS["bg_input"], - activebackground=COLORS["bg_panel"], - font=("Consolas", 9)).pack(anchor="w", padx=10, pady=2) - - self.ground_war_var = tk.BooleanVar(value=True) - tk.Checkbutton(opt_frame, text="Ground war active", - variable=self.ground_war_var, - bg=COLORS["bg_panel"], fg=COLORS["fg_main"], - selectcolor=COLORS["bg_input"], - activebackground=COLORS["bg_panel"], - font=("Consolas", 9)).pack(anchor="w", padx=10, pady=2) + self._combo(self._row(parent, "Weather", 4), self.weather_var, + ["clear", "scattered", "overcast", "rain", "storm"]) + self.time_var = tk.StringVar(value="morning") + self._combo(self._row(parent, "Time", 5), self.time_var, + ["morning", "afternoon", "evening", "night"]) + + self.players_var = tk.StringVar(value="1") + self._combo(self._row(parent, "Players", 6), self.players_var, + ["1", "2", "3", "4"]) + + def _build_options(self, parent): + def _check(text, var): + tk.Checkbutton(parent, text=text, variable=var, + bg=C["surface"], fg=C["fg"], activebackground=C["surface"], + activeforeground=C["fg_bright"], selectcolor=C["surface2"], + font=FONT_MONO_SM, cursor="hand2").pack( + anchor="w", padx=10, pady=2) + + self.wingman_var = tk.BooleanVar(value=True) + self.ground_war_var = tk.BooleanVar(value=True) self.auto_deploy_var = tk.BooleanVar(value=bool(self.dcs_folder)) - tk.Checkbutton(opt_frame, text="Auto-deploy to DCS", - variable=self.auto_deploy_var, - bg=COLORS["bg_panel"], fg=COLORS["fg_main"], - selectcolor=COLORS["bg_input"], - activebackground=COLORS["bg_panel"], - font=("Consolas", 9)).pack(anchor="w", padx=10, pady=2) - # ── Buttons ── - btn_frame = tk.Frame(parent, bg=COLORS["bg_panel"]) - btn_frame.pack(fill="x", padx=8, pady=(8, 4)) + _check("Include wingman", self.wingman_var) + _check("Ground war active", self.ground_war_var) + _check("Auto-deploy to DCS", self.auto_deploy_var) + tk.Frame(parent, bg=C["surface"], height=4).pack() + + def _build_buttons(self, parent): + tk.Frame(parent, bg=C["bg"], height=4).pack() + # Primary — generate from description self.gen_nl_btn = tk.Button( - btn_frame, text="⚡ GENERATE FROM DESCRIPTION", + parent, text="⚡ GENERATE FROM DESCRIPTION", command=self._on_generate_nl, - bg=COLORS["bg_button_generate"], fg=COLORS["fg_bright"], - activebackground=COLORS["bg_button_generate_hover"], - activeforeground=COLORS["fg_bright"], - font=("Consolas", 10, "bold"), relief="flat", bd=0, pady=8) - self.gen_nl_btn.pack(fill="x", pady=(0, 4)) + bg=C["green"], fg=C["fg_bright"], activeforeground=C["fg_bright"], + activebackground=C["green_h"], font=FONT_MONO_MD, + relief="flat", bd=0, pady=10, cursor="hand2") + self.gen_nl_btn.pack(fill="x") + _hover(self.gen_nl_btn, C["green"], C["green_h"]) + + tk.Frame(parent, bg=C["bg"], height=5).pack() + # Secondary — generate from selections self.gen_manual_btn = tk.Button( - btn_frame, text="▶ GENERATE FROM SELECTIONS", + parent, text="▶ GENERATE FROM SELECTIONS", command=self._on_generate_manual, - bg=COLORS["bg_button"], fg=COLORS["fg_main"], - activebackground=COLORS["bg_button_hover"], - activeforeground=COLORS["fg_bright"], - font=("Consolas", 10), relief="flat", bd=0, pady=6) - self.gen_manual_btn.pack(fill="x", pady=(0, 4)) + bg=C["surface2"], fg=C["fg"], activeforeground=C["fg_bright"], + activebackground=C["surface2"], font=FONT_MONO, + relief="flat", bd=0, pady=8, cursor="hand2", + highlightbackground=C["border"], highlightthickness=1) + self.gen_manual_btn.pack(fill="x") + _hover(self.gen_manual_btn, C["surface2"], C["border"]) + tk.Frame(parent, bg=C["bg"], height=5).pack() + + # Utility — open folder self.open_folder_btn = tk.Button( - btn_frame, text="📁 Open Output Folder", + parent, text="📁 Open Output Folder", command=self._on_open_folder, - bg=COLORS["bg_accent"], fg=COLORS["fg_main"], - activebackground=COLORS["bg_button_hover"], - font=("Consolas", 9), relief="flat", bd=0, pady=4) + bg=C["bg"], fg=C["fg_dim"], activeforeground=C["fg"], + activebackground=C["surface2"], font=FONT_MONO_SM, + relief="flat", bd=0, pady=5, cursor="hand2") self.open_folder_btn.pack(fill="x") + _hover(self.open_folder_btn, C["bg"], C["surface2"]) def _build_output(self, parent): - # Tab-like header - header = tk.Frame(parent, bg=COLORS["bg_panel"]) - header.pack(fill="x", padx=8, pady=(8, 0)) - - tk.Label(header, text="OUTPUT / BRIEFING", - bg=COLORS["bg_panel"], fg=COLORS["fg_accent"], - font=("Consolas", 10, "bold")).pack(side="left") - - self.copy_btn = tk.Button(header, text="Copy", command=self._on_copy, - bg=COLORS["bg_accent"], fg=COLORS["fg_main"], - font=("Consolas", 8), relief="flat", bd=0, padx=8) - self.copy_btn.pack(side="right", padx=4) - - # Output text area + # Header row + hdr = tk.Frame(parent, bg=C["surface"]) + hdr.pack(fill="x", padx=10, pady=(8, 4)) + + tk.Label(hdr, text="OUTPUT / BRIEFING", + bg=C["surface"], fg=C["fg_dim"], + font=("Consolas", 9, "bold")).pack(side="left") + + # Action buttons top-right + for label, cmd, color in [ + ("Clear", self._on_clear, C["fg_dim"]), + ("Copy", self._on_copy, C["accent"]), + ]: + b = tk.Button(hdr, text=label, command=cmd, + bg=C["surface"], fg=color, activebackground=C["surface2"], + activeforeground=C["fg_bright"], font=("Consolas", 8), + relief="flat", bd=0, padx=8, cursor="hand2") + b.pack(side="right", padx=2) + _hover(b, C["surface"], C["surface2"]) + + _sep(parent) + + # Progress bar (hidden until generation starts) + self._progress_var = tk.DoubleVar(value=0) + self._progress_bar = ttk.Progressbar( + parent, variable=self._progress_var, + mode="indeterminate", style="Accent.TProgressbar", length=100) + # Don't pack yet — shown dynamically + + # Output text with syntax coloring self.output_text = scrolledtext.ScrolledText( parent, wrap="word", - bg=COLORS["bg_input"], fg=COLORS["fg_main"], - insertbackground=COLORS["fg_accent"], + bg=C["surface"], fg=C["fg"], + insertbackground=C["accent"], font=("Consolas", 9), relief="flat", bd=0, - selectbackground=COLORS["highlight"]) - self.output_text.pack(fill="both", expand=True, padx=8, pady=8) - self.output_text.insert("1.0", "Ready. Describe a mission or select options and generate.\n\n" - "Natural language mode uses Ollama for smart parsing.\n" - "Manual mode bypasses the LLM entirely.\n") + padx=12, pady=8, + selectbackground=C["select"]) + self.output_text.pack(fill="both", expand=True) + + # Configure text tags for colored output + self.output_text.tag_configure("head", foreground=C["tag_head"], font=("Consolas", 9, "bold")) + self.output_text.tag_configure("ok", foreground=C["tag_ok"]) + self.output_text.tag_configure("err", foreground=C["tag_err"]) + self.output_text.tag_configure("dim", foreground=C["tag_dim"]) + self.output_text.tag_configure("bright", foreground=C["fg_bright"]) + self.output_text.tag_configure("warning", foreground=C["fg_warning"]) + + self._log("Ready — describe a mission above or use manual selections.\n", "dim") + self._log("Natural language mode uses Ollama for smart parsing.\n", "dim") + self._log("Manual mode bypasses the LLM entirely.\n", "dim") self.output_text.configure(state="disabled") - def _log(self, text, tag=None): - """Append text to the output area.""" + def _build_statusbar(self): + _sep(self.root) + bar = tk.Frame(self.root, bg=C["bg"], pady=5) + bar.pack(fill="x", padx=12) + + self.bottom_status = tk.Label(bar, text="Ready", + bg=C["bg"], fg=C["fg_dim"], + font=("Consolas", 8)) + self.bottom_status.pack(side="left") + + # ── Logging helpers ─────────────────────────────────────── + + def _log(self, text: str, tag: str = ""): self.output_text.configure(state="normal") - self.output_text.insert("end", text + "\n") + if tag: + self.output_text.insert("end", text + "\n", tag) + else: + self.output_text.insert("end", text + "\n") self.output_text.see("end") self.output_text.configure(state="disabled") @@ -361,27 +447,42 @@ def _clear_output(self): self.output_text.delete("1.0", "end") self.output_text.configure(state="disabled") - def _set_status(self, text, color=None): - self.bottom_status.configure(text=text, fg=color or COLORS["fg_dim"]) + def _set_status(self, text: str, color: str = ""): + self.bottom_status.configure(text=text, fg=color or C["fg_dim"]) - def _set_buttons_enabled(self, enabled): + def _set_buttons_enabled(self, enabled: bool): state = "normal" if enabled else "disabled" self.gen_nl_btn.configure(state=state) self.gen_manual_btn.configure(state=state) + def _start_progress(self): + self._progress_bar.pack(fill="x", padx=0, pady=0, + before=self.output_text) + self._progress_bar.start(12) + + def _stop_progress(self): + self._progress_bar.stop() + self._progress_bar.pack_forget() + + # ── Status indicators ───────────────────────────────────── + def _check_ollama(self): - """Check Ollama connection in background.""" def check(): connected = self.client.check_connection() self.ollama_connected = connected - self.root.after(0, lambda: self.status_label.configure( - text=f"Ollama: {'Connected ✓' if connected else 'Offline ✗'} | " - f"DCS: {'Found ✓' if self.dcs_folder else 'Not found'}", - fg=COLORS["fg_success"] if connected else COLORS["fg_warning"])) + def update(): + color = C["fg_success"] if connected else C["fg_warning"] + self._ollama_dot.configure(fg=color) + tip = "Connected" if connected else "Offline" + self._ollama_dot.configure(text="●") + self._set_status( + f"Ollama {tip} · DCS {'found' if self.dcs_folder else 'not detected'}") + self.root.after(0, update) threading.Thread(target=check, daemon=True).start() + # ── Generation handlers ─────────────────────────────────── + def _on_generate_nl(self): - """Generate from natural language description.""" description = self.nl_input.get("1.0", "end").strip() if not description: messagebox.showwarning("Empty", "Enter a mission description first.") @@ -389,154 +490,147 @@ def _on_generate_nl(self): self._generate_mission(description=description) def _on_generate_manual(self): - """Generate from manual dropdown selections.""" - # Map display name back to key - ac_idx = self.aircraft_display.index(self.aircraft_var.get()) \ - if self.aircraft_var.get() in self.aircraft_display else 0 + ac_idx = self.aircraft_display.index(self.aircraft_var.get()) \ + if self.aircraft_var.get() in self.aircraft_display else 0 map_idx = self.map_display.index(self.map_var.get()) \ - if self.map_var.get() in self.map_display else 0 + if self.map_var.get() in self.map_display else 0 plan = { - "map_name": self.map_list[map_idx], + "map_name": self.map_list[map_idx], "player_aircraft": self.aircraft_list[ac_idx], - "mission_type": self.mission_var.get(), + "mission_type": self.mission_var.get(), "player_airfield": "AUTO", - "time_of_day": self.time_var.get(), - "weather": self.weather_var.get(), - "difficulty": self.difficulty_var.get(), - "player_count": 1, - "wingman": self.wingman_var.get(), + "time_of_day": self.time_var.get(), + "weather": self.weather_var.get(), + "difficulty": self.difficulty_var.get(), + "player_count": int(self.players_var.get()), + "wingman": self.wingman_var.get(), "ground_war": { - "enabled": self.ground_war_var.get(), + "enabled": self.ground_war_var.get(), "front_line_desc": "Dynamic", - "blue_advancing": True, - "red_advancing": True, - "intensity": "medium", + "blue_advancing": True, + "red_advancing": True, + "intensity": "medium", }, "special_requests": "", } self._generate_mission(plan=plan) def _generate_mission(self, description=None, plan=None): - """Run generation in a background thread.""" self._set_buttons_enabled(False) self._clear_output() - self._set_status("Generating...", COLORS["fg_accent"]) - self._log("═" * 50) - self._log(" GENERATING MISSION...") - self._log("═" * 50) + self._set_status("Generating…", C["accent"]) + self.root.after(0, self._start_progress) + self._log("GENERATING MISSION", "head") + self._log("─" * 48, "dim") def run(): try: if description: - self._log(f"\n Description: \"{description}\"\n") + self._log(f'\n"{description}"\n', "bright") parser = MissionParser(self.client) - self._log(" Parsing with LLM..." if self.ollama_connected - else " Parsing with rule engine (Ollama offline)...") + parse_msg = "Parsing with LLM…" if self.ollama_connected \ + else "Parsing with rule engine (Ollama offline)…" + self._log(parse_msg, "dim") mission_plan = parser.parse_description(description) if not mission_plan: - self.root.after(0, lambda: self._log(" ERROR: Could not parse description.")) + self.root.after(0, lambda: self._log("ERROR: Could not parse description.", "err")) return else: mission_plan = plan - # Fill defaults the parser would normally handle from src.llm.mission_parser import MissionParser as MP - p = MP(self.client) - mission_plan = p._validate_and_fill(mission_plan, "") + mission_plan = MP(self.client)._validate_and_fill(mission_plan, "") - # Operation name op_name = generate_mission_name(mission_plan.get("mission_type", "general")) mission_plan["_operation_name"] = op_name - self.root.after(0, lambda: self._log(f"\n {op_name}\n")) - - # Show plan + self.root.after(0, lambda: self._log(f"\n{op_name}", "head")) self.root.after(0, lambda: self._log_plan(mission_plan)) - # Scale difficulty - self.root.after(0, lambda: self._log(" Applying difficulty scaling...")) + self._log("Scaling difficulty…", "dim") scaled = scale_plan(mission_plan) - # Build - self.root.after(0, lambda: self._log(" Building mission structure...")) - builder = MissionBuilder(scaled) - data = builder.build() + self._log("Building mission structure…", "dim") + data = MissionBuilder(scaled).build() - # Lua - self.root.after(0, lambda: self._log(" Generating Lua files...")) + self._log("Generating Lua files…", "dim") lua_files = LuaGenerator(data).generate_all() - # Briefing - self.root.after(0, lambda: self._log(" Generating briefing...")) + self._log("Generating briefing…", "dim") briefing = BriefingGenerator(data, scaled).generate() - # Package - filename = generate_filename( + filename = generate_filename( mission_plan.get("mission_type", "mission"), mission_plan.get("map_name", "unknown"), op_name) output_path = OUTPUT_DIR / filename - MizPackager().package(lua_files, briefing, str(output_path)) + ac_type = PLAYER_AIRCRAFT.get( + mission_plan.get("player_aircraft", ""), {}).get("type", "") + MizPackager().package(lua_files, briefing, str(output_path), + aircraft_type=ac_type) - # Save briefing briefing_path = output_path.with_suffix(".txt") with open(briefing_path, "w", encoding="utf-8") as f: f.write(briefing) self.last_miz_path = output_path - # Auto-deploy deployed_path = None if self.auto_deploy_var.get() and self.dcs_folder: deployed_path = deploy_mission(output_path, self.dcs_folder) deploy_briefing(briefing_path, self.dcs_folder) - # Show results def show_results(): - self._log(f"\n ✓ Mission saved: {output_path.name}") + self._log(f"\n✓ Mission saved: {output_path.name}", "ok") if deployed_path: - self._log(f" ✓ Deployed to DCS: {deployed_path}") + self._log(f"✓ Deployed to DCS: {deployed_path}", "ok") else: - self._log(f" ℹ Copy to: %USERPROFILE%\\Saved Games\\DCS\\Missions\\") - self._log(f"\n{'═' * 50}") - self._log(" KNEEBOARD BRIEFING") - self._log("═" * 50) + self._log("ℹ Copy .miz to your DCS Saved Games\\Missions\\ folder", "dim") + self._log("\n" + "─" * 48, "dim") + self._log("BRIEFING", "head") + self._log("─" * 48, "dim") self._log(briefing) - self._set_status(f"✓ {op_name} — {output_path.name}", COLORS["fg_success"]) + self._set_status(f"✓ {op_name} · {output_path.name}", C["fg_success"]) self.root.after(0, show_results) except Exception as e: - self.root.after(0, lambda: self._log(f"\n ERROR: {e}")) - self.root.after(0, lambda: self._set_status(f"Error: {e}", COLORS["fg_error"])) - import traceback - traceback.print_exc() + self.root.after(0, lambda: self._log(f"\nERROR: {e}", "err")) + self.root.after(0, lambda: self._set_status(f"Error: {e}", C["fg_error"])) + import traceback; traceback.print_exc() finally: + self.root.after(0, self._stop_progress) self.root.after(0, lambda: self._set_buttons_enabled(True)) threading.Thread(target=run, daemon=True).start() def _log_plan(self, plan): - self._log(f" Map: {plan.get('map_name', '?')}") - self._log(f" Aircraft: {plan.get('player_aircraft', '?')}") - self._log(f" Mission: {plan.get('mission_type', '?')}") - self._log(f" Difficulty: {plan.get('difficulty', 'medium').upper()}") - self._log(f" Weather: {plan.get('weather', 'clear')}") - self._log(f" Departure: {plan.get('player_airfield', 'AUTO')}") - + pairs = [ + ("Map", plan.get("map_name", "?")), + ("Aircraft", plan.get("player_aircraft", "?")), + ("Mission", plan.get("mission_type", "?")), + ("Difficulty", plan.get("difficulty", "medium").upper()), + ("Weather", plan.get("weather", "clear")), + ("Departure", plan.get("player_airfield", "AUTO")), + ] sams = plan.get("enemy_sam_sites", []) if sams: - self._log(f" SAMs: {', '.join(s.get('type', '?') for s in sams)}") - + pairs.append(("SAMs", ", ".join(s.get("type", "?") for s in sams))) enemy_air = plan.get("enemy_air", []) if enemy_air: - parts = [] - for e in enemy_air: - cnt = e.get("count", 2) - ac = e.get("aircraft", "?") - parts.append(f"{cnt}x {ac}") - self._log(f" Enemy Air: {', '.join(parts)}") + pairs.append(("Enemy Air", ", ".join( + f"{e.get('count', 2)}× {e.get('aircraft', '?')}" for e in enemy_air))) + + for label, value in pairs: + line = f" {label:<12} {value}" + self._log(line) self._log("") + # ── Utility handlers ────────────────────────────────────── + + def _on_clear(self): + self._clear_output() + self._set_status("Ready") + def _on_open_folder(self): folder = str(OUTPUT_DIR.resolve()) if sys.platform == "win32": @@ -550,19 +644,16 @@ def _on_copy(self): content = self.output_text.get("1.0", "end") self.root.clipboard_clear() self.root.clipboard_append(content) - self._set_status("Copied to clipboard", COLORS["fg_success"]) + self._set_status("Copied to clipboard", C["fg_success"]) def main(): root = tk.Tk() - - # Set icon if available try: root.iconbitmap(default="") except Exception: pass - - app = DCSMissionGeneratorGUI(root) + DCSMissionGeneratorGUI(root) root.mainloop() diff --git a/main.py b/main.py index f22a2d6..3c62396 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,7 @@ from src.custom_mods import load_custom_aircraft, register_custom_aircraft, ensure_custom_dir from src.validator import validate_mission from src.history import record_mission, display_history +from src.units import PLAYER_AIRCRAFT, MISSION_TEMPLATES OUTPUT_DIR = Path("./output") OUTPUT_DIR.mkdir(exist_ok=True) @@ -38,9 +39,12 @@ ║ DCS WORLD MISSION GENERATOR v2.0 ║ ║ Natural Language → .miz Mission Files ║ ╠══════════════════════════════════════════════════════════════╣ -║ Maps: Caucasus, Syria, Cold War Germany ║ -║ Modules: F-16C, F/A-18C, A-10C II, JF-17 ║ -║ NEW: Convoys, Campaigns, Difficulty Scaling, Auto-Deploy ║ +║ Maps: Caucasus · Syria · Cold War Germany ║ +║ Persian Gulf · Mariana Islands ║ +║ Aircraft: F-16C · F/A-18C · F-15C · F-15E · AV-8B ║ +║ M-2000C · A-10C · JF-17 · AH-64D + mods ║ +║ Missions: SEAD · CAS · CAP · Strike · Anti-Ship ║ +║ Escort · Convoy · CSAR · FAC(A) ║ ╚══════════════════════════════════════════════════════════════╝ """ @@ -103,7 +107,7 @@ def display_mission_plan(plan: dict): op_name = plan.get("_operation_name", "") campaign_info = "" if plan.get("_campaign_mission_num"): - campaign_info = f" (Mission {plan['_campaign_mission_num']}/{plan['_campaign_total']})" + campaign_info = f" (Mission {plan['_campaign_mission_num']}/{plan.get('_campaign_total', '?')})" print("\n" + "=" * 60) if op_name: @@ -222,7 +226,8 @@ def build_and_save_mission(plan: dict, skip_prompts: bool = False) -> tuple: # Package .miz print(" Packaging .miz file...") packager = MizPackager() - packager.package(lua_files, briefing_text, str(output_path)) + ac_type = PLAYER_AIRCRAFT.get(plan.get("player_aircraft", ""), {}).get("type", "") + packager.package(lua_files, briefing_text, str(output_path), aircraft_type=ac_type) # Save briefing briefing_path = output_path.with_suffix(".txt") @@ -507,6 +512,22 @@ def show_maps(): def main(): + # --generate / --quick headless mode: generate without interactive prompt + if len(sys.argv) >= 2 and sys.argv[1] in ("--generate", "--quick", "-g"): + description = " ".join(sys.argv[2:]).strip() + if not description: + print("Usage: python main.py --generate \"\"") + sys.exit(1) + + custom_dir = ensure_custom_dir() + custom_aircraft = load_custom_aircraft(custom_dir) + if custom_aircraft: + register_custom_aircraft(custom_aircraft) + + client = OllamaClient(model="llama3.1:8b") + run_quick_mission(description, client) + return + print_banner() # Load custom aircraft mods diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..47c57e2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "dcs-mission-manager" +version = "2.0.0" +description = "Generate realistic DCS World .miz mission files from plain-English descriptions" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +# No runtime dependencies — stdlib only (Ollama is optional and accessed over HTTP) + +[project.optional-dependencies] +dev = [ + "pyinstaller>=6.0", + "pytest>=8.0", +] + +[project.scripts] +dcs-mission-gen = "main:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["src*"] diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..a255483 Binary files /dev/null and b/src/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/__pycache__/callsigns.cpython-311.pyc b/src/__pycache__/callsigns.cpython-311.pyc new file mode 100644 index 0000000..188e159 Binary files /dev/null and b/src/__pycache__/callsigns.cpython-311.pyc differ diff --git a/src/__pycache__/difficulty.cpython-311.pyc b/src/__pycache__/difficulty.cpython-311.pyc new file mode 100644 index 0000000..ed04fc9 Binary files /dev/null and b/src/__pycache__/difficulty.cpython-311.pyc differ diff --git a/src/__pycache__/flight_profile.cpython-311.pyc b/src/__pycache__/flight_profile.cpython-311.pyc new file mode 100644 index 0000000..18e2569 Binary files /dev/null and b/src/__pycache__/flight_profile.cpython-311.pyc differ diff --git a/src/__pycache__/mission_events.cpython-311.pyc b/src/__pycache__/mission_events.cpython-311.pyc new file mode 100644 index 0000000..00fd7f1 Binary files /dev/null and b/src/__pycache__/mission_events.cpython-311.pyc differ diff --git a/src/__pycache__/naming.cpython-311.pyc b/src/__pycache__/naming.cpython-311.pyc new file mode 100644 index 0000000..669aade Binary files /dev/null and b/src/__pycache__/naming.cpython-311.pyc differ diff --git a/src/custom_mods.py b/src/custom_mods.py index 37a3eb4..2f50281 100644 --- a/src/custom_mods.py +++ b/src/custom_mods.py @@ -97,6 +97,7 @@ def load_custom_aircraft(directory: Path | None = None) -> dict: elif isinstance(clsid, dict): pylons[pylon_num] = clsid except ValueError: + print(f" WARNING: Invalid pylon number '{pylon_num_str}' in {loadout_name} loadout — skipping") continue entry["default_loadouts"][loadout_name] = { diff --git a/src/flight_profile.py b/src/flight_profile.py index 90921f5..2006cff 100644 --- a/src/flight_profile.py +++ b/src/flight_profile.py @@ -66,6 +66,81 @@ "medium_alt_m": 4500, "pop_up_alt_m": 3000, }, + "F-15C": { + "climb_rate_fpm": 50000, + "cruise_speed_kts": 480, + "cruise_alt_m": 9000, # FL300 — Eagle likes altitude + "combat_speed_kts": 450, + "ingress_speed_kts": 460, + "egress_speed_kts": 550, + "approach_speed_kts": 165, + "fuel_flow_cruise_kg_hr": 3200, + "fuel_flow_combat_kg_hr": 6500, + "internal_fuel_kg": 6100, + "low_alt_ingress_m": 200, + "medium_alt_m": 6000, + "pop_up_alt_m": 3500, + }, + "F-15E": { + "climb_rate_fpm": 50000, + "cruise_speed_kts": 480, + "cruise_alt_m": 9000, + "combat_speed_kts": 440, + "ingress_speed_kts": 450, + "egress_speed_kts": 540, + "approach_speed_kts": 165, + "fuel_flow_cruise_kg_hr": 3400, + "fuel_flow_combat_kg_hr": 6800, + "internal_fuel_kg": 6100, + "low_alt_ingress_m": 150, + "medium_alt_m": 5500, + "pop_up_alt_m": 3000, + }, + "AV-8B": { + "climb_rate_fpm": 14700, + "cruise_speed_kts": 340, + "cruise_alt_m": 6000, # Harrier prefers medium-low + "combat_speed_kts": 300, + "ingress_speed_kts": 320, + "egress_speed_kts": 400, + "approach_speed_kts": 150, + "fuel_flow_cruise_kg_hr": 1800, + "fuel_flow_combat_kg_hr": 3500, + "internal_fuel_kg": 3060, + "low_alt_ingress_m": 100, + "medium_alt_m": 3000, + "pop_up_alt_m": 1500, + }, + "M-2000C": { + "climb_rate_fpm": 48000, + "cruise_speed_kts": 450, + "cruise_alt_m": 8000, + "combat_speed_kts": 420, + "ingress_speed_kts": 440, + "egress_speed_kts": 520, + "approach_speed_kts": 175, + "fuel_flow_cruise_kg_hr": 2600, + "fuel_flow_combat_kg_hr": 5200, + "internal_fuel_kg": 3160, + "low_alt_ingress_m": 150, + "medium_alt_m": 5000, + "pop_up_alt_m": 3000, + }, + "AH-64D": { + "climb_rate_fpm": 2500, + "cruise_speed_kts": 130, + "cruise_alt_m": 500, # Helicopters nap-of-earth + "combat_speed_kts": 100, + "ingress_speed_kts": 110, + "egress_speed_kts": 140, + "approach_speed_kts": 60, + "fuel_flow_cruise_kg_hr": 500, + "fuel_flow_combat_kg_hr": 700, + "internal_fuel_kg": 1160, + "low_alt_ingress_m": 30, # NOE flight + "medium_alt_m": 200, + "pop_up_alt_m": 100, + }, } # Mission type profiles — how each mission type shapes the flight @@ -118,6 +193,18 @@ "egress_alt": "high", "attack_desc": "Overhead CAP above convoy, descend to intercept inbound threats", }, + "CSAR": { + "ingress_alt": "low", # Stay low to avoid radar + "target_alt": "low", + "egress_alt": "low", + "attack_desc": "Nap-of-earth ingress to survivor location, recover and egress low and fast", + }, + "FAC": { + "ingress_alt": "medium", # High enough to observe, low enough to mark + "target_alt": "medium", + "egress_alt": "medium", + "attack_desc": "Orbit target area at medium altitude, spot and mark targets for CAS flights", + }, } @@ -179,9 +266,6 @@ def compute_flight_profile(aircraft_key: str, mission_type: str, weather_adjusted = True # Never go below 50m for safety - for alt in [cruise_alt, ingress_alt, target_alt, egress_alt]: - alt = max(alt, 50) - cruise_alt = max(cruise_alt, 50) ingress_alt = max(ingress_alt, 50) target_alt = max(target_alt, 50) diff --git a/src/generators/__pycache__/__init__.cpython-311.pyc b/src/generators/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..cdc9454 Binary files /dev/null and b/src/generators/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/generators/__pycache__/kneeboard_generator.cpython-311.pyc b/src/generators/__pycache__/kneeboard_generator.cpython-311.pyc new file mode 100644 index 0000000..56072ad Binary files /dev/null and b/src/generators/__pycache__/kneeboard_generator.cpython-311.pyc differ diff --git a/src/generators/__pycache__/mission_builder.cpython-311.pyc b/src/generators/__pycache__/mission_builder.cpython-311.pyc new file mode 100644 index 0000000..d048fcc Binary files /dev/null and b/src/generators/__pycache__/mission_builder.cpython-311.pyc differ diff --git a/src/generators/kneeboard_generator.py b/src/generators/kneeboard_generator.py new file mode 100644 index 0000000..2c15db8 --- /dev/null +++ b/src/generators/kneeboard_generator.py @@ -0,0 +1,249 @@ +""" +Kneeboard PNG Generator +Produces DCS-compatible kneeboard card images from briefing text. +Pure stdlib — no Pillow or external packages required. +Uses zlib compression and a built-in 8x13 bitmap font to render text onto PNG. +""" + +import zlib +import struct +import math + +# --------------------------------------------------------------------------- +# Minimal 8×13 bitmap font (printable ASCII 32-126) +# Each character is 13 rows of 8-bit scanlines stored as integers. +# This is a compact public-domain proportional terminal font. +# --------------------------------------------------------------------------- +_FONT_W = 8 +_FONT_H = 13 + +# Encoded as tuples of 13 bytes (row 0 = top). Only the subset we actually +# need for briefing text is included; anything outside 0x20-0x7E falls back +# to a blank glyph. +_GLYPHS: dict[int, tuple] = { + 0x20: (0,0,0,0,0,0,0,0,0,0,0,0,0), # space + 0x21: (0,24,24,24,24,24,24,0,24,24,0,0,0), # ! + 0x22: (0,54,54,54,0,0,0,0,0,0,0,0,0), # " + 0x23: (0,36,36,126,36,36,36,126,36,36,0,0,0), # # + 0x24: (0,24,62,72,72,60,12,12,124,24,0,0,0), # $ + 0x25: (0,0,70,38,16,16,8,8,100,98,0,0,0), # % + 0x26: (0,56,108,108,56,110,219,219,110,59,0,0,0), # & + 0x27: (0,24,24,24,0,0,0,0,0,0,0,0,0), # ' + 0x28: (0,6,12,24,24,24,24,24,12,6,0,0,0), # ( + 0x29: (0,96,48,24,24,24,24,24,48,96,0,0,0), # ) + 0x2A: (0,0,0,54,28,127,28,54,0,0,0,0,0), # * + 0x2B: (0,0,0,24,24,126,24,24,0,0,0,0,0), # + + 0x2C: (0,0,0,0,0,0,0,0,24,24,48,0,0), # , + 0x2D: (0,0,0,0,0,126,0,0,0,0,0,0,0), # - + 0x2E: (0,0,0,0,0,0,0,0,24,24,0,0,0), # . + 0x2F: (0,0,3,6,12,24,48,96,0,0,0,0,0), # / + 0x30: (0,60,102,110,118,102,102,102,60,0,0,0,0), # 0 + 0x31: (0,24,56,24,24,24,24,24,126,0,0,0,0), # 1 + 0x32: (0,60,102,6,12,24,48,96,126,0,0,0,0), # 2 + 0x33: (0,60,102,6,28,6,6,102,60,0,0,0,0), # 3 + 0x34: (0,12,28,60,108,126,12,12,12,0,0,0,0), # 4 + 0x35: (0,126,96,96,124,6,6,102,60,0,0,0,0), # 5 + 0x36: (0,28,48,96,124,102,102,102,60,0,0,0,0), # 6 + 0x37: (0,126,6,6,12,24,48,48,48,0,0,0,0), # 7 + 0x38: (0,60,102,102,60,102,102,102,60,0,0,0,0), # 8 + 0x39: (0,60,102,102,62,6,6,12,56,0,0,0,0), # 9 + 0x3A: (0,0,0,24,24,0,0,24,24,0,0,0,0), # : + 0x3B: (0,0,0,24,24,0,0,24,24,48,0,0,0), # ; + 0x3C: (0,0,6,12,24,48,24,12,6,0,0,0,0), # < + 0x3D: (0,0,0,0,126,0,126,0,0,0,0,0,0), # = + 0x3E: (0,0,96,48,24,12,24,48,96,0,0,0,0), # > + 0x3F: (0,60,102,6,12,24,0,24,24,0,0,0,0), # ? + 0x40: (0,60,102,110,110,110,96,96,60,0,0,0,0), # @ + 0x41: (0,24,60,102,102,126,102,102,102,0,0,0,0), # A + 0x42: (0,124,102,102,124,102,102,102,124,0,0,0,0), # B + 0x43: (0,60,102,96,96,96,96,102,60,0,0,0,0), # C + 0x44: (0,120,108,102,102,102,102,108,120,0,0,0,0), # D + 0x45: (0,126,96,96,120,96,96,96,126,0,0,0,0), # E + 0x46: (0,126,96,96,120,96,96,96,96,0,0,0,0), # F + 0x47: (0,60,102,96,96,110,102,102,60,0,0,0,0), # G + 0x48: (0,102,102,102,126,102,102,102,102,0,0,0,0), # H + 0x49: (0,60,24,24,24,24,24,24,60,0,0,0,0), # I + 0x4A: (0,30,12,12,12,12,12,108,56,0,0,0,0), # J + 0x4B: (0,102,108,120,112,120,108,102,102,0,0,0,0), # K + 0x4C: (0,96,96,96,96,96,96,96,126,0,0,0,0), # L + 0x4D: (0,99,119,127,107,99,99,99,99,0,0,0,0), # M + 0x4E: (0,102,118,126,126,110,102,102,102,0,0,0,0), # N + 0x4F: (0,60,102,102,102,102,102,102,60,0,0,0,0), # O + 0x50: (0,124,102,102,124,96,96,96,96,0,0,0,0), # P + 0x51: (0,60,102,102,102,102,102,60,14,0,0,0,0), # Q + 0x52: (0,124,102,102,124,120,108,102,102,0,0,0,0), # R + 0x53: (0,60,102,96,60,6,6,102,60,0,0,0,0), # S + 0x54: (0,126,24,24,24,24,24,24,24,0,0,0,0), # T + 0x55: (0,102,102,102,102,102,102,102,60,0,0,0,0), # U + 0x56: (0,102,102,102,102,102,60,60,24,0,0,0,0), # V + 0x57: (0,99,99,99,99,107,127,119,99,0,0,0,0), # W + 0x58: (0,102,102,60,24,24,60,102,102,0,0,0,0), # X + 0x59: (0,102,102,102,60,24,24,24,24,0,0,0,0), # Y + 0x5A: (0,126,6,12,24,48,96,96,126,0,0,0,0), # Z + 0x5B: (0,60,48,48,48,48,48,48,60,0,0,0,0), # [ + 0x5C: (0,0,96,48,24,12,6,3,0,0,0,0,0), # backslash + 0x5D: (0,60,12,12,12,12,12,12,60,0,0,0,0), # ] + 0x5E: (0,24,60,102,0,0,0,0,0,0,0,0,0), # ^ + 0x5F: (0,0,0,0,0,0,0,0,0,126,0,0,0), # _ + 0x60: (0,48,24,12,0,0,0,0,0,0,0,0,0), # ` + 0x61: (0,0,0,60,6,62,102,102,62,0,0,0,0), # a + 0x62: (0,96,96,124,102,102,102,102,124,0,0,0,0), # b + 0x63: (0,0,0,60,102,96,96,102,60,0,0,0,0), # c + 0x64: (0,6,6,62,102,102,102,102,62,0,0,0,0), # d + 0x65: (0,0,0,60,102,126,96,102,60,0,0,0,0), # e + 0x66: (0,28,54,48,120,48,48,48,48,0,0,0,0), # f + 0x67: (0,0,0,62,102,102,62,6,102,60,0,0,0), # g + 0x68: (0,96,96,124,102,102,102,102,102,0,0,0,0), # h + 0x69: (0,24,0,24,24,24,24,24,24,0,0,0,0), # i + 0x6A: (0,6,0,6,6,6,6,6,102,60,0,0,0), # j + 0x6B: (0,96,96,102,108,120,120,108,102,0,0,0,0), # k + 0x6C: (0,56,24,24,24,24,24,24,60,0,0,0,0), # l + 0x6D: (0,0,0,110,127,107,107,99,99,0,0,0,0), # m + 0x6E: (0,0,0,124,102,102,102,102,102,0,0,0,0), # n + 0x6F: (0,0,0,60,102,102,102,102,60,0,0,0,0), # o + 0x70: (0,0,0,124,102,102,102,124,96,96,0,0,0), # p + 0x71: (0,0,0,62,102,102,102,62,6,6,0,0,0), # q + 0x72: (0,0,0,108,118,102,96,96,96,0,0,0,0), # r + 0x73: (0,0,0,62,96,60,6,6,124,0,0,0,0), # s + 0x74: (0,48,48,126,48,48,48,54,28,0,0,0,0), # t + 0x75: (0,0,0,102,102,102,102,102,62,0,0,0,0), # u + 0x76: (0,0,0,102,102,102,102,60,24,0,0,0,0), # v + 0x77: (0,0,0,99,99,107,107,127,54,0,0,0,0), # w + 0x78: (0,0,0,102,60,24,24,60,102,0,0,0,0), # x + 0x79: (0,0,0,102,102,102,62,6,12,120,0,0,0), # y + 0x7A: (0,0,0,126,12,24,48,96,126,0,0,0,0), # z + 0x7B: (0,14,24,24,48,96,48,24,24,14,0,0,0), # { + 0x7C: (0,24,24,24,24,0,24,24,24,24,0,0,0), # | + 0x7D: (0,112,24,24,12,6,12,24,24,112,0,0,0), # } + 0x7E: (0,118,220,0,0,0,0,0,0,0,0,0,0), # ~ +} +_BLANK = (0,) * _FONT_H + + +# --------------------------------------------------------------------------- +# PNG writer (pure stdlib) +# --------------------------------------------------------------------------- + +def _png_chunk(name: bytes, data: bytes) -> bytes: + chunk = name + data + return struct.pack(">I", len(data)) + chunk + struct.pack(">I", zlib.crc32(chunk) & 0xFFFFFFFF) + + +def _write_png(width: int, height: int, pixels: list[list[tuple]]) -> bytes: + """Encode an RGB pixel grid as a PNG bytestring.""" + header = b"\x89PNG\r\n\x1a\n" + + ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8-bit RGB + ihdr = _png_chunk(b"IHDR", ihdr_data) + + raw_rows = [] + for row in pixels: + row_bytes = b"\x00" # filter type None + for r, g, b in row: + row_bytes += bytes([r, g, b]) + raw_rows.append(row_bytes) + + compressed = zlib.compress(b"".join(raw_rows), 9) + idat = _png_chunk(b"IDAT", compressed) + iend = _png_chunk(b"IEND", b"") + + return header + ihdr + idat + iend + + +# --------------------------------------------------------------------------- +# Text renderer +# --------------------------------------------------------------------------- + +# Palette +_BG = (18, 24, 32) # Dark panel background (#121820) +_FG = (200, 214, 229) # Main text (#c8d6e5) +_HDR = (78, 205, 196) # Section header accent (#4ecdc4) +_DIM = (107, 123, 141) # Dim separator (#6b7b8d) + +_SCALE = 2 # Render at 2× for crisp high-DPI kneeboard +_PAD_X = 12 +_PAD_Y = 10 +_LINE_H = (_FONT_H + 3) * _SCALE + + +def _draw_char(pixels: list[list], x: int, y: int, ch: str, color: tuple): + code = ord(ch) if len(ch) == 1 else 0x20 + glyph = _GLYPHS.get(code, _BLANK) + for row_i, row_bits in enumerate(glyph): + for col_i in range(_FONT_W): + if row_bits & (0x80 >> col_i): + px = x + col_i * _SCALE + py = y + row_i * _SCALE + for dy in range(_SCALE): + for dx in range(_SCALE): + ry, rx = py + dy, px + dx + if 0 <= ry < len(pixels) and 0 <= rx < len(pixels[0]): + pixels[ry][rx] = color + + +def _pick_color(line: str) -> tuple: + stripped = line.strip() + if stripped.startswith("═") or stripped.startswith("─"): + return _DIM + if line.startswith(" ") and stripped.isupper(): + return _HDR + return _FG + + +def generate_kneeboard_png(briefing_text: str, aircraft_type: str) -> bytes: + """ + Render a briefing string into a PNG kneeboard card. + Returns raw PNG bytes suitable for embedding in a .miz archive. + """ + lines = briefing_text.splitlines() + + # Measure canvas + max_chars = max((len(ln) for ln in lines), default=80) + width = max(640, _PAD_X * 2 + max_chars * _FONT_W * _SCALE) + height = _PAD_Y * 2 + len(lines) * _LINE_H + + # Cap at a reasonable kneeboard size (~2048 wide) + if width > 2048: + width = 2048 + chars_per_row = (width - _PAD_X * 2) // (_FONT_W * _SCALE) + + # Allocate pixel grid (fill with background) + pixels: list[list[tuple]] = [[_BG] * width for _ in range(height)] + + cy = _PAD_Y + for raw_line in lines: + # Wrap long lines + wrapped = [raw_line[i:i + chars_per_row] + for i in range(0, max(1, len(raw_line)), chars_per_row)] if raw_line else [""] + for segment in wrapped: + color = _pick_color(segment) + cx = _PAD_X + for ch in segment: + if ch == "\t": + cx += _FONT_W * _SCALE * 4 + continue + _draw_char(pixels, cx, cy, ch, color) + cx += _FONT_W * _SCALE + cy += _LINE_H + if cy >= height: + break + if cy >= height: + break + + return _write_png(width, height, pixels) + + +def get_dcs_aircraft_folder(aircraft_type: str) -> str: + """Map DCS unit type string to the kneeboard folder name DCS expects.""" + mapping = { + "F-16C_50": "F-16C_50", + "FA-18C_hornet": "FA-18C_hornet", + "A-10C_2": "A-10C_2", + "JF-17": "JF-17", + "F-15C": "F-15C", + "F-15ESE": "F-15ESE", + "AV8BNA": "AV8BNA", + "M-2000C": "M-2000C", + "AH-64D_BLK_II": "AH-64D_BLK_II", + } + return mapping.get(aircraft_type, aircraft_type) diff --git a/src/generators/lua_generator.py b/src/generators/lua_generator.py index 1ca5a77..3648898 100644 --- a/src/generators/lua_generator.py +++ b/src/generators/lua_generator.py @@ -242,7 +242,7 @@ def _gen_coalition(self, side: str) -> str: air_groups = [g for g in air_groups if g] # Remove None ground_groups = self.data.get("blue_ground", []) + self.data.get("blue_sam", []) else: - country_id = 0 # Russia + country_id = 1 # Russia country_name = "Russia" air_groups = self.data.get("red_air", []) ground_groups = self.data.get("red_ground", []) + self.data.get("red_sam", []) diff --git a/src/generators/mission_builder.py b/src/generators/mission_builder.py index ec01cdf..cfad075 100644 --- a/src/generators/mission_builder.py +++ b/src/generators/mission_builder.py @@ -74,7 +74,13 @@ def build(self) -> dict: if not player_af: blue_afs = [af for af in self.map_data.get("airfields", []) if af.get("default_coalition") == "blue"] - player_af = blue_afs[0] if blue_afs else self.map_data["airfields"][0] + all_afs = self.map_data.get("airfields", []) + if blue_afs: + player_af = blue_afs[0] + elif all_afs: + player_af = all_afs[0] + else: + raise ValueError(f"No airfields defined for map '{self.plan.get('map_name', 'unknown')}'.") # Build convoy FIRST if convoy mission (so target position is available for player waypoints) self._convoy_route = None @@ -249,11 +255,13 @@ def _build_player_group(self, airfield: dict): group_id = self._next_group_id() unit_id = self._next_unit_id() + player_count = max(1, int(self.plan.get("player_count", 1))) + player_units = [{ "unit_id": unit_id, "type": ac_data.get("type", "F-16C_50"), "name": player_cs["full"], - "skill": "Player", + "skill": "Player", # Slot 1 — primary player "x": airfield["x"], "y": airfield["y"], "alt": airfield.get("alt", 0), @@ -265,9 +273,21 @@ def _build_player_group(self, airfield: dict): "callsign_name": player_cs["callsign"], "callsign_flight": player_cs["flight"], }] + self._total_units += 1 - # Add wingman - if self.plan.get("wingman", True): + # Additional co-op player slots (skill = "Client" in DCS multiplayer) + for slot in range(2, player_count + 1): + co_id = self._next_unit_id() + co = copy.deepcopy(player_units[0]) + co["unit_id"] = co_id + co["name"] = f"{player_cs['callsign']} 1-{slot}" + co["skill"] = "Client" + co["y"] = airfield["y"] + (slot - 1) * 30 + player_units.append(co) + self._total_units += 1 + + # Add AI wingman only when it's a solo player mission with no extra slots + if player_count == 1 and self.plan.get("wingman", True): wm_id = self._next_unit_id() wm = copy.deepcopy(player_units[0]) wm["unit_id"] = wm_id @@ -277,8 +297,6 @@ def _build_player_group(self, airfield: dict): player_units.append(wm) self._total_units += 1 - self._total_units += 1 - self.player_group = { "group_id": group_id, "name": f"{player_cs['callsign']} Flight", diff --git a/src/generators/miz_packager.py b/src/generators/miz_packager.py index 2c14117..c3b2127 100644 --- a/src/generators/miz_packager.py +++ b/src/generators/miz_packager.py @@ -13,19 +13,23 @@ import zipfile import os +from src.generators.kneeboard_generator import generate_kneeboard_png, get_dcs_aircraft_folder + class MizPackager: """Package Lua files into a .miz archive.""" - def package(self, lua_files: dict[str, str], briefing_text: str, output_path: str): + def package(self, lua_files: dict[str, str], briefing_text: str, output_path: str, + aircraft_type: str = ""): """ Create a .miz file from generated Lua content. Args: lua_files: Dict mapping filename to Lua content string Expected keys: mission, warehouses, options, theatre, dictionary - briefing_text: Briefing text (saved separately, not in .miz) + briefing_text: Briefing text — also rendered as a kneeboard PNG inside the .miz output_path: Full path for the output .miz file + aircraft_type: DCS unit type string used to place kneeboard in the right folder """ # Ensure output directory exists os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True) @@ -54,6 +58,15 @@ def package(self, lua_files: dict[str, str], briefing_text: str, output_path: st # Map resource (usually empty but required) miz.writestr("l10n/DEFAULT/mapResource", self._gen_map_resource()) + # Kneeboard card — rendered as PNG inside KNEEBOARD// + if briefing_text: + try: + png_bytes = generate_kneeboard_png(briefing_text, aircraft_type) + folder = get_dcs_aircraft_folder(aircraft_type) if aircraft_type else "COMMON" + miz.writestr(f"KNEEBOARD/{folder}/01_briefing.png", png_bytes) + except Exception as e: + print(f" Warning: kneeboard generation failed — {e}") + print(f" .miz file: {os.path.getsize(output_path)} bytes") @staticmethod diff --git a/src/llm/ollama_client.py b/src/llm/ollama_client.py index 869dd3d..83ed07a 100644 --- a/src/llm/ollama_client.py +++ b/src/llm/ollama_client.py @@ -79,8 +79,6 @@ def generate(self, prompt: str, system: str = "", temperature: float = 0.3, print(f" ERROR: Invalid JSON response: {e}") return None - return None - def generate_json(self, prompt: str, system: str = "", temperature: float = 0.2, max_retries: int = 3) -> dict | None: """ diff --git a/src/maps/__init__.py b/src/maps/__init__.py index 7bfe908..236f663 100644 --- a/src/maps/__init__.py +++ b/src/maps/__init__.py @@ -1,11 +1,15 @@ from src.maps.caucasus import CAUCASUS_MAP from src.maps.syria import SYRIA_MAP from src.maps.cold_war_germany import COLD_WAR_GERMANY_MAP +from src.maps.persian_gulf import PERSIAN_GULF_MAP +from src.maps.mariana_islands import MARIANA_ISLANDS_MAP MAP_REGISTRY = { "Caucasus": CAUCASUS_MAP, "Syria": SYRIA_MAP, "ColdWarGermany": COLD_WAR_GERMANY_MAP, + "PersianGulf": PERSIAN_GULF_MAP, + "MarianaIslands": MARIANA_ISLANDS_MAP, } # Aliases for LLM output normalization @@ -22,6 +26,18 @@ "cold war": "ColdWarGermany", "fulda": "ColdWarGermany", "fulda gap": "ColdWarGermany", + "persian gulf": "PersianGulf", + "persian": "PersianGulf", + "gulf": "PersianGulf", + "uae": "PersianGulf", + "iran": "PersianGulf", + "hormuz": "PersianGulf", + "strait of hormuz": "PersianGulf", + "mariana islands": "MarianaIslands", + "marianas": "MarianaIslands", + "guam": "MarianaIslands", + "saipan": "MarianaIslands", + "pacific": "MarianaIslands", } diff --git a/src/maps/__pycache__/__init__.cpython-311.pyc b/src/maps/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..898f4e4 Binary files /dev/null and b/src/maps/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/maps/__pycache__/caucasus.cpython-311.pyc b/src/maps/__pycache__/caucasus.cpython-311.pyc new file mode 100644 index 0000000..70c5b11 Binary files /dev/null and b/src/maps/__pycache__/caucasus.cpython-311.pyc differ diff --git a/src/maps/__pycache__/cold_war_germany.cpython-311.pyc b/src/maps/__pycache__/cold_war_germany.cpython-311.pyc new file mode 100644 index 0000000..9486eb3 Binary files /dev/null and b/src/maps/__pycache__/cold_war_germany.cpython-311.pyc differ diff --git a/src/maps/__pycache__/mariana_islands.cpython-311.pyc b/src/maps/__pycache__/mariana_islands.cpython-311.pyc new file mode 100644 index 0000000..b54467d Binary files /dev/null and b/src/maps/__pycache__/mariana_islands.cpython-311.pyc differ diff --git a/src/maps/__pycache__/persian_gulf.cpython-311.pyc b/src/maps/__pycache__/persian_gulf.cpython-311.pyc new file mode 100644 index 0000000..5ad3857 Binary files /dev/null and b/src/maps/__pycache__/persian_gulf.cpython-311.pyc differ diff --git a/src/maps/__pycache__/syria.cpython-311.pyc b/src/maps/__pycache__/syria.cpython-311.pyc new file mode 100644 index 0000000..ecd6e7e Binary files /dev/null and b/src/maps/__pycache__/syria.cpython-311.pyc differ diff --git a/src/maps/mariana_islands.py b/src/maps/mariana_islands.py new file mode 100644 index 0000000..dae39fb --- /dev/null +++ b/src/maps/mariana_islands.py @@ -0,0 +1,162 @@ +""" +Mariana Islands Map Database +Covers Guam, Saipan, Tinian, Rota, and surrounding Pacific ocean areas. +DCS coordinates use a local X/Y system (meters) relative to the map origin. +""" + +MARIANA_ISLANDS_MAP = { + "display_name": "Mariana Islands", + "theater_id": "MarianaIslands", + "dcs_theater": "MarianaIslands", + + "bounds": { + "x_min": -400000, "x_max": 400000, + "y_min": -400000, "y_max": 400000, + }, + + "default_date": {"year": 2024, "month": 6, "day": 15}, + + "airfields": [ + # ---- BLUE (US / Coalition) ---- + { + "name": "Andersen AFB", + "id": 1, + "x": 13600, + "y": 144700, + "alt": 191, + "default_coalition": "blue", + "runways": [{"heading": 6, "length": 3350}, {"heading": 186, "length": 3350}], + "tacan": "54X", + "ils": "109.10", + "atis": "126.200", + }, + { + "name": "Antonio B. Won Pat International (Guam)", + "id": 2, + "x": 6500, + "y": 136500, + "alt": 91, + "default_coalition": "blue", + "runways": [{"heading": 60, "length": 3048}, {"heading": 240, "length": 3048}], + "tacan": "112X", + "ils": "111.10", + }, + { + "name": "Saipan International", + "id": 3, + "x": 137400, + "y": 172000, + "alt": 64, + "default_coalition": "blue", + "runways": [{"heading": 70, "length": 2560}], + "tacan": "66X", + }, + { + "name": "Tinian International", + "id": 4, + "x": 117000, + "y": 162000, + "alt": 57, + "default_coalition": "blue", + "runways": [{"heading": 95, "length": 2950}], + }, + { + "name": "Rota International", + "id": 5, + "x": 60000, + "y": 110000, + "alt": 187, + "default_coalition": "blue", + "runways": [{"heading": 90, "length": 2130}], + }, + + # ---- RED (OPFOR) ---- + { + "name": "Pagan Island Airstrip", + "id": 10, + "x": 305000, + "y": 230000, + "alt": 57, + "default_coalition": "red", + "runways": [{"heading": 105, "length": 1500}], + }, + { + "name": "Agrihan Airstrip", + "id": 11, + "x": 347000, + "y": 260000, + "alt": 45, + "default_coalition": "red", + "runways": [{"heading": 90, "length": 1200}], + }, + ], + + "cities": [ + {"name": "Hagatna (Guam)", "x": 4000, "y": 134000, "side": "blue"}, + {"name": "Tamuning", "x": 6000, "y": 136000, "side": "blue"}, + {"name": "Chalan Kanoa (Saipan)", "x": 133000, "y": 170000, "side": "blue"}, + {"name": "Garapan (Saipan)", "x": 133500, "y": 172500, "side": "blue"}, + ], + + "sam_zones": [ + {"name": "Andersen AFB Defense", "x": 13600, "y": 144700, "radius": 25000, "side": "blue"}, + {"name": "Guam Harbor Defense", "x": 4000, "y": 133000, "radius": 20000, "side": "blue"}, + {"name": "Saipan Defense", "x": 137400, "y": 172000, "radius": 15000, "side": "blue"}, + {"name": "Pagan OPFOR SAM", "x": 305000, "y": 230000, "radius": 30000, "side": "red"}, + {"name": "Northern Islands OPFOR", "x": 340000, "y": 250000, "radius": 25000, "side": "red"}, + ], + + "front_lines": [ + { + "name": "Northern Island Chain", + "description": "OPFOR has seized northern islands; coalition holds southern Marianas", + "blue_start": {"x": 200000, "y": 190000}, + "red_start": {"x": 280000, "y": 220000}, + "axis": "south", + "width": 60000, + }, + ], + + # Pacific carrier operations — this map is built for blue-water naval combat + "naval_zones": [ + {"name": "Philippine Sea Carrier Station", "x": -100000, "y": 150000, "radius": 100000}, + {"name": "Western Pacific Patrol", "x": 200000, "y": 180000, "radius": 80000}, + {"name": "Guam Approaches", "x": 0, "y": 110000, "radius": 60000}, + {"name": "Northern Marianas Sea", "x": 220000, "y": 210000, "radius": 70000}, + ], + + "cap_orbits": [ + {"name": "Southern CAP", "x1": 80000, "y1": 140000, "x2": 100000, "y2": 160000, "alt": 9000, "side": "blue"}, + {"name": "Saipan CAP", "x1": 120000, "y1": 165000, "x2": 140000, "y2": 175000, "alt": 8500, "side": "blue"}, + {"name": "OPFOR CAP North", "x1": 290000, "y1": 225000, "x2": 310000, "y2": 240000, "alt": 8000, "side": "red"}, + ], + + "support_orbits": { + "tanker": {"name": "Arco", "x1": 50000, "y1": 140000, "x2": 60000, "y2": 160000, "alt": 7600, "freq": 251.0, "tacan": "51Y"}, + "awacs": {"name": "Darkstar", "x1": 30000, "y1": 140000, "x2": 40000, "y2": 160000, "alt": 9500, "freq": 252.0}, + }, + + "convoy_routes": { + "red": [ + { + "name": "Northern Supply Chain", + "waypoints": [ + {"x": 347000, "y": 260000}, # Agrihan + {"x": 305000, "y": 230000}, # Pagan + {"x": 250000, "y": 205000}, # Forward position + ], + }, + ], + "blue": [ + { + "name": "Saipan-Guam Logistics", + "waypoints": [ + {"x": 13600, "y": 144700}, # Andersen + {"x": 60000, "y": 110000}, # Rota + {"x": 117000, "y": 162000}, # Tinian + {"x": 137400, "y": 172000}, # Saipan + ], + }, + ], + }, +} diff --git a/src/maps/persian_gulf.py b/src/maps/persian_gulf.py new file mode 100644 index 0000000..c7a37d2 --- /dev/null +++ b/src/maps/persian_gulf.py @@ -0,0 +1,301 @@ +""" +Persian Gulf Map Database +Covers UAE, Oman, Iran, Qatar, Bahrain, and the Strait of Hormuz. +DCS coordinates use a local X/Y system (meters) relative to the map origin. +""" + +PERSIAN_GULF_MAP = { + "display_name": "Persian Gulf", + "theater_id": "PersianGulf", + "dcs_theater": "PersianGulf", + + "bounds": { + "x_min": -200000, "x_max": 900000, + "y_min": -200000, "y_max": 900000, + }, + + "default_date": {"year": 2024, "month": 3, "day": 21}, + + "airfields": [ + # ---- BLUE (UAE / Coalition) ---- + { + "name": "Al Dhafra", + "id": 1, + "x": 95000, + "y": 573000, + "alt": 27, + "default_coalition": "blue", + "runways": [{"heading": 132, "length": 3660}, {"heading": 312, "length": 3660}], + "tacan": "25X", + "ils": "109.10", + "atis": "127.850", + }, + { + "name": "Al Minhad", + "id": 2, + "x": 131000, + "y": 609000, + "alt": 66, + "default_coalition": "blue", + "runways": [{"heading": 160, "length": 3660}, {"heading": 340, "length": 3660}], + "tacan": "99X", + "ils": "110.30", + }, + { + "name": "Dubai International", + "id": 3, + "x": 152000, + "y": 637000, + "alt": 19, + "default_coalition": "blue", + "runways": [{"heading": 121, "length": 4000}], + "tacan": "33X", + "ils": "110.90", + }, + { + "name": "Sharjah International", + "id": 4, + "x": 162000, + "y": 652000, + "alt": 34, + "default_coalition": "blue", + "runways": [{"heading": 121, "length": 3660}], + }, + { + "name": "Fujairah International", + "id": 5, + "x": 183000, + "y": 686000, + "alt": 13, + "default_coalition": "blue", + "runways": [{"heading": 91, "length": 2440}], + }, + { + "name": "Khasab", + "id": 6, + "x": 239000, + "y": 722000, + "alt": 20, + "default_coalition": "blue", + "runways": [{"heading": 20, "length": 1800}], + }, + { + "name": "Al Bateen", + "id": 7, + "x": 81000, + "y": 574000, + "alt": 16, + "default_coalition": "blue", + "runways": [{"heading": 99, "length": 2750}], + }, + { + "name": "Abu Dhabi International", + "id": 8, + "x": 73000, + "y": 554000, + "alt": 27, + "default_coalition": "blue", + "runways": [{"heading": 131, "length": 4100}], + "ils": "111.10", + }, + { + "name": "Liwa", + "id": 9, + "x": 17000, + "y": 477000, + "alt": 146, + "default_coalition": "blue", + "runways": [{"heading": 90, "length": 3048}], + }, + { + "name": "Al Ain International", + "id": 10, + "x": 109000, + "y": 614000, + "alt": 264, + "default_coalition": "blue", + "runways": [{"heading": 100, "length": 4000}], + }, + { + "name": "Ras Al Khaimah", + "id": 11, + "x": 201000, + "y": 669000, + "alt": 31, + "default_coalition": "blue", + "runways": [{"heading": 180, "length": 2900}], + }, + + # ---- BLUE (Qatar / Bahrain) ---- + { + "name": "Al Udeid", + "id": 20, + "x": -71000, + "y": 428000, + "alt": 20, + "default_coalition": "blue", + "runways": [{"heading": 160, "length": 3660}], + "tacan": "21X", + "ils": "110.70", + "atis": "126.300", + }, + { + "name": "Doha International", + "id": 21, + "x": -67000, + "y": 435000, + "alt": 9, + "default_coalition": "blue", + "runways": [{"heading": 160, "length": 4572}], + }, + + # ---- RED (Iranian side) ---- + { + "name": "Bandar Abbas", + "id": 30, + "x": 278000, + "y": 721000, + "alt": 9, + "default_coalition": "red", + "runways": [{"heading": 216, "length": 3560}, {"heading": 36, "length": 3560}], + }, + { + "name": "Bandar Lengeh", + "id": 31, + "x": 198000, + "y": 681000, + "alt": 23, + "default_coalition": "red", + "runways": [{"heading": 255, "length": 2650}], + }, + { + "name": "Sirri Island", + "id": 32, + "x": 119000, + "y": 636000, + "alt": 7, + "default_coalition": "red", + "runways": [{"heading": 90, "length": 3048}], + }, + { + "name": "Lavan Island", + "id": 33, + "x": 145000, + "y": 653000, + "alt": 22, + "default_coalition": "red", + "runways": [{"heading": 100, "length": 2200}], + }, + { + "name": "Lar", + "id": 34, + "x": 302000, + "y": 706000, + "alt": 792, + "default_coalition": "red", + "runways": [{"heading": 210, "length": 2700}], + }, + { + "name": "Havadarya", + "id": 35, + "x": 275000, + "y": 729000, + "alt": 19, + "default_coalition": "red", + "runways": [{"heading": 128, "length": 2200}], + }, + { + "name": "Qeshm Island", + "id": 36, + "x": 258000, + "y": 730000, + "alt": 16, + "default_coalition": "red", + "runways": [{"heading": 100, "length": 2820}], + }, + { + "name": "Abu Musa Island", + "id": 37, + "x": 158000, + "y": 655000, + "alt": 14, + "default_coalition": "red", + "runways": [{"heading": 75, "length": 2000}], + }, + ], + + "cities": [ + {"name": "Abu Dhabi", "x": 83000, "y": 567000, "side": "blue"}, + {"name": "Dubai", "x": 152000, "y": 636000, "side": "blue"}, + {"name": "Sharjah", "x": 162000, "y": 651000, "side": "blue"}, + {"name": "Doha", "x": -67000, "y": 432000, "side": "blue"}, + {"name": "Bandar Abbas", "x": 278000, "y": 720000, "side": "red"}, + {"name": "Bandar Lengeh", "x": 198000, "y": 680000, "side": "red"}, + {"name": "Strait of Hormuz", "x": 245000, "y": 700000, "side": "contested"}, + ], + + "sam_zones": [ + {"name": "Bandar Abbas Defense", "x": 275000, "y": 715000, "radius": 40000, "side": "red"}, + {"name": "Bandar Lengeh Area", "x": 198000, "y": 678000, "radius": 25000, "side": "red"}, + {"name": "Qeshm Island Defense", "x": 258000, "y": 728000, "radius": 20000, "side": "red"}, + {"name": "Strait North Shore", "x": 242000, "y": 708000, "radius": 30000, "side": "red"}, + {"name": "Abu Musa Island", "x": 158000, "y": 654000, "radius": 15000, "side": "red"}, + {"name": "Lavan Island Area", "x": 145000, "y": 650000, "radius": 15000, "side": "red"}, + {"name": "Al Dhafra Defense", "x": 95000, "y": 570000, "radius": 20000, "side": "blue"}, + {"name": "Dubai Air Defense", "x": 152000, "y": 635000, "radius": 15000, "side": "blue"}, + ], + + "front_lines": [ + { + "name": "Strait of Hormuz Front", + "description": "Naval and air engagement across the Strait", + "blue_start": {"x": 195000, "y": 680000}, + "red_start": {"x": 245000, "y": 710000}, + "axis": "north", + "width": 40000, + }, + ], + + # Extensive naval zones — carrier ops are the heart of PG missions + "naval_zones": [ + {"name": "Gulf of Oman Carrier Station", "x": 320000, "y": 690000, "radius": 60000}, + {"name": "Persian Gulf Patrol", "x": 100000, "y": 600000, "radius": 80000}, + {"name": "Strait of Hormuz Transit", "x": 245000, "y": 700000, "radius": 30000}, + {"name": "Southern Gulf", "x": 0, "y": 500000, "radius": 80000}, + ], + + "cap_orbits": [ + {"name": "Gulf CAP Alpha", "x1": 200000, "y1": 660000, "x2": 220000, "y2": 690000, "alt": 7500, "side": "blue"}, + {"name": "Gulf CAP Bravo", "x1": 160000, "y1": 640000, "x2": 180000, "y2": 660000, "alt": 8000, "side": "blue"}, + {"name": "Iranian CAP", "x1": 260000, "y1": 720000, "x2": 280000, "y2": 700000, "alt": 7000, "side": "red"}, + {"name": "Hormuz CAP", "x1": 240000, "y1": 705000, "x2": 250000, "y2": 695000, "alt": 7000, "side": "red"}, + ], + + "support_orbits": { + "tanker": {"name": "Shell", "x1": 140000, "y1": 600000, "x2": 150000, "y2": 630000, "alt": 7600, "freq": 251.0, "tacan": "51Y"}, + "awacs": {"name": "Magic", "x1": 120000, "y1": 580000, "x2": 130000, "y2": 610000, "alt": 9000, "freq": 252.0}, + }, + + "convoy_routes": { + "red": [ + { + "name": "Bandar Abbas Supply Route", + "waypoints": [ + {"x": 278000, "y": 721000}, # Bandar Abbas + {"x": 258000, "y": 730000}, # Qeshm area + {"x": 242000, "y": 710000}, # Strait crossing + ], + }, + ], + "blue": [ + { + "name": "UAE Logistics Route", + "waypoints": [ + {"x": 95000, "y": 573000}, # Al Dhafra + {"x": 131000, "y": 609000}, # Al Minhad + {"x": 152000, "y": 637000}, # Dubai + ], + }, + ], + }, +} diff --git a/src/units.py b/src/units.py index 068af37..466b810 100644 --- a/src/units.py +++ b/src/units.py @@ -28,7 +28,7 @@ 6: {"CLSID": "{AAQ-28_LITENING}"}, # Targeting pod 7: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, # AIM-120C 8: {"CLSID": "{6D21ECEA-F85B-4E8D-9D51-31DC9B8AA4EF}"}, # AGM-88C - 9: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, # AIM-9X + 9: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, # AIM-9X }, }, "CAP": { @@ -39,7 +39,7 @@ 3: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 4: {"CLSID": "{8A0BE8AE-58D4-4572-9263-3144C45E5D75}"}, 5: {"CLSID": "{F376DBEE-4CAE-41BA-ADD9-B2910AC95DEC}"}, - 6: {"CLSID": ""}, + 6: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, # AIM-9X 7: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 8: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 9: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, @@ -129,6 +129,142 @@ }, }, }, + "F-15C": { + "type": "F-15C", + "display_name": "F-15C Eagle", + "category": "fighter", + "roles": ["CAP", "escort", "sweep"], + "fuel": 6100, + "chaff": 60, + "flare": 60, + "radio_freq": 305.0, + "default_loadouts": { + "CAP": { + "description": "6x AIM-120C, 2x AIM-9M, centerline tank", + "pylons": { + 1: {"CLSID": "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}"}, # AIM-9M + 2: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, # AIM-120C + 3: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 4: {"CLSID": "{E1F29B21-F291-4589-9FD8-3272EEC69506}"}, # Fuel tank + 5: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 6: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 7: {"CLSID": "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}"}, + }, + }, + "escort": { + "description": "6x AIM-120C, 2x AIM-9M, centerline tank", + "pylons": { + 1: {"CLSID": "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}"}, + 2: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 3: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 4: {"CLSID": "{E1F29B21-F291-4589-9FD8-3272EEC69506}"}, + 5: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 6: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 7: {"CLSID": "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}"}, + }, + }, + }, + }, + "F-15E": { + "type": "F-15ESE", + "display_name": "F-15E Strike Eagle", + "category": "fighter", + "roles": ["strike", "SEAD", "escort", "anti-ship", "CAP"], + "fuel": 6100, + "chaff": 60, + "flare": 60, + "radio_freq": 305.0, + "default_loadouts": { + "strike": { + "description": "4x GBU-31 JDAM, 2x AIM-120C, 2x AIM-9M, LANTIRN pod, fuel", + "pylons": {}, + }, + "SEAD": { + "description": "4x AGM-88C HARM, 2x AIM-120C, 2x AIM-9M", + "pylons": {}, + }, + "CAP": { + "description": "6x AIM-120C, 2x AIM-9M, fuel tank", + "pylons": { + 1: {"CLSID": "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}"}, + 2: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 3: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 4: {"CLSID": "{E1F29B21-F291-4589-9FD8-3272EEC69506}"}, + 5: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 6: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, + 7: {"CLSID": "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}"}, + }, + }, + }, + }, + "AV-8B": { + "type": "AV8BNA", + "display_name": "AV-8B Harrier II", + "category": "attacker", + "roles": ["CAS", "strike", "anti-ship"], + "fuel": 3060, + "chaff": 60, + "flare": 60, + "radio_freq": 305.0, + "default_loadouts": { + "CAS": { + "description": "4x GBU-12, 2x AGM-65E Maverick, 2x AIM-9M, LITENING pod", + "pylons": {}, + }, + "strike": { + "description": "6x Mk-83, 2x AIM-9M", + "pylons": {}, + }, + "anti-ship": { + "description": "2x AGM-65E Maverick, 2x Mk-83, 2x AIM-9M", + "pylons": {}, + }, + }, + }, + "M-2000C": { + "type": "M-2000C", + "display_name": "Mirage 2000C", + "category": "fighter", + "roles": ["CAP", "sweep", "escort", "SEAD"], + "fuel": 3160, + "chaff": 54, + "flare": 54, + "radio_freq": 305.0, + "default_loadouts": { + "CAP": { + "description": "2x Magic II, 2x Super 530D, centerline tank", + "pylons": {}, + }, + "SEAD": { + "description": "2x AS-30L, 2x Magic II", + "pylons": {}, + }, + "sweep": { + "description": "2x Magic II, 2x Super 530D", + "pylons": {}, + }, + }, + }, + "AH-64D": { + "type": "AH-64D_BLK_II", + "display_name": "AH-64D Apache", + "category": "helicopter", + "roles": ["CAS", "CSAR"], + "fuel": 1160, + "chaff": 30, + "flare": 30, + "radio_freq": 305.0, + "default_loadouts": { + "CAS": { + "description": "16x Hellfire AGM-114K, 2x rocket pods, 30mm cannon", + "pylons": {}, + }, + "CSAR": { + "description": "8x Hellfire AGM-114K, 2x rocket pods, 30mm cannon", + "pylons": {}, + }, + }, + }, } # ============================================================ @@ -167,37 +303,37 @@ "loadouts": { "SEAD": { "pylons": { - 1: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, # AIM-9X + 1: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, # AIM-9X 2: {"CLSID": "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}"}, # AGM-88C 3: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, # AIM-120C 4: {"CLSID": "{8A0BE8AE-58D4-4572-9263-3144C45E5D75}"}, # ECM 5: {"CLSID": "{F376DBEE-4CAE-41BA-ADD9-B2910AC95DEC}"}, # 370gal tank 7: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, # AIM-120C 8: {"CLSID": "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}"}, # AGM-88C - 9: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, # AIM-9X + 9: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, # AIM-9X }, }, "CAP": { "pylons": { - 1: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, + 1: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, 2: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 3: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 5: {"CLSID": "{F376DBEE-4CAE-41BA-ADD9-B2910AC95DEC}"}, 7: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 8: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, - 9: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, + 9: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, }, }, "strike": { "pylons": { - 1: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, + 1: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, 2: {"CLSID": "{GBU-31}"}, 3: {"CLSID": "{GBU-31}"}, 5: {"CLSID": "{F376DBEE-4CAE-41BA-ADD9-B2910AC95DEC}"}, 6: {"CLSID": "{AAQ-28_LITENING}"}, 7: {"CLSID": "{GBU-31}"}, 8: {"CLSID": "{GBU-31}"}, - 9: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, + 9: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, }, }, }, @@ -211,36 +347,36 @@ "loadouts": { "CAP": { "pylons": { - 1: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, # AIM-9X + 1: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, # AIM-9X 2: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, # AIM-120C 3: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 5: {"CLSID": "{FPU_8A_FUEL_TANK}"}, 7: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 8: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, - 9: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, + 9: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, }, }, "SEAD": { "pylons": { - 1: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, + 1: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, 2: {"CLSID": "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}"}, # AGM-88 3: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 5: {"CLSID": "{FPU_8A_FUEL_TANK}"}, 7: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 8: {"CLSID": "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}"}, - 9: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, + 9: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, }, }, "strike": "SEAD", "anti-ship": { "pylons": { - 1: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, + 1: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, 2: {"CLSID": "{AGM_84D}"}, # Harpoon 3: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 5: {"CLSID": "{FPU_8A_FUEL_TANK}"}, 7: {"CLSID": "{40EF17B7-F508-45de-8566-6FBBE0C1A276}"}, 8: {"CLSID": "{AGM_84D}"}, - 9: {"CLSID": "{5CE2FF2A-645A-4197-B48D-8720PD3897FF}"}, + 9: {"CLSID": "{9B31BFDB-4411-4B9F-80C4-E78A2D8E3E80}"}, }, }, }, @@ -424,10 +560,10 @@ }, "SEAD": { "pylons": { - 1: {"CLSID": "{X-25MP}"}, # Kh-25MP ARM - 2: {"CLSID": "{X-25MP}"}, - 9: {"CLSID": "{X-25MP}"}, - 10: {"CLSID": "{X-25MP}"}, + 1: {"CLSID": "{0243B919-7624-47B6-A489-B3EB547EC2B4}"}, # Kh-25MP ARM + 2: {"CLSID": "{0243B919-7624-47B6-A489-B3EB547EC2B4}"}, + 9: {"CLSID": "{0243B919-7624-47B6-A489-B3EB547EC2B4}"}, + 10: {"CLSID": "{0243B919-7624-47B6-A489-B3EB547EC2B4}"}, }, }, }, @@ -780,6 +916,28 @@ "ground_war_default": False, "convoy_side": "blue", }, + "CSAR": { + "description": "Combat Search and Rescue — Recover downed aircrew", + "player_goes_first": False, + "default_enemy_sam": ["SA-8", "ZSU-23-4"], + "default_enemy_air": ["MiG-29A"], + "default_friendly_flights": [ + {"task": "escort", "aircraft": "F-15C", "count": 2}, + {"task": "SEAD", "aircraft": "F-16C_AI", "count": 2}, + ], + "ground_war_default": True, + }, + "FAC": { + "description": "Forward Air Controller (Airborne) — Coordinate CAS package", + "player_goes_first": False, + "default_enemy_sam": ["SA-8", "SA-15", "ZSU-23-4"], + "default_enemy_air": ["MiG-29A"], + "default_friendly_flights": [ + {"task": "CAS", "aircraft": "A-10C_AI", "count": 4}, + {"task": "escort", "aircraft": "F-15C", "count": 2}, + ], + "ground_war_default": True, + }, } # Mapping from common LLM outputs to our keys @@ -790,6 +948,12 @@ "a-10": "A-10C", "a10": "A-10C", "warthog": "A-10C", "a-10c": "A-10C", "a10c": "A-10C", "hawg": "A-10C", "thunderbolt": "A-10C", "jf-17": "JF-17", "jf17": "JF-17", "thunder": "JF-17", + "f-15c": "F-15C", "f15c": "F-15C", "eagle": "F-15C", "f-15": "F-15C", + "f-15e": "F-15E", "f15e": "F-15E", "strike eagle": "F-15E", "mudhen": "F-15E", + "av-8b": "AV-8B", "av8b": "AV-8B", "harrier": "AV-8B", "av-8": "AV-8B", + "m-2000c": "M-2000C", "m2000c": "M-2000C", "mirage": "M-2000C", + "mirage 2000": "M-2000C", "mirage2000": "M-2000C", + "ah-64": "AH-64D", "ah64": "AH-64D", "apache": "AH-64D", "ah-64d": "AH-64D", } MISSION_TYPE_ALIASES = { @@ -807,6 +971,10 @@ "convoy defense": "convoy_defense", "convoy escort": "convoy_defense", "protect convoy": "convoy_defense", "defend convoy": "convoy_defense", "convoy protection": "convoy_defense", + "csar": "CSAR", "search and rescue": "CSAR", "rescue": "CSAR", + "combat sar": "CSAR", "pilot rescue": "CSAR", + "fac": "FAC", "faca": "FAC", "forward air controller": "FAC", + "jtac": "FAC", "tac": "FAC", "coordinate cas": "FAC", } diff --git a/src/validator.py b/src/validator.py index fb22a70..b9127d9 100644 --- a/src/validator.py +++ b/src/validator.py @@ -5,9 +5,17 @@ """ import math +import re from src.maps import MAP_REGISTRY from src.units import PLAYER_AIRCRAFT, SAM_SYSTEMS +# Full GUID pattern: {8hex-4hex-4hex-4hex-12hex} +_GUID_RE = re.compile( + r"^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$" +) +# DCS shorthand pattern: {WORD} — letters, digits, hyphens, underscores only +_SHORTHAND_RE = re.compile(r"^\{[A-Za-z0-9_\-\.]+\}$") + class ValidationResult: """Holds results of mission validation.""" @@ -64,6 +72,7 @@ def validate_mission(mission_data: dict, plan: dict) -> ValidationResult: _check_fuel(mission_data, plan, result) _check_waypoints(mission_data, plan, result) _check_group_integrity(mission_data, result) + _check_clsids(mission_data, result) return result @@ -259,3 +268,30 @@ def _check_group_integrity(data: dict, r: ValidationResult): f"Consider reducing ground forces or using lighter difficulty.") elif total > 100: r.info.append(f"Mission has {total} units — should be fine but watch FPS") + + +def _check_clsids(data: dict, r: ValidationResult): + """Validate weapon CLSID strings on all units to catch typos before packaging.""" + all_groups = [] + for key in ("blue_air", "red_air"): + all_groups.extend(data.get(key, [])) + player = data.get("player_group") + if player: + all_groups.append(player) + + for group in all_groups: + group_name = group.get("name", "?") + for unit in group.get("units", []): + pylons: dict = unit.get("pylons", {}) + for pylon_num, pylon_data in pylons.items(): + clsid = pylon_data.get("CLSID", "") + if not clsid: + r.warnings.append( + f"Group '{group_name}' pylon {pylon_num} has empty CLSID — " + f"weapon slot will be ignored" + ) + elif not (_GUID_RE.match(clsid) or _SHORTHAND_RE.match(clsid)): + r.errors.append( + f"Group '{group_name}' pylon {pylon_num} has invalid CLSID: " + f"'{clsid}' — DCS will fail to load this unit's loadout" + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-311.pyc b/tests/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..8596125 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-311.pyc differ diff --git a/tests/__pycache__/test_smoke.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_smoke.cpython-311-pytest-9.0.3.pyc new file mode 100644 index 0000000..21eb409 Binary files /dev/null and b/tests/__pycache__/test_smoke.cpython-311-pytest-9.0.3.pyc differ diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..3dbedfe --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,208 @@ +""" +Smoke tests for DCS Mission Manager. +These tests validate that the core pipeline doesn't crash end-to-end. +No .miz file is written to disk; we test up to the Lua generation stage. +""" + +import sys +import os +import pytest + +# Ensure project root is on the path regardless of working directory +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# --------------------------------------------------------------------------- +# Unit registry tests +# --------------------------------------------------------------------------- + +class TestUnits: + def test_player_aircraft_present(self): + from src.units import PLAYER_AIRCRAFT + assert len(PLAYER_AIRCRAFT) >= 4, "Expected at least 4 player aircraft" + + def test_new_aircraft_present(self): + from src.units import PLAYER_AIRCRAFT + for key in ("F-15C", "F-15E", "AV-8B", "M-2000C", "AH-64D"): + assert key in PLAYER_AIRCRAFT, f"Missing player aircraft: {key}" + + def test_mission_templates_present(self): + from src.units import MISSION_TEMPLATES + for mt in ("SEAD", "CAS", "CAP", "strike", "anti-ship", "escort", + "convoy_attack", "convoy_defense", "CSAR", "FAC"): + assert mt in MISSION_TEMPLATES, f"Missing mission template: {mt}" + + def test_aircraft_aliases_resolve(self): + from src.units import resolve_aircraft + assert resolve_aircraft("viper") == "F-16C" + assert resolve_aircraft("hornet") == "F/A-18C" + assert resolve_aircraft("eagle") == "F-15C" + assert resolve_aircraft("apache") == "AH-64D" + assert resolve_aircraft("mirage") == "M-2000C" + + def test_mission_type_aliases_resolve(self): + from src.units import resolve_mission_type + assert resolve_mission_type("wild weasel") == "SEAD" + assert resolve_mission_type("rescue") == "CSAR" + assert resolve_mission_type("fac") == "FAC" + + +# --------------------------------------------------------------------------- +# Map registry tests +# --------------------------------------------------------------------------- + +class TestMaps: + def test_all_maps_registered(self): + from src.maps import MAP_REGISTRY + for key in ("Caucasus", "Syria", "ColdWarGermany", "PersianGulf", "MarianaIslands"): + assert key in MAP_REGISTRY, f"Map not registered: {key}" + + def test_maps_have_airfields(self): + from src.maps import MAP_REGISTRY + for key, data in MAP_REGISTRY.items(): + assert len(data.get("airfields", [])) > 0, f"{key} has no airfields" + + def test_map_aliases_resolve(self): + from src.maps import MAP_ALIASES + assert MAP_ALIASES.get("persian gulf") == "PersianGulf" + assert MAP_ALIASES.get("guam") == "MarianaIslands" + assert MAP_ALIASES.get("caucasus") == "Caucasus" + + def test_persian_gulf_has_blue_airfields(self): + from src.maps import MAP_REGISTRY + pg = MAP_REGISTRY["PersianGulf"] + blue = [af for af in pg["airfields"] if af.get("default_coalition") == "blue"] + assert len(blue) >= 5, "Persian Gulf should have at least 5 blue airfields" + + def test_mariana_islands_has_andersen(self): + from src.maps import MAP_REGISTRY + mi = MAP_REGISTRY["MarianaIslands"] + names = [af["name"] for af in mi["airfields"]] + assert any("Andersen" in n for n in names), "Mariana Islands missing Andersen AFB" + + +# --------------------------------------------------------------------------- +# Flight profile tests +# --------------------------------------------------------------------------- + +class TestFlightProfile: + def test_profiles_for_new_aircraft(self): + from src.flight_profile import AIRCRAFT_PROFILES, get_profile + for key in ("F-15C", "F-15E", "AV-8B", "M-2000C", "AH-64D"): + assert key in AIRCRAFT_PROFILES, f"No flight profile for {key}" + p = get_profile(key) + assert p["cruise_speed_kts"] > 0 + + def test_fallback_profile(self): + from src.flight_profile import get_profile + # Unknown aircraft should fall back to F-16C profile + p = get_profile("UNKNOWN_TYPE") + assert p["cruise_speed_kts"] == get_profile("F-16C")["cruise_speed_kts"] + + def test_mission_profiles_for_new_types(self): + from src.flight_profile import MISSION_PROFILES + assert "CSAR" in MISSION_PROFILES + assert "FAC" in MISSION_PROFILES + + +# --------------------------------------------------------------------------- +# Mission builder smoke test +# --------------------------------------------------------------------------- + +class TestMissionBuilder: + def _minimal_plan(self, mission_type="SEAD", map_name="Caucasus", aircraft="F-16C"): + return { + "player_aircraft": aircraft, + "map_name": map_name, + "mission_type": mission_type, + "player_airfield": "AUTO", + "difficulty": "medium", + "time_of_day": "morning", + "weather": "clear", + "player_count": 1, + "wingman": False, + "enemy_sam_sites": [{"type": "SA-6", "location_desc": "Forward position"}], + "enemy_air": [{"aircraft": "MiG-29A", "task": "CAP", "count": 2}], + "friendly_flights": [], + "ground_war": {"enabled": False}, + "_operation_name": "TEST OP", + } + + def test_build_sead_caucasus(self): + from src.generators.mission_builder import MissionBuilder + plan = self._minimal_plan("SEAD", "Caucasus", "F-16C") + data = MissionBuilder(plan).build() + assert "player_group" in data + assert data["player_group"]["units"][0]["skill"] == "Player" + + def test_build_cas_persian_gulf(self): + from src.generators.mission_builder import MissionBuilder + plan = self._minimal_plan("CAS", "PersianGulf", "A-10C") + data = MissionBuilder(plan).build() + assert "player_group" in data + + def test_multiplayer_slots(self): + from src.generators.mission_builder import MissionBuilder + plan = self._minimal_plan("CAP", "Caucasus", "F-15C") + plan["player_count"] = 3 + plan["wingman"] = False + data = MissionBuilder(plan).build() + units = data["player_group"]["units"] + assert len(units) == 3 + assert units[0]["skill"] == "Player" + assert units[1]["skill"] == "Client" + assert units[2]["skill"] == "Client" + + def test_build_mariana_islands(self): + from src.generators.mission_builder import MissionBuilder + plan = self._minimal_plan("CAP", "MarianaIslands", "F/A-18C") + data = MissionBuilder(plan).build() + assert "player_group" in data + + +# --------------------------------------------------------------------------- +# Kneeboard generator tests +# --------------------------------------------------------------------------- + +class TestKneeboard: + def test_generates_valid_png(self): + from src.generators.kneeboard_generator import generate_kneeboard_png + briefing = "1. SITUATION\nTest mission briefing text.\n\n2. MISSION\nDestroy targets." + png_bytes = generate_kneeboard_png(briefing, "F-16C_50") + assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n", "Output is not a valid PNG" + assert len(png_bytes) > 1000, "PNG seems too small" + + def test_aircraft_folder_mapping(self): + from src.generators.kneeboard_generator import get_dcs_aircraft_folder + assert get_dcs_aircraft_folder("F-16C_50") == "F-16C_50" + assert get_dcs_aircraft_folder("FA-18C_hornet") == "FA-18C_hornet" + assert get_dcs_aircraft_folder("UNKNOWN") == "UNKNOWN" + + +# --------------------------------------------------------------------------- +# Naming / difficulty tests +# --------------------------------------------------------------------------- + +class TestNaming: + def test_generates_operation_name(self): + from src.naming import generate_mission_name + name = generate_mission_name("SEAD") + assert isinstance(name, str) and len(name) > 3 + + def test_generates_filename(self): + from src.naming import generate_filename + fn = generate_filename("SEAD", "Caucasus", "Operation Iron Fist") + assert fn.endswith(".miz") + + +class TestDifficulty: + def test_scale_does_not_crash(self): + from src.difficulty import scale_plan + plan = { + "difficulty": "hard", + "enemy_sam_sites": [{"type": "SA-6"}], + "enemy_air": [{"aircraft": "MiG-29A", "task": "CAP", "count": 2}], + "friendly_flights": [], + } + scaled = scale_plan(plan) + assert "enemy_sam_sites" in scaled