diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 8f469615..246cd000 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -12,15 +12,16 @@ on: jobs: build: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.10", "3.11"] + os: [windows-latest, ubuntu-latest] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install deps @@ -41,3 +42,23 @@ jobs: run: | pip install pytest pytest + + package: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + - name: Set up uv + uses: astral-sh/setup-uv@v10.3.2 + - name: Build sdist and wheel + run: | + uv build + - name: Check package metadata + run: | + uvx twine check --strict dist/* + - name: Smoke test installed wheel + run: | + uv venv /tmp/wheel-test + uv pip install --python /tmp/wheel-test dist/*.whl + cd /tmp && /tmp/wheel-test/bin/python -c "import dcs; assert dcs.terrain.Caucasus().city_graph.nodes" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..6d24ad95 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# AGENTS.md + +Guidance for AI coding agents (and humans) working in this repository. + +## Project + +**pydcs** (`dcs` package): a Python framework to create, load, edit and save mission files (`.miz`) for +the flight simulator *DCS World*. A `.miz` is a zip archive with Lua tables (`mission`, `options`, +`warehouses`, `l10n/DEFAULT/dictionary`, `mapResource`, …) plus resources. License: LGPLv3. +Package metadata and version live in `pyproject.toml` (setuptools backend, no `setup.py`). + +## Setup & commands + +Only runtime dependency is `pyproj` (`requirements.txt`). Use a virtualenv (`venv/` is gitignored): + +```sh +python3 -m venv venv +./venv/bin/pip install -r requirements.txt mypy flake8 pytest +``` + +CI (`.github/workflows/pythonpackage.yml`, runs on **Windows and Linux**, Python 3.11–3.15) does exactly this — +run all three before considering a change done: + +```sh +mypy dcs # type check (must be clean) +flake8 . # lint, config in setup.cfg +flake8 . --isolated --count --select=E9,F63,F7,F82 --show-source --statistics # syntax check of excluded files +pytest # tests/ and dcs/lua/test_parse.py +``` + +- Single test: `pytest tests/test_mission.py::BasicTests::test_basic_mission` +- Tests use paths relative to the repo root (e.g. `tests/missions/*.miz` as input, `missions/` as output + dir — gitignored), so run pytest from the repo root. +- The suite must pass on Windows and Linux. Use `os.path` / `pathlib` instead of hard-coded `\\` + separators. Tests for genuinely Windows-only behavior (e.g. the `ansi` codec, Steam library parsing) + are gated with `@pytest.mark.skipif(sys.platform != "win32", ...)`. +- Packaging: `make build` (`uv build`, sdist + wheel into `dist/`; `python -m build` works too), + `make upload` (twine, credentials from `~/.pypirc`). Packages are auto-discovered (`dcs*`); non-Python + files to ship must be listed under `[tool.setuptools.package-data]` in `pyproject.toml`. A separate CI + job (`package`, Linux) runs `uv build`, `twine check --strict` and a smoke test of the installed wheel. + +## Code style + +- flake8 with max line length **127**, `max-complexity` 10 (C901 ignored). Generated data files, `tests/`, + `tools/` and `doc/` are excluded from flake8 (see `setup.cfg`). +- The package ships `py.typed`; new/changed code in `dcs/` should be type annotated and pass `mypy dcs`. +- Commit messages: short imperative summary line (e.g. "Fix TriggerZoneQuadPoint argument forwarding to + parent __init__"); data refreshes are titled "DCS export". + +## Architecture + +- `dcs/mission.py` — `Mission` is the entry point: `Mission(terrain)`, `load_file()`, `save()`, and + helpers to add groups/flights (`flight_group_inflight`, `awacs_flight`, `refuel_flight`, …). + Holds coalitions, countries, triggers, weather, translations, drawings, warehouses, etc. +- **Load/save pattern**: most model classes implement `load_from_dict(d)` / `create_from_dict(...)` to + read the parsed Lua table and `dict()` to produce it again. Round-tripping a mission without changes + must not lose data — when adding fields, implement both directions and add a load/save test. +- `dcs/lua/` — own Lua table parser (`parse.loads`) and serializer (`serialize.dumps`) used for all + `.miz` content. Serialization key order matters (ints numerically, then strings alphabetically). +- `dcs/translation.py` — `Translation`/`String`/`ResourceKey`: localized strings (`DictKey_*`) and + resource files embedded in the `.miz`. +- `dcs/country.py`, `dcs/coalition.py`, `dcs/unitgroup.py`, `dcs/unit.py`, `dcs/flyingunit.py`, + `dcs/point.py` — the object model of countries → groups → units / waypoints. +- `dcs/task.py`, `dcs/action.py`, `dcs/condition.py`, `dcs/triggers.py`, `dcs/goals.py` — waypoint + tasks/commands/options and the trigger system (rules, conditions, actions, trigger zones). +- `dcs/unittype.py` — base classes (`FlyingType`, `VehicleType`, `ShipType`, `StaticType`) for unit + type definitions; `dcs/payloads.py` and `dcs/liveries/` read payloads and liveries from the user's + DCS installation (`dcs/installation.py`, `dcs/winreg.py` — degrade gracefully off Windows). +- `dcs/terrain/` — `terrain.py` defines `Terrain`, `Airport`, `Runway`, `ParkingSlot`, `Warehouse`, + `Graph`; one subpackage per map (`caucasus`, `nevada`, `normandy`, `persiangulf`, `thechannel`, + `syria`, `marianaislands`, `falklands`, `sinai`, `kola`, `germany`), each with `.py` (the + `Terrain` subclass incl. bounds, projection, `utc_offset`), `airports.py` (generated airport data) + and `projection.py`. `dcs/mapping.py` handles points, vectors and lat/lon conversion via `pyproj`. +- `dcs/drawing/` — mission editor drawings (layers, lines, polygons, icons, text boxes). +- `dcs/scripts/` — example random-mission generators installed as console scripts (`dcs_random`, + `dcs_dogfight_wwii`, `dcs_oil_convoy`). + +## Generated files — don't hand-edit + +These are produced from a running DCS installation and get overwritten on the next export: + +- `dcs/planes.py`, `dcs/helicopters.py`, `dcs/vehicles.py`, `dcs/ships.py`, `dcs/statics.py`, + `dcs/weapons_data.py`, `dcs/countries.py` — generated by `tools/pydcs_export.lua` (run inside the DCS + mission editor). Fix the generator instead, and keep generated diffs out of unrelated changes. +- `dcs/terrain/*/airports.py` — generated via `tools/airport_import.py` (instructions in its docstring). +- `dcs/cloud_presets.py` — from `tools/weather_import.py`; projections from + `tools/export_map_projection.py`. + +These files are huge (`planes.py`, `countries.py` are ~40k lines) — search them, don't read them whole. + +## Adding a new terrain + +Add the subpackage (with `__init__.py`) under `dcs/terrain/` and export it in `dcs/terrain/__init__.py`; +packaging picks it up automatically. If the generated airport file needs it, add an `E126` +per-file-ignore in `setup.cfg`. Add a test in `tests/test_terrain.py`. + +## Other directories + +- `tests/` — pytest/unittest tests; sample missions in `tests/missions/` and `tests/*.miz`. +- `tools/` — data-export/import helpers and dev utilities (`missiondiff.py` diffs two `.miz` files). +- `doc/` — Sphinx docs (published on readthedocs). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Makefile b/Makefile index 97c45292..b9ea3129 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,10 @@ .PHONY: build upload clean build: - python3 setup.py sdist bdist_wheel + uv build upload: - python3 -m twine upload dist/* + uvx twine upload dist/* clean: rm -Rf dist/ - diff --git a/Manifest.in b/Manifest.in deleted file mode 100644 index 3e677d05..00000000 --- a/Manifest.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.md -include LICENSE.txt diff --git a/README.md b/README.md index d55b8362..889ad5cc 100644 --- a/README.md +++ b/README.md @@ -35,19 +35,9 @@ pydcs comes with 2 proof of concept scripts: ### dcs_random -This script can generate 3 different mission types - - * refuel - - Generates a refuel mission for A-10C or M-2000C aircrafts, search your tanker and refuel. - - * CAS - - Support ground troops. - - * CAP - - Take care of your tanker and AWACS. +This script generates a random training mission on the Nevada map: player flight(s) for the +A-10C, M-2000C, Ka-50, AV-8B or F/A-18C at Nellis, Creech or Groom Lake, with AWACS, tankers and +civil air traffic around them. For options see the script help with `dcs_random --help` diff --git a/dcs/action.py b/dcs/action.py index aed30e14..0b034426 100644 --- a/dcs/action.py +++ b/dcs/action.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, List, Type, Optional, TYPE_CHECKING +from typing import Any, Dict, List, Type, Optional, Union, TYPE_CHECKING from dcs.lua.serialize import dumps from dcs.translation import String, ResourceKey from enum import Enum, IntEnum @@ -88,6 +88,7 @@ def create_from_dict(cls, d, mission): def dict(self): d = super(AddRadioItem, self).dict() d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id d["flag"] = self.flag d["value"] = self.value return d @@ -96,7 +97,7 @@ def dict(self): class AddRadioItemForCoalition(Action): predicate = "a_add_radio_item_for_coalition" - def __init__(self, coalitionlist=all, radiotext=String(), flag=1, value=1): + def __init__(self, coalitionlist="all", radiotext=String(), flag=1, value=1): super(AddRadioItemForCoalition, self).__init__(AddRadioItemForCoalition.predicate) self.coalitionlist = coalitionlist self.params.append(self.coalitionlist) @@ -115,6 +116,7 @@ def dict(self): d = super(AddRadioItemForCoalition, self).dict() d["coalitionlist"] = self.coalitionlist d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id d["flag"] = self.flag d["value"] = self.value return d @@ -142,6 +144,7 @@ def dict(self): d = super(AddRadioItemForGroup, self).dict() d["group"] = self.group d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id d["flag"] = self.flag d["value"] = self.value return d @@ -716,15 +719,16 @@ class MarkToAll(TextAction): def __init__(self, value: int, zone: int, text: String = String(), comment: String = String(), readonly: bool = True) -> None: super().__init__(MarkToAll.predicate, text) + # parameter order of a_mark_to_all: value, text, zone, readonly, comment self.value = value self.params.append(self.value) + self.params.append(self.text) self.zone = zone self.params.append(self.zone) - self.params.append(self.text) - self.comment = comment - self.params.append(self.comment) self.readonly = readonly self.params.append(self.readonly) + self.comment = comment + self.params.append(self.comment) @classmethod def create_from_dict(cls, d: Dict[Any, Any], mission) -> "MarkToAll": @@ -736,6 +740,7 @@ def dict(self) -> Dict[Any, Any]: d["value"] = self.value d["zone"] = self.zone d["comment"] = self.comment.id + d["KeyDict_comment"] = self.comment.id d["readonly"] = self.readonly return d @@ -748,19 +753,20 @@ def __init__(self, value: int, zone: int, text: String = String(), comment: String = String(), readonly: bool = True) -> None: super().__init__(MarkToCoalition.predicate, text) + # parameter order of a_mark_to_coalition: value, text, zone, coalitionlist, readonly, comment self.value = value self.params.append(self.value) + self.params.append(self.text) self.zone = zone self.params.append(self.zone) if not isinstance(coalitionlist, Coalition): raise TypeError("Unexpected type for coalitionlist") self.coalitionlist = coalitionlist.value self.params.append(self.coalitionlist) - self.params.append(self.text) - self.comment = comment - self.params.append(self.comment) self.readonly = readonly self.params.append(self.readonly) + self.comment = comment + self.params.append(self.comment) @classmethod def create_from_dict(cls, d: Dict[Any, Any], mission) -> "MarkToCoalition": @@ -775,6 +781,7 @@ def dict(self): d["zone"] = self.zone d["coalitionlist"] = self.coalitionlist d["comment"] = self.comment.id + d["KeyDict_comment"] = self.comment.id d["readonly"] = self.readonly return d @@ -786,17 +793,18 @@ def __init__(self, value: int, zone: int, group: int = 0, text: String = String(), comment: String = String(), readonly: bool = True) -> None: super(MarkToGroup, self).__init__(MarkToGroup.predicate, text) + # parameter order of a_mark_to_group: value, text, zone, group, readonly, comment self.value = value self.params.append(self.value) + self.params.append(self.text) self.zone = zone self.params.append(self.zone) self.group = group self.params.append(self.group) - self.params.append(self.text) - self.comment = comment - self.params.append(self.comment) self.readonly = readonly self.params.append(self.readonly) + self.comment = comment + self.params.append(self.comment) @classmethod def create_from_dict(cls, d: Dict[Any, Any], mission) -> "MarkToGroup": @@ -809,6 +817,7 @@ def dict(self) -> Dict[Any, Any]: d["zone"] = self.zone d["group"] = self.group d["comment"] = self.comment.id + d["KeyDict_comment"] = self.comment.id d["readonly"] = self.readonly return d @@ -817,23 +826,29 @@ class MessageToAll(TextAction): predicate = "a_out_text_delay" def __init__(self, text: String = String(), seconds: int = 10, - clearview: bool = False) -> None: + clearview: bool = False, start_delay: Optional[int] = None) -> None: super().__init__(MessageToAll.predicate, text) self.params.append(self.text) self.seconds = seconds self.params.append(self.seconds) self.clearview = clearview self.params.append(self.clearview) + # older versions of the mission editor don't write a start delay + self.start_delay = start_delay + if self.start_delay is not None: + self.params.append(self.start_delay) @classmethod def create_from_dict(cls, d: Dict[Any, Any], mission) -> "MessageToAll": return cls(mission.translation.get_string(d["text"]), d["seconds"], - d["clearview"]) + d["clearview"], d.get("start_delay")) def dict(self) -> Dict[Any, Any]: d = super().dict() d["seconds"] = self.seconds d["clearview"] = self.clearview + if self.start_delay is not None: + d["start_delay"] = self.start_delay return d @@ -1052,13 +1067,14 @@ def create_from_dict(cls, d, mission): def dict(self): d = super(RemoveRadioItem, self).dict() d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id return d class RemoveRadioItemForCoalition(Action): predicate = "a_remove_radio_item_for_coalition" - def __init__(self, coalitionlist=all, radiotext=String()): + def __init__(self, coalitionlist="all", radiotext=String()): super(RemoveRadioItemForCoalition, self).__init__(RemoveRadioItemForCoalition.predicate) self.coalitionlist = coalitionlist self.params.append(self.coalitionlist) @@ -1073,6 +1089,7 @@ def dict(self): d = super(RemoveRadioItemForCoalition, self).dict() d["coalitionlist"] = self.coalitionlist d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id return d @@ -1094,6 +1111,7 @@ def dict(self): d = super(RemoveRadioItemForGroup, self).dict() d["group"] = self.group d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id return d @@ -1835,7 +1853,9 @@ class HorzAlignment(Enum): def __eq__(self, other: Any) -> bool: if isinstance(other, str): return self.value == other - return self == other + return self is other + + __hash__ = Enum.__hash__ class VertAlignment(Enum): Top = "0" @@ -1845,7 +1865,9 @@ class VertAlignment(Enum): def __eq__(self, other: Any) -> bool: if isinstance(other, str): return self.value == other - return self == other + return self is other + + __hash__ = Enum.__hash__ class SizeUnits(Enum): OriginalSize = "0" @@ -1854,7 +1876,9 @@ class SizeUnits(Enum): def __eq__(self, other: Any) -> bool: if isinstance(other, str): return self.value == other - return self == other + return self is other + + __hash__ = Enum.__hash__ def __init__(self, predicate: str, file_res_key: ResourceKey, seconds: int, clearview: bool, start_delay: int, @@ -1865,9 +1889,10 @@ def __init__(self, predicate: str, file_res_key: ResourceKey, seconds: int, self.seconds = seconds self.clearview = clearview self.start_delay = start_delay - self.horz_alignment = horz_alignment - self.vert_alignment = vert_alignment - self.size_units = size_units + # accept the enums as well as their raw values as loaded from a mission + self.horz_alignment = PictureAction.HorzAlignment(horz_alignment) + self.vert_alignment = PictureAction.VertAlignment(vert_alignment) + self.size_units = PictureAction.SizeUnits(size_units) self.size = size if size not in range(1, 101): raise ValueError @@ -1878,9 +1903,9 @@ def dict(self) -> Dict[str, Any]: d["seconds"] = self.seconds d["clearview"] = self.clearview d["start_delay"] = self.start_delay - d["horzAlignment"] = self.horz_alignment - d["vertAlignment"] = self.vert_alignment - d["size_units"] = self.size_units + d["horzAlignment"] = self.horz_alignment.value + d["vertAlignment"] = self.vert_alignment.value + d["size_units"] = self.size_units.value d["size"] = self.size return d @@ -1902,7 +1927,7 @@ def __init__(self, file_res_key: ResourceKey, seconds: int, start_delay=start_delay, horz_alignment=horz_alignment, vert_alignment=vert_alignment, size=size, size_units=size_units) self.params = [self.file_res_key, seconds, clearview, start_delay, - horz_alignment, vert_alignment, size, size_units] + self.horz_alignment.value, self.vert_alignment.value, size, self.size_units.value] @classmethod def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToAll: @@ -1913,7 +1938,7 @@ def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToAll: class PictureToCoalition(PictureAction): predicate = "a_out_picture_s" - def __init__(self, coalition: Coalition, file_res_key: ResourceKey, seconds: int, + def __init__(self, coalition: Union[Coalition, str], file_res_key: ResourceKey, seconds: int, clearview: bool, start_delay: int, horz_alignment: PictureAction.HorzAlignment, vert_alignment: PictureAction.VertAlignment, size: int, size_units: PictureAction.SizeUnits) -> None: @@ -1921,9 +1946,10 @@ def __init__(self, coalition: Coalition, file_res_key: ResourceKey, seconds: int seconds=seconds, clearview=clearview, start_delay=start_delay, horz_alignment=horz_alignment, vert_alignment=vert_alignment, size=size, size_units=size_units) - self.coalition = coalition + self.coalition = coalition.value if isinstance(coalition, Coalition) else coalition self.params = [self.coalition, self.file_res_key, seconds, clearview, - start_delay, horz_alignment, vert_alignment, size, size_units] + start_delay, self.horz_alignment.value, self.vert_alignment.value, size, + self.size_units.value] @classmethod def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToCoalition: @@ -1949,7 +1975,8 @@ def __init__(self, country: Country, file_res_key: ResourceKey, seconds: int, vert_alignment=vert_alignment, size=size, size_units=size_units) self.country = country self.params = [self.country.id, self.file_res_key, seconds, clearview, - start_delay, horz_alignment, vert_alignment, size, size_units] + start_delay, self.horz_alignment.value, self.vert_alignment.value, size, + self.size_units.value] @classmethod def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToCountry: @@ -1976,7 +2003,8 @@ def __init__(self, group: Group, file_res_key: ResourceKey, seconds: int, vert_alignment=vert_alignment, size=size, size_units=size_units) self.group = group self.params = [self.group.id, self.file_res_key, seconds, clearview, - start_delay, horz_alignment, vert_alignment, size, size_units] + start_delay, self.horz_alignment.value, self.vert_alignment.value, size, + self.size_units.value] @classmethod def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToGroup: @@ -2006,7 +2034,8 @@ def __init__(self, unit_id: int, file_res_key: ResourceKey, seconds: int, vert_alignment=vert_alignment, size=size, size_units=size_units) self.unit_id = unit_id self.params = [self.unit_id, self.file_res_key, seconds, clearview, - start_delay, horz_alignment, vert_alignment, size, size_units] + start_delay, self.horz_alignment.value, self.vert_alignment.value, size, + self.size_units.value] @classmethod def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToUnit: diff --git a/dcs/coalition.py b/dcs/coalition.py index f73854ed..7e2e9e93 100644 --- a/dcs/coalition.py +++ b/dcs/coalition.py @@ -1,5 +1,5 @@ import sys -from typing import Dict, Union, List, TYPE_CHECKING, Optional +from typing import Any, Dict, Union, List, TYPE_CHECKING, Optional import dcs.countries as countries from dcs.mapping import Point import dcs.unitgroup as unitgroup @@ -22,7 +22,8 @@ def __init__(self, name, bullseye=None): self.name = name self.countries: Dict[str, Country] = {} self.bullseye = bullseye - self.nav_points = [] # TODO + # reference points of the coalition, kept as loaded from the mission + self.nav_points: List[Dict[str, Any]] = [] @staticmethod def _sort_keys(points): @@ -63,6 +64,13 @@ def _park_unit_on_airport( ret: List[StatusMessage] = [] if group.points[0].airdrome_id is not None and unit.parking is not None: airport = mission.terrain.airport_by_id(group.points[0].airdrome_id) + if airport is None: + # e.g. an airfield added in a newer DCS version, keep the parking as loaded + msg = "Unknown airport id '{i}' for unit '{u}' in group '{p}', keeping its parking slot " \ + "'{s}' unchecked".format(i=group.points[0].airdrome_id, u=unit.name, p=group.name, s=unit.parking) + print("WARN", msg, file=sys.stderr) + ret.append(StatusMessage(msg, MessageType.AIRPORT_UNKNOWN, MessageSeverity.WARN)) + return ret slot = airport.parking_slot(unit.parking) if slot is not None: unit.set_parking(slot) @@ -92,6 +100,8 @@ def get_name(mission: "Mission", name: str) -> str: def load_from_dict(self, mission, d, countries_in_coalition: Dict[int, int]) -> List[StatusMessage]: status: List[StatusMessage] = [] + nav_points = d.get("nav_points", {}) + self.nav_points = [nav_points[k] for k in sorted(nav_points)] for country_idx in d["country"]: imp_country = d["country"][country_idx] _country = countries.get_by_id(imp_country["id"]) @@ -314,5 +324,5 @@ def dict(self): for country in sorted(self.countries.keys()): d["country"][i] = self.country(country).dict() i += 1 - d["nav_points"] = {} + d["nav_points"] = {i + 1: point for i, point in enumerate(self.nav_points)} return d diff --git a/dcs/drawing/drawing.py b/dcs/drawing/drawing.py index 4a983f20..c3c5a350 100644 --- a/dcs/drawing/drawing.py +++ b/dcs/drawing/drawing.py @@ -1,5 +1,6 @@ from enum import Enum -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Optional import dcs.mapping as mapping @@ -47,10 +48,14 @@ class Drawing: name: str color: Rgba layer_name: str + # keyword only, so the subclasses can still add positional fields + hidden_on_planner: Optional[bool] = field(default=None, kw_only=True) def dict(self): d = {} d["visible"] = self.visible + if self.hidden_on_planner is not None: + d["hiddenOnPlanner"] = self.hidden_on_planner d["mapX"] = self.position.x d["mapY"] = self.position.y d["name"] = self.name diff --git a/dcs/drawing/layer.py b/dcs/drawing/layer.py index 59f60219..8caa53c8 100644 --- a/dcs/drawing/layer.py +++ b/dcs/drawing/layer.py @@ -31,6 +31,7 @@ def load_from_dict(self, data): for object_index in sorted(data["objects"].keys()): object_data = data["objects"][object_index] object = self.load_drawing_from_data(object_data) + object.hidden_on_planner = object_data.get("hiddenOnPlanner") self.objects.append(object) def dict(self): diff --git a/dcs/flyingunit.py b/dcs/flyingunit.py index 54402b93..f477b5e0 100644 --- a/dcs/flyingunit.py +++ b/dcs/flyingunit.py @@ -9,7 +9,7 @@ from dcs.helicopters import HelicopterType, Ka_50 import json -from typing import TYPE_CHECKING, Dict, Optional, Type +from typing import TYPE_CHECKING, Any, Dict, Optional, Type if TYPE_CHECKING: from dcs.terrain import Terrain @@ -47,6 +47,8 @@ def __init__( self.hardpoint_racks = True self.addpropaircraft = dict(_type.property_defaults) if _type.property_defaults else None self.payload_restricted: Optional[Dict[int, Dict[int, str]]] = None + self.datalinks: Optional[Dict[str, Any]] = None + self.data_cartridge: Optional[Dict[str, Any]] = None def load_from_dict(self, d): super(FlyingUnit, self).load_from_dict(d) @@ -73,6 +75,8 @@ def load_from_dict(self, d): self.hardpoint_racks = d.get("hardpoint_racks", None) self.addpropaircraft = d.get("AddPropAircraft") self.payload_restricted = d["payload"].get("restricted", None) + self.datalinks = d.get("datalinks") + self.data_cartridge = d.get("dataCartridge") return True def set_parking(self, parking_slot: ParkingSlot): @@ -234,6 +238,10 @@ def dict(self): d["Radio"] = self.radio if self.payload_restricted is not None: d["payload"]["restricted"] = self.payload_restricted + if self.datalinks is not None: + d["datalinks"] = self.datalinks + if self.data_cartridge is not None: + d["dataCartridge"] = self.data_cartridge return d diff --git a/dcs/forcedoptions.py b/dcs/forcedoptions.py index 5f5aae3e..6c034192 100644 --- a/dcs/forcedoptions.py +++ b/dcs/forcedoptions.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Optional +from typing import Any, Dict, Optional class ForcedOptions: @@ -29,6 +29,14 @@ class Labels(Enum): DotOnly = 3 NeutralDot = 4 + # keys handled by the attributes below + _KNOWN_KEYS = { + "accidental_failures", "birds", "civTraffic", "cockpitStatusBarAllowed", "cockpitVisualRM", + "easyCommunication", "easyFlight", "easyRadar", "externalViews", "fuel", "geffect", "immortal", "labels", + "miniHUD", "optionsView", "padlock", "permitCrash", "radio", "RBDAI", "unrestrictedSATNAV", "userMarks", + "wakeTurbulence", "weapons", + } + def __init__(self): # In the order they appear in the ME. self.permit_crash: Optional[bool] = None @@ -54,8 +62,11 @@ def __init__(self): self.birds: Optional[int] = None self.cockpit_status_bar: Optional[bool] = None self.battle_damage_assessment: Optional[bool] = None + # forced options pydcs doesn't know, written back unchanged + self.unknown_options: Dict[str, Any] = {} def load_from_dict(self, d): + self.unknown_options = {k: v for k, v in d.items() if k not in ForcedOptions._KNOWN_KEYS} self.fuel = d.get("fuel") self.easy_radar = d.get("easyRadar") self.mini_hud = d.get("miniHUD") @@ -85,7 +96,7 @@ def load_from_dict(self, d): self.user_marks = d.get("userMarks") def dict(self): - d = {} + d = dict(self.unknown_options) if self.fuel is not None: d["fuel"] = self.fuel if self.easy_radar is not None: diff --git a/dcs/goals.py b/dcs/goals.py index 0772f159..a7d1ea3c 100644 --- a/dcs/goals.py +++ b/dcs/goals.py @@ -76,5 +76,6 @@ def generate_result(self): return d def dict(self): - return {i + 1: self.goals[side][i].dict() - for side in ["blue", "red", "offline"] for i in range(0, len(self.goals[side]))} + # all sides share one table, so number them consecutively + all_goals = [g for side in ["blue", "red", "offline"] for g in self.goals[side]] + return {i + 1: g.dict() for i, g in enumerate(all_goals)} diff --git a/dcs/liveries/livery.py b/dcs/liveries/livery.py index bd432fa8..3a19351e 100644 --- a/dcs/liveries/livery.py +++ b/dcs/liveries/livery.py @@ -9,11 +9,12 @@ def _attempt_read_from_filestream(filestream: bytes) -> Optional[str]: - encodes = ["utf-8", "ansi"] + # 'ansi' (the active Windows code page) only exists on Windows, cp1252 is the fallback elsewhere + encodes = ["utf-8", "ansi", "cp1252"] for enc in encodes: try: code = filestream.decode(enc) - except UnicodeDecodeError: + except (UnicodeDecodeError, LookupError): continue return code return None @@ -71,7 +72,7 @@ def from_lua(code: str, path: str) -> Optional[Livery]: :param unit: The name of the unit as 'DCS type', i.e. name of the unit inside the Liveries folder, e.g. 'A-10CII' """ - path_id = path.split("\\")[-1].replace(".zip", "") + path_id = os.path.basename(path).replace(".zip", "") if path_id == "placeholder": return None diff --git a/dcs/lua/parse.py b/dcs/lua/parse.py index a1d9158e..35f15453 100644 --- a/dcs/lua/parse.py +++ b/dcs/lua/parse.py @@ -1,6 +1,9 @@ from typing import Dict, Any, Callable, Optional, List, Union +_ESCAPES = {'n': '\n', 'r': '\r', 't': '\t', 'a': '\a', 'b': '\b', 'f': '\f', 'v': '\v'} + + def loads( tablestr, _globals: Optional[Dict[str, Any]] = None, @@ -175,7 +178,20 @@ def string(self) -> str: else: s += c elif state == 2: - s += c + # escape sequences as handled by the lua 5.1 lexer + if c.isdigit(): + digits = c + while len(digits) < 3 and self.char(1).isdigit(): + self.pos += 1 + digits += self.char() + s += chr(int(digits)) + elif c in ('\n', '\r'): + # escaped line break, a \r\n or \n\r pair counts as one + if self.char(1) in ('\n', '\r') and self.char(1) != c: + self.pos += 1 + s += '\n' + else: + s += _ESCAPES.get(c, c) state = 0 return s @@ -187,32 +203,36 @@ def number(self) -> Union[int, float]: if self.advance(): raise self.eob_exception() - found_exponent, found_exponent_sign, found_separator = False, False, False + found_exponent, found_separator = False, False while not self.eob(): - if self.char().isnumeric(): + c = self.char() + if c.isdigit(): pass - elif self.char() == '.': - if not found_separator: - found_separator = True - else: + elif c == '.': + if found_separator or found_exponent: raise SyntaxError() - elif self.char().lower() == 'e': - if not found_exponent: - found_exponent = True - else: - raise SyntaxError() - elif self.char() == '-': - if not found_exponent_sign: - found_exponent_sign = True - else: + found_separator = True + elif c.lower() == 'e': + if found_exponent: raise SyntaxError() + found_exponent = True + elif c in '+-': + # a sign is only valid directly after the exponent marker, e.g. 1e+20 or 2.5e-3 + if not n or n[-1].lower() != 'e': + break else: break - n += self.char() + n += c self.pos += 1 - num = float(n) * sign + try: + if not found_separator and not found_exponent: + # parse integers directly, going through float would lose precision of large values + return int(n) * sign + num = float(n) * sign + except ValueError as ex: + raise SyntaxError("Invalid number '{}' in line {}".format(n, self.lineno)) from ex if num.is_integer(): return int(num) return num @@ -249,7 +269,7 @@ def _object(self) -> Dict[Union[int, str], Any]: self.eat_ws() key: Union[int, str] - if self.char() == '"': + if self.char() in ('"', "'"): key = self.string() else: number = self.number() diff --git a/dcs/lua/serialize.py b/dcs/lua/serialize.py index 9a0999a2..5a21254a 100644 --- a/dcs/lua/serialize.py +++ b/dcs/lua/serialize.py @@ -1,3 +1,15 @@ +import math + + +def _quote(value: str) -> str: + v = value.replace('\\', '\\\\') + v = v.replace('"', '\\"') + v = v.replace('\n', '\\\n') + # a raw carriage return inside a quoted string is a syntax error in lua + v = v.replace('\r', '\\r') + return '"{val}"'.format(val=v) + + def dumps(value, varname=None, indent=None): nl = "\n" if indent else "" s = varname + '=' + nl if varname else '' @@ -11,7 +23,7 @@ def dumps(value, varname=None, indent=None): child = value[key] KNL = "\n" if indent and isinstance(child, (dict, list)) else "" selem = '\t' * indentcount - skey = key if isinstance(key, int) else '"{key}"'.format(key=key) + skey = key if isinstance(key, int) else _quote(str(key)) selem += '[{key}]={nl}'.format(key=skey, nl=KNL) selem += dumps(value[key], indent=indent + 1 if indent else None) e.append(selem) @@ -34,14 +46,16 @@ def dumps(value, varname=None, indent=None): s += nl + ",{nl}".format(nl=nl).join(e) s += nl + '\t' * (indentcount - 1) + "}" elif isinstance(value, str): - v = value.replace('\\', '\\\\') - v = v.replace('"', '\\"') - v = v.replace('\n', '\\\n') - s += '"{val}"'.format(val=v) + s += _quote(value) elif isinstance(value, bool): s += "true" if value else "false" elif value is None: s += "nil" + elif isinstance(value, float) and not math.isfinite(value): + # Lua has no literal for these: an overflowing number reads back as infinity, nan can't be expressed + if math.isnan(value): + raise ValueError("NaN can not be serialized to lua") + s += "1e309" if value > 0 else "-1e309" else: s += str(value) diff --git a/dcs/lua/test_parse.py b/dcs/lua/test_parse.py index 30048640..6dae86c2 100644 --- a/dcs/lua/test_parse.py +++ b/dcs/lua/test_parse.py @@ -341,6 +341,26 @@ def test_float_beginning_with_dot(self) -> None: r = loads("num = .1") self.assertEqual(r["num"], 0.1) + def test_number_exponents(self) -> None: + r = loads("a = 1e+5 b = 2.5E-3 c = -1e+20 d = 3e2") + self.assertEqual(r, {"a": 100000, "b": 0.0025, "c": -1e20, "d": 300}) + + def test_large_integer_keeps_precision(self) -> None: + self.assertEqual(loads("a = 12345678901234567890")["a"], 12345678901234567890) + + def test_invalid_number_raises_syntax_error(self) -> None: + for code in ["a = 1.2.3", "a = 1e5e5", "a = -"]: + with self.subTest(code), self.assertRaises(SyntaxError): + loads(code) + + def test_string_escapes(self) -> None: + # as written by lua's %q format: \r, \000 and an escaped line break + r = loads('a = "x\\r\\\ny\\000z\\tq\\"e\\\\"') + self.assertEqual(r["a"], 'x\r\ny\x00z\tq"e\\') + + def test_single_quoted_key(self) -> None: + self.assertEqual(loads("a = {['k'] = 1}")["a"], {"k": 1}) + if __name__ == '__main__': unittest.main() diff --git a/dcs/mapping.py b/dcs/mapping.py index 073370b7..0b58dd56 100644 --- a/dcs/mapping.py +++ b/dcs/mapping.py @@ -256,7 +256,8 @@ def random_point(self) -> Point: def random_distant_points(self, distance: float) -> Tuple[Point, Point]: # determine vertical/horizontal - if self.width() > self.height(): + horizontal = self.width() > self.height() + if horizontal: axis_y = self.width() axis_x = self.height() sy = self.left @@ -272,7 +273,8 @@ def random_distant_points(self, distance: float) -> Tuple[Point, Point]: d = distance if distance < axis_y else axis_y * 0.2 sy += random.random() * (axis_y - d) sx += random.random() * axis_x - p1 = Point(sx, sy, self._terrain) + # sy runs along the long axis, which is x (north) for a vertical rectangle + p1 = Point(sx, sy, self._terrain) if horizontal else Point(sy, sx, self._terrain) while True: hdg = random.random() * 60 p2 = p1.point_from_heading(hdg_start + hdg, d) diff --git a/dcs/mission.py b/dcs/mission.py index e464d289..e43ccc69 100644 --- a/dcs/mission.py +++ b/dcs/mission.py @@ -75,6 +75,19 @@ def from_string(s: str): return st_map[s.lower()] +def _open_miz(filename: str) -> zipfile.ZipFile: + """Opens a mission file for reading. + + The mission editor stores UTF-8 file names without setting the archive's UTF-8 flag, so + they would be read as cp437 and resources with non-ASCII names would not be found. + """ + try: + return zipfile.ZipFile(filename, 'r', metadata_encoding='utf-8') + except UnicodeDecodeError: + # names that are not valid UTF-8, keep the zip standard's default + return zipfile.ZipFile(filename, 'r') + + class Mission: """This class represents the whole dcs .miz file. @@ -95,8 +108,6 @@ class Mission: Args: terrain: the used terrain for this mission. """ - _COUNTRY_IDS = {x for x in range(0, 13)} | {x for x in range(15, 47)} - _CURRENT_MIZ_VERSION: int = 20 # on save this version number will be written def __init__(self, terrain: Optional[Terrain] = None) -> None: @@ -115,6 +126,8 @@ def __init__(self, terrain: Optional[Terrain] = None) -> None: self._description_text = self.string("") self._description_bluetask = self.string("") self._description_redtask = self.string("") + # only present in missions saved by newer versions of the mission editor + self._description_neutralstask: Optional[String] = None self._sortie = self.string("") self.pictureFileNameR: List[Union[ResourceKey, str]] = [] self.pictureFileNameB: List[Union[ResourceKey, str]] = [] @@ -240,13 +253,21 @@ def load_file(self, filename: str, bypass_triggers: bool = False) -> List[Status self.tmpdir = tempfile.mkdtemp() status = [] + # state that is filled from the file or added to afterwards must not survive from a previous load + self.map_resource = MapResource(self) + self.pictureFileNameR = [] + self.pictureFileNameB = [] + self.pictureFileNameN = [] + self.forced_options = ForcedOptions() + self.aircraft_kneeboards = defaultdict(list) + def loaddict(fname: str, mizfile: zipfile.ZipFile, reserved_files: List[str]) -> Dict[str, Any]: reserved_files.append(fname) with mizfile.open(fname) as mfile: data = mfile.read().decode() return lua.loads(data) - with zipfile.ZipFile(filename, 'r') as miz: + with _open_miz(filename) as miz: reserved_files: List[str] = [] mission_dict = loaddict('mission', miz, reserved_files) @@ -317,6 +338,10 @@ def loaddict(fname: str, mizfile: zipfile.ZipFile, reserved_files: List[str]) -> self._description_text = self.translation.get_string(imp_mission["descriptionText"]) self._description_bluetask = self.translation.get_string(imp_mission["descriptionBlueTask"]) self._description_redtask = self.translation.get_string(imp_mission["descriptionRedTask"]) + if "descriptionNeutralsTask" in imp_mission: + self._description_neutralstask = self.translation.get_string(imp_mission["descriptionNeutralsTask"]) + else: + self._description_neutralstask = None self._sortie = self.translation.get_string(imp_mission["sortie"]) for pic in sorted(imp_mission["pictureFileNameR"]): self.pictureFileNameR.append(imp_mission["pictureFileNameR"][pic]) @@ -360,6 +385,7 @@ def loaddict(fname: str, mizfile: zipfile.ZipFile, reserved_files: List[str]) -> self.init_script = imp_mission.get("initScript") # import coalition with countries and units + self.coalition = {} for col_name in ["blue", "red", "neutrals"]: if col_name in imp_mission["coalition"]: self.coalition[col_name] = Coalition(col_name, imp_mission["coalition"][col_name]["bullseye"]) @@ -396,6 +422,7 @@ def loaddict(fname: str, mizfile: zipfile.ZipFile, reserved_files: List[str]) -> self.forced_options.load_from_dict(imp_mission["forcedOptions"]) # map + self.map = copy.copy(self.terrain.map_view_default) self.map.load_from_dict(imp_mission["map"]) # weather @@ -470,6 +497,25 @@ def set_description_redtask_text(self, text: str): """ self._description_redtask.set(text) + def description_neutralstask_text(self) -> str: + """Returns the neutral task description text. + + Returns: + the neutral task description text + """ + return str(self._description_neutralstask) if self._description_neutralstask is not None else "" + + def set_description_neutralstask_text(self, text: str): + """Sets the neutral coalitions task description text. + + Args: + text: text to set. + """ + if self._description_neutralstask is None: + self._description_neutralstask = self.string(text) + else: + self._description_neutralstask.set(text) + def add_picture_red(self, filepath: str) -> ResourceKey: """Adds a new briefing picture to the red coalition. @@ -571,11 +617,9 @@ def next_eplrs(self, group_type: str) -> int: Returns: int: the next eplrs id to use """ - eplrs_usage = self.eplrs_for(group_type) + used = set(self.eplrs_for(group_type).values()) eplrs_id = 1 - for ep_id in [eplrs_usage[x] for x in eplrs_usage]: - if ep_id != eplrs_id: - return eplrs_id + while eplrs_id in used: eplrs_id += 1 return eplrs_id @@ -2162,6 +2206,8 @@ def dict(self): m["pictureFileNameN"][i + 1] = str(self.pictureFileNameN[i]) m["descriptionBlueTask"] = self._description_bluetask.id m["descriptionRedTask"] = self._description_redtask.id + if self._description_neutralstask is not None: + m["descriptionNeutralsTask"] = self._description_neutralstask.id if self.init_script_file is not None: m["initScriptFile"] = self.init_script_file if self.init_script is not None: @@ -2171,7 +2217,11 @@ def dict(self): m["coalition"][col] = self.coalition[col].dict() col_blue = {self.coalition["blue"].country(x).id for x in self.coalition["blue"].countries.keys()} col_red = {self.coalition["red"].country(x).id for x in self.coalition["red"].countries.keys()} - col_neutral = list(Mission._COUNTRY_IDS - col_blue - col_red) + col_neutral_countries = set() + if "neutrals" in self.coalition: + col_neutral_countries = {self.coalition["neutrals"].country(x).id + for x in self.coalition["neutrals"].countries.keys()} + col_neutral = sorted((set(countries.country_dict) | col_neutral_countries) - col_blue - col_red) col_blue = list(col_blue) col_red = list(col_red) m["coalitions"] = { @@ -2286,16 +2336,28 @@ def store(self, zipf: zipfile.ZipFile, lang='DEFAULT'): zipf.write(file["path"], file["respath"]) if lang in self.files: + names_in_zip = set(zipf.namelist()) + written: Dict[str, str] = {} # name in zip -> source file for reskey in self.files[lang]: filepath = self.files[lang][reskey] if os.path.isabs(filepath): nameinzip = os.path.basename(filepath) - zippath = "l10n/{lang}/{name}".format(lang=lang, name=nameinzip) - # do not write files twice - # if a script is called multiple times, a resource key is duplicated for the same file - has_file = [x for x in zipf.filelist if x.filename == zippath] - if not has_file: - zipf.write(filepath, zippath) + stem, ext = os.path.splitext(nameinzip) + n = 1 + + def taken(name: str) -> bool: + if name in written: + return written[name] != filepath + return "l10n/{lang}/{name}".format(lang=lang, name=name) in names_in_zip + + # the same file can be used by several resource keys and is only written once, + # a different file with an already used name gets a unique one + while taken(nameinzip): + nameinzip = "{stem}_{n}{ext}".format(stem=stem, n=n, ext=ext) + n += 1 + if nameinzip not in written: + zipf.write(filepath, "l10n/{lang}/{name}".format(lang=lang, name=nameinzip)) + written[nameinzip] = filepath d[reskey] = nameinzip return d @@ -2335,8 +2397,12 @@ def __init__(self): self.weapons = False self.setGlobal = True self.labels = False + # options pydcs doesn't know, written back unchanged + self.unknown_options: Dict[str, Any] = {} def load_from_dict(self, d): + known = self.dict() + self.unknown_options = {k: v for k, v in d.items() if k not in known} self.geffect = d.get("geffect", "realistic") self.padlock = d.get("padlock", False) self.compassTape = d.get("compassTape", True) @@ -2372,6 +2438,7 @@ def load_from_dict(self, d): def dict(self): return { + **self.unknown_options, "geffect": self.geffect, "padlock": self.padlock, "compassTape": self.compassTape, @@ -2417,6 +2484,7 @@ def __init__(self): self.options = {} def load_from_dict(self, d): + self.playerName = d.get("playerName", self.playerName) self.difficulty.load_from_dict(d["difficulty"]) self.options = d diff --git a/dcs/scripts/destroy_oil_transport.py b/dcs/scripts/destroy_oil_transport.py index 46ab35ae..d58315d9 100644 --- a/dcs/scripts/destroy_oil_transport.py +++ b/dcs/scripts/destroy_oil_transport.py @@ -15,7 +15,7 @@ def main(): choices=aircraft_types, help="Player aircraft type") parser.add_argument("-u", "--unhide", action="store_true", default=False, help="Show enemy pre mission") - parser.add_argument("-t", "--terrain", choices=["caucasus", "nevada"], default='caucasus') + parser.add_argument("-t", "--terrain", choices=["caucasus"], default='caucasus') parser.add_argument("-d", "--difficulty", choices=["easy", "normal", "hard", "ohno"], default='normal') parser.add_argument("-m", "--multiplayer", action="store_true", default=False) parser.add_argument("-s", "--stats", action="store_false", default=True) @@ -23,8 +23,7 @@ def main(): args = parser.parse_args() terrain_map = { - "caucasus": dcs.terrain.Caucasus, - "nevada": dcs.terrain.Nevada + "caucasus": dcs.terrain.Caucasus } difficulty_map = { 'easy': 0.25, @@ -36,7 +35,7 @@ def main(): terrain = terrain_map[args.terrain]() - zone_abkhazia = dcs.Polygon([ + zone_abkhazia = dcs.Polygon(terrain, [ dcs.Point(-187092.85714285, 460857.14285714, terrain), dcs.Point(-149378.57142856, 476285.71428571, terrain), dcs.Point(-147664.28571428, 520000, terrain), @@ -60,11 +59,18 @@ def main(): m.random_date() m.random_daytime('day') - for a in m.terrain.default_red_airports(): - a.set_red() + red_airports = [ + "Anapa-Vityazevo", "Krymsk", "Novorossiysk", "Krasnodar-Center", "Krasnodar-Pashkovsky", + "Maykop-Khanskaya", "Gelendzhik", "Mineralnye Vody", "Mozdok", "Beslan", "Nalchik", "Sochi-Adler" + ] + for a in red_airports: + m.terrain.airports[a].set_red() + + blue_airports = ["Batumi", "Vaziani", "Soganlug", "Kobuleti", "Senaki-Kolkhi", "Tbilisi-Lochini", "Kutaisi"] + for b in blue_airports: + m.terrain.airports[b].set_blue() - for b in m.terrain.default_blue_airports(): - b.set_blue() + senaki = m.terrain.airports["Senaki-Kolkhi"] city_graph = m.terrain.city_graph @@ -79,11 +85,11 @@ def main(): abkhazia = m.country(dcs.countries.Abkhazia.name) convoy_vehicles = [] for i in range(0, 1 + int(max(difficulty, random.random()) * 10)): - convoy_vehicles.append(dcs.vehicles.Unarmed.Fuel_Truck_ATZ_10) + convoy_vehicles.append(dcs.vehicles.Unarmed.ATZ_10) airdef = [ - dcs.countries.Abkhazia.Vehicle.AirDefence.AAA_ZU_23_on_Ural_375, - dcs.countries.Abkhazia.Vehicle.AirDefence.SPAAA_ZSU_23_4_Shilka + dcs.countries.Abkhazia.Vehicle.AirDefence.Ural_375_ZU_23, + dcs.countries.Abkhazia.Vehicle.AirDefence.ZSU_23_4_Shilka ] if random.random() < difficulty: convoy_vehicles.append(random.choice(airdef)) @@ -101,9 +107,9 @@ def main(): _, path = city_graph.travel(oil_convoy, start_node, destination_node, 100) # add light air defence around and in cities on path - aaa_def = [[dcs.countries.Abkhazia.Vehicle.AirDefence.AAA_ZU_23_Emplacement], - [dcs.countries.Abkhazia.Vehicle.AirDefence.SAM_SA_18_Igla_MANPADS, - dcs.countries.Abkhazia.Vehicle.AirDefence.SAM_SA_18_Igla_comm]] + aaa_def = [[dcs.countries.Abkhazia.Vehicle.AirDefence.ZU_23_Emplacement], + [dcs.countries.Abkhazia.Vehicle.AirDefence.SA_18_Igla_manpad, + dcs.countries.Abkhazia.Vehicle.AirDefence.SA_18_Igla_comm]] for city in city_graph.rated_nodes_within(zone_enemy, 50): use_building_pos = int(min(difficulty, random.random()) * len(city.air_defence_pos_small)) small_aaa_pos = list(city.air_defence_pos_small) @@ -118,9 +124,9 @@ def main(): vg.hidden = not args.unhide vg.formation_scattered(random.randrange(0, 360), 15) - aaa_def += [[dcs.countries.Abkhazia.Vehicle.AirDefence.SPAAA_ZSU_23_4_Shilka, - dcs.countries.Abkhazia.Vehicle.Armor.ARV_BRDM_2, - dcs.countries.Abkhazia.Vehicle.Armor.ARV_BRDM_2]] + aaa_def += [[dcs.countries.Abkhazia.Vehicle.AirDefence.ZSU_23_4_Shilka, + dcs.countries.Abkhazia.Vehicle.Armor.BRDM_2, + dcs.countries.Abkhazia.Vehicle.Armor.BRDM_2]] for node in city_graph.nodes_within(zone_enemy): if random.random() < (difficulty - 0.1): vg = m.vehicle_group_platoon(abkhazia, @@ -146,12 +152,12 @@ def main(): # place farp for helis zugdidi_ne = city_graph.node('Zugidi NE') - farp = m.farp(usa, "FARP Zugdidi", zugdidi_ne.position + dcs.mapping.Point(0, 500), callsign_id=1) + farp = m.farp(usa, "FARP Zugdidi", zugdidi_ne.position + dcs.mapping.Point(0, 500, m.terrain), callsign_id=1) fg = m.flight_group_from_unit( usa, dcs.helicopters.Ka_50.id + " Client", dcs.helicopters.Ka_50, farp, dcs.task.CAS, group_size=2) fg.set_client() - farp = m.farp(usa, "FARP Kvemo", zugdidi_ne.position + dcs.mapping.Point(0, -22 * 1000), callsign_id=2) + farp = m.farp(usa, "FARP Kvemo", zugdidi_ne.position + dcs.mapping.Point(0, -22 * 1000, m.terrain), callsign_id=2) fg = m.flight_group_from_unit( usa, dcs.helicopters.UH_1H.id + " Client", dcs.helicopters.UH_1H, farp, dcs.task.CAS, group_size=2) fg.set_client() @@ -159,22 +165,27 @@ def main(): # place player player_fg = None if args.multiplayer: + # senaki first, the other blue airports take the client groups that no longer fit there + client_airports = [senaki] + [m.terrain.airports[b] for b in blue_airports if b != "Senaki-Kolkhi"] for x in aircrafts: if dcs.task.CAS in x.tasks and x not in [dcs.planes.Su_33, dcs.planes.MiG_29A, dcs.planes.MiG_29S]: + airport = next((a for a in client_airports if len(a.free_parking_slots(x)) >= 2), None) + if airport is None: + continue country = m.country(dcs.countries.Georgia.name) if x in [dcs.planes.Su_25T, dcs.planes.Su_25] else usa fg = m.flight_group_from_airport( - country, x.id + " Client", x, m.terrain.senaki_kolkhi(), dcs.task.CAS, group_size=2) - fg.add_runway_waypoint(m.terrain.senaki_kolkhi()) + country, x.id + " Client", x, airport, dcs.task.CAS, group_size=2) + fg.add_runway_waypoint(airport) fg.set_client() else: - aircraft_type = [x for x in aircrafts if x.id == args.aircrafttype][0] - player_fg = m.flight_group_from_airport(usa, "Player", aircraft_type, m.terrain.senaki_kolkhi(), dcs.task.CAS) - player_fg.add_runway_waypoint(m.terrain.senaki_kolkhi()) + aircraft_type = [x for x in aircrafts + helicopters if x.id == args.aircrafttype][0] + player_fg = m.flight_group_from_airport(usa, "Player", aircraft_type, senaki, dcs.task.CAS) + player_fg.add_runway_waypoint(senaki) player_fg.units[0].set_player() # CAP flight cap_type = random.choice([dcs.planes.F_16C_bl_52d, dcs.planes.F_A_18C, dcs.planes.F_15E]) - m.patrol_flight(usa, "CAP", cap_type, m.terrain.kutaisi(), zugdidi_ne.position, + m.patrol_flight(usa, "CAP", cap_type, m.terrain.airports["Kutaisi"], zugdidi_ne.position, zugdidi_ne.position.point_from_heading(random.randint(270, 350), 35 * 1000)) notify_nodes = path[1:5] diff --git a/dcs/scripts/nevada_random_mission.py b/dcs/scripts/nevada_random_mission.py index 6e22f649..36c739bf 100644 --- a/dcs/scripts/nevada_random_mission.py +++ b/dcs/scripts/nevada_random_mission.py @@ -101,8 +101,8 @@ def __init__(self, aircraft_types: List[Tuple[str, str]], playercount: int, star pg: Optional[FlyingGroup] = None for pg in player_groups: - assert pg.points[0].airdrome_id is not None - airport = self.m.terrain.airport_by_id(pg.points[0].airdrome_id) + airdrome_id = pg.points[0].airdrome_id + airport = self.m.terrain.airport_by_id(airdrome_id) if airdrome_id is not None else None if not airport: airport = random.choice(blue_military) @@ -156,8 +156,8 @@ def daytime(self, period): def setup_airports(self): nevada = self.m.terrain # type: dcs.terrain.Nevada - self.blue_airports = nevada.default_blue_airports() - self.red_airports = [nevada.tonopah(), nevada.mina(), nevada.tonopah_test_range()] + self.blue_airports = list(nevada.airport_list()) + self.red_airports = [nevada.airports[x] for x in ["Tonopah", "Mina", "Tonopah Test Range"]] for a in self.blue_airports: self.setup_airport(a, "blue", []) for a in self.red_airports: @@ -191,14 +191,14 @@ def civil_flight(countries, airports): slots = airport.free_parking_slot(ptype) - beatty = self.m.terrain.beatty() - groom = self.m.terrain.groom_lake() - x = random.randrange(int(dcs.terrain.Nevada.bounds.bottom) + 100 * 1000, + beatty = self.m.terrain.airports["Beatty"] + groom = self.m.terrain.airports["Groom Lake"] + x = random.randrange(int(self.m.terrain.bounds.bottom) + 100 * 1000, int(groom.position.x)) y = random.randrange(int(beatty.position.y), - int(dcs.terrain.Nevada.bounds.right) - 130 * 1000) + int(self.m.terrain.bounds.right) - 130 * 1000) - pos = dcs.mapping.Point(x, y) + pos = dcs.mapping.Point(x, y, self.m.terrain) name = "Civil " + str(c_count) rand = random.random() if 0.3 < rand < 0.5 and slots: @@ -211,16 +211,17 @@ def civil_flight(countries, airports): pg = self.m.flight_group_from_airport(country, name, ptype, airport) pg.uncontrolled = True else: - bound = dcs.mapping.Rectangle(dcs.terrain.Nevada.bounds.top - 100 * 1000, - dcs.terrain.Nevada.bounds.left + 200 * 1000, - dcs.terrain.Nevada.bounds.bottom + 100 * 1000, - dcs.terrain.Nevada.bounds.right - 130 * 1000) + bound = dcs.mapping.Rectangle(self.m.terrain.bounds.top - 100 * 1000, + self.m.terrain.bounds.left + 200 * 1000, + self.m.terrain.bounds.bottom + 100 * 1000, + self.m.terrain.bounds.right - 130 * 1000, + self.m.terrain) point = bound.random_point() pg = self.m.flight_group_inflight( country, name, ptype, point, random.randrange(5000, 8000, 100), 400 ) - tmp = pg.add_waypoint(dcs.mapping.Point(0, 0), pg.points[0].alt) + tmp = pg.add_waypoint(dcs.mapping.Point(0, 0, self.m.terrain), pg.points[0].alt) wp = pg.add_runway_waypoint(airport, distance=random.randrange(6000, 8000, 100)) heading = wp.position.heading_between_point(point) tmp.position = wp.position.point_from_heading(heading, 30 * 1000) @@ -357,7 +358,7 @@ def add_sa6_site(self, country, position): def add_ground_targets(self): russia = self.m.country(dcs.countries.Russia.name) - pos1 = self.m.terrain.groom_lake().position.point_from_heading(5, 60 * 1000) + pos1 = self.m.terrain.airports["Groom Lake"].position.point_from_heading(5, 60 * 1000) rus_vehicles = dcs.countries.Russia.Vehicle vtypes = [ rus_vehicles.Armor.T_72B, @@ -397,7 +398,7 @@ def add_ground_targets(self): jtac1.units[0].player_can_drive = True # these are targets with light air defense in sector 74C - pos = self.m.terrain.groom_lake().position.point_from_heading(320, 53 * 1000) + pos = self.m.terrain.airports["Groom Lake"].position.point_from_heading(320, 53 * 1000) self.m.vehicle_group(russia, "ref 2", rus_vehicles.Infantry.Infantry_AK, pos) vtypes_light = [ rus_vehicles.AirDefence.Ural_375_ZU_23, diff --git a/dcs/status_message.py b/dcs/status_message.py index 52db6f1f..fc979d8c 100644 --- a/dcs/status_message.py +++ b/dcs/status_message.py @@ -12,6 +12,7 @@ class MessageType(IntEnum): PARKING_SLOT_NOT_VALID = 1 PARKING_SLOTS_FULL = 3 MISSION_FORMAT_OLD = 4 + AIRPORT_UNKNOWN = 5 class StatusMessage: diff --git a/dcs/task.py b/dcs/task.py index 6d79b9a3..5553a05d 100644 --- a/dcs/task.py +++ b/dcs/task.py @@ -17,22 +17,27 @@ def _create_from_dict(d: TaskDict) -> 'Task': + # Unknown tasks, options and actions are kept as EmptyTaskAction with their raw params, + # so missions using things pydcs doesn't know (yet) still load and save unchanged. _id = d["id"] + cls: Type[Task] if _id == "WrappedAction": actionid = d["params"]["action"]["id"] if actionid == "Option": - t = options[d["params"]["action"]["params"]["name"]].create_from_dict(d) + cls = options.get(d["params"]["action"]["params"]["name"], EmptyTaskAction) else: - t = wrappedactions[actionid].create_from_dict(d) + cls = wrappedactions.get(actionid, EmptyTaskAction) elif _id == "EngageTargets": - t = engagetargets_tasks[d.get("key")].create_from_dict(d) + cls = engagetargets_tasks.get(d.get("key"), EmptyTaskAction) else: - t = tasks_map[_id].create_from_dict(d) + cls = tasks_map.get(_id, EmptyTaskAction) + t = cls.create_from_dict(d) t.auto = d["auto"] t.enabled = d["enabled"] t.number = d["number"] t.params = d["params"] + t.name = d.get("name") return t @@ -53,6 +58,7 @@ def __init__(self, _id: str = ''): self.auto: bool = False self.number: int = 1 self.enabled: bool = True + self.name: Optional[str] = None # optional name given in the mission editor def __repr__(self) -> str: return str(self.dict()) @@ -64,13 +70,16 @@ def create_from_dict(cls, d: TaskDict) -> "Task": return t def dict(self) -> TaskDict: - return { + d: TaskDict = { "id": self.id, "auto": self.auto, "enabled": self.enabled, "params": self.params, "number": self.number } + if self.name is not None: + d["name"] = self.name + return d def __eq__(self, other: Any) -> bool: if not isinstance(other, Task): @@ -765,6 +774,13 @@ def __init__(self, groupId: int, speed: float, altitude: float, lastWaypoint: Op "lastWptIndex": lastWaypoint, } + @classmethod + def create_from_dict(cls, d: TaskDict) -> "RecoveryTanker": + p = d["params"] + t = cls(p["groupId"], p["speed"], p["altitude"], p.get("lastWptIndex")) + t.params = p + return t + class OrbitAction(Task): Id = "Orbit" @@ -863,6 +879,12 @@ def __init__(self, group_id: int, groupName: str, position: Vector2 = Vector2(0, "number": number } + @classmethod + def create_from_dict(cls, d: TaskDict) -> "FACAttackGroup": + t = cls(d["params"]["groupId"], d["params"]["groupName"]) + t.params = d["params"] + return t + class FACEngageGroup(Task): Id = "FAC_EngageGroup" @@ -1150,6 +1172,8 @@ def __init__(self, alt_above: int = DEFAULT_ALT, EngageTargets.Id: EngageTargets, WWIIFollowBigFormation.Id: WWIIFollowBigFormation, CarpetBombing.Id: CarpetBombing, + RecoveryTanker.Id: RecoveryTanker, + NoTask.Id: NoTask, } @@ -1841,7 +1865,7 @@ class Values(IntEnum): # RotaryWedge has no VariantIndex or zInverse field. RotaryWedge = 8 RotaryEchelonRightSmall = 589825 - RotaryEchelonRightMedium = 583826 + RotaryEchelonRightMedium = 589826 RotaryEchelonRightLarge = 589827 RotaryEchelonLeftSmall = 590081 RotaryEchelonLeftMedium = 590082 @@ -2020,7 +2044,7 @@ def rotary_front_right_medium(): value=OptFormation.Values.RotaryFrontRightMedium, formationIndex=OptFormation.FormationIndex.RotaryFront, zInverse=OptFormation.ZInverse.Right, - variantIndex=OptFormation.VariantIndex.Close + variantIndex=OptFormation.VariantIndex.Open ) @staticmethod @@ -2038,7 +2062,7 @@ def rotary_front_left_medium(): value=OptFormation.Values.RotaryFrontLeftMedium, formationIndex=OptFormation.FormationIndex.RotaryFront, zInverse=OptFormation.ZInverse.Left, - variantIndex=OptFormation.VariantIndex.Close + variantIndex=OptFormation.VariantIndex.Open ) @staticmethod @@ -2072,7 +2096,7 @@ def line_abreast_open(): @staticmethod def line_abreast_group_close(): return OptFormation( - value=OptFormation.Values.LineAbreastClose, + value=OptFormation.Values.LineAbreastGroupClose, formationIndex=OptFormation.FormationIndex.LineAbreast, zInverse=None, variantIndex=OptFormation.VariantIndex.GroupClose @@ -2506,5 +2530,6 @@ def __init__(self, value: Values = Values.AllUnits): OptRadioUsageEngage.Key: OptRadioUsageEngage, OptRadioUsageKill.Key: OptRadioUsageKill, OptInterceptionRange.Key: OptInterceptionRange, - OptRestrictTargets.Key: OptRestrictTargets + OptRestrictTargets.Key: OptRestrictTargets, + OptJettisonEmptyTanks.Key: OptJettisonEmptyTanks, } diff --git a/dcs/templates.py b/dcs/templates.py index 6b59586e..f59b4cb0 100644 --- a/dcs/templates.py +++ b/dcs/templates.py @@ -90,6 +90,7 @@ def patriot_site(mission: Mission, position, heading, prefix="", skill=unit.Skil for u in vg.units: u.skill = skill + return vg @staticmethod def hawk_site(mission: Mission, position, heading, prefix="", skill=unit.Skill.Average): @@ -135,6 +136,7 @@ def hawk_site(mission: Mission, position, heading, prefix="", skill=unit.Skill.A for u in vg.units: u.skill = skill + return vg @staticmethod def sa11_site(mission, country, position, heading, prefix="", skill=unit.Skill.Average): @@ -167,16 +169,16 @@ def sa11_site(mission, country, position, heading, prefix="", skill=unit.Skill.A @staticmethod def sa15_site(mission, country, position, heading, prefix="", skill=unit.Skill.Average): vg = mission.vehicle_group(country, prefix + "SA15 site", - dcs.vehicles.AirDefence.MCC_SR_Sborka_Dog_Ear_SR, position, heading) + dcs.vehicles.AirDefence.Dog_Ear_radar, position, heading) - u = mission.vehicle("Operator 1", Russia.Vehicle.Infantry.Infantry_AK_74_Rus) + u = mission.vehicle("Operator 1", Russia.Vehicle.Infantry.Infantry_AK) u.position = position.point_from_heading(heading + 180, 10) u.heading = heading vg.add_unit(u) hdg = 90 for i in range(0, 4): # 4 tor vehicles - u = mission.vehicle("tor #" + str(i + 1), dcs.vehicles.AirDefence.SAM_SA_15_Tor_Gauntlet) + u = mission.vehicle("tor #" + str(i + 1), dcs.vehicles.AirDefence.Tor_9A331) u.position = position.point_from_heading(heading + hdg, 50) u.heading = heading vg.add_unit(u) diff --git a/dcs/terrain/caucasus/caucasus.py b/dcs/terrain/caucasus/caucasus.py index 68378893..a768071f 100644 --- a/dcs/terrain/caucasus/caucasus.py +++ b/dcs/terrain/caucasus/caucasus.py @@ -40,7 +40,7 @@ def __init__(self): self.bullseye_red = {"x": 11557, "y": 371700} try: - self.city_graph: Graph = Graph.from_pickle(os.path.join(os.path.dirname(__file__), 'citygraph.p')) + self.city_graph: Graph = Graph.from_pickle(os.path.join(os.path.dirname(__file__), 'citygraph.p'), self) except FileNotFoundError: pass diff --git a/dcs/terrain/nevada/nevada.py b/dcs/terrain/nevada/nevada.py index c41f40ba..f1b304c5 100644 --- a/dcs/terrain/nevada/nevada.py +++ b/dcs/terrain/nevada/nevada.py @@ -40,7 +40,7 @@ def __init__(self): self.bullseye_red = {"x": -288293.969, "y": -88022.641} try: - self.city_graph = Graph.from_pickle(os.path.join(os.path.dirname(__file__), 'nevada.p')) # type: Graph + self.city_graph = Graph.from_pickle(os.path.join(os.path.dirname(__file__), 'citygraph.p'), self) # type: Graph except FileNotFoundError: pass diff --git a/dcs/terrain/terrain.py b/dcs/terrain/terrain.py index 49eb0df0..4a557c17 100644 --- a/dcs/terrain/terrain.py +++ b/dcs/terrain/terrain.py @@ -201,6 +201,8 @@ class Airport(Warehouse): def __init__(self, position: mapping.Point, terrain: Terrain) -> None: self.position = position self._terrain = terrain + # per instance copy, the terrains add their zones to it and must not change the class default + self.unit_zones = list(self.unit_zones) self.runway_used = None self.runways = [] # type: List[Runway] self.parking_slots = [] # type: List[ParkingSlot] @@ -407,9 +409,17 @@ def add_edge(self, from_node: Node, to_node: Node, distance: int, on_road: bool self.edge_properties[(from_node.name, to_node.name)] = (distance, on_road) @staticmethod - def from_pickle(pickle_file): + def from_pickle(pickle_file, terrain: Terrain) -> Graph: with open(pickle_file, 'rb') as f: - return pickle.load(f) + graph = pickle.load(f) + # The pickled positions only carry coordinates, rebind them to the terrain. + # Older pickles also lack the air defence positions. + for node in graph.nodes: + node.position = mapping.Point(node.position.x, node.position.y, terrain) + node.air_defence_pos_small = [ + mapping.Point(p.x, p.y, terrain) for p in getattr(node, 'air_defence_pos_small', []) + ] + return graph def _dijkstra(self, initial): visited = {initial: 0} diff --git a/dcs/triggers.py b/dcs/triggers.py index 78af817f..cfcd24e4 100644 --- a/dcs/triggers.py +++ b/dcs/triggers.py @@ -79,13 +79,13 @@ def __repr__(self): class TriggerZoneQuadPoint(TriggerZone): def __init__(self, _id, position: mapping.Point, verticies: List[mapping.Point], hidden=False, name="", color=None, properties=None, - heading: Optional[float] = None, link_unit_id: Optional[int] = None): - # Forward by keyword and pass radius=0: quad zones have no radius (the DCS ME - # writes "radius": 0 for them). Positional forwarding silently slots `heading` - # into the parent's `radius` arg (added in PR #254) -- keyword args prevent that. + heading: Optional[float] = None, link_unit_id: Optional[int] = None, radius: float = 0): + # Forward by keyword: positional forwarding silently slots `heading` into the parent's + # `radius` arg (added in PR #254) -- keyword args prevent that. Quad zones have no radius + # and the DCS ME writes 0 for new ones, but a radius stored in a loaded mission is kept. super(TriggerZoneQuadPoint, self).__init__( _id, position, hidden=hidden, name=name, color=color, - properties=properties, radius=0, heading=heading, link_unit_id=link_unit_id) + properties=properties, radius=radius, heading=heading, link_unit_id=link_unit_id) self.type = TriggerZoneType.QuadPoint self.verticies = copy.copy(verticies) @@ -148,6 +148,7 @@ def _make_quad(self, imp_zone) -> TriggerZoneQuadPoint: imp_zone.get("properties", {}), imp_zone.get("heading", None), imp_zone.get("linkUnit", None), + radius=imp_zone.get("radius", 0), ) return tz @@ -236,6 +237,7 @@ def __init__(self, event: Event, comment: str): @classmethod def create_from_dict(cls, mission, d) -> 'TriggerRule': trig = cls(Event(d["eventlist"]), d["comment"]) + trig.color = d.get("colorItem", TriggerColor.White) actions = d["actions"] for a in actions: action_ = action.actions_map[actions[a]["predicate"]].create_from_dict(actions[a], mission) diff --git a/dcs/unit.py b/dcs/unit.py index 3c1f8dbc..b397ea40 100644 --- a/dcs/unit.py +++ b/dcs/unit.py @@ -36,8 +36,6 @@ def from_percentage(p): class Unit: def __init__(self, _id, terrain: Terrain, name: Optional[str] = None, type="") -> None: - if type == "": - breakpoint() self.type = type self._terrain = terrain self.position = mapping.Point(0, 0, self._terrain) @@ -83,14 +81,30 @@ class Vehicle(Unit): def __init__(self, terrain: Terrain, id: Optional[int] = None, name: Optional[str] = None, _type="Sandbox"): super().__init__(id, terrain, name, _type) self.player_can_drive = False + self.transportable: Optional[Dict[str, Any]] = None + self.cold_at_start: Optional[bool] = None + self.addpropvehicle: Optional[Dict[str, Any]] = None + self.wagons: Optional[Dict[int, str]] = None # train wagons def load_from_dict(self, d: Dict[str, Any]) -> None: super().load_from_dict(d) self.player_can_drive = d["playerCanDrive"] + self.transportable = d.get("transportable") + self.cold_at_start = d.get("coldAtStart") + self.addpropvehicle = d.get("AddPropVehicle") + self.wagons = d.get("wagons") def dict(self): d = super(Vehicle, self).dict() d["playerCanDrive"] = self.player_can_drive + if self.transportable is not None: + d["transportable"] = self.transportable + if self.cold_at_start is not None: + d["coldAtStart"] = self.cold_at_start + if self.addpropvehicle is not None: + d["AddPropVehicle"] = self.addpropvehicle + if self.wagons is not None: + d["wagons"] = self.wagons return d @@ -98,6 +112,8 @@ class Ship(Unit): def __init__(self, terrain: Terrain, id=None, name: Optional[str] = None, _type=None): super().__init__(id, terrain, name, _type.id) self.frequency = 127500000 + self.modulation: Optional[int] = None + self.transportable: Optional[Dict[str, Any]] = None def set_frequency(self, frequency: int) -> None: """Sets the communications frequency for this unit. @@ -110,10 +126,16 @@ def set_frequency(self, frequency: int) -> None: def load_from_dict(self, d: Dict[str, Any]) -> None: super().load_from_dict(d) self.frequency = d.get("frequency", self.frequency) + self.modulation = d.get("modulation") + self.transportable = d.get("transportable") def dict(self): d = super(Ship, self).dict() d["frequency"] = self.frequency + if self.modulation is not None: + d["modulation"] = self.modulation + if self.transportable is not None: + d["transportable"] = self.transportable return d @@ -136,6 +158,7 @@ def __init__(self, unit_id: int, name: Optional[str], _type: Union[str, Type[Uni self.can_cargo: Optional[bool] = None self.effect_preset: Optional[int] = None self.effect_transparency: Optional[float] = None + self.rope_length: Optional[float] = None if not isinstance(_type, str): if issubclass(_type, StaticType): @@ -166,6 +189,8 @@ def load_from_dict(self, d: Dict[str, Any]) -> None: self.mass = d.get("mass") self.effect_preset = d.get("effectPreset", None) self.effect_transparency = d.get("effectTransparency", None) + # sic, the mission editor writes it with this typo + self.rope_length = d.get("lenghtRope") def dict(self): d = super(Static, self).dict() @@ -176,7 +201,8 @@ def dict(self): (self.rate, "rate"), (self.mass, "mass"), (self.effect_preset, "effectPreset"), - (self.effect_transparency, "effectTransparency") + (self.effect_transparency, "effectTransparency"), + (self.rope_length, "lenghtRope"), ] for attr, keyName in fields: if attr is not None: diff --git a/dcs/unitgroup.py b/dcs/unitgroup.py index 1be6f08e..e6531a9e 100644 --- a/dcs/unitgroup.py +++ b/dcs/unitgroup.py @@ -169,7 +169,7 @@ def formation_vee(self, heading=0, distance=20): u_idx += 1 - if u_idx <= unit_count: + if u_idx < unit_count: self.units[u_idx].position = start_pos.point_from_heading(heading - 225, distance * (i + 1)) self.units[u_idx].heading = self.units[0].heading u_idx += 1 @@ -247,9 +247,13 @@ def __init__(self, _id, name=None, start_time=0): self.task_selected = True self.spawn_probability = 1.0 self.late_activation = False + self.uncontrollable: Optional[bool] = None + self.route_relative_tot: Optional[bool] = None def load_from_dict(self, d: Dict[str, Any], terrain: Terrain) -> None: super().load_from_dict(d, terrain) + self.uncontrollable = d.get("uncontrollable") + self.route_relative_tot = d.get("route", {}).get("routeRelativeTOT") self.frequency = d.get("frequency") self.task = d.get("task") # ships don't have a task self.spawn_probability = d.get("probability", 1.0) @@ -283,6 +287,10 @@ def dict(self): d["probability"] = self.spawn_probability if self.late_activation: d["lateActivation"] = self.late_activation + if self.uncontrollable is not None: + d["uncontrollable"] = self.uncontrollable + if self.route_relative_tot is not None and "route" in d: + d["route"]["routeRelativeTOT"] = self.route_relative_tot return d @@ -303,6 +311,8 @@ def load_from_dict(self, d: Dict[str, Any], terrain: Terrain) -> None: self.communication = d.get("communication", False) self.visible = d.get("visible", False) self.manualHeading = d.get("manualHeading", False) + spans = d.get("route", {}).get("spans", {}) + self.spans = [spans[k] for k in sorted(spans)] def add_span(self, position: mapping.Point): self.spans.append({"x": position.x, "y": position.y}) @@ -326,13 +336,8 @@ def dict(self): d["frequency"] = self.frequency d["visible"] = self.visible d["manualHeading"] = self.manualHeading - if self.spans is not None: - # spans - d["route"]["spans"] = {} - i = 1 - for spawn in self.spans: - d["route"]["spans"][i] = spawn - i += 1 + if self.spans is not None and "route" in d: + d["route"]["spans"] = {i + 1: span for i, span in enumerate(self.spans)} return d @@ -577,6 +582,10 @@ def __init__(self, _id, name=None, start_time=0): self.spans = None self.visible = False # visible before start flag + def load_from_dict(self, d: Dict[str, Any], terrain: Terrain) -> None: + super().load_from_dict(d, terrain) + self.visible = d.get("visible", False) + def add_waypoint(self, position: mapping.Point, speed=20) -> MovingPoint: mp = MovingPoint(position) mp.type = "Turning Point" diff --git a/dcs/weather.py b/dcs/weather.py index a594bb58..e226958f 100644 --- a/dcs/weather.py +++ b/dcs/weather.py @@ -44,14 +44,32 @@ def __init__(self, preset: Preset = Preset.Auto, crystals: Optional[Crystals] = self.crystals = crystals def validate_halo(self) -> None: - if self.preset is (Halo.Preset.Auto or Halo.Preset.Off) and self.crystals is not None: - raise ValueError( - f"Halo preset: {self.preset} cannot have a crystals value.") - - if self.preset is not (Halo.Preset.Auto or Halo.Preset.Off) and self.crystals is None: + if self.preset in (Halo.Preset.Auto, Halo.Preset.Off): + if self.crystals is not None: + raise ValueError( + f"Halo preset: {self.preset} cannot have a crystals value.") + elif isinstance(self.preset, Halo.Preset) and self.crystals is None: raise ValueError( f"Halo preset: {self.preset} must have a crystals value.") + @classmethod + def load_from_dict(cls, d: Dict[str, Any]) -> "Halo": + # values unknown to pydcs are kept as raw strings, so they are written back unchanged + preset: Any = d.get("preset", Halo.Preset.Auto.value) + try: + preset = Halo.Preset(preset) + except ValueError: + pass + crystals: Any = d.get("crystalsPreset") + if preset in (Halo.Preset.Auto, Halo.Preset.Off): + crystals = None + elif crystals is not None: + try: + crystals = Halo.Crystals(crystals) + except ValueError: + pass + return cls(preset, crystals) + def dict(self): if self.crystals: return {"crystalsPreset": f"{self.crystals}", "preset": f"{self.preset}"} @@ -135,6 +153,7 @@ def __init__(self, terrain): self.name = "Summer, clean sky" self.fog_thickness = 0 self.fog_visibility = 25 + self.fog_density: Optional[int] = None self.visibility_distance = 80000 self.clouds_preset: Optional[CloudPreset] = None self.clouds_thickness = 200 @@ -145,7 +164,16 @@ def __init__(self, terrain): self.enable_dust = False self.dust_density = 0 + # values pydcs doesn't know, written back unchanged + self.unknown_values: Dict[str, Any] = {} + self.unknown_season_values: Dict[str, Any] = {} + + _KNOWN_KEYS = {"atmosphere_type", "wind", "enable_fog", "halo", "groundTurbulence", "season", "type_weather", + "qnh", "cyclones", "name", "fog", "visibility", "clouds", "enable_dust", "dust_density"} + def load_from_dict(self, d): + self.unknown_values = {k: v for k, v in d.items() if k not in Weather._KNOWN_KEYS} + self.unknown_season_values = {k: v for k, v in d.get("season", {}).items() if k != "temperature"} self.atmosphere_type = d["atmosphere_type"] wind = d.get("wind", {}) wind_at_ground = wind.get("atGround", {}) @@ -154,10 +182,7 @@ def load_from_dict(self, d): self.wind_at_ground = Wind(wind_at_ground.get("dir", 0), wind_at_ground.get("speed", 0)) self.wind_at_2000 = Wind(wind_at_2000.get("dir", 0), wind_at_2000.get("speed", 0)) self.wind_at_8000 = Wind(wind_at_8000.get("dir", 0), wind_at_8000.get("speed", 0)) - halo = d.get("halo", {}) - halo_preset = halo.get("preset", {}) - halo_crystals_preset = halo.get("crystalsPreset", {}) - self.halo = Halo(halo_preset, halo_crystals_preset) + self.halo = Halo.load_from_dict(d.get("halo", {})) self.enable_fog = d["enable_fog"] self.turbulence_at_ground = d.get("groundTurbulence", 0) season = d.get("season", {}) @@ -165,7 +190,7 @@ def load_from_dict(self, d): self.type_weather = d.get("type_weather", 0) self.qnh = d.get("qnh", 760) cyclones = d.get("cyclones", {}) - for x in cyclones: + for x in sorted(cyclones): c = Cyclone() c.centerX = cyclones[x].get("centerX", 0) c.centerZ = cyclones[x].get("centerZ", 0) @@ -178,6 +203,7 @@ def load_from_dict(self, d): fog = d.get("fog", {}) self.fog_thickness = fog.get("thickness", 0) self.fog_visibility = fog.get("visibility", 25) + self.fog_density = fog.get("density") visibility = d.get("visibility", {}) self.visibility_distance = visibility.get("distance", 80000) clouds = d.get("clouds", {}) @@ -368,18 +394,19 @@ def _make_cloud_dict(self) -> Dict[str, Any]: self.clouds_preset.validate_base(self.clouds_base) - # The hard coded values here seem to be the case for all presets. They are not - # configurable in the ME and not defined by clouds.lua. + # Thickness and density are not configurable in the ME for presets and not defined by + # clouds.lua. New missions use 200 and 0, but the ME may keep other values from before a + # preset was selected, so the loaded ones are written back. return { "base": self.clouds_base, - "density": 0, + "density": self.clouds_density, "preset": self.clouds_preset.name, - "thickness": 200, + "thickness": self.clouds_thickness, "iprecptns": 0, } def dict(self) -> Dict[str, Any]: - d = {} + d = dict(self.unknown_values) d["atmosphere_type"] = self.atmosphere_type d["wind"] = {"atGround": self.wind_at_ground.dict(), "at2000": self.wind_at_2000.dict(), @@ -387,13 +414,15 @@ def dict(self) -> Dict[str, Any]: d["enable_fog"] = self.enable_fog d["halo"] = self.halo._make_halo_dict() d["groundTurbulence"] = self.turbulence_at_ground - d["season"] = {"temperature": self.season_temperature} + d["season"] = {**self.unknown_season_values, "temperature": self.season_temperature} assert isinstance(self.type_weather, int) d["type_weather"] = self.type_weather d["qnh"] = self.qnh d["cyclones"] = {x + 1: self.cyclones[x].dict() for x in range(0, len(self.cyclones))} d["name"] = self.name d["fog"] = {"thickness": self.fog_thickness, "visibility": self.fog_visibility} + if self.fog_density is not None: + d["fog"]["density"] = self.fog_density d["visibility"] = {"distance": self.visibility_distance} d["clouds"] = self._make_cloud_dict() d["enable_dust"] = self.enable_dust diff --git a/doc/caucasus_random_mission.rst b/doc/caucasus_random_mission.rst index 03c59b43..0eb7048f 100644 --- a/doc/caucasus_random_mission.rst +++ b/doc/caucasus_random_mission.rst @@ -1,30 +1,28 @@ Random mission script ===================== -pydcs delivers a small random mission creator script. -This script was indented to show features of the framework and is also a little testbed. +pydcs delivers a small random mission creator script, installed as ``dcs_random``. +It generates a random training mission on the Nevada map. +This script was intended to show features of the framework and is also a little testbed. It is invoked by the commandline with the following arguments:: - usage: caucasus_random_mission.py [-h] [-a {A-10C,Su-25T,M-2000C,Ka-50,MiG-21Bis}] - [-p PLAYERCOUNT] [-s {inflight,runway,warm,cold}] - [-t {main,CAS,CAP,refuel}] - [-d {random,day,night,dusk,dawn,noon}] - [-w {dynamic,dyncyclone,dynanti,dynone,clear}] [-u] - [--show-stats] [-o OUTPUT] + usage: dcs_random [-h] [-a {A-10C,M-2000C,Ka-50,AV8BNA,FA-18C_hornet}] + [-p PLAYERCOUNT] [-s {inflight,runway,warm,cold}] + [-d {random,day,night,dusk,dawn,noon}] + [-w {dynamic,dyncyclone,dynanti,dynone,clear}] [-u] + [--show-stats] [-o OUTPUT] - Random DCS mission generator + Nevada random training mission generator - optional arguments: + options: -h, --help show this help message and exit - -a {A-10C,Su-25T,M-2000C,Ka-50,MiG-21Bis}, --aircrafttype {A-10C,Su-25T,M-2000C,Ka-50,MiG-21Bis} + -a, --aircrafttype {A-10C,M-2000C,Ka-50,AV8BNA,FA-18C_hornet} Player aircraft type - -p PLAYERCOUNT, --playercount PLAYERCOUNT - -s {inflight,runway,warm,cold}, --start {inflight,runway,warm,cold} - -t {main,CAS,CAP,refuel}, --missiontype {main,CAS,CAP,refuel} - -d {random,day,night,dusk,dawn,noon}, --daytime {random,day,night,dusk,dawn,noon} - -w {dynamic,dyncyclone,dynanti,dynone,clear}, --weather {dynamic,dyncyclone,dynanti,dynone,clear} + -p, --playercount PLAYERCOUNT + -s, --start {inflight,runway,warm,cold} + -d, --daytime {random,day,night,dusk,dawn,noon} + -w, --weather {dynamic,dyncyclone,dynanti,dynone,clear} -u, --unhide Show enemy pre mission --show-stats Show generated missions stats - -o OUTPUT, --output OUTPUT - Name and path of the generated mission + -o, --output OUTPUT Name and path of the generated mission diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..6b43eff3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "pydcs" +version = "0.15.0" +description = "A Digital Combat Simulator mission builder framework" +readme = "README.md" +authors = [{ name = "Peinthor Rene", email = "peinthor@gmail.com" }] +license = "LGPL-3.0-or-later" +license-files = ["LICENSE.txt"] +requires-python = ">=3.11" +dependencies = ["pyproj"] +keywords = ["dcs", "digital combat simulator", "eagle dynamics", "mission", "framework"] +classifiers = [ + "Development Status :: 4 - Beta", + "Topic :: Games/Entertainment :: Simulation", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Programming Language :: Python :: 3 :: Only", +] + +[project.urls] +Homepage = "https://github.com/pydcs/dcs" +Documentation = "https://dcs.readthedocs.org/en/latest" + +[project.scripts] +dcs_random = "dcs.scripts.nevada_random_mission:main" +dcs_dogfight_wwii = "dcs.scripts.dogfight_wwii:main" +dcs_oil_convoy = "dcs.scripts.destroy_oil_transport:main" + +[tool.setuptools.packages.find] +include = ["dcs*"] + +[tool.setuptools.package-data] +dcs = ["py.typed", "terrain/*/citygraph.p"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 6bdec778..00000000 --- a/setup.py +++ /dev/null @@ -1,71 +0,0 @@ -from setuptools import setup -from codecs import open -from os import path - -here = path.abspath(path.dirname(__file__)) - -long_description = '' -with open(path.join(here, 'README.md'), encoding='utf-8') as f: - for line in f: - if line.startswith('## Sample'): - break - long_description += line - -setup( - name="pydcs", - version='0.15.0', - description="A Digital Combat Simulator mission builder framework", - long_description=long_description, - url='https://github.com/pydcs/dcs', - author="Peinthor Rene", - author_email="peinthor@gmail.com", - license="LGPLv3", - classifiers=[ - 'Development Status :: 4 - Beta', - 'Topic :: Games/Entertainment :: Simulation', - 'Intended Audience :: Developers', - 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3 :: Only' - ], - keywords='dcs digital combat simulator eagle dynamics mission framework', - install_requires=[ - 'pyproj' - ], - packages=[ - 'dcs', - 'dcs/drawing', - 'dcs/liveries', - 'dcs/lua', - 'dcs/scripts', - 'dcs/terrain', - 'dcs/terrain/caucasus', - 'dcs/terrain/falklands', - 'dcs/terrain/germany', - 'dcs/terrain/kola', - 'dcs/terrain/marianaislands', - 'dcs/terrain/nevada', - 'dcs/terrain/normandy', - 'dcs/terrain/persiangulf', - 'dcs/terrain/projections', - 'dcs/terrain/sinai', - 'dcs/terrain/syria', - 'dcs/terrain/thechannel', - ], - package_data={ - 'dcs': ['py.typed'], - 'dcs/terrain': ['caucasus.p', 'nevada.p'], - }, - entry_points={ - 'console_scripts': [ - 'dcs_random=dcs.scripts.caucasus_random_mission:main', - 'dcs_dogfight_wwii=dcs.scripts.dogfight_wwii:main', - 'dcs_oil_convoy=dcs.scripts.destroy_oil_transport:main' - ] - }, - test_suite="tests" -) diff --git a/tests/liveries/test_livery.py b/tests/liveries/test_livery.py index f6894993..b4c929c9 100644 --- a/tests/liveries/test_livery.py +++ b/tests/liveries/test_livery.py @@ -1,6 +1,9 @@ +import sys from pathlib import Path from zipfile import ZipFile +import pytest + from dcs.liveries.livery import Livery from dcs.liveries.liveryset import LiverySet @@ -66,6 +69,7 @@ def test_livery_valid_for_country() -> None: assert Livery("", "", 0, {"USA", "RUS"}).valid_for_country("RUS") +@pytest.mark.skipif(sys.platform != "win32", reason="the 'ansi' codec only exists on Windows") def test_ansi_encoded_description(tmp_path: Path) -> None: (tmp_path / "description.lua").write_bytes("name = 'Š'".encode("ansi")) livery = Livery.from_path(str(tmp_path)) @@ -73,6 +77,14 @@ def test_ansi_encoded_description(tmp_path: Path) -> None: assert livery.name == "Š" +@pytest.mark.skipif(sys.platform == "win32", reason="Windows decodes with the 'ansi' codec first") +def test_cp1252_encoded_description_without_ansi_codec(tmp_path: Path) -> None: + (tmp_path / "description.lua").write_bytes("name = 'Š'".encode("cp1252")) + livery = Livery.from_path(str(tmp_path)) + assert livery is not None + assert livery.name == "Š" + + def test_find_unzipped_livery(tmp_path: Path) -> None: description = tmp_path / "description.lua" description.touch() diff --git a/tests/test_installation.py b/tests/test_installation.py index 27f8c384..3b3fecdb 100644 --- a/tests/test_installation.py +++ b/tests/test_installation.py @@ -1,3 +1,5 @@ +import os +import sys import textwrap from pathlib import Path from typing import Any, Callable, Iterator, Optional, TypeVar @@ -97,15 +99,16 @@ def none_installed_fixture() -> Iterator[None]: def test_get_dcs_install_directory_stable(stable_dcs_install: Path) -> None: - assert get_dcs_install_directory() == f"{stable_dcs_install}\\" + assert get_dcs_install_directory() == f"{stable_dcs_install}{os.path.sep}" def test_get_dcs_install_directory_beta(beta_dcs_install: Path) -> None: - assert get_dcs_install_directory() == f"{beta_dcs_install}\\" + assert get_dcs_install_directory() == f"{beta_dcs_install}{os.path.sep}" +@pytest.mark.skipif(sys.platform != "win32", reason="Steam library folders are parsed as Windows drive paths") def test_get_dcs_install_directory_steam(steam_dcs_install: Path) -> None: - assert get_dcs_install_directory() == f"{steam_dcs_install}\\" + assert get_dcs_install_directory() == f"{steam_dcs_install}{os.path.sep}" def test_get_dcs_install_directory_not_installed(none_installed: None) -> None: diff --git a/tests/test_mapping.py b/tests/test_mapping.py index 68c7e755..84900f77 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -76,6 +76,17 @@ def test_random_point(self) -> None: rp = r.random_point() self.assertTrue(r.point_in_rect(rp)) + def test_random_distant_points(self) -> None: + terrain = Caucasus() + wide = Rectangle(-300000, 600000, -301000, 620000, terrain) + tall = Rectangle(-300000, 600000, -320000, 601000, terrain) + for r in (wide, tall): + for i in range(0, 50): + p1, p2 = r.random_distant_points(500) + self.assertTrue(r.point_in_rect(p1)) + self.assertTrue(r.point_in_rect(p2)) + self.assertAlmostEqual(p1.distance_to_point(p2), 500) + class TriangleTests(unittest.TestCase): def test_triangle_random(self) -> None: diff --git a/tests/test_mission.py b/tests/test_mission.py index 8ddff939..b42fb9ce 100644 --- a/tests/test_mission.py +++ b/tests/test_mission.py @@ -1,7 +1,10 @@ import os import time import unittest +from unittest import mock from pathlib import Path +import tempfile +import warnings import zipfile import math @@ -1541,3 +1544,222 @@ def test_update_warehouses_gap_fills_missing_entries(self): self.assertNotIn(uid, m.warehouses.warehouses) m.update_warehouses() self.assertIn(uid, m.warehouses.warehouses) + + +class SaveLoadRoundTripTests(unittest.TestCase): + + def _save_and_load(self, m: dcs.Mission) -> dcs.Mission: + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "roundtrip.miz") + m.save(path) + m2 = dcs.Mission() + self.assertEqual(m2.load_file(path), []) + return m2 + + def test_unit_with_empty_type(self) -> None: + with mock.patch("sys.breakpointhook", side_effect=AssertionError("breakpoint() called")): + static = dcs.unit.Static(1, "static", "", Caucasus()) + self.assertEqual(static.type, "") + + def test_goals_of_all_sides_are_saved(self) -> None: + m = dcs.Mission() + m.goals.add_blue(dcs.goals.Goal("blue 1")) + m.goals.add_blue(dcs.goals.Goal("blue 2")) + m.goals.add_red(dcs.goals.Goal("red 1")) + m.goals.add_offline(dcs.goals.Goal("offline 1")) + + m2 = self._save_and_load(m) + comments = {side: [g.comment for g in goals] for side, goals in m2.goals.goals.items()} + self.assertEqual(comments, {"blue": ["blue 1", "blue 2"], "red": ["red 1"], "offline": ["offline 1"]}) + + def test_all_neutral_countries_in_coalitions(self) -> None: + m = dcs.Mission() + coalitions = m.dict()["coalitions"] + neutrals = set(coalitions["neutrals"].values()) + blue = set(coalitions["blue"].values()) + red = set(coalitions["red"].values()) + self.assertFalse(neutrals & (blue | red)) + self.assertEqual(neutrals | blue | red, set(dcs.countries.country_dict)) + + m = dcs.Mission() + m.load_file("tests/missions/Draw_tool_test.miz") + algeria = m.country("Algeria") + self.assertIn(algeria, m.coalition["neutrals"].countries.values()) + self.assertIn(algeria.id, m.dict()["coalitions"]["neutrals"].values()) + + def test_mark_actions_parameter_order(self) -> None: + with zipfile.ZipFile("tests/loadtest.miz") as miz: + orig = dcs.lua.loads(miz.read("mission").decode())["mission"]["trig"]["actions"] + m = dcs.Mission() + m.load_file("tests/loadtest.miz") + regenerated = m.triggerrules.trig()["actions"] + + def marks(actions): + return [x for a in actions.values() for x in a.split(";") if x.startswith("a_mark_to_")] + + self.assertEqual(len(marks(orig)), 3) + self.assertEqual(marks(orig), marks(regenerated)) + + def test_picture_action_from_enums(self) -> None: + P = dcs.action.PictureAction + m = dcs.Mission() + res_key = m.map_resource.add_resource_file("tests/images/blue.png") + trigger = dcs.triggers.TriggerOnce(comment="picture") + trigger.add_action(PictureToAll(res_key, 10, False, 0, P.HorzAlignment.Center, P.VertAlignment.Top, + 50, P.SizeUnits.WindowSize)) + trigger.add_action(PictureToCoalition(dcs.action.Coalition.Red, res_key, 10, False, 0, + P.HorzAlignment.Right, P.VertAlignment.Bottom, + 50, P.SizeUnits.OriginalSize)) + m.triggerrules.triggers.append(trigger) + + m2 = self._save_and_load(m) + self.assertEqual(m2.triggerrules.triggers[0].actions, trigger.actions) + self.assertEqual(m2.triggerrules.triggers[0].actions[1].coalition, "red") + + def test_picture_alignment_enums(self) -> None: + P = dcs.action.PictureAction + self.assertEqual(P.HorzAlignment.Left, P.HorzAlignment.Left) + self.assertNotEqual(P.HorzAlignment.Left, P.HorzAlignment.Right) + self.assertEqual(P.HorzAlignment.Left, "0") + self.assertEqual(len({P.HorzAlignment.Center, P.VertAlignment.Center, P.SizeUnits.WindowSize}), 3) + + def test_radio_item_for_coalition_default(self) -> None: + m = dcs.Mission() + trigger = dcs.triggers.TriggerOnce(comment="radio") + trigger.add_action(dcs.action.AddRadioItemForCoalition(radiotext=m.string("menu"), flag=5)) + trigger.add_action(dcs.action.RemoveRadioItemForCoalition(radiotext=m.string("menu"))) + m.triggerrules.triggers.append(trigger) + + m2 = self._save_and_load(m) + self.assertEqual([a.coalitionlist for a in m2.triggerrules.triggers[0].actions], ["all", "all"]) + + def test_vehicle_group_without_waypoints(self) -> None: + m = dcs.Mission() + group = dcs.unitgroup.VehicleGroup(m.next_group_id(), "no waypoints") + group.add_unit(m.vehicle("unit", dcs.vehicles.Armor.M_1_Abrams)) + self.assertNotIn("route", group.dict()) + + def test_coalition_nav_points(self) -> None: + m = dcs.Mission() + m.load_file("tests/loadtest.miz") + point = {"type": "Default", "comment": "", "callsignStr": "REF1", "id": 1, "properties": {"vnav": 1}, + "x": -280000, "y": 650000} + m.coalition["blue"].nav_points.append(point) + + m2 = self._save_and_load(m) + self.assertEqual(m2.coalition["blue"].nav_points, [point]) + self.assertEqual(m2.coalition["red"].nav_points, []) + + def test_neutral_task_description(self) -> None: + m = dcs.Mission() + self.assertNotIn("descriptionNeutralsTask", m.dict()) + m.set_description_neutralstask_text("neutral task") + + m2 = self._save_and_load(m) + self.assertEqual(m2.description_neutralstask_text(), "neutral task") + + def test_next_eplrs_skips_used_ids(self) -> None: + m = dcs.Mission() + pos = m.terrain.airports["Batumi"].position + usa, russia = m.country("USA"), m.country("Russia") + a = m.vehicle_group(usa, "A", dcs.vehicles.Armor.M_1_Abrams, pos) + m.vehicle_group(usa, "B", dcs.vehicles.Armor.M_1_Abrams, pos) + usa.vehicle_group.remove(a) + m.vehicle_group(russia, "C", dcs.vehicles.Armor.T_90, pos) + m.vehicle_group(usa, "D", dcs.vehicles.Armor.M_1_Abrams, pos) + + ids = list(m.eplrs_for("vehicle").values()) + self.assertEqual(sorted(ids), [1, 2, 3]) + + def test_resource_files_with_same_name(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + for side, content in (("red", b"RED"), ("blue", b"BLUE")): + os.makedirs(os.path.join(tmp, side)) + with open(os.path.join(tmp, side, "brief.jpg"), "wb") as f: + f.write(content) + m = dcs.Mission() + red = m.add_picture_red(os.path.join(tmp, "red", "brief.jpg")) + blue = m.add_picture_blue(os.path.join(tmp, "blue", "brief.jpg")) + red_again = m.map_resource.add_resource_file(os.path.join(tmp, "red", "brief.jpg")) + path = os.path.join(tmp, "pictures.miz") + m.save(path) + + with zipfile.ZipFile(path) as miz: + resources = dcs.lua.loads(miz.read("l10n/DEFAULT/mapResource").decode())["mapResource"] + self.assertEqual(miz.read("l10n/DEFAULT/" + resources[red.key]), b"RED") + self.assertEqual(miz.read("l10n/DEFAULT/" + resources[blue.key]), b"BLUE") + self.assertEqual(resources[red_again.key], resources[red.key]) + self.assertEqual(len(miz.namelist()), len(set(miz.namelist()))) + + def test_load_file_replaces_previous_state(self) -> None: + m = dcs.Mission() + m.load_file("tests/missions/LUNA.miz") + pictures = list(m.pictureFileNameR) + self.assertTrue(pictures) + m.reload() + self.assertEqual(m.pictureFileNameR, pictures) + + # a reloaded mission is saved without duplicate archive entries + m = dcs.Mission() + m.load_file("tests/missions/a_out_picture.miz") + m.reload() + with tempfile.TemporaryDirectory() as tmp, warnings.catch_warnings(): + warnings.simplefilter("error") + path = os.path.join(tmp, "reloaded.miz") + m.save(path) + with zipfile.ZipFile(path) as miz: + self.assertEqual(len(miz.namelist()), len(set(miz.namelist()))) + + # nothing of the first mission is kept + m = dcs.Mission() + m.load_file("tests/missions/Coop_OP_Custodia_v1.miz") + m.add_aircraft_kneeboard(dcs.planes.FA_18C_hornet, Path("tests/images/blue.png")) + m.load_file("tests/loadtest.miz") + fresh = dcs.Mission() + fresh.load_file("tests/loadtest.miz") + self.assertEqual(m.forced_options.dict(), fresh.forced_options.dict()) + self.assertEqual(m.aircraft_kneeboards, {}) + + # a mission without neutral coalition doesn't keep the one of the previous mission + m.load_file("tests/missions/Coop_OP_Custodia_v1.miz") + self.assertEqual(set(m.coalition), {"blue", "red"}) + + def test_load_file_uses_map_view_of_loaded_terrain(self) -> None: + m = dcs.Mission() # Caucasus + m.load_file("tests/missions/Forestry_Operations.miz") # Nevada + self.assertIsInstance(m.terrain, dcs.terrain.Nevada) + self.assertIs(m.map._terrain, m.terrain) + position = m.map.position.latlng() + self.assertAlmostEqual(position.lat, 36.27, places=1) + self.assertAlmostEqual(position.lng, -115.57, places=1) + + def test_load_unknown_airport(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + # move a parked plane group to an airport id pydcs doesn't know + path = os.path.join(tmp, "unknown_airport.miz") + with zipfile.ZipFile("tests/loadtest.miz") as src, zipfile.ZipFile(path, "w") as dst: + for item in src.infolist(): + data = src.read(item.filename) + if item.filename == "mission": + mission = dcs.lua.loads(data.decode())["mission"] + group = next(g for c in mission["coalition"]["blue"]["country"].values() + for g in c.get("plane", {}).get("group", {}).values() + if g["route"]["points"][1].get("airdromeId") is not None + and g["units"][1].get("parking") is not None) + group["route"]["points"][1]["airdromeId"] = 999 + data = dcs.lua.dumps(mission, "mission", 1).encode() + dst.writestr(item, data) + + m = dcs.Mission() + status = m.load_file(path) + self.assertIn(dcs.status_message.MessageType.AIRPORT_UNKNOWN, [s.type for s in status]) + + loaded = m.find_group(group["name"]) + saved = os.path.join(tmp, "saved.miz") + m.save(saved) + m2 = dcs.Mission() + status = m2.load_file(saved) + self.assertEqual({s.type for s in status}, {dcs.status_message.MessageType.AIRPORT_UNKNOWN}) + reloaded = m2.find_group(group["name"]) + self.assertEqual(reloaded.points[0].airdrome_id, 999) + self.assertEqual(reloaded.units[0].parking, loaded.units[0].parking) diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py new file mode 100644 index 00000000..cc7daa6f --- /dev/null +++ b/tests/test_roundtrip.py @@ -0,0 +1,179 @@ +"""Load and save every sample mission and check that nothing the mission editor wrote gets lost.""" +import glob +import os +import tempfile +import unittest +import zipfile +from typing import Any, Dict, Iterator, Tuple + +import dcs +from dcs.lua import loads + +GROUP_CATEGORIES = ("vehicle", "ship", "plane", "helicopter", "static") + +# keys dropped on purpose because they hold the value DCS uses when the key is absent +DEFAULT_VALUED_GROUP_KEYS = {"lateActivation": False, "probability": 1} + + +def _read_miz(path: str) -> Dict[str, Any]: + # the mission editor writes UTF-8 file names without setting the archive's UTF-8 flag + with zipfile.ZipFile(path, metadata_encoding="utf-8") as miz: + content = {name: loads(miz.read(name).decode("utf-8")) for name in ("mission", "options")} + content["files"] = miz.namelist() + content["mapResource"] = loads(miz.read("l10n/DEFAULT/mapResource").decode("utf-8"))["mapResource"] + return content + + +def _groups(mission: Dict[str, Any]) -> Iterator[Tuple[str, Dict[str, Any]]]: + for side in mission["coalition"].values(): + for country in side.get("country", {}).values(): + for category in GROUP_CATEGORIES: + for group in country.get(category, {}).get("group", {}).values(): + yield category, group + + +class RoundTripTests(unittest.TestCase): + missions: Dict[str, Tuple[Dict[str, Any], Dict[str, Any]]] = {} + generated_trig_actions: Dict[str, Dict[int, str]] = {} + + @classmethod + def setUpClass(cls) -> None: + with tempfile.TemporaryDirectory() as tmp: + for path in sorted(glob.glob("tests/**/*.miz", recursive=True)): + m = dcs.Mission() + m.load_file(path) + cls.generated_trig_actions[os.path.basename(path)] = m.triggerrules.trig()["actions"] + saved = os.path.join(tmp, "saved.miz") + m.save(saved) + cls.missions[os.path.basename(path)] = (_read_miz(path), _read_miz(saved)) + + def test_group_and_unit_keys_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + saved_groups = {(c, g["groupId"]): g for c, g in _groups(saved["mission"]["mission"])} + for category, group in _groups(orig["mission"]["mission"]): + with self.subTest(mission=name, group=group["name"]): + new = saved_groups[(category, group["groupId"])] + lost = {k for k in set(group) - set(new) if DEFAULT_VALUED_GROUP_KEYS.get(k, object()) != group[k]} + self.assertFalse(lost, "group keys lost") + self.assertFalse(set(group.get("route", {})) - set(new.get("route", {})), "route keys lost") + new_units = {u["unitId"]: u for u in new.get("units", {}).values()} + for unit in group.get("units", {}).values(): + new_unit = new_units[unit["unitId"]] + self.assertFalse(set(unit) - set(new_unit), "unit keys lost") + self.assertFalse(set(unit.get("payload", {})) - set(new_unit.get("payload", {})), + "payload keys lost") + + def test_group_values_are_kept(self) -> None: + def facts(mission: Dict[str, Any]) -> Dict[Any, Any]: + out: Dict[Any, Any] = {} + for category, group in _groups(mission): + key = (category, group["groupId"]) + route = group.get("route", {}) + out[key + ("spans",)] = route.get("spans", {}) + out[key + ("visible",)] = group.get("visible") + out[key + ("task names",)] = [ + task.get("name") + for point in route.get("points", {}).values() + for task in point.get("task", {}).get("params", {}).get("tasks", {}).values() + ] + return out + + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before, after = facts(orig["mission"]["mission"]), facts(saved["mission"]["mission"]) + self.assertEqual({k: v for k, v in before.items() if v is not None}, + {k: v for k, v in after.items() if k in before and before[k] is not None}) + + def test_options_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before, after = orig["options"]["options"], saved["options"]["options"] + self.assertEqual(before.get("playerName"), after.get("playerName")) + self.assertFalse(set(before) - set(after), "options lost") + self.assertFalse(set(before["difficulty"]) - set(after["difficulty"]), "difficulty options lost") + for key, value in before["difficulty"].items(): + self.assertEqual(value, after["difficulty"][key], key) + + def test_mission_settings_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before, after = orig["mission"]["mission"], saved["mission"]["mission"] + self.assertEqual(before.get("forcedOptions"), after.get("forcedOptions")) + self.assertEqual("descriptionNeutralsTask" in before, "descriptionNeutralsTask" in after) + for side in before["coalition"]: + self.assertEqual(before["coalition"][side].get("nav_points", {}), + after["coalition"][side]["nav_points"]) + + def test_trigger_action_keys_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before, after = orig["mission"]["mission"]["trigrules"], saved["mission"]["mission"]["trigrules"] + for rule_key, rule in before.items(): + for action_key, action in rule["actions"].items(): + new_action = after[rule_key]["actions"][action_key] + expected = [] + if action["predicate"] == "a_out_text_delay" and "start_delay" in action: + expected.append("start_delay") + # KeyDict_ belongs to localized fields; on other fields it's a stale leftover + expected += [k for k in ("KeyDict_radiotext", "KeyDict_comment") + if k in action and k[len("KeyDict_"):] in new_action] + for key in expected: + self.assertEqual(action[key], new_action.get(key), (action["predicate"], key)) + + def test_trigger_zones_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before = orig["mission"]["mission"]["triggers"]["zones"] + after = {z["zoneId"]: z for z in saved["mission"]["mission"]["triggers"]["zones"].values()} + for zone in before.values(): + self.assertEqual(zone["radius"], after[zone["zoneId"]]["radius"], zone["name"]) + + def test_generated_trigger_code_matches_mission_editor(self) -> None: + for name, (orig_miz, _) in self.missions.items(): + with self.subTest(mission=name): + orig = orig_miz["mission"]["mission"]["trig"]["actions"] + generated = self.generated_trig_actions[name] + for key, code in orig.items(): + for dcs_call, pydcs_call in zip(code.split(";"), generated[key].split(";")): + dcs_call, pydcs_call = dcs_call.strip(), pydcs_call.strip() + # the mission editor fills in a trailing default argument that isn't stored + if dcs_call != pydcs_call: + self.assertEqual(dcs_call, pydcs_call[:-1] + ", 0)") + + def test_weather_is_kept(self) -> None: + def flatten(d: Dict[Any, Any], prefix: str = "") -> Dict[str, Any]: + out: Dict[str, Any] = {} + for k, v in d.items(): + if isinstance(v, dict): + out.update(flatten(v, f"{prefix}{k}/")) + else: + out[f"{prefix}{k}"] = v + return out + + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before = flatten(orig["mission"]["mission"]["weather"]) + after = flatten(saved["mission"]["mission"]["weather"]) + self.assertEqual(before, {k: after.get(k, "") for k in before}) + + def test_drawings_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before = orig["mission"]["mission"].get("drawings", {}).get("layers", {}) + after = saved["mission"]["mission"]["drawings"]["layers"] + for layer_key, layer in before.items(): + for object_key, drawing in layer["objects"].items(): + new = after[layer_key]["objects"][object_key] + self.assertFalse(set(drawing) - set(new), drawing["name"]) + self.assertEqual(drawing.get("hiddenOnPlanner"), new.get("hiddenOnPlanner")) + + + def test_resources_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + # resources whose file is missing in the original mission can't be kept + expected = {k: v for k, v in orig["mapResource"].items() + if k and "l10n/DEFAULT/" + v in orig["files"]} + self.assertEqual(expected, {k: v for k, v in saved["mapResource"].items() if k in expected}) + self.assertFalse(set(orig["files"]) - set(saved["files"]), "files lost") + self.assertEqual(len(saved["files"]), len(set(saved["files"])), "duplicate zip entries") diff --git a/tests/test_serialize.py b/tests/test_serialize.py index 5d3477ef..a0b9fc2a 100644 --- a/tests/test_serialize.py +++ b/tests/test_serialize.py @@ -1,4 +1,5 @@ import unittest +from dcs.lua.parse import loads from dcs.lua.serialize import dumps class TestLuaSerialize(unittest.TestCase): @@ -23,3 +24,24 @@ def test_some_key_is_string_should_arrange_in_alphabetical_order(self): dumped = dumps(original, 'v') self.assertEqual('v={[1]=1,[10]=3,[3]=2,["x"]=4}', dumped) + + def test_round_trip_of_large_and_small_floats(self): + original = {"a": 1e20, "b": -1.5e300, "c": 2.5e-300, "d": 12345678901234567890} + + self.assertEqual(original, loads(dumps(original, 'v'))['v']) + + def test_infinity_round_trips_and_nan_is_rejected(self): + original = {"a": float("inf"), "b": float("-inf")} + + self.assertEqual(original, loads(dumps(original, 'v'))['v']) + with self.assertRaises(ValueError): + dumps({"a": float("nan")}, 'v') + + def test_special_characters_in_strings_and_keys(self): + original = {'quote"key': 'a"b', 'back\\slash': 'c\\d', 'cr\rlf\n': 'line1\r\nline2'} + + dumped = dumps(original, 'v') + + self.assertNotIn('\r', dumped) + self.assertEqual(original, loads(dumped)['v']) + diff --git a/tests/test_task.py b/tests/test_task.py new file mode 100644 index 00000000..75687070 --- /dev/null +++ b/tests/test_task.py @@ -0,0 +1,91 @@ +import inspect +import os +import tempfile +import unittest + +import dcs +import dcs.task as task + + +def _load_task(d): + return task._create_from_dict(dict({"auto": False, "enabled": True, "number": 1}, **d)) + + +class TaskLoadTests(unittest.TestCase): + + def test_all_tasks_load_back(self) -> None: + # tasks that need constructor arguments + instances = { + task.FACAttackGroup: task.FACAttackGroup(7, "target"), + task.RecoveryTanker: task.RecoveryTanker(5, 400.0, 3000.0, 2), + } + for _, cls in inspect.getmembers(task, inspect.isclass): + if not issubclass(cls, task.Task) or cls.__module__ != "dcs.task" \ + or cls in (task.Task, task.Option, task.WrappedAction, task.EmptyTaskAction, task.EngageTargets): + continue + t = instances.get(cls) or cls() + with self.subTest(cls.__name__): + loaded = _load_task(t.dict()) + self.assertIs(type(loaded), cls) + self.assertEqual(loaded.dict(), t.dict()) + + def test_unknown_tasks_are_preserved(self) -> None: + unknown = [ + {"id": "SomeFutureTask", "params": {"x": 1}}, + {"id": "WrappedAction", "params": {"action": {"id": "Option", "params": {"name": 999, "value": 3}}}}, + {"id": "WrappedAction", "params": {"action": {"id": "FutureCommand", "params": {"v": 2}}}}, + {"id": "EngageTargets", "key": "FutureKey", "params": {"targetTypes": {}}}, + ] + for d in unknown: + with self.subTest(d["id"]): + t = _load_task(d) + self.assertIsInstance(t, task.EmptyTaskAction) + self.assertEqual(t.dict()["id"], d["id"]) + self.assertEqual(t.dict()["params"], d["params"]) + + def test_mission_with_tasks_reloads(self) -> None: + m = dcs.Mission() + group = m.flight_group_inflight(m.country("USA"), "F", dcs.planes.FA_18C_hornet, + m.terrain.airports["Batumi"].position, 5000) + tasks = [task.OptJettisonEmptyTanks(), task.RecoveryTanker(5, 400.0, 3000.0, 2), + task.FACAttackGroup(7, "target"), task.NoTask()] + group.points[0].tasks.extend(tasks) + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "tasks.miz") + m.save(path) + m2 = dcs.Mission() + self.assertEqual(m2.load_file(path), []) + loaded = m2.country("USA").plane_group[0].points[0].tasks[-len(tasks):] + self.assertEqual([type(t) for t in loaded], [type(t) for t in tasks]) + + +class FormationTests(unittest.TestCase): + + @staticmethod + def _decode(value: int): + # formation values are encoded as formationIndex << 16 | zInverse << 8 | variantIndex + return value >> 16, (value >> 8) & 0xFF, value & 0xFF + + def test_values_decode_to_known_formations(self) -> None: + formations = {int(x) for x in task.OptFormation.FormationIndex} + for name, value in task.OptFormation.Values.__members__.items(): + if name == "RotaryWedge": + continue # DCS uses the plain value 8 for it + with self.subTest(name): + formation, z_inverse, variant = self._decode(int(value)) + self.assertIn(formation, formations) + self.assertIn(z_inverse, (0, 1)) + self.assertIn(variant, (0, 1, 2, 3)) + + def test_factories_match_their_value(self) -> None: + for name, factory in inspect.getmembers(task.OptFormation, predicate=inspect.isfunction): + if name.startswith("_") or inspect.signature(factory).parameters: + continue + params = factory().params["action"]["params"] + if params["value"] == task.OptFormation.Values.RotaryWedge: + continue + with self.subTest(name): + formation, z_inverse, variant = self._decode(int(params["value"])) + self.assertEqual(params["formationIndex"], formation) + self.assertEqual(params.get("variantIndex", 0), variant) + self.assertEqual(params.get("zInverse", 0), z_inverse) diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 00000000..580d172f --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,23 @@ +import unittest + +import dcs +from dcs.templates import ShipTemplate, VehicleTemplate + + +class TemplateTests(unittest.TestCase): + def test_all_templates(self) -> None: + templates = { + "sa10_site": lambda m, pos: VehicleTemplate.Russia.sa10_site(m, pos, 90), + "patriot_site": lambda m, pos: VehicleTemplate.USA.patriot_site(m, pos, 90), + "hawk_site": lambda m, pos: VehicleTemplate.USA.hawk_site(m, pos, 90), + "sa11_site": lambda m, pos: VehicleTemplate.sa11_site(m, m.country("Russia"), pos, 90), + "sa15_site": lambda m, pos: VehicleTemplate.sa15_site(m, m.country("Russia"), pos, 90), + "sa6_site": lambda m, pos: VehicleTemplate.sa6_site(m, m.country("Russia"), pos, 90), + "kuznetsov_taskgroup": lambda m, pos: ShipTemplate.kuznetsov_taskgroup(m, pos, 90), + } + for name, create in templates.items(): + with self.subTest(name): + m = dcs.Mission() + group = create(m, m.terrain.airports["Batumi"].position) + self.assertIsNotNone(group) + self.assertGreater(len(group.units), 1) diff --git a/tests/test_terrain.py b/tests/test_terrain.py index 9d4d646e..3999522f 100644 --- a/tests/test_terrain.py +++ b/tests/test_terrain.py @@ -70,9 +70,22 @@ def test_parking_mixed_used(self): self.assertEqual(len(used)+2, len(m.terrain.airports["Batumi"].parking_slots)) + def test_city_graph(self): + terrain = dcs.terrain.Caucasus() + node = terrain.city_graph.node('Adler') + # positions must be bound to the terrain to be usable for geometry + self.assertIsNotNone(node.position.random_point_within(50)) + self.assertAlmostEqual(node.position.latlng().lat, 43.4, places=1) + class NevadaTest(unittest.TestCase): + def test_city_graph(self): + terrain = dcs.terrain.Nevada() + self.assertEqual(len(terrain.city_graph.nodes), 4) + for node in terrain.city_graph.nodes: + self.assertIsNotNone(node.position.random_point_within(50)) + def test_parking_slots(self): m = dcs.mission.Mission(terrain=dcs.terrain.Nevada()) slots = m.terrain.airports["Nellis"].free_parking_slots(dcs.planes.A_10C) @@ -169,3 +182,14 @@ def test_airplane_parking_used(self): hslots = m.terrain.airports["Aleppo"].free_parking_slots(dcs.helicopters.UH_1H) self.assertEqual(len(hslots), 8) + +class AirportTests(unittest.TestCase): + def test_unit_zones_are_per_instance(self) -> None: + first = dcs.terrain.Caucasus() + zones = len(first.airports["Batumi"].unit_zones) + self.assertGreater(zones, 0) + + second = dcs.terrain.Caucasus() + self.assertEqual(len(first.airports["Batumi"].unit_zones), zones) + self.assertEqual(len(second.airports["Batumi"].unit_zones), zones) + self.assertTrue(all(z._terrain is second for z in second.airports["Batumi"].unit_zones)) diff --git a/tests/test_triggers.py b/tests/test_triggers.py index cdbfce70..6b7c21f6 100644 --- a/tests/test_triggers.py +++ b/tests/test_triggers.py @@ -1,7 +1,7 @@ import unittest from dcs.mission import Mission -from dcs.triggers import Rules +from dcs.triggers import Rules, TriggerOnce from dcs import lua @@ -5807,3 +5807,11 @@ def test_load_trig_func_startup(self): lua.dumps(action), lua.dumps(r.trig()['funcStartup'][key]), f"FuncStartup nr {key}") + + def test_load_trigger_color(self): + m = Mission() + d = {"eventlist": "", "comment": "colored", "predicate": "triggerOnce", "colorItem": "0x0080ffff", + "actions": {}, "rules": {}} + trigger = TriggerOnce.create_from_dict(m, d) + self.assertEqual(trigger.dict()["colorItem"], "0x0080ffff") + diff --git a/tests/test_unitgroup.py b/tests/test_unitgroup.py new file mode 100644 index 00000000..b07e98af --- /dev/null +++ b/tests/test_unitgroup.py @@ -0,0 +1,17 @@ +import unittest + +import dcs +from dcs.unitgroup import VehicleGroup + + +class VehicleGroupFormationTests(unittest.TestCase): + def test_all_formations_for_any_group_size(self) -> None: + for formation in VehicleGroup.Formation: + for size in range(1, 8): + with self.subTest(formation=formation.name, size=size): + m = dcs.Mission() + group = m.vehicle_group(m.country("USA"), "group", dcs.vehicles.Armor.M_1_Abrams, + m.terrain.airports["Batumi"].position, group_size=size) + group.formation(formation, heading=90) + positions = {(round(u.position.x, 3), round(u.position.y, 3)) for u in group.units} + self.assertEqual(len(positions), size) diff --git a/tests/test_weather.py b/tests/test_weather.py index 46ea2ad9..cc74df17 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -1,7 +1,7 @@ import unittest from dcs.terrain import Caucasus -from dcs.weather import CloudPreset, Weather +from dcs.weather import CloudPreset, Halo, Weather from dcs.cloud_presets import Clouds @@ -63,3 +63,34 @@ def test_cloud_presets(self) -> None: del weather_dict["clouds"]["preset"] weather.load_from_dict(weather_dict) self.assertIsNone(weather.clouds_preset) + + +class HaloTests(unittest.TestCase): + def test_auto_and_off_need_no_crystals(self) -> None: + self.assertEqual(Halo(Halo.Preset.Off)._make_halo_dict(), {"preset": "off"}) + self.assertEqual(Halo(Halo.Preset.Auto)._make_halo_dict(), {"preset": "auto"}) + with self.assertRaises(ValueError): + Halo(Halo.Preset.Off, Halo.Crystals.AllKinds)._make_halo_dict() + + def test_other_presets_need_crystals(self) -> None: + with self.assertRaises(ValueError): + Halo(Halo.Preset.Cirrus)._make_halo_dict() + self.assertEqual(Halo(Halo.Preset.Cirrus, Halo.Crystals.Tangents)._make_halo_dict(), + {"preset": "CirrusOnly", "crystalsPreset": "Tangents"}) + + def test_load(self) -> None: + self.assertEqual(Halo.load_from_dict({})._make_halo_dict(), {"preset": "auto"}) + self.assertEqual(Halo.load_from_dict({"preset": "off"})._make_halo_dict(), {"preset": "off"}) + loaded = Halo.load_from_dict({"preset": "CirrusOnly", "crystalsPreset": "Tangents"}) + self.assertIs(loaded.preset, Halo.Preset.Cirrus) + self.assertIs(loaded.crystals, Halo.Crystals.Tangents) + # values pydcs doesn't know are written back unchanged + future = {"preset": "FuturePreset", "crystalsPreset": "FutureCrystals"} + self.assertEqual(Halo.load_from_dict(future)._make_halo_dict(), future) + + def test_weather_without_halo_round_trips(self) -> None: + weather = Weather(Caucasus()) + d = weather.dict() + del d["halo"] + weather.load_from_dict(d) + self.assertEqual(weather.dict()["halo"], {"preset": "auto"})