diff --git a/game/game.py b/game/game.py index 9e09621f..a78ec5e4 100644 --- a/game/game.py +++ b/game/game.py @@ -370,7 +370,8 @@ class Game: # By default, use the existing frontline conflict position for front_line in self.theater.conflicts(): position = Conflict.frontline_position(front_line.control_point_a, - front_line.control_point_b) + front_line.control_point_b, + self.theater) points.append(position[0]) points.append(front_line.control_point_a.position) points.append(front_line.control_point_b.position) diff --git a/gen/armor.py b/gen/armor.py index f7875c72..8c6c6d6b 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -1,7 +1,8 @@ +from __future__ import annotations import logging import random from dataclasses import dataclass -from typing import List +from typing import List, TYPE_CHECKING from dcs import Mission from dcs.action import AITaskPush @@ -36,6 +37,9 @@ from .conflictgen import Conflict from .ground_forces.combat_stance import CombatStance from game.plugins import LuaPluginManager +if TYPE_CHECKING: + from game import Game + SPREAD_DISTANCE_FACTOR = 0.1, 0.3 SPREAD_DISTANCE_SIZE_FACTOR = 0.1 @@ -65,7 +69,7 @@ class JtacInfo: class GroundConflictGenerator: - def __init__(self, mission: Mission, conflict: Conflict, game, player_planned_combat_groups, enemy_planned_combat_groups, player_stance): + def __init__(self, mission: Mission, conflict: Conflict, game: Game, player_planned_combat_groups, enemy_planned_combat_groups, player_stance): self.mission = mission self.conflict = conflict self.enemy_planned_combat_groups = enemy_planned_combat_groups @@ -93,7 +97,7 @@ class GroundConflictGenerator: if combat_width < 35000: combat_width = 35000 - position = Conflict.frontline_position(self.conflict.from_cp, self.conflict.to_cp) + position = Conflict.frontline_position(self.conflict.from_cp, self.conflict.to_cp, self.game.theater) # Create player groups at random position for group in self.player_planned_combat_groups: @@ -114,6 +118,8 @@ class GroundConflictGenerator: player_groups.append((g,group)) self.gen_infantry_group_for_group(g, True, self.mission.country(self.game.player_country), self.conflict.heading + 90) + else: + logging.warning(f"Unable to get valid position for {group}") # Create enemy groups at random position for group in self.enemy_planned_combat_groups: @@ -454,7 +460,7 @@ class GroundConflictGenerator: def get_valid_position_for_group(self, conflict_position, isplayer, combat_width, distance_from_frontline): i = 0 - while i < 25: # 25 attempt for valid position + while i < 1000: # 25 attempt for valid position heading_diff = -90 if isplayer else 90 shifted = conflict_position[0].point_from_heading(self.conflict.heading, random.randint((int)(-combat_width / 2), (int)(combat_width / 2))) diff --git a/gen/briefinggen.py b/gen/briefinggen.py index 062ee8b1..b35a587d 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -6,7 +6,7 @@ import os import random import logging from dataclasses import dataclass -from theater.frontline import FrontLine +from theater import FrontLine from typing import List, Dict, TYPE_CHECKING from jinja2 import Environment, FileSystemLoader, select_autoescape diff --git a/gen/conflictgen.py b/gen/conflictgen.py index d7d90ea9..6a5a8e07 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -5,8 +5,7 @@ from typing import Tuple from dcs.country import Country from dcs.mapping import Point -from theater import ConflictTheater, ControlPoint -from theater.frontline import FrontLine +from theater import ConflictTheater, ControlPoint, FrontLine AIR_DISTANCE = 40000 @@ -136,8 +135,8 @@ class Conflict: return from_cp.has_frontline and to_cp.has_frontline @staticmethod - def frontline_position(from_cp: ControlPoint, to_cp: ControlPoint) -> Tuple[Point, int]: - frontline = FrontLine(from_cp, to_cp) + def frontline_position(from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater) -> Tuple[Point, int]: + frontline = FrontLine(from_cp, to_cp, theater) attack_heading = frontline.attack_heading position = frontline.position return position, _opposite_heading(attack_heading) @@ -160,7 +159,7 @@ class Conflict: return Point(*intersection.xy[0]), _heading_sum(heading, 90), intersection.length """ - frontline = cls.frontline_position(from_cp, to_cp) + frontline = cls.frontline_position(from_cp, to_cp, theater) center_position, heading = frontline left_position, right_position = None, None @@ -210,7 +209,7 @@ class Conflict: @classmethod def _find_ground_position(cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater) -> Point: pos = initial - for _ in range(0, int(max_distance), 500): + for _ in range(0, int(max_distance), 100): if theater.is_on_land(pos): return pos @@ -477,7 +476,7 @@ class Conflict: @classmethod def transport_conflict(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater): - frontline_position, heading = cls.frontline_position(from_cp, to_cp) + frontline_position, heading = cls.frontline_position(from_cp, to_cp, theater) initial_dest = frontline_position.point_from_heading(heading, TRANSPORT_FRONTLINE_DIST) dest = cls._find_ground_position(initial_dest, from_cp.position.distance_to_point(to_cp.position) / 3, heading, theater) if not dest: diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index ce68be2d..41a4957f 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -321,7 +321,7 @@ class ObjectiveFinder: continue if Conflict.has_frontline_between(cp, connected): - yield FrontLine(cp, connected) + yield FrontLine(cp, connected, self.game.theater) def vulnerable_control_points(self) -> Iterator[ControlPoint]: """Iterates over friendly CPs that are vulnerable to enemy CPs. diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index e47acce6..78cf29d7 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -355,7 +355,7 @@ class GroundObjectsGenerator: """ FARP_CAPACITY = 4 - def __init__(self, mission: Mission, conflict: Conflict, game, + def __init__(self, mission: Mission, conflict: Conflict, game: Game, radio_registry: RadioRegistry, tacan_registry: TacanRegistry): self.m = mission self.conflict = conflict @@ -370,7 +370,7 @@ class GroundObjectsGenerator: center = self.conflict.center heading = self.conflict.heading - 90 else: - center, heading = self.conflict.frontline_position(self.conflict.from_cp, self.conflict.to_cp) + center, heading = self.conflict.frontline_position(self.conflict.from_cp, self.conflict.to_cp, self.game.theater) heading -= 90 initial_position = center.point_from_heading(heading, FARP_FRONTLINE_DISTANCE) diff --git a/gen/visualgen.py b/gen/visualgen.py index c187ec90..c2636ea6 100644 --- a/gen/visualgen.py +++ b/gen/visualgen.py @@ -104,7 +104,7 @@ class VisualGenerator: if from_cp.is_global or to_cp.is_global: continue - frontline = Conflict.frontline_position(from_cp, to_cp) + frontline = Conflict.frontline_position(from_cp, to_cp, self.game.theater) if not frontline: continue diff --git a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py index a3f8f029..c339ffb1 100644 --- a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py +++ b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py @@ -55,7 +55,7 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox): if cp.captured: enemy_cp = [ecp for ecp in cp.connected_points if ecp.captured != cp.captured] for ecp in enemy_cp: - pos = Conflict.frontline_position(cp, ecp)[0] + pos = Conflict.frontline_position(cp, ecp, self.game.theater)[0] wpt = FlightWaypoint( FlightWaypointType.CUSTOM, pos.x, diff --git a/theater/__init__.py b/theater/__init__.py index 209a6646..8fb31434 100644 --- a/theater/__init__.py +++ b/theater/__init__.py @@ -1,5 +1,4 @@ from .base import * from .conflicttheater import * from .controlpoint import * -from .frontline import FrontLine from .missiontarget import MissionTarget diff --git a/theater/conflicttheater.py b/theater/conflicttheater.py index 10ce735d..863e2add 100644 --- a/theater/conflicttheater.py +++ b/theater/conflicttheater.py @@ -1,6 +1,11 @@ from __future__ import annotations -from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING +import logging +import json +from dataclasses import dataclass +from itertools import tee +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional, Tuple, Union from dcs.mapping import Point from dcs.terrain import ( @@ -13,9 +18,10 @@ from dcs.terrain import ( ) from dcs.terrain.terrain import Terrain -from .controlpoint import ControlPoint +from .controlpoint import ControlPoint, MissionTarget from .landmap import Landmap, load_landmap, poly_contains -from .frontline import FrontLine, ComplexFrontLine + +Numeric = Union[int, float] SIZE_TINY = 150 SIZE_SMALL = 600 @@ -54,6 +60,17 @@ COAST_DL_W = [225, 270, 315, 0, 45] COAST_DR_E = [315, 0, 45, 90, 135] COAST_DR_W = [135, 180, 225, 315] +FRONTLINE_MIN_CP_DISTANCE = 5000 + +def pairwise(iterable): + """ + itertools recipe + s -> (s0,s1), (s1,s2), (s2, s3), ... + """ + a, b = tee(iterable) + next(b, None) + return zip(a, b) + class ConflictTheater: terrain: Terrain @@ -61,15 +78,15 @@ class ConflictTheater: reference_points: Dict[Tuple[float, float], Tuple[float, float]] overview_image: str landmap: Optional[Landmap] - frontline_data: Optional[Dict[str, ComplexFrontLine]] = None """ 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] = [] - ConflictTheater.frontline_data = FrontLine.load_json_frontlines(self) + self.frontline_data = FrontLine.load_json_frontlines(self) """ self.land_poly = geometry.Polygon(self.landmap[0][0]) for x in self.landmap[1]: @@ -130,7 +147,7 @@ class ConflictTheater: def conflicts(self, from_player=True) -> Iterator[FrontLine]: for cp in [x for x in self.controlpoints if x.captured == from_player]: for connected_point in [x for x in cp.connected_points if x.captured != from_player]: - yield FrontLine(cp, connected_point) + yield FrontLine(cp, connected_point, self) def enemy_points(self) -> List[ControlPoint]: return [point for point in self.controlpoints if not point.captured] @@ -280,4 +297,206 @@ class SyriaTheater(ConflictTheater): "day": (8, 16), "dusk": (16, 18), "night": (0, 5), - } \ No newline at end of file + } + +@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, + control_point_a: ControlPoint, + control_point_b: ControlPoint, + theater: ConflictTheater + ) -> None: + self.control_point_a = control_point_a + self.control_point_b = control_point_b + self.segments: List[FrontLineSegment] = [] + self.theater = theater + self._build_segments() + self.name = f"Front line {control_point_a}/{control_point_b}" + + @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 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): + """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.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 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.control_point_a.base.strength + self.control_point_b.base.strength + ) + if self.control_point_a.base.strength == 0: + return self._adjust_for_min_dist(0) + if self.control_point_b.base.strength == 0: + return self._adjust_for_min_dist(self.attack_distance) + strength_pct = self.control_point_a.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.control_point_a.id), str(self.control_point_b.id)] + ) # from_cp.id|to_cp.id + reversed_cp_ids = "|".join( + [str(self.control_point_b.id), str(self.control_point_a.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.control_point_a.position, self.control_point_b.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/theater/frontline.py b/theater/frontline.py index 9cbf7608..76e7aac8 100644 --- a/theater/frontline.py +++ b/theater/frontline.py @@ -1,235 +1,2 @@ -"""Battlefield front lines.""" -from __future__ import annotations - - -import logging -import json -from dataclasses import dataclass -from pathlib import Path -from itertools import tee -from typing import Tuple, List, Union, Dict, Optional, TYPE_CHECKING - -from dcs.mapping import Point - -from .controlpoint import ControlPoint, MissionTarget - -if TYPE_CHECKING: - from theater.conflicttheater import ConflictTheater - -Numeric = Union[int, float] - -# TODO: Dedup by moving everything to using this class. -FRONTLINE_MIN_CP_DISTANCE = 5000 - - -def pairwise(iterable): - """ - itertools recipe - s -> (s0,s1), (s1,s2), (s2, s3), ... - """ - a, b = tee(iterable) - next(b, None) - return zip(a, b) - - -@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. - """ - - theater: ConflictTheater - - def __init__( - self, - control_point_a: ControlPoint, - control_point_b: ControlPoint, - ) -> None: - self.control_point_a = control_point_a - self.control_point_b = control_point_b - self.segments: List[FrontLineSegment] = [] - self._build_segments() - self.name = f"Front line {control_point_a}/{control_point_b}" - - @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 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): - """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.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 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.control_point_a.base.strength + self.control_point_b.base.strength - ) - if self.control_point_a.base.strength == 0: - return self._adjust_for_min_dist(0) - if self.control_point_b.base.strength == 0: - return self._adjust_for_min_dist(self.attack_distance) - strength_pct = self.control_point_a.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.control_point_a.id), str(self.control_point_b.id)] - ) # from_cp.id|to_cp.id - reversed_cp_ids = "|".join( - [str(self.control_point_b.id), str(self.control_point_a.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.control_point_a.position, self.control_point_b.position - ) - ) - - @classmethod - def load_json_frontlines( - cls, theater: ConflictTheater - ) -> Optional[Dict[str, ComplexFrontLine]]: - """Load complex frontlines from json and set the theater class variable to current theater instance""" - cls.theater = theater - 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 +"""Only here to keep compatibility for save games generated in version 2.2.0""" +from theater.conflicttheater import * \ No newline at end of file diff --git a/theater/start_generator.py b/theater/start_generator.py index b5ff0a89..f074218d 100644 --- a/theater/start_generator.py +++ b/theater/start_generator.py @@ -40,7 +40,6 @@ from theater.theatergroundobject import ( LhaGroundObject, MissileSiteGroundObject, ShipGroundObject, ) -from theater.frontline import FrontLine GroundObjectTemplates = Dict[str, Dict[str, Any]]