diff --git a/game/game.py b/game/game.py index 7308a128..a78ec5e4 100644 --- a/game/game.py +++ b/game/game.py @@ -369,9 +369,9 @@ class Game: # By default, use the existing frontline conflict position for front_line in self.theater.conflicts(): - position = Conflict.frontline_position(self.theater, - front_line.control_point_a, - front_line.control_point_b) + position = Conflict.frontline_position(front_line.control_point_a, + 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 5685a120..0e504021 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.game.theater, 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: 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))) @@ -462,9 +468,8 @@ class GroundConflictGenerator: if self.conflict.theater.is_on_land(final_position): return final_position - else: - i = i + 1 - continue + i += 1 + continue return None def _generate_group(self, side: Country, unit: VehicleType, count: int, at: Point, move_formation: PointAction = PointAction.OffRoad, heading=0): 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 3c9eecfe..6a5a8e07 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -5,7 +5,7 @@ from typing import Tuple from dcs.country import Country from dcs.mapping import Point -from theater import ConflictTheater, ControlPoint +from theater import ConflictTheater, ControlPoint, FrontLine AIR_DISTANCE = 40000 @@ -134,14 +134,11 @@ class Conflict: def has_frontline_between(cls, from_cp: ControlPoint, to_cp: ControlPoint) -> bool: return from_cp.has_frontline and to_cp.has_frontline - @classmethod - def frontline_position(cls, theater: ConflictTheater, from_cp: ControlPoint, to_cp: ControlPoint) -> Tuple[Point, int]: - attack_heading = from_cp.position.heading_between_point(to_cp.position) - attack_distance = from_cp.position.distance_to_point(to_cp.position) - middle_point = from_cp.position.point_from_heading(attack_heading, attack_distance / 2) - - strength_delta = (from_cp.base.strength - to_cp.base.strength) / 1.0 - position = middle_point.point_from_heading(attack_heading, strength_delta * attack_distance / 2 - FRONTLINE_MIN_CP_DISTANCE) + @staticmethod + 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) @@ -162,7 +159,7 @@ class Conflict: return Point(*intersection.xy[0]), _heading_sum(heading, 90), intersection.length """ - frontline = cls.frontline_position(theater, from_cp, to_cp) + frontline = cls.frontline_position(from_cp, to_cp, theater) center_position, heading = frontline left_position, right_position = None, None @@ -212,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 @@ -479,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(theater, 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 008c344e..d23923cd 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 1989452e..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.theater, 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 efd0c1f9..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(self.game.theater, 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 72ece41e..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(self.game.theater, cp, ecp)[0] + pos = Conflict.frontline_position(cp, ecp, self.game.theater)[0] wpt = FlightWaypoint( FlightWaypointType.CUSTOM, pos.x, diff --git a/qt_ui/widgets/map/QFrontLine.py b/qt_ui/widgets/map/QFrontLine.py index f1425893..1849f5ff 100644 --- a/qt_ui/widgets/map/QFrontLine.py +++ b/qt_ui/widgets/map/QFrontLine.py @@ -14,8 +14,10 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as const from qt_ui.dialogs import Dialog +from qt_ui.models import GameModel +from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.mission.QPackageDialog import QNewPackageDialog -from theater.missiontarget import MissionTarget +from theater import FrontLine class QFrontLine(QGraphicsLineItem): @@ -26,9 +28,10 @@ class QFrontLine(QGraphicsLineItem): """ def __init__(self, x1: float, y1: float, x2: float, y2: float, - mission_target: MissionTarget) -> None: + mission_target: FrontLine, game_model: GameModel) -> None: super().__init__(x1, y1, x2, y2) self.mission_target = mission_target + self.game_model = game_model self.new_package_dialog: Optional[QNewPackageDialog] = None self.setAcceptHoverEvents(True) @@ -55,6 +58,14 @@ class QFrontLine(QGraphicsLineItem): new_package_action.triggered.connect(self.open_new_package_dialog) menu.addAction(new_package_action) + cheat_forward = QAction(f"CHEAT: Advance Frontline") + cheat_forward.triggered.connect(self.cheat_forward) + menu.addAction(cheat_forward) + + cheat_backward = QAction(f"CHEAT: Retreat Frontline") + cheat_backward.triggered.connect(self.cheat_backward) + menu.addAction(cheat_backward) + menu.exec_(event.screenPos()) @property @@ -80,3 +91,16 @@ class QFrontLine(QGraphicsLineItem): def open_new_package_dialog(self) -> None: """Opens the dialog for planning a new mission package.""" Dialog.open_new_package_dialog(self.mission_target) + + def cheat_forward(self) -> None: + self.mission_target.control_point_a.base.affect_strength(0.1) + self.mission_target.control_point_b.base.affect_strength(-0.1) + self.game_model.game.initialize_turn() + GameUpdateSignal.get_instance().updateGame(self.game_model.game) + + def cheat_backward(self) -> None: + self.mission_target.control_point_a.base.affect_strength(-0.1) + self.mission_target.control_point_b.base.affect_strength(0.1) + self.game_model.game.initialize_turn() + GameUpdateSignal.get_instance().updateGame(self.game_model.game) + \ No newline at end of file diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index a24b609b..04e2d5d6 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -2,7 +2,8 @@ from __future__ import annotations import datetime import logging -from typing import List, Optional, Tuple +import math +from typing import Iterable, List, Optional, Tuple, Iterator from PySide2.QtCore import QPointF, Qt from PySide2.QtGui import ( @@ -38,13 +39,41 @@ from qt_ui.widgets.map.QLiberationScene import QLiberationScene from qt_ui.widgets.map.QMapControlPoint import QMapControlPoint from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject from qt_ui.windows.GameUpdateSignal import GameUpdateSignal -from theater import ControlPoint, FrontLine +from theater import ControlPoint +from theater.conflicttheater import FrontLine from theater.theatergroundobject import ( EwrGroundObject, MissileSiteGroundObject, TheaterGroundObject, ) +def binomial(i: int, n: int) -> float: + """Binomial coefficient""" + return math.factorial(n) / float( + math.factorial(i) * math.factorial(n - i)) + + +def bernstein(t: float, i: int, n: int) -> float: + """Bernstein polynom""" + return binomial(i, n) * (t ** i) * ((1 - t) ** (n - i)) + + +def bezier(t: float, points: Iterable[Tuple[float, float]]) -> Tuple[float, float]: + """Calculate coordinate of a point in the bezier curve""" + n = len(points) - 1 + x = y = 0 + for i, pos in enumerate(points): + bern = bernstein(t, i, n) + x += pos[0] * bern + y += pos[1] * bern + return x, y + + +def bezier_curve_range(n: int, points: Iterable[Tuple[float, float]]) -> Iterator[Tuple[float, float]]: + """Range of points in a curve bezier""" + for i in range(n): + t = i / float(n - 1) + yield bezier(t, points) class QLiberationMap(QGraphicsView): WAYPOINT_SIZE = 4 @@ -189,6 +218,67 @@ class QLiberationMap(QGraphicsView): return detection_range, threat_range + def display_culling(self, scene: QGraphicsScene) -> None: + """Draws the culling distance rings on the map""" + culling_points = self.game_model.game.get_culling_points() + culling_distance = self.game_model.game.settings.perf_culling_distance + for point in culling_points: + culling_distance_point = Point(point.x + culling_distance*1000, point.y + culling_distance*1000) + distance_point = self._transform_point(culling_distance_point) + transformed = self._transform_point(point) + diameter = distance_point[0] - transformed[0] + scene.addEllipse(transformed[0]-diameter/2, transformed[1]-diameter/2, diameter, diameter, CONST.COLORS["transparent"], CONST.COLORS["light_green_transparent"]) + + @staticmethod + def ground_object_display_options(ground_object: TheaterGroundObject, cp: ControlPoint) -> Tuple[bool, bool]: + is_missile = isinstance(ground_object, MissileSiteGroundObject) + is_aa = ground_object.category == "aa" and not is_missile + is_ewr = isinstance(ground_object, EwrGroundObject) + is_display_type = is_aa or is_ewr + should_display = ((DisplayOptions.sam_ranges and cp.captured) + or + (DisplayOptions.enemy_sam_ranges and not cp.captured)) + return is_display_type, should_display + + def draw_threat_range(self, scene: QGraphicsScene, ground_object: TheaterGroundObject, cp: ControlPoint) -> None: + go_pos = self._transform_point(ground_object.position) + detection_range, threat_range = self.aa_ranges( + ground_object + ) + if threat_range: + threat_pos = self._transform_point(Point(ground_object.position.x+threat_range, + ground_object.position.y+threat_range)) + threat_radius = Point(*go_pos).distance_to_point(Point(*threat_pos)) + + # Add threat range circle + scene.addEllipse(go_pos[0] - threat_radius / 2 + 7, go_pos[1] - threat_radius / 2 + 6, + threat_radius, threat_radius, self.threat_pen(cp.captured)) + + if detection_range and DisplayOptions.detection_range: + # Add detection range circle + detection_pos = self._transform_point(Point(ground_object.position.x+detection_range, + ground_object.position.y+detection_range)) + detection_radius = Point(*go_pos).distance_to_point(Point(*detection_pos)) + scene.addEllipse(go_pos[0] - detection_radius/2 + 7, go_pos[1] - detection_radius/2 + 6, + detection_radius, detection_radius, self.detection_pen(cp.captured)) + + def draw_ground_objects(self, scene: QGraphicsScene, cp: ControlPoint) -> None: + added_objects = [] + for ground_object in cp.ground_objects: + if ground_object.obj_name in added_objects: + continue + + go_pos = self._transform_point(ground_object.position) + if not ground_object.airbase_group: + buildings = self.game.theater.find_ground_objects_by_obj_name(ground_object.obj_name) + scene.addItem(QMapGroundObject(self, go_pos[0], go_pos[1], 14, 12, cp, ground_object, self.game, buildings)) + + is_display_type, should_display = self.ground_object_display_options(ground_object, cp) + + if is_display_type and should_display: + self.draw_threat_range(scene, ground_object, cp) + added_objects.append(ground_object.obj_name) + def reload_scene(self): scene = self.scene() scene.clear() @@ -199,20 +289,13 @@ class QLiberationMap(QGraphicsView): self.addBackground() # Uncomment below to help set up theater reference points - #for i, r in enumerate(self.game.theater.reference_points.items()): - # text = scene.addText(str(r), font=QFont("Trebuchet MS", 10, weight=5, italic=False)) - # text.setPos(0, i * 24) + # for i, r in enumerate(self.game.theater.reference_points.items()): + # text = scene.addText(str(r), font=QFont("Trebuchet MS", 10, weight=5, italic=False)) + # text.setPos(0, i * 24) # Display Culling if DisplayOptions.culling and self.game.settings.perf_culling: - culling_points = self.game_model.game.get_culling_points() - culling_distance = self.game_model.game.settings.perf_culling_distance - for point in culling_points: - culling_distance_point = Point(point.x + culling_distance*1000, point.y + culling_distance*1000) - distance_point = self._transform_point(culling_distance_point) - transformed = self._transform_point(point) - diameter = distance_point[0] - transformed[0] - scene.addEllipse(transformed[0]-diameter/2, transformed[1]-diameter/2, diameter, diameter, CONST.COLORS["transparent"], CONST.COLORS["light_green_transparent"]) + self.display_culling() for cp in self.game.theater.controlpoints: @@ -230,45 +313,7 @@ class QLiberationMap(QGraphicsView): pen = QPen(brush=CONST.COLORS[enemyColor]) brush = CONST.COLORS[enemyColor+"_transparent"] - added_objects = [] - for ground_object in cp.ground_objects: - if ground_object.obj_name in added_objects: - continue - - go_pos = self._transform_point(ground_object.position) - if not ground_object.airbase_group: - buildings = self.game.theater.find_ground_objects_by_obj_name(ground_object.obj_name) - scene.addItem(QMapGroundObject(self, go_pos[0], go_pos[1], 14, 12, cp, ground_object, self.game, buildings)) - - is_missile = isinstance(ground_object, MissileSiteGroundObject) - is_aa = ground_object.category == "aa" and not is_missile - is_ewr = isinstance(ground_object, EwrGroundObject) - is_display_type = is_aa or is_ewr - should_display = ((DisplayOptions.sam_ranges and cp.captured) - or - (DisplayOptions.enemy_sam_ranges and not cp.captured)) - - if is_display_type and should_display: - detection_range, threat_range = self.aa_ranges( - ground_object - ) - if threat_range: - threat_pos = self._transform_point(Point(ground_object.position.x+threat_range, - ground_object.position.y+threat_range)) - threat_radius = Point(*go_pos).distance_to_point(Point(*threat_pos)) - - # Add threat range circle - scene.addEllipse(go_pos[0] - threat_radius / 2 + 7, go_pos[1] - threat_radius / 2 + 6, - threat_radius, threat_radius, self.threat_pen(cp.captured)) - if detection_range: - # Add detection range circle - detection_pos = self._transform_point(Point(ground_object.position.x+detection_range, - ground_object.position.y+detection_range)) - detection_radius = Point(*go_pos).distance_to_point(Point(*detection_pos)) - if DisplayOptions.detection_range: - scene.addEllipse(go_pos[0] - detection_radius/2 + 7, go_pos[1] - detection_radius/2 + 6, - detection_radius, detection_radius, self.detection_pen(cp.captured)) - added_objects.append(ground_object.obj_name) + self.draw_ground_objects(scene, cp) for cp in self.game.theater.enemy_points(): if DisplayOptions.lines: @@ -399,36 +444,45 @@ class QLiberationMap(QGraphicsView): flight_path_pen )) + def draw_bezier_frontline(self, scene: QGraphicsScene, pen:QPen, frontline: FrontLine) -> None: + """ + Thanks to Alquimista for sharing a python implementation of the bezier algorithm this is adapted from. + https://gist.github.com/Alquimista/1274149#file-bezdraw-py + """ + bezier_fixed_points = [] + for segment in frontline.segments: + bezier_fixed_points.append(self._transform_point(segment.point_a)) + bezier_fixed_points.append(self._transform_point(segment.point_b)) + + old_point = bezier_fixed_points[0] + for point in bezier_curve_range(int(len(bezier_fixed_points) * 2), bezier_fixed_points): + scene.addLine(old_point[0], old_point[1], point[0], point[1], pen=pen) + old_point = point + def scene_create_lines_for_cp(self, cp: ControlPoint, playerColor, enemyColor): scene = self.scene() - pos = self._transform_point(cp.position) for connected_cp in cp.connected_points: pos2 = self._transform_point(connected_cp.position) if not cp.captured: color = CONST.COLORS["dark_"+enemyColor] - elif cp.captured: - color = CONST.COLORS["dark_"+playerColor] else: - color = CONST.COLORS["dark_"+enemyColor] - + color = CONST.COLORS["dark_"+playerColor] pen = QPen(brush=color) pen.setColor(color) pen.setWidth(6) + frontline = FrontLine(cp, connected_cp, self.game.theater) if cp.captured and not connected_cp.captured and Conflict.has_frontline_between(cp, connected_cp): - if not cp.captured: - scene.addLine(pos[0], pos[1], pos2[0], pos2[1], pen=pen) - else: - posx, h = Conflict.frontline_position(self.game.theater, cp, connected_cp) - pos2 = self._transform_point(posx) - scene.addLine(pos[0], pos[1], pos2[0], pos2[1], pen=pen) - - p1 = point_from_heading(pos2[0], pos2[1], h+180, 25) - p2 = point_from_heading(pos2[0], pos2[1], h, 25) - scene.addItem(QFrontLine(p1[0], p1[1], p2[0], p2[1], - FrontLine(cp, connected_cp))) + posx = frontline.position + h = frontline.attack_heading + pos2 = self._transform_point(posx) + self.draw_bezier_frontline(scene, pen, frontline) + p1 = point_from_heading(pos2[0], pos2[1], h+180, 25) + p2 = point_from_heading(pos2[0], pos2[1], h, 25) + scene.addItem(QFrontLine(p1[0], p1[1], p2[0], p2[1], + frontline, self.game_model)) else: - scene.addLine(pos[0], pos[1], pos2[0], pos2[1], pen=pen) + self.draw_bezier_frontline(scene, pen, frontline) def wheelEvent(self, event: QWheelEvent): @@ -451,7 +505,7 @@ class QLiberationMap(QGraphicsView): #print(self.factorized, factor, self._zoom) - def _transform_point(self, p: Point, treshold=30) -> (int, int): + def _transform_point(self, p: Point, treshold=30) -> Tuple[int, int]: point_a = list(self.game.theater.reference_points.keys())[0] point_a_img = self.game.theater.reference_points[point_a] @@ -501,18 +555,11 @@ class QLiberationMap(QGraphicsView): return CONST.COLORS[f"{name}_transparent"] def threat_pen(self, player: bool) -> QPen: - if player: - color = "blue" - else: - color = "red" - qpen = QPen(CONST.COLORS[color]) - return qpen + color = "blue" if player else "red" + return QPen(CONST.COLORS[color]) def detection_pen(self, player: bool) -> QPen: - if player: - color = "purple" - else: - color = "yellow" + color = "purple" if player else "yellow" qpen = QPen(CONST.COLORS[color]) qpen.setStyle(Qt.DotLine) return qpen diff --git a/resources/frontlines/caucasus.json b/resources/frontlines/caucasus.json new file mode 100644 index 00000000..2bf1b5e3 --- /dev/null +++ b/resources/frontlines/caucasus.json @@ -0,0 +1 @@ +{"28|26": {"points": [[-83519.432788948, 834083.89603137], [-76119.776928765, 743854.15425227], [-69364.629004892, 719860.24185348], [-66223.861015868, 712472.15322055], [-55063.838612323, 701790.55845189], [-51581.840958501, 704161.97484394]], "start_cp": 28}, "32|27": {"points": [[-148553.72718928, 843792.03708555], [-114733.18523211, 848507.87882073], [-93593.960629467, 841634.68667249], [-83721.151431284, 834200.8738046]], "start_cp": 32}, "32|28": {"points": [[-148554.87082704, 843689.04452871], [-114733.18523211, 845562.62834108], [-110339.43457942, 812629.20979521], [-111707.66520572, 785946.43211532], [-119336.17607842, 776106.17954928], [-124839.73032662, 760747.06391035]], "start_cp": 32}, "27|26": {"points": [[-124831.33530722, 760646.27221205], [-100762.17095534, 753347.75031825], [-86615.899059596, 729586.43747856], [-70460.285574716, 694513.4327179], [-50443.346691892, 703139.51687735]], "start_cp": 27}, "26|16": {"points": [[-51128.273849964, 705442.60983569], [-17096.829383992, 583922.76901062], [-9413.0964883513, 489204.29454645], [-26751.202695677, 458085.37842522]], "start_cp": 26}, "16|17": {"points": [[-26290.077082534, 457532.52869133], [-12087.691792587, 444620.30900361], [-9183.8957732278, 411618.86444668], [-19095.485460604, 393640.78914313], [-12512.173613316, 386130.07819866], [-6723.0801746587, 294506.97247323], [8289.1802310393, 267690.0671157], [-1709.4806012567, 249581.13338705], [-20329.725545816, 256364.19845868], [-32338.671748957, 281488.17801525], [-44457.480224214, 298277.60074695], [-50348.762426692, 298378.38229135]], "start_cp": 16}, "17|18": {"points": [[-50428.449268252, 298385.98215495], [-47925.882995864, 299522.84586356], [-47742.307667018, 302423.33605932], [-51670.819704316, 306425.27822816], [-52890.978467907, 309683.60757879], [-52158.285335864, 315248.49431547], [-54476.310454094, 321830.02991902], [-61058.499192715, 325379.38150096], [-61379.264542506, 327677.41312797], [-59644.880081364, 329300.50019054], [-59830.375745657, 332806.36824568], [-63794.637882896, 337172.7521818], [-67470.778941327, 340636.53352785], [-70179.929699705, 341613.85524925], [-69660.171385319, 350494.94296899], [-69388.993134334, 354630.4112965], [-75106.334592587, 354856.39317232], [-76281.440346853, 358652.8886861], [-81388.630740391, 366494.45977706], [-87241.561324136, 371036.69548105], [-89840.352896069, 379895.1850132], [-95874.068980471, 384753.79534334], [-98992.618866791, 390742.31505257], [-105500.89689041, 395510.53263238], [-109975.33803166, 401747.63240502], [-112664.52235392, 406199.47535868], [-118042.89099844, 411623.04037836], [-119433.38420159, 414545.36512974], [-121777.77888263, 415031.75406772], [-125094.95143961, 417580.4321027], [-127809.00171351, 419876.18788994], [-131233.17983685, 423387.91602212], [-134219.60791602, 427162.2941808], [-142354.54855856, 436882.58763511], [-142151.02610079, 439008.26663852], [-144141.02346569, 440738.2075296], [-154337.7160827, 451791.06647059], [-156349.9178749, 457412.78487971], [-161121.11800072, 459238.28753654], [-164180.10148029, 461855.90812217]], "start_cp": 17}, "18|21": {"points": [[-164264.74921603, 462240.8713078], [-170274.83407271, 474599.20385019], [-172409.88129215, 477254.97986699], [-173104.00129217, 479130.75653371], [-174533.55795887, 482196.45320045], [-175268.99462556, 485708.3698672], [-176186.22462558, 487716.35986724], [-177285.24795894, 488666.6432006], [-178400.79795897, 489162.44320061], [-180541.00129235, 490542.41986731], [-183995.07462577, 493831.22653406], [-185517.42530201, 501467.08245066], [-185474.55938496, 507404.01196323], [-187746.45298904, 511540.57295936], [-190489.87168078, 513490.97218551], [-196791.16148834, 516405.85454547]], "start_cp": 18}, "21|20": {"points": [[-196714.42589455, 516411.96814195], [-197450.59049978, 523168.80433533], [-197732.15105425, 528120.95761683], [-197699.02628313, 533768.73109171], [-198311.83454874, 540807.74495336], [-198842.52672667, 546607.07868341], [-199402.89801799, 549442.89703645], [-200896.04977609, 551418.00206698], [-202121.5817902, 551281.83184319], [-202802.43290916, 551962.68296214], [-202604.3671291, 553881.44520647], [-204919.26093354, 558771.19415168], [-207135.12184795, 559043.53459927], [-209821.38899001, 559390.14971437], [-212321.96946326, 561717.42263007], [-214993.578381, 562726.87596614], [-220482.29198081, 564029.17214026]], "start_cp": 21}, "20|23": {"points": [[-220672.07816602, 564666.16009935], [-225466.53293841, 577226.74074072], [-225867.80020827, 582560.25153588], [-226971.28520037, 588278.31013131], [-229011.0604888, 592424.73858648], [-232087.44289102, 595718.47409321], [-233993.46242283, 598192.95559065], [-234745.0795704, 605985.79430519], [-238229.0345155, 610496.39618843], [-245013.37774879, 611843.03758538], [-252127.56713626, 613492.22847069], [-260588.37132113, 620015.44606012], [-270266.03318718, 624574.33114385], [-274285.71028992, 632588.96571051], [-278888.32106515, 640507.86809234], [-281561.45308744, 647019.85888349]], "start_cp": 20}, "23|24": {"points": [[-281730.7296916, 647253.59774005], [-290982.81972459, 649906.82826413], [-294663.23013322, 650003.68116962], [-298488.96273308, 642238.09370951], [-301465.17238261, 635293.60452728], [-305217.78454941, 628521.64923777], [-309876.19965302, 626882.57725687], [-316044.28631799, 627745.2467205], [-318472.62171129, 628904.60667428], [-317887.74401676, 635471.94764428]], "start_cp": 23}, "24|25": {"points": [[-317873.22860422, 635639.0650959], [-312899.88159494, 646184.05618911], [-306709.82545376, 651773.13600592], [-302953.72342634, 651502.69665994], [-299948.8418044, 651262.30613019], [-297935.57111771, 652704.64930872], [-296162.69096076, 656130.21435772], [-295856.9172118, 663129.98092517], [-295423.18658872, 668374.17845869], [-293700.92883347, 671849.09769727], [-290615.92415898, 672244.90961777], [-288066.42972986, 674165.76158491], [-286820.78633299, 677600.01207161], [-286893.79300875, 679835.68894319], [-286618.90964092, 682436.5085003], [-285244.4928018, 683705.20096718]], "start_cp": 24}, "25|23": {"points": [[-284669.68592925, 683920.85836338], [-284033.62440161, 671710.30738432], [-284533.77033624, 669857.91503381], [-285774.87321108, 669765.29541629], [-288423.79427231, 670358.06096845], [-288982.2616923, 669926.15330961], [-288459.09137937, 667542.82188402], [-285718.97087515, 657267.36992099], [-281630.5138127, 647419.52117057]], "start_cp": 25}, "25|31": {"points": [[-284263.99293597, 684700.54580073], [-272604.87474782, 700374.86510124], [-273331.93020798, 704467.91806214], [-277532.6950889, 709395.73840322], [-281410.32420975, 715562.24582457], [-282763.16813743, 718965.99542922], [-284038.34156377, 723212.87736217], [-285647.59520339, 726629.3333984], [-286742.41427361, 730713.85069882], [-290637.44365803, 734503.60901881], [-294658.79831979, 742062.07144589], [-297037.92437623, 747430.89573254], [-299143.34566511, 751157.49141386], [-299648.64677444, 755747.30982361], [-297543.22548556, 760400.29087204], [-293500.81661091, 766927.09686757], [-290805.87736114, 772043.27059955], [-294728.00259664, 779192.46248204], [-289131.68727782, 794537.19803365], [-284302.60873658, 803382.98676341], [-286153.0033178, 818998.51176593], [-283670.76668445, 834568.9051933], [-290756.42398328, 855871.00866495], [-293497.37680705, 872691.40288022], [-298728.59569045, 876959.26028386], [-302426.32111763, 886345.79406053], [-308456.45796809, 893001.69982944], [-313415.88310948, 898749.18653378], [-318952.43355462, 903032.81497309]], "start_cp": 25}, "31|32": {"points": [[-319413.50770939, 903161.13927527], [-299973.76567703, 879091.99809442], [-288926.34291968, 873766.98136965], [-272951.29274537, 873210.63633871], [-262767.79330839, 883418.21980792], [-255300.09049795, 886097.47196129], [-240022.65268728, 889745.81531906], [-225543.28998612, 892596.08356732], [-216365.42622672, 890999.9333483], [-213743.17943832, 883418.21980792], [-215007.91769269, 872553.78042396], [-217795.79179069, 862875.12317806], [-216059.9456542, 857983.19315703], [-212483.05058506, 853985.48690329], [-205939.33685399, 854213.80611265], [-200950.59208156, 852508.09558384], [-194072.72704603, 852086.25319499], [-191404.28495595, 852694.64158494], [-188982.449823, 852694.64158494], [-186034.22200645, 852522.66532349], [-183530.30158366, 851166.88402139], [-180342.38338683, 851032.52731578], [-178180.46185106, 851948.59576314], [-175212.40008161, 852779.16448874], [-171877.91093321, 852962.37817822], [-167460.79156926, 854118.55241154], [-163919.58243455, 854146.2181079], [-159410.07392707, 852181.95366599], [-155370.88225779, 848557.74744219], [-151193.36210669, 845265.52957477], [-148897.1093084, 843799.24766743]], "start_cp": 31}} \ No newline at end of file diff --git a/resources/mizdata/caucasus/caucusus_frontline.miz b/resources/mizdata/caucasus/caucusus_frontline.miz new file mode 100644 index 00000000..d392b5ce Binary files /dev/null and b/resources/mizdata/caucasus/caucusus_frontline.miz differ diff --git a/resources/tools/generate_frontlines.py b/resources/tools/generate_frontlines.py new file mode 100644 index 00000000..c1346bc9 --- /dev/null +++ b/resources/tools/generate_frontlines.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import json +import argparse +from pathlib import Path +from typing import List, Tuple, Union, Dict + +from dcs.terrain import Caucasus, PersianGulf, Syria, Nevada, Normandy, TheChannel +from dcs import Mission + +Terrain = Union[Caucasus, PersianGulf, Syria, Nevada, Normandy, TheChannel] + +SAVE_PATH = Path("resources/frontlines") + +def validate_miz(file_path: Path) -> bool: + return bool(file_path.suffix == ".miz" and file_path.exists()) + +def validate_airports(airports: Tuple[int], terrain: Terrain): + for airport in airports: + if terrain.airport_by_id(airport) is None: + print(f"Cannot load airport for invalid id {airport}") + +def load_files(files) -> List[Mission]: + missions = [] + for file in files: + if validate_miz(file): + mission = Mission() + mission.load_file(file) + missions.append(mission) + else: + print(f"Error: {file} doesn't look like a valid mission file.") + return missions + +def create_frontline_dict(mission: Mission) -> Dict[str, Dict]: + frontline_dict = {} + for group in mission.country("USA").vehicle_group: + groupname = str(group.name).replace(group.name.id, "").replace(":","") + control_points = groupname.split("|") + frontline_dict[groupname] = { + "points": [(i.position.x, i.position.y) for i in group.points], + "start_cp": int(control_points[0]) + } + return frontline_dict + +def process_missions(missions: List[Mission]) -> None: + for mission in missions: + frontline_dict = create_frontline_dict(mission) + write_json(frontline_dict, mission.terrain.name.lower()) + +def write_json(frontline_dict: Dict[str, Dict], terrain_name: str) -> None: + with open(SAVE_PATH.joinpath(terrain_name + ".json"), "w") as file: + json.dump(frontline_dict, file) + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Process a miz file to create json descriptions of multi-segment frontlines" + ) + parser.add_argument( + "files", + metavar="N", + type=Path, + nargs="+", + help="A list of space separated .miz files to extract frontlines from", + ) + + args = parser.parse_args() + missions = load_files(args.files) + process_missions(missions) + # frontline_dict = create_frontline_dict(missions[0]) + + print("Done") + + + 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 373eb959..78f3c052 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,11 +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 -if TYPE_CHECKING: - from . import FrontLine +Numeric = Union[int, float] SIZE_TINY = 150 SIZE_SMALL = 600 @@ -56,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 @@ -67,9 +82,11 @@ 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 = FrontLine.load_json_frontlines(self) """ self.land_poly = geometry.Polygon(self.landmap[0][0]) for x in self.landmap[1]: @@ -128,10 +145,9 @@ class ConflictTheater: return [point for point in self.controlpoints if point.captured] def conflicts(self, from_player=True) -> Iterator[FrontLine]: - from . import FrontLine # Circular import that needs to be resolved. 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] @@ -169,7 +185,7 @@ class ConflictTheater: cp.captured_invert = False return cp - + @staticmethod def from_json(data: Dict[str, Any]) -> ConflictTheater: theaters = { @@ -183,7 +199,6 @@ class ConflictTheater: theater = theaters[data["theater"]] t = theater() cps = {} - for p in data["player_points"]: cp = t.add_json_cp(theater, p) cp.captured = True @@ -200,7 +215,7 @@ class ConflictTheater: cps[l[1]].connect(cps[l[0]]) return t - + class CaucasusTheater(ConflictTheater): terrain = caucasus.Caucasus() @@ -232,7 +247,6 @@ class PersianGulfTheater(ConflictTheater): "night": (0, 5), } - class NevadaTheater(ConflictTheater): terrain = nevada.Nevada() overview_image = "nevada.gif" @@ -246,7 +260,6 @@ class NevadaTheater(ConflictTheater): "night": (0, 5), } - class NormandyTheater(ConflictTheater): terrain = normandy.Normandy() overview_image = "normandy.gif" @@ -260,7 +273,6 @@ class NormandyTheater(ConflictTheater): "night": (0, 5), } - class TheChannelTheater(ConflictTheater): terrain = thechannel.TheChannel() overview_image = "thechannel.gif" @@ -274,7 +286,6 @@ class TheChannelTheater(ConflictTheater): "night": (0, 5), } - class SyriaTheater(ConflictTheater): terrain = syria.Syria() overview_image = "syria.gif" @@ -287,3 +298,209 @@ class SyriaTheater(ConflictTheater): "dusk": (16, 18), "night": (0, 5), } + +@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}" + + def is_friendly(self, to_player: bool) -> bool: + """Returns True if the objective is in friendly territory.""" + return False + + @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 0ef17079..6177b6a6 100644 --- a/theater/frontline.py +++ b/theater/frontline.py @@ -1,47 +1,2 @@ -"""Battlefield front lines.""" -from typing import Tuple - -from dcs.mapping import Point -from . import ControlPoint, MissionTarget - -# 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) - - 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 - - -class FrontLine(MissionTarget): - """Defines a front line location between two control points. - - Front lines are the area where ground combat happens. - """ - - 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)) - self.control_point_a = control_point_a - self.control_point_b = control_point_b - - @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 - - def is_friendly(self, to_player: bool) -> bool: - return False +"""Only here to keep compatibility for save games generated in version 2.2.0""" +from theater.conflicttheater import * diff --git a/theater/start_generator.py b/theater/start_generator.py index eb0252c4..f074218d 100644 --- a/theater/start_generator.py +++ b/theater/start_generator.py @@ -73,7 +73,6 @@ class GameGenerator: namegen.reset() self.prepare_theater() self.populate_red_airbases() - game = Game(player_name=self.player, enemy_name=self.enemy, theater=self.theater, @@ -89,7 +88,6 @@ class GameGenerator: def prepare_theater(self) -> None: to_remove = [] - # Auto-capture half the bases if midgame. if self.midgame: control_points = self.theater.controlpoints