Example #1
0
 def for_front_line(front_line: FrontLine) -> FrontLineJs:
     a = front_line.position.point_from_heading(
         front_line.attack_heading.right.degrees,
         nautical_miles(2).meters)
     b = front_line.position.point_from_heading(
         front_line.attack_heading.left.degrees,
         nautical_miles(2).meters)
     return FrontLineJs(id=front_line.id, extents=[a.latlng(), b.latlng()])
Example #2
0
 def extents(self) -> List[LeafletLatLon]:
     a = self.theater.point_to_ll(
         self.front_line.position.point_from_heading(
             self.front_line.attack_heading + 90, nautical_miles(2).meters
         )
     )
     b = self.theater.point_to_ll(
         self.front_line.position.point_from_heading(
             self.front_line.attack_heading + 270, nautical_miles(2).meters
         )
     )
     return [[a.latitude, a.longitude], [b.latitude, b.longitude]]
    def build(self) -> PatrollingLayout:
        racetrack_half_distance = nautical_miles(20).meters

        location = self.package.target

        closest_boundary = self.threat_zones.closest_boundary(
            location.position)
        heading_to_threat_boundary = Heading.from_degrees(
            location.position.heading_between_point(closest_boundary))
        distance_to_threat = meters(
            location.position.distance_to_point(closest_boundary))
        orbit_heading = heading_to_threat_boundary

        # Station 70nm outside the threat zone.
        threat_buffer = nautical_miles(70)
        if self.threat_zones.threatened(location.position):
            orbit_distance = distance_to_threat + threat_buffer
        else:
            orbit_distance = distance_to_threat - threat_buffer

        racetrack_center = location.position.point_from_heading(
            orbit_heading.degrees, orbit_distance.meters)

        racetrack_start = racetrack_center.point_from_heading(
            orbit_heading.right.degrees, racetrack_half_distance)

        racetrack_end = racetrack_center.point_from_heading(
            orbit_heading.left.degrees, racetrack_half_distance)

        builder = WaypointBuilder(self.flight, self.coalition)

        tanker_type = self.flight.unit_type
        if tanker_type.patrol_altitude is not None:
            altitude = tanker_type.patrol_altitude
        else:
            altitude = feet(21000)

        racetrack = builder.race_track(racetrack_start, racetrack_end,
                                       altitude)

        return PatrollingLayout(
            departure=builder.takeoff(self.flight.departure),
            nav_to=builder.nav_path(self.flight.departure.position,
                                    racetrack_start, altitude),
            nav_from=builder.nav_path(racetrack_end,
                                      self.flight.arrival.position, altitude),
            patrol_start=racetrack[0],
            patrol_end=racetrack[1],
            arrival=builder.land(self.flight.arrival),
            divert=builder.divert(self.flight.divert),
            bullseye=builder.bullseye(),
        )
Example #4
0
    def for_threats(
        cls,
        theater: ConflictTheater,
        doctrine: Doctrine,
        barcap_locations: Iterable[ControlPoint],
        air_defenses: Iterable[TheaterGroundObject],
    ) -> ThreatZones:
        """Generates the threat zones projected by the given locations.

        Args:
            theater: The theater the threat zones are in.
            doctrine: The doctrine of the owning coalition.
            barcap_locations: The locations that will be considered for BARCAP planning.
            air_defenses: TGOs that may have air defenses.

        Returns:
            The threat zones projected by the given locations. If the threat zone
            belongs to the player, it is the zone that will be avoided by the enemy and
            vice versa.
        """
        air_threats = []
        air_defense_threats = []
        radar_sam_threats = []
        for barcap in barcap_locations:
            point = ShapelyPoint(barcap.position.x, barcap.position.y)
            cap_threat_range = cls.barcap_threat_range(doctrine, barcap)
            air_threats.append(point.buffer(cap_threat_range.meters))

        for tgo in air_defenses:
            for group in tgo.groups:
                threat_range = tgo.threat_range(group)
                # Any system with a shorter range than this is not worth
                # even avoiding.
                if threat_range > nautical_miles(3):
                    point = ShapelyPoint(tgo.position.x, tgo.position.y)
                    threat_zone = point.buffer(threat_range.meters)
                    air_defense_threats.append(threat_zone)
                radar_threat_range = tgo.threat_range(group, radar_only=True)
                if radar_threat_range > nautical_miles(3):
                    point = ShapelyPoint(tgo.position.x, tgo.position.y)
                    threat_zone = point.buffer(threat_range.meters)
                    radar_sam_threats.append(threat_zone)

        return ThreatZones(
            theater,
            airbases=unary_union(air_threats),
            air_defenses=unary_union(air_defense_threats),
            radar_sam_threats=unary_union(radar_sam_threats),
        )
Example #5
0
    def from_threat_zones(
        cls, threat_zones: ThreatZones, theater: ConflictTheater
    ) -> NavMesh:
        # Simplify the threat poly to reduce the number of nav zones. Increase
        # the size of the zone and then simplify it with the buffer size as the
        # error margin. This will create a simpler poly around the threat zone.
        buffer = nautical_miles(10).meters
        threat_poly = threat_zones.all.buffer(buffer).simplify(buffer)

        # Threat zones can be disconnected. Create a list of threat zones.
        if isinstance(threat_poly, MultiPolygon):
            polys = list(threat_poly.geoms)
        else:
            polys = [threat_poly]

        # Subtract the threat zones from the whole-map poly to build a navmesh
        # for the *safe* areas. Navigation within threatened regions is always
        # a straight line to the target or out of the threatened region.
        bounds = cls.map_bounds(theater)
        for poly in polys:
            bounds = bounds.difference(poly)

        # Triangulate the safe-region to build the navmesh.
        navpolys = cls.create_navpolys(triangulate(bounds), threat_zones)
        cls.associate_neighbors(navpolys)
        return cls(navpolys)
Example #6
0
 def configure_escort_tasks(waypoint: MovingPoint,
                            target_types: List[Type[TargetType]]) -> None:
     # Ideally we would use the escort mission type and escort task to have
     # the AI automatically but the AI only escorts AI flights while they are
     # traveling between waypoints. When an AI flight performs an attack
     # (such as attacking the mission target), AI escorts wander aimlessly
     # until the escorted group resumes its flight plan.
     #
     # As such, we instead use the Search Then Engage task, which is an
     # enroute task that causes the AI to follow their flight plan and engage
     # enemies of the set type within a certain distance. The downside to
     # this approach is that AI escorts are no longer related to the group
     # they are escorting, aside from the fact that they fly a similar flight
     # plan at the same time. With Escort, the escorts will follow the
     # escorted group out of the area. The strike element may or may not fly
     # directly over the target, and they may or may not require multiple
     # attack runs. For the escort flight we must just assume a flight plan
     # for the escort to fly. If the strike flight doesn't need to overfly
     # the target, the escorts are needlessly going in harms way. If the
     # strike flight needs multiple passes, the escorts may leave before the
     # escorted aircraft do.
     #
     # Another possible option would be to use Search Then Engage for join ->
     # ingress and egress -> split, but use a Search Then Engage in Zone task
     # for the target area that is set to end on a flag flip that occurs when
     # the strike aircraft finish their attack task.
     #
     # https://forums.eagle.ru/topic/251798-options-for-alternate-ai-escort-behavior
     waypoint.add_task(
         ControlledTask(
             EngageTargets(
                 # TODO: From doctrine.
                 max_distance=int(nautical_miles(30).meters),
                 targets=target_types,
             )))
Example #7
0
 def add_tasks(self, waypoint: MovingPoint) -> None:
     if isinstance(self.flight.flight_plan, CasFlightPlan):
         waypoint.add_task(
             EngageTargetsInZone(
                 position=self.flight.flight_plan.layout.target.position,
                 radius=int(
                     self.flight.flight_plan.engagement_distance.meters),
                 targets=[
                     Targets.All.GroundUnits.GroundVehicles,
                     Targets.All.GroundUnits.AirDefence.AAA,
                     Targets.All.GroundUnits.Infantry,
                 ],
             ))
     else:
         logging.error(
             "No CAS waypoint found. Falling back to search and engage")
         waypoint.add_task(
             EngageTargets(
                 max_distance=int(nautical_miles(10).meters),
                 targets=[
                     Targets.All.GroundUnits.GroundVehicles,
                     Targets.All.GroundUnits.AirDefence.AAA,
                     Targets.All.GroundUnits.Infantry,
                 ],
             ))
Example #8
0
    def order_airlift_assets_at(self, control_point: ControlPoint) -> None:
        unclaimed_parking = control_point.unclaimed_parking(self.game)
        # Buy a maximum of unclaimed_parking only to prevent that aircraft procurement
        # take place at another base
        gap = min(
            [
                self.desired_airlift_capacity(control_point)
                - self.current_airlift_capacity(control_point),
                unclaimed_parking,
            ]
        )

        if gap <= 0:
            return

        if gap % 2:
            # Always buy in pairs since we're not trying to fill odd squadrons. Purely
            # aesthetic.
            gap += 1

        if gap > unclaimed_parking:
            # Prevent to buy more aircraft than possible
            return

        self.game.procurement_requests_for(player=control_point.captured).append(
            AircraftProcurementRequest(
                control_point, nautical_miles(200), FlightType.TRANSPORT, gap
            )
        )
Example #9
0
    def for_faction(cls, game: Game, player: bool) -> ThreatZones:
        """Generates the threat zones projected by the given coalition.

        Args:
            game: The game to generate the threat zone for.
            player: True if the coalition projecting the threat zone belongs to
            the player.

        Returns:
            The threat zones projected by the given coalition. If the threat
            zone belongs to the player, it is the zone that will be avoided by
            the enemy and vice versa.
        """
        air_threats = []
        air_defenses = []
        radar_sam_threats = []
        for control_point in game.theater.controlpoints:
            if control_point.captured != player:
                continue
            if control_point.runway_is_operational():
                point = ShapelyPoint(control_point.position.x,
                                     control_point.position.y)
                cap_threat_range = cls.barcap_threat_range(game, control_point)
                air_threats.append(point.buffer(cap_threat_range.meters))

            for tgo in control_point.ground_objects:
                for group in tgo.groups:
                    threat_range = tgo.threat_range(group)
                    # Any system with a shorter range than this is not worth
                    # even avoiding.
                    if threat_range > nautical_miles(3):
                        point = ShapelyPoint(tgo.position.x, tgo.position.y)
                        threat_zone = point.buffer(threat_range.meters)
                        air_defenses.append(threat_zone)
                    radar_threat_range = tgo.threat_range(group,
                                                          radar_only=True)
                    if radar_threat_range > nautical_miles(3):
                        point = ShapelyPoint(tgo.position.x, tgo.position.y)
                        threat_zone = point.buffer(threat_range.meters)
                        radar_sam_threats.append(threat_zone)

        return cls(
            airbases=unary_union(air_threats),
            air_defenses=unary_union(air_defenses),
            radar_sam_threats=unary_union(radar_sam_threats),
        )
Example #10
0
 def map_bounds(theater: ConflictTheater) -> Polygon:
     points = []
     for cp in theater.controlpoints:
         points.append(ShapelyPoint(cp.position.x, cp.position.y))
         for tgo in cp.ground_objects:
             points.append(ShapelyPoint(tgo.position.x, tgo.position.y))
     # Needs to be a large enough boundary beyond the known points so that
     # threatened airbases at the map edges have room to retreat from the
     # threat without running off the navmesh.
     return box(*LineString(points).bounds).buffer(
         nautical_miles(100).meters, resolution=1)
