from __future__ import annotations import logging import pickle import random from dataclasses import dataclass from datetime import datetime from typing import Any, Dict, Iterable, List, Optional, Set from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike from dcs.vehicles import AirDefence from game import Game, db from game.factions.faction import Faction from game.theater import Carrier, Lha, LocationType, PointWithHeading from game.theater.theatergroundobject import ( BuildingGroundObject, CarrierGroundObject, EwrGroundObject, LhaGroundObject, MissileSiteGroundObject, SamGroundObject, ShipGroundObject, VehicleGroupGroundObject, CoastalSiteGroundObject, ) from game.version import VERSION from gen import namegen from gen.coastal.coastal_group_generator import generate_coastal_group from gen.defenses.armor_group_generator import generate_armor_group from gen.fleet.ship_group_generator import ( generate_carrier_group, generate_lha_group, generate_ship_group, ) from gen.locations.preset_location_finder import MizDataLocationFinder from gen.missiles.missiles_group_generator import generate_missile_group from gen.sam.airdefensegroupgenerator import AirDefenseRange from gen.sam.sam_group_generator import generate_anti_air_group from gen.sam.ewr_group_generator import generate_ewr_group from . import ( ConflictTheater, ControlPoint, ControlPointType, Fob, OffMapSpawn, ) from ..settings import Settings GroundObjectTemplates = Dict[str, Dict[str, Any]] UNIT_VARIETY = 6 UNIT_AMOUNT_FACTOR = 16 UNIT_COUNT_IMPORTANCE_LOG = 1.3 COUNT_BY_TASK = { PinpointStrike: 12, CAP: 8, CAS: 4, AirDefence: 1, } @dataclass(frozen=True) class GeneratorSettings: start_date: datetime player_budget: int enemy_budget: int midgame: bool inverted: bool no_carrier: bool no_lha: bool no_player_navy: bool no_enemy_navy: bool class GameGenerator: def __init__( self, player: str, enemy: str, theater: ConflictTheater, settings: Settings, generator_settings: GeneratorSettings, ) -> None: self.player = player self.enemy = enemy self.theater = theater self.settings = settings self.generator_settings = generator_settings def generate(self) -> Game: # Reset name generator namegen.reset() self.prepare_theater() game = Game( player_name=self.player, enemy_name=self.enemy, theater=self.theater, start_date=self.generator_settings.start_date, settings=self.settings, player_budget=self.generator_settings.player_budget, enemy_budget=self.generator_settings.enemy_budget, ) GroundObjectGenerator(game, self.generator_settings).generate() game.settings.version = VERSION return game def prepare_theater(self) -> None: to_remove: List[ControlPoint] = [] # Auto-capture half the bases if midgame. if self.generator_settings.midgame: control_points = self.theater.controlpoints for control_point in control_points[: len(control_points) // 2]: control_point.captured = True # Remove carrier and lha, invert situation if needed for cp in self.theater.controlpoints: if isinstance(cp, Carrier) and self.generator_settings.no_carrier: to_remove.append(cp) elif isinstance(cp, Lha) and self.generator_settings.no_lha: to_remove.append(cp) if self.generator_settings.inverted: cp.captured = cp.captured_invert # do remove for cp in to_remove: self.theater.controlpoints.remove(cp) # TODO: Fix this. This captures all bases for blue. # reapply midgame inverted if needed if self.generator_settings.midgame and self.generator_settings.inverted: for i, cp in enumerate(reversed(self.theater.controlpoints)): if i > len(self.theater.controlpoints): break else: cp.captured = True 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[PointWithHeading]: 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 Mat %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[PointWithHeading]: 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 PointWithHeading.from_point(preset.position, preset.heading) return None def random_position( self, location_type: LocationType ) -> Optional[PointWithHeading]: # TODO: Flesh out preset locations so we never hit this case. if location_type == LocationType.Coastal: # No coastal locations generated randomly return None 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[PointWithHeading]: """ Find a valid ground object location :param on_ground: Whether it should be on ground or on sea (True = on ground) :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[PointWithHeading]) -> 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 = PointWithHeading.from_point( near.random_point_within(max_range, min_range), random.randint(0, 360) ) if is_valid(p): return p return None class ControlPointGroundObjectGenerator: def __init__( self, game: Game, generator_settings: GeneratorSettings, control_point: ControlPoint, ) -> None: self.game = game self.generator_settings = generator_settings self.control_point = control_point self.location_finder = LocationFinder(game, control_point) @property def faction_name(self) -> str: if self.control_point.captured: return self.game.player_name else: return self.game.enemy_name @property def faction(self) -> Faction: return db.FACTIONS[self.faction_name] def generate(self) -> bool: self.control_point.connected_objectives = [] if self.faction.navy_generators: # Even airbases can generate navies if they are close enough to the # water. This is not controlled by the control point definition, but # rather by whether or not the generator can find a valid position # for the ship. self.generate_navy() return True def generate_navy(self) -> None: skip_player_navy = self.generator_settings.no_player_navy if self.control_point.captured and skip_player_navy: return skip_enemy_navy = self.generator_settings.no_enemy_navy if not self.control_point.captured and skip_enemy_navy: return for _ in range(self.faction.navy_group_count): self.generate_ship() def generate_ship(self) -> None: point = self.location_finder.location_for(LocationType.OffshoreStrikeTarget) if point is None: return group_id = self.game.next_group_id() g = ShipGroundObject( namegen.random_objective_name(), group_id, point, self.control_point ) group = generate_ship_group(self.game, g, self.faction_name) g.groups = [] if group is not None: g.groups.append(group) self.control_point.connected_objectives.append(g) class NoOpGroundObjectGenerator(ControlPointGroundObjectGenerator): def generate(self) -> bool: return True class CarrierGroundObjectGenerator(ControlPointGroundObjectGenerator): def generate(self) -> bool: if not super().generate(): return False carrier_names = self.faction.carrier_names if not carrier_names: logging.info( f"Skipping generation of {self.control_point.name} because " f"{self.faction_name} has no carriers" ) return False # Create ground object group group_id = self.game.next_group_id() g = CarrierGroundObject( namegen.random_objective_name(), group_id, self.control_point ) group = generate_carrier_group(self.faction_name, self.game, g) g.groups = [] if group is not None: g.groups.append(group) self.control_point.connected_objectives.append(g) self.control_point.name = random.choice(carrier_names) return True class LhaGroundObjectGenerator(ControlPointGroundObjectGenerator): def generate(self) -> bool: if not super().generate(): return False lha_names = self.faction.helicopter_carrier_names if not lha_names: logging.info( f"Skipping generation of {self.control_point.name} because " f"{self.faction_name} has no LHAs" ) return False # Create ground object group group_id = self.game.next_group_id() g = LhaGroundObject( namegen.random_objective_name(), group_id, self.control_point ) group = generate_lha_group(self.faction_name, self.game, g) g.groups = [] if group is not None: g.groups.append(group) self.control_point.connected_objectives.append(g) self.control_point.name = random.choice(lha_names) return True 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: if self.control_point.captured: return self.game.player_name else: return self.game.enemy_name @property def faction(self) -> Faction: return db.FACTIONS[self.faction_name] def generate(self) -> None: self.generate_ewr() self.generate_garrison() self.generate_base_defenses() def generate_ewr(self) -> None: position = self.location_finder.location_for(LocationType.Ewr) if position is None: return group_id = self.game.next_group_id() g = EwrGroundObject( namegen.random_objective_name(), group_id, position, self.control_point, True, ) group = generate_ewr_group(self.game, g, self.faction) if group is None: logging.error(f"Could not generate EWR at {self.control_point}") return g.groups = [group] self.control_point.base_defenses.append(g) def generate_base_defenses(self) -> None: # First group has a 1/2 chance of being a SAM, 1/6 chance of SHORAD, # and a 1/6 chance of a garrison. # # Further groups have a 1/3 chance of being SHORAD and 2/3 chance of # being a garrison. for i in range(random.randint(2, 5)): if i == 0 and random.randint(0, 1) == 0: self.generate_sam() elif random.randint(0, 2) == 1: self.generate_shorad() else: self.generate_garrison() def generate_garrison(self) -> None: position = self.location_finder.location_for(LocationType.Garrison) if position is None: return group_id = self.game.next_group_id() g = VehicleGroupGroundObject( namegen.random_objective_name(), group_id, position, self.control_point, for_airbase=True, ) group = generate_armor_group(self.faction_name, self.game, g) if group is None: logging.error(f"Could not generate garrison at {self.control_point}") return g.groups.append(group) self.control_point.base_defenses.append(g) def generate_sam(self) -> None: position = self.location_finder.location_for(LocationType.BaseAirDefense) if position is None: return group_id = self.game.next_group_id() g = SamGroundObject( namegen.random_objective_name(), group_id, position, self.control_point, for_airbase=True, ) groups = generate_anti_air_group(self.game, g, self.faction) if not groups: logging.error(f"Could not generate SAM at {self.control_point}") return g.groups = groups self.control_point.base_defenses.append(g) def generate_shorad(self) -> None: position = self.location_finder.location_for(LocationType.BaseAirDefense) if position is None: return group_id = self.game.next_group_id() g = SamGroundObject( namegen.random_objective_name(), group_id, position, self.control_point, for_airbase=True, ) groups = generate_anti_air_group( self.game, g, self.faction, ranges=[{AirDefenseRange.Short}] ) if not groups: logging.error(f"Could not generate SHORAD group at {self.control_point}") return g.groups = groups self.control_point.base_defenses.append(g) class FobDefenseGenerator(BaseDefenseGenerator): def generate(self) -> None: self.generate_garrison() self.generate_fob_defenses() def generate_fob_defenses(self): # First group has a 1/2 chance of being a SHORAD, # and a 1/2 chance of a garrison. # # Further groups have a 1/3 chance of being SHORAD and 2/3 chance of # being a garrison. for i in range(random.randint(2, 5)): if i == 0 and random.randint(0, 1) == 0: self.generate_shorad() elif i == 0 and random.randint(0, 1) == 0: self.generate_garrison() elif random.randint(0, 2) == 1: self.generate_shorad() else: self.generate_garrison() class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): def __init__( self, game: Game, generator_settings: GeneratorSettings, control_point: ControlPoint, templates: GroundObjectTemplates, ) -> None: super().__init__(game, generator_settings, control_point) self.templates = templates def generate(self) -> bool: if not super().generate(): return False BaseDefenseGenerator(self.game, self.control_point).generate() self.generate_ground_points() if self.faction.missiles: self.generate_missile_sites() if self.faction.coastal_defenses: self.generate_coastal_sites() return True def generate_ground_points(self) -> None: """Generate ground objects and AA sites for the control point.""" skip_sams = self.generate_required_aa() skip_ewrs = self.generate_required_ewr() if self.control_point.is_global: return # Always generate at least one AA point. self.generate_aa_site() # And between 2 and 7 other objectives. amount = random.randrange(2, 7) for i in range(amount): # 1 in 4 additional objectives are AA. if random.randint(0, 3) == 0: if skip_sams > 0: skip_sams -= 1 else: self.generate_aa_site() # 1 in 4 additional objectives are EWR. elif random.randint(0, 3) == 0: if skip_ewrs > 0: skip_ewrs -= 1 else: self.generate_ewr_site() else: self.generate_ground_point() def generate_required_aa(self) -> int: """Generates the AA sites that are required by the campaign. Returns: The number of AA sites that were generated. """ presets = self.control_point.preset_locations for position in presets.required_long_range_sams: self.generate_aa_at( position, ranges=[ {AirDefenseRange.Long}, {AirDefenseRange.Medium}, {AirDefenseRange.Short}, ], ) for position in presets.required_medium_range_sams: self.generate_aa_at( position, ranges=[ {AirDefenseRange.Medium}, {AirDefenseRange.Short}, ], ) return len(presets.required_long_range_sams) + len( presets.required_medium_range_sams ) def generate_required_ewr(self) -> int: """Generates the EWR sites that are required by the campaign. Returns: The number of EWR sites that were generated. """ presets = self.control_point.preset_locations for position in presets.required_ewrs: self.generate_ewr_at(position) return len(presets.required_ewrs) def generate_ground_point(self) -> None: try: category = random.choice(self.faction.building_set) except IndexError: logging.exception("Faction has no buildings defined") return obj_name = namegen.random_objective_name() template = random.choice(list(self.templates[category].values())) if category == "oil": location_type = LocationType.OffshoreStrikeTarget else: location_type = LocationType.StrikeTarget # Pick from preset locations point = self.location_finder.location_for(location_type) if point is None: return object_id = 0 group_id = self.game.next_group_id() # TODO: Create only one TGO per objective, each with multiple units. for unit in template: object_id += 1 template_point = Point(unit["offset"].x, unit["offset"].y) g = BuildingGroundObject( obj_name, category, group_id, object_id, point + template_point, unit["heading"], self.control_point, unit["type"], ) self.control_point.connected_objectives.append(g) def generate_aa_site(self) -> None: position = self.location_finder.location_for(LocationType.Sam) if position is None: return self.generate_aa_at( position, ranges=[ # Prefer to use proper SAMs, but fall back to SHORADs if needed. {AirDefenseRange.Long, AirDefenseRange.Medium}, {AirDefenseRange.Short}, ], ) def generate_aa_at( self, position: Point, ranges: Iterable[Set[AirDefenseRange]] ) -> None: group_id = self.game.next_group_id() g = SamGroundObject( namegen.random_objective_name(), group_id, position, self.control_point, for_airbase=False, ) groups = generate_anti_air_group(self.game, g, self.faction, ranges) if not groups: logging.error( "Could not generate air defense group for %s at %s", g.name, self.control_point, ) return g.groups = groups self.control_point.connected_objectives.append(g) def generate_ewr_site(self) -> None: position = self.location_finder.location_for(LocationType.Ewr) if position is None: return self.generate_ewr_at(position) def generate_ewr_at(self, position: Point) -> None: group_id = self.game.next_group_id() g = EwrGroundObject( namegen.random_objective_name(), group_id, position, self.control_point, for_airbase=False, ) group = generate_ewr_group(self.game, g, self.faction) if group is None: logging.error( "Could not generate ewr group for %s at %s", g.name, self.control_point, ) return g.groups = [group] self.control_point.connected_objectives.append(g) def generate_missile_sites(self) -> None: for i in range(self.faction.missiles_group_count): self.generate_missile_site() def generate_missile_site(self) -> None: position = self.location_finder.location_for(LocationType.MissileSite) if position is None: return group_id = self.game.next_group_id() g = MissileSiteGroundObject( namegen.random_objective_name(), group_id, position, self.control_point ) group = generate_missile_group(self.game, g, self.faction_name) g.groups = [] if group is not None: g.groups.append(group) self.control_point.connected_objectives.append(g) return def generate_coastal_sites(self) -> None: for i in range(self.faction.coastal_group_count): self.generate_coastal_site() def generate_coastal_site(self) -> None: position = self.location_finder.location_for(LocationType.Coastal) if position is None: return group_id = self.game.next_group_id() g = CoastalSiteGroundObject( namegen.random_objective_name(), group_id, position, self.control_point, position.heading, ) group = generate_coastal_group(self.game, g, self.faction_name) g.groups = [] if group is not None: g.groups.append(group) self.control_point.connected_objectives.append(g) return class FobGroundObjectGenerator(AirbaseGroundObjectGenerator): def generate(self) -> bool: self.generate_fob() FobDefenseGenerator(self.game, self.control_point).generate() self.generate_required_aa() return True def generate_fob(self) -> None: try: category = self.faction.building_set[self.faction.building_set.index("fob")] except IndexError: logging.exception("Faction has no fob buildings defined") return obj_name = self.control_point.name template = random.choice(list(self.templates[category].values())) point = self.control_point.position # Pick from preset locations object_id = 0 group_id = self.game.next_group_id() # TODO: Create only one TGO per objective, each with multiple units. for unit in template: object_id += 1 template_point = Point(unit["offset"].x, unit["offset"].y) g = BuildingGroundObject( obj_name, category, group_id, object_id, point + template_point, unit["heading"], self.control_point, unit["type"], airbase_group=True, ) self.control_point.connected_objectives.append(g) class GroundObjectGenerator: def __init__(self, game: Game, generator_settings: GeneratorSettings) -> None: self.game = game self.generator_settings = generator_settings with open("resources/groundobject_templates.p", "rb") as f: self.templates: GroundObjectTemplates = pickle.load(f) def generate(self) -> None: # Copied so we can remove items from the original list without breaking # the iterator. control_points = list(self.game.theater.controlpoints) for control_point in control_points: if not self.generate_for_control_point(control_point): self.game.theater.controlpoints.remove(control_point) def generate_for_control_point(self, control_point: ControlPoint) -> bool: generator: ControlPointGroundObjectGenerator if control_point.cptype == ControlPointType.AIRCRAFT_CARRIER_GROUP: generator = CarrierGroundObjectGenerator( self.game, self.generator_settings, control_point ) elif control_point.cptype == ControlPointType.LHA_GROUP: generator = LhaGroundObjectGenerator( self.game, self.generator_settings, control_point ) elif isinstance(control_point, OffMapSpawn): generator = NoOpGroundObjectGenerator( self.game, self.generator_settings, control_point ) elif isinstance(control_point, Fob): generator = FobGroundObjectGenerator( self.game, self.generator_settings, control_point, self.templates ) else: generator = AirbaseGroundObjectGenerator( self.game, self.generator_settings, control_point, self.templates ) return generator.generate()