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()])
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(), )
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), )
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)
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, )))
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, ], ))
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 ) )
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), )
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)
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"])
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"])
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
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)
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
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)
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),
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}")
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)
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
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)
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