diff --git a/changelog.md b/changelog.md index 6530da79..a1167905 100644 --- a/changelog.md +++ b/changelog.md @@ -7,7 +7,7 @@ Saves from 4.x are not compatible with 5.0. * **[Campaign]** Weather! Theaters now experience weather that is more realistic for the region and its current season. For example, Persian Gulf will have very hot, sunny summers and Marianas will experience lots of rain during fall. These changes affect pressure, temperature, clouds and precipitation. Additionally, temperature will drop during the night, by an amount that is somewhat realistic for the region. * **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. * **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts. -* **[Campaign]** (WIP) Squadrons now have a home base and will not operate out of other bases. See https://github.com/dcs-liberation/dcs_liberation/issues/1145 for status. +* **[Campaign]** Squadrons now have a home base and will not operate out of other bases. See https://github.com/dcs-liberation/dcs_liberation/discussions/1550 for details. * **[Campaign]** Aircraft now belong to squadrons rather than bases to support squadron location transfers. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py index 4cf43cbf..b9b242af 100644 --- a/game/squadrons/squadron.py +++ b/game/squadrons/squadron.py @@ -11,17 +11,19 @@ from typing import ( from faker import Faker -from game.dcs.aircrafttype import AircraftType from game.settings import AutoAtoBehavior, Settings -from game.squadrons.operatingbases import OperatingBases -from game.squadrons.pilot import Pilot, PilotStatus -from game.squadrons.squadrondef import SquadronDef +from gen.ato import Package +from gen.flights.flight import FlightType, Flight +from gen.flights.flightplan import FlightPlanBuilder +from .pilot import Pilot, PilotStatus if TYPE_CHECKING: from game import Game from game.coalition import Coalition - from gen.flights.flight import FlightType - from game.theater import ControlPoint + from game.dcs.aircrafttype import AircraftType + from game.theater import ControlPoint, ConflictTheater + from .operatingbases import OperatingBases + from .squadrondef import SquadronDef @dataclass @@ -312,7 +314,9 @@ class Squadron: def arrival(self) -> ControlPoint: return self.location if self.destination is None else self.destination - def plan_relocation(self, destination: ControlPoint) -> None: + def plan_relocation( + self, destination: ControlPoint, theater: ConflictTheater + ) -> None: if destination == self.location: logging.warning( f"Attempted to plan relocation of {self} to current location " @@ -331,6 +335,7 @@ class Squadron: if not destination.can_operate(self.aircraft): raise RuntimeError(f"{self} cannot operate at {destination}.") self.destination = destination + self.replan_ferry_flights(theater) def cancel_relocation(self) -> None: if self.destination is None: @@ -343,6 +348,55 @@ class Squadron: if self.expected_size_next_turn >= self.location.unclaimed_parking(): raise RuntimeError(f"Not enough parking for {self} at {self.location}.") self.destination = None + self.cancel_ferry_flights() + + def replan_ferry_flights(self, theater: ConflictTheater) -> None: + self.cancel_ferry_flights() + self.plan_ferry_flights(theater) + + def cancel_ferry_flights(self) -> None: + for package in self.coalition.ato.packages: + # Copy the list so our iterator remains consistent throughout the removal. + 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) + + def plan_ferry_flights(self, theater: ConflictTheater) -> None: + if self.destination is None: + raise RuntimeError( + f"Cannot plan ferry flights for {self} because there is no destination." + ) + package = Package(self.destination) + builder = FlightPlanBuilder(package, self.coalition, theater) + remaining = self.untasked_aircraft + while remaining: + size = min(remaining, self.aircraft.max_group_size) + self.plan_ferry_flight(builder, package, size) + remaining -= size + package.set_tot_asap() + self.coalition.ato.add_package(package) + + def plan_ferry_flight( + self, builder: FlightPlanBuilder, package: Package, size: int + ) -> None: + start_type = self.location.required_aircraft_start_type + if start_type is None: + start_type = self.settings.default_start_type + + flight = Flight( + package, + self.coalition.country_name, + self, + size, + FlightType.FERRY, + start_type, + divert=None, + ) + package.add_flight(flight) + builder.populate_flight_plan(flight) @classmethod def create_from( diff --git a/gen/aircraft.py b/gen/aircraft.py index 2b0747b0..ea775f9d 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -57,6 +57,7 @@ from dcs.task import ( Transport, WeaponType, TargetType, + Nothing, ) from dcs.terrain.terrain import Airport, NoParkingSlotError from dcs.triggers import Event, TriggerOnce, TriggerRule @@ -1086,6 +1087,23 @@ class AircraftConflictGenerator: restrict_jettison=True, ) + def configure_ferry( + self, + group: FlyingGroup[Any], + package: Package, + flight: Flight, + dynamic_runways: Dict[str, RunwayData], + ) -> None: + group.task = Nothing.name + self._setup_group(group, package, flight, dynamic_runways) + self.configure_behavior( + flight, + group, + react_on_threat=OptReactOnThreat.Values.EvadeFire, + roe=OptROE.Values.WeaponHold, + restrict_jettison=True, + ) + def configure_unknown_task(self, group: FlyingGroup[Any], flight: Flight) -> None: logging.error(f"Unhandled flight type: {flight.flight_type}") self.configure_behavior(flight, group) @@ -1130,6 +1148,8 @@ class AircraftConflictGenerator: self.configure_oca_strike(group, package, flight, dynamic_runways) elif flight_type == FlightType.TRANSPORT: self.configure_transport(group, package, flight, dynamic_runways) + elif flight_type == FlightType.FERRY: + self.configure_ferry(group, package, flight, dynamic_runways) else: self.configure_unknown_task(group, flight) diff --git a/gen/ato.py b/gen/ato.py index 944cf316..ec62fc1f 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -183,6 +183,7 @@ class Package: FlightType.TARCAP, FlightType.BARCAP, FlightType.AEWC, + FlightType.FERRY, FlightType.REFUELING, FlightType.SWEEP, FlightType.ESCORT, diff --git a/gen/flights/flight.py b/gen/flights/flight.py index b37a3d11..bf520c3a 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -70,6 +70,7 @@ class FlightType(Enum): TRANSPORT = "Transport" SEAD_ESCORT = "SEAD Escort" REFUELING = "Refueling" + FERRY = "Ferry" def __str__(self) -> str: return self.value diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 032c84e2..e471e44c 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -37,10 +37,8 @@ from game.theater.theatergroundobject import ( NavalGroundObject, BuildingGroundObject, ) - from game.threatzones import ThreatZones from game.utils import Distance, Heading, Speed, feet, meters, nautical_miles, knots - from .closestairfields import ObjectiveDistanceCache from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType from .traveltime import GroundSpeed, TravelTime @@ -836,6 +834,39 @@ class AirliftFlightPlan(FlightPlan): return self.package.time_over_target +@dataclass(frozen=True) +class FerryFlightPlan(FlightPlan): + takeoff: FlightWaypoint + nav_to_destination: list[FlightWaypoint] + land: FlightWaypoint + divert: Optional[FlightWaypoint] + bullseye: FlightWaypoint + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.takeoff + yield from self.nav_to_destination + yield self.land + if self.divert is not None: + yield self.divert + yield self.bullseye + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + return self.land + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + # TOT planning isn't really useful for ferries. They're behind the front + # lines so no need to wait for escorts or for other missions to complete. + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + return None + + @property + def mission_departure_time(self) -> timedelta: + return self.package.time_over_target + + @dataclass(frozen=True) class CustomFlightPlan(FlightPlan): custom_waypoints: List[FlightWaypoint] @@ -958,6 +989,8 @@ class FlightPlanBuilder: return self.generate_transport(flight) elif task == FlightType.REFUELING: return self.generate_refueling_racetrack(flight) + elif task == FlightType.FERRY: + return self.generate_ferry(flight) raise PlanningError(f"{task} flight plan generation not implemented") def regenerate_package_waypoints(self) -> None: @@ -1244,6 +1277,42 @@ class FlightPlanBuilder: bullseye=builder.bullseye(), ) + def generate_ferry(self, flight: Flight) -> FerryFlightPlan: + """Generate a ferry flight at a given location. + + Args: + flight: The flight to generate the flight plan for. + """ + + if flight.departure == flight.arrival: + raise PlanningError( + f"Cannot plan ferry flight: departure and arrival are both " + f"{flight.departure}" + ) + + altitude_is_agl = flight.unit_type.dcs_unit_type.helicopter + altitude = ( + feet(1500) + if altitude_is_agl + else flight.unit_type.preferred_patrol_altitude + ) + + builder = WaypointBuilder(flight, self.coalition) + return FerryFlightPlan( + package=self.package, + flight=flight, + takeoff=builder.takeoff(flight.departure), + nav_to_destination=builder.nav_path( + flight.departure.position, + flight.arrival.position, + altitude, + altitude_is_agl, + ), + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert), + bullseye=builder.bullseye(), + ) + def cap_racetrack_for_objective( self, location: MissionTarget, barcap: bool ) -> Tuple[Point, Point]: diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index 9fcd1c7b..b90e826b 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -20,7 +20,7 @@ from game.squadrons import Squadron from game.theater import ConflictTheater from gen.flights.flight import Flight from qt_ui.delegates import TwoColumnRowDelegate -from qt_ui.models import GameModel, AirWingModel, SquadronModel +from qt_ui.models import GameModel, AirWingModel, SquadronModel, AtoModel from qt_ui.windows.SquadronDialog import SquadronDialog @@ -57,8 +57,14 @@ class SquadronDelegate(TwoColumnRowDelegate): class SquadronList(QListView): """List view for displaying the air wing's squadrons.""" - def __init__(self, air_wing_model: AirWingModel, theater: ConflictTheater) -> None: + def __init__( + self, + ato_model: AtoModel, + air_wing_model: AirWingModel, + theater: ConflictTheater, + ) -> None: super().__init__() + self.ato_model = ato_model self.air_wing_model = air_wing_model self.theater = theater self.dialog: Optional[SquadronDialog] = None @@ -78,6 +84,7 @@ class SquadronList(QListView): if not index.isValid(): return self.dialog = SquadronDialog( + self.ato_model, SquadronModel(self.air_wing_model.squadron_at_index(index)), self.theater, self, @@ -199,7 +206,11 @@ class AirWingTabs(QTabWidget): super().__init__() self.addTab( - SquadronList(game_model.blue_air_wing_model, game_model.game.theater), + SquadronList( + game_model.ato_model, + game_model.blue_air_wing_model, + game_model.game.theater, + ), "Squadrons", ) self.addTab(AirInventoryView(game_model), "Inventory") diff --git a/qt_ui/windows/SquadronDialog.py b/qt_ui/windows/SquadronDialog.py index 24b004cc..aafad8b8 100644 --- a/qt_ui/windows/SquadronDialog.py +++ b/qt_ui/windows/SquadronDialog.py @@ -24,7 +24,7 @@ from game.theater import ControlPoint, ConflictTheater from gen.flights.flight import FlightType from qt_ui.delegates import TwoColumnRowDelegate from qt_ui.errorreporter import report_errors -from qt_ui.models import SquadronModel +from qt_ui.models import SquadronModel, AtoModel class PilotDelegate(TwoColumnRowDelegate): @@ -135,10 +135,16 @@ class SquadronDialog(QDialog): """Dialog window showing a squadron.""" def __init__( - self, squadron_model: SquadronModel, theater: ConflictTheater, parent + self, + ato_model: AtoModel, + squadron_model: SquadronModel, + theater: ConflictTheater, + parent, ) -> None: super().__init__(parent) + self.ato_model = ato_model self.squadron_model = squadron_model + self.theater = theater self.setMinimumSize(1000, 440) self.setWindowTitle(str(squadron_model.squadron)) @@ -194,7 +200,8 @@ class SquadronDialog(QDialog): if destination is None: self.squadron.cancel_relocation() else: - self.squadron.plan_relocation(destination) + self.squadron.plan_relocation(destination, self.theater) + self.ato_model.replace_from_game(player=True) def check_disabled_button_states( self, button: QPushButton, index: QModelIndex