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 logging
import os import os
from pathlib import Path 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 import Mission
from dcs.action import DoScript, DoScriptFile from dcs.action import DoScript, DoScriptFile
@ -94,7 +94,7 @@ class Operation:
) )
return Conflict( return Conflict(
cls.game.theater, cls.game.theater,
FrontLine(player_cp, enemy_cp, cls.game.theater), FrontLine(player_cp, enemy_cp),
cls.game.player_name, cls.game.player_name,
cls.game.enemy_name, cls.game.enemy_name,
cls.game.player_country, cls.game.player_country,

View File

@ -1,5 +1,6 @@
from .base import * from .base import *
from .conflicttheater import * from .conflicttheater import *
from .controlpoint import * from .controlpoint import *
from .frontline import FrontLine
from .missiontarget import MissionTarget from .missiontarget import MissionTarget
from .theatergroundobject import SamGroundObject from .theatergroundobject import SamGroundObject

View File

@ -1,13 +1,12 @@
from __future__ import annotations from __future__ import annotations
import itertools import itertools
import json
import logging import logging
import math import math
from dataclasses import dataclass from dataclasses import dataclass
from functools import cached_property from functools import cached_property
from pathlib import Path 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 import Mission
from dcs.countries import ( from dcs.countries import (
@ -44,7 +43,6 @@ from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
from pyproj import CRS, Transformer from pyproj import CRS, Transformer
from shapely import geometry, ops from shapely import geometry, ops
from gen.flights.flight import FlightType
from .controlpoint import ( from .controlpoint import (
Airfield, Airfield,
Carrier, Carrier,
@ -54,12 +52,11 @@ from .controlpoint import (
MissionTarget, MissionTarget,
OffMapSpawn, OffMapSpawn,
) )
from .frontline import FrontLine
from .landmap import Landmap, load_landmap, poly_contains from .landmap import Landmap, load_landmap, poly_contains
from .projections import TransverseMercator from .projections import TransverseMercator
from ..point_with_heading import PointWithHeading from ..point_with_heading import PointWithHeading
from ..utils import Distance, meters, nautical_miles, pairwise from ..utils import Distance, meters, nautical_miles
Numeric = Union[int, float]
SIZE_TINY = 150 SIZE_TINY = 150
SIZE_SMALL = 600 SIZE_SMALL = 600
@ -71,8 +68,6 @@ IMPORTANCE_LOW = 1
IMPORTANCE_MEDIUM = 1.2 IMPORTANCE_MEDIUM = 1.2
IMPORTANCE_HIGH = 1.4 IMPORTANCE_HIGH = 1.4
FRONTLINE_MIN_CP_DISTANCE = 5000
class MizCampaignLoader: class MizCampaignLoader:
BLUE_COUNTRY = CombinedJointTaskForcesBlue() BLUE_COUNTRY = CombinedJointTaskForcesBlue()
@ -313,14 +308,11 @@ class MizCampaignLoader:
if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE: if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE:
yield group yield group
@cached_property def add_supply_routes(self) -> None:
def front_lines(self) -> Dict[str, ComplexFrontLine]:
# Dict of front line ID to a front line.
front_lines = {}
for group in self.front_line_path_groups: for group in self.front_line_path_groups:
# The unit will have its first waypoint at the source CP and the # The unit will have its first waypoint at the source CP and the final
# final waypoint at the destination CP. Intermediate waypoints # waypoint at the destination CP. Each waypoint defines the path of the
# define the curve of the front line. # cargo ship.
waypoints = [p.position for p in group.points] waypoints = [p.position for p in group.points]
origin = self.theater.closest_control_point(waypoints[0]) origin = self.theater.closest_control_point(waypoints[0])
if origin is None: if origin is None:
@ -333,21 +325,17 @@ class MizCampaignLoader:
f"No control point near the final waypoint of {group.name}" f"No control point near the final waypoint of {group.name}"
) )
convoy_origin = waypoints[0] # TODO: Snapping? Probably should be in the UI instead?
convoy_destination = waypoints[-1] # 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. self.control_points[origin.id].create_convoy_route(destination, waypoints)
waypoints[0] = origin.position self.control_points[destination.id].create_convoy_route(
waypoints[-1] = destination.position origin, list(reversed(waypoints))
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[destination.id].connect(
self.control_points[origin.id], convoy_destination
)
return front_lines
def add_shipping_lanes(self) -> None: def add_shipping_lanes(self) -> None:
for group in self.shipping_lane_groups: for group in self.shipping_lane_groups:
@ -466,8 +454,8 @@ class MizCampaignLoader:
for control_point in self.control_points.values(): for control_point in self.control_points.values():
self.theater.add_controlpoint(control_point) self.theater.add_controlpoint(control_point)
self.add_preset_locations() self.add_preset_locations()
self.add_supply_routes()
self.add_shipping_lanes() self.add_shipping_lanes()
self.theater.set_frontline_data(self.front_lines)
@dataclass @dataclass
@ -492,35 +480,15 @@ class ConflictTheater:
land_poly = None # type: Polygon land_poly = None # type: Polygon
""" """
daytime_map: Dict[str, Tuple[int, int]] daytime_map: Dict[str, Tuple[int, int]]
_frontline_data: Optional[Dict[str, ComplexFrontLine]] = None
def __init__(self): def __init__(self):
self.controlpoints: List[ControlPoint] = [] self.controlpoints: List[ControlPoint] = []
self._frontline_data: Optional[Dict[str, ComplexFrontLine]] = None
""" """
self.land_poly = geometry.Polygon(self.landmap[0][0]) self.land_poly = geometry.Polygon(self.landmap[0][0])
for x in self.landmap[1]: for x in self.landmap[1]:
self.land_poly = self.land_poly.difference(geometry.Polygon(x)) 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): def add_controlpoint(self, point: ControlPoint):
self.controlpoints.append(point) self.controlpoints.append(point)
@ -607,7 +575,7 @@ class ConflictTheater:
for enemy_cp in [ for enemy_cp in [
x for x in player_cp.connected_points if not x.is_friendly_to(player_cp) 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]: def enemy_points(self) -> List[ControlPoint]:
return list(self.control_points_for(player=False)) return list(self.control_points_for(player=False))
@ -872,219 +840,3 @@ class SyriaTheater(ConflictTheater):
from .syria import PARAMETERS from .syria import PARAMETERS
return 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. # TODO: Should be Airbase specific.
self.has_frontline = has_frontline self.has_frontline = has_frontline
self.connected_points: List[ControlPoint] = [] self.connected_points: List[ControlPoint] = []
self.convoy_routes: Dict[ControlPoint, List[Point]] = {}
self.shipping_lanes: Dict[ControlPoint, List[Point]] = {} self.shipping_lanes: Dict[ControlPoint, List[Point]] = {}
self.convoy_spawns: Dict[ControlPoint, Point] = {}
self.base: Base = Base() self.base: Base = Base()
self.cptype = cptype self.cptype = cptype
# TODO: Should be Airbase specific. # TODO: Should be Airbase specific.
@ -416,11 +416,21 @@ class ControlPoint(MissionTarget, ABC):
... ...
# TODO: Should be Airbase specific. # 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.connected_points.append(to)
self.convoy_spawns[to] = convoy_location
self.stances[to.id] = CombatStance.DEFENSIVE 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: def create_shipping_lane(self, to: ControlPoint, waypoints: List[Point]) -> None:
self.shipping_lanes[to] = waypoints 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 @property
def route_start(self) -> Point: def route_start(self) -> Point:
return self.origin.convoy_spawns[self.destination] return self.origin.convoy_origin_for(self.destination)
@property @property
def route_end(self) -> Point: def route_end(self) -> Point:
return self.destination.convoy_spawns[self.origin] return self.destination.convoy_origin_for(self.origin)
def description(self) -> str: def description(self) -> str:
return f"In a convoy from {self.origin} to {self.destination}" return f"In a convoy from {self.origin} to {self.destination}"

View File

@ -915,9 +915,9 @@ class QLiberationMap(QGraphicsView):
convoys.append(convoy) convoys.append(convoy)
if a.captured: if a.captured:
frontline = FrontLine(a, b, self.game.theater) frontline = FrontLine(a, b)
else: else:
frontline = FrontLine(b, a, self.game.theater) frontline = FrontLine(b, a)
if a.front_is_active(b): if a.front_is_active(b):
if DisplayOptions.actual_frontline_pos: if DisplayOptions.actual_frontline_pos:
self.draw_actual_frontline(scene, frontline, convoys) self.draw_actual_frontline(scene, frontline, convoys)