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