diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 2e682107..f9ff79d8 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -1,6 +1,7 @@ from __future__ import annotations import itertools +import logging import random import re from dataclasses import dataclass, field @@ -38,6 +39,19 @@ class ControlPointType(Enum): FOB = 5 # A FOB (ground units only) +class LocationType(Enum): + BaseAirDefense = "base air defense" + Coastal = "coastal defense" + Ewr = "EWR" + Garrison = "garrison" + MissileSite = "missile site" + OffshoreStrikeTarget = "offshore strike target" + Sam = "SAM" + Ship = "ship" + Shorad = "SHORAD" + StrikeTarget = "strike target" + + @dataclass class PresetLocations: base_garrisons: List[Point] = field(default_factory=list) @@ -45,9 +59,12 @@ class PresetLocations: ewrs: List[Point] = field(default_factory=list) sams: List[Point] = field(default_factory=list) + offshore: List[Point] = field(default_factory=list) coastal_defenses: List[Point] = field(default_factory=list) strike_locations: List[Point] = field(default_factory=list) + fixed_sams: List[Point] = field(default_factory=list) + @staticmethod def _random_from(points: List[Point]) -> Optional[Point]: if not points: @@ -56,23 +73,25 @@ class PresetLocations: 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) + def random_for(self, location_type: LocationType) -> Optional[Point]: + if location_type == LocationType.Garrison: + return self._random_from(self.base_garrisons) + if location_type == LocationType.Sam: + return self._random_from(self.sams) + if location_type == LocationType.BaseAirDefense: + return self._random_from(self.base_air_defense) + if location_type == LocationType.Ewr: + return self._random_from(self.ewrs) + if location_type == LocationType.Shorad: + return self._random_from(self.base_garrisons) + if location_type == LocationType.OffshoreStrikeTarget: + return self._random_from(self.offshore) + if location_type == LocationType.Ship: + return self._random_from(self.offshore) + if location_type == LocationType.StrikeTarget: + return self._random_from(self.strike_locations) + logging.error(f"Unknown location type: {location_type}") + return None class ControlPoint(MissionTarget): diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index b16302d9..35a829fb 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, Callable, Dict, List, Optional +from typing import Any, Dict, Optional from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike @@ -13,6 +13,7 @@ from dcs.vehicles import AirDefence from game import Game, db from game.factions.faction import Faction from game.settings import Settings +from game.theater import LocationType from game.theater.conflicttheater import IMPORTANCE_HIGH, IMPORTANCE_LOW from game.theater.theatergroundobject import ( BuildingGroundObject, @@ -32,8 +33,7 @@ from gen.fleet.ship_group_generator import ( generate_lha_group, generate_ship_group, ) -from gen.locations.preset_location_finder import PresetLocationFinder -from gen.locations.preset_locations import PresetLocation +from gen.locations.preset_location_finder import MizDataLocationFinder from gen.missiles.missiles_group_generator import generate_missile_group from gen.sam.sam_group_generator import ( generate_anti_air_group, @@ -164,11 +164,155 @@ class GameGenerator: control_point.base.commision_units({unit_type: count_per_type}) +class LocationFinder: + def __init__(self, game: Game, control_point: ControlPoint) -> None: + self.game = game + self.control_point = control_point + self.miz_data = MizDataLocationFinder.compute_possible_locations( + game.theater.terrain.name, control_point.full_name) + + def location_for(self, location_type: LocationType) -> Optional[Point]: + position = self.control_point.preset_locations.random_for(location_type) + if position is not None: + return position + + logging.warning(f"No campaign location for %s at %s", + location_type.value, self.control_point) + position = self.random_from_miz_data( + location_type == LocationType.OffshoreStrikeTarget) + if position is not None: + return position + + logging.debug(f"No mizdata location for %s at %s", location_type.value, + self.control_point) + position = self.random_position(location_type) + if position is not None: + return position + + logging.error(f"Could not find position for %s at %s", + location_type.value, self.control_point) + return None + + def random_from_miz_data(self, offshore: bool) -> Optional[Point]: + if offshore: + locations = self.miz_data.offshore_locations + else: + locations = self.miz_data.ashore_locations + if self.miz_data.offshore_locations: + preset = random.choice(locations) + locations.remove(preset) + return preset.position + return None + + def random_position(self, location_type: LocationType) -> Optional[Point]: + # TODO: Flesh out preset locations so we never hit this case. + logging.warning("Falling back to random location for %s at %s", + location_type.value, self.control_point) + + is_base_defense = location_type in { + LocationType.BaseAirDefense, + LocationType.Garrison, + LocationType.Shorad, + } + + on_land = location_type not in { + LocationType.OffshoreStrikeTarget, + LocationType.Ship, + } + + avoid_others = location_type not in { + LocationType.Garrison, + LocationType.MissileSite, + LocationType.Sam, + LocationType.Ship, + LocationType.Shorad, + } + + if is_base_defense: + min_range = 400 + max_range = 3200 + elif location_type == LocationType.Ship: + min_range = 5000 + max_range = 40000 + elif location_type == LocationType.MissileSite: + min_range = 2500 + max_range = 40000 + else: + min_range = 10000 + max_range = 40000 + + position = self._find_random_position(min_range, max_range, + on_land, is_base_defense, + avoid_others) + + # Retry once, searching a bit further (On some big airbases, 3200 is too + # short (Ex : Incirlik)), but searching farther on every base would be + # problematic, as some base defense units would end up very far away + # from small airfields. + if position is None and is_base_defense: + position = self._find_random_position(3200, 4800, + on_land, is_base_defense, + avoid_others) + return position + + def _find_random_position(self, min_range: int, max_range: int, + on_ground: bool, is_base_defense: bool, + avoid_others: bool) -> Optional[Point]: + """ + Find a valid ground object location + :param on_ground: Whether it should be on ground or on sea (True = on + ground) + :param theater: Theater object + :param min_range: Minimal range from point + :param max_range: Max range from point + :param is_base_defense: True if the location is for base defense. + :return: + """ + near = self.control_point.position + others = self.control_point.ground_objects + + def is_valid(point: Optional[Point]) -> bool: + if point is None: + return False + + if on_ground and not self.game.theater.is_on_land(point): + return False + elif not on_ground and not self.game.theater.is_in_sea(point): + return False + + if avoid_others: + for other in others: + if other.position.distance_to_point(point) < 10000: + return False + + if is_base_defense: + # If it's a base defense we don't care how close it is to other + # points. + return True + + # Else verify that it's not too close to another control point. + for control_point in self.game.theater.controlpoints: + if control_point != self.control_point: + if control_point.position.distance_to_point(point) < 30000: + return False + for ground_obj in control_point.ground_objects: + if ground_obj.position.distance_to_point(point) < 10000: + return False + return True + + for _ in range(300): + # Check if on land or sea + p = near.random_point_within(max_range, min_range) + if is_valid(p): + return p + return None + + class ControlPointGroundObjectGenerator: def __init__(self, game: Game, control_point: ControlPoint) -> None: self.game = game self.control_point = control_point - self.preset_locations = PresetLocationFinder.compute_possible_locations(game.theater.terrain.name, control_point.full_name) + self.location_finder = LocationFinder(game, control_point) @property def faction_name(self) -> str: @@ -205,11 +349,9 @@ class ControlPointGroundObjectGenerator: self.generate_ship() def generate_ship(self) -> None: - point = find_location(False, self.control_point.position, - self.game.theater, 5000, 40000, [], False) + point = self.location_finder.location_for( + LocationType.OffshoreStrikeTarget) if point is None: - logging.error( - f"Could not find point for {self.control_point}'s navy") return group_id = self.game.next_group_id() @@ -223,27 +365,6 @@ class ControlPointGroundObjectGenerator: g.groups.append(group) self.control_point.connected_objectives.append(g) - def pick_preset_location(self, offshore=False) -> Optional[PresetLocation]: - """ - Return a preset location if any is setup and still available for this point - @:param offshore Whether this should be an offshore location - @:return The preset location if found; None if it couldn't be found - """ - if offshore: - if len(self.preset_locations.offshore_locations) > 0: - location = random.choice(self.preset_locations.offshore_locations) - self.preset_locations.offshore_locations.remove(location) - logging.info("Picked a preset offshore location") - return location - else: - if len(self.preset_locations.ashore_locations) > 0: - location = random.choice(self.preset_locations.ashore_locations) - self.preset_locations.ashore_locations.remove(location) - logging.info("Picked a preset ashore location") - return location - logging.info("No preset location found") - return None - class CarrierGroundObjectGenerator(ControlPointGroundObjectGenerator): def generate(self) -> bool: @@ -299,6 +420,7 @@ class BaseDefenseGenerator: def __init__(self, game: Game, control_point: ControlPoint) -> None: self.game = game self.control_point = control_point + self.location_finder = LocationFinder(game, control_point) @property def faction_name(self) -> str: @@ -317,8 +439,7 @@ class BaseDefenseGenerator: self.generate_base_defenses() def generate_ewr(self) -> None: - position = self._find_location( - "EWR", self.control_point.preset_locations.random_ewr) + position = self.location_finder.location_for(LocationType.Ewr) if position is None: return @@ -349,8 +470,7 @@ class BaseDefenseGenerator: self.generate_garrison() def generate_garrison(self) -> None: - position = self._find_location( - "garrison", self.control_point.preset_locations.random_garrison) + position = self.location_finder.location_for(LocationType.Garrison) if position is None: return @@ -366,8 +486,8 @@ class BaseDefenseGenerator: self.control_point.base_defenses.append(g) def generate_sam(self) -> None: - position = self._find_location( - "SAM", self.control_point.preset_locations.random_base_sam) + position = self.location_finder.location_for( + LocationType.BaseAirDefense) if position is None: return @@ -382,8 +502,7 @@ class BaseDefenseGenerator: self.control_point.base_defenses.append(g) def generate_shorad(self) -> None: - position = self._find_location( - "SHORAD", self.control_point.preset_locations.random_garrison) + position = self.location_finder.location_for(LocationType.Garrison) if position is None: return @@ -397,34 +516,6 @@ class BaseDefenseGenerator: g.groups.append(group) self.control_point.base_defenses.append(g) - 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) - - # Retry once, searching a bit further (On some big airbase, 3200 is too short (Ex : Incirlik)) - # But searching farther on every base would be problematic, as some base defense units - # would end up very far away from small airfields. - # (I know it's not good for performance, but this is only done on campaign generation) - # TODO : Make the whole process less stupid with preset possible positions for each airbase - if position is None: - position = find_location(True, self.control_point.position, - self.game.theater, 3200, 4800, [], True) - return position - class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): def __init__(self, game: Game, control_point: ControlPoint, @@ -471,23 +562,14 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): obj_name = namegen.random_objective_name() template = random.choice(list(self.templates[category].values())) - offshore = category == "oil" + if category == "oil": + location_type = LocationType.OffshoreStrikeTarget + else: + location_type = LocationType.StrikeTarget # Pick from preset locations - location = self.pick_preset_location(offshore) - - # Else try the old algorithm - if location is None: - point = find_location(not offshore, - self.control_point.position, - self.game.theater, 10000, 40000, - self.control_point.ground_objects) - else: - point = location.position - + point = self.location_finder.location_for(location_type) if point is None: - logging.error( - f"Could not find point for {obj_name} at {self.control_point}") return object_id = 0 @@ -505,22 +587,8 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.control_point.connected_objectives.append(g) def generate_aa_site(self) -> None: - obj_name = namegen.random_objective_name() - - # Pick from preset locations - location = self.pick_preset_location(False) - - # If no preset location, then try the old algorithm - if location is None: - position = find_location(True, self.control_point.position, - self.game.theater, 10000, 40000, - self.control_point.ground_objects) - else: - position = location.position - + position = self.location_finder.location_for(LocationType.Sam) if position is None: - logging.error( - f"Could not find point for {obj_name} at {self.control_point}") return group_id = self.game.next_group_id() @@ -537,22 +605,8 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.generate_missile_site() def generate_missile_site(self) -> None: - - # Pick from preset locations - location = self.pick_preset_location(False) - - # If no preset location, then try the old algorithm - if location is None: - position = find_location(True, self.control_point.position, - self.game.theater, 2500, 40000, - [], False) - else: - position = location.position - - + position = self.location_finder.location_for(LocationType.MissileSite) if position is None: - logging.info( - f"Could not find point for {self.control_point} missile site") return group_id = self.game.next_group_id() @@ -591,68 +645,3 @@ class GroundObjectGenerator: generator = AirbaseGroundObjectGenerator(self.game, control_point, self.templates) return generator.generate() - - -# TODO: https://stackoverflow.com/a/19482012/632035 -# A lot of the time spent on mission generation is spent in this function since -# just randomly guess up to 1800 times and often fail. This is particularly -# problematic while trying to find placement for navies in Nevada. -def find_location(on_ground: bool, near: Point, theater: ConflictTheater, - min_range: int, max_range: int, - others: List[TheaterGroundObject], - is_base_defense: bool = False) -> Optional[Point]: - """ - Find a valid ground object location - :param on_ground: Whether it should be on ground or on sea (True = on - ground) - :param near: Point - :param theater: Theater object - :param min_range: Minimal range from point - :param max_range: Max range from point - :param others: Other already existing ground objects - :param is_base_defense: True if the location is for base defense. - :return: - """ - point = None - for _ in range(300): - - # Check if on land or sea - p = near.random_point_within(max_range, min_range) - if on_ground and theater.is_on_land(p): - point = p - elif not on_ground and theater.is_in_sea(p): - point = p - - if point: - for angle in range(0, 360, 45): - p = point.point_from_heading(angle, 2500) - if on_ground and not theater.is_on_land(p): - point = None - break - elif not on_ground and not theater.is_in_sea(p): - point = None - break - if point: - for other in others: - if other.position.distance_to_point(point) < 10000: - point = None - break - - if point: - for control_point in theater.controlpoints: - if is_base_defense: - break - if control_point.position != near: - if point is None: - break - if control_point.position.distance_to_point(point) < 30000: - point = None - break - for ground_obj in control_point.ground_objects: - if ground_obj.position.distance_to_point(point) < 10000: - point = None - break - - if point: - return point - return None diff --git a/gen/locations/preset_location_finder.py b/gen/locations/preset_location_finder.py index 41386d90..4df32466 100644 --- a/gen/locations/preset_location_finder.py +++ b/gen/locations/preset_location_finder.py @@ -8,7 +8,7 @@ from gen.locations.preset_control_point_locations import PresetControlPointLocat from gen.locations.preset_locations import PresetLocation -class PresetLocationFinder: +class MizDataLocationFinder: @staticmethod def compute_possible_locations(terrain_name: str, cp_name: str) -> PresetControlPointLocations: