From e721a234e138ba03092ef0cb190928047795ab0d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 8 May 2021 16:46:02 -0700 Subject: [PATCH] Clean up front line code. The routes do not need be be recreated each time we create a `FrontLine`. The front lines follow the convoy routes, which are static. Add the convoy route data to the `ControlPoint` the way we do for shipping lanes and have `FrontLine` load the data from there. --- game/operation/operation.py | 4 +- game/theater/__init__.py | 1 + game/theater/conflicttheater.py | 284 ++-------------------------- game/theater/controlpoint.py | 16 +- game/theater/frontline.py | 179 ++++++++++++++++++ game/transfers.py | 4 +- qt_ui/widgets/map/QLiberationMap.py | 4 +- 7 files changed, 217 insertions(+), 275 deletions(-) create mode 100644 game/theater/frontline.py diff --git a/game/operation/operation.py b/game/operation/operation.py index 73eb0ca0..0b43e45c 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import os from pathlib import Path -from typing import Iterable, List, Optional, Set, TYPE_CHECKING +from typing import Iterable, List, Set, TYPE_CHECKING from dcs import Mission from dcs.action import DoScript, DoScriptFile @@ -94,7 +94,7 @@ class Operation: ) return Conflict( cls.game.theater, - FrontLine(player_cp, enemy_cp, cls.game.theater), + FrontLine(player_cp, enemy_cp), cls.game.player_name, cls.game.enemy_name, cls.game.player_country, diff --git a/game/theater/__init__.py b/game/theater/__init__.py index c5b83a16..d741edb7 100644 --- a/game/theater/__init__.py +++ b/game/theater/__init__.py @@ -1,5 +1,6 @@ from .base import * from .conflicttheater import * from .controlpoint import * +from .frontline import FrontLine from .missiontarget import MissionTarget from .theatergroundobject import SamGroundObject diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 62aae3e0..35e081b1 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -1,13 +1,12 @@ from __future__ import annotations import itertools -import json import logging import math from dataclasses import dataclass from functools import cached_property from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, cast +from typing import Any, Dict, Iterator, List, Optional, Tuple from dcs import Mission from dcs.countries import ( @@ -44,7 +43,6 @@ from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed from pyproj import CRS, Transformer from shapely import geometry, ops -from gen.flights.flight import FlightType from .controlpoint import ( Airfield, Carrier, @@ -54,12 +52,11 @@ from .controlpoint import ( MissionTarget, OffMapSpawn, ) +from .frontline import FrontLine from .landmap import Landmap, load_landmap, poly_contains from .projections import TransverseMercator from ..point_with_heading import PointWithHeading -from ..utils import Distance, meters, nautical_miles, pairwise - -Numeric = Union[int, float] +from ..utils import Distance, meters, nautical_miles SIZE_TINY = 150 SIZE_SMALL = 600 @@ -71,8 +68,6 @@ IMPORTANCE_LOW = 1 IMPORTANCE_MEDIUM = 1.2 IMPORTANCE_HIGH = 1.4 -FRONTLINE_MIN_CP_DISTANCE = 5000 - class MizCampaignLoader: BLUE_COUNTRY = CombinedJointTaskForcesBlue() @@ -313,14 +308,11 @@ class MizCampaignLoader: if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE: yield group - @cached_property - def front_lines(self) -> Dict[str, ComplexFrontLine]: - # Dict of front line ID to a front line. - front_lines = {} + def add_supply_routes(self) -> None: for group in self.front_line_path_groups: - # The unit will have its first waypoint at the source CP and the - # final waypoint at the destination CP. Intermediate waypoints - # define the curve of the front line. + # The unit will have its first waypoint at the source CP and the final + # waypoint at the destination CP. Each waypoint defines the path of the + # cargo ship. waypoints = [p.position for p in group.points] origin = self.theater.closest_control_point(waypoints[0]) if origin is None: @@ -333,21 +325,17 @@ class MizCampaignLoader: f"No control point near the final waypoint of {group.name}" ) - convoy_origin = waypoints[0] - convoy_destination = waypoints[-1] + # TODO: Snapping? Probably should be in the UI instead? + # convoy_origin = waypoints[0] + # convoy_destination = waypoints[-1] + # + # waypoints[0] = origin.position + # waypoints[-1] = destination.position - # Snap the begin and end points to the control points. - waypoints[0] = origin.position - waypoints[-1] = destination.position - front_line_id = f"{origin.id}|{destination.id}" - front_lines[front_line_id] = ComplexFrontLine(origin, waypoints) - self.control_points[origin.id].connect( - self.control_points[destination.id], convoy_origin + self.control_points[origin.id].create_convoy_route(destination, waypoints) + self.control_points[destination.id].create_convoy_route( + origin, list(reversed(waypoints)) ) - self.control_points[destination.id].connect( - self.control_points[origin.id], convoy_destination - ) - return front_lines def add_shipping_lanes(self) -> None: for group in self.shipping_lane_groups: @@ -466,8 +454,8 @@ class MizCampaignLoader: for control_point in self.control_points.values(): self.theater.add_controlpoint(control_point) self.add_preset_locations() + self.add_supply_routes() self.add_shipping_lanes() - self.theater.set_frontline_data(self.front_lines) @dataclass @@ -492,35 +480,15 @@ class ConflictTheater: land_poly = None # type: Polygon """ daytime_map: Dict[str, Tuple[int, int]] - _frontline_data: Optional[Dict[str, ComplexFrontLine]] = None def __init__(self): self.controlpoints: List[ControlPoint] = [] - self._frontline_data: Optional[Dict[str, ComplexFrontLine]] = None """ self.land_poly = geometry.Polygon(self.landmap[0][0]) for x in self.landmap[1]: self.land_poly = self.land_poly.difference(geometry.Polygon(x)) """ - @property - def frontline_data(self) -> Optional[Dict[str, ComplexFrontLine]]: - if self._frontline_data is None: - self.load_frontline_data_from_file() - return self._frontline_data - - def load_frontline_data_from_file(self) -> None: - if self._frontline_data is not None: - logging.warning("Replacing existing frontline data from file") - self._frontline_data = FrontLine.load_json_frontlines(self) - if self._frontline_data is None: - self._frontline_data = {} - - def set_frontline_data(self, data: Dict[str, ComplexFrontLine]) -> None: - if self._frontline_data is not None: - logging.warning("Replacing existing frontline data") - self._frontline_data = data - def add_controlpoint(self, point: ControlPoint): self.controlpoints.append(point) @@ -607,7 +575,7 @@ class ConflictTheater: for enemy_cp in [ x for x in player_cp.connected_points if not x.is_friendly_to(player_cp) ]: - yield FrontLine(player_cp, enemy_cp, self) + yield FrontLine(player_cp, enemy_cp) def enemy_points(self) -> List[ControlPoint]: return list(self.control_points_for(player=False)) @@ -872,219 +840,3 @@ class SyriaTheater(ConflictTheater): from .syria import PARAMETERS return PARAMETERS - - -@dataclass -class ComplexFrontLine: - """ - Stores data necessary for building a multi-segment frontline. - "points" should be ordered from closest to farthest distance originating from start_cp.position - """ - - start_cp: ControlPoint - points: List[Point] - - -@dataclass -class FrontLineSegment: - """ - Describes a line segment of a FrontLine - """ - - point_a: Point - point_b: Point - - @property - def attack_heading(self) -> Numeric: - """The heading of the frontline segment from player to enemy control point""" - return self.point_a.heading_between_point(self.point_b) - - @property - def attack_distance(self) -> Numeric: - """Length of the segment""" - return self.point_a.distance_to_point(self.point_b) - - -class FrontLine(MissionTarget): - """Defines a front line location between two control points. - Front lines are the area where ground combat happens. - Overwrites the entirety of MissionTarget __init__ method to allow for - dynamic position calculation. - """ - - def __init__( - self, - blue_point: ControlPoint, - red_point: ControlPoint, - theater: ConflictTheater, - ) -> None: - self.blue_cp = blue_point - self.red_cp = red_point - self.segments: List[FrontLineSegment] = [] - self.theater = theater - self._build_segments() - self.name = f"Front line {blue_point}/{red_point}" - - def control_point_hostile_to(self, player: bool) -> ControlPoint: - if player: - return self.red_cp - return self.blue_cp - - def is_friendly(self, to_player: bool) -> bool: - """Returns True if the objective is in friendly territory.""" - return False - - def mission_types(self, for_player: bool) -> Iterator[FlightType]: - yield from [ - FlightType.CAS, - FlightType.AEWC, - # TODO: FlightType.TROOP_TRANSPORT - # TODO: FlightType.EVAC - ] - yield from super().mission_types(for_player) - - @property - def position(self): - """ - The position where the conflict should occur - according to the current strength of each control point. - """ - return self.point_from_a(self._position_distance) - - @property - def points(self) -> Iterator[Point]: - yield self.segments[0].point_a - for segment in self.segments: - yield segment.point_b - - @property - def control_points(self) -> Tuple[ControlPoint, ControlPoint]: - """Returns a tuple of the two control points.""" - return self.blue_cp, self.red_cp - - @property - def attack_distance(self): - """The total distance of all segments""" - return sum(i.attack_distance for i in self.segments) - - @property - def attack_heading(self): - """The heading of the active attack segment from player to enemy control point""" - return self.active_segment.attack_heading - - @property - def active_segment(self) -> FrontLineSegment: - """The FrontLine segment where there can be an active conflict""" - if self._position_distance <= self.segments[0].attack_distance: - return self.segments[0] - - remaining_dist = self._position_distance - for segment in self.segments: - if remaining_dist <= segment.attack_distance: - return segment - else: - remaining_dist -= segment.attack_distance - logging.error( - "Frontline attack distance is greater than the sum of its segments" - ) - return self.segments[0] - - def point_from_a(self, distance: Numeric) -> Point: - """ - Returns a point {distance} away from control_point_a along the frontline segments. - """ - if distance < self.segments[0].attack_distance: - return self.blue_cp.position.point_from_heading( - self.segments[0].attack_heading, distance - ) - remaining_dist = distance - for segment in self.segments: - if remaining_dist < segment.attack_distance: - return segment.point_a.point_from_heading( - segment.attack_heading, remaining_dist - ) - else: - remaining_dist -= segment.attack_distance - - @property - def _position_distance(self) -> float: - """ - The distance from point "a" where the conflict should occur - according to the current strength of each control point - """ - total_strength = self.blue_cp.base.strength + self.red_cp.base.strength - if self.blue_cp.base.strength == 0: - return self._adjust_for_min_dist(0) - if self.red_cp.base.strength == 0: - return self._adjust_for_min_dist(self.attack_distance) - strength_pct = self.blue_cp.base.strength / total_strength - return self._adjust_for_min_dist(strength_pct * self.attack_distance) - - def _adjust_for_min_dist(self, distance: Numeric) -> Numeric: - """ - Ensures the frontline conflict is never located within the minimum distance - constant of either end control point. - """ - if (distance > self.attack_distance / 2) and ( - distance + FRONTLINE_MIN_CP_DISTANCE > self.attack_distance - ): - distance = self.attack_distance - FRONTLINE_MIN_CP_DISTANCE - elif (distance < self.attack_distance / 2) and ( - distance < FRONTLINE_MIN_CP_DISTANCE - ): - distance = FRONTLINE_MIN_CP_DISTANCE - return distance - - def _build_segments(self) -> None: - """Create line segments for the frontline""" - control_point_ids = "|".join( - [str(self.blue_cp.id), str(self.red_cp.id)] - ) # from_cp.id|to_cp.id - reversed_cp_ids = "|".join([str(self.red_cp.id), str(self.blue_cp.id)]) - complex_frontlines = self.theater.frontline_data - if (complex_frontlines) and ( - (control_point_ids in complex_frontlines) - or (reversed_cp_ids in complex_frontlines) - ): - # The frontline segments must be stored in the correct order for the distance algorithms to work. - # The points in the frontline are ordered from the id before the | to the id after. - # First, check if control point id pair matches in order, and create segments if a match is found. - if control_point_ids in complex_frontlines: - point_pairs = pairwise(complex_frontlines[control_point_ids].points) - for i in point_pairs: - self.segments.append(FrontLineSegment(i[0], i[1])) - # Check the reverse order and build in reverse if found. - elif reversed_cp_ids in complex_frontlines: - point_pairs = pairwise( - reversed(complex_frontlines[reversed_cp_ids].points) - ) - for i in point_pairs: - self.segments.append(FrontLineSegment(i[0], i[1])) - # If no complex frontline has been configured, fall back to the old straight line method. - else: - self.segments.append( - FrontLineSegment(self.blue_cp.position, self.red_cp.position) - ) - - @staticmethod - def load_json_frontlines( - theater: ConflictTheater, - ) -> Optional[Dict[str, ComplexFrontLine]]: - """Load complex frontlines from json""" - try: - path = Path(f"resources/frontlines/{theater.terrain.name.lower()}.json") - with open(path, "r") as file: - logging.debug(f"Loading frontline from {path}...") - data = json.load(file) - return { - frontline: ComplexFrontLine( - data[frontline]["start_cp"], - [Point(i[0], i[1]) for i in data[frontline]["points"]], - ) - for frontline in data - } - except OSError: - logging.warning( - f"Unable to load preset frontlines for {theater.terrain.name}" - ) - return None diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 52937ab0..f811f059 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -273,8 +273,8 @@ class ControlPoint(MissionTarget, ABC): # TODO: Should be Airbase specific. self.has_frontline = has_frontline self.connected_points: List[ControlPoint] = [] + self.convoy_routes: Dict[ControlPoint, List[Point]] = {} self.shipping_lanes: Dict[ControlPoint, List[Point]] = {} - self.convoy_spawns: Dict[ControlPoint, Point] = {} self.base: Base = Base() self.cptype = cptype # TODO: Should be Airbase specific. @@ -416,11 +416,21 @@ class ControlPoint(MissionTarget, ABC): ... # TODO: Should be Airbase specific. - def connect(self, to: ControlPoint, convoy_location: Point) -> None: + def connect(self, to: ControlPoint) -> None: self.connected_points.append(to) - self.convoy_spawns[to] = convoy_location self.stances[to.id] = CombatStance.DEFENSIVE + def convoy_origin_for(self, destination: ControlPoint) -> Point: + return self.convoy_route_to(destination)[0] + + def convoy_route_to(self, destination: ControlPoint) -> List[Point]: + return self.convoy_routes[destination] + + def create_convoy_route(self, to: ControlPoint, waypoints: List[Point]) -> None: + self.connected_points.append(to) + self.stances[to.id] = CombatStance.DEFENSIVE + self.convoy_routes[to] = waypoints + def create_shipping_lane(self, to: ControlPoint, waypoints: List[Point]) -> None: self.shipping_lanes[to] = waypoints diff --git a/game/theater/frontline.py b/game/theater/frontline.py new file mode 100644 index 00000000..97ef6a9b --- /dev/null +++ b/game/theater/frontline.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterator, List, Tuple + +from dcs.mapping import Point + +from gen.flights.flight import FlightType +from .controlpoint import ( + ControlPoint, + MissionTarget, +) +from ..utils import pairwise + + +FRONTLINE_MIN_CP_DISTANCE = 5000 + + +@dataclass +class FrontLineSegment: + """ + Describes a line segment of a FrontLine + """ + + point_a: Point + point_b: Point + + @property + def attack_heading(self) -> float: + """The heading of the frontline segment from player to enemy control point""" + return self.point_a.heading_between_point(self.point_b) + + @property + def attack_distance(self) -> float: + """Length of the segment""" + return self.point_a.distance_to_point(self.point_b) + + +class FrontLine(MissionTarget): + """Defines a front line location between two control points. + Front lines are the area where ground combat happens. + Overwrites the entirety of MissionTarget __init__ method to allow for + dynamic position calculation. + """ + + def __init__( + self, + blue_point: ControlPoint, + red_point: ControlPoint, + ) -> None: + self.blue_cp = blue_point + self.red_cp = red_point + try: + route = blue_point.convoy_route_to(red_point) + except KeyError: + # Some campaigns are air only and the mission generator currently relies on + # *some* "front line" being drawn between these two. In this case there will + # be no supply route to follow. Just create an arbitrary route between the + # two points. + route = [blue_point.position, red_point.position] + # Snap the beginning and end points to the CPs rather than the convoy waypoints, + # which are on roads. + route[0] = blue_point.position + route[-1] = red_point.position + self.segments: List[FrontLineSegment] = [ + FrontLineSegment(a, b) for a, b in pairwise(route) + ] + self.name = f"Front line {blue_point}/{red_point}" + + def control_point_hostile_to(self, player: bool) -> ControlPoint: + if player: + return self.red_cp + return self.blue_cp + + def is_friendly(self, to_player: bool) -> bool: + """Returns True if the objective is in friendly territory.""" + return False + + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + yield from [ + FlightType.CAS, + FlightType.AEWC, + # TODO: FlightType.TROOP_TRANSPORT + # TODO: FlightType.EVAC + ] + yield from super().mission_types(for_player) + + @property + def position(self): + """ + The position where the conflict should occur + according to the current strength of each control point. + """ + return self.point_from_a(self._position_distance) + + @property + def points(self) -> Iterator[Point]: + yield self.segments[0].point_a + for segment in self.segments: + yield segment.point_b + + @property + def control_points(self) -> Tuple[ControlPoint, ControlPoint]: + """Returns a tuple of the two control points.""" + return self.blue_cp, self.red_cp + + @property + def attack_distance(self): + """The total distance of all segments""" + return sum(i.attack_distance for i in self.segments) + + @property + def attack_heading(self): + """The heading of the active attack segment from player to enemy control point""" + return self.active_segment.attack_heading + + @property + def active_segment(self) -> FrontLineSegment: + """The FrontLine segment where there can be an active conflict""" + if self._position_distance <= self.segments[0].attack_distance: + return self.segments[0] + + remaining_dist = self._position_distance + for segment in self.segments: + if remaining_dist <= segment.attack_distance: + return segment + else: + remaining_dist -= segment.attack_distance + logging.error( + "Frontline attack distance is greater than the sum of its segments" + ) + return self.segments[0] + + def point_from_a(self, distance: float) -> Point: + """ + Returns a point {distance} away from control_point_a along the frontline segments. + """ + if distance < self.segments[0].attack_distance: + return self.blue_cp.position.point_from_heading( + self.segments[0].attack_heading, distance + ) + remaining_dist = distance + for segment in self.segments: + if remaining_dist < segment.attack_distance: + return segment.point_a.point_from_heading( + segment.attack_heading, remaining_dist + ) + else: + remaining_dist -= segment.attack_distance + + @property + def _position_distance(self) -> float: + """ + The distance from point "a" where the conflict should occur + according to the current strength of each control point + """ + total_strength = self.blue_cp.base.strength + self.red_cp.base.strength + if self.blue_cp.base.strength == 0: + return self._adjust_for_min_dist(0) + if self.red_cp.base.strength == 0: + return self._adjust_for_min_dist(self.attack_distance) + strength_pct = self.blue_cp.base.strength / total_strength + return self._adjust_for_min_dist(strength_pct * self.attack_distance) + + def _adjust_for_min_dist(self, distance: float) -> float: + """ + Ensures the frontline conflict is never located within the minimum distance + constant of either end control point. + """ + if (distance > self.attack_distance / 2) and ( + distance + FRONTLINE_MIN_CP_DISTANCE > self.attack_distance + ): + distance = self.attack_distance - FRONTLINE_MIN_CP_DISTANCE + elif (distance < self.attack_distance / 2) and ( + distance < FRONTLINE_MIN_CP_DISTANCE + ): + distance = FRONTLINE_MIN_CP_DISTANCE + return distance diff --git a/game/transfers.py b/game/transfers.py index 0a2f1877..b531fce4 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -338,11 +338,11 @@ class Convoy(MultiGroupTransport): @property def route_start(self) -> Point: - return self.origin.convoy_spawns[self.destination] + return self.origin.convoy_origin_for(self.destination) @property def route_end(self) -> Point: - return self.destination.convoy_spawns[self.origin] + return self.destination.convoy_origin_for(self.origin) def description(self) -> str: return f"In a convoy from {self.origin} to {self.destination}" diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 50359d19..51c0576f 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -915,9 +915,9 @@ class QLiberationMap(QGraphicsView): convoys.append(convoy) if a.captured: - frontline = FrontLine(a, b, self.game.theater) + frontline = FrontLine(a, b) else: - frontline = FrontLine(b, a, self.game.theater) + frontline = FrontLine(b, a) if a.front_is_active(b): if DisplayOptions.actual_frontline_pos: self.draw_actual_frontline(scene, frontline, convoys)