diff --git a/changelog.md b/changelog.md index c10d7132..e0bccf6f 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,9 @@ ## Features/Improvements * **[Preset Groups]** Add SA-2 with ZSU-23/57 * **[Campaign Design]** Ability to define almost all possible settings in the campaign's yaml file. +* **[Campaign Design]** Ability to add roadbases and/or ground spawns to campaigns. +* **[Campaign Design]** Ability to define SCENERY REMOVE OBJECTS ZONE triggers with the roadbase objects in campaign miz. This might not work reliably in multiplayer due to DCS issues. FARPs can be used to remove scenery objects in multiplayer. +* **[Campaign Management]** Improved squadron retreat logic at longer ranges. * **[Options]** Ability to load & save your settings. * **[UI]** Added fuel selector in flight's edit window. * **[Plugins]** Expose Splash Damage's "game_messages" option and set its default to false. diff --git a/game/campaignloader/mizcampaignloader.py b/game/campaignloader/mizcampaignloader.py index ca660f64..dcc207fa 100644 --- a/game/campaignloader/mizcampaignloader.py +++ b/game/campaignloader/mizcampaignloader.py @@ -9,13 +9,14 @@ from uuid import UUID from dcs import Mission from dcs.countries import CombinedJointTaskForcesBlue, CombinedJointTaskForcesRed from dcs.country import Country -from dcs.planes import F_15C +from dcs.planes import F_15C, A_10A, AJS37 from dcs.ships import HandyWind, LHA_Tarawa, Stennis, USS_Arleigh_Burke_IIa from dcs.statics import Fortification, Warehouse from dcs.terrain import Airport from dcs.unitgroup import PlaneGroup, ShipGroup, StaticGroup, VehicleGroup from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed +from game.point_with_heading import PointWithHeading from game.positioned import Positioned from game.profiling import logged_duration from game.scenery_group import SceneryGroup @@ -28,6 +29,7 @@ from game.theater.controlpoint import ( OffMapSpawn, ) from game.theater.presetlocation import PresetLocation +from game.utils import Distance, meters, feet, Heading from game.utils import Distance, meters, feet if TYPE_CHECKING: @@ -40,6 +42,8 @@ class MizCampaignLoader: RED_COUNTRY = CombinedJointTaskForcesRed() OFF_MAP_UNIT_TYPE = F_15C.id + GROUND_SPAWN_UNIT_TYPE = A_10A.id + GROUND_SPAWN_ROADBASE_UNIT_TYPE = AJS37.id CV_UNIT_TYPE = Stennis.id LHA_UNIT_TYPE = LHA_Tarawa.id @@ -48,7 +52,7 @@ class MizCampaignLoader: CP_CONVOY_SPAWN_TYPE = Armor.M1043_HMMWV_Armament.id FOB_UNIT_TYPE = Unarmed.SKP_11.id - FARP_HELIPADS_TYPE = ["Invisible FARP", "SINGLE_HELIPAD"] + FARP_HELIPADS_TYPE = ["Invisible FARP", "SINGLE_HELIPAD", "FARP"] OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id @@ -96,6 +100,8 @@ class MizCampaignLoader: STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id + GROUND_SPAWN_WAYPOINT_DISTANCE = 1000 + def __init__(self, miz: Path, theater: ConflictTheater) -> None: self.theater = theater self.mission = Mission() @@ -224,6 +230,18 @@ class MizCampaignLoader: if group.units[0].type in self.FARP_HELIPADS_TYPE: yield group + @property + def ground_spawns_roadbase(self) -> Iterator[PlaneGroup]: + for group in itertools.chain(self.blue.plane_group, self.red.plane_group): + if group.units[0].type == self.GROUND_SPAWN_ROADBASE_UNIT_TYPE: + yield group + + @property + def ground_spawns(self) -> Iterator[PlaneGroup]: + for group in itertools.chain(self.blue.plane_group, self.red.plane_group): + if group.units[0].type == self.GROUND_SPAWN_UNIT_TYPE: + yield group + @property def factories(self) -> Iterator[StaticGroup]: for group in self.blue.static_group: @@ -362,6 +380,37 @@ class MizCampaignLoader: closest = spawn return closest + @staticmethod + def _add_helipad(helipads: list[PointWithHeading], static: StaticGroup) -> None: + helipads.append( + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) + ) + + def _add_ground_spawn( + self, + ground_spawns: list[tuple[PointWithHeading, Point]], + plane_group: PlaneGroup, + ) -> None: + if len(plane_group.points) >= 2: + first_waypoint = plane_group.points[1].position + else: + first_waypoint = plane_group.position.point_from_heading( + plane_group.units[0].heading, + self.GROUND_SPAWN_WAYPOINT_DISTANCE, + ) + + ground_spawns.append( + ( + PointWithHeading.from_point( + plane_group.position, + Heading.from_degrees(plane_group.units[0].heading), + ), + first_waypoint, + ) + ) + def add_supply_routes(self) -> None: for group in self.front_line_path_groups: # The unit will have its first waypoint at the source CP and the final @@ -475,7 +524,20 @@ class MizCampaignLoader: for static in self.helipads: closest, distance = self.objective_info(static) - closest.helipads.append(PresetLocation.from_group(static)) + if static.units[0].type == "SINGLE_HELIPAD": + self._add_helipad(closest.helipads, static) + elif static.units[0].type == "FARP": + self._add_helipad(closest.helipads_quad, static) + else: + self._add_helipad(closest.helipads_invisible, static) + + for plane_group in self.ground_spawns_roadbase: + closest, distance = self.objective_info(plane_group) + self._add_ground_spawn(closest.ground_spawns_roadbase, plane_group) + + for plane_group in self.ground_spawns: + closest, distance = self.objective_info(plane_group) + self._add_ground_spawn(closest.ground_spawns, plane_group) for static in self.factories: closest, distance = self.objective_info(static) diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index 77520e57..9ad4712f 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -13,6 +13,7 @@ from game.theater import ( FrontLine, MissionTarget, OffMapSpawn, + ParkingType, ) from game.theater.theatergroundobject import ( BuildingGroundObject, @@ -161,11 +162,21 @@ class ObjectiveFinder: break def oca_targets(self, min_aircraft: int) -> Iterator[ControlPoint]: + parking_type = ParkingType() + parking_type.include_rotary_wing = True + parking_type.include_fixed_wing = True + parking_type.include_fixed_wing_stol = True + airfields = [] for control_point in self.enemy_control_points(): - if not isinstance(control_point, Airfield): + if not isinstance(control_point, Airfield) and not isinstance( + control_point, Fob + ): continue - if control_point.allocated_aircraft().total_present >= min_aircraft: + if ( + control_point.allocated_aircraft(parking_type).total_present + >= min_aircraft + ): airfields.append(control_point) return self._targets_by_range(airfields) diff --git a/game/game.py b/game/game.py index f0db7ad5..102f037e 100644 --- a/game/game.py +++ b/game/game.py @@ -90,6 +90,8 @@ class TurnState(Enum): class Game: + scenery_clear_zones: List[Point] + def __init__( self, player_faction: Faction, diff --git a/game/migrator.py b/game/migrator.py index 20224c21..a550bf04 100644 --- a/game/migrator.py +++ b/game/migrator.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any from dcs.countries import countries_by_name from game.ato.packagewaypoints import PackageWaypoints from game.data.doctrine import MODERN_DOCTRINE, COLDWAR_DOCTRINE, WWII_DOCTRINE -from game.theater import SeasonalConditions +from game.theater import ParkingType, SeasonalConditions if TYPE_CHECKING: from game import Game @@ -110,9 +110,13 @@ class Migrator: s.country = countries_by_name[c]() # code below is used to fix corruptions wrt overpopulation - if s.owned_aircraft < 0 or s.location.unclaimed_parking() < 0: + parking_type = ParkingType().from_squadron(s) + if ( + s.owned_aircraft < 0 + or s.location.unclaimed_parking(parking_type) < 0 + ): s.owned_aircraft = max( - 0, s.location.unclaimed_parking() + s.owned_aircraft + 0, s.location.unclaimed_parking(parking_type) + s.owned_aircraft ) def _update_factions(self) -> None: diff --git a/game/missiongenerator/aircraft/aircraftgenerator.py b/game/missiongenerator/aircraft/aircraftgenerator.py index bee1cf8a..6458393f 100644 --- a/game/missiongenerator/aircraft/aircraftgenerator.py +++ b/game/missiongenerator/aircraft/aircraftgenerator.py @@ -3,8 +3,9 @@ from __future__ import annotations import logging from datetime import datetime from functools import cached_property -from typing import Any, Dict, List, TYPE_CHECKING +from typing import Any, Dict, List, TYPE_CHECKING, Tuple +from dcs import Point from dcs.action import AITaskPush from dcs.condition import FlagIsTrue, GroupDead, Or, FlagIsFalse from dcs.country import Country @@ -54,7 +55,9 @@ class AircraftGenerator: laser_code_registry: LaserCodeRegistry, unit_map: UnitMap, mission_data: MissionData, - helipads: dict[ControlPoint, StaticGroup], + helipads: dict[ControlPoint, list[StaticGroup]], + ground_spawns_roadbase: dict[ControlPoint, list[Tuple[StaticGroup, Point]]], + ground_spawns: dict[ControlPoint, list[Tuple[StaticGroup, Point]]], ) -> None: self.mission = mission self.settings = settings @@ -67,6 +70,8 @@ class AircraftGenerator: self.flights: List[FlightData] = [] self.mission_data = mission_data self.helipads = helipads + self.ground_spawns_roadbase = ground_spawns_roadbase + self.ground_spawns = ground_spawns self.ewrj_package_dict: Dict[int, List[FlyingGroup[Any]]] = {} self.ewrj = settings.plugins.get("ewrj") @@ -186,7 +191,13 @@ class AircraftGenerator: flight.state = Completed(flight, self.game.settings) group = FlightGroupSpawner( - flight, country, self.mission, self.helipads, self.mission_data + flight, + country, + self.mission, + self.helipads, + self.ground_spawns_roadbase, + self.ground_spawns, + self.mission_data, ).create_idle_aircraft() AircraftPainter(flight, group).apply_livery() self.unit_map.add_aircraft(group, flight) @@ -196,7 +207,13 @@ class AircraftGenerator: ) -> FlyingGroup[Any]: """Creates and configures the flight group in the mission.""" group = FlightGroupSpawner( - flight, country, self.mission, self.helipads, self.mission_data + flight, + country, + self.mission, + self.helipads, + self.ground_spawns_roadbase, + self.ground_spawns, + self.mission_data, ).create_flight_group() self.flights.append( FlightGroupConfigurator( @@ -216,10 +233,10 @@ class AircraftGenerator: wpt = group.waypoint("LANDING") if flight.is_helo and isinstance(flight.arrival, Fob) and wpt: - hpad = self.helipads[flight.arrival].units.pop(0) - wpt.helipad_id = hpad.id - wpt.link_unit = hpad.id - self.helipads[flight.arrival].units.append(hpad) + hpad = self.helipads[flight.arrival].pop(0) + wpt.helipad_id = hpad.units[0].id + wpt.link_unit = hpad.units[0].id + self.helipads[flight.arrival].append(hpad) if self.ewrj: self._track_ewrj_flight(flight, group) diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index 70b2c629..1a1f522f 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -29,6 +29,8 @@ from .flightdata import FlightData from .waypoints import WaypointGenerator from ...ato.flightplans.aewc import AewcFlightPlan from ...ato.flightplans.theaterrefueling import TheaterRefuelingFlightPlan +from ...ato.flightwaypointtype import FlightWaypointType +from ...theater import Fob if TYPE_CHECKING: from game import Game @@ -103,6 +105,19 @@ class FlightGroupConfigurator: self.mission_data, ).create_waypoints() + # Special handling for landing waypoints when: + # 1. It's an AI-only flight + # 2. Aircraft are not helicopters/VTOL + # 3. Landing waypoint does not point to an airfield + if ( + self.flight.client_count < 1 + and not self.flight.unit_type.helicopter + and not self.flight.unit_type.lha_capable + and isinstance(self.flight.squadron.location, Fob) + ): + # Need to set uncontrolled to false, otherwise the AI will skip the mission and just land + self.group.uncontrolled = False + return FlightData( package=self.flight.package, aircraft_type=self.flight.unit_type, diff --git a/game/missiongenerator/aircraft/flightgroupspawner.py b/game/missiongenerator/aircraft/flightgroupspawner.py index 3a182de5..fcd4dd84 100644 --- a/game/missiongenerator/aircraft/flightgroupspawner.py +++ b/game/missiongenerator/aircraft/flightgroupspawner.py @@ -1,10 +1,10 @@ import logging import random -from typing import Any, Union +from typing import Any, Union, Tuple, Optional from dcs import Mission from dcs.country import Country -from dcs.mapping import Vector2 +from dcs.mapping import Vector2, Point from dcs.mission import StartType as DcsStartType from dcs.planes import F_14A, Su_33 from dcs.point import PointAction @@ -47,13 +47,17 @@ class FlightGroupSpawner: flight: Flight, country: Country, mission: Mission, - helipads: dict[ControlPoint, StaticGroup], + helipads: dict[ControlPoint, list[StaticGroup]], + ground_spawns_roadbase: dict[ControlPoint, list[Tuple[StaticGroup, Point]]], + ground_spawns: dict[ControlPoint, list[Tuple[StaticGroup, Point]]], mission_data: MissionData, ) -> None: self.flight = flight self.country = country self.mission = mission self.helipads = helipads + self.ground_spawns_roadbase = ground_spawns_roadbase + self.ground_spawns = ground_spawns self.mission_data = mission_data def create_flight_group(self) -> FlyingGroup[Any]: @@ -88,11 +92,11 @@ class FlightGroupSpawner: return grp def create_idle_aircraft(self) -> FlyingGroup[Any]: - airport = self.flight.squadron.location.dcs_airport - assert airport is not None - group = self._generate_at_airport( + assert isinstance(self.flight.squadron.location, Airfield) + airfield = self.flight.squadron.location + group = self._generate_at_airfield( name=namegen.next_aircraft_name(self.country, self.flight), - airport=airport, + airfield=airfield, ) group.uncontrolled = True @@ -121,18 +125,47 @@ class FlightGroupSpawner: elif isinstance(cp, Fob): is_heli = self.flight.squadron.aircraft.helicopter is_vtol = not is_heli and self.flight.squadron.aircraft.lha_capable - if not is_heli and not is_vtol: + if not is_heli and not is_vtol and not cp.has_ground_spawns: raise RuntimeError( - f"Cannot spawn non-VTOL aircraft at {cp} because it is a FOB" + f"Cannot spawn fixed-wing aircraft at {cp} because of insufficient ground spawn slots." ) pilot_count = len(self.flight.roster.pilots) - if is_vtol and self.flight.roster.player_count != pilot_count: + if ( + not is_heli + and self.flight.roster.player_count != pilot_count + and not self.flight.coalition.game.settings.ground_start_ai_planes + ): raise RuntimeError( - f"VTOL aircraft at {cp} must be piloted by humans exclusively." + f"Fixed-wing aircraft at {cp} must be piloted by humans exclusively because" + f' the "AI fixed-wing aircraft can use roadbases / bases with only ground' + f' spawns" setting is currently disabled.' ) - return self._generate_at_cp_helipad(name, cp) + if cp.has_helipads and (is_heli or is_vtol): + pad_group = self._generate_at_cp_helipad(name, cp) + if pad_group is not None: + return pad_group + if cp.has_ground_spawns and (self.flight.client_count > 0 or is_heli): + pad_group = self._generate_at_cp_ground_spawn(name, cp) + if pad_group is not None: + return pad_group + return self._generate_over_departure(name, cp) elif isinstance(cp, Airfield): - return self._generate_at_airport(name, cp.airport) + is_heli = self.flight.squadron.aircraft.helicopter + if cp.has_helipads and is_heli: + pad_group = self._generate_at_cp_helipad(name, cp) + if pad_group is not None: + return pad_group + if ( + cp.has_ground_spawns + and len(self.ground_spawns[cp]) + + len(self.ground_spawns_roadbase[cp]) + >= self.flight.count + and (self.flight.client_count > 0 or is_heli) + ): + pad_group = self._generate_at_cp_ground_spawn(name, cp) + if pad_group is not None: + return pad_group + return self._generate_at_airfield(name, cp) else: raise NotImplementedError( f"Aircraft spawn behavior not implemented for {cp} ({cp.__class__})" @@ -185,7 +218,7 @@ class FlightGroupSpawner: group.points[0].alt_type = alt_type return group - def _generate_at_airport(self, name: str, airport: Airport) -> FlyingGroup[Any]: + def _generate_at_airfield(self, name: str, airfield: Airfield) -> FlyingGroup[Any]: # TODO: Delayed runway starts should be converted to air starts for multiplayer. # Runway starts do not work with late activated aircraft in multiplayer. Instead # of spawning on the runway the aircraft will spawn on the taxiway, potentially @@ -193,13 +226,14 @@ class FlightGroupSpawner: # starts or (less likely) downgrade to warm starts to avoid the issue when the # player is generating the mission for multiplayer (which would need a new # option). + self.flight.unit_type.dcs_unit_type.load_payloads() return self.mission.flight_group_from_airport( country=self.country, name=name, aircraft_type=self.flight.unit_type.dcs_unit_type, - airport=airport, + airport=airfield.airport, maintask=None, - start_type=self.dcs_start_type(), + start_type=self._start_type_at_airfield(airfield), group_size=self.flight.count, parking_slots=None, ) @@ -253,26 +287,84 @@ class FlightGroupSpawner: group_size=self.flight.count, ) - def _generate_at_cp_helipad(self, name: str, cp: ControlPoint) -> FlyingGroup[Any]: + def _generate_at_cp_helipad( + self, name: str, cp: ControlPoint + ) -> Optional[FlyingGroup[Any]]: try: - helipad = self.helipads[cp] - except IndexError: - raise NoParkingSlotError() + helipad = self.helipads[cp].pop() + except IndexError as ex: + logging.warning("Not enough helipads available at " + str(ex)) + if isinstance(cp, Airfield): + return self._generate_at_airfield(name, cp) + else: + return None + # raise RuntimeError(f"Not enough helipads available at {cp}") from ex group = self._generate_at_group(name, helipad) - group.points[0].type = "TakeOffGround" + # Note : A bit dirty, need better support in pydcs group.points[0].action = PointAction.FromGroundArea - - if self.start_type is StartType.WARM: - group.points[0].type = "TakeOffGroundHot" + group.points[0].type = "TakeOffGround" + group.units[0].heading = helipad.units[0].heading + if self.start_type is not StartType.COLD: group.points[0].action = PointAction.FromGroundAreaHot - hpad = helipad.units[0] - for i in range(self.flight.count): - pos = cp.helipads.pop(0) - group.units[i].position = pos - group.units[i].heading = hpad.heading - cp.helipads.append(pos) + group.points[0].type = "TakeOffGroundHot" + + for i in range(self.flight.count - 1): + try: + helipad = self.helipads[cp].pop() + terrain = cp.coalition.game.theater.terrain + group.units[1 + i].position = Point( + helipad.x, helipad.y, terrain=terrain + ) + group.units[1 + i].heading = helipad.units[0].heading + except IndexError as ex: + logging.warning("Not enough helipads available at " + str(ex)) + if isinstance(cp, Airfield): + return self._generate_at_airfield(name, cp) + else: + return None + return group + + def _generate_at_cp_ground_spawn( + self, name: str, cp: ControlPoint + ) -> Optional[FlyingGroup[Any]]: + try: + if len(self.ground_spawns_roadbase[cp]) > 0: + ground_spawn = self.ground_spawns_roadbase[cp].pop() + else: + ground_spawn = self.ground_spawns[cp].pop() + except IndexError as ex: + logging.warning("Not enough STOL slots available at " + str(ex)) + return None + # raise RuntimeError(f"Not enough STOL slots available at {cp}") from ex + + group = self._generate_at_group(name, ground_spawn[0]) + + # Note : A bit dirty, need better support in pydcs + group.points[0].action = PointAction.FromGroundArea + group.points[0].type = "TakeOffGround" + group.units[0].heading = ground_spawn[0].units[0].heading + + try: + cp.coalition.game.scenery_clear_zones + except AttributeError: + cp.coalition.game.scenery_clear_zones = [] + cp.coalition.game.scenery_clear_zones.append(ground_spawn[1]) + + for i in range(self.flight.count - 1): + try: + terrain = cp.coalition.game.theater.terrain + if len(self.ground_spawns_roadbase[cp]) > 0: + ground_spawn = self.ground_spawns_roadbase[cp].pop() + else: + ground_spawn = self.ground_spawns[cp].pop() + group.units[1 + i].position = Point( + ground_spawn[0].x, ground_spawn[0].y, terrain=terrain + ) + group.units[1 + i].heading = ground_spawn[0].units[0].heading + except IndexError as ex: + raise RuntimeError(f"Not enough STOL slots available at {cp}") from ex return group def dcs_start_type(self) -> DcsStartType: @@ -284,6 +376,12 @@ class FlightGroupSpawner: return DcsStartType.Warm raise ValueError(f"There is no pydcs StartType matching {self.start_type}") + def _start_type_at_airfield( + self, + airfield: Airfield, + ) -> DcsStartType: + return self.dcs_start_type() + def _start_type_at_group( self, at: Union[ShipGroup, StaticGroup], diff --git a/game/missiongenerator/drawingsgenerator.py b/game/missiongenerator/drawingsgenerator.py index 2ee2c926..1d8a9957 100644 --- a/game/missiongenerator/drawingsgenerator.py +++ b/game/missiongenerator/drawingsgenerator.py @@ -9,11 +9,12 @@ from game.missiongenerator.frontlineconflictdescription import ( ) # Misc config settings for objects drawn in ME mission file (and F10 map) +from game.theater import TRIGGER_RADIUS_CAPTURE + FRONTLINE_COLORS = Rgba(255, 0, 0, 255) WHITE = Rgba(255, 255, 255, 255) CP_RED = Rgba(255, 0, 0, 80) CP_BLUE = Rgba(0, 0, 255, 80) -CP_CIRCLE_RADIUS = 2500 BLUE_PATH_COLOR = Rgba(0, 0, 255, 100) RED_PATH_COLOR = Rgba(255, 0, 0, 100) ACTIVE_PATH_COLOR = Rgba(255, 80, 80, 100) @@ -40,7 +41,7 @@ class DrawingsGenerator: color = CP_RED shape = self.player_layer.add_circle( cp.position, - CP_CIRCLE_RADIUS, + TRIGGER_RADIUS_CAPTURE, line_thickness=2, color=WHITE, fill=color, diff --git a/game/missiongenerator/missiongenerator.py b/game/missiongenerator/missiongenerator.py index 80895b58..d23c590e 100644 --- a/game/missiongenerator/missiongenerator.py +++ b/game/missiongenerator/missiongenerator.py @@ -243,6 +243,8 @@ class MissionGenerator: self.unit_map, mission_data=self.mission_data, helipads=tgo_generator.helipads, + ground_spawns_roadbase=tgo_generator.ground_spawns_roadbase, + ground_spawns=tgo_generator.ground_spawns, ) aircraft_generator.clear_parking_slots() diff --git a/game/missiongenerator/tgogenerator.py b/game/missiongenerator/tgogenerator.py index 310ac46e..326a1ded 100644 --- a/game/missiongenerator/tgogenerator.py +++ b/game/missiongenerator/tgogenerator.py @@ -9,13 +9,16 @@ from __future__ import annotations import logging import random -from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Type +from collections import defaultdict +from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Type, Tuple import dcs.vehicles -from dcs import Mission, Point +from dcs import Mission, Point, unitgroup from dcs.action import DoScript, SceneryDestructionZone from dcs.condition import MapObjectIsDead +from dcs.countries import * from dcs.country import Country +from dcs.point import StaticPoint, PointAction from dcs.ships import ( CVN_71, CVN_72, @@ -37,16 +40,17 @@ from dcs.task import ( ) from dcs.translation import String from dcs.triggers import Event, TriggerOnce, TriggerStart, TriggerZone -from dcs.unit import Unit, InvisibleFARP, BaseFARP +from dcs.unit import Unit, InvisibleFARP, BaseFARP, SingleHeliPad, FARP from dcs.unitgroup import MovingGroup, ShipGroup, StaticGroup, VehicleGroup from dcs.unittype import ShipType, VehicleType -from dcs.vehicles import vehicle_map +from dcs.vehicles import vehicle_map, Unarmed from game.missiongenerator.groundforcepainter import ( NavalForcePainter, GroundForcePainter, ) from game.missiongenerator.missiondata import CarrierInfo, MissionData +from game.point_with_heading import PointWithHeading from game.radio.RadioFrequencyContainer import RadioFrequencyContainer from game.radio.radios import RadioFrequency, RadioRegistry from game.radio.tacan import TacanBand, TacanChannel, TacanRegistry, TacanUsage @@ -74,6 +78,161 @@ FARP_FRONTLINE_DISTANCE = 10000 AA_CP_MIN_DISTANCE = 40000 +def farp_truck_types_for_country( + country_id: int, +) -> Tuple[Type[VehicleType], Type[VehicleType]]: + soviet_tankers: List[Type[VehicleType]] = [ + Unarmed.ATMZ_5, + Unarmed.ATZ_10, + Unarmed.ATZ_5, + Unarmed.ATZ_60_Maz, + Unarmed.TZ_22_KrAZ, + ] + soviet_trucks: List[Type[VehicleType]] = [ + Unarmed.S_75_ZIL, + Unarmed.GAZ_3308, + Unarmed.GAZ_66, + Unarmed.KAMAZ_Truck, + Unarmed.KrAZ6322, + Unarmed.Ural_375, + Unarmed.Ural_375_PBU, + Unarmed.Ural_4320_31, + Unarmed.Ural_4320T, + Unarmed.ZIL_135, + ] + + axis_trucks: List[Type[VehicleType]] = [Unarmed.Blitz_36_6700A] + + us_tankers: List[Type[VehicleType]] = [Unarmed.M978_HEMTT_Tanker] + us_trucks: List[Type[VehicleType]] = [Unarmed.M_818] + uk_trucks: List[Type[VehicleType]] = [Unarmed.Bedford_MWD] + + if country_id in [ + Abkhazia.id, + Algeria.id, + Bahrain.id, + Belarus.id, + Belgium.id, + Bulgaria.id, + China.id, + Croatia.id, + Cuba.id, + Cyprus.id, + CzechRepublic.id, + Egypt.id, + Ethiopia.id, + Finland.id, + GDR.id, + Georgia.id, + Ghana.id, + Greece.id, + Hungary.id, + India.id, + Insurgents.id, + Iraq.id, + Jordan.id, + Kazakhstan.id, + Lebanon.id, + Libya.id, + Morocco.id, + Nigeria.id, + NorthKorea.id, + Poland.id, + Romania.id, + Russia.id, + Serbia.id, + Slovakia.id, + Slovenia.id, + SouthAfrica.id, + SouthOssetia.id, + Sudan.id, + Syria.id, + Tunisia.id, + USSR.id, + Ukraine.id, + Venezuela.id, + Vietnam.id, + Yemen.id, + Yugoslavia.id, + ]: + tanker_type = random.choice(soviet_tankers) + ammo_truck_type = random.choice(soviet_trucks) + elif country_id in [ItalianSocialRepublic.id, ThirdReich.id]: + tanker_type = random.choice(soviet_tankers) + ammo_truck_type = random.choice(axis_trucks) + elif country_id in [ + Argentina.id, + Australia.id, + Austria.id, + Bolivia.id, + Brazil.id, + Canada.id, + Chile.id, + Denmark.id, + Ecuador.id, + France.id, + Germany.id, + Honduras.id, + Indonesia.id, + Iran.id, + Israel.id, + Italy.id, + Japan.id, + Kuwait.id, + Malaysia.id, + Mexico.id, + Norway.id, + Oman.id, + Pakistan.id, + Peru.id, + Philippines.id, + Portugal.id, + Qatar.id, + SaudiArabia.id, + SouthKorea.id, + Spain.id, + Sweden.id, + Switzerland.id, + Thailand.id, + TheNetherlands.id, + Turkey.id, + USA.id, + USAFAggressors.id, + UnitedArabEmirates.id, + ]: + tanker_type = random.choice(us_tankers) + ammo_truck_type = random.choice(us_trucks) + elif country_id in [UK.id]: + tanker_type = random.choice(us_tankers) + ammo_truck_type = random.choice(uk_trucks) + elif country_id in [CombinedJointTaskForcesBlue.id]: + tanker_types = us_tankers + truck_types = us_trucks + uk_trucks + + tanker_type = random.choice(tanker_types) + ammo_truck_type = random.choice(truck_types) + elif country_id in [CombinedJointTaskForcesRed.id]: + tanker_types = us_tankers + truck_types = us_trucks + uk_trucks + + tanker_type = random.choice(tanker_types) + ammo_truck_type = random.choice(truck_types) + elif country_id in [UnitedNationsPeacekeepers.id]: + tanker_types = soviet_tankers + us_tankers + truck_types = soviet_trucks + us_trucks + uk_trucks + + tanker_type = random.choice(tanker_types) + ammo_truck_type = random.choice(truck_types) + else: + tanker_types = soviet_tankers + us_tankers + truck_types = soviet_trucks + us_trucks + uk_trucks + axis_trucks + + tanker_type = random.choice(tanker_types) + ammo_truck_type = random.choice(truck_types) + + return tanker_type, ammo_truck_type + + class GroundObjectGenerator: """generates the DCS groups and units from the TheaterGroundObject""" @@ -598,66 +757,321 @@ class HelipadGenerator: self.game = game self.radio_registry = radio_registry self.tacan_registry = tacan_registry - self.helipads: Optional[StaticGroup] = None + self.helipads: list[StaticGroup] = [] + + def create_helipad( + self, i: int, helipad: PointWithHeading, helipad_type: str + ) -> None: + # Note: Helipad are generated as neutral object in order not to interfere with + # capture triggers + pad: BaseFARP + neutral_country = self.m.country(self.game.neutral_country.name) + country = self.m.country( + self.game.coalition_for(self.cp.captured).faction.country.name + ) + + name = f"{self.cp.name} {helipad_type} {i}" + logging.info("Generating helipad static : " + name) + terrain = self.m.terrain + if helipad_type == "SINGLE_HELIPAD": + pad = SingleHeliPad( + unit_id=self.m.next_unit_id(), name=name, terrain=terrain + ) + number_of_pads = 1 + elif helipad_type == "FARP": + pad = FARP(unit_id=self.m.next_unit_id(), name=name, terrain=terrain) + number_of_pads = 4 + else: + pad = InvisibleFARP( + unit_id=self.m.next_unit_id(), name=name, terrain=terrain + ) + number_of_pads = 1 + pad.position = Point(helipad.x, helipad.y, terrain=terrain) + pad.heading = helipad.heading.degrees + + # Set FREQ + if isinstance(self.cp, RadioFrequencyContainer) and self.cp.frequency: + if isinstance(pad, BaseFARP): + pad.heliport_frequency = self.cp.frequency.mhz + + sg = unitgroup.StaticGroup(self.m.next_group_id(), name) + sg.add_unit(pad) + sp = StaticPoint(pad.position) + sg.add_point(sp) + neutral_country.add_static_group(sg) + + if number_of_pads > 1: + self.append_helipad(pad, name, helipad.heading.degrees, 60, 0, 0) + self.append_helipad(pad, name, helipad.heading.degrees + 180, 20, 0, 0) + self.append_helipad( + pad, name, helipad.heading.degrees + 90, 60, helipad.heading.degrees, 20 + ) + self.append_helipad( + pad, + name, + helipad.heading.degrees + 90, + 60, + helipad.heading.degrees + 180, + 60, + ) + else: + self.helipads.append(sg) + + # Generate a FARP Ammo and Fuel stack for each pad + self.m.static_group( + country=country, + name=(name + "_fuel"), + _type=Fortification.FARP_Fuel_Depot, + position=pad.position.point_from_heading(helipad.heading.degrees, 35), + heading=pad.heading + 180, + ) + self.m.static_group( + country=country, + name=(name + "_ammo"), + _type=Fortification.FARP_Ammo_Dump_Coating, + position=pad.position.point_from_heading( + helipad.heading.degrees, 35 + ).point_from_heading(helipad.heading.degrees + 90, 10), + heading=pad.heading + 90, + ) + self.m.static_group( + country=country, + name=(name + "_ws"), + _type=Fortification.Windsock, + position=helipad.point_from_heading(helipad.heading.degrees + 45, 35), + heading=pad.heading, + ) + + def append_helipad( + self, + pad: BaseFARP, + name: str, + heading_1: int, + distance_1: int, + heading_2: int, + distance_2: int, + ) -> None: + new_pad = InvisibleFARP(pad._terrain) + new_pad.position = pad.position.point_from_heading(heading_1, distance_1) + new_pad.position = new_pad.position.point_from_heading(heading_2, distance_2) + sg = unitgroup.StaticGroup(self.m.next_group_id(), name) + sg.add_unit(new_pad) + self.helipads.append(sg) def generate(self) -> None: - # This gets called for every control point, so we don't want to add an empty group (causes DCS mission editor to crash) - if len(self.cp.helipads) == 0: - return - # Note: Helipad are generated as neutral object in order not to interfer with - # capture triggers - country = self.m.country(self.cp.coalition.faction.country.name) - for i, helipad in enumerate(self.cp.helipads): - heading = helipad.heading.degrees - name_i = self.cp.name + "_helipad" + "_" + str(i) - if self.helipads is None: - self.helipads = self.m.farp( - self.m.country(self.game.neutral_country.name), - name_i, - helipad, - farp_type="InvisibleFARP", - ) - else: - # Create a new Helipad Unit - self.helipads.add_unit( - InvisibleFARP(self.m.terrain, self.m.next_unit_id(), name_i) - ) + self.create_helipad(i, helipad, "SINGLE_HELIPAD") + for i, helipad in enumerate(self.cp.helipads_quad): + self.create_helipad(i, helipad, "FARP") + for i, helipad in enumerate(self.cp.helipads_invisible): + self.create_helipad(i, helipad, "Invisible FARP") - # Set FREQ - if isinstance(self.cp, RadioFrequencyContainer) and self.cp.frequency: - for hp in self.helipads.units: - if isinstance(hp, BaseFARP): - hp.heliport_frequency = self.cp.frequency.mhz - pad = self.helipads.units[-1] - pad.position = helipad - pad.heading = heading - # Generate a FARP Ammo and Fuel stack for each pad - self.m.static_group( +class GroundSpawnRoadbaseGenerator: + """ + Generates Highway strip starting positions for given control point + """ + + def __init__( + self, + mission: Mission, + cp: ControlPoint, + game: Game, + radio_registry: RadioRegistry, + tacan_registry: TacanRegistry, + ): + self.m = mission + self.cp = cp + self.game = game + self.radio_registry = radio_registry + self.tacan_registry = tacan_registry + self.ground_spawns_roadbase: list[Tuple[StaticGroup, Point]] = [] + + def create_ground_spawn_roadbase( + self, i: int, ground_spawn: Tuple[PointWithHeading, Point] + ) -> None: + # Note: FARPs are generated as neutral object in order not to interfere with + # capture triggers + neutral_country = self.m.country(self.game.neutral_country.name) + country = self.m.country( + self.game.coalition_for(self.cp.captured).faction.country.name + ) + terrain = self.cp.coalition.game.theater.terrain + + name = f"{self.cp.name} roadbase spawn {i}" + logging.info("Generating Roadbase Spawn static : " + name) + + pad = InvisibleFARP(unit_id=self.m.next_unit_id(), name=name, terrain=terrain) + + pad.position = Point(ground_spawn[0].x, ground_spawn[0].y, terrain=terrain) + pad.heading = ground_spawn[0].heading.degrees + sg = unitgroup.StaticGroup(self.m.next_group_id(), name) + sg.add_unit(pad) + sp = StaticPoint(pad.position) + sg.add_point(sp) + neutral_country.add_static_group(sg) + + self.ground_spawns_roadbase.append((sg, ground_spawn[1])) + + # tanker_type: Type[VehicleType] + # ammo_truck_type: Type[VehicleType] + + tanker_type, ammo_truck_type = farp_truck_types_for_country(country.id) + + # Generate ammo truck/farp and fuel truck/stack for each pad + if self.game.settings.ground_start_trucks_roadbase: + self.m.vehicle_group( country=country, - name=(name_i + "_fuel"), - _type=Fortification.FARP_Fuel_Depot, - position=helipad.point_from_heading(heading, 35), - heading=heading, - ) - self.m.static_group( - country=country, - name=(name_i + "_ammo"), - _type=Fortification.FARP_Ammo_Dump_Coating, - position=helipad.point_from_heading(heading, 35).point_from_heading( - heading + 90, 10 + name=(name + "_fuel"), + _type=tanker_type, + position=pad.position.point_from_heading( + ground_spawn[0].heading.degrees + 90, 35 ), - heading=heading, + group_size=1, + heading=pad.heading + 315, + move_formation=PointAction.OffRoad, + ) + self.m.vehicle_group( + country=country, + name=(name + "_ammo"), + _type=ammo_truck_type, + position=pad.position.point_from_heading( + ground_spawn[0].heading.degrees + 90, 35 + ).point_from_heading(ground_spawn[0].heading.degrees + 180, 10), + group_size=1, + heading=pad.heading + 315, + move_formation=PointAction.OffRoad, + ) + else: + self.m.static_group( + country=country, + name=(name + "_fuel"), + _type=Fortification.FARP_Fuel_Depot, + position=pad.position.point_from_heading( + ground_spawn[0].heading.degrees + 90, 35 + ), + heading=pad.heading + 270, ) self.m.static_group( country=country, - name=(name_i + "_ws"), - _type=Fortification.Windsock, - position=helipad.point_from_heading(heading + 45, 35), - heading=heading, + name=(name + "_ammo"), + _type=Fortification.FARP_Ammo_Dump_Coating, + position=pad.position.point_from_heading( + ground_spawn[0].heading.degrees + 90, 35 + ).point_from_heading(ground_spawn[0].heading.degrees + 180, 10), + heading=pad.heading + 180, ) + def generate(self) -> None: + try: + for i, ground_spawn in enumerate(self.cp.ground_spawns_roadbase): + self.create_ground_spawn_roadbase(i, ground_spawn) + except AttributeError: + self.ground_spawns_roadbase = [] + + +class GroundSpawnGenerator: + """ + Generates STOL aircraft starting positions for given control point + """ + + def __init__( + self, + mission: Mission, + cp: ControlPoint, + game: Game, + radio_registry: RadioRegistry, + tacan_registry: TacanRegistry, + ): + self.m = mission + self.cp = cp + self.game = game + self.radio_registry = radio_registry + self.tacan_registry = tacan_registry + self.ground_spawns: list[Tuple[StaticGroup, Point]] = [] + + def create_ground_spawn( + self, i: int, vtol_pad: Tuple[PointWithHeading, Point] + ) -> None: + # Note: FARPs are generated as neutral object in order not to interfere with + # capture triggers + neutral_country = self.m.country(self.game.neutral_country.name) + country = self.m.country( + self.game.coalition_for(self.cp.captured).faction.country.name + ) + terrain = self.cp.coalition.game.theater.terrain + + name = f"{self.cp.name} ground spawn {i}" + logging.info("Generating Ground Spawn static : " + name) + + pad = InvisibleFARP(unit_id=self.m.next_unit_id(), name=name, terrain=terrain) + + pad.position = Point(vtol_pad[0].x, vtol_pad[0].y, terrain=terrain) + pad.heading = vtol_pad[0].heading.degrees + sg = unitgroup.StaticGroup(self.m.next_group_id(), name) + sg.add_unit(pad) + sp = StaticPoint(pad.position) + sg.add_point(sp) + neutral_country.add_static_group(sg) + + self.ground_spawns.append((sg, vtol_pad[1])) + + # tanker_type: Type[VehicleType] + # ammo_truck_type: Type[VehicleType] + + tanker_type, ammo_truck_type = farp_truck_types_for_country(country.id) + + # Generate a FARP Ammo and Fuel stack for each pad + if self.game.settings.ground_start_trucks: + self.m.vehicle_group( + country=country, + name=(name + "_fuel"), + _type=tanker_type, + position=pad.position.point_from_heading( + vtol_pad[0].heading.degrees - 175, 35 + ), + group_size=1, + heading=pad.heading + 45, + move_formation=PointAction.OffRoad, + ) + self.m.vehicle_group( + country=country, + name=(name + "_ammo"), + _type=ammo_truck_type, + position=pad.position.point_from_heading( + vtol_pad[0].heading.degrees - 185, 35 + ), + group_size=1, + heading=pad.heading + 45, + move_formation=PointAction.OffRoad, + ) + else: + self.m.static_group( + country=country, + name=(name + "_fuel"), + _type=Fortification.FARP_Fuel_Depot, + position=pad.position.point_from_heading( + vtol_pad[0].heading.degrees - 180, 45 + ), + heading=pad.heading, + ) + self.m.static_group( + country=country, + name=(name + "_ammo"), + _type=Fortification.FARP_Ammo_Dump_Coating, + position=pad.position.point_from_heading( + vtol_pad[0].heading.degrees - 180, 35 + ), + heading=pad.heading + 270, + ) + + def generate(self) -> None: + try: + for i, vtol_pad in enumerate(self.cp.ground_spawns): + self.create_ground_spawn(i, vtol_pad) + except AttributeError: + self.ground_spawns = [] + class TgoGenerator: """Creates DCS groups and statics for the theater during mission generation. @@ -684,7 +1098,13 @@ class TgoGenerator: self.unit_map = unit_map self.icls_alloc = iter(range(1, 21)) self.runways: Dict[str, RunwayData] = {} - self.helipads: dict[ControlPoint, StaticGroup] = {} + self.helipads: dict[ControlPoint, list[StaticGroup]] = defaultdict(list) + self.ground_spawns_roadbase: dict[ + ControlPoint, list[Tuple[StaticGroup, Point]] + ] = defaultdict(list) + self.ground_spawns: dict[ + ControlPoint, list[Tuple[StaticGroup, Point]] + ] = defaultdict(list) self.mission_data = mission_data def generate(self) -> None: @@ -696,8 +1116,25 @@ class TgoGenerator: self.m, cp, self.game, self.radio_registry, self.tacan_registry ) helipad_gen.generate() - if helipad_gen.helipads is not None: - self.helipads[cp] = helipad_gen.helipads + self.helipads[cp] = helipad_gen.helipads + + # Generate Highway Strip slots + ground_spawn_roadbase_gen = GroundSpawnRoadbaseGenerator( + self.m, cp, self.game, self.radio_registry, self.tacan_registry + ) + ground_spawn_roadbase_gen.generate() + self.ground_spawns_roadbase[ + cp + ] = ground_spawn_roadbase_gen.ground_spawns_roadbase + random.shuffle(self.ground_spawns_roadbase[cp]) + + # Generate STOL pads + ground_spawn_gen = GroundSpawnGenerator( + self.m, cp, self.game, self.radio_registry, self.tacan_registry + ) + ground_spawn_gen.generate() + self.ground_spawns[cp] = ground_spawn_gen.ground_spawns + random.shuffle(self.ground_spawns[cp]) for ground_object in cp.ground_objects: generator: GroundObjectGenerator diff --git a/game/missiongenerator/triggergenerator.py b/game/missiongenerator/triggergenerator.py index 7796bd34..a20ff1dd 100644 --- a/game/missiongenerator/triggergenerator.py +++ b/game/missiongenerator/triggergenerator.py @@ -1,14 +1,26 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import logging +from typing import TYPE_CHECKING, List -from dcs.action import ClearFlag, DoScript, MarkToAll, SetFlag +from dcs import Point +from dcs.action import ( + ClearFlag, + DoScript, + MarkToAll, + SetFlag, + RemoveSceneObjects, + RemoveSceneObjectsMask, + SceneryDestructionZone, + Smoke, +) from dcs.condition import ( AllOfCoalitionOutsideZone, FlagIsFalse, FlagIsTrue, PartOfCoalitionInZone, TimeAfter, + TimeSinceFlag, ) from dcs.mission import Mission from dcs.task import Option @@ -17,7 +29,7 @@ from dcs.triggers import Event, TriggerCondition, TriggerOnce from dcs.unit import Skill from game.theater import Airfield -from game.theater.controlpoint import Fob +from game.theater.controlpoint import Fob, TRIGGER_RADIUS_CAPTURE if TYPE_CHECKING: from game.game import Game @@ -37,6 +49,7 @@ TRIGGER_RADIUS_SMALL = 50000 TRIGGER_RADIUS_MEDIUM = 100000 TRIGGER_RADIUS_LARGE = 150000 TRIGGER_RADIUS_ALL_MAP = 3000000 +TRIGGER_RADIUS_CLEAR_SCENERY = 1000 class Silence(Option): @@ -133,6 +146,37 @@ class TriggerGenerator: v += 1 self.mission.triggerrules.triggers.append(mark_trigger) + def _generate_clear_statics_trigger(self, scenery_clear_zones: List[Point]) -> None: + for zone_center in scenery_clear_zones: + trigger_zone = self.mission.triggers.add_triggerzone( + zone_center, + radius=TRIGGER_RADIUS_CLEAR_SCENERY, + hidden=False, + name="CLEAR", + ) + clear_trigger = TriggerCondition(Event.NoEvent, "Clear Trigger") + clear_flag = self.get_capture_zone_flag() + clear_trigger.add_condition(TimeSinceFlag(clear_flag, 30)) + clear_trigger.add_action(ClearFlag(clear_flag)) + clear_trigger.add_action(SetFlag(clear_flag)) + clear_trigger.add_action( + RemoveSceneObjects( + objects_mask=RemoveSceneObjectsMask.OBJECTS_ONLY, + zone=trigger_zone.id, + ) + ) + clear_trigger.add_action( + SceneryDestructionZone(destruction_level=100, zone=trigger_zone.id) + ) + self.mission.triggerrules.triggers.append(clear_trigger) + + enable_clear_trigger = TriggerOnce(Event.NoEvent, "Enable Clear Trigger") + enable_clear_trigger.add_condition(TimeAfter(30)) + enable_clear_trigger.add_action(ClearFlag(clear_flag)) + enable_clear_trigger.add_action(SetFlag(clear_flag)) + # clear_trigger.add_action(MessageToAll(text=String("Enable clear trigger"),)) + self.mission.triggerrules.triggers.append(enable_clear_trigger) + def _generate_capture_triggers( self, player_coalition: str, enemy_coalition: str ) -> None: @@ -154,7 +198,10 @@ class TriggerGenerator: defend_coalition_int = 1 trigger_zone = self.mission.triggers.add_triggerzone( - cp.position, radius=3000, hidden=False, name="CAPTURE" + cp.position, + radius=TRIGGER_RADIUS_CAPTURE, + hidden=False, + name="CAPTURE", ) flag = self.get_capture_zone_flag() capture_trigger = TriggerCondition(Event.NoEvent, "Capture Trigger") @@ -203,6 +250,11 @@ class TriggerGenerator: self._set_allegiances(player_coalition, enemy_coalition) self._gen_markers() self._generate_capture_triggers(player_coalition, enemy_coalition) + try: + self._generate_clear_statics_trigger(self.game.scenery_clear_zones) + self.game.scenery_clear_zones.clear() + except AttributeError: + logging.info(f"Unable to create Clear Statics triggers") @classmethod def get_capture_zone_flag(cls) -> int: diff --git a/game/procurement.py b/game/procurement.py index 071256b6..ce7015f0 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -8,7 +8,7 @@ from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple from game.config import RUNWAY_REPAIR_COST from game.data.units import UnitClass from game.dcs.groundunittype import GroundUnitType -from game.theater import ControlPoint, MissionTarget +from game.theater import ControlPoint, MissionTarget, ParkingType if TYPE_CHECKING: from game import Game @@ -63,12 +63,16 @@ class ProcurementAi: if len(self.faction.aircrafts) == 0 or len(self.air_wing.squadrons) == 0: return 1 + parking_type = ParkingType( + fixed_wing=True, fixed_wing_stol=True, rotary_wing=True + ) + for cp in self.owned_points: cp_ground_units = cp.allocated_ground_units( self.game.coalition_for(self.is_player).transfers ) armor_investment += cp_ground_units.total_value - cp_aircraft = cp.allocated_aircraft() + cp_aircraft = cp.allocated_aircraft(parking_type) aircraft_investment += cp_aircraft.total_value air = self.game.settings.auto_procurement_balance / 100.0 @@ -232,11 +236,13 @@ class ProcurementAi: for squadron in self.air_wing.best_squadrons_for( request.near, request.task_capability, request.number, this_turn=False ): + parking_type = ParkingType().from_squadron(squadron) + if not squadron.can_provide_pilots(request.number): continue - if not squadron.has_aircraft_capacity_for(request.number): + if squadron.location.unclaimed_parking(parking_type) < request.number: continue - if squadron.location.unclaimed_parking() < request.number: + if not squadron.has_aircraft_capacity_for(request.number): continue if self.threat_zones.threatened(squadron.location.position): threatened.append(squadron) diff --git a/game/purchaseadapter.py b/game/purchaseadapter.py index a4a71123..aeeff043 100644 --- a/game/purchaseadapter.py +++ b/game/purchaseadapter.py @@ -7,7 +7,7 @@ from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.dcs.unittype import UnitType from game.squadrons import Squadron -from game.theater import ControlPoint +from game.theater import ControlPoint, ParkingType ItemType = TypeVar("ItemType") @@ -109,9 +109,11 @@ class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]): return item.owned_aircraft def can_buy(self, item: Squadron) -> bool: + parking_type = ParkingType().from_squadron(item) + unclaimed_parking = self.control_point.unclaimed_parking(parking_type) return ( super().can_buy(item) - and self.control_point.unclaimed_parking() > 0 + and unclaimed_parking > 0 and item.has_aircraft_capacity_for(1) ) diff --git a/game/settings/settings.py b/game/settings/settings.py index ecf71bbc..17405774 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -565,6 +565,30 @@ class Settings: 'Use this to allow spectators when disabling "Allow external views".' ), ) + ground_start_ai_planes: bool = boolean_option( + "AI fixed-wing aircraft can use roadbases / bases with only ground spawns", + MISSION_GENERATOR_PAGE, + GAMEPLAY_SECTION, + default=False, + detail=( + "If enabled, AI can use roadbases or airbases which only have ground spawns." + "AI will always air-start from these bases (due to DCS limitation)." + ), + ) + ground_start_trucks: bool = boolean_option( + "Spawn trucks at ground spawns in airbases instead of FARP statics", + MISSION_GENERATOR_PAGE, + GAMEPLAY_SECTION, + default=False, + detail=("Might have a negative performance impact."), + ) + ground_start_trucks_roadbase: bool = boolean_option( + "Spawn trucks at ground spawns in roadbases instead of FARP statics", + MISSION_GENERATOR_PAGE, + GAMEPLAY_SECTION, + default=False, + detail=("Might have a negative performance impact."), + ) # Performance perf_smoke_gen: bool = boolean_option( diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py index d627d677..db1e4395 100644 --- a/game/squadrons/squadron.py +++ b/game/squadrons/squadron.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: from game import Game from game.coalition import Coalition from game.dcs.aircrafttype import AircraftType - from game.theater import ControlPoint, MissionTarget + from game.theater import ControlPoint, MissionTarget, ParkingType from .operatingbases import OperatingBases from .squadrondef import SquadronDef @@ -172,7 +172,10 @@ class Squadron: raise ValueError("Squadrons can only be created with active pilots.") self._recruit_pilots(self.settings.squadron_pilot_limit) if squadrons_start_full: - self.owned_aircraft = min(self.max_size, self.location.unclaimed_parking()) + parking_type = ParkingType().from_squadron(self) + self.owned_aircraft = min( + self.max_size, self.location.unclaimed_parking(parking_type) + ) def end_turn(self) -> None: if self.destination is not None: @@ -326,9 +329,14 @@ class Squadron: self.destination = None def cancel_overflow_orders(self) -> None: + from game.theater import ParkingType + if self.pending_deliveries <= 0: return - overflow = -self.location.unclaimed_parking() + parking_type = ParkingType().from_aircraft( + self.aircraft, self.coalition.game.settings.ground_start_ai_planes + ) + overflow = -self.location.unclaimed_parking(parking_type) if overflow > 0: sell_count = min(overflow, self.pending_deliveries) logging.debug( @@ -356,6 +364,8 @@ class Squadron: return self.location if self.destination is None else self.destination def plan_relocation(self, destination: ControlPoint) -> None: + from game.theater import ParkingType + if destination == self.location: logging.warning( f"Attempted to plan relocation of {self} to current location " @@ -369,7 +379,8 @@ class Squadron: ) return - if self.expected_size_next_turn > destination.unclaimed_parking(): + parking_type = ParkingType().from_squadron(self) + if self.expected_size_next_turn > destination.unclaimed_parking(parking_type): raise RuntimeError(f"Not enough parking for {self} at {destination}.") if not destination.can_operate(self.aircraft): raise RuntimeError(f"{self} cannot operate at {destination}.") @@ -377,6 +388,8 @@ class Squadron: self.replan_ferry_flights() def cancel_relocation(self) -> None: + from game.theater import ParkingType + if self.destination is None: logging.warning( f"Attempted to cancel relocation of squadron with no transfer order. " @@ -384,7 +397,10 @@ class Squadron: ) return - if self.expected_size_next_turn >= self.location.unclaimed_parking(): + parking_type = ParkingType().from_squadron(self) + if self.expected_size_next_turn >= self.location.unclaimed_parking( + parking_type + ): raise RuntimeError(f"Not enough parking for {self} at {self.location}.") self.destination = None self.cancel_ferry_flights() @@ -399,6 +415,7 @@ class Squadron: for flight in list(package.flights): if flight.squadron == self and flight.flight_type is FlightType.FERRY: package.remove_flight(flight) + flight.return_pilots_and_aircraft() if not package.flights: self.coalition.ato.remove_package(package) diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 54321302..27a434f9 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -94,6 +94,7 @@ if TYPE_CHECKING: FREE_FRONTLINE_UNIT_SUPPLY: int = 15 AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION: int = 12 +TRIGGER_RADIUS_CAPTURE = 3000 class ControlPointType(Enum): @@ -315,6 +316,47 @@ class ControlPointStatus(IntEnum): StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point] +class ParkingType: + def __init__( + self, + fixed_wing: bool = False, + fixed_wing_stol: bool = False, + rotary_wing: bool = False, + ) -> None: + self.include_fixed_wing = fixed_wing + self.include_fixed_wing_stol = fixed_wing_stol + self.include_rotary_wing = rotary_wing + + def from_squadron(self, squadron: Squadron) -> ParkingType: + return self.from_aircraft( + squadron.aircraft, squadron.coalition.game.settings.ground_start_ai_planes + ) + + def from_aircraft( + self, aircraft: AircraftType, ground_start_ai_planes: bool + ) -> ParkingType: + if aircraft.helicopter or aircraft.lha_capable: + self.include_rotary_wing = True + self.include_fixed_wing = True + self.include_fixed_wing_stol = True + elif aircraft.flyable or ground_start_ai_planes: + self.include_rotary_wing = False + self.include_fixed_wing = True + self.include_fixed_wing_stol = True + else: + self.include_rotary_wing = False + self.include_fixed_wing = True + self.include_fixed_wing_stol = False + return self + + #: Fixed wing aircraft with no STOL or VTOL capability + include_fixed_wing: bool + #: Fixed wing aircraft with STOL capability + include_fixed_wing_stol: bool + #: Helicopters and VTOL aircraft + include_rotary_wing: bool + + class ControlPoint(MissionTarget, SidcDescribable, ABC): # Not sure what distance DCS uses, but assuming it's about 2NM since that's roughly # the distance of the circle on the map. @@ -340,6 +382,10 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): self.connected_objectives: List[TheaterGroundObject] = [] self.preset_locations = PresetLocations() self.helipads: List[PointWithHeading] = [] + self.helipads_quad: List[PointWithHeading] = [] + self.helipads_invisible: List[PointWithHeading] = [] + self.ground_spawns_roadbase: List[Tuple[PointWithHeading, Point]] = [] + self.ground_spawns: List[Tuple[PointWithHeading, Point]] = [] self._coalition: Optional[Coalition] = None self.captured_invert = False @@ -525,7 +571,17 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): """ Returns true if cp has helipads """ - return len(self.helipads) > 0 + return ( + len(self.helipads) + len(self.helipads_quad) + len(self.helipads_invisible) + > 0 + ) + + @property + def has_ground_spawns(self) -> bool: + """ + Returns true if cp can operate STOL aircraft + """ + return len(self.ground_spawns_roadbase) + len(self.ground_spawns) > 0 def can_recruit_ground_units(self, game: Game) -> bool: """Returns True if this control point is capable of recruiting ground units.""" @@ -590,9 +646,8 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): def can_deploy_ground_units(self) -> bool: ... - @property @abstractmethod - def total_aircraft_parking(self) -> int: + def total_aircraft_parking(self, parking_type: ParkingType) -> int: """ :return: The maximum number of aircraft that can be stored in this control point @@ -761,17 +816,22 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): self, squadron: Squadron ) -> Optional[ControlPoint]: closest = ObjectiveDistanceCache.get_closest_airfields(self) - max_retreat_distance = squadron.aircraft.max_mission_range + # Multiply the max mission range by two when evaluating retreats, + # since you only need to fly one way in that case + max_retreat_distance = squadron.aircraft.max_mission_range * 2 # Skip the first airbase because that's the airbase we're retreating # from. airfields = list(closest.operational_airfields_within(max_retreat_distance))[1:] not_preferred: Optional[ControlPoint] = None overfull: list[ControlPoint] = [] + + parking_type = ParkingType().from_squadron(squadron) + for airbase in airfields: if airbase.captured != self.captured: continue - if airbase.unclaimed_parking() < squadron.owned_aircraft: + if airbase.unclaimed_parking(parking_type) < squadron.owned_aircraft: if airbase.can_operate(squadron.aircraft): overfull.append(airbase) continue @@ -798,7 +858,7 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): loss_count = math.inf for airbase in overfull: overflow = -( - airbase.unclaimed_parking() + airbase.unclaimed_parking(parking_type) - squadron.owned_aircraft - squadron.pending_deliveries ) @@ -813,10 +873,13 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): squadron.refund_orders() self.capture_aircraft(game, squadron.aircraft, squadron.owned_aircraft) return + + parking_type = ParkingType().from_squadron(squadron) + logging.debug(f"{squadron} retreating to {destination} from {self}") squadron.relocate_to(destination) squadron.cancel_overflow_orders() - overflow = -destination.unclaimed_parking() + overflow = -destination.unclaimed_parking(parking_type) if overflow > 0: logging.debug( f"Not enough room for {squadron} at {destination}. Capturing " @@ -869,8 +932,11 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): def can_operate(self, aircraft: AircraftType) -> bool: ... - def unclaimed_parking(self) -> int: - return self.total_aircraft_parking - self.allocated_aircraft().total + def unclaimed_parking(self, parking_type: ParkingType) -> int: + return ( + self.total_aircraft_parking(parking_type) + - self.allocated_aircraft(parking_type).total + ) @abstractmethod def active_runway( @@ -932,17 +998,33 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): u.position.x = u.position.x + delta.x u.position.y = u.position.y + delta.y - def allocated_aircraft(self) -> AircraftAllocations: + def allocated_aircraft(self, parking_type: ParkingType) -> AircraftAllocations: present: dict[AircraftType, int] = defaultdict(int) on_order: dict[AircraftType, int] = defaultdict(int) transferring: dict[AircraftType, int] = defaultdict(int) for squadron in self.squadrons: + if not parking_type.include_rotary_wing and ( + squadron.aircraft.helicopter or squadron.aircraft.lha_capable + ): + continue + elif not parking_type.include_fixed_wing and ( + not squadron.aircraft.helicopter or squadron.aircraft.lha_capable + ): + continue present[squadron.aircraft] += squadron.owned_aircraft if squadron.destination is None: on_order[squadron.aircraft] += squadron.pending_deliveries else: transferring[squadron.aircraft] -= squadron.owned_aircraft for squadron in self.coalition.air_wing.iter_squadrons(): + if not parking_type.include_rotary_wing and ( + squadron.aircraft.helicopter or squadron.aircraft.lha_capable + ): + continue + elif not parking_type.include_fixed_wing and ( + not squadron.aircraft.helicopter or squadron.aircraft.lha_capable + ): + continue if squadron.destination == self: on_order[squadron.aircraft] += squadron.pending_deliveries transferring[squadron.aircraft] += squadron.owned_aircraft @@ -1093,11 +1175,14 @@ class Airfield(ControlPoint, CTLD): # Needs ground spawns just like helos do, but also need to be able to # limit takeoff weight to ~20500 lbs or it won't be able to take off. - # return false if aircraft is fixed wing and airport has no runways - if not aircraft.helicopter and not self.airport.runways: - return False - else: - return self.runway_is_operational() + parking_type = ParkingType().from_aircraft( + aircraft, self.coalition.game.settings.ground_start_ai_planes + ) + if parking_type.include_rotary_wing and self.has_helipads: + return True + if parking_type.include_fixed_wing_stol and self.has_ground_spawns: + return True + return self.runway_is_operational() def mission_types(self, for_player: bool) -> Iterator[FlightType]: from game.ato import FlightType @@ -1120,14 +1205,25 @@ class Airfield(ControlPoint, CTLD): yield FlightType.REFUELING - @property - def total_aircraft_parking(self) -> int: + def total_aircraft_parking(self, parking_type: ParkingType) -> int: """ Return total aircraft parking slots available Note : additional helipads shouldn't contribute to this score as it could allow airfield to buy more planes than what they are able to host """ - return len(self.airport.parking_slots) + parking_slots = 0 + if parking_type.include_rotary_wing: + parking_slots += ( + len(self.helipads) + + 4 * len(self.helipads_quad) + + len(self.helipads_invisible) + ) + if parking_type.include_fixed_wing_stol: + parking_slots += len(self.ground_spawns) + parking_slots += len(self.ground_spawns_roadbase) + if parking_type.include_fixed_wing: + parking_slots += len(self.airport.parking_slots) + return parking_slots @property def heading(self) -> Heading: @@ -1317,8 +1413,7 @@ class Carrier(NavalControlPoint): def can_operate(self, aircraft: AircraftType) -> bool: return aircraft.carrier_capable - @property - def total_aircraft_parking(self) -> int: + def total_aircraft_parking(self, parking_type: ParkingType) -> int: return 90 @property @@ -1348,8 +1443,7 @@ class Lha(NavalControlPoint): def can_operate(self, aircraft: AircraftType) -> bool: return aircraft.lha_capable - @property - def total_aircraft_parking(self) -> int: + def total_aircraft_parking(self, parking_type: ParkingType) -> int: return 20 @property @@ -1383,8 +1477,7 @@ class OffMapSpawn(ControlPoint): def mission_types(self, for_player: bool) -> Iterator[FlightType]: yield from [] - @property - def total_aircraft_parking(self) -> int: + def total_aircraft_parking(self, parking_type: ParkingType) -> int: return 1000 def can_operate(self, aircraft: AircraftType) -> bool: @@ -1444,7 +1537,7 @@ class Fob(ControlPoint, RadioFrequencyContainer, CTLD): return SymbolSet.LAND_INSTALLATIONS, LandInstallationEntity.MILITARY_BASE def runway_is_operational(self) -> bool: - return self.has_helipads + return self.has_helipads or self.has_ground_spawns def active_runway( self, @@ -1470,17 +1563,33 @@ class Fob(ControlPoint, RadioFrequencyContainer, CTLD): yield from super().mission_types(for_player) - @property - def total_aircraft_parking(self) -> int: - return len(self.helipads) + def total_aircraft_parking(self, parking_type: ParkingType) -> int: + parking_slots = 0 + if parking_type.include_rotary_wing: + parking_slots += ( + len(self.helipads) + + 4 * len(self.helipads_quad) + + len(self.helipads_invisible) + ) + + try: + if parking_type.include_fixed_wing_stol: + parking_slots += len(self.ground_spawns) + parking_slots += len(self.ground_spawns_roadbase) + except AttributeError: + self.ground_spawns_roadbase = [] + self.ground_spawns = [] + return parking_slots def can_operate(self, aircraft: AircraftType) -> bool: - # FOBs and FARPs are the same class, distinguished only by non-FARP FOBs having - # zero parking. - # https://github.com/dcs-liberation/dcs_liberation/issues/2378 - return ( - aircraft.helicopter or aircraft.lha_capable - ) and self.total_aircraft_parking > 0 + parking_type = ParkingType().from_aircraft( + aircraft, self.coalition.game.settings.ground_start_ai_planes + ) + if parking_type.include_rotary_wing and self.has_helipads: + return True + if parking_type.include_fixed_wing_stol and self.has_ground_spawns: + return True + return False @property def heading(self) -> Heading: diff --git a/game/transfers.py b/game/transfers.py index abec1381..571c64bc 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -48,7 +48,7 @@ from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.naming import namegen from game.procurement import AircraftProcurementRequest -from game.theater import ControlPoint, MissionTarget +from game.theater import ControlPoint, MissionTarget, ParkingType, Carrier, Airfield from game.theater.transitnetwork import ( TransitConnection, TransitNetwork, @@ -728,8 +728,17 @@ class PendingTransfers: self.order_airlift_assets_at(control_point) def desired_airlift_capacity(self, control_point: ControlPoint) -> int: + parking_type = ParkingType() + parking_type.include_rotary_wing = True + if isinstance(control_point, Airfield) or isinstance(control_point, Carrier): + parking_type.include_fixed_wing = True + parking_type.include_fixed_wing_stol = True + else: + parking_type.include_fixed_wing = False + parking_type.include_fixed_wing_stol = False + if control_point.has_factory: - is_major_hub = control_point.total_aircraft_parking > 0 + is_major_hub = control_point.total_aircraft_parking(parking_type) > 0 # Check if there is a CP which is only reachable via Airlift transit_network = self.network_for(control_point) for cp in self.game.theater.control_points_for(self.player): @@ -750,7 +759,8 @@ class PendingTransfers: if ( is_major_hub and cp.has_factory - and cp.total_aircraft_parking > control_point.total_aircraft_parking + and cp.total_aircraft_parking(parking_type) + > control_point.total_aircraft_parking(parking_type) ): is_major_hub = False @@ -769,7 +779,16 @@ class PendingTransfers: ) def order_airlift_assets_at(self, control_point: ControlPoint) -> None: - unclaimed_parking = control_point.unclaimed_parking() + parking_type = ParkingType() + parking_type.include_rotary_wing = True + if isinstance(control_point, Airfield) or isinstance(control_point, Carrier): + parking_type.include_fixed_wing = True + parking_type.include_fixed_wing_stol = True + else: + parking_type.include_fixed_wing = False + parking_type.include_fixed_wing_stol = False + + unclaimed_parking = control_point.unclaimed_parking(parking_type) # Buy a maximum of unclaimed_parking only to prevent that aircraft procurement # take place at another base gap = min( diff --git a/qt_ui/windows/SquadronDialog.py b/qt_ui/windows/SquadronDialog.py index eb610c5a..fbee8cc8 100644 --- a/qt_ui/windows/SquadronDialog.py +++ b/qt_ui/windows/SquadronDialog.py @@ -22,10 +22,11 @@ from dcs.unittype import FlyingType from game.ato.flightplans.custom import CustomFlightPlan from game.ato.flighttype import FlightType from game.ato.flightwaypointtype import FlightWaypointType +from game.dcs.aircrafttype import AircraftType from game.server import EventStream from game.sim import GameUpdateEvents from game.squadrons import Pilot, Squadron -from game.theater import ConflictTheater, ControlPoint +from game.theater import ConflictTheater, ControlPoint, ParkingType from qt_ui.delegates import TwoColumnRowDelegate from qt_ui.errorreporter import report_errors from qt_ui.models import AtoModel, SquadronModel @@ -105,7 +106,8 @@ class SquadronDestinationComboBox(QComboBox): self.squadron = squadron self.theater = theater - room = squadron.location.unclaimed_parking() + parking_type = ParkingType().from_squadron(squadron) + room = squadron.location.unclaimed_parking(parking_type) self.addItem( f"Remain at {squadron.location} (room for {room} more aircraft)", squadron.location, @@ -142,11 +144,20 @@ class SquadronDestinationComboBox(QComboBox): self.setCurrentIndex(selected_index) def iter_destinations(self) -> Iterator[ControlPoint]: + size = self.squadron.expected_size_next_turn + parking_type = ParkingType().from_squadron(self.squadron) for control_point in self.theater.control_points_for(self.squadron.player): if control_point == self.squadron.location: continue if not control_point.can_operate(self.squadron.aircraft): continue + ac_type = self.squadron.aircraft.dcs_unit_type + if ( + self.squadron.destination is not control_point + and control_point.unclaimed_parking(parking_type) < size + and self.calculate_parking_slots(control_point, ac_type) < size + ): + continue yield control_point @staticmethod @@ -156,14 +167,39 @@ class SquadronDestinationComboBox(QComboBox): if cp.dcs_airport: ap = deepcopy(cp.dcs_airport) overflow = [] + + parking_type = ParkingType( + fixed_wing=False, fixed_wing_stol=False, rotary_wing=True + ) + free_helicopter_slots = cp.total_aircraft_parking(parking_type) + + parking_type = ParkingType( + fixed_wing=False, fixed_wing_stol=True, rotary_wing=False + ) + free_ground_spawns = cp.total_aircraft_parking(parking_type) + for s in cp.squadrons: for count in range(s.owned_aircraft): - slot = ap.free_parking_slot(s.aircraft.dcs_unit_type) - if slot: - slot.unit_id = id(s) + count + is_heli = s.aircraft.helicopter + is_vtol = not is_heli and s.aircraft.lha_capable + count_ground_spawns = ( + s.aircraft.flyable + or cp.coalition.game.settings.ground_start_ai_planes + ) + + if free_helicopter_slots > 0 and (is_heli or is_vtol): + free_helicopter_slots = -1 + elif free_ground_spawns > 0 and ( + is_heli or is_vtol or count_ground_spawns + ): + free_ground_spawns = -1 else: - overflow.append(s) - break + slot = ap.free_parking_slot(s.aircraft.dcs_unit_type) + if slot: + slot.unit_id = id(s) + count + else: + overflow.append(s) + break if overflow: overflow_msg = "" for s in overflow: @@ -178,7 +214,11 @@ class SquadronDestinationComboBox(QComboBox): ) return len(ap.free_parking_slots(dcs_unit_type)) else: - return cp.unclaimed_parking() + parking_type = ParkingType().from_aircraft( + next(AircraftType.for_dcs_type(dcs_unit_type)), + cp.coalition.game.settings.ground_start_ai_planes, + ) + return cp.unclaimed_parking(parking_type) class SquadronDialog(QDialog): diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index 73af5c9b..5938c7c1 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -25,6 +25,7 @@ from game.theater import ( ControlPointType, FREE_FRONTLINE_UNIT_SUPPLY, NavalControlPoint, + ParkingType, ) from qt_ui.dialogs import Dialog from qt_ui.models import GameModel @@ -237,8 +238,26 @@ class QBaseMenu2(QDialog): self.repair_button.setDisabled(True) def update_intel_summary(self) -> None: - aircraft = self.cp.allocated_aircraft().total_present - parking = self.cp.total_aircraft_parking + parking_type_all = ParkingType( + fixed_wing=True, fixed_wing_stol=True, rotary_wing=True + ) + + aircraft = self.cp.allocated_aircraft(parking_type_all).total_present + parking = self.cp.total_aircraft_parking(parking_type_all) + + parking_type_fixed_wing = ParkingType( + fixed_wing=True, fixed_wing_stol=False, rotary_wing=False + ) + parking_type_stol = ParkingType( + fixed_wing=False, fixed_wing_stol=True, rotary_wing=False + ) + parking_type_rotary_wing = ParkingType( + fixed_wing=False, fixed_wing_stol=False, rotary_wing=True + ) + + fixed_wing_parking = self.cp.total_aircraft_parking(parking_type_fixed_wing) + ground_spawn_parking = self.cp.total_aircraft_parking(parking_type_stol) + rotary_wing_parking = self.cp.total_aircraft_parking(parking_type_rotary_wing) ground_unit_limit = self.cp.frontline_unit_count_limit deployable_unit_info = "" @@ -257,6 +276,9 @@ class QBaseMenu2(QDialog): "\n".join( [ f"{aircraft}/{parking} aircraft", + f"{fixed_wing_parking} fixed wing parking", + f"{ground_spawn_parking} ground spawns", + f"{rotary_wing_parking} rotary wing parking", f"{self.cp.base.total_armor} ground units" + deployable_unit_info, f"{allocated.total_transferring} more ground units en route, {allocated.total_ordered} ordered", str(self.cp.runway_status), diff --git a/qt_ui/windows/basemenu/QBaseMenuTabs.py b/qt_ui/windows/basemenu/QBaseMenuTabs.py index 3ec0e403..ad6e17ec 100644 --- a/qt_ui/windows/basemenu/QBaseMenuTabs.py +++ b/qt_ui/windows/basemenu/QBaseMenuTabs.py @@ -23,7 +23,10 @@ class QBaseMenuTabs(QTabWidget): if isinstance(cp, Fob): self.ground_forces_hq = QGroundForcesHQ(cp, game_model) self.addTab(self.ground_forces_hq, "Ground Forces HQ") - if cp.helipads: + if cp.has_ground_spawns: + self.airfield_command = QAirfieldCommand(cp, game_model) + self.addTab(self.airfield_command, "Airfield Command") + elif cp.has_helipads: self.airfield_command = QAirfieldCommand(cp, game_model) self.addTab(self.airfield_command, "Heliport") else: diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index dc4ce0d5..14b2b633 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -13,7 +13,7 @@ from PySide2.QtWidgets import ( from game.dcs.aircrafttype import AircraftType from game.purchaseadapter import AircraftPurchaseAdapter from game.squadrons import Squadron -from game.theater import ControlPoint +from game.theater import ControlPoint, ParkingType from qt_ui.models import GameModel from qt_ui.uiconstants import ICONS from qt_ui.windows.basemenu.UnitTransactionFrame import UnitTransactionFrame @@ -91,8 +91,12 @@ class QHangarStatus(QHBoxLayout): self.setAlignment(Qt.AlignLeft) def update_label(self) -> None: - next_turn = self.control_point.allocated_aircraft() - max_amount = self.control_point.total_aircraft_parking + parking_type = ParkingType( + fixed_wing=True, fixed_wing_stol=True, rotary_wing=True + ) + + next_turn = self.control_point.allocated_aircraft(parking_type) + max_amount = self.control_point.total_aircraft_parking(parking_type) components = [f"{next_turn.total_present} present"] if next_turn.total_ordered > 0: diff --git a/qt_ui/windows/basemenu/intel/QIntelInfo.py b/qt_ui/windows/basemenu/intel/QIntelInfo.py index 91b13efb..795fd73d 100644 --- a/qt_ui/windows/basemenu/intel/QIntelInfo.py +++ b/qt_ui/windows/basemenu/intel/QIntelInfo.py @@ -11,7 +11,7 @@ from PySide2.QtWidgets import ( QWidget, ) -from game.theater import ControlPoint +from game.theater import ControlPoint, ParkingType class QIntelInfo(QFrame): @@ -24,7 +24,12 @@ class QIntelInfo(QFrame): intel_layout = QVBoxLayout() units_by_task: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) - for unit_type, count in self.cp.allocated_aircraft().present.items(): + parking_type = ParkingType( + fixed_wing=True, fixed_wing_stol=True, rotary_wing=True + ) + for unit_type, count in self.cp.allocated_aircraft( + parking_type + ).present.items(): if count: task_type = unit_type.dcs_unit_type.task_default.name units_by_task[task_type][unit_type.name] += count diff --git a/qt_ui/windows/intel.py b/qt_ui/windows/intel.py index 3f412644..cb8e3b27 100644 --- a/qt_ui/windows/intel.py +++ b/qt_ui/windows/intel.py @@ -17,6 +17,7 @@ from PySide2.QtWidgets import ( ) from game.game import Game +from game.theater import ParkingType from qt_ui.uiconstants import ICONS from qt_ui.windows.finances.QFinancesMenu import FinancesLayout @@ -77,7 +78,10 @@ class AircraftIntelLayout(IntelTableLayout): total = 0 for control_point in game.theater.control_points_for(player): - allocation = control_point.allocated_aircraft() + parking_type = ParkingType( + fixed_wing=True, fixed_wing_stol=True, rotary_wing=True + ) + allocation = control_point.allocated_aircraft(parking_type) base_total = allocation.total_present total += base_total if not base_total: diff --git a/tests/theater/test_controlpoint.py b/tests/theater/test_controlpoint.py index 84eca6a7..d3c490dc 100644 --- a/tests/theater/test_controlpoint.py +++ b/tests/theater/test_controlpoint.py @@ -1,9 +1,24 @@ import pytest from typing import Any +from dcs import Point +from dcs.planes import AJS37 from dcs.terrain.terrain import Airport from game.ato.flighttype import FlightType -from game.theater.controlpoint import Airfield, Carrier, Lha, OffMapSpawn, Fob +from game.dcs.aircrafttype import AircraftType +from game.dcs.countries import country_with_name +from game.point_with_heading import PointWithHeading +from game.squadrons import Squadron +from game.squadrons.operatingbases import OperatingBases +from game.theater.controlpoint import ( + Airfield, + Carrier, + Lha, + OffMapSpawn, + Fob, + ParkingType, +) +from game.utils import Heading @pytest.fixture @@ -117,3 +132,89 @@ def test_mission_types_enemy(mocker: Any) -> None: off_map_spawn = OffMapSpawn(name="test", position=None, theater=None, starts_blue=True) # type: ignore mission_types = list(off_map_spawn.mission_types(for_player=True)) assert len(mission_types) == 0 + + +@pytest.fixture +def test_control_point_parking(mocker: Any) -> None: + """ + Test correct number of parking slots are returned for control point + """ + # Airfield + mocker.patch("game.theater.controlpoint.unclaimed_parking", return_value=10) + airport = Airport(None, None) # type: ignore + airport.name = "test" # required for Airfield.__init__ + point = Point(0, 0, None) # type: ignore + control_point = Airfield(airport, theater=None, starts_blue=True) # type: ignore + parking_type_ground_start = ParkingType( + fixed_wing=False, fixed_wing_stol=True, rotary_wing=False + ) + parking_type_rotary = ParkingType( + fixed_wing=False, fixed_wing_stol=False, rotary_wing=True + ) + for x in range(10): + control_point.ground_spawns.append( + ( + PointWithHeading.from_point( + point, + Heading.from_degrees(0), + ), + point, + ) + ) + for x in range(20): + control_point.helipads.append( + PointWithHeading.from_point( + point, + Heading.from_degrees(0), + ) + ) + + assert control_point.unclaimed_parking(parking_type_ground_start) == 10 + assert control_point.unclaimed_parking(parking_type_rotary) == 20 + + +@pytest.fixture +def test_parking_type_from_squadron(mocker: Any) -> None: + """ + Test correct ParkingType object returned for a squadron of Viggens + """ + mocker.patch( + "game.theater.controlpoint.parking_type.include_fixed_wing_stol", + return_value=True, + ) + aircraft = next(AircraftType.for_dcs_type(AJS37)) + squadron = Squadron( + name="test", + nickname=None, + country=country_with_name("Sweden"), + role="test", + aircraft=aircraft, + max_size=16, + livery=None, + primary_task=FlightType.STRIKE, + auto_assignable_mission_types=set(aircraft.iter_task_capabilities()), + operating_bases=OperatingBases.default_for_aircraft(aircraft), + female_pilot_percentage=0, + ) # type: ignore + parking_type = ParkingType().from_squadron(squadron) + + assert parking_type.include_rotary_wing == False + assert parking_type.include_fixed_wing == True + assert parking_type.include_fixed_wing_stol == True + + +@pytest.fixture +def test_parking_type_from_aircraft(mocker: Any) -> None: + """ + Test correct ParkingType object returned for Viggen aircraft type + """ + mocker.patch( + "game.theater.controlpoint.parking_type.include_fixed_wing_stol", + return_value=True, + ) + aircraft = next(AircraftType.for_dcs_type(AJS37)) + parking_type = ParkingType().from_aircraft(aircraft, False) + + assert parking_type.include_rotary_wing == False + assert parking_type.include_fixed_wing == True + assert parking_type.include_fixed_wing_stol == True