Merge branch 'develop' into helipads

# Conflicts:
#	game/game.py
#	game/operation/operation.py
#	game/theater/conflicttheater.py
#	game/theater/controlpoint.py
#	gen/groundobjectsgen.py
#	resources/campaigns/golan_heights_lite.miz
This commit is contained in:
Khopa
2021-08-02 19:34:05 +02:00
408 changed files with 9630 additions and 5172 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import logging
from typing import List, Type
from collections import Sequence
from typing import Type
from dcs.helicopters import (
AH_1W,
@@ -124,29 +125,30 @@ from pydcs_extensions.su57.su57 import Su_57
CAP_CAPABLE = [
Su_57,
F_22A,
MiG_31,
F_15C,
F_14B,
F_14A_135_GR,
MiG_25PD,
Su_33,
Su_34,
J_11A,
Su_30,
Su_27,
J_11A,
F_15C,
MiG_29S,
MiG_29G,
MiG_29A,
F_16C_50,
FA_18C_hornet,
JF_17,
JAS39Gripen,
F_16A,
F_4E,
JAS39Gripen,
JF_17,
MiG_31,
MiG_25PD,
MiG_29G,
MiG_29A,
MiG_23MLD,
MiG_21Bis,
Mirage_2000_5,
M_2000C,
F_15E,
M_2000C,
F_5E_3,
MiG_19P,
A_4E_C,
@@ -173,6 +175,7 @@ CAS_CAPABLE = [
A_10C_2,
A_10C,
Hercules,
Su_34,
Su_25TM,
Su_25T,
Su_25,
@@ -190,17 +193,16 @@ CAS_CAPABLE = [
F_14B,
F_14A_135_GR,
AJS37,
Su_24MR,
Su_24M,
Su_17M4,
Su_33,
F_4E,
S_3B,
Su_34,
Su_30,
MiG_19P,
MiG_29S,
MiG_27K,
MiG_29A,
MiG_21Bis,
AH_64D,
AH_64A,
AH_1W,
@@ -212,13 +214,14 @@ CAS_CAPABLE = [
Mi_24P,
Mi_24V,
Mi_8MT,
UH_1H,
MiG_19P,
MiG_15bis,
M_2000C,
F_5E_3,
F_86F_Sabre,
C_101CC,
L_39ZA,
UH_1H,
A_20G,
Ju_88A4,
P_47D_40,
@@ -299,13 +302,14 @@ STRIKE_CAPABLE = [
Tornado_GR4,
F_16C_50,
FA_18C_hornet,
AV8BNA,
JF_17,
F_16A,
F_14B,
F_14A_135_GR,
JAS39Gripen_AG,
Tornado_IDS,
Su_17M4,
Su_24MR,
Su_24M,
Su_25TM,
Su_25T,
@@ -317,11 +321,9 @@ STRIKE_CAPABLE = [
MiG_29S,
MiG_29G,
MiG_29A,
JF_17,
F_4E,
A_10C_2,
A_10C,
AV8BNA,
S_3B,
A_4E_C,
M_2000C,
@@ -375,6 +377,7 @@ RUNWAY_ATTACK_CAPABLE = [
Su_34,
Su_30,
Tornado_IDS,
M_2000C,
] + STRIKE_CAPABLE
# For any aircraft that isn't necessarily directly involved in strike
@@ -415,7 +418,7 @@ REFUELING_CAPABALE = [
]
def dcs_types_for_task(task: FlightType) -> list[Type[FlyingType]]:
def dcs_types_for_task(task: FlightType) -> Sequence[Type[FlyingType]]:
cap_missions = (FlightType.BARCAP, FlightType.TARCAP, FlightType.SWEEP)
if task in cap_missions:
return CAP_CAPABLE

View File

@@ -2,14 +2,14 @@ from __future__ import annotations
from datetime import timedelta
from enum import Enum
from typing import List, Optional, TYPE_CHECKING, Union
from typing import List, Optional, TYPE_CHECKING, Union, Sequence, Any
from dcs.mapping import Point
from dcs.point import MovingPoint, PointAction
from dcs.unit import Unit
from game import db
from game.dcs.aircrafttype import AircraftType
from game.savecompat import has_save_compat_for
from game.squadrons import Pilot, Squadron
from game.theater.controlpoint import ControlPoint, MissionTarget
from game.utils import Distance, meters
@@ -139,7 +139,7 @@ class FlightWaypoint:
Args:
waypoint_type: The waypoint type.
x: X cooidinate of the waypoint.
x: X coordinate of the waypoint.
y: Y coordinate of the waypoint.
alt: Altitude of the waypoint. By default this is AGL, but it can be
changed to MSL by setting alt_type to "RADIO".
@@ -154,11 +154,13 @@ class FlightWaypoint:
# Only used in the waypoint list in the flight edit page. No sense
# having three names. A short and long form is enough.
self.description = ""
self.targets: List[Union[MissionTarget, Unit]] = []
self.targets: Sequence[Union[MissionTarget, Unit]] = []
self.obj_name = ""
self.pretty_name = ""
self.only_for_player = False
self.flyover = False
# The minimum amount of fuel remaining at this waypoint in pounds.
self.min_fuel: Optional[float] = None
# These are set very late by the air conflict generator (part of mission
# generation). We do it late so that we don't need to propagate changes
@@ -167,6 +169,12 @@ class FlightWaypoint:
self.tot: Optional[timedelta] = None
self.departure_time: Optional[timedelta] = None
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
if "min_fuel" not in state:
state["min_fuel"] = None
self.__dict__.update(state)
@property
def position(self) -> Point:
return Point(self.x, self.y)
@@ -325,12 +333,12 @@ class Flight:
def clear_roster(self) -> None:
self.roster.clear()
def __repr__(self):
def __repr__(self) -> str:
if self.custom_name:
return f"{self.custom_name} {self.count} x {self.unit_type}"
return f"[{self.flight_type}] {self.count} x {self.unit_type}"
def __str__(self):
def __str__(self) -> str:
if self.custom_name:
return f"{self.custom_name} {self.count} x {self.unit_type}"
return f"[{self.flight_type}] {self.count} x {self.unit_type}"

View File

@@ -20,6 +20,8 @@ from dcs.unit import Unit
from shapely.geometry import Point as ShapelyPoint
from game.data.doctrine import Doctrine
from game.dcs.aircrafttype import FuelConsumption
from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry
from game.theater import (
Airfield,
ControlPoint,
@@ -28,9 +30,17 @@ from game.theater import (
SamGroundObject,
TheaterGroundObject,
NavalControlPoint,
ConflictTheater,
)
from game.theater.theatergroundobject import EwrGroundObject, NavalGroundObject
from game.utils import Distance, Speed, feet, meters, nautical_miles, knots
from game.theater.theatergroundobject import (
EwrGroundObject,
NavalGroundObject,
BuildingGroundObject,
)
from game.threatzones import ThreatZones
from game.utils import Distance, Heading, Speed, feet, meters, nautical_miles, knots
from .closestairfields import ObjectiveDistanceCache
from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType
from .traveltime import GroundSpeed, TravelTime
@@ -38,8 +48,8 @@ from .waypointbuilder import StrikeTarget, WaypointBuilder
from ..conflictgen import Conflict, FRONTLINE_LENGTH
if TYPE_CHECKING:
from game import Game
from gen.ato import Package
from game.coalition import Coalition
from game.transfers import Convoy
INGRESS_TYPES = {
@@ -131,6 +141,17 @@ class FlightPlan:
@cached_property
def bingo_fuel(self) -> int:
"""Bingo fuel value for the FlightPlan"""
if (fuel := self.flight.unit_type.fuel_consumption) is not None:
return self._bingo_estimate(fuel)
return self._legacy_bingo_estimate()
def _bingo_estimate(self, fuel: FuelConsumption) -> int:
distance_to_arrival = self.max_distance_from(self.flight.arrival)
fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles
bingo = fuel_consumed + fuel.min_safe
return math.ceil(bingo / 100) * 100
def _legacy_bingo_estimate(self) -> int:
distance_to_arrival = self.max_distance_from(self.flight.arrival)
bingo = 1000.0 # Minimum Emergency Fuel
@@ -219,11 +240,7 @@ class FlightPlan:
tot_waypoint = self.tot_waypoint
if tot_waypoint is None:
return None
time = self.tot
if time is None:
return None
return time - self._travel_time_to_waypoint(tot_waypoint)
return self.tot - self._travel_time_to_waypoint(tot_waypoint)
def startup_time(self) -> Optional[timedelta]:
takeoff_time = self.takeoff_time()
@@ -540,7 +557,6 @@ class StrikeFlightPlan(FormationFlightPlan):
join: FlightWaypoint
ingress: FlightWaypoint
targets: List[FlightWaypoint]
egress: FlightWaypoint
split: FlightWaypoint
nav_from: List[FlightWaypoint]
land: FlightWaypoint
@@ -555,7 +571,6 @@ class StrikeFlightPlan(FormationFlightPlan):
yield self.join
yield self.ingress
yield from self.targets
yield self.egress
yield self.split
yield from self.nav_from
yield self.land
@@ -567,7 +582,6 @@ class StrikeFlightPlan(FormationFlightPlan):
def package_speed_waypoints(self) -> Set[FlightWaypoint]:
return {
self.ingress,
self.egress,
self.split,
} | set(self.targets)
@@ -631,8 +645,8 @@ class StrikeFlightPlan(FormationFlightPlan):
@property
def split_time(self) -> timedelta:
travel_time = self.travel_time_between_waypoints(self.egress, self.split)
return self.egress_time + travel_time
travel_time = self.travel_time_between_waypoints(self.ingress, self.split)
return self.ingress_time + travel_time
@property
def ingress_time(self) -> timedelta:
@@ -642,19 +656,9 @@ class StrikeFlightPlan(FormationFlightPlan):
)
return tot - travel_time
@property
def egress_time(self) -> timedelta:
tot = self.tot
travel_time = self.travel_time_between_waypoints(
self.target_area_waypoint, self.egress
)
return tot + travel_time
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]:
if waypoint == self.ingress:
return self.ingress_time
elif waypoint == self.egress:
return self.egress_time
elif waypoint in self.targets:
return self.tot
return super().tot_for_waypoint(waypoint)
@@ -868,7 +872,9 @@ class CustomFlightPlan(FlightPlan):
class FlightPlanBuilder:
"""Generates flight plans for flights."""
def __init__(self, game: Game, package: Package, is_player: bool) -> None:
def __init__(
self, package: Package, coalition: Coalition, theater: ConflictTheater
) -> None:
# TODO: Plan similar altitudes for the in-country leg of the mission.
# Waypoint altitudes for a given flight *shouldn't* differ too much
# between the join and split points, so we don't need speeds for each
@@ -876,11 +882,21 @@ class FlightPlanBuilder:
# hold too well right now since nothing is stopping each waypoint from
# jumping 20k feet each time, but that's a huge waste of energy we
# should be avoiding anyway.
self.game = game
self.package = package
self.is_player = is_player
self.doctrine: Doctrine = self.game.faction_for(self.is_player).doctrine
self.threat_zones = self.game.threat_zone_for(not self.is_player)
self.coalition = coalition
self.theater = theater
@property
def is_player(self) -> bool:
return self.coalition.player
@property
def doctrine(self) -> Doctrine:
return self.coalition.doctrine
@property
def threat_zones(self) -> ThreatZones:
return self.coalition.opponent.threat_zone
def populate_flight_plan(
self,
@@ -945,95 +961,33 @@ class FlightPlanBuilder:
raise PlanningError(f"{task} flight plan generation not implemented")
def regenerate_package_waypoints(self) -> None:
# The simple case is where the target is greater than the ingress
# distance into the threat zone and the target is not near the departure
# airfield. In this case, we can plan the shortest route from the
# departure airfield to the target, use the last non-threatened point as
# the join point, and plan the IP inside the threatened area.
#
# When the target is near the edge of the threat zone the IP may need to
# be placed outside the zone.
#
# +--------------+ +---------------+
# | | | |
# | | IP---+-T |
# | | | |
# | | | |
# +--------------+ +---------------+
#
# Here we want to place the IP first and route the flight to the IP
# rather than routing to the target and placing the IP based on the join
# point.
#
# The other case that we need to handle is when the target is close to
# the origin airfield. In this case we also need to set up the IP first,
# but depending on the placement of the IP we may need to place the join
# point in a retreating position.
#
# A messy (and very unlikely) case that we can't do much about:
#
# +--------------+ +---------------+
# | | | |
# | IP-+---+-T |
# | | | |
# | | | |
# +--------------+ +---------------+
from gen.ato import PackageWaypoints
target = self.package.target.position
package_airfield = self.package_airfield()
join_point = self.preferred_join_point()
if join_point is None:
# The whole path from the origin airfield to the target is
# threatened. Need to retreat out of the threat area.
join_point = self.retreat_point(self.package_airfield().position)
# Start by picking the best IP for the attack.
ingress_point = IpZoneGeometry(
self.package.target.position,
package_airfield.position,
self.coalition,
).find_best_ip()
attack_heading = join_point.heading_between_point(target)
ingress_point = self._ingress_point(attack_heading)
join_distance = meters(join_point.distance_to_point(target))
ingress_distance = meters(ingress_point.distance_to_point(target))
if join_distance < ingress_distance:
# The second case described above. The ingress point is farther from
# the target than the join point. Use the fallback behavior for now.
self.legacy_package_waypoints_impl()
return
join_point = JoinZoneGeometry(
self.package.target.position,
package_airfield.position,
ingress_point,
self.coalition,
).find_best_join_point()
# The first case described above. The ingress and join points are placed
# reasonably relative to each other.
egress_point = self._egress_point(attack_heading)
# And the split point based on the best route from the IP. Since that's no
# different than the best route *to* the IP, this is the same as the join point.
# TODO: Estimate attack completion point based on the IP and split from there?
self.package.waypoints = PackageWaypoints(
WaypointBuilder.perturb(join_point),
ingress_point,
egress_point,
WaypointBuilder.perturb(join_point),
)
def retreat_point(self, origin: Point) -> Point:
return self.threat_zones.closest_boundary(origin)
def legacy_package_waypoints_impl(self) -> None:
from gen.ato import PackageWaypoints
ingress_point = self._ingress_point(self._target_heading_to_package_airfield())
egress_point = self._egress_point(self._target_heading_to_package_airfield())
join_point = self._rendezvous_point(ingress_point)
split_point = self._rendezvous_point(egress_point)
self.package.waypoints = PackageWaypoints(
join_point,
ingress_point,
egress_point,
split_point,
)
def preferred_join_point(self) -> Optional[Point]:
path = self.game.navmesh_for(self.is_player).shortest_path(
self.package_airfield().position, self.package.target.position
)
for point in reversed(path):
if not self.threat_zones.threatened(point):
return point
return None
def generate_strike(self, flight: Flight) -> StrikeFlightPlan:
"""Generates a strike flight plan.
@@ -1047,26 +1001,16 @@ class FlightPlanBuilder:
raise InvalidObjectiveLocation(flight.flight_type, location)
targets: List[StrikeTarget] = []
if len(location.groups) > 0 and location.dcs_identifier == "AA":
if isinstance(location, BuildingGroundObject):
# A building "group" is implemented as multiple TGOs with the same name.
for building in location.strike_targets:
targets.append(StrikeTarget(building.category, building))
else:
# TODO: Replace with DEAD?
# Strike missions on SEAD targets target units.
for g in location.groups:
for j, u in enumerate(g.units):
targets.append(StrikeTarget(f"{u.type} #{j}", u))
else:
# TODO: Does this actually happen?
# ConflictTheater is built with the belief that multiple ground
# objects have the same name. If that's the case,
# TheaterGroundObject needs some refactoring because it behaves very
# differently for SAM sites than it does for strike targets.
buildings = self.game.theater.find_ground_objects_by_obj_name(
location.obj_name
)
for building in buildings:
if building.is_dead:
continue
targets.append(StrikeTarget(building.category, building))
return self.strike_flightplan(
flight, location, FlightWaypointType.INGRESS_STRIKE, targets
@@ -1087,23 +1031,23 @@ class FlightPlanBuilder:
else:
patrol_alt = feet(25000)
builder = WaypointBuilder(flight, self.game, self.is_player)
orbit_location = builder.orbit(orbit_location, patrol_alt)
builder = WaypointBuilder(flight, self.coalition)
orbit = builder.orbit(orbit_location, patrol_alt)
return AwacsFlightPlan(
package=self.package,
flight=flight,
takeoff=builder.takeoff(flight.departure),
nav_to=builder.nav_path(
flight.departure.position, orbit_location.position, patrol_alt
flight.departure.position, orbit.position, patrol_alt
),
nav_from=builder.nav_path(
orbit_location.position, flight.arrival.position, patrol_alt
orbit.position, flight.arrival.position, patrol_alt
),
land=builder.land(flight.arrival),
divert=builder.divert(flight.divert),
bullseye=builder.bullseye(),
hold=orbit_location,
hold=orbit,
hold_duration=timedelta(hours=4),
)
@@ -1134,7 +1078,7 @@ class FlightPlanBuilder:
)
@staticmethod
def anti_ship_targets_for_tgo(tgo: TheaterGroundObject) -> List[StrikeTarget]:
def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> List[StrikeTarget]:
return [StrikeTarget(f"{g.name} at {tgo.name}", g) for g in tgo.groups]
def generate_anti_ship(self, flight: Flight) -> StrikeFlightPlan:
@@ -1171,16 +1115,17 @@ class FlightPlanBuilder:
if isinstance(location, FrontLine):
raise InvalidObjectiveLocation(flight.flight_type, location)
start, end = self.racetrack_for_objective(location, barcap=True)
patrol_alt = meters(
random.randint(
int(self.doctrine.min_patrol_altitude.meters),
int(self.doctrine.max_patrol_altitude.meters),
)
start_pos, end_pos = self.racetrack_for_objective(location, barcap=True)
preferred_alt = flight.unit_type.preferred_patrol_altitude
randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
patrol_alt = max(
self.doctrine.min_patrol_altitude,
min(self.doctrine.max_patrol_altitude, randomized_alt),
)
builder = WaypointBuilder(flight, self.game, self.is_player)
start, end = builder.race_track(start, end, patrol_alt)
builder = WaypointBuilder(flight, self.coalition)
start, end = builder.race_track(start_pos, end_pos, patrol_alt)
return BarCapFlightPlan(
package=self.package,
@@ -1209,12 +1154,15 @@ class FlightPlanBuilder:
"""
assert self.package.waypoints is not None
target = self.package.target.position
heading = Heading.from_degrees(
self.package.waypoints.join.heading_between_point(target)
)
start_pos = target.point_from_heading(
heading.degrees, -self.doctrine.sweep_distance.meters
)
heading = self.package.waypoints.join.heading_between_point(target)
start = target.point_from_heading(heading, -self.doctrine.sweep_distance.meters)
builder = WaypointBuilder(flight, self.game, self.is_player)
start, end = builder.sweep(start, target, self.doctrine.ingress_altitude)
builder = WaypointBuilder(flight, self.coalition)
start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude)
hold = builder.hold(self._hold_point(flight))
@@ -1253,7 +1201,7 @@ class FlightPlanBuilder:
altitude = feet(1500)
altitude_is_agl = True
builder = WaypointBuilder(flight, self.game, self.is_player)
builder = WaypointBuilder(flight, self.coalition)
pickup = None
nav_to_pickup = []
@@ -1305,7 +1253,9 @@ class FlightPlanBuilder:
else:
raise PlanningError("Could not find any enemy airfields")
heading = location.position.heading_between_point(closest_airfield.position)
heading = Heading.from_degrees(
location.position.heading_between_point(closest_airfield.position)
)
position = ShapelyPoint(
self.package.target.position.x, self.package.target.position.y
@@ -1341,20 +1291,20 @@ class FlightPlanBuilder:
)
end = location.position.point_from_heading(
heading,
heading.degrees,
random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)),
)
diameter = random.randint(
int(self.doctrine.cap_min_track_length.meters),
int(self.doctrine.cap_max_track_length.meters),
)
start = end.point_from_heading(heading - 180, diameter)
start = end.point_from_heading(heading.opposite.degrees, diameter)
return start, end
def aewc_orbit(self, location: MissionTarget) -> Point:
closest_boundary = self.threat_zones.closest_boundary(location.position)
heading_to_threat_boundary = location.position.heading_between_point(
closest_boundary
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)
@@ -1368,19 +1318,17 @@ class FlightPlanBuilder:
orbit_distance = distance_to_threat - threat_buffer
return location.position.point_from_heading(
orbit_heading, orbit_distance.meters
orbit_heading.degrees, orbit_distance.meters
)
def racetrack_for_frontline(
self, origin: Point, front_line: FrontLine
) -> Tuple[Point, Point]:
# Find targets waypoints
ingress, heading, distance = Conflict.frontline_vector(
front_line, self.game.theater
)
center = ingress.point_from_heading(heading, distance / 2)
ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater)
center = ingress.point_from_heading(heading.degrees, distance / 2)
orbit_center = center.point_from_heading(
heading - 90,
heading.left.degrees,
random.randint(
int(nautical_miles(6).meters), int(nautical_miles(15).meters)
),
@@ -1393,8 +1341,8 @@ class FlightPlanBuilder:
combat_width = 35000
radius = combat_width * 1.25
start = orbit_center.point_from_heading(heading, radius)
end = orbit_center.point_from_heading(heading + 180, radius)
start = orbit_center.point_from_heading(heading.degrees, radius)
end = orbit_center.point_from_heading(heading.opposite.degrees, radius)
if end.distance_to_point(origin) < start.distance_to_point(origin):
start, end = end, start
@@ -1408,15 +1356,15 @@ class FlightPlanBuilder:
"""
location = self.package.target
patrol_alt = meters(
random.randint(
int(self.doctrine.min_patrol_altitude.meters),
int(self.doctrine.max_patrol_altitude.meters),
)
preferred_alt = flight.unit_type.preferred_patrol_altitude
randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
patrol_alt = max(
self.doctrine.min_patrol_altitude,
min(self.doctrine.max_patrol_altitude, randomized_alt),
)
# Create points
builder = WaypointBuilder(flight, self.game, self.is_player)
builder = WaypointBuilder(flight, self.coalition)
if isinstance(location, FrontLine):
orbit0p, orbit1p = self.racetrack_for_frontline(
@@ -1547,11 +1495,9 @@ class FlightPlanBuilder:
def generate_escort(self, flight: Flight) -> StrikeFlightPlan:
assert self.package.waypoints is not None
builder = WaypointBuilder(flight, self.game, self.is_player)
ingress, target, egress = builder.escort(
self.package.waypoints.ingress,
self.package.target,
self.package.waypoints.egress,
builder = WaypointBuilder(flight, self.coalition)
ingress, target = builder.escort(
self.package.waypoints.ingress, self.package.target
)
hold = builder.hold(self._hold_point(flight))
join = builder.join(self.package.waypoints.join)
@@ -1569,7 +1515,6 @@ class FlightPlanBuilder:
join=join,
ingress=ingress,
targets=[target],
egress=egress,
split=split,
nav_from=builder.nav_path(
split.position, flight.arrival.position, self.doctrine.ingress_altitude
@@ -1590,18 +1535,16 @@ class FlightPlanBuilder:
if not isinstance(location, FrontLine):
raise InvalidObjectiveLocation(flight.flight_type, location)
ingress, heading, distance = Conflict.frontline_vector(
location, self.game.theater
)
center = ingress.point_from_heading(heading, distance / 2)
egress = ingress.point_from_heading(heading, distance)
ingress, heading, distance = Conflict.frontline_vector(location, self.theater)
center = ingress.point_from_heading(heading.degrees, distance / 2)
egress = ingress.point_from_heading(heading.degrees, distance)
ingress_distance = ingress.distance_to_point(flight.departure.position)
egress_distance = egress.distance_to_point(flight.departure.position)
if egress_distance < ingress_distance:
ingress, egress = egress, ingress
builder = WaypointBuilder(flight, self.game, self.is_player)
builder = WaypointBuilder(flight, self.coalition)
return CasFlightPlan(
package=self.package,
@@ -1629,8 +1572,8 @@ class FlightPlanBuilder:
location = self.package.target
closest_boundary = self.threat_zones.closest_boundary(location.position)
heading_to_threat_boundary = location.position.heading_between_point(
closest_boundary
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)
@@ -1645,19 +1588,19 @@ class FlightPlanBuilder:
orbit_distance = distance_to_threat - threat_buffer
racetrack_center = location.position.point_from_heading(
orbit_heading, orbit_distance.meters
orbit_heading.degrees, orbit_distance.meters
)
racetrack_half_distance = Distance.from_nautical_miles(20).meters
racetrack_start = racetrack_center.point_from_heading(
orbit_heading + 90, racetrack_half_distance
orbit_heading.right.degrees, racetrack_half_distance
)
racetrack_end = racetrack_center.point_from_heading(
orbit_heading - 90, racetrack_half_distance
orbit_heading.left.degrees, racetrack_half_distance
)
builder = WaypointBuilder(flight, self.game, self.is_player)
builder = WaypointBuilder(flight, self.coalition)
tanker_type = flight.unit_type
if tanker_type.patrol_altitude is not None:
@@ -1724,49 +1667,10 @@ class FlightPlanBuilder:
origin = flight.departure.position
target = self.package.target.position
join = self.package.waypoints.join
origin_to_target = origin.distance_to_point(target)
join_to_target = join.distance_to_point(target)
if origin_to_target < join_to_target:
# If the origin airfield is closer to the target than the join
# point, plan the hold point such that it retreats from the origin
# airfield.
return join.point_from_heading(
target.heading_between_point(origin), self.doctrine.push_distance.meters
)
heading_to_join = origin.heading_between_point(join)
hold_point = origin.point_from_heading(
heading_to_join, self.doctrine.push_distance.meters
)
hold_distance = meters(hold_point.distance_to_point(join))
if hold_distance >= self.doctrine.push_distance:
# Hold point is between the origin airfield and the join point and
# spaced sufficiently.
return hold_point
# The hold point is between the origin airfield and the join point, but
# the distance between the hold point and the join point is too short.
# Bend the hold point out to extend the distance while maintaining the
# minimum distance from the origin airfield to keep the AI flying
# properly.
origin_to_join = origin.distance_to_point(join)
cos_theta = (
self.doctrine.hold_distance.meters ** 2
+ origin_to_join ** 2
- self.doctrine.join_distance.meters ** 2
) / (2 * self.doctrine.hold_distance.meters * origin_to_join)
try:
theta = math.acos(cos_theta)
except ValueError:
# No solution that maintains hold and join distances. Extend the
# hold point away from the target.
return origin.point_from_heading(
target.heading_between_point(origin), self.doctrine.hold_distance.meters
)
return origin.point_from_heading(
heading_to_join - theta, self.doctrine.hold_distance.meters
)
ip = self.package.waypoints.ingress
return HoldZoneGeometry(
target, origin, ip, join, self.coalition, self.theater
).find_best_hold_point()
# TODO: Make a model for the waypoint builder and use that in the UI.
def generate_rtb_waypoint(
@@ -1778,7 +1682,7 @@ class FlightPlanBuilder:
flight: The flight to generate the landing waypoint for.
arrival: Arrival airfield or carrier.
"""
builder = WaypointBuilder(flight, self.game, self.is_player)
builder = WaypointBuilder(flight, self.coalition)
return builder.land(arrival)
def strike_flightplan(
@@ -1790,7 +1694,7 @@ class FlightPlanBuilder:
lead_time: timedelta = timedelta(),
) -> StrikeFlightPlan:
assert self.package.waypoints is not None
builder = WaypointBuilder(flight, self.game, self.is_player, targets)
builder = WaypointBuilder(flight, self.coalition, targets)
target_waypoints: List[FlightWaypoint] = []
if targets is not None:
@@ -1819,7 +1723,6 @@ class FlightPlanBuilder:
ingress_type, self.package.waypoints.ingress, location
),
targets=target_waypoints,
egress=builder.egress(self.package.waypoints.egress, location),
split=split,
nav_from=builder.nav_path(
split.position, flight.arrival.position, self.doctrine.ingress_altitude
@@ -1830,64 +1733,6 @@ class FlightPlanBuilder:
lead_time=lead_time,
)
def _retreating_rendezvous_point(self, attack_transition: Point) -> Point:
"""Creates a rendezvous point that retreats from the origin airfield."""
return attack_transition.point_from_heading(
self.package.target.position.heading_between_point(
self.package_airfield().position
),
self.doctrine.join_distance.meters,
)
def _advancing_rendezvous_point(self, attack_transition: Point) -> Point:
"""Creates a rendezvous point that advances toward the target."""
heading = self._heading_to_package_airfield(attack_transition)
return attack_transition.point_from_heading(
heading, -self.doctrine.join_distance.meters
)
def _rendezvous_should_retreat(self, attack_transition: Point) -> bool:
transition_target_distance = attack_transition.distance_to_point(
self.package.target.position
)
origin_target_distance = self._distance_to_package_airfield(
self.package.target.position
)
# If the origin point is closer to the target than the ingress point,
# the rendezvous point should be positioned in a position that retreats
# from the origin airfield.
return origin_target_distance < transition_target_distance
def _rendezvous_point(self, attack_transition: Point) -> Point:
"""Returns the position of the rendezvous point.
Args:
attack_transition: The ingress or egress point for this rendezvous.
"""
if self._rendezvous_should_retreat(attack_transition):
return self._retreating_rendezvous_point(attack_transition)
return self._advancing_rendezvous_point(attack_transition)
def _ingress_point(self, heading: int) -> Point:
return self.package.target.position.point_from_heading(
heading - 180 + 15, self.doctrine.ingress_egress_distance.meters
)
def _egress_point(self, heading: int) -> Point:
return self.package.target.position.point_from_heading(
heading - 180 - 15, self.doctrine.ingress_egress_distance.meters
)
def _target_heading_to_package_airfield(self) -> int:
return self._heading_to_package_airfield(self.package.target.position)
def _heading_to_package_airfield(self, point: Point) -> int:
return self.package_airfield().position.heading_between_point(point)
def _distance_to_package_airfield(self, point: Point) -> int:
return self.package_airfield().position.distance_to_point(point)
def package_airfield(self) -> ControlPoint:
# We'll always have a package, but if this is being planned via the UI
# it could be the first flight in the package.

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
import datetime
from typing import Optional, List, Iterator, TYPE_CHECKING, Mapping
from collections import Iterable
from typing import Optional, Iterator, TYPE_CHECKING, Mapping
from game.data.weapons import Weapon, Pylon
from game.data.weapons import Weapon, Pylon, WeaponType
from game.dcs.aircrafttype import AircraftType
if TYPE_CHECKING:
@@ -19,16 +20,45 @@ class Loadout:
is_custom: bool = False,
) -> None:
self.name = name
self.pylons = {k: v for k, v in pylons.items() if v is not None}
# We clear unused pylon entries on initialization, but UI actions can still
# cause a pylon to be emptied, so make the optional type explicit.
self.pylons: Mapping[int, Optional[Weapon]] = {
k: v for k, v in pylons.items() if v is not None
}
self.date = date
self.is_custom = is_custom
def derive_custom(self, name: str) -> Loadout:
return Loadout(name, self.pylons, self.date, is_custom=True)
def has_weapon_of_type(self, weapon_type: WeaponType) -> bool:
for weapon in self.pylons.values():
if weapon is not None and weapon.weapon_group.type is weapon_type:
return True
return False
@staticmethod
def _fallback_for(
weapon: Weapon,
pylon: Pylon,
date: datetime.date,
skip_types: Optional[Iterable[WeaponType]] = None,
) -> Optional[Weapon]:
if skip_types is None:
skip_types = set()
for fallback in weapon.fallbacks:
if not pylon.can_equip(fallback):
continue
if not fallback.available_on(date):
continue
if fallback.weapon_group.type in skip_types:
continue
return fallback
return None
def degrade_for_date(self, unit_type: AircraftType, date: datetime.date) -> Loadout:
if self.date is not None and self.date <= date:
return Loadout(self.name, self.pylons, self.date)
return Loadout(self.name, self.pylons, self.date, self.is_custom)
new_pylons = dict(self.pylons)
for pylon_number, weapon in self.pylons.items():
@@ -37,16 +67,39 @@ class Loadout:
continue
if not weapon.available_on(date):
pylon = Pylon.for_aircraft(unit_type, pylon_number)
for fallback in weapon.fallbacks:
if not pylon.can_equip(fallback):
continue
if not fallback.available_on(date):
continue
new_pylons[pylon_number] = fallback
break
else:
fallback = self._fallback_for(weapon, pylon, date)
if fallback is None:
del new_pylons[pylon_number]
return Loadout(f"{self.name} ({date.year})", new_pylons, date)
else:
new_pylons[pylon_number] = fallback
loadout = Loadout(self.name, new_pylons, date, self.is_custom)
# If this is not a custom loadout, we should replace any LGBs with iron bombs if
# the loadout lost its TGP.
#
# If the loadout was chosen explicitly by the user, assume they know what
# they're doing. They may be coordinating buddy-lase.
if not loadout.is_custom:
loadout.replace_lgbs_if_no_tgp(unit_type, date)
return loadout
def replace_lgbs_if_no_tgp(
self, unit_type: AircraftType, date: datetime.date
) -> None:
if self.has_weapon_of_type(WeaponType.TGP):
return
new_pylons = dict(self.pylons)
for pylon_number, weapon in self.pylons.items():
if weapon is not None and weapon.weapon_group.type is WeaponType.LGB:
pylon = Pylon.for_aircraft(unit_type, pylon_number)
fallback = self._fallback_for(
weapon, pylon, date, skip_types={WeaponType.LGB}
)
if fallback is None:
del new_pylons[pylon_number]
else:
new_pylons[pylon_number] = fallback
self.pylons = new_pylons
@classmethod
def iter_for(cls, flight: Flight) -> Iterator[Loadout]:
@@ -64,14 +117,10 @@ class Loadout:
pylons = payload["pylons"]
yield Loadout(
name,
{p["num"]: Weapon.from_clsid(p["CLSID"]) for p in pylons.values()},
{p["num"]: Weapon.with_clsid(p["CLSID"]) for p in pylons.values()},
date=None,
)
@classmethod
def all_for(cls, flight: Flight) -> List[Loadout]:
return list(cls.iter_for(flight))
@classmethod
def default_loadout_names_for(cls, flight: Flight) -> Iterator[str]:
from gen.flights.flight import FlightType
@@ -92,6 +141,7 @@ class Loadout:
FlightType.CAS: ("CAS MAVERICK F", "CAS"),
FlightType.STRIKE: ("STRIKE",),
FlightType.ANTISHIP: ("ANTISHIP",),
FlightType.DEAD: ("DEAD",),
FlightType.SEAD: ("SEAD",),
FlightType.BAI: ("BAI",),
FlightType.OCA_RUNWAY: ("RUNWAY_ATTACK", "RUNWAY_STRIKE"),
@@ -128,9 +178,13 @@ class Loadout:
if payload is not None:
return Loadout(
name,
{i: Weapon.from_clsid(d["clsid"]) for i, d in payload},
{i: Weapon.with_clsid(d["clsid"]) for i, d in payload},
date=None,
)
# TODO: Try group.load_task_default_loadout(loadout_for_task)
return cls.empty_loadout()
@classmethod
def empty_loadout(cls) -> Loadout:
return Loadout("Empty", {}, date=None)

View File

@@ -10,14 +10,15 @@ from typing import (
TYPE_CHECKING,
Tuple,
Union,
Any,
)
from dcs.mapping import Point
from dcs.unit import Unit
from dcs.unitgroup import Group, VehicleGroup
from dcs.unitgroup import VehicleGroup, ShipGroup
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
from game.transfers import MultiGroupTransport
from game.theater import (
@@ -33,24 +34,24 @@ from .flight import Flight, FlightWaypoint, FlightWaypointType
@dataclass(frozen=True)
class StrikeTarget:
name: str
target: Union[VehicleGroup, TheaterGroundObject, Unit, Group, MultiGroupTransport]
target: Union[
VehicleGroup, TheaterGroundObject[Any], Unit, ShipGroup, MultiGroupTransport
]
class WaypointBuilder:
def __init__(
self,
flight: Flight,
game: Game,
player: bool,
coalition: Coalition,
targets: Optional[List[StrikeTarget]] = None,
) -> None:
self.flight = flight
self.conditions = game.conditions
self.doctrine = game.faction_for(player).doctrine
self.threat_zones = game.threat_zone_for(not player)
self.navmesh = game.navmesh_for(player)
self.doctrine = coalition.doctrine
self.threat_zones = coalition.opponent.threat_zone
self.navmesh = coalition.nav_mesh
self.targets = targets
self._bullseye = game.bullseye_for(player)
self._bullseye = coalition.bullseye
@property
def is_helo(self) -> bool:
@@ -426,22 +427,19 @@ class WaypointBuilder:
self,
ingress: Point,
target: MissionTarget,
egress: Point,
) -> Tuple[FlightWaypoint, FlightWaypoint, FlightWaypoint]:
) -> Tuple[FlightWaypoint, FlightWaypoint]:
"""Creates the waypoints needed to escort the package.
Args:
ingress: The package ingress point.
target: The mission target.
egress: The package egress point.
"""
# This would preferably be no points at all, and instead the Escort task
# would begin on the join point and end on the split point, however the
# escort task does not appear to work properly (see the longer
# description in gen.aircraft.JoinPointBuilder), so instead we give
# the escort flights a flight plan including the ingress point, target
# area, and egress point.
ingress = self.ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target)
# the escort flights a flight plan including the ingress point and target area.
ingress_wp = self.ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target)
waypoint = FlightWaypoint(
FlightWaypointType.TARGET_GROUP_LOC,
@@ -454,9 +452,7 @@ class WaypointBuilder:
waypoint.name = "TARGET"
waypoint.description = "Escort the package"
waypoint.pretty_name = "Target area"
egress = self.egress(egress, target)
return ingress, waypoint, egress
return ingress_wp, waypoint
@staticmethod
def pickup(control_point: ControlPoint) -> FlightWaypoint: