diff --git a/changelog.md b/changelog.md index 9bdb1e26..df3e696a 100644 --- a/changelog.md +++ b/changelog.md @@ -230,6 +230,7 @@ BAI/ANTISHIP/DEAD/STRIKE/BARCAP/CAS/OCA/AIR-ASSAULT (main) missions * **[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. * **[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. * **[UI]** Fixed flight properties UI to support F-15E S4+ laser codes. diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index c6dcc748..3ef80ac4 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -399,11 +399,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: @@ -411,25 +411,22 @@ 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]: - # Replace slashes with underscores because slashes are not allowed in filenames - aircraft_id = aircraft.id.replace("/", "_") - 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 + def _data_directory(cls) -> Path: + return Path("resources/units/aircraft") - with data_path.open(encoding="utf-8") as data_file: - data = yaml.safe_load(data_file) + @classmethod + def _variant_from_dict( + cls, aircraft: Type[FlyingType], variant_id: str, data: dict[str, Any] + ) -> AircraftType: 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", {})) @@ -441,7 +438,7 @@ class AircraftType(UnitType[Type[FlyingType]]): nautical_miles(50) if aircraft.helicopter else nautical_miles(150) ) logging.warning( - f"{aircraft_id} does not specify a max_range. Defaulting to " + f"{variant_id} does not specify a max_range. Defaulting to " f"{mission_range.nautical_miles}NM" ) @@ -472,7 +469,7 @@ 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) from game.ato.flighttype import FlightType @@ -480,50 +477,43 @@ class AircraftType(UnitType[Type[FlyingType]]): for task_name, priority in data.get("tasks", {}).items(): task_priorities[FlightType(task_name)] = priority - if FlightType.SEAD in task_priorities: - task_priorities[FlightType.SEAD_SWEEP] = task_priorities[FlightType.SEAD] - - cls._custom_weapon_injections(aircraft, data) - cls._user_weapon_injections(aircraft) - - 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), - has_built_in_target_pod=data.get("has_built_in_target_pod", False), - task_priorities=task_priorities, - 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), + ) @staticmethod def _custom_weapon_injections( diff --git a/game/dcs/groundunittype.py b/game/dcs/groundunittype.py index 502e0a0a..8387165e 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 @@ -97,15 +96,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: @@ -120,23 +117,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 59dddca6..bd81948f 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 @@ -53,15 +51,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: @@ -72,18 +68,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 e8da39ba..4e4224c0 100644 --- a/game/dcs/unittype.py +++ b/game/dcs/unittype.py @@ -1,12 +1,15 @@ 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, Type, TypeVar -from typing_extensions import Self +from pathlib import Path +from typing import ClassVar, Generic, Iterator, Type, TypeVar, Any +import yaml from dcs.unittype import UnitType as DcsUnitType +from typing_extensions import Self from game.data.units import UnitClass @@ -50,8 +53,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