diff --git a/theater/frontline.py b/theater/frontline.py index c70b3417..aa2852af 100644 --- a/theater/frontline.py +++ b/theater/frontline.py @@ -1,44 +1,199 @@ """Battlefield front lines.""" -from typing import Tuple +from __future__ import annotations +from dataclasses import dataclass + +import logging + +from itertools import tee +from typing import Tuple, List, Union, Dict from dcs.mapping import Point -from . import ControlPoint, MissionTarget + +from .controlpoint import ControlPoint, MissionTarget + +Numeric = Union[int, float] # TODO: Dedup by moving everything to using this class. FRONTLINE_MIN_CP_DISTANCE = 5000 -def compute_position(control_point_a: ControlPoint, - control_point_b: ControlPoint) -> Point: - a = control_point_a.position - b = control_point_b.position - attack_heading = a.heading_between_point(b) - attack_distance = a.distance_to_point(b) - middle_point = a.point_from_heading(attack_heading, attack_distance / 2) +def pairwise(iterable): + "s -> (s0,s1), (s1,s2), (s2, s3), ..." + a, b = tee(iterable) + next(b, None) + return zip(a, b) - strength_delta = float(control_point_a.base.strength - - control_point_b.base.strength) - position = middle_point.point_from_heading(attack_heading, - strength_delta * - attack_distance / 2 - - FRONTLINE_MIN_CP_DISTANCE) - return position + +@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] + # control_points: List[ControlPoint] + + +@dataclass +class FrontLineSegment: + """ + Describes a line segment of a FrontLine + """ + + point_a: Point + point_b: Point + + @property + def attack_heading(self) -> Numeric: + return self.point_a.heading_between_point(self.point_b) + + @property + def attack_distance(self) -> Numeric: + 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, control_point_a: ControlPoint, - control_point_b: ControlPoint) -> None: - super().__init__(f"Front line {control_point_a}/{control_point_b}", - compute_position(control_point_a, control_point_b)) + def __init__( + self, + control_point_a: ControlPoint, + control_point_b: ControlPoint, + frontline_data: Dict[str, ComplexFrontLine], + ) -> None: self.control_point_a = control_point_a self.control_point_b = control_point_b + self.segments: List[FrontLineSegment] = [] + self._build_segments(frontline_data) + self.name = f"Front line {control_point_a}/{control_point_b}" + + @property + def position(self): + return self._calculate_position() @property def control_points(self) -> Tuple[ControlPoint, ControlPoint]: """Returns a tuple of the two control points.""" return self.control_point_a, self.control_point_b + + @property + def middle_point(self): + self.point_from_a(self.attack_distance / 2) + + @property + def attack_distance(self): + return sum(i.attack_distance for i in self.segments) + + @property + def attack_heading(self): + 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] + + @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.control_point_a.base.strength + self.control_point_b.base.strength + ) + if total_strength == 0: + return self._adjust_for_min_dist(0) + strength_pct = self.control_point_a.base.strength / total_strength + return self._adjust_for_min_dist(strength_pct * self.attack_distance) + + def _calculate_position(self) -> Point: + """ + The position where the conflict should occur + according to the current strength of each control point. + """ + return self.point_from_a(self._position_distance) + + def _build_segments(self, frontline_data: Dict[str, ComplexFrontLine]) -> None: + control_point_ids = "|".join( + [str(self.control_point_a.id), str(self.control_point_b.id)] + ) + reversed_cp_ids = "|".join( + [str(self.control_point_b.id), str(self.control_point_a.id)] + ) + complex_frontlines = 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.control_point_a.position, self.control_point_b.position + ) + ) + + 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 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.control_point_a.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 self.control_point_a.position.point_from_heading( + segment.attack_heading, remaining_dist + ) + else: + remaining_dist -= segment.attack_distance