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.
This commit is contained in:
Dan Albert 2021-05-08 16:46:02 -07:00
parent 67289bbba2
commit e721a234e1
7 changed files with 217 additions and 275 deletions

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

179
game/theater/frontline.py Normal file
View File

@ -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

View File

@ -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}"

View File

@ -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)