Example #11
0
    def draw_scale(self, scale_distance_nm=20, number_of_points=4):

        PADDING = 14
        POS_X = 0
        POS_Y = 10
        BIG_LINE = 5
        SMALL_LINE = 2

        dist = self.distance_to_pixels(nautical_miles(scale_distance_nm))
        self.scene().addRect(
            POS_X,
            POS_Y - PADDING,
            PADDING * 2 + dist,
            BIG_LINE * 2 + 3 * PADDING,
            pen=CONST.COLORS["black"],
            brush=CONST.COLORS["black"],
        )
        l = self.scene().addLine(
            POS_X + PADDING,
            POS_Y + BIG_LINE * 2,
            POS_X + PADDING + dist,
            POS_Y + BIG_LINE * 2,
        )

        text = self.scene().addText(
            "0nm", font=QFont("Trebuchet MS", 6, weight=5, italic=False)
        )
        text.setPos(POS_X, POS_Y + BIG_LINE * 2)
        text.setDefaultTextColor(Qt.white)

        text2 = self.scene().addText(
            str(scale_distance_nm) + "nm",
            font=QFont("Trebuchet MS", 6, weight=5, italic=False),
        )
        text2.setPos(POS_X + dist, POS_Y + BIG_LINE * 2)
        text2.setDefaultTextColor(Qt.white)

        l.setPen(CONST.COLORS["white"])
        for i in range(number_of_points + 1):
            d = float(i) / float(number_of_points)
            if i == 0 or i == number_of_points:
                h = BIG_LINE
            else:
                h = SMALL_LINE

            l = self.scene().addLine(
                POS_X + PADDING + d * dist,
                POS_Y + BIG_LINE * 2,
                POS_X + PADDING + d * dist,
                POS_Y + BIG_LINE - h,
            )
            l.setPen(CONST.COLORS["white"])
Example #12
0
    def for_faction(cls, game: Game, player: bool) -> ThreatZones:
        """Generates the threat zones projected by the given coalition.

        Args:
            game: The game to generate the threat zone for.
            player: True if the coalition projecting the threat zone belongs to
            the player.

        Returns:
            The threat zones projected by the given coalition. If the threat
            zone belongs to the player, it is the zone that will be avoided by
            the enemy and vice versa.
        """
        air_threats = []
        air_defenses = []
        for control_point in game.theater.controlpoints:
            if control_point.captured != player:
                continue
            if control_point.runway_is_operational():
                point = ShapelyPoint(control_point.position.x,
                                     control_point.position.y)
                cap_threat_range = cls.barcap_threat_range(game, control_point)
                air_threats.append(point.buffer(cap_threat_range.meters))

            for tgo in control_point.ground_objects:
                for group in tgo.groups:
                    threat_range = tgo.threat_range(group)
                    # Any system with a shorter range than this is not worth
                    # even avoiding.
                    if threat_range > nautical_miles(3):
                        point = ShapelyPoint(tgo.position.x, tgo.position.y)
                        threat_zone = point.buffer(threat_range.meters)
                        air_defenses.append(threat_zone)

        for front_line in game.theater.conflicts(player):
            vector = Conflict.frontline_vector(front_line.control_point_a,
                                               front_line.control_point_b,
                                               game.theater)

            start = vector[0]
            end = vector[0].point_from_heading(vector[1], vector[2])

            line = LineString([
                ShapelyPoint(start.x, start.y),
                ShapelyPoint(end.x, end.y),
            ])
            doctrine = game.faction_for(player).doctrine
            air_threats.append(
                line.buffer(doctrine.cap_engagement_range.meters))

        return cls(airbases=unary_union(air_threats),
                   air_defenses=unary_union(air_defenses))
    def draw_navmesh_neighbor_line(self, scene: QGraphicsScene, poly: Polygon,
                                   begin: ShapelyPoint) -> None:
        vertex = Point(begin.x, begin.y)
        centroid = poly.centroid
        direction = Point(centroid.x, centroid.y)
        end = vertex.point_from_heading(
            vertex.heading_between_point(direction),
            nautical_miles(2).meters)

        scene.addLine(
            QLineF(QPointF(*self._transform_point(vertex)),
                   QPointF(*self._transform_point(end))),
            CONST.COLORS["yellow"])
Example #14
0
 def find_divert_field(self, aircraft: Type[FlyingType],
                       arrival: ControlPoint) -> Optional[ControlPoint]:
     divert_limit = nautical_miles(150)
     for airfield in self.closest_airfields.airfields_within(divert_limit):
         if airfield.captured != self.is_player:
             continue
         if airfield == arrival:
             continue
         if not airfield.can_operate(aircraft):
             continue
         if isinstance(airfield, OffMapSpawn):
             continue
         return airfield
     return None
Example #15
0
    def add_tasks(self, waypoint: MovingPoint) -> None:
        if not isinstance(self.flight.flight_plan, SweepFlightPlan):
            flight_plan_type = self.flight.flight_plan.__class__.__name__
            logging.error(f"Cannot create sweep for {self.flight} because "
                          f"{flight_plan_type} is not a sweep flight plan.")
            return

        waypoint.tasks.append(
            EngageTargets(
                max_distance=int(nautical_miles(50).meters),
                targets=[
                    Targets.All.Air.Planes.Fighters,
                    Targets.All.Air.Planes.MultiroleFighters,
                ],
            ))

        if self.flight.count < 4:
            waypoint.tasks.append(OptFormation.line_abreast_open())
        else:
            waypoint.tasks.append(OptFormation.spread_four_open())
    def add_tasks(self, waypoint: MovingPoint) -> None:
        target = self.package.target
        if not isinstance(target, Airfield):
            logging.error(
                "Unexpected target type for OCA Strike mission: %s",
                target.__class__.__name__,
            )
            return

        task = EngageTargetsInZone(
            position=target.position,
            # Al Dhafra is 4 nm across at most. Add a little wiggle room in case
            # the airport position from DCS is not centered.
            radius=int(nautical_miles(3).meters),
            targets=[Targets.All.Air],
        )
        task.params["attackQtyLimit"] = False
        task.params["directionEnabled"] = False
        task.params["altitudeEnabled"] = False
        task.params["groupAttack"] = True
        waypoint.tasks.append(task)
class CoalitionMissionPlanner:
    """Coalition flight planning AI.

    This class is responsible for automatically planning missions for the
    coalition at the start of the turn.

    The primary goal of the mission planner is to protect existing friendly
    assets. Missions will be planned with the following priorities:

    1. CAP for airfields/fleets in close proximity to the enemy to prevent heavy
       losses of friendly aircraft.
    2. CAP for front line areas to protect ground and CAS units.
    3. DEAD to reduce necessity of SEAD for future missions.
    4. CAS to protect friendly ground units.
    5. Strike missions to reduce the enemy's resources.

    TODO: Anti-ship and airfield strikes to reduce enemy sortie rates.
    TODO: BAI to prevent enemy forces from reaching the front line.
    TODO: Should fleets always have a CAP?

    TODO: Stance and doctrine-specific planning behavior.
    """

    # TODO: Merge into doctrine, also limit by aircraft.
    MAX_CAP_RANGE = nautical_miles(100)
    MAX_CAS_RANGE = nautical_miles(50)
    MAX_ANTISHIP_RANGE = nautical_miles(150)
    MAX_BAI_RANGE = nautical_miles(150)
    MAX_OCA_RANGE = nautical_miles(150)
    MAX_SEAD_RANGE = nautical_miles(150)
    MAX_STRIKE_RANGE = nautical_miles(150)
    MAX_AWEC_RANGE = Distance.inf()
    MAX_TANKER_RANGE = nautical_miles(200)

    def __init__(self, game: Game, is_player: bool) -> None:
        self.game = game
        self.is_player = is_player
        self.objective_finder = ObjectiveFinder(self.game, self.is_player)
        self.ato = self.game.blue_ato if is_player else self.game.red_ato
        self.threat_zones = self.game.threat_zone_for(not self.is_player)
        self.procurement_requests = self.game.procurement_requests_for(self.is_player)
        self.faction = self.game.faction_for(self.is_player)

    def air_wing_can_plan(self, mission_type: FlightType) -> bool:
        """Returns True if it is possible for the air wing to plan this mission type.

        Not all mission types can be fulfilled by all air wings. Many factions do not
        have AEW&C aircraft, so they will never be able to plan those missions. It's
        also possible for the player to exclude mission types from their squadron
        designs.
        """
        return self.game.air_wing_for(self.is_player).can_auto_plan(mission_type)

    def critical_missions(self) -> Iterator[ProposedMission]:
        """Identifies the most important missions to plan this turn.

        Non-critical missions that cannot be fulfilled will create purchase
        orders for the next turn. Critical missions will create a purchase order
        unless the mission can be doubly fulfilled. In other words, the AI will
        attempt to have *double* the aircraft it needs for these missions to
        ensure that they can be planned again next turn even if all aircraft are
        eliminated this turn.
        """

        # Find farthest, friendly CP for AEWC.
        yield ProposedMission(
            self.objective_finder.farthest_friendly_control_point(),
            [ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)],
            # Supports all the early CAP flights, so should be in the air ASAP.
            asap=True,
        )

        yield ProposedMission(
            self.objective_finder.closest_friendly_control_point(),
            [ProposedFlight(FlightType.REFUELING, 1, self.MAX_TANKER_RANGE)],
        )

        # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP.
        for cp in self.objective_finder.vulnerable_control_points():
            # Plan CAP in such a way, that it is established during the whole desired mission length
            for _ in range(
                0,
                int(self.game.settings.desired_player_mission_duration.total_seconds()),
                int(self.faction.doctrine.cap_duration.total_seconds()),
            ):
                yield ProposedMission(
                    cp,
                    [
                        ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
                    ],
                )

        # Find front lines, plan CAS.
        for front_line in self.objective_finder.front_lines():
            yield ProposedMission(
                front_line,
                [
                    ProposedFlight(FlightType.CAS, 2, self.MAX_CAS_RANGE),
                    # This is *not* an escort because front lines don't create a threat
                    # zone. Generating threat zones from front lines causes the front
                    # line to push back BARCAPs as it gets closer to the base. While
                    # front lines do have the same problem of potentially pulling
                    # BARCAPs off bases to engage a front line TARCAP, that's probably
                    # the one time where we do want that.
                    #
                    # TODO: Use intercepts and extra TARCAPs to cover bases near fronts.
                    # We don't have intercept missions yet so this isn't something we
                    # can do today, but we should probably return to having the front
                    # line project a threat zone (so that strike missions will route
                    # around it) and instead *not plan* a BARCAP at bases near the
                    # front, since there isn't a place to put a barrier. Instead, the
                    # aircraft that would have been a BARCAP could be used as additional
                    # interceptors and TARCAPs which will defend the base but won't be
                    # trying to avoid front line contacts.
                    ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE),
                ],
            )

    def propose_missions(self) -> Iterator[ProposedMission]:
        """Identifies and iterates over potential mission in priority order."""
        yield from self.critical_missions()

        # Find enemy SAM sites with ranges that cover friendly CPs, front lines,
        # or objects, plan DEAD.
        # Find enemy SAM sites with ranges that extend to within 50 nmi of
        # friendly CPs, front, lines, or objects, plan DEAD.
        for sam in self.objective_finder.threatening_air_defenses():
            flights = [ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE)]

            # Only include SEAD against SAMs that still have emitters. No need to
            # suppress an EWR, and SEAD isn't useful against a SAM that no longer has a
            # working track radar.
            #
            # For SAMs without track radars and EWRs, we still want a SEAD escort if
            # needed.
            #
            # Note that there is a quirk here: we should potentially be included a SEAD
            # escort *and* SEAD when the target is a radar SAM but the flight path is
            # also threatened by SAMs. We don't want to include a SEAD escort if the
            # package is *only* threatened by the target though. Could be improved, but
            # needs a decent refactor to the escort planning to do so.
            if sam.has_live_radar_sam:
                flights.append(ProposedFlight(FlightType.SEAD, 2, self.MAX_SEAD_RANGE))
            else:
                flights.append(
                    ProposedFlight(
                        FlightType.SEAD_ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.Sead
                    )
                )
            # TODO: Max escort range.
            flights.append(
                ProposedFlight(
                    FlightType.ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.AirToAir
                )
            )
            yield ProposedMission(sam, flights)

        # These will only rarely get planned. When a convoy is travelling multiple legs,
        # they're targetable after the first leg. The reason for this is that
        # procurement happens *after* mission planning so that the missions that could
        # not be filled will guide the procurement process. Procurement is the stage
        # that convoys are created (because they're created to move ground units that
        # were just purchased), so we haven't created any yet. Any incomplete transfers
        # from the previous turn (multi-leg journeys) will still be present though so
        # they can be targeted.
        #
        # Even after this is fixed, the player's convoys that were created through the
        # UI will never be targeted on the first turn of their journey because the AI
        # stops planning after the start of the turn. We could potentially fix this by
        # moving opfor mission planning until the takeoff button is pushed.
        for convoy in self.objective_finder.convoys():
            yield ProposedMission(
                convoy,
                [
                    ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE),
                    # TODO: Max escort range.
                    ProposedFlight(
                        FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
                    ),
                    ProposedFlight(
                        FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead
                    ),
                ],
            )

        for ship in self.objective_finder.cargo_ships():
            yield ProposedMission(
                ship,
                [
                    ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE),
                    # TODO: Max escort range.
                    ProposedFlight(
                        FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
                    ),
                    ProposedFlight(
                        FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead
                    ),
                ],
            )

        for group in self.objective_finder.threatening_ships():
            yield ProposedMission(
                group,
                [
                    ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE),
                    # TODO: Max escort range.
                    ProposedFlight(
                        FlightType.ESCORT,
                        2,
                        self.MAX_ANTISHIP_RANGE,
                        EscortType.AirToAir,
                    ),
                ],
            )

        for group in self.objective_finder.threatening_vehicle_groups():
            yield ProposedMission(
                group,
                [
                    ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE),
                    # TODO: Max escort range.
                    ProposedFlight(
                        FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
                    ),
                    ProposedFlight(
                        FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead
                    ),
                ],
            )

        for target in self.objective_finder.oca_targets(min_aircraft=20):
            flights = [
                ProposedFlight(FlightType.OCA_RUNWAY, 2, self.MAX_OCA_RANGE),
            ]
            if self.game.settings.default_start_type == "Cold":
                # Only schedule if the default start type is Cold. If the player
                # has set anything else there are no targets to hit.
                flights.append(
                    ProposedFlight(FlightType.OCA_AIRCRAFT, 2, self.MAX_OCA_RANGE)
                )
            flights.extend(
                [
                    # TODO: Max escort range.
                    ProposedFlight(
                        FlightType.ESCORT, 2, self.MAX_OCA_RANGE, EscortType.AirToAir
                    ),
                    ProposedFlight(
                        FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead
                    ),
                ]
            )
            yield ProposedMission(target, flights)

        # Plan strike missions.
        for target in self.objective_finder.strike_targets():
            yield ProposedMission(
                target,
                [
                    ProposedFlight(FlightType.STRIKE, 2, self.MAX_STRIKE_RANGE),
                    # TODO: Max escort range.
                    ProposedFlight(
                        FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE, EscortType.AirToAir
                    ),
                    ProposedFlight(
                        FlightType.SEAD_ESCORT,
                        2,
                        self.MAX_STRIKE_RANGE,
                        EscortType.Sead,
                    ),
                ],
            )

    def plan_missions(self) -> None:
        """Identifies and plans mission for the turn."""
        player = "Blue" if self.is_player else "Red"
        with logged_duration(f"{player} mission identification and fulfillment"):
            with MultiEventTracer() as tracer:
                for proposed_mission in self.propose_missions():
                    self.plan_mission(proposed_mission, tracer)

        with logged_duration(f"{player} reserve mission planning"):
            with MultiEventTracer() as tracer:
                for critical_mission in self.critical_missions():
                    self.plan_mission(critical_mission, tracer, reserves=True)

        with logged_duration(f"{player} mission scheduling"):
            self.stagger_missions()

        for cp in self.objective_finder.friendly_control_points():
            inventory = self.game.aircraft_inventory.for_control_point(cp)
            for aircraft, available in inventory.all_aircraft:
                self.message("Unused aircraft", f"{available} {aircraft} from {cp}")

    def plan_flight(
        self,
        mission: ProposedMission,
        flight: ProposedFlight,
        builder: PackageBuilder,
        missing_types: Set[FlightType],
        for_reserves: bool,
    ) -> None:
        if not builder.plan_flight(flight):
            missing_types.add(flight.task)
            purchase_order = AircraftProcurementRequest(
                near=mission.location,
                range=flight.max_distance,
                task_capability=flight.task,
                number=flight.num_aircraft,
            )
            if for_reserves:
                # Reserves are planned for critical missions, so prioritize
                # those orders over aircraft needed for non-critical missions.
                self.procurement_requests.insert(0, purchase_order)
            else:
                self.procurement_requests.append(purchase_order)

    def scrub_mission_missing_aircraft(
        self,
        mission: ProposedMission,
        builder: PackageBuilder,
        missing_types: Set[FlightType],
        not_attempted: Iterable[ProposedFlight],
        reserves: bool,
    ) -> None:
        # Try to plan the rest of the mission just so we can count the missing
        # types to buy.
        for flight in not_attempted:
            self.plan_flight(mission, flight, builder, missing_types, reserves)

        missing_types_str = ", ".join(sorted([t.name for t in missing_types]))
        builder.release_planned_aircraft()
        desc = "reserve aircraft" if reserves else "aircraft"
        self.message(
            "Insufficient aircraft",
            f"Not enough {desc} in range for {mission.location.name} "
            f"capable of: {missing_types_str}",
        )

    def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]:
        threats = defaultdict(bool)
        for flight in builder.package.flights:
            if self.threat_zones.waypoints_threatened_by_aircraft(
                flight.flight_plan.escorted_waypoints()
            ):
                threats[EscortType.AirToAir] = True
            if self.threat_zones.waypoints_threatened_by_radar_sam(
                list(flight.flight_plan.escorted_waypoints())
            ):
                threats[EscortType.Sead] = True
        return threats

    def plan_mission(
        self, mission: ProposedMission, tracer: MultiEventTracer, reserves: bool = False
    ) -> None:
        """Allocates aircraft for a proposed mission and adds it to the ATO."""
        builder = PackageBuilder(
            mission.location,
            self.objective_finder.closest_airfields_to(mission.location),
            self.game.aircraft_inventory,
            self.game.air_wing_for(self.is_player),
            self.is_player,
            self.game.country_for(self.is_player),
            self.game.settings.default_start_type,
            mission.asap,
        )

        # Attempt to plan all the main elements of the mission first. Escorts
        # will be planned separately so we can prune escorts for packages that
        # are not expected to encounter that type of threat.
        missing_types: Set[FlightType] = set()
        escorts = []
        for proposed_flight in mission.flights:
            if not self.air_wing_can_plan(proposed_flight.task):
                # This air wing can never plan this mission type because they do not
                # have compatible aircraft or squadrons. Skip fulfillment so that we
                # don't place the purchase request.
                continue
            if proposed_flight.escort_type is not None:
                # Escorts are planned after the primary elements of the package.
                # If the package does not need escorts they may be pruned.
                escorts.append(proposed_flight)
                continue
            with tracer.trace("Flight planning"):
                self.plan_flight(
                    mission, proposed_flight, builder, missing_types, reserves
                )

        if missing_types:
            self.scrub_mission_missing_aircraft(
                mission, builder, missing_types, escorts, reserves
            )
            return

        if not builder.package.flights:
            # The non-escort part of this mission is unplannable by this faction. Scrub
            # the mission and do not attempt planning escorts because there's no reason
            # to buy them because this mission will never be planned.
            return

        # Create flight plans for the main flights of the package so we can
        # determine threats. This is done *after* creating all of the flights
        # rather than as each flight is added because the flight plan for
        # flights that will rendezvous with their package will be affected by
        # the other flights in the package. Escorts will not be able to
        # contribute to this.
        flight_plan_builder = FlightPlanBuilder(
            self.game, builder.package, self.is_player
        )
        for flight in builder.package.flights:
            with tracer.trace("Flight plan population"):
                flight_plan_builder.populate_flight_plan(flight)

        needed_escorts = self.check_needed_escorts(builder)
        for escort in escorts:
            # This list was generated from the not None set, so this should be
            # impossible.
            assert escort.escort_type is not None
            if needed_escorts[escort.escort_type]:
                with tracer.trace("Flight planning"):
                    self.plan_flight(mission, escort, builder, missing_types, reserves)

        # Check again for unavailable aircraft. If the escort was required and
        # none were found, scrub the mission.
        if missing_types:
            self.scrub_mission_missing_aircraft(
                mission, builder, missing_types, escorts, reserves
            )
            return

        if reserves:
            # Mission is planned reserves which will not be used this turn.
            # Return reserves to the inventory.
            builder.release_planned_aircraft()
            return

        package = builder.build()
        # Add flight plans for escorts.
        for flight in package.flights:
            if not flight.flight_plan.waypoints:
                with tracer.trace("Flight plan population"):
                    flight_plan_builder.populate_flight_plan(flight)

        if package.has_players and self.game.settings.auto_ato_player_missions_asap:
            package.auto_asap = True
            package.set_tot_asap()

        self.ato.add_package(package)

    def stagger_missions(self) -> None:
        def start_time_generator(
            count: int, earliest: int, latest: int, margin: int
        ) -> Iterator[timedelta]:
            interval = (latest - earliest) // count
            for time in range(earliest, latest, interval):
                error = random.randint(-margin, margin)
                yield timedelta(seconds=max(0, time + error))

        dca_types = {
            FlightType.BARCAP,
            FlightType.TARCAP,
        }

        previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta)
        non_dca_packages = [
            p for p in self.ato.packages if p.primary_task not in dca_types
        ]

        start_time = start_time_generator(
            count=len(non_dca_packages),
            earliest=5 * 60,
            latest=int(
                self.game.settings.desired_player_mission_duration.total_seconds()
            ),
            margin=5 * 60,
        )
        for package in self.ato.packages:
            tot = TotEstimator(package).earliest_tot()
            if package.primary_task in dca_types:
                previous_end_time = previous_cap_end_time[package.target]
                if tot > previous_end_time:
                    # Can't get there exactly on time, so get there ASAP. This
                    # will typically only happen for the first CAP at each
                    # target.
                    package.time_over_target = tot
                else:
                    package.time_over_target = previous_end_time

                departure_time = package.mission_departure_time
                # Should be impossible for CAPs
                if departure_time is None:
                    logging.error(f"Could not determine mission end time for {package}")
                    continue
                previous_cap_end_time[package.target] = departure_time
            elif package.auto_asap:
                package.set_tot_asap()
            else:
                # But other packages should be spread out a bit. Note that take
                # times are delayed, but all aircraft will become active at
                # mission start. This makes it more worthwhile to attack enemy
                # airfields to hit grounded aircraft, since they're more likely
                # to be present. Runway and air started aircraft will be
                # delayed until their takeoff time by AirConflictGenerator.
                package.time_over_target = next(start_time) + tot

    def message(self, title: str, text: str) -> None:
        """Emits a planning message to the player.

        If the mission planner belongs to the players coalition, this emits a
        message to the info panel.
        """
        if self.is_player:
            self.game.informations.append(Information(title, text, self.game.turn))
        else:
            logging.info(f"{title}: {text}")
class ObjectiveFinder:
    """Identifies potential objectives for the mission planner."""

    # TODO: Merge into doctrine.
    AIRFIELD_THREAT_RANGE = nautical_miles(150)
    SAM_THREAT_RANGE = nautical_miles(100)

    def __init__(self, game: Game, is_player: bool) -> None:
        self.game = game
        self.is_player = is_player

    def enemy_air_defenses(self) -> Iterator[tuple[TheaterGroundObject[Any], Distance]]:
        """Iterates over all enemy SAM sites."""
        doctrine = self.game.faction_for(self.is_player).doctrine
        threat_zones = self.game.threat_zone_for(not self.is_player)
        for cp in self.enemy_control_points():
            for ground_object in cp.ground_objects:
                if ground_object.is_dead:
                    continue

                if isinstance(ground_object, EwrGroundObject):
                    if threat_zones.threatened_by_air_defense(ground_object):
                        # This is a very weak heuristic for determining whether the EWR
                        # is close enough to be worth targeting before a SAM that is
                        # covering it. Ingress distance corresponds to the beginning of
                        # the attack range and is sufficient for most standoff weapons,
                        # so treating the ingress distance as the threat distance sorts
                        # these EWRs such that they will be attacked before SAMs that do
                        # not threaten the ingress point, but after those that do.
                        target_range = doctrine.ingress_egress_distance
                    else:
                        # But if the EWR isn't covered then we should only be worrying
                        # about its detection range.
                        target_range = ground_object.max_detection_range()
                elif isinstance(ground_object, SamGroundObject):
                    target_range = ground_object.max_threat_range()
                else:
                    continue

                yield ground_object, target_range

    def threatening_air_defenses(self) -> Iterator[TheaterGroundObject[Any]]:
        """Iterates over enemy SAMs in threat range of friendly control points.

        SAM sites are sorted by their closest proximity to any friendly control
        point (airfield or fleet).
        """

        target_ranges: list[tuple[TheaterGroundObject[Any], Distance]] = []
        for target, threat_range in self.enemy_air_defenses():
            ranges: list[Distance] = []
            for cp in self.friendly_control_points():
                ranges.append(meters(target.distance_to(cp)) - threat_range)
            target_ranges.append((target, min(ranges)))

        target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
        for target, _range in target_ranges:
            yield target

    def enemy_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]:
        """Iterates over all enemy vehicle groups."""
        for cp in self.enemy_control_points():
            for ground_object in cp.ground_objects:
                if not isinstance(ground_object, VehicleGroupGroundObject):
                    continue

                if ground_object.is_dead:
                    continue

                yield ground_object

    def threatening_vehicle_groups(self) -> Iterator[MissionTarget]:
        """Iterates over enemy vehicle groups near friendly control points.

        Groups are sorted by their closest proximity to any friendly control
        point (airfield or fleet).
        """
        return self._targets_by_range(self.enemy_vehicle_groups())

    def enemy_ships(self) -> Iterator[NavalGroundObject]:
        for cp in self.enemy_control_points():
            for ground_object in cp.ground_objects:
                if not isinstance(ground_object, NavalGroundObject):
                    continue

                if ground_object.is_dead:
                    continue

                yield ground_object

    def threatening_ships(self) -> Iterator[MissionTarget]:
        """Iterates over enemy ships near friendly control points.

        Groups are sorted by their closest proximity to any friendly control
        point (airfield or fleet).
        """
        return self._targets_by_range(self.enemy_ships())

    def _targets_by_range(
        self, targets: Iterable[MissionTargetType]
    ) -> Iterator[MissionTargetType]:
        target_ranges: list[tuple[MissionTargetType, float]] = []
        for target in targets:
            ranges: list[float] = []
            for cp in self.friendly_control_points():
                ranges.append(target.distance_to(cp))
            target_ranges.append((target, min(ranges)))

        target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
        for target, _range in target_ranges:
            yield target

    def strike_targets(self) -> Iterator[TheaterGroundObject[Any]]:
        """Iterates over enemy strike targets.

        Targets are sorted by their closest proximity to any friendly control
        point (airfield or fleet).
        """
        targets: list[tuple[TheaterGroundObject[Any], float]] = []
        # Building objectives are made of several individual TGOs (one per
        # building).
        found_targets: Set[str] = set()
        for enemy_cp in self.enemy_control_points():
            for ground_object in enemy_cp.ground_objects:
                # TODO: Reuse ground_object.mission_types.
                # The mission types for ground objects are currently not
                # accurate because we include things like strike and BAI for all
                # targets since they have different planning behavior (waypoint
                # generation is better for players with strike when the targets
                # are stationary, AI behavior against weaker air defenses is
                # better with BAI), so that's not a useful filter. Once we have
                # better control over planning profiles and target dependent
                # loadouts we can clean this up.
                if isinstance(ground_object, VehicleGroupGroundObject):
                    # BAI target, not strike target.
                    continue

                if isinstance(ground_object, NavalGroundObject):
                    # Anti-ship target, not strike target.
                    continue

                if isinstance(ground_object, SamGroundObject):
                    # SAMs are targeted by DEAD. No need to double plan.
                    continue

                is_building = isinstance(ground_object, BuildingGroundObject)
                is_fob = isinstance(enemy_cp, Fob)
                if is_building and is_fob and ground_object.is_control_point:
                    # This is the FOB structure itself. Can't be repaired or
                    # targeted by the player, so shouldn't be targetable by the
                    # AI.
                    continue

                if ground_object.is_dead:
                    continue
                if ground_object.name in found_targets:
                    continue
                ranges: list[float] = []
                for friendly_cp in self.friendly_control_points():
                    ranges.append(ground_object.distance_to(friendly_cp))
                targets.append((ground_object, min(ranges)))
                found_targets.add(ground_object.name)
        targets = sorted(targets, key=operator.itemgetter(1))
        for target, _range in targets:
            yield target

    def front_lines(self) -> Iterator[FrontLine]:
        """Iterates over all active front lines in the theater."""
        yield from self.game.theater.conflicts()

    def vulnerable_control_points(self) -> Iterator[ControlPoint]:
        """Iterates over friendly CPs that are vulnerable to enemy CPs.

        Vulnerability is defined as any enemy CP within threat range of of the
        CP.
        """
        for cp in self.friendly_control_points():
            if isinstance(cp, OffMapSpawn):
                # Off-map spawn locations don't need protection.
                continue
            airfields_in_proximity = self.closest_airfields_to(cp)
            airfields_in_threat_range = (
                airfields_in_proximity.operational_airfields_within(
                    self.AIRFIELD_THREAT_RANGE
                )
            )
            for airfield in airfields_in_threat_range:
                if not airfield.is_friendly(self.is_player):
                    yield cp
                    break

    def oca_targets(self, min_aircraft: int) -> Iterator[MissionTarget]:
        airfields = []
        for control_point in self.enemy_control_points():
            if not isinstance(control_point, Airfield):
                continue
            if control_point.base.total_aircraft >= min_aircraft:
                airfields.append(control_point)
        return self._targets_by_range(airfields)

    def convoys(self) -> Iterator[Convoy]:
        for front_line in self.front_lines():
            yield from self.game.transfers.convoys.travelling_to(
                front_line.control_point_hostile_to(self.is_player)
            )

    def cargo_ships(self) -> Iterator[CargoShip]:
        for front_line in self.front_lines():
            yield from self.game.transfers.cargo_ships.travelling_to(
                front_line.control_point_hostile_to(self.is_player)
            )

    def friendly_control_points(self) -> Iterator[ControlPoint]:
        """Iterates over all friendly control points."""
        return (
            c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player)
        )

    def farthest_friendly_control_point(self) -> ControlPoint:
        """Finds the friendly control point that is farthest from any threats."""
        threat_zones = self.game.threat_zone_for(not self.is_player)

        farthest = None
        max_distance = meters(0)
        for cp in self.friendly_control_points():
            if isinstance(cp, OffMapSpawn):
                continue
            distance = threat_zones.distance_to_threat(cp.position)
            if distance > max_distance:
                farthest = cp
                max_distance = distance

        if farthest is None:
            raise RuntimeError("Found no friendly control points. You probably lost.")
        return farthest

    def closest_friendly_control_point(self) -> ControlPoint:
        """Finds the friendly control point that is closest to any threats."""
        threat_zones = self.game.threat_zone_for(not self.is_player)

        closest = None
        min_distance = meters(math.inf)
        for cp in self.friendly_control_points():
            if isinstance(cp, OffMapSpawn):
                continue
            distance = threat_zones.distance_to_threat(cp.position)
            if distance < min_distance:
                closest = cp
                min_distance = distance

        if closest is None:
            raise RuntimeError("Found no friendly control points. You probably lost.")
        return closest

    def enemy_control_points(self) -> Iterator[ControlPoint]:
        """Iterates over all enemy control points."""
        return (
            c
            for c in self.game.theater.controlpoints
            if not c.is_friendly(self.is_player)
        )

    def all_possible_targets(self) -> Iterator[MissionTarget]:
        """Iterates over all possible mission targets in the theater.

        Valid mission targets are control points (airfields and carriers), front
        lines, and ground objects (SAM sites, factories, resource extraction
        sites, etc).
        """
        for cp in self.game.theater.controlpoints:
            yield cp
            yield from cp.ground_objects
        yield from self.front_lines()

    @staticmethod
    def closest_airfields_to(location: MissionTarget) -> ClosestAirfields:
        """Returns the closest airfields to the given location."""
        return ObjectiveDistanceCache.get_closest_airfields(location)
Example #19
0
    def __init__(
        self,
        target: Point,
        home: Point,
        coalition: Coalition,
    ) -> None:
        self._target = target
        self.threat_zone = coalition.opponent.threat_zone.all
        self.home = ShapelyPoint(home.x, home.y)

        max_ip_distance = coalition.doctrine.max_ingress_distance
        min_ip_distance = coalition.doctrine.min_ingress_distance

        # The minimum distance between the home location and the IP.
        min_distance_from_home = nautical_miles(5)

        # The distance that is expected to be needed between the beginning of the attack
        # and weapon release. This buffers the threat zone to give a 5nm window between
        # the edge of the "safe" zone and the actual threat so that "safe" IPs are less
        # likely to end up with the attacker entering a threatened area.
        attack_distance_buffer = nautical_miles(5)

        home_threatened = coalition.opponent.threat_zone.threatened(home)

        shapely_target = ShapelyPoint(target.x, target.y)
        home_to_target_distance = meters(home.distance_to_point(target))

        self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference(
            self.home.buffer(min_distance_from_home.meters)
        )

        # If the home zone is not threatened and home is within LAR, constrain the max
        # range to the home-to-target distance to prevent excessive backtracking.
        #
        # If the home zone *is* threatened, we need to back out of the zone to
        # rendezvous anyway.
        if not home_threatened and (
            min_ip_distance < home_to_target_distance < max_ip_distance
        ):
            max_ip_distance = home_to_target_distance
        max_ip_bubble = shapely_target.buffer(max_ip_distance.meters)
        min_ip_bubble = shapely_target.buffer(min_ip_distance.meters)
        self.ip_bubble = max_ip_bubble.difference(min_ip_bubble)

        # The intersection of the home bubble and IP bubble will be all the points that
        # are within the valid IP range that are not farther from home than the target
        # is. However, if the origin airfield is threatened but there are safe
        # placements for the IP, we should not constrain to the home zone. In this case
        # we'll either end up with a safe zone outside the home zone and pick the
        # closest point in to to home (minimizing backtracking), or we'll have no safe
        # IP anywhere within range of the target, and we'll later pick the IP nearest
        # the edge of the threat zone.
        if home_threatened:
            self.permissible_zone = self.ip_bubble
        else:
            self.permissible_zone = self.ip_bubble.intersection(self.home_bubble)

        if self.permissible_zone.is_empty:
            # If home is closer to the target than the min range, there will not be an
            # IP solution that's close enough to home, in which case we need to ignore
            # the home bubble.
            self.permissible_zone = self.ip_bubble

        safe_zones = self.permissible_zone.difference(
            self.threat_zone.buffer(attack_distance_buffer.meters)
        )

        if not isinstance(safe_zones, MultiPolygon):
            safe_zones = MultiPolygon([safe_zones])
        self.safe_zones = safe_zones
Example #20
0
    def _each_variant_of(cls,
                         aircraft: Type[FlyingType]) -> Iterator[AircraftType]:
        data_path = Path("resources/units/aircraft") / f"{aircraft.id}.yaml"
        if not data_path.exists():
            logging.warning(
                f"No data for {aircraft.id}; it will not be available")
            return

        with data_path.open(encoding="utf-8") as data_file:
            data = yaml.safe_load(data_file)

        try:
            price = data["price"]
        except KeyError as ex:
            raise KeyError(
                f"Missing required price field: {data_path}") from ex

        radio_config = RadioConfig.from_data(data.get("radios", {}))
        patrol_config = PatrolConfig.from_data(data.get("patrol", {}))

        try:
            mission_range = nautical_miles(int(data["max_range"]))
        except (KeyError, ValueError):
            mission_range = (nautical_miles(50)
                             if aircraft.helicopter else nautical_miles(150))
            logging.warning(
                f"{aircraft.id} does not specify a max_range. Defaulting to "
                f"{mission_range.nautical_miles}NM")

        fuel_data = data.get("fuel")
        if fuel_data is not None:
            fuel_consumption: Optional[
                FuelConsumption] = FuelConsumption.from_data(fuel_data)
        else:
            fuel_consumption = None

        try:
            introduction = data["introduced"]
            if introduction is None:
                introduction = "N/A"
        except KeyError:
            introduction = "No data."

        units_data = data.get("kneeboard_units", "nautical").lower()
        units: UnitSystem = NauticalUnits()
        if units_data == "imperial":
            units = ImperialUnits()
        if units_data == "metric":
            units = MetricUnits()

        prop_overrides = data.get("default_overrides")
        if prop_overrides is not None:
            cls._set_props_overrides(prop_overrides, aircraft, data_path)

        for variant in data.get("variants", [aircraft.id]):
            yield AircraftType(
                dcs_unit_type=aircraft,
                name=variant,
                description=data.get(
                    "description",
                    f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
                ),
                year_introduced=introduction,
                country_of_origin=data.get("origin", "No data."),
                manufacturer=data.get("manufacturer", "No data."),
                role=data.get("role", "No data."),
                price=price,
                carrier_capable=data.get("carrier_capable", False),
                lha_capable=data.get("lha_capable", False),
                always_keeps_gun=data.get("always_keeps_gun", False),
                gunfighter=data.get("gunfighter", False),
                max_group_size=data.get("max_group_size",
                                        aircraft.group_size_max),
                patrol_altitude=patrol_config.altitude,
                patrol_speed=patrol_config.speed,
                max_mission_range=mission_range,
                fuel_consumption=fuel_consumption,
                default_livery=data.get("default_livery"),
                intra_flight_radio=radio_config.intra_flight,
                channel_allocator=radio_config.channel_allocator,
                channel_namer=radio_config.channel_namer,
                kneeboard_units=units,
                utc_kneeboard=data.get("utc_kneeboard", False),
                unit_class=UnitClass.PLANE,
            )
 def setGame(self, game: Optional[Game]):
     self.game = game
     if self.game is not None:
         logging.debug("Reloading Map Canvas")
         self.nm_to_pixel_ratio = self.distance_to_pixels(nautical_miles(1))
         self.reload_scene()
class ObjectiveFinder:
    """Identifies potential objectives for the mission planner."""

    # TODO: Merge into doctrine.
    AIRFIELD_THREAT_RANGE = nautical_miles(150)
    SAM_THREAT_RANGE = nautical_miles(100)

    def __init__(self, game: Game, is_player: bool) -> None:
        self.game = game
        self.is_player = is_player

    def enemy_air_defenses(self) -> Iterator[IadsGroundObject]:
        """Iterates over all enemy SAM sites."""
        for cp in self.enemy_control_points():
            for ground_object in cp.ground_objects:
                if ground_object.is_dead:
                    continue

                if isinstance(ground_object, IadsGroundObject):
                    yield ground_object

    def enemy_ships(self) -> Iterator[NavalGroundObject]:
        for cp in self.enemy_control_points():
            for ground_object in cp.ground_objects:
                if not isinstance(ground_object, NavalGroundObject):
                    continue

                if ground_object.is_dead:
                    continue

                yield ground_object

    def threatening_ships(self) -> Iterator[NavalGroundObject]:
        """Iterates over enemy ships near friendly control points.

        Groups are sorted by their closest proximity to any friendly control
        point (airfield or fleet).
        """
        return self._targets_by_range(self.enemy_ships())

    def _targets_by_range(
        self, targets: Iterable[MissionTargetType]
    ) -> Iterator[MissionTargetType]:
        target_ranges: list[tuple[MissionTargetType, float]] = []
        for target in targets:
            ranges: list[float] = []
            for cp in self.friendly_control_points():
                ranges.append(target.distance_to(cp))
            target_ranges.append((target, min(ranges)))

        target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
        for target, _range in target_ranges:
            yield target

    def strike_targets(self) -> Iterator[BuildingGroundObject]:
        """Iterates over enemy strike targets.

        Targets are sorted by their closest proximity to any friendly control
        point (airfield or fleet).
        """
        targets: list[tuple[BuildingGroundObject, float]] = []
        # Building objectives are made of several individual TGOs (one per
        # building).
        found_targets: set[str] = set()
        for enemy_cp in self.enemy_control_points():
            for ground_object in enemy_cp.ground_objects:
                # TODO: Reuse ground_object.mission_types.
                # The mission types for ground objects are currently not
                # accurate because we include things like strike and BAI for all
                # targets since they have different planning behavior (waypoint
                # generation is better for players with strike when the targets
                # are stationary, AI behavior against weaker air defenses is
                # better with BAI), so that's not a useful filter. Once we have
                # better control over planning profiles and target dependent
                # loadouts we can clean this up.
                if not isinstance(ground_object, BuildingGroundObject):
                    # Other group types (like ships, SAMs, garrisons, etc) have better
                    # suited mission types like anti-ship, DEAD, and BAI.
                    continue

                if isinstance(enemy_cp, Fob) and ground_object.is_control_point:
                    # This is the FOB structure itself. Can't be repaired or
                    # targeted by the player, so shouldn't be targetable by the
                    # AI.
                    continue

                if ground_object.is_dead:
                    continue
                if ground_object.name in found_targets:
                    continue
                ranges: list[float] = []
                for friendly_cp in self.friendly_control_points():
                    ranges.append(ground_object.distance_to(friendly_cp))
                targets.append((ground_object, min(ranges)))
                found_targets.add(ground_object.name)
        targets = sorted(targets, key=operator.itemgetter(1))
        for target, _range in targets:
            yield target

    def front_lines(self) -> Iterator[FrontLine]:
        """Iterates over all active front lines in the theater."""
        yield from self.game.theater.conflicts()

    def vulnerable_control_points(self) -> Iterator[ControlPoint]:
        """Iterates over friendly CPs that are vulnerable to enemy CPs.

        Vulnerability is defined as any enemy CP within threat range of of the
        CP.
        """
        for cp in self.friendly_control_points():
            if isinstance(cp, OffMapSpawn):
                # Off-map spawn locations don't need protection.
                continue
            airfields_in_proximity = self.closest_airfields_to(cp)
            airfields_in_threat_range = (
                airfields_in_proximity.operational_airfields_within(
                    self.AIRFIELD_THREAT_RANGE
                )
            )
            for airfield in airfields_in_threat_range:
                if not airfield.is_friendly(self.is_player):
                    yield cp
                    break

    def oca_targets(self, min_aircraft: int) -> Iterator[ControlPoint]:
        airfields = []
        for control_point in self.enemy_control_points():
            if not isinstance(control_point, Airfield):
                continue
            if control_point.allocated_aircraft().total_present >= min_aircraft:
                airfields.append(control_point)
        return self._targets_by_range(airfields)

    def convoys(self) -> Iterator[Convoy]:
        for front_line in self.front_lines():
            yield from self.game.coalition_for(
                self.is_player
            ).transfers.convoys.travelling_to(
                front_line.control_point_hostile_to(self.is_player)
            )

    def cargo_ships(self) -> Iterator[CargoShip]:
        for front_line in self.front_lines():
            yield from self.game.coalition_for(
                self.is_player
            ).transfers.cargo_ships.travelling_to(
                front_line.control_point_hostile_to(self.is_player)
            )

    def friendly_control_points(self) -> Iterator[ControlPoint]:
        """Iterates over all friendly control points."""
        return (
            c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player)
        )

    def farthest_friendly_control_point(self) -> ControlPoint:
        """Finds the friendly control point that is farthest from any threats."""
        threat_zones = self.game.threat_zone_for(not self.is_player)

        farthest = None
        max_distance = meters(0)
        for cp in self.friendly_control_points():
            if isinstance(cp, OffMapSpawn):
                continue
            distance = threat_zones.distance_to_threat(cp.position)
            if distance > max_distance:
                farthest = cp
                max_distance = distance

        if farthest is None:
            raise RuntimeError("Found no friendly control points. You probably lost.")
        return farthest

    def closest_friendly_control_point(self) -> ControlPoint:
        """Finds the friendly control point that is closest to any threats."""
        threat_zones = self.game.threat_zone_for(not self.is_player)

        closest = None
        min_distance = meters(math.inf)
        for cp in self.friendly_control_points():
            if isinstance(cp, OffMapSpawn):
                continue
            distance = threat_zones.distance_to_threat(cp.position)
            if distance < min_distance:
                closest = cp
                min_distance = distance

        if closest is None:
            raise RuntimeError("Found no friendly control points. You probably lost.")
        return closest

    def enemy_control_points(self) -> Iterator[ControlPoint]:
        """Iterates over all enemy control points."""
        return (
            c
            for c in self.game.theater.controlpoints
            if not c.is_friendly(self.is_player)
        )

    def prioritized_unisolated_points(self) -> list[ControlPoint]:
        prioritized = []
        capturable_later = []
        for cp in self.game.theater.control_points_for(not self.is_player):
            if cp.is_isolated:
                continue
            if cp.has_active_frontline:
                prioritized.append(cp)
            else:
                capturable_later.append(cp)
        prioritized.extend(self._targets_by_range(capturable_later))
        return prioritized

    @staticmethod
    def closest_airfields_to(location: MissionTarget) -> ClosestAirfields:
        """Returns the closest airfields to the given location."""
        return ObjectiveDistanceCache.get_closest_airfields(location)
