diff --git a/changelog.md b/changelog.md
index 2e43f057..afe31ecf 100644
--- a/changelog.md
+++ b/changelog.md
@@ -24,6 +24,7 @@ Saves from 8.x are not compatible with 9.0.0.
* **[Mission Generation]** Restored previous AI behavior for anti-ship missions. A DCS update caused only a single aircraft in a flight to attack. The full flight will now attack like they used to.
* **[Mission Generation]** Fix generation of OCA Runway missions to allow LGBs to be used.
* **[Mission Generation]** Fixed AI flights flying far too slowly toward NAV points.
+* **[Modding]** Unit variants can now actually override base unit type properties.
* **[New Game Wizard]** Factions are reset to default after clicking "Back" to Theater Configuration screen.
* **[Plugins]** Fixed Lua errors in Skynet plugin that would occur whenever one coalition had no IADS nodes.
* **[UI]** Fixed deleting waypoints in custom flight plans deleting the wrong waypoint.
diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py
index 4ad92f98..ff8a8358 100644
--- a/game/dcs/aircrafttype.py
+++ b/game/dcs/aircrafttype.py
@@ -7,7 +7,6 @@ from functools import cache, cached_property
from pathlib import Path
from typing import Any, ClassVar, Dict, Iterator, Optional, TYPE_CHECKING, Type
-import yaml
from dcs.helicopters import helicopter_map
from dcs.planes import plane_map
from dcs.unitpropertydescription import UnitPropertyDescription
@@ -395,11 +394,11 @@ class AircraftType(UnitType[Type[FlyingType]]):
@staticmethod
def _set_props_overrides(
- config: Dict[str, Any], aircraft: Type[FlyingType], data_path: Path
+ config: Dict[str, Any], aircraft: Type[FlyingType]
) -> None:
if aircraft.property_defaults is None:
logging.warning(
- f"'{data_path.name}' attempted to set default prop that does not exist."
+ f"'{aircraft.id}' attempted to set default prop that does not exist."
)
else:
for k in config:
@@ -407,25 +406,23 @@ class AircraftType(UnitType[Type[FlyingType]]):
aircraft.property_defaults[k] = config[k]
else:
logging.warning(
- f"'{data_path.name}' attempted to set default prop '{k}' that does not exist"
+ f"'{aircraft.id}' attempted to set default prop '{k}' that does not exist"
)
@classmethod
- def _each_variant_of(cls, aircraft: Type[FlyingType]) -> Iterator[AircraftType]:
+ def _data_directory(cls) -> Path:
+ return Path("resources/units/aircraft")
+
+ @classmethod
+ def _variant_from_dict(
+ cls, aircraft: Type[FlyingType], variant_id: str, data: dict[str, Any]
+ ) -> AircraftType:
from game.ato.flighttype import FlightType
- data_path = Path("resources/units/aircraft") / f"{aircraft.id}.yaml"
- if not data_path.exists():
- logging.warning(f"No data for {aircraft.id}; it will not be available")
- return
-
- with data_path.open(encoding="utf-8") as data_file:
- data = yaml.safe_load(data_file)
-
try:
price = data["price"]
except KeyError as ex:
- raise KeyError(f"Missing required price field: {data_path}") from ex
+ raise KeyError(f"Missing required price field") from ex
radio_config = RadioConfig.from_data(data.get("radios", {}))
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
@@ -468,50 +465,49 @@ class AircraftType(UnitType[Type[FlyingType]]):
prop_overrides = data.get("default_overrides")
if prop_overrides is not None:
- cls._set_props_overrides(prop_overrides, aircraft, data_path)
+ cls._set_props_overrides(prop_overrides, aircraft)
task_priorities: dict[FlightType, int] = {}
for task_name, priority in data.get("tasks", {}).items():
task_priorities[FlightType(task_name)] = priority
- for variant in data.get("variants", [aircraft.id]):
- yield AircraftType(
- dcs_unit_type=aircraft,
- variant_id=variant,
- description=data.get(
- "description",
- f"No data. Google {variant}",
- ),
- year_introduced=introduction,
- country_of_origin=data.get("origin", "No data."),
- manufacturer=data.get("manufacturer", "No data."),
- role=data.get("role", "No data."),
- price=price,
- carrier_capable=data.get("carrier_capable", False),
- lha_capable=data.get("lha_capable", False),
- always_keeps_gun=data.get("always_keeps_gun", False),
- gunfighter=data.get("gunfighter", False),
- max_group_size=data.get("max_group_size", aircraft.group_size_max),
- patrol_altitude=patrol_config.altitude,
- patrol_speed=patrol_config.speed,
- max_mission_range=mission_range,
- fuel_consumption=fuel_consumption,
- default_livery=data.get("default_livery"),
- intra_flight_radio=radio_config.intra_flight,
- channel_allocator=radio_config.channel_allocator,
- channel_namer=radio_config.channel_namer,
- kneeboard_units=units,
- utc_kneeboard=data.get("utc_kneeboard", False),
- unit_class=unit_class,
- cabin_size=data.get("cabin_size", 10 if aircraft.helicopter else 0),
- can_carry_crates=data.get("can_carry_crates", aircraft.helicopter),
- task_priorities=task_priorities,
- has_built_in_target_pod=data.get("has_built_in_target_pod", False),
- laser_code_configs=[
- LaserCodeConfig.from_yaml(d) for d in data.get("laser_codes", [])
- ],
- use_f15e_waypoint_names=data.get("use_f15e_waypoint_names", False),
- )
+ return AircraftType(
+ dcs_unit_type=aircraft,
+ variant_id=variant_id,
+ description=data.get(
+ "description",
+ f"No data. Google {variant_id}",
+ ),
+ year_introduced=introduction,
+ country_of_origin=data.get("origin", "No data."),
+ manufacturer=data.get("manufacturer", "No data."),
+ role=data.get("role", "No data."),
+ price=price,
+ carrier_capable=data.get("carrier_capable", False),
+ lha_capable=data.get("lha_capable", False),
+ always_keeps_gun=data.get("always_keeps_gun", False),
+ gunfighter=data.get("gunfighter", False),
+ max_group_size=data.get("max_group_size", aircraft.group_size_max),
+ patrol_altitude=patrol_config.altitude,
+ patrol_speed=patrol_config.speed,
+ max_mission_range=mission_range,
+ fuel_consumption=fuel_consumption,
+ default_livery=data.get("default_livery"),
+ intra_flight_radio=radio_config.intra_flight,
+ channel_allocator=radio_config.channel_allocator,
+ channel_namer=radio_config.channel_namer,
+ kneeboard_units=units,
+ utc_kneeboard=data.get("utc_kneeboard", False),
+ unit_class=unit_class,
+ cabin_size=data.get("cabin_size", 10 if aircraft.helicopter else 0),
+ can_carry_crates=data.get("can_carry_crates", aircraft.helicopter),
+ task_priorities=task_priorities,
+ has_built_in_target_pod=data.get("has_built_in_target_pod", False),
+ laser_code_configs=[
+ LaserCodeConfig.from_yaml(d) for d in data.get("laser_codes", [])
+ ],
+ use_f15e_waypoint_names=data.get("use_f15e_waypoint_names", False),
+ )
def __hash__(self) -> int:
return hash(self.variant_id)
diff --git a/game/dcs/groundunittype.py b/game/dcs/groundunittype.py
index ba32dff3..3f48c00a 100644
--- a/game/dcs/groundunittype.py
+++ b/game/dcs/groundunittype.py
@@ -6,7 +6,6 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, ClassVar, Iterator, Optional, Type
-import yaml
from dcs.unittype import VehicleType
from dcs.vehicles import vehicle_map
@@ -99,15 +98,13 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
yield from vehicle_map.values()
@classmethod
- def _each_variant_of(cls, vehicle: Type[VehicleType]) -> Iterator[GroundUnitType]:
- data_path = Path("resources/units/ground_units") / f"{vehicle.id}.yaml"
- if not data_path.exists():
- logging.warning(f"No data for {vehicle.id}; it will not be available")
- return
-
- with data_path.open(encoding="utf-8") as data_file:
- data = yaml.safe_load(data_file)
+ def _data_directory(cls) -> Path:
+ return Path("resources/units/ground_units")
+ @classmethod
+ def _variant_from_dict(
+ cls, vehicle: Type[VehicleType], variant_id: str, data: dict[str, Any]
+ ) -> GroundUnitType:
try:
introduction = data["introduced"]
if introduction is None:
@@ -122,23 +119,22 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
else:
unit_class = UnitClass(class_name)
- for variant in data.get("variants", [vehicle.id]):
- yield GroundUnitType(
- dcs_unit_type=vehicle,
- unit_class=unit_class,
- spawn_weight=data.get("spawn_weight", 0),
- variant_id=variant,
- description=data.get(
- "description",
- f"No data. Google {variant}",
- ),
- year_introduced=introduction,
- country_of_origin=data.get("origin", "No data."),
- manufacturer=data.get("manufacturer", "No data."),
- role=data.get("role", "No data."),
- price=data.get("price", 1),
- skynet_properties=SkynetProperties.from_data(
- data.get("skynet_properties", {})
- ),
- reversed_heading=data.get("reversed_heading", False),
- )
+ return GroundUnitType(
+ dcs_unit_type=vehicle,
+ unit_class=unit_class,
+ spawn_weight=data.get("spawn_weight", 0),
+ variant_id=variant_id,
+ description=data.get(
+ "description",
+ f"No data. Google {variant_id}",
+ ),
+ year_introduced=introduction,
+ country_of_origin=data.get("origin", "No data."),
+ manufacturer=data.get("manufacturer", "No data."),
+ role=data.get("role", "No data."),
+ price=data.get("price", 1),
+ skynet_properties=SkynetProperties.from_data(
+ data.get("skynet_properties", {})
+ ),
+ reversed_heading=data.get("reversed_heading", False),
+ )
diff --git a/game/dcs/shipunittype.py b/game/dcs/shipunittype.py
index 4c739e9c..e6893e52 100644
--- a/game/dcs/shipunittype.py
+++ b/game/dcs/shipunittype.py
@@ -1,12 +1,10 @@
from __future__ import annotations
-import logging
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar, Iterator, Type, Any
-import yaml
from dcs.ships import ship_map
from dcs.unittype import ShipType
@@ -55,15 +53,13 @@ class ShipUnitType(UnitType[Type[ShipType]]):
yield from ship_map.values()
@classmethod
- def _each_variant_of(cls, ship: Type[ShipType]) -> Iterator[ShipUnitType]:
- data_path = Path("resources/units/ships") / f"{ship.id}.yaml"
- if not data_path.exists():
- logging.warning(f"No data for {ship.id}; it will not be available")
- return
-
- with data_path.open(encoding="utf-8") as data_file:
- data = yaml.safe_load(data_file)
+ def _data_directory(cls) -> Path:
+ return Path("resources/units/ships")
+ @classmethod
+ def _variant_from_dict(
+ cls, ship: Type[ShipType], variant_id: str, data: dict[str, Any]
+ ) -> ShipUnitType:
try:
introduction = data["introduced"]
if introduction is None:
@@ -74,18 +70,17 @@ class ShipUnitType(UnitType[Type[ShipType]]):
class_name = data.get("class")
unit_class = UnitClass(class_name)
- for variant in data.get("variants", [ship.id]):
- yield ShipUnitType(
- dcs_unit_type=ship,
- unit_class=unit_class,
- variant_id=variant,
- description=data.get(
- "description",
- f"No data. Google {variant}",
- ),
- year_introduced=introduction,
- country_of_origin=data.get("origin", "No data."),
- manufacturer=data.get("manufacturer", "No data."),
- role=data.get("role", "No data."),
- price=data.get("price"),
- )
+ return ShipUnitType(
+ dcs_unit_type=ship,
+ unit_class=unit_class,
+ variant_id=variant_id,
+ description=data.get(
+ "description",
+ f"No data. Google {variant_id}",
+ ),
+ year_introduced=introduction,
+ country_of_origin=data.get("origin", "No data."),
+ manufacturer=data.get("manufacturer", "No data."),
+ role=data.get("role", "No data."),
+ price=data["price"],
+ )
diff --git a/game/dcs/unittype.py b/game/dcs/unittype.py
index 5be44e48..54b4700b 100644
--- a/game/dcs/unittype.py
+++ b/game/dcs/unittype.py
@@ -1,10 +1,13 @@
from __future__ import annotations
+import logging
from abc import ABC
from dataclasses import dataclass
from functools import cached_property
-from typing import ClassVar, Generic, Iterator, Self, Type, TypeVar
+from pathlib import Path
+from typing import ClassVar, Generic, Iterator, Self, Type, TypeVar, Any
+import yaml
from dcs.unittype import UnitType as DcsUnitType
from game.data.units import UnitClass
@@ -49,8 +52,29 @@ class UnitType(ABC, Generic[DcsUnitTypeT]):
def each_dcs_type() -> Iterator[DcsUnitTypeT]:
raise NotImplementedError
+ @classmethod
+ def _data_directory(cls) -> Path:
+ raise NotImplementedError
+
@classmethod
def _each_variant_of(cls, unit: DcsUnitTypeT) -> Iterator[Self]:
+ data_path = cls._data_directory() / f"{unit.id}.yaml"
+ if not data_path.exists():
+ logging.warning(f"No data for {unit.id}; it will not be available")
+ return
+
+ with data_path.open(encoding="utf-8") as data_file:
+ data = yaml.safe_load(data_file)
+
+ for variant_id, variant_data in data.get("variants", {unit.id: {}}).items():
+ if variant_data is None:
+ variant_data = {}
+ yield cls._variant_from_dict(unit, variant_id, data | variant_data)
+
+ @classmethod
+ def _variant_from_dict(
+ cls, dcs_unit_type: DcsUnitTypeT, variant_id: str, data: dict[str, Any]
+ ) -> Self:
raise NotImplementedError
@classmethod