diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index c0b373ce..d6605c47 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -1,13 +1,22 @@ from __future__ import annotations -import logging +import itertools import json +import logging from dataclasses import dataclass +from functools import cached_property from itertools import tee from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple, Union +from dcs import Mission +from dcs.countries import ( + CombinedJointTaskForcesBlue, + CombinedJointTaskForcesRed, +) +from dcs.country import Country from dcs.mapping import Point +from dcs.ships import CVN_74_John_C__Stennis, LHA_1_Tarawa from dcs.terrain import ( caucasus, nevada, @@ -16,11 +25,14 @@ from dcs.terrain import ( syria, thechannel, ) -from dcs.terrain.terrain import Terrain +from dcs.terrain.terrain import Airport, Terrain +from dcs.unitgroup import MovingGroup, ShipGroup, VehicleGroup +from dcs.vehicles import AirDefence, Armor from gen.flights.flight import FlightType from .controlpoint import ControlPoint, MissionTarget from .landmap import Landmap, load_landmap, poly_contains +from ..utils import nm_to_meter Numeric = Union[int, float] @@ -73,6 +85,193 @@ def pairwise(iterable): return zip(a, b) +class MizCampaignLoader: + BLUE_COUNTRY = CombinedJointTaskForcesBlue() + RED_COUNTRY = CombinedJointTaskForcesRed() + + CV_UNIT_TYPE = CVN_74_John_C__Stennis.id + LHA_UNIT_TYPE = LHA_1_Tarawa.id + FRONT_LINE_UNIT_TYPE = Armor.APC_M113.id + + EWR_UNIT_TYPE = AirDefence.EWR_55G6.id + SAM_UNIT_TYPE = AirDefence.SAM_SA_10_S_300PS_SR_64H6E.id + GARRISON_UNIT_TYPE = AirDefence.SAM_SA_19_Tunguska_2S6.id + + BASE_DEFENSE_RADIUS = nm_to_meter(2) + + def __init__(self, miz: Path, theater: ConflictTheater) -> None: + self.theater = theater + self.mission = Mission() + self.mission.load_file(str(miz)) + self.control_point_id = itertools.count(1000) + + # If there are no red carriers there usually aren't red units. Make sure + # both countries are initialized so we don't have to deal with None. + if self.mission.country(self.BLUE_COUNTRY.name) is None: + self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY) + if self.mission.country(self.RED_COUNTRY.name) is None: + self.mission.coalition["red"].add_country(self.RED_COUNTRY) + + @staticmethod + def control_point_from_airport(airport: Airport) -> ControlPoint: + # TODO: Radials? + radials = LAND + + # The wiki says this is a legacy property and to just use regular. + size = SIZE_REGULAR + + # The importance is taken from the periodicity of the airport's + # warehouse divided by 10. 30 is the default, and out of range (valid + # values are between 1.0 and 1.4). If it is used, pick the default + # importance. + if airport.periodicity == 30: + importance = IMPORTANCE_MEDIUM + else: + importance = airport.periodicity / 10 + + cp = ControlPoint.from_airport(airport, radials, size, importance) + cp.captured = airport.is_blue() + + # Use the unlimited aircraft option to determine if an airfield should + # be owned by the player when the campaign is "inverted". + cp.captured_invert = airport.unlimited_aircrafts + + return cp + + def country(self, blue: bool) -> Country: + country = self.mission.country( + self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name) + # Should be guaranteed because we initialized them. + assert country + return country + + @property + def blue(self) -> Country: + return self.country(blue=True) + + @property + def red(self) -> Country: + return self.country(blue=False) + + def carriers(self, blue: bool) -> Iterator[ShipGroup]: + for group in self.country(blue).ship_group: + if group.units[0].type == self.CV_UNIT_TYPE: + yield group + + def lhas(self, blue: bool) -> Iterator[ShipGroup]: + for group in self.country(blue).ship_group: + if group.units[0].type == self.LHA_UNIT_TYPE: + yield group + + @property + def ewrs(self) -> Iterator[VehicleGroup]: + for group in self.blue.vehicle_group: + if group.units[0].type == self.EWR_UNIT_TYPE: + yield group + + @property + def sams(self) -> Iterator[VehicleGroup]: + for group in self.blue.vehicle_group: + if group.units[0].type == self.SAM_UNIT_TYPE: + yield group + + @property + def garrisons(self) -> Iterator[VehicleGroup]: + for group in self.blue.vehicle_group: + if group.units[0].type == self.GARRISON_UNIT_TYPE: + yield group + + @cached_property + def control_points(self) -> Dict[int, ControlPoint]: + control_points = {} + for airport in self.mission.terrain.airport_list(): + if airport.is_blue() or airport.is_red(): + control_point = self.control_point_from_airport(airport) + control_points[control_point.id] = control_point + + for blue in (False, True): + for group in self.carriers(blue): + control_point = ControlPoint.carrier( + "carrier", group.position, next(self.control_point_id)) + control_point.captured = blue + control_point.captured_invert = group.late_activation + control_points[control_point.id] = control_point + for group in self.lhas(blue): + control_point = ControlPoint.lha( + "lha", group.position, next(self.control_point_id)) + control_point.captured = blue + control_point.captured_invert = group.late_activation + control_points[control_point.id] = control_point + + return control_points + + @property + def front_line_path_groups(self) -> Iterator[VehicleGroup]: + for group in self.country(blue=True).vehicle_group: + if group.units[0].type == self.FRONT_LINE_UNIT_TYPE: + yield group + + @cached_property + 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: + # The unit will have its first waypoint at the source CP and the + # final waypoint at the destination CP. Intermediate waypoints + # define the curve of the front line. + waypoints = [p.position for p in group.points] + origin = self.mission.terrain.nearest_airport(waypoints[0]) + if origin is None: + raise RuntimeError( + f"No airport near the first waypoint of {group.name}") + destination = self.mission.terrain.nearest_airport(waypoints[-1]) + if destination is None: + raise RuntimeError( + f"No airport near the final waypoint of {group.name}") + + # Snap the begin and end points to the control points. + waypoints[0] = origin.position + waypoints[-1] = destination.position + front_line_id = f"{origin.id}|{destination.id}" + front_lines[front_line_id] = ComplexFrontLine(origin, waypoints) + self.control_points[origin.id].connect( + self.control_points[destination.id]) + self.control_points[destination.id].connect( + self.control_points[origin.id]) + return front_lines + + def objective_info(self, group: MovingGroup) -> Tuple[ControlPoint, int]: + closest = self.theater.closest_control_point(group.position) + distance = closest.position.distance_to_point(group.position) + return closest, distance + + def add_preset_locations(self) -> None: + for group in self.garrisons: + closest, distance = self.objective_info(group) + if distance < self.BASE_DEFENSE_RADIUS: + closest.preset_locations.base_garrisons.append(group.position) + else: + logging.warning( + f"Found garrison unit too far from base: {group.name}") + + for group in self.sams: + closest, distance = self.objective_info(group) + if distance < self.BASE_DEFENSE_RADIUS: + closest.preset_locations.base_air_defense.append(group.position) + else: + closest.preset_locations.sams.append(group.position) + + for group in self.ewrs: + closest, distance = self.objective_info(group) + closest.preset_locations.ewrs.append(group.position) + + def populate_theater(self) -> None: + for control_point in self.control_points.values(): + self.theater.add_controlpoint(control_point) + self.add_preset_locations() + self.theater.set_frontline_data(self.front_lines) + + class ConflictTheater: terrain: Terrain @@ -83,17 +282,35 @@ class ConflictTheater: land_poly = None # type: Polygon """ daytime_map: Dict[str, Tuple[int, int]] - frontline_data: Optional[Dict[str, ComplexFrontLine]] = None + _frontline_data: Optional[Dict[str, ComplexFrontLine]] = None def __init__(self): self.controlpoints: List[ControlPoint] = [] - self.frontline_data = FrontLine.load_json_frontlines(self) + self._frontline_data: Optional[Dict[str, ComplexFrontLine]] = None """ self.land_poly = geometry.Polygon(self.landmap[0][0]) for x in self.landmap[1]: self.land_poly = self.land_poly.difference(geometry.Polygon(x)) """ + @property + def frontline_data(self) -> Optional[Dict[str, ComplexFrontLine]]: + if self._frontline_data is None: + self.load_frontline_data_from_file() + return self._frontline_data + + def load_frontline_data_from_file(self) -> None: + if self._frontline_data is not None: + logging.warning("Replacing existing frontline data from file") + self._frontline_data = FrontLine.load_json_frontlines(self) + if self._frontline_data is None: + self._frontline_data = {} + + def set_frontline_data(self, data: Dict[str, ComplexFrontLine]) -> None: + if self._frontline_data is not None: + logging.warning("Replacing existing frontline data") + self._frontline_data = data + def add_controlpoint(self, point: ControlPoint, connected_to: Optional[List[ControlPoint]] = None): if connected_to is None: @@ -153,11 +370,21 @@ class ConflictTheater: def enemy_points(self) -> List[ControlPoint]: return [point for point in self.controlpoints if not point.captured] + def closest_control_point(self, point: Point) -> ControlPoint: + closest = self.controlpoints[0] + closest_distance = point.distance_to_point(closest.position) + for control_point in self.controlpoints[1:]: + distance = point.distance_to_point(control_point.position) + if distance < closest_distance: + closest = control_point + closest_distance = distance + return closest + def add_json_cp(self, theater, p: dict) -> ControlPoint: if p["type"] == "airbase": - airbase = theater.terrain.airports[p["id"]].__class__ + airbase = theater.terrain.airports[p["id"]] if "radials" in p.keys(): radials = p["radials"] @@ -188,7 +415,7 @@ class ConflictTheater: return cp @staticmethod - def from_json(data: Dict[str, Any]) -> ConflictTheater: + def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater: theaters = { "Caucasus": CaucasusTheater, "Nevada": NevadaTheater, @@ -199,6 +426,12 @@ class ConflictTheater: } theater = theaters[data["theater"]] t = theater() + + miz = data.get("miz", None) + if miz is not None: + MizCampaignLoader(directory / miz, t).populate_theater() + return t + cps = {} for p in data["player_points"]: cp = t.add_json_cp(theater, p) @@ -376,10 +609,6 @@ class FrontLine(MissionTarget): """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""" diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 46ac7e00..2e682107 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -1,9 +1,11 @@ from __future__ import annotations import itertools +import random import re +from dataclasses import dataclass, field from enum import Enum -from typing import Dict, Iterator, List, TYPE_CHECKING +from typing import Dict, Iterator, List, Optional, TYPE_CHECKING from dcs.mapping import Point from dcs.ships import ( @@ -36,6 +38,43 @@ class ControlPointType(Enum): FOB = 5 # A FOB (ground units only) +@dataclass +class PresetLocations: + base_garrisons: List[Point] = field(default_factory=list) + base_air_defense: List[Point] = field(default_factory=list) + + ewrs: List[Point] = field(default_factory=list) + sams: List[Point] = field(default_factory=list) + coastal_defenses: List[Point] = field(default_factory=list) + strike_locations: List[Point] = field(default_factory=list) + + @staticmethod + def _random_from(points: List[Point]) -> Optional[Point]: + if not points: + return None + point = random.choice(points) + points.remove(point) + return point + + def random_garrison(self) -> Optional[Point]: + return self._random_from(self.base_garrisons) + + def random_base_sam(self) -> Optional[Point]: + return self._random_from(self.base_air_defense) + + def random_ewr(self) -> Optional[Point]: + return self._random_from(self.ewrs) + + def random_sam(self) -> Optional[Point]: + return self._random_from(self.sams) + + def random_coastal_defense(self) -> Optional[Point]: + return self._random_from(self.coastal_defenses) + + def random_strike_location(self) -> Optional[Point]: + return self._random_from(self.strike_locations) + + class ControlPoint(MissionTarget): position = None # type: Point @@ -57,6 +96,7 @@ class ControlPoint(MissionTarget): self.at = at self.connected_objectives: List[TheaterGroundObject] = [] self.base_defenses: List[BaseDefenseGroundObject] = [] + self.preset_locations = PresetLocations() self.size = size self.importance = importance @@ -79,7 +119,7 @@ class ControlPoint(MissionTarget): def from_airport(cls, airport: Airport, radials: List[int], size: int, importance: float, has_frontline=True): assert airport obj = cls(airport.id, airport.name, airport.position, airport, radials, size, importance, has_frontline, cptype=ControlPointType.AIRBASE) - obj.airport = airport() + obj.airport = airport return obj @classmethod @@ -157,7 +197,7 @@ class ControlPoint(MissionTarget): else: return 0 - def connect(self, to): + def connect(self, to: ControlPoint) -> None: self.connected_points.append(to) self.stances[to.id] = CombatStance.DEFENSIVE diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 95bc1c69..b16302d9 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -4,7 +4,7 @@ import logging import math import pickle import random -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike @@ -13,6 +13,17 @@ from dcs.vehicles import AirDefence from game import Game, db from game.factions.faction import Faction from game.settings import Settings +from game.theater.conflicttheater import IMPORTANCE_HIGH, IMPORTANCE_LOW +from game.theater.theatergroundobject import ( + BuildingGroundObject, + CarrierGroundObject, + EwrGroundObject, + LhaGroundObject, + MissileSiteGroundObject, + SamGroundObject, + ShipGroundObject, + VehicleGroupGroundObject, +) from game.version import VERSION from gen import namegen from gen.defenses.armor_group_generator import generate_armor_group @@ -34,17 +45,6 @@ from theater import ( ControlPointType, TheaterGroundObject, ) -from game.theater.conflicttheater import IMPORTANCE_HIGH, IMPORTANCE_LOW -from game.theater.theatergroundobject import ( - EwrGroundObject, - SamGroundObject, - BuildingGroundObject, - CarrierGroundObject, - LhaGroundObject, - MissileSiteGroundObject, - ShipGroundObject, - VehicleGroupGroundObject, -) GroundObjectTemplates = Dict[str, Dict[str, Any]] @@ -317,10 +317,9 @@ class BaseDefenseGenerator: self.generate_base_defenses() def generate_ewr(self) -> None: - position = self._find_location() + position = self._find_location( + "EWR", self.control_point.preset_locations.random_ewr) if position is None: - logging.error("Could not find position for " - f"{self.control_point} EWR") return group_id = self.game.next_group_id() @@ -350,10 +349,9 @@ class BaseDefenseGenerator: self.generate_garrison() def generate_garrison(self) -> None: - position = self._find_location() + position = self._find_location( + "garrison", self.control_point.preset_locations.random_garrison) if position is None: - logging.error("Could not find position for " - f"{self.control_point} garrison") return group_id = self.game.next_group_id() @@ -368,10 +366,9 @@ class BaseDefenseGenerator: self.control_point.base_defenses.append(g) def generate_sam(self) -> None: - position = self._find_location() + position = self._find_location( + "SAM", self.control_point.preset_locations.random_base_sam) if position is None: - logging.error("Could not find position for " - f"{self.control_point} SAM") return group_id = self.game.next_group_id() @@ -385,10 +382,9 @@ class BaseDefenseGenerator: self.control_point.base_defenses.append(g) def generate_shorad(self) -> None: - position = self._find_location() + position = self._find_location( + "SHORAD", self.control_point.preset_locations.random_garrison) if position is None: - logging.error("Could not find position for " - f"{self.control_point} SHORAD") return group_id = self.game.next_group_id() @@ -401,7 +397,21 @@ class BaseDefenseGenerator: g.groups.append(group) self.control_point.base_defenses.append(g) - def _find_location(self) -> Optional[Point]: + def _find_location(self, position_type: str, + get_preset: Callable[[], None]) -> Optional[Point]: + position = get_preset() + if position is None: + logging.warning( + f"Found no preset location for {self.control_point} " + f"{position_type}. Falling back to random location." + ) + position = self._find_random_location() + if position is None: + logging.error("Could not find position for " + f"{self.control_point} {position_type}.") + return position + + def _find_random_location(self) -> Optional[Point]: position = find_location(True, self.control_point.position, self.game.theater, 400, 3200, [], True) diff --git a/qt_ui/windows/newgame/QCampaignList.py b/qt_ui/windows/newgame/QCampaignList.py index 617869bc..822cfaca 100644 --- a/qt_ui/windows/newgame/QCampaignList.py +++ b/qt_ui/windows/newgame/QCampaignList.py @@ -29,8 +29,10 @@ class Campaign: data = json.load(campaign_file) sanitized_theater = data["theater"].replace(" ", "") - return cls(data["name"], f"Terrain_{sanitized_theater}", data.get("authors", "???"), - data.get("description", ""), ConflictTheater.from_json(data)) + return cls(data["name"], f"Terrain_{sanitized_theater}", + data.get("authors", "???"), + data.get("description", ""), + ConflictTheater.from_json(path.parent, data)) def load_campaigns() -> List[Campaign]: diff --git a/resources/campaigns/inherent_resolve.json b/resources/campaigns/inherent_resolve.json index fc5969a5..66befcd5 100644 --- a/resources/campaigns/inherent_resolve.json +++ b/resources/campaigns/inherent_resolve.json @@ -3,82 +3,5 @@ "theater": "Syria", "authors": "Khopa", "description": "

In this scenario, you start from Jordan, and have to fight your way through eastern Syria.

", - "player_points": [ - { - "type": "airbase", - "id": "King Hussein Air College", - "size": 1000, - "importance": 1.4 - }, - { - "type": "airbase", - "id": "Incirlik", - "size": 1000, - "importance": 1.4, - "captured_invert": true - }, - { - "type": "carrier", - "id": 1001, - "x": -210000, - "y": -200000, - "captured_invert": true - }, - { - "type": "lha", - "id": 1002, - "x": -131000, - "y": -161000, - "captured_invert": true - } - ], - "enemy_points": [ - { - "type": "airbase", - "id": "Khalkhalah", - "size": 1000, - "importance": 1.2 - }, - { - "type": "airbase", - "id": "Palmyra", - "size": 1000, - "importance": 1 - }, - { - "type": "airbase", - "id": "Tabqa", - "size": 1000, - "importance": 1 - }, - { - "type": "airbase", - "id": "Jirah", - "size": 1000, - "importance": 1, - "captured_invert": true - } - ], - "links": [ - [ - "Khalkhalah", - "King Hussein Air College" - ], - [ - "Incirlik", - "Incirlik" - ], - [ - "Khalkhalah", - "Palmyra" - ], - [ - "Palmyra", - "Tabqa" - ], - [ - "Jirah", - "Tabqa" - ] - ] + "miz": "inherent_resolve.miz" } \ No newline at end of file diff --git a/resources/campaigns/inherent_resolve.miz b/resources/campaigns/inherent_resolve.miz new file mode 100644 index 00000000..bee1a2d2 Binary files /dev/null and b/resources/campaigns/inherent_resolve.miz differ