Example #23
0
    cas_duration: timedelta

    sweep_distance: Distance

    ground_unit_procurement_ratios: GroundUnitProcurementRatios


MODERN_DOCTRINE = Doctrine(
    cap=True,
    cas=True,
    sead=True,
    strike=True,
    antiship=True,
    rendezvous_altitude=feet(25000),
    hold_distance=nautical_miles(15),
    push_distance=nautical_miles(20),
    join_distance=nautical_miles(20),
    split_distance=nautical_miles(20),
    ingress_egress_distance=nautical_miles(45),
    ingress_altitude=feet(20000),
    egress_altitude=feet(20000),
    min_patrol_altitude=feet(15000),
    max_patrol_altitude=feet(33000),
    pattern_altitude=feet(5000),
    cap_duration=timedelta(minutes=30),
    cap_min_track_length=nautical_miles(15),
    cap_max_track_length=nautical_miles(40),
    cap_min_distance_from_cp=nautical_miles(10),
    cap_max_distance_from_cp=nautical_miles(40),
    cap_engagement_range=nautical_miles(50),
Example #24
0
class CoalitionMissionPlanner:
    """Coalition flight planning AI.

    This class is responsible for automatically planning missions for the
    coalition at the start of the turn.

    The primary goal of the mission planner is to protect existing friendly
    assets. Missions will be planned with the following priorities:

    1. CAP for airfields/fleets in close proximity to the enemy to prevent heavy
       losses of friendly aircraft.
    2. CAP for front line areas to protect ground and CAS units.
    3. DEAD to reduce necessity of SEAD for future missions.
    4. CAS to protect friendly ground units.
    5. Strike missions to reduce the enemy's resources.

    TODO: Anti-ship and airfield strikes to reduce enemy sortie rates.
    TODO: BAI to prevent enemy forces from reaching the front line.
    TODO: Should fleets always have a CAP?

    TODO: Stance and doctrine-specific planning behavior.
    """

    # TODO: Merge into doctrine, also limit by aircraft.
    MAX_CAP_RANGE = nautical_miles(100)
    MAX_CAS_RANGE = nautical_miles(50)
    MAX_ANTISHIP_RANGE = nautical_miles(150)
    MAX_BAI_RANGE = nautical_miles(150)
    MAX_OCA_RANGE = nautical_miles(150)
    MAX_SEAD_RANGE = nautical_miles(150)
    MAX_STRIKE_RANGE = nautical_miles(150)
    MAX_AWEC_RANGE = nautical_miles(200)

    def __init__(self, game: Game, is_player: bool) -> None:
        self.game = game
        self.is_player = is_player
        self.objective_finder = ObjectiveFinder(self.game, self.is_player)
        self.ato = self.game.blue_ato if is_player else self.game.red_ato
        self.threat_zones = self.game.threat_zone_for(not self.is_player)
        self.procurement_requests: List[AircraftProcurementRequest] = []

    def critical_missions(self) -> Iterator[ProposedMission]:
        """Identifies the most important missions to plan this turn.

        Non-critical missions that cannot be fulfilled will create purchase
        orders for the next turn. Critical missions will create a purchase order
        unless the mission can be doubly fulfilled. In other words, the AI will
        attempt to have *double* the aircraft it needs for these missions to
        ensure that they can be planned again next turn even if all aircraft are
        eliminated this turn.
        """

        # Find farthest, friendly CP for AEWC
        cp = self.objective_finder.farthest_friendly_control_point()
        yield ProposedMission(
            cp, [ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)])

        # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP.
        for cp in self.objective_finder.vulnerable_control_points():
            # Plan three rounds of CAP to give ~90 minutes coverage. Spacing
            # these out appropriately is done in stagger_missions.
            yield ProposedMission(
                cp,
                [
                    ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
                ],
            )
            yield ProposedMission(
                cp,
                [
                    ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
                ],
            )
            yield ProposedMission(
                cp,
                [
                    ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
                ],
            )

        # Find front lines, plan CAS.
        for front_line in self.objective_finder.front_lines():
            yield ProposedMission(
                front_line,
                [
                    ProposedFlight(FlightType.CAS, 2, self.MAX_CAS_RANGE),
                    ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE,
                                   EscortType.AirToAir),
                ],
            )

    def propose_missions(self) -> Iterator[ProposedMission]:
        """Identifies and iterates over potential mission in priority order."""
        yield from self.critical_missions()

        # Find enemy SAM sites with ranges that cover friendly CPs, front lines,
        # or objects, plan DEAD.
        # Find enemy SAM sites with ranges that extend to within 50 nmi of
        # friendly CPs, front, lines, or objects, plan DEAD.
        for sam in self.objective_finder.threatening_sams():
            yield ProposedMission(
                sam,
                [
                    ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE),
                    # TODO: Max escort range.
                    ProposedFlight(FlightType.ESCORT, 2, self.MAX_SEAD_RANGE,
                                   EscortType.AirToAir),
                ],
            )

        for group in self.objective_finder.threatening_ships():
            yield ProposedMission(
                group,
                [
                    ProposedFlight(FlightType.ANTISHIP, 2,
                                   self.MAX_ANTISHIP_RANGE),
                    # TODO: Max escort range.
                    ProposedFlight(
                        FlightType.ESCORT,
                        2,
                        self.MAX_ANTISHIP_RANGE,
                        EscortType.AirToAir,
                    ),
                ],
            )

        for group in self.objective_finder.threatening_vehicle_groups():
            yield ProposedMission(
                group,
                [
                    ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE),
                    # TODO: Max escort range.
                    ProposedFlight(FlightType.ESCORT, 2, self.MAX_BAI_RANGE,
                                   EscortType.AirToAir),
                    ProposedFlight(FlightType.SEAD, 2, self.MAX_OCA_RANGE,
                                   EscortType.Sead),
                ],
            )

        for target in self.objective_finder.oca_targets(min_aircraft=20):
            flights = [
                ProposedFlight(FlightType.OCA_RUNWAY, 2, self.MAX_OCA_RANGE),
            ]
            if self.game.settings.default_start_type == "Cold":
                # Only schedule if the default start type is Cold. If the player
                # has set anything else there are no targets to hit.
                flights.append(
                    ProposedFlight(FlightType.OCA_AIRCRAFT, 2,
                                   self.MAX_OCA_RANGE))
            flights.extend([
                # TODO: Max escort range.
                ProposedFlight(FlightType.ESCORT, 2, self.MAX_OCA_RANGE,
                               EscortType.AirToAir),
                ProposedFlight(FlightType.SEAD, 2, self.MAX_OCA_RANGE,
                               EscortType.Sead),
            ])
            yield ProposedMission(target, flights)

        # Plan strike missions.
        for target in self.objective_finder.strike_targets():
            yield ProposedMission(
                target,
                [
                    ProposedFlight(FlightType.STRIKE, 2,
                                   self.MAX_STRIKE_RANGE),
                    # TODO: Max escort range.
                    ProposedFlight(FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE,
                                   EscortType.AirToAir),
                    ProposedFlight(FlightType.SEAD, 2, self.MAX_STRIKE_RANGE,
                                   EscortType.Sead),
                ],
            )

    def plan_missions(self) -> None:
        """Identifies and plans mission for the turn."""
        for proposed_mission in self.propose_missions():
            self.plan_mission(proposed_mission)

        for critical_mission in self.critical_missions():
            self.plan_mission(critical_mission, reserves=True)

        self.stagger_missions()

        for cp in self.objective_finder.friendly_control_points():
            inventory = self.game.aircraft_inventory.for_control_point(cp)
            for aircraft, available in inventory.all_aircraft:
                self.message("Unused aircraft",
                             f"{available} {aircraft.id} from {cp}")

    def plan_flight(
        self,
        mission: ProposedMission,
        flight: ProposedFlight,
        builder: PackageBuilder,
        missing_types: Set[FlightType],
        for_reserves: bool,
    ) -> None:
        if not builder.plan_flight(flight):
            missing_types.add(flight.task)
            purchase_order = AircraftProcurementRequest(
                near=mission.location,
                range=flight.max_distance,
                task_capability=flight.task,
                number=flight.num_aircraft,
            )
            if for_reserves:
                # Reserves are planned for critical missions, so prioritize
                # those orders over aircraft needed for non-critical missions.
                self.procurement_requests.insert(0, purchase_order)
            else:
                self.procurement_requests.append(purchase_order)

    def scrub_mission_missing_aircraft(
        self,
        mission: ProposedMission,
        builder: PackageBuilder,
        missing_types: Set[FlightType],
        not_attempted: Iterable[ProposedFlight],
        reserves: bool,
    ) -> None:
        # Try to plan the rest of the mission just so we can count the missing
        # types to buy.
        for flight in not_attempted:
            self.plan_flight(mission, flight, builder, missing_types, reserves)

        missing_types_str = ", ".join(sorted([t.name for t in missing_types]))
        builder.release_planned_aircraft()
        desc = "reserve aircraft" if reserves else "aircraft"
        self.message(
            "Insufficient aircraft",
            f"Not enough {desc} in range for {mission.location.name} "
            f"capable of: {missing_types_str}",
        )

    def check_needed_escorts(
            self, builder: PackageBuilder) -> Dict[EscortType, bool]:
        threats = defaultdict(bool)
        for flight in builder.package.flights:
            if self.threat_zones.threatened_by_aircraft(flight):
                threats[EscortType.AirToAir] = True
            if self.threat_zones.threatened_by_air_defense(flight):
                threats[EscortType.Sead] = True
        return threats

    def plan_mission(self,
                     mission: ProposedMission,
                     reserves: bool = False) -> None:
        """Allocates aircraft for a proposed mission and adds it to the ATO."""

        if self.is_player:
            package_country = self.game.player_country
        else:
            package_country = self.game.enemy_country

        builder = PackageBuilder(
            mission.location,
            self.objective_finder.closest_airfields_to(mission.location),
            self.game.aircraft_inventory,
            self.is_player,
            package_country,
            self.game.settings.default_start_type,
        )

        # Attempt to plan all the main elements of the mission first. Escorts
        # will be planned separately so we can prune escorts for packages that
        # are not expected to encounter that type of threat.
        missing_types: Set[FlightType] = set()
        escorts = []
        for proposed_flight in mission.flights:
            if proposed_flight.escort_type is not None:
                # Escorts are planned after the primary elements of the package.
                # If the package does not need escorts they may be pruned.
                escorts.append(proposed_flight)
                continue
            self.plan_flight(mission, proposed_flight, builder, missing_types,
                             reserves)

        if missing_types:
            self.scrub_mission_missing_aircraft(mission, builder,
                                                missing_types, escorts,
                                                reserves)
            return

        # Create flight plans for the main flights of the package so we can
        # determine threats. This is done *after* creating all of the flights
        # rather than as each flight is added because the flight plan for
        # flights that will rendezvous with their package will be affected by
        # the other flights in the package. Escorts will not be able to
        # contribute to this.
        flight_plan_builder = FlightPlanBuilder(self.game, builder.package,
                                                self.is_player)
        for flight in builder.package.flights:
            flight_plan_builder.populate_flight_plan(flight)

        needed_escorts = self.check_needed_escorts(builder)
        for escort in escorts:
            # This list was generated from the not None set, so this should be
            # impossible.
            assert escort.escort_type is not None
            if needed_escorts[escort.escort_type]:
                self.plan_flight(mission, escort, builder, missing_types,
                                 reserves)

        # Check again for unavailable aircraft. If the escort was required and
        # none were found, scrub the mission.
        if missing_types:
            self.scrub_mission_missing_aircraft(mission, builder,
                                                missing_types, escorts,
                                                reserves)
            return

        if reserves:
            # Mission is planned reserves which will not be used this turn.
            # Return reserves to the inventory.
            builder.release_planned_aircraft()
            return

        package = builder.build()
        # Add flight plans for escorts.
        for flight in package.flights:
            if not flight.flight_plan.waypoints:
                flight_plan_builder.populate_flight_plan(flight)
        self.ato.add_package(package)

    def stagger_missions(self) -> None:
        def start_time_generator(count: int, earliest: int, latest: int,
                                 margin: int) -> Iterator[timedelta]:
            interval = (latest - earliest) // count
            for time in range(earliest, latest, interval):
                error = random.randint(-margin, margin)
                yield timedelta(minutes=max(0, time + error))

        dca_types = {
            FlightType.BARCAP,
            FlightType.TARCAP,
        }

        previous_cap_end_time: Dict[MissionTarget,
                                    timedelta] = defaultdict(timedelta)
        non_dca_packages = [
            p for p in self.ato.packages if p.primary_task not in dca_types
        ]

        start_time = start_time_generator(count=len(non_dca_packages),
                                          earliest=5,
                                          latest=90,
                                          margin=5)
        for package in self.ato.packages:
            tot = TotEstimator(package).earliest_tot()
            if package.primary_task in dca_types:
                previous_end_time = previous_cap_end_time[package.target]
                if tot > previous_end_time:
                    # Can't get there exactly on time, so get there ASAP. This
                    # will typically only happen for the first CAP at each
                    # target.
                    package.time_over_target = tot
                else:
                    package.time_over_target = previous_end_time

                departure_time = package.mission_departure_time
                # Should be impossible for CAPs
                if departure_time is None:
                    logging.error(
                        f"Could not determine mission end time for {package}")
                    continue
                previous_cap_end_time[package.target] = departure_time
            else:
                # But other packages should be spread out a bit. Note that take
                # times are delayed, but all aircraft will become active at
                # mission start. This makes it more worthwhile to attack enemy
                # airfields to hit grounded aircraft, since they're more likely
                # to be present. Runway and air started aircraft will be
                # delayed until their takeoff time by AirConflictGenerator.
                package.time_over_target = next(start_time) + tot

    def message(self, title, text) -> None:
        """Emits a planning message to the player.

        If the mission planner belongs to the players coalition, this emits a
        message to the info panel.
        """
        if self.is_player:
            self.game.informations.append(
                Information(title, text, self.game.turn))
        else:
            logging.info(f"{title}: {text}")
