diff --git a/changelog.md b/changelog.md index b0587444..91526aaf 100644 --- a/changelog.md +++ b/changelog.md @@ -7,7 +7,8 @@ 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]** 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]** FOBs control point can have FARP/helipad slot and host helicopters. To enable this feature on a FOB, add "Invisible FARP" statics objects near the FOB location in the campaign definition file. +* **[Campaign]** 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]** Aircraft now belong to squadrons rather than bases to support squadron location transfers. * **[Campaign]** Skipped turns are no longer counted as defeats on front lines. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. diff --git a/game/campaignloader/mizcampaignloader.py b/game/campaignloader/mizcampaignloader.py index 29dec2ff..c3a94e71 100644 --- a/game/campaignloader/mizcampaignloader.py +++ b/game/campaignloader/mizcampaignloader.py @@ -45,7 +45,7 @@ class MizCampaignLoader: SHIPPING_LANE_UNIT_TYPE = HandyWind.id FOB_UNIT_TYPE = Unarmed.SKP_11.id - FARP_HELIPAD = "SINGLE_HELIPAD" + FARP_HELIPADS_TYPE = ["Invisible FARP", "SINGLE_HELIPAD"] OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id @@ -212,7 +212,7 @@ class MizCampaignLoader: @property def helipads(self) -> Iterator[StaticGroup]: for group in self.blue.static_group: - if group.units[0].type == self.FARP_HELIPAD: + if group.units[0].type in self.FARP_HELIPADS_TYPE: yield group @property diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index 618f08d8..421e55d5 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -184,6 +184,10 @@ class AircraftType(UnitType[Type[FlyingType]]): def flyable(self) -> bool: return self.dcs_unit_type.flyable + @property + def helicopter(self) -> bool: + return self.dcs_unit_type.helicopter + @cached_property def max_speed(self) -> Speed: return kph(self.dcs_unit_type.max_speed) diff --git a/game/game.py b/game/game.py index eec8e5a3..6bb2d3b8 100644 --- a/game/game.py +++ b/game/game.py @@ -8,6 +8,8 @@ from datetime import date, datetime, timedelta from enum import Enum from typing import Any, List, Type, Union, cast, TYPE_CHECKING +from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors +from dcs.country import Country from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike from dcs.vehicles import AirDefence @@ -195,6 +197,17 @@ class Game: ) ) + @property + def neutral_country(self) -> Type[Country]: + """Return the best fitting country that can be used as neutral faction in the generated mission""" + countries_in_use = [self.red.country_name, self.blue.country_name] + if UnitedNationsPeacekeepers not in countries_in_use: + return UnitedNationsPeacekeepers + elif Switzerland.name not in countries_in_use: + return Switzerland + else: + return USAFAggressors + def _generate_events(self) -> None: for front_line in self.theater.conflicts(): self._generate_player_event( diff --git a/game/operation/operation.py b/game/operation/operation.py index 29ce0532..4817d972 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -39,6 +39,7 @@ from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator from gen.visualgen import VisualGenerator from .. import db from ..theater import Airfield, FrontLine +from ..theater.bullseye import Bullseye from ..unitmap import UnitMap if TYPE_CHECKING: @@ -105,6 +106,9 @@ class Operation: cls.current_mission.coalition["red"] = Coalition( "red", bullseye=cls.game.red.bullseye.to_pydcs() ) + cls.current_mission.coalition["neutrals"] = Coalition( + "neutrals", bullseye=Bullseye(Point(0, 0)).to_pydcs() + ) p_country = cls.game.blue.country_name e_country = cls.game.red.country_name @@ -115,6 +119,16 @@ class Operation: country_dict[db.country_id_from_name(e_country)]() ) + belligerents = [ + db.country_id_from_name(p_country), + db.country_id_from_name(e_country), + ] + for country in country_dict.keys(): + if country not in belligerents: + cls.current_mission.coalition["neutrals"].add_country( + country_dict[country]() + ) + @classmethod def inject_lua_trigger(cls, contents: str, comment: str) -> None: trigger = TriggerStart(comment=comment) @@ -365,6 +379,7 @@ class Operation: cls.laser_code_registry, cls.unit_map, air_support=cls.airsupportgen.air_support, + helipads=cls.groundobjectgen.helipads, ) cls.airgen.clear_parking_slots() diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 90ae2650..db8230d3 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -32,6 +32,7 @@ from dcs.ships import ( ) from dcs.terrain.terrain import Airport, ParkingSlot from dcs.unit import Unit +from dcs.unittype import FlyingType from game import db from game.point_with_heading import PointWithHeading @@ -410,6 +411,13 @@ class ControlPoint(MissionTarget, ABC): return True return False + @property + def has_helipads(self) -> bool: + """ + Returns true if cp has helipads + """ + return len(self.helipads) > 0 + def can_recruit_ground_units(self, game: Game) -> bool: """Returns True if this control point is capable of recruiting ground units.""" if not self.can_deploy_ground_units: @@ -828,6 +836,22 @@ class ControlPoint(MissionTarget, ABC): """Return the number of ammo depots, including dead ones""" return len(list(self.all_ammo_depots)) + @property + def active_fuel_depots_count(self) -> int: + """Return the number of available fuel depots""" + return len( + [ + obj + for obj in self.connected_objectives + if obj.category == "fuel" and not obj.is_dead + ] + ) + + @property + def total_fuel_depots_count(self) -> int: + """Return the number of fuel depots, including dead ones""" + return len([obj for obj in self.connected_objectives if obj.category == "fuel"]) + @property def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: return [] @@ -886,6 +910,11 @@ class Airfield(ControlPoint): @property def total_aircraft_parking(self) -> 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) @property @@ -1165,7 +1194,7 @@ class Fob(ControlPoint): self.name = name def runway_is_operational(self) -> bool: - return False + return self.has_helipads def active_runway( self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData] @@ -1189,10 +1218,10 @@ class Fob(ControlPoint): @property def total_aircraft_parking(self) -> int: - return 0 + return len(self.helipads) def can_operate(self, aircraft: AircraftType) -> bool: - return False + return aircraft.helicopter @property def heading(self) -> Heading: diff --git a/game/version.py b/game/version.py index 6d7afc30..11d6cee5 100644 --- a/game/version.py +++ b/game/version.py @@ -111,6 +111,9 @@ VERSION = _build_version_string() #: * DCS 2.7.4.9632 changed scenery target IDs. Any mission using map buildings as #: strike targets must check and potentially recreate all those objectives. #: +#: Version 8.1 +#: * You can now add "Invisible FARP" static to FOB to add helicopter slots +#: #: Version 9.0 #: * Campaign files now define the initial squadron layouts. See TODO. #: * CV and LHA control points now get their names from the group name in the campaign diff --git a/gen/aircraft.py b/gen/aircraft.py index 5588701f..fd49df18 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -229,6 +229,7 @@ class AircraftConflictGenerator: laser_code_registry: LaserCodeRegistry, unit_map: UnitMap, air_support: AirSupport, + helipads: dict[ControlPoint, list[StaticGroup]], ) -> None: self.m = mission self.game = game @@ -239,6 +240,7 @@ class AircraftConflictGenerator: self.unit_map = unit_map self.flights: List[FlightData] = [] self.air_support = air_support + self.helipads = helipads @cached_property def use_client(self) -> bool: @@ -534,6 +536,54 @@ class AircraftConflictGenerator: group_size=count, ) + def _generate_at_cp_helipad( + self, + name: str, + side: Country, + unit_type: Type[FlyingType], + count: int, + start_type: str, + cp: ControlPoint, + ) -> FlyingGroup[Any]: + assert count > 0 + + logging.info( + "airgen at cp's helipads : {} for {} at {}".format( + unit_type, side.id, cp.name + ) + ) + + try: + helipad = self.helipads[cp].pop() + except IndexError as ex: + raise RuntimeError(f"Not enough helipads available at {cp}") from ex + + group = self._generate_at_group( + name=name, + side=side, + unit_type=unit_type, + count=count, + start_type=start_type, + at=helipad, + ) + + # Note : A bit dirty, need better support in pydcs + group.points[0].action = PointAction.FromGroundArea + group.points[0].type = "TakeOffGround" + group.units[0].heading = helipad.units[0].heading + if start_type != "Cold": + group.points[0].action = PointAction.FromGroundAreaHot + group.points[0].type = "TakeOffGroundHot" + + for i in range(count - 1): + try: + helipad = self.helipads[cp].pop() + group.units[1 + i].position = Point(helipad.x, helipad.y) + group.units[1 + i].heading = helipad.units[0].heading + except IndexError as ex: + raise RuntimeError(f"Not enough helipads available at {cp}") from ex + return group + def _add_radio_waypoint( self, group: FlyingGroup[Any], @@ -692,11 +742,13 @@ class AircraftConflictGenerator: self, cp: ControlPoint, country: Country, flight: Flight ) -> FlyingGroup[Any]: name = namegen.next_aircraft_name(country, cp.id, flight) + group: FlyingGroup[Any] try: if flight.start_type == "In Flight": group = self._generate_inflight( name=name, side=country, flight=flight, origin=cp ) + return group elif isinstance(cp, NavalControlPoint): group_name = cp.get_carrier_group_name() carrier_group = self.m.find_group(group_name) @@ -705,7 +757,7 @@ class AircraftConflictGenerator: f"Carrier group {carrier_group} is a " "{carrier_group.__class__.__name__}, expected a ShipGroup" ) - group = self._generate_at_group( + return self._generate_at_group( name=name, side=country, unit_type=flight.unit_type.dcs_unit_type, @@ -714,11 +766,22 @@ class AircraftConflictGenerator: at=carrier_group, ) else: + # If the flight is an helicopter flight, then prioritize dedicated helipads + if flight.unit_type.helicopter: + return self._generate_at_cp_helipad( + name=name, + side=country, + unit_type=flight.unit_type.dcs_unit_type, + count=flight.count, + start_type=flight.start_type, + cp=cp, + ) + if not isinstance(cp, Airfield): raise RuntimeError( f"Attempted to spawn at airfield for non-airfield {cp}" ) - group = self._generate_at_airport( + return self._generate_at_airport( name=name, side=country, unit_type=flight.unit_type.dcs_unit_type, @@ -737,8 +800,7 @@ class AircraftConflictGenerator: name=name, side=country, flight=flight, origin=cp ) group.points[0].alt = 1500 - - return group + return group @staticmethod def set_reduced_fuel( diff --git a/gen/flights/flight.py b/gen/flights/flight.py index c0766b39..15b125e2 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -220,6 +220,8 @@ class FlightWaypoint: PointAction.FromParkingArea: FlightWaypointType.TAKEOFF, PointAction.FromParkingAreaHot: FlightWaypointType.TAKEOFF, PointAction.FromRunway: FlightWaypointType.TAKEOFF, + PointAction.FromGroundArea: FlightWaypointType.TAKEOFF, + PointAction.FromGroundAreaHot: FlightWaypointType.TAKEOFF, }[point.action] if waypoint.waypoint_type == FlightWaypointType.NAV: waypoint.name = "NAV" diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index e9184560..84ef0509 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging import random +from collections import defaultdict from typing import ( Dict, Iterator, @@ -35,7 +36,7 @@ from dcs.task import ( FireAtPoint, ) from dcs.triggers import TriggerStart, TriggerZone -from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad +from dcs.unit import Ship, Unit, Vehicle, InvisibleFARP from dcs.unitgroup import ShipGroup, StaticGroup, VehicleGroup from dcs.unittype import StaticType, ShipType, VehicleType from dcs.vehicles import vehicle_map @@ -589,22 +590,45 @@ class HelipadGenerator: self.game = game self.radio_registry = radio_registry self.tacan_registry = tacan_registry + self.helipads: list[StaticGroup] = [] def generate(self) -> None: + + # Note : Helipad are generated as neutral object in order not to interfer with capture triggers + neutral_country = self.m.country(self.game.neutral_country.name) country = self.m.country(self.game.coalition_for(self.cp.captured).country_name) for i, helipad in enumerate(self.cp.helipads): name = self.cp.name + "_helipad_" + str(i) - logging.info("Generating helipad : " + name) - pad = SingleHeliPad(name=(name + "_unit")) + logging.info("Generating helipad static : " + name) + pad = InvisibleFARP(name=name) pad.position = Point(helipad.x, helipad.y) pad.heading = helipad.heading.degrees - # pad.heliport_frequency = self.radio_registry.alloc_uhf() TODO : alloc radio & callsign sg = unitgroup.StaticGroup(self.m.next_group_id(), name) sg.add_unit(pad) sp = StaticPoint() sp.position = pad.position sg.add_point(sp) - country.add_static_group(sg) + neutral_country.add_static_group(sg) + + 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, + ) + 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, + ) class GroundObjectsGenerator: @@ -631,13 +655,18 @@ class GroundObjectsGenerator: self.unit_map = unit_map self.icls_alloc = iter(range(1, 21)) self.runways: Dict[str, RunwayData] = {} + self.helipads: dict[ControlPoint, list[StaticGroup]] = defaultdict(list) def generate(self) -> None: for cp in self.game.theater.controlpoints: country = self.m.country(self.game.coalition_for(cp.captured).country_name) - HelipadGenerator( + + # Generate helipads + helipad_gen = HelipadGenerator( self.m, cp, self.game, self.radio_registry, self.tacan_registry - ).generate() + ) + helipad_gen.generate() + self.helipads[cp] = helipad_gen.helipads for ground_object in cp.ground_objects: generator: GenericGroundObjectGenerator[Any] diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index 4d157dee..dc668d28 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -40,12 +40,14 @@ class QAircraftRecruitmentMenu(UnitTransactionFrame[Squadron]): row = 0 unit_types: Set[AircraftType] = set() + for squadron in cp.squadrons: unit_types.add(squadron.aircraft) sorted_squadrons = sorted(cp.squadrons, key=lambda s: (s.aircraft.name, s.name)) for row, squadron in enumerate(sorted_squadrons): self.add_purchase_row(squadron, task_box_layout, row) + stretch = QVBoxLayout() stretch.addStretch() task_box_layout.addLayout(stretch, row, 0) diff --git a/resources/campaigns/golan_heights_lite.json b/resources/campaigns/golan_heights_lite.json deleted file mode 100644 index ba618c9b..00000000 --- a/resources/campaigns/golan_heights_lite.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Syria - Battle for Golan Heights", - "theater": "Syria", - "authors": "Khopa", - "recommended_player_faction": "Israel 2000", - "recommended_enemy_faction": "Syria 2011", - "description": "
In this scenario, you start in Israel and the conflict is focused around the golan heights, an historically disputed territory.
This scenario is designed to be performance friendly.
In this scenario, you start in Israel and the conflict is focused around the golan heights, an historically disputed territory.
This scenario is designed to be performance and helicopter friendly.