Example #25
0
class ObjectiveFinder:
    """Identifies potential objectives for the mission planner."""

    # TODO: Merge into doctrine.
    AIRFIELD_THREAT_RANGE = nautical_miles(150)
    SAM_THREAT_RANGE = nautical_miles(100)

    def __init__(self, game: Game, is_player: bool) -> None:
        self.game = game
        self.is_player = is_player

    def enemy_sams(self) -> Iterator[TheaterGroundObject]:
        """Iterates over all enemy SAM sites."""
        # Control points might have the same ground object several times, for
        # some reason.
        found_targets: Set[str] = set()
        for cp in self.enemy_control_points():
            for ground_object in cp.ground_objects:
                is_ewr = isinstance(ground_object, EwrGroundObject)
                is_sam = isinstance(ground_object, SamGroundObject)
                if not is_ewr and not is_sam:
                    continue

                if ground_object.is_dead:
                    continue

                if ground_object.name in found_targets:
                    continue

                if not ground_object.has_radar:
                    continue

                # TODO: Yield in order of most threatening.
                # Need to sort in order of how close their defensive range comes
                # to friendly assets. To do that we need to add effective range
                # information to the database.
                yield ground_object
                found_targets.add(ground_object.name)

    def threatening_sams(self) -> Iterator[MissionTarget]:
        """Iterates over enemy SAMs in threat range of friendly control points.

        SAM sites are sorted by their closest proximity to any friendly control
        point (airfield or fleet).
        """
        return self._targets_by_range(self.enemy_sams())

    def enemy_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]:
        """Iterates over all enemy vehicle groups."""
        for cp in self.enemy_control_points():
            for ground_object in cp.ground_objects:
                if not isinstance(ground_object, VehicleGroupGroundObject):
                    continue

                if ground_object.is_dead:
                    continue

                yield ground_object

    def threatening_vehicle_groups(self) -> Iterator[MissionTarget]:
        """Iterates over enemy vehicle groups near friendly control points.

        Groups are sorted by their closest proximity to any friendly control
        point (airfield or fleet).
        """
        return self._targets_by_range(self.enemy_vehicle_groups())

    def enemy_ships(self) -> Iterator[NavalGroundObject]:
        for cp in self.enemy_control_points():
            for ground_object in cp.ground_objects:
                if not isinstance(ground_object, NavalGroundObject):
                    continue

                if ground_object.is_dead:
                    continue

                yield ground_object

    def threatening_ships(self) -> Iterator[MissionTarget]:
        """Iterates over enemy ships near friendly control points.

        Groups are sorted by their closest proximity to any friendly control
        point (airfield or fleet).
        """
        return self._targets_by_range(self.enemy_ships())

    def _targets_by_range(
            self, targets: Iterable[MissionTarget]) -> Iterator[MissionTarget]:
        target_ranges: List[Tuple[MissionTarget, int]] = []
        for target in targets:
            ranges: List[int] = []
            for cp in self.friendly_control_points():
                ranges.append(target.distance_to(cp))
            target_ranges.append((target, min(ranges)))

        target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
        for target, _range in target_ranges:
            yield target

    def strike_targets(self) -> Iterator[TheaterGroundObject]:
        """Iterates over enemy strike targets.

        Targets are sorted by their closest proximity to any friendly control
        point (airfield or fleet).
        """
        targets: List[Tuple[TheaterGroundObject, int]] = []
        # Building objectives are made of several individual TGOs (one per
        # building).
        found_targets: Set[str] = set()
        for enemy_cp in self.enemy_control_points():
            for ground_object in enemy_cp.ground_objects:
                # TODO: Reuse ground_object.mission_types.
                # The mission types for ground objects are currently not
                # accurate because we include things like strike and BAI for all
                # targets since they have different planning behavior (waypoint
                # generation is better for players with strike when the targets
                # are stationary, AI behavior against weaker air defenses is
                # better with BAI), so that's not a useful filter. Once we have
                # better control over planning profiles and target dependent
                # loadouts we can clean this up.
                if isinstance(ground_object, VehicleGroupGroundObject):
                    # BAI target, not strike target.
                    continue

                if isinstance(ground_object, NavalGroundObject):
                    # Anti-ship target, not strike target.
                    continue

                if isinstance(ground_object, SamGroundObject):
                    # SAMs are targeted by DEAD. No need to double plan.
                    continue

                is_building = isinstance(ground_object, BuildingGroundObject)
                is_fob = isinstance(enemy_cp, Fob)
                if is_building and is_fob and ground_object.airbase_group:
                    # This is the FOB structure itself. Can't be repaired or
                    # targeted by the player, so shouldn't be targetable by the
                    # AI.
                    continue

                if ground_object.is_dead:
                    continue
                if ground_object.name in found_targets:
                    continue
                ranges: List[int] = []
                for friendly_cp in self.friendly_control_points():
                    ranges.append(ground_object.distance_to(friendly_cp))
                targets.append((ground_object, min(ranges)))
                found_targets.add(ground_object.name)
        targets = sorted(targets, key=operator.itemgetter(1))
        for target, _range in targets:
            yield target

    def front_lines(self) -> Iterator[FrontLine]:
        """Iterates over all active front lines in the theater."""
        for cp in self.friendly_control_points():
            for connected in cp.connected_points:
                if connected.is_friendly(self.is_player):
                    continue

                if Conflict.has_frontline_between(cp, connected):
                    yield FrontLine(cp, connected, self.game.theater)

    def vulnerable_control_points(self) -> Iterator[ControlPoint]:
        """Iterates over friendly CPs that are vulnerable to enemy CPs.

        Vulnerability is defined as any enemy CP within threat range of of the
        CP.
        """
        for cp in self.friendly_control_points():
            if isinstance(cp, OffMapSpawn):
                # Off-map spawn locations don't need protection.
                continue
            airfields_in_proximity = self.closest_airfields_to(cp)
            airfields_in_threat_range = airfields_in_proximity.airfields_within(
                self.AIRFIELD_THREAT_RANGE)
            for airfield in airfields_in_threat_range:
                if not airfield.is_friendly(self.is_player):
                    yield cp
                    break

    def oca_targets(self, min_aircraft: int) -> Iterator[MissionTarget]:
        airfields = []
        for control_point in self.enemy_control_points():
            if not isinstance(control_point, Airfield):
                continue
            if control_point.base.total_aircraft >= min_aircraft:
                airfields.append(control_point)
        return self._targets_by_range(airfields)

    def friendly_control_points(self) -> Iterator[ControlPoint]:
        """Iterates over all friendly control points."""
        return (c for c in self.game.theater.controlpoints
                if c.is_friendly(self.is_player))

    def farthest_friendly_control_point(self) -> ControlPoint:
        """
        Iterates over all friendly control points and find the one farthest away from the frontline
        BUT! prefer Cvs. Everybody likes CVs!
        """
        from_frontline = 0
        cp = None
        first_friendly_cp = None

        for c in self.game.theater.controlpoints:
            if c.is_friendly(self.is_player):
                if first_friendly_cp is None:
                    first_friendly_cp = c
                if c.is_carrier:
                    return c
                if c.has_active_frontline:
                    if c.distance_to(
                            self.front_lines().__next__()) > from_frontline:
                        from_frontline = c.distance_to(
                            self.front_lines().__next__())
                        cp = c

        # If no frontlines on the map, return the first friendly cp
        if cp is None:
            return first_friendly_cp
        else:
            return cp

    def enemy_control_points(self) -> Iterator[ControlPoint]:
        """Iterates over all enemy control points."""
        return (c for c in self.game.theater.controlpoints
                if not c.is_friendly(self.is_player))

    def all_possible_targets(self) -> Iterator[MissionTarget]:
        """Iterates over all possible mission targets in the theater.

        Valid mission targets are control points (airfields and carriers), front
        lines, and ground objects (SAM sites, factories, resource extraction
        sites, etc).
        """
        for cp in self.game.theater.controlpoints:
            yield cp
            yield from cp.ground_objects
        yield from self.front_lines()

    @staticmethod
    def closest_airfields_to(location: MissionTarget) -> ClosestAirfields:
        """Returns the closest airfields to the given location."""
        return ObjectiveDistanceCache.get_closest_airfields(location)
Example #26
0
    def __init__(self, target: Point, home: Point, ip: Point,
                 coalition: Coalition) -> None:
        self._target = target
        # Normal join placement is based on the path from home to the IP. If no path is
        # found it means that the target is on a direct path. In that case we instead
        # want to enforce that the join point is:
        #
        # * Not closer to the target than the IP.
        # * Not too close to the home airfield.
        # * Not threatened.
        # * A minimum distance from the IP.
        # * Not too sharp a turn at the ingress point.
        self.ip = ShapelyPoint(ip.x, ip.y)
        self.threat_zone = coalition.opponent.threat_zone.all
        self.home = ShapelyPoint(home.x, home.y)

        self.ip_bubble = self.ip.buffer(
            coalition.doctrine.join_distance.meters)

        ip_distance = ip.distance_to_point(target)
        self.target_bubble = ShapelyPoint(target.x,
                                          target.y).buffer(ip_distance)

        # The minimum distance between the home location and the IP.
        min_distance_from_home = nautical_miles(5)

        self.home_bubble = self.home.buffer(min_distance_from_home.meters)

        excluded_zones = shapely.ops.unary_union(
            [self.ip_bubble, self.target_bubble, self.threat_zone])

        if not isinstance(excluded_zones, MultiPolygon):
            excluded_zones = MultiPolygon([excluded_zones])
        self.excluded_zones = excluded_zones

        ip_heading = target.heading_between_point(ip)

        # Arbitrarily large since this is later constrained by the map boundary, and
        # we'll be picking a location close to the IP anyway. Just used to avoid real
        # distance calculations to project to the map edge.
        large_distance = nautical_miles(400).meters
        turn_limit = 40
        ip_limit_ccw = ip.point_from_heading(ip_heading - turn_limit,
                                             large_distance)
        ip_limit_cw = ip.point_from_heading(ip_heading + turn_limit,
                                            large_distance)

        ip_direction_limit_wedge = Polygon([
            (ip.x, ip.y),
            (ip_limit_ccw.x, ip_limit_ccw.y),
            (ip_limit_cw.x, ip_limit_cw.y),
        ])

        permissible_zones = ip_direction_limit_wedge.difference(
            self.excluded_zones).difference(self.home_bubble)
        if permissible_zones.is_empty:
            permissible_zones = MultiPolygon([])
        if not isinstance(permissible_zones, MultiPolygon):
            permissible_zones = MultiPolygon([permissible_zones])
        self.permissible_zones = permissible_zones

        preferred_lines = ip_direction_limit_wedge.intersection(
            self.excluded_zones.boundary).difference(self.home_bubble)

        if preferred_lines.is_empty:
            preferred_lines = MultiLineString([])
        if not isinstance(preferred_lines, MultiLineString):
            preferred_lines = MultiLineString([preferred_lines])
        self.preferred_lines = preferred_lines
Example #27
0
class AirliftPlanner:
    #: Maximum range from for any link in the route of takeoff, pickup, dropoff, and RTB
    #: for a helicopter to be considered for airlift. Total route length is not
    #: considered because the helicopter can refuel at each stop. Cargo planes have no
    #: maximum range.
    HELO_MAX_RANGE = nautical_miles(100)

    def __init__(
        self, game: Game, transfer: TransferOrder, next_stop: ControlPoint
    ) -> None:
        self.game = game
        self.transfer = transfer
        self.next_stop = next_stop
        self.for_player = transfer.destination.captured
        self.package = Package(target=next_stop, auto_asap=True)

    def compatible_with_mission(
        self, unit_type: AircraftType, airfield: ControlPoint
    ) -> bool:
        if unit_type not in aircraft_for_task(FlightType.TRANSPORT):
            return False
        if not self.transfer.origin.can_operate(unit_type):
            return False
        if not self.next_stop.can_operate(unit_type):
            return False

        # Cargo planes have no maximum range.
        if not unit_type.dcs_unit_type.helicopter:
            return True

        # A helicopter that is transport capable and able to operate at both bases. Need
        # to check that no leg of the journey exceeds the maximum range. This doesn't
        # account for any routing around threats that might take place, but it's close
        # enough.

        home = airfield.position
        pickup = self.transfer.position.position
        drop_off = self.transfer.position.position
        if meters(home.distance_to_point(pickup)) > self.HELO_MAX_RANGE:
            return False

        if meters(pickup.distance_to_point(drop_off)) > self.HELO_MAX_RANGE:
            return False

        if meters(drop_off.distance_to_point(home)) > self.HELO_MAX_RANGE:
            return False

        return True

    def create_package_for_airlift(self) -> None:
        distance_cache = ObjectiveDistanceCache.get_closest_airfields(
            self.transfer.position
        )
        air_wing = self.game.air_wing_for(self.for_player)
        for cp in distance_cache.closest_airfields:
            if cp.captured != self.for_player:
                continue

            inventory = self.game.aircraft_inventory.for_control_point(cp)
            for unit_type, available in inventory.all_aircraft:
                squadrons = air_wing.auto_assignable_for_task_with_type(
                    unit_type, FlightType.TRANSPORT
                )
                for squadron in squadrons:
                    if self.compatible_with_mission(unit_type, cp):
                        while (
                            available
                            and squadron.has_available_pilots
                            and self.transfer.transport is None
                        ):
                            flight_size = self.create_airlift_flight(
                                squadron, inventory
                            )
                            available -= flight_size
        if self.package.flights:
            self.game.ato_for(self.for_player).add_package(self.package)

    def create_airlift_flight(
        self, squadron: Squadron, inventory: ControlPointAircraftInventory
    ) -> int:
        available_aircraft = inventory.available(squadron.aircraft)
        capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2
        required = math.ceil(self.transfer.size / capacity_each)
        flight_size = min(
            required,
            available_aircraft,
            squadron.aircraft.dcs_unit_type.group_size_max,
        )
        # TODO: Use number_of_available_pilots directly once feature flag is gone.
        # The number of currently available pilots is not relevant when pilot limits
        # are disabled.
        if not squadron.can_provide_pilots(flight_size):
            flight_size = squadron.number_of_available_pilots
        capacity = flight_size * capacity_each

        if capacity < self.transfer.size:
            transfer = self.game.transfers.split_transfer(self.transfer, capacity)
        else:
            transfer = self.transfer

        player = inventory.control_point.captured
        flight = Flight(
            self.package,
            self.game.country_for(player),
            squadron,
            flight_size,
            FlightType.TRANSPORT,
            self.game.settings.default_start_type,
            departure=inventory.control_point,
            arrival=inventory.control_point,
            divert=None,
            cargo=transfer,
        )

        transport = Airlift(transfer, flight, self.next_stop)
        transfer.transport = transport

        self.package.add_flight(flight)
        planner = FlightPlanBuilder(self.game, self.package, self.for_player)
        planner.populate_flight_plan(flight)
        self.game.aircraft_inventory.claim_for_flight(flight)
        return flight_size
from gen.flights.flightplan import (
    BarCapFlightPlan,
    FlightPlan,
    FlightPlanBuilder,
    InvalidObjectiveLocation,
)
from gen.flights.traveltime import TotEstimator
from qt_ui.displayoptions import DisplayOptions, ThreatZoneOptions
from qt_ui.models import GameModel
from qt_ui.widgets.map.QFrontLine import QFrontLine
from qt_ui.widgets.map.QLiberationScene import QLiberationScene
from qt_ui.widgets.map.QMapControlPoint import QMapControlPoint
from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal

MAX_SHIP_DISTANCE = nautical_miles(80)


def binomial(i: int, n: int) -> float:
    """Binomial coefficient"""
    return math.factorial(n) / float(math.factorial(i) * math.factorial(n - i))


def bernstein(t: float, i: int, n: int) -> float:
    """Bernstein polynom"""
    return binomial(i, n) * (t**i) * ((1 - t)**(n - i))


def bezier(t: float, points: Iterable[Tuple[float,
                                            float]]) -> Tuple[float, float]:
    """Calculate coordinate of a point in the bezier curve"""
 def perturb(point: Point) -> Point:
     deviation = nautical_miles(1)
     x_adj = random.randint(int(-deviation.meters), int(deviation.meters))
     y_adj = random.randint(int(-deviation.meters), int(deviation.meters))
     return Point(point.x + x_adj, point.y + y_adj)
Example #30
0
    def cap_racetrack_for_objective(self, location: MissionTarget,
                                    barcap: bool) -> tuple[Point, Point]:
        closest_cache = ObjectiveDistanceCache.get_closest_airfields(location)
        for airfield in closest_cache.operational_airfields:
            # If the mission is a BARCAP of an enemy airfield, find the *next*
            # closest enemy airfield.
            if airfield == self.package.target:
                continue
            if airfield.captured != self.is_player:
                closest_airfield = airfield
                break
        else:
            raise PlanningError("Could not find any enemy airfields")

        heading = Heading.from_degrees(
            location.position.heading_between_point(closest_airfield.position))

        position = ShapelyPoint(self.package.target.position.x,
                                self.package.target.position.y)

        if barcap:
            # BARCAPs should remain far enough back from the enemy that their
            # commit range does not enter the enemy's threat zone. Include a 5nm
            # buffer.
            distance_to_no_fly = (
                meters(position.distance(self.threat_zones.all)) -
                self.doctrine.cap_engagement_range - nautical_miles(5))
            max_track_length = self.doctrine.cap_max_track_length
        else:
            # Other race tracks (TARCAPs, currently) just try to keep some
            # distance from the nearest enemy airbase, but since they are by
            # definition in enemy territory they can't avoid the threat zone
            # without being useless.
            min_distance_from_enemy = nautical_miles(20)
            distance_to_airfield = meters(
                closest_airfield.position.distance_to_point(
                    self.package.target.position))
            distance_to_no_fly = distance_to_airfield - min_distance_from_enemy

            # TARCAPs fly short racetracks because they need to react faster.
            max_track_length = self.doctrine.cap_min_track_length + 0.3 * (
                self.doctrine.cap_max_track_length -
                self.doctrine.cap_min_track_length)

        min_cap_distance = min(self.doctrine.cap_min_distance_from_cp,
                               distance_to_no_fly)
        max_cap_distance = min(self.doctrine.cap_max_distance_from_cp,
                               distance_to_no_fly)

        end = location.position.point_from_heading(
            heading.degrees,
            random.randint(int(min_cap_distance.meters),
                           int(max_cap_distance.meters)),
        )

        track_length = random.randint(
            int(self.doctrine.cap_min_track_length.meters),
            int(max_track_length.meters),
        )
        start = end.point_from_heading(heading.opposite.degrees, track_length)
        return start, end