diff --git a/game/factions/faction.py b/game/factions/faction.py index 04846247..195623eb 100644 --- a/game/factions/faction.py +++ b/game/factions/faction.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import itertools import logging from dataclasses import dataclass, field @@ -24,6 +25,7 @@ from game.data.doctrine import ( from game.data.groundunitclass import GroundUnitClass from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType +from gen.templates import GroundObjectTemplates, TemplateCategory if TYPE_CHECKING: from game.theater.start_generator import ModSettings @@ -72,7 +74,7 @@ class Faction: air_defenses: List[str] = field(default_factory=list) # Possible EWR generators for this faction. - ewrs: List[str] = field(default_factory=list) + ewrs: List[GroundUnitType] = field(default_factory=list) # Possible Missile site generators for this faction missiles: List[str] = field(default_factory=list) @@ -137,12 +139,68 @@ class Faction: #: both will use it. unrestricted_satnav: bool = False - def has_access_to_unittype(self, unit_class: GroundUnitClass) -> bool: + # All possible templates which can be generated by the faction + templates: GroundObjectTemplates = field(default=GroundObjectTemplates()) + + def __getitem__(self, item: str) -> Any: + return getattr(self, item) + + def has_access_to_unit_type(self, unit_type: str) -> bool: + # Supports all GroundUnit lists and AirDefenses + for unit in self.ground_units: + if unit_type == unit.dcs_id: + return True + return unit_type in self.air_defenses + + def has_access_to_unit_class(self, unit_class: GroundUnitClass) -> bool: for vehicle in itertools.chain(self.frontline_units, self.artillery_units): if vehicle.unit_class is unit_class: return True return False + def load_templates(self, all_templates: GroundObjectTemplates) -> None: + # This loads all faction possible sam templates and the default ones + # For legacy reasons this allows to check for template names. This can be + # improved in the future to have more control about the possible Templates. + # For example it can be possible to define the unit_types and check if all + # requirements for the template are fulfilled. + for category, template in all_templates.templates: + if ( + ( + category == TemplateCategory.AirDefence + and ( + # Check if faction has the template name or ALL required + # unit_types in the list air_defenses. For legacy reasons this + # allows both and also the EWR template + template.name in self.air_defenses + or all( + self.has_access_to_unit_type(required_unit) + for required_unit in template.required_units + ) + or template.template_type == "EWR" + ) + ) + or template.name in self.navy_generators + or template.name in self.missiles + or template.name in self.coastal_defenses + or ( + template.template_type + in self.building_set + ["fob", "ammo", "factory"] + ) + or (template.template_type == "carrier" and self.aircraft_carrier) + or (template.template_type == "lha" and self.helicopter_carrier) + or category == TemplateCategory.Armor + ): + # Make a deep copy of a template and add it to the template_list. + # This is required to have faction independent templates. Otherwise + # the reference would be the same and changes would affect all. + faction_template = copy.deepcopy(template) + # Initialize all randomizers + for group_template in faction_template.groups: + if group_template.randomizer: + group_template.randomizer.init_randomization_for_faction(self) + self.templates.add_template(category, faction_template) + @classmethod def from_json(cls: Type[Faction], json: Dict[str, Any]) -> Faction: faction = Faction(locales=json.get("locales")) @@ -182,8 +240,7 @@ class Faction: faction.logistics_units = [ GroundUnitType.named(n) for n in json.get("logistics_units", []) ] - - faction.ewrs = json.get("ewrs", []) + faction.ewrs = [GroundUnitType.named(n) for n in json.get("ewrs", [])] faction.air_defenses = json.get("air_defenses", []) # Compatibility for older factions. All air defenses now belong to a @@ -245,6 +302,8 @@ class Faction: faction.unrestricted_satnav = json.get("unrestricted_satnav", False) + # Templates + faction.templates = GroundObjectTemplates() return faction @property @@ -320,13 +379,13 @@ class Faction: self.remove_vehicle("KORNET") # high digit sams if not mod_settings.high_digit_sams: - self.remove_air_defenses("SA10BGenerator") - self.remove_air_defenses("SA12Generator") - self.remove_air_defenses("SA20Generator") - self.remove_air_defenses("SA20BGenerator") - self.remove_air_defenses("SA23Generator") - self.remove_air_defenses("SA17Generator") - self.remove_air_defenses("KS19Generator") + self.remove_air_defenses("SA-10B/S-300PS Battery") + self.remove_air_defenses("SA-12/S-300V Battery") + self.remove_air_defenses("SA-20/S-300PMU-1 Battery") + self.remove_air_defenses("SA-20B/S-300PMU-2 Battery") + self.remove_air_defenses("SA-23/S-300VM Battery") + self.remove_air_defenses("SA-17 Grizzly Battery") + self.remove_air_defenses("KS-19 AAA Site") return self def remove_aircraft(self, name: str) -> None: diff --git a/game/missiongenerator/tgogenerator.py b/game/missiongenerator/tgogenerator.py index 5dc0ad98..77667a41 100644 --- a/game/missiongenerator/tgogenerator.py +++ b/game/missiongenerator/tgogenerator.py @@ -123,7 +123,7 @@ class GroundObjectGenerator: if not u.alive: continue - unit_type = static_type_from_name(u.type) + unit_type = unit_type_from_name(u.type) if not unit_type: raise RuntimeError( f"Unit type {u.type} is not a valid dcs unit type" diff --git a/game/procurement.py b/game/procurement.py index 5833494e..f13c5b5b 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -158,7 +158,7 @@ class ProcurementAi: worst_balanced: Optional[GroundUnitClass] = None worst_fulfillment = math.inf for unit_class in GroundUnitClass: - if not self.faction.has_access_to_unittype(unit_class): + if not self.faction.has_access_to_unit_class(unit_class): continue current_ratio = self.cost_ratio_of_ground_unit(cp, unit_class) diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 5695d091..9b983a43 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -1,45 +1,25 @@ 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, Set - -from dcs.mapping import Point +from typing import List from game import Game from game.factions.faction import Faction from game.scenery_group import SceneryGroup from game.theater import PointWithHeading from game.theater.theatergroundobject import ( + AirDefenseRange, BuildingGroundObject, - CarrierGroundObject, - EwrGroundObject, - FactoryGroundObject, - LhaGroundObject, - MissileSiteGroundObject, - SamGroundObject, - ShipGroundObject, - SceneryGroundObject, - VehicleGroupGroundObject, - CoastalSiteGroundObject, + SceneryGroundUnit, + GroundGroup, ) from game.utils import Heading from game.version import VERSION -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.missiles.missiles_group_generator import generate_missile_group +from gen.templates import GroundObjectTemplates, TemplateCategory, GroundObjectTemplate from gen.naming import namegen -from gen.sam.airdefensegroupgenerator import AirDefenseRange -from gen.sam.ewr_group_generator import generate_ewr_group -from gen.sam.sam_group_generator import generate_anti_air_group from . import ( ConflictTheater, ControlPoint, @@ -51,8 +31,6 @@ from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig from ..profiling import logged_duration from ..settings import Settings -GroundObjectTemplates = Dict[str, Dict[str, Any]] - @dataclass(frozen=True) class GeneratorSettings: @@ -97,6 +75,9 @@ class GameGenerator: self.settings = settings self.generator_settings = generator_settings + with logged_duration(f"Initializing templates"): + self.load_templates() + def generate(self) -> Game: with logged_duration("TGO population"): # Reset name generator @@ -142,6 +123,13 @@ class GameGenerator: for cp in to_remove: self.theater.controlpoints.remove(cp) + def load_templates(self) -> None: + templates = GroundObjectTemplates.from_json( + "resources/templates/templates.json" + ) + self.player.load_templates(templates) + self.enemy.load_templates(templates) + class ControlPointGroundObjectGenerator: def __init__( @@ -170,6 +158,26 @@ class ControlPointGroundObjectGenerator: return True + def generate_random_from_templates( + self, templates: list[GroundObjectTemplate], position: PointWithHeading + ) -> None: + try: + template = random.choice(templates) + with logged_duration( + f"Ground Object generation from template {template.name}" + ): + ground_object = template.generate( + namegen.random_objective_name(), + position, + self.control_point, + self.game, + ) + self.control_point.connected_objectives.append(ground_object) + except NotImplementedError: + logging.error("Template Generator not implemented yet") + except IndexError: + logging.error(f"No templates to generate object") + def generate_navy(self) -> None: skip_player_navy = self.generator_settings.no_player_navy if self.control_point.captured and skip_player_navy: @@ -179,21 +187,12 @@ class ControlPointGroundObjectGenerator: if not self.control_point.captured and skip_enemy_navy: return - for position in self.control_point.preset_locations.ships: - self.generate_ship_at(position) - - def generate_ship_at(self, position: PointWithHeading) -> None: - group_id = self.game.next_group_id() - - g = ShipGroundObject( - namegen.random_objective_name(), group_id, position, self.control_point + templates = list( + self.faction.templates.for_category(TemplateCategory.Naval, "ship") ) - 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) + for position in self.control_point.preset_locations.ships: + self.generate_random_from_templates(templates, position) class NoOpGroundObjectGenerator(ControlPointGroundObjectGenerator): @@ -214,16 +213,16 @@ class CarrierGroundObjectGenerator(ControlPointGroundObjectGenerator): ) return False - # Create ground object group - group_id = self.game.next_group_id() - g = CarrierGroundObject( - namegen.random_objective_name(), group_id, self.control_point + templates = list( + self.faction.templates.for_category(TemplateCategory.Naval, "carrier") + ) + + self.generate_random_from_templates( + templates, + PointWithHeading.from_point( + self.control_point.position, self.control_point.heading + ), ) - 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 @@ -241,16 +240,16 @@ class LhaGroundObjectGenerator(ControlPointGroundObjectGenerator): ) return False - # Create ground object group - group_id = self.game.next_group_id() - g = LhaGroundObject( - namegen.random_objective_name(), group_id, self.control_point + templates = list( + self.faction.templates.for_category(TemplateCategory.Naval, "lha") + ) + + self.generate_random_from_templates( + templates, + PointWithHeading.from_point( + self.control_point.position, self.control_point.heading + ), ) - 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 @@ -261,10 +260,8 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): 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(): @@ -291,156 +288,102 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.generate_coastal_sites() def generate_armor_groups(self) -> None: - for position in self.control_point.preset_locations.armor_groups: - self.generate_armor_at(position) - - def generate_armor_at(self, position: PointWithHeading) -> None: - group_id = self.game.next_group_id() - - g = VehicleGroupGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - ) - - group = generate_armor_group(self.faction_name, self.game, g) - if group is None: - logging.error( - "Could not generate armor group for %s at %s", - g.name, - self.control_point, - ) + templates = list(self.faction.templates.for_category(TemplateCategory.Armor)) + if not templates: + logging.error(f"{self.faction_name} has no access to Armor templates") return - g.groups = [group] - self.control_point.connected_objectives.append(g) + + for position in self.control_point.preset_locations.armor_groups: + self.generate_random_from_templates(templates, position) def generate_aa(self) -> None: presets = self.control_point.preset_locations for position in presets.long_range_sams: self.generate_aa_at( position, - ranges=[ - {AirDefenseRange.Long}, - {AirDefenseRange.Medium}, - {AirDefenseRange.Short}, - {AirDefenseRange.AAA}, + [ + AirDefenseRange.Long, + AirDefenseRange.Medium, + AirDefenseRange.Short, + AirDefenseRange.AAA, ], ) for position in presets.medium_range_sams: self.generate_aa_at( position, - ranges=[ - {AirDefenseRange.Medium}, - {AirDefenseRange.Short}, - {AirDefenseRange.AAA}, + [ + AirDefenseRange.Medium, + AirDefenseRange.Short, + AirDefenseRange.AAA, ], ) for position in presets.short_range_sams: self.generate_aa_at( position, - ranges=[{AirDefenseRange.Short}, {AirDefenseRange.AAA}], + [AirDefenseRange.Short, AirDefenseRange.AAA], ) for position in presets.aaa: self.generate_aa_at( position, - ranges=[{AirDefenseRange.AAA}], + [AirDefenseRange.AAA], ) def generate_ewrs(self) -> None: - presets = self.control_point.preset_locations - for position in presets.ewrs: - self.generate_ewr_at(position) + templates = list( + self.faction.templates.for_category(TemplateCategory.AirDefence, "EWR") + ) + if not templates: + logging.error(f"{self.faction_name} has no access to EWR templates") + return - def generate_strike_target_at(self, category: str, position: Point) -> None: + for position in self.control_point.preset_locations.ewrs: + self.generate_random_from_templates(templates, position) - obj_name = namegen.random_objective_name() - template = random.choice(list(self.templates[category].values())) - - 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, - position + template_point, - Heading.from_degrees(unit["heading"]), - self.control_point, - unit["type"], + def generate_building_at( + self, + template_category: TemplateCategory, + building_category: str, + position: PointWithHeading, + ) -> None: + templates = list( + self.faction.templates.for_category(template_category, building_category) + ) + if templates: + self.generate_random_from_templates(templates, position) + else: + logging.error( + f"{self.faction_name} has no access to Building type {building_category}" ) - self.control_point.connected_objectives.append(g) - def generate_ammunition_depots(self) -> None: for position in self.control_point.preset_locations.ammunition_depots: - self.generate_strike_target_at(category="ammo", position=position) + self.generate_building_at(TemplateCategory.Building, "ammo", position) def generate_factories(self) -> None: for position in self.control_point.preset_locations.factories: - self.generate_factory_at(position) - - def generate_factory_at(self, point: PointWithHeading) -> None: - obj_name = namegen.random_objective_name() - group_id = self.game.next_group_id() - - g = FactoryGroundObject( - obj_name, - group_id, - point, - point.heading, - self.control_point, - ) - - self.control_point.connected_objectives.append(g) + self.generate_building_at(TemplateCategory.Building, "factory", position) def generate_aa_at( - self, position: Point, ranges: Iterable[Set[AirDefenseRange]] + self, position: PointWithHeading, ranges: list[AirDefenseRange] ) -> None: - group_id = self.game.next_group_id() - g = SamGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - ) - groups = generate_anti_air_group(self.game, g, self.faction, ranges) - if not groups: + templates = [] + for aa_range in ranges: + for template in self.faction.templates.for_category( + TemplateCategory.AirDefence, aa_range.name + ): + templates.append(template) + if len(templates) > 0: + # Only take next (smaller) aa_range when no template available for the + # most requested range. Otherwise break the loop and continue + break + + if templates: + self.generate_random_from_templates(templates, position) + else: logging.error( - "Could not generate air defense group for %s at %s", - g.name, - self.control_point, + f"{self.faction_name} has no access to SAM Templates ({', '.join([range.name for range in ranges])})" ) - return - g.groups = groups - self.control_point.connected_objectives.append(g) - - def generate_ewr_at(self, position: PointWithHeading) -> None: - group_id = self.game.next_group_id() - - g = EwrGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - ) - 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_scenery_sites(self) -> None: presets = self.control_point.preset_locations @@ -448,143 +391,93 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.generate_tgo_for_scenery(scenery_group) def generate_tgo_for_scenery(self, scenery: SceneryGroup) -> None: - - obj_name = namegen.random_objective_name() - category = scenery.category - group_id = self.game.next_group_id() - object_id = 0 - + # Special Handling for scenery Objects based on trigger zones + g = BuildingGroundObject( + namegen.random_objective_name(), + scenery.category, + scenery.position, + Heading.from_degrees(0), + self.control_point, + ) + ground_group = GroundGroup( + self.game.next_group_id(), + scenery.zone_def.name, + PointWithHeading.from_point(scenery.position, Heading.from_degrees(0)), + [], + g, + ) + ground_group.static_group = True + g.groups.append(ground_group) # Each nested trigger zone is a target/building/unit for an objective. for zone in scenery.zones: - - object_id += 1 - local_position = zone.position - local_dcs_identifier = zone.name - - g = SceneryGroundObject( - obj_name, - category, - group_id, - object_id, - local_position, - self.control_point, - local_dcs_identifier, - zone, + scenery_unit = SceneryGroundUnit( + zone.id, + zone.name, + "", + PointWithHeading.from_point(zone.position, Heading.from_degrees(0)), + g, ) + scenery_unit.zone = zone + ground_group.units.append(scenery_unit) - self.control_point.connected_objectives.append(g) - - return + self.control_point.connected_objectives.append(g) def generate_missile_sites(self) -> None: + templates = list(self.faction.templates.for_category(TemplateCategory.Missile)) + if not templates: + logging.error(f"{self.faction_name} has no access to Missile templates") + return for position in self.control_point.preset_locations.missile_sites: - self.generate_missile_site_at(position) - - def generate_missile_site_at(self, position: PointWithHeading) -> None: - 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 + self.generate_random_from_templates(templates, position) def generate_coastal_sites(self) -> None: + templates = list(self.faction.templates.for_category(TemplateCategory.Coastal)) + if not templates: + logging.error(f"{self.faction_name} has no access to Coastal templates") + return for position in self.control_point.preset_locations.coastal_defenses: - self.generate_coastal_site_at(position) - - def generate_coastal_site_at(self, position: PointWithHeading) -> None: - 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 + self.generate_random_from_templates(templates, position) def generate_strike_targets(self) -> None: building_set = list(set(self.faction.building_set) - {"oil"}) if not building_set: - logging.error("Faction has no buildings defined") + logging.error(f"{self.faction_name} has no buildings defined") return for position in self.control_point.preset_locations.strike_locations: category = random.choice(building_set) - self.generate_strike_target_at(category, position) + self.generate_building_at(TemplateCategory.Building, category, position) def generate_offshore_strike_targets(self) -> None: if "oil" not in self.faction.building_set: - logging.error("Faction does not support offshore strike targets") + logging.error( + f"{self.faction_name} does not support offshore strike targets" + ) return for position in self.control_point.preset_locations.offshore_strike_locations: - self.generate_strike_target_at("oil", position) + self.generate_building_at(TemplateCategory.Building, "oil", position) class FobGroundObjectGenerator(AirbaseGroundObjectGenerator): def generate(self) -> bool: - self.generate_fob() - self.generate_armor_groups() - self.generate_factories() - self.generate_ammunition_depots() - self.generate_aa() - self.generate_ewrs() - self.generate_scenery_sites() - self.generate_strike_targets() - self.generate_offshore_strike_targets() - - if self.faction.missiles: - self.generate_missile_sites() - - if self.faction.coastal_defenses: - self.generate_coastal_sites() - - return True + if super(FobGroundObjectGenerator, self).generate(): + self.generate_fob() + return True + return False def generate_fob(self) -> None: - category = "fob" - 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, - Heading.from_degrees(unit["heading"]), - self.control_point, - unit["type"], - is_fob_structure=True, - ) - self.control_point.connected_objectives.append(g) + self.generate_building_at( + TemplateCategory.Building, + "fob", + PointWithHeading.from_point( + self.control_point.position, self.control_point.heading + ), + ) 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 @@ -610,10 +503,10 @@ class GroundObjectGenerator: ) elif isinstance(control_point, Fob): generator = FobGroundObjectGenerator( - self.game, self.generator_settings, control_point, self.templates + self.game, self.generator_settings, control_point ) else: generator = AirbaseGroundObjectGenerator( - self.game, self.generator_settings, control_point, self.templates + self.game, self.generator_settings, control_point ) return generator.generate() diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index 6265e241..51c3666d 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -6,21 +6,23 @@ from abc import ABC from collections.abc import Sequence from dataclasses import dataclass from enum import Enum -from typing import Iterator, List, TYPE_CHECKING, Union, Optional +from typing import Iterator, List, TYPE_CHECKING, Union, Optional, Any + +from dcs.vehicles import vehicle_map +from dcs.ships import ship_map from dcs.mapping import Point from dcs.triggers import TriggerZone -from dcs.unit import Unit -from dcs.vehicles import vehicle_map from game.dcs.helpers import unit_type_from_name from ..data.radar_db import LAUNCHER_TRACKER_PAIRS, TELARS, TRACK_RADARS from ..dcs.groundunittype import GroundUnitType +from ..dcs.unittype import UnitType from ..point_with_heading import PointWithHeading from ..utils import Distance, Heading, meters if TYPE_CHECKING: - from gen.templates import UnitTemplate, GroupTemplate, TemplateRandomizer + from gen.templates import UnitTemplate, GroupTemplate from .controlpoint import ControlPoint from ..ato.flighttype import FlightType @@ -85,7 +87,7 @@ class GroundUnit: position: PointWithHeading ground_object: TheaterGroundObject alive: bool = True - _dcs_type: Optional[GroundUnitType] = None + _unit_type: Optional[UnitType[Any]] = None @staticmethod def from_template(id: int, t: UnitTemplate, go: TheaterGroundObject) -> GroundUnit: @@ -98,17 +100,24 @@ class GroundUnit: ) @property - def unit_type(self) -> Optional[GroundUnitType]: - if not self._dcs_type: + def unit_type(self) -> Optional[UnitType[Any]]: + if not self._unit_type: try: if self.type in vehicle_map: - dcs_unit_type = vehicle_map[self.type] + vehicle_type = db.vehicle_type_from_name(self.type) + self._unit_type = next(GroundUnitType.for_dcs_type(vehicle_type)) + elif self.type in ship_map: + ship_type = db.ship_type_from_name(self.type) + # TODO Allow handling of Ships. This requires extension of UnitType + return None + elif (static_type := db.static_type_from_name(self.type)) is not None: + # TODO Allow handling of Statics + return None else: return None - self._dcs_type = next(GroundUnitType.for_dcs_type(dcs_unit_type)) except StopIteration: return None - return self._dcs_type + return self._unit_type def kill(self) -> None: self.alive = False diff --git a/gen/templates.py b/gen/templates.py new file mode 100644 index 00000000..b989bf59 --- /dev/null +++ b/gen/templates.py @@ -0,0 +1,576 @@ +from __future__ import annotations + +import copy +import json +import logging +import random +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Iterator, Any, TYPE_CHECKING, Optional, Tuple, Union + +from dcs import Point +from dcs.ships import ship_map +from dcs.unit import Unit +from dcs.unittype import UnitType +from dcs.vehicles import vehicle_map + +from game.dcs.groundunittype import GroundUnitType +from game.theater.theatergroundobject import ( + SamGroundObject, + EwrGroundObject, + BuildingGroundObject, + GroundGroup, + MissileSiteGroundObject, + ShipGroundObject, + CarrierGroundObject, + LhaGroundObject, + CoastalSiteGroundObject, + VehicleGroupGroundObject, + IadsGroundObject, +) +from game.point_with_heading import PointWithHeading + +from game.utils import Heading +from game import db + +if TYPE_CHECKING: + from game import Game + from game.factions.faction import Faction + from game.theater import TheaterGroundObject, ControlPoint + + +class TemplateEncoder(json.JSONEncoder): + def default(self, obj: Any) -> dict[str, Any]: + if hasattr(obj, "to_json"): + return obj.to_json() + else: + return obj.__dict__ + + +class TemplateCategory(Enum): + AirDefence = "AirDefence" # Has subcategories for the AARange + Building = "Building" # Has subcategories from + Naval = "Naval" # Has subcategories lha, carrier, ship + Armor = "Armor" + Missile = "Missile" + Coastal = "Coastal" + + +@dataclass +class UnitTemplate: + name: str + type: str + position: Point + heading: int + + @staticmethod + def from_unit(unit: Unit) -> UnitTemplate: + return UnitTemplate( + unit.name, + unit.type, + Point(int(unit.position.x), int(unit.position.y)), + int(unit.heading), + ) + + @staticmethod + def from_dict(d_unit: dict[str, Any]) -> UnitTemplate: + return UnitTemplate( + d_unit["name"], + d_unit["type"], + Point(d_unit["position"]["x"], d_unit["position"]["y"]), + d_unit["heading"], + ) + + +@dataclass +class GroupTemplate: + name: str + units: list[UnitTemplate] + + # Is Static group + static: bool = False + + # Every group can only have one Randomizer + randomizer: Optional[TemplateRandomizer] = None + + # Defines if this groupTemplate is required or not + optional: bool = False + + @staticmethod + def from_dict(d_group: dict[str, Any]) -> GroupTemplate: + units = [UnitTemplate.from_dict(unit) for unit in d_group["units"]] + randomizer = ( + TemplateRandomizer.from_dict(d_group["randomizer"]) + if d_group["randomizer"] + else None + ) + return GroupTemplate( + d_group["name"], units, d_group["static"], randomizer, d_group["optional"] + ) + + @property + def unit_types_count(self) -> dict[str, int]: + units: dict[str, int] = {} + for unit in self.units: + if unit.type in units: + units[unit.type] += 1 + else: + units[unit.type] = 1 + return units + + +@dataclass +class TemplateRandomizer: + # Selection of units to apply the randomization. + # If left empty the randomizer will be applied to all unit of the group + units: list[int] = field(default_factory=list) + + # Define the amount of random units to be created by the randomizer. + # This can be a fixed int or a random value from a range of two ints as tuple + count: Union[int, list[int]] = field(default=1) + + # The randomizer can pick a random unit type from a faction list like + # frontline_units or air_defenses to allow faction sensitive randomization + faction_types: list[str] = field(default_factory=list) + + # Only works for vehicle units. Allows to specify the type class of the unit. + # For example this allows to select frontline_units as faction_type and also define + # Shorads as class to only pick AntiAir from the list + type_classes: list[str] = field(default_factory=list) + + # Allows to define the exact UnitTypes the randomizer picks from. these have to be + # the dcs_unit_types found in the pydcs arrays + unit_types: list[str] = field(default_factory=list) + + # Runtime Attributes + _initialized: bool = False + _possible_types: list[str] = field(default_factory=list) + _random_unit_type: Optional[str] = None + _forced_unit_type: Optional[str] = None + _unit_counter: Optional[int] = None + + def to_json(self) -> dict[str, Any]: + d = self.__dict__ + # Do not serialize the runtime attributes + d.pop("_initialized", None) + d.pop("_possible_types", None) + d.pop("_random_unit_type", None) + d.pop("_forced_unit_type", None) + d.pop("_unit_counter", None) + return d + + @staticmethod + def from_dict(d: dict[str, Any]) -> TemplateRandomizer: + return TemplateRandomizer( + d["units"], + d["count"], + d["faction_types"], + d["type_classes"], + d["unit_types"], + ) + + @property + def possible_ground_units(self) -> Iterator[GroundUnitType]: + for unit_type in self._possible_types: + if unit_type in vehicle_map: + dcs_unit_type = vehicle_map[unit_type] + try: + yield next(GroundUnitType.for_dcs_type(dcs_unit_type)) + except StopIteration: + continue + + def force_type(self, type: str) -> None: + self._forced_unit_type = type + + def randomize(self) -> None: + self.randomize_unit_type() + self.reset_unit_counter() + + def reset_unit_counter(self) -> None: + if isinstance(self.count, list): + count = random.choice(range(self.count[0], self.count[1])) + elif isinstance(self.count, int): + count = self.count + self._unit_counter = count + + def init_randomization_for_faction(self, faction: Faction) -> None: + # Initializes the randomization + # This sets the random_unit_type and the random_unit_count + if self._initialized: + return + + type_list = [] + for faction_type in self.faction_types: + for unit_type in faction[faction_type]: + if isinstance(unit_type, GroundUnitType): + # GroundUnitType + type_list.append(unit_type.dcs_id) + elif issubclass(unit_type, UnitType): + # DCS Unit Type object + type_list.append(unit_type.id) + elif db.unit_type_from_name(unit_type): + # DCS Unit Type as string + type_list.append(unit_type) + else: + raise KeyError + + if self.unit_types and self.faction_types: + # If Faction types were defined use unit_types as filter + filtered_type_list = [ + unit_type for unit_type in type_list if unit_type in self.unit_types + ] + type_list = filtered_type_list + else: + # If faction_types is not defined append the unit_types + for unit_type in self.unit_types: + type_list.append(unit_type) + + if self.type_classes: + filtered_type_list = [] + for unit_type in type_list: + if unit_type in vehicle_map: + dcs_type = vehicle_map[unit_type] + else: + continue + try: + ground_unit_type = next(GroundUnitType.for_dcs_type(dcs_type)) + except (KeyError, StopIteration): + logging.error(f"Unit {unit_type} has no GroundUnitType") + continue + if ( + ground_unit_type.unit_class + and ground_unit_type.unit_class.value in self.type_classes + ): + filtered_type_list.append(unit_type) + type_list = filtered_type_list + self._possible_types = type_list + if self.randomize_unit_type(): + self.reset_unit_counter() + self._initialized = True + + @property + def unit_type(self) -> Optional[str]: + return self._random_unit_type + + def randomize_unit_type(self) -> bool: + try: + self._random_unit_type = self._forced_unit_type or random.choice( + self._possible_types + ) + except IndexError: + logging.warning("Can not initialize randomizer") + return False + return True + + @property + def unit_count(self) -> int: + if not self._unit_counter: + self.reset_unit_counter() + return self._unit_counter or 1 + + def use_unit(self) -> None: + if self._unit_counter is None: + self.reset_unit_counter() + if self._unit_counter and self._unit_counter > 0: + self._unit_counter -= 1 + else: + raise IndexError + + @property + def unit_range(self) -> list[int]: + if len(self.units) > 1: + return list(range(self.units[0], self.units[1] + 1)) + return self.units + + +class GroundObjectTemplate(ABC): + def __init__( + self, name: str, template_type: str = "", description: str = "" + ) -> None: + self.name = name + self.template_type = template_type + self.description = description + self.groups: list[GroupTemplate] = [] + + @classmethod + def from_dict(cls, d_object: dict[str, Any]) -> GroundObjectTemplate: + template = cls( + d_object["name"], d_object["template_type"], d_object["description"] + ) + for d_group in d_object["groups"]: + template.groups.append(GroupTemplate.from_dict(d_group)) + return template + + def generate( + self, + name: str, + position: PointWithHeading, + control_point: ControlPoint, + game: Game, + randomization: bool = True, + ) -> TheaterGroundObject: + + # Create the ground_object based on the type + ground_object = self._create_ground_object(name, position, control_point) + + # Generate all groups using the randomization if it defined + for g_id, group in enumerate(self.groups): + tgo_group = GroundGroup.from_template( + game.next_group_id(), + group, + ground_object, + randomization, + ) + # Set Group Name + tgo_group.name = f"{self.name} {g_id}" + + # Assign UniqueID, name and align relative to ground_object + for u_id, unit in enumerate(tgo_group.units): + unit.id = game.next_unit_id() + unit.name = f"{self.name} {g_id}-{u_id}" + if isinstance(self, AirDefenceTemplate): + # Head SAM and EWR towards the center of the conflict + unit.position.heading = ( + game.theater.heading_to_conflict_from(unit.position) + or unit.position.heading + ) + unit.position = PointWithHeading.from_point( + Point( + ground_object.position.x + unit.position.x, + ground_object.position.y + unit.position.y, + ), + unit.position.heading, + ) + ground_object.groups.append(tgo_group) + + return ground_object + + @abstractmethod + def _create_ground_object( + self, + name: str, + position: PointWithHeading, + control_point: ControlPoint, + ) -> TheaterGroundObject: + raise NotImplementedError + + @property + def randomizable(self) -> bool: + # Returns True if any group of the template has a randomizer + return any(group_template.randomizer for group_template in self.groups) + + def estimated_price_for(self, go: TheaterGroundObject) -> float: + # Price can only be estimated because of randomization + template_price = 0 + for g_id, group in enumerate(self.groups): + tgo_group = GroundGroup.from_template(g_id, group, go) + for unit in tgo_group.units: + if unit.type in vehicle_map: + dcs_type = vehicle_map[unit.type] + try: + unit_type = next(GroundUnitType.for_dcs_type(dcs_type)) + except StopIteration: + continue + template_price = template_price + unit_type.price + return template_price + + @property + def size(self) -> int: + return sum([len(group.units) for group in self.groups]) + + @property + def min_size(self) -> int: + return self._size_for_randomized(True) + + @property + def max_size(self) -> int: + return self._size_for_randomized(False) + + def _size_for_randomized(self, min_size: bool) -> int: + size = 0 + for group in self.groups: + for unit_id, unit in enumerate(group.units): + if group.randomizer and unit_id in group.randomizer.units: + if isinstance(group.randomizer.count, int): + size = size + group.randomizer.count + else: + size = size + group.randomizer.count[0 if min_size else 1] + else: + size = size + 1 + return size + + @property + def required_units(self) -> list[str]: + """returns all required unit types by theyre dcs type id""" + # todo take care for randomizer + unit_types = [] + for group in self.groups: + # this completly excludes randomized groups + if not group.optional and not group.randomizer: + for unit in group.units: + if unit.type not in unit_types: + unit_types.append(unit.type) + return unit_types + + +class AirDefenceTemplate(GroundObjectTemplate): + def _create_ground_object( + self, + name: str, + position: PointWithHeading, + control_point: ControlPoint, + ) -> IadsGroundObject: + if self.template_type == "EWR": + return EwrGroundObject(name, position, control_point) + elif self.template_type in ["Long", "Medium", "Short", "AAA"]: + return SamGroundObject(name, position, control_point) + raise RuntimeError( + f" No Template Definition for AirDefence with subcategory {self.template_type}" + ) + + +class BuildingTemplate(GroundObjectTemplate): + def _create_ground_object( + self, + name: str, + position: PointWithHeading, + control_point: ControlPoint, + ) -> BuildingGroundObject: + return BuildingGroundObject( + name, + self.template_type, + position, + Heading.from_degrees(0), + control_point, + self.template_type == "fob", + ) + + +class NavalTemplate(GroundObjectTemplate): + def _create_ground_object( + self, + name: str, + position: PointWithHeading, + control_point: ControlPoint, + ) -> TheaterGroundObject: + if self.template_type == "ship": + return ShipGroundObject(name, position, control_point) + elif self.template_type == "carrier": + return CarrierGroundObject(name, control_point) + elif self.template_type == "lha": + return LhaGroundObject(name, control_point) + raise NotImplementedError + + +class CoastalTemplate(GroundObjectTemplate): + def _create_ground_object( + self, + name: str, + position: PointWithHeading, + control_point: ControlPoint, + ) -> TheaterGroundObject: + return CoastalSiteGroundObject(name, position, control_point, position.heading) + + +class ArmorTemplate(GroundObjectTemplate): + def _create_ground_object( + self, + name: str, + position: PointWithHeading, + control_point: ControlPoint, + ) -> TheaterGroundObject: + return VehicleGroupGroundObject(name, position, control_point) + + +class MissileTemplate(GroundObjectTemplate): + def _create_ground_object( + self, + name: str, + position: PointWithHeading, + control_point: ControlPoint, + ) -> TheaterGroundObject: + return MissileSiteGroundObject(name, position, control_point) + + +TEMPLATE_TYPES = { + TemplateCategory.AirDefence: AirDefenceTemplate, + TemplateCategory.Building: BuildingTemplate, + TemplateCategory.Naval: NavalTemplate, + TemplateCategory.Armor: ArmorTemplate, + TemplateCategory.Missile: MissileTemplate, + TemplateCategory.Coastal: CoastalTemplate, +} + + +class GroundObjectTemplates: + # list of templates per category. e.g. AA or similar + _templates: dict[TemplateCategory, list[GroundObjectTemplate]] + + def __init__(self) -> None: + self._templates = {} + + @property + def templates(self) -> Iterator[tuple[TemplateCategory, GroundObjectTemplate]]: + for category, templates in self._templates.items(): + for template in templates: + yield category, template + + @classmethod + def from_json(cls, template_file: str) -> GroundObjectTemplates: + # Rebuild the TemplatesObject from the json dict + + obj = GroundObjectTemplates() + with open(template_file, "r") as f: + json_templates: dict[str, list[dict[str, Any]]] = json.load(f) + for category, templates in json_templates.items(): + for d_template in templates: + template = TEMPLATE_TYPES[TemplateCategory(category)].from_dict( + d_template + ) + obj.add_template(TemplateCategory(category), template) + return obj + + def to_json(self) -> dict[str, Any]: + return { + category.value: templates for category, templates in self._templates.items() + } + + @property + def all(self) -> Iterator[GroundObjectTemplate]: + for templates in self._templates.values(): + yield from templates + + def by_name(self, template_name: str) -> Optional[GroundObjectTemplate]: + for template in self.all: + if template.name == template_name: + return template + return None + + def by_category_and_name( + self, category: TemplateCategory, template_name: str + ) -> Optional[GroundObjectTemplate]: + if category in self._templates: + for template in self._templates[category]: + if template.name == template_name: + return template + return None + + def add_template( + self, category: TemplateCategory, template: GroundObjectTemplate + ) -> None: + if category not in self._templates: + self._templates[category] = [template] + else: + self._templates[category].append(template) + + def for_category( + self, category: TemplateCategory, sub_category: Optional[str] = None + ) -> Iterator[GroundObjectTemplate]: + + if category not in self._templates: + return None + + for template in self._templates[category]: + if not sub_category or template.template_type == sub_category: + yield template diff --git a/qt_ui/windows/groundobject/QGroundObjectBuyMenu.py b/qt_ui/windows/groundobject/QGroundObjectBuyMenu.py new file mode 100644 index 00000000..d1116b87 --- /dev/null +++ b/qt_ui/windows/groundobject/QGroundObjectBuyMenu.py @@ -0,0 +1,259 @@ +import logging +from typing import Optional + +from PySide2.QtCore import Signal +from PySide2.QtGui import Qt +from PySide2.QtWidgets import ( + QComboBox, + QDialog, + QGridLayout, + QGroupBox, + QLabel, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, + QCheckBox, +) + +from game import Game +from game.point_with_heading import PointWithHeading +from game.theater import TheaterGroundObject +from game.theater.theatergroundobject import ( + VehicleGroupGroundObject, + SamGroundObject, + EwrGroundObject, + GroundGroup, +) +from gen.templates import ( + TemplateCategory, + GroundObjectTemplate, + GroupTemplate, +) +from qt_ui.uiconstants import EVENT_ICONS +from qt_ui.windows.GameUpdateSignal import GameUpdateSignal + + +class QGroundObjectGroupTemplate(QGroupBox): + group_template_changed = Signal(GroupTemplate) + # UI to show one GroupTemplate and configure the TemplateRandomizer for it + # one row: [Required | Unit Selector | Amount | Price] + # If the group is not randomizable: Just view labels instead of edit fields + + def __init__(self, group_id: int, group_template: GroupTemplate) -> None: + super(QGroundObjectGroupTemplate, self).__init__(str(group_id + 1)) + self.group_template = group_template + + self.group_layout = QGridLayout() + self.setLayout(self.group_layout) + + self.amount_selector = QSpinBox() + self.unit_selector = QComboBox() + self.group_selector = QCheckBox() + + self.group_selector.setChecked(True) + self.group_selector.setEnabled(self.group_template.optional) + + if self.group_template.randomizer: + # Group can be randomized + for unit in self.group_template.randomizer.possible_ground_units: + self.unit_selector.addItem(f"{unit} [${unit.price}M]", userData=unit) + self.group_layout.addWidget( + self.unit_selector, 0, 0, alignment=Qt.AlignRight + ) + self.group_layout.addWidget( + self.amount_selector, 0, 1, alignment=Qt.AlignRight + ) + + self.amount_selector.setMinimum(1) + self.amount_selector.setMaximum(len(self.group_template.units)) + self.amount_selector.setValue(self.group_template.randomizer.unit_count) + + self.on_group_changed() + else: + # Group can not be randomized so just show the group info + group_info = QVBoxLayout() + for unit_type, count in self.group_template.unit_types_count.items(): + group_info.addWidget( + QLabel(f"{count}x {unit_type}"), alignment=Qt.AlignLeft + ) + self.group_layout.addLayout(group_info, 0, 0, 1, 2) + + self.group_layout.addWidget(self.group_selector, 0, 2, alignment=Qt.AlignRight) + + self.amount_selector.valueChanged.connect(self.on_group_changed) + self.unit_selector.currentIndexChanged.connect(self.on_group_changed) + self.group_selector.stateChanged.connect(self.on_group_changed) + + def on_group_changed(self) -> None: + unit_type = self.unit_selector.itemData(self.unit_selector.currentIndex()) + count = self.amount_selector.value() if self.group_selector.isChecked() else 0 + self.group_template.randomizer.count = count + self.group_template.randomizer.force_type(unit_type.dcs_id) + self.group_template_changed.emit(self.group_template) + + +class QGroundObjectTemplateLayout(QGroupBox): + def __init__( + self, + game: Game, + ground_object: TheaterGroundObject, + template_changed_signal: Signal(GroundObjectTemplate), + current_group_value: int, + ): + super(QGroundObjectTemplateLayout, self).__init__("Groups:") + # Connect to the signal to handle template updates + self.game = game + self.ground_object = ground_object + self.template_changed_signal = template_changed_signal + self.template_changed_signal.connect(self.load_for_template) + self.template: Optional[GroundObjectTemplate] = None + + self.current_group_value = current_group_value + + self.buy_button = QPushButton("Buy") + self.buy_button.clicked.connect(self.buy_group) + + self.template_layout = QGridLayout() + self.setLayout(self.template_layout) + + self.template_grid = QGridLayout() + self.template_layout.addLayout(self.template_grid, 0, 0, 1, 2) + self.template_layout.addWidget(self.buy_button, 1, 1) + stretch = QVBoxLayout() + stretch.addStretch() + self.template_layout.addLayout(stretch, 2, 0) + + def load_for_template(self, template: GroundObjectTemplate) -> None: + self.template = template + + # Clean the current grid + for id in range(self.template_grid.count()): + self.template_grid.itemAt(id).widget().deleteLater() + + for g_id, group in enumerate(template.groups): + group_row = QGroundObjectGroupTemplate(g_id, group) + group_row.group_template_changed.connect(self.group_template_changed) + self.template_grid.addWidget(group_row) + + self.update_price() + + def group_template_changed(self, group_template: GroupTemplate) -> None: + self.update_price() + + def update_price(self) -> None: + price = "$" + str(self.template.estimated_price_for(self.ground_object)) + if self.template.randomizable: + price = "~" + price + self.buy_button.setText(f"Buy [{price}M][-${self.current_group_value}M]") + + def buy_group(self): + if not self.template: + return + groups = self.generate_groups() + + price = 0 + for group in groups: + for unit in group.units: + if unit.unit_type: + price += unit.unit_type.price + + price -= self.current_group_value + + if price > self.game.blue.budget: + self.error_money() + self.close() + return + else: + self.game.blue.budget -= price + + self.ground_object.groups = groups + + # Replan redfor missions + self.game.initialize_turn(for_red=True, for_blue=False) + + GameUpdateSignal.get_instance().updateGame(self.game) + + def error_money(self): + msg = QMessageBox() + msg.setIcon(QMessageBox.Information) + msg.setText("Not enough money to buy these units !") + msg.setWindowTitle("Not enough money") + msg.setStandardButtons(QMessageBox.Ok) + msg.setWindowFlags(Qt.WindowStaysOnTopHint) + msg.exec_() + self.close() + + def generate_groups(self) -> list[GroundGroup]: + go = self.template.generate( + self.ground_object.name, + PointWithHeading.from_point( + self.ground_object.position, self.ground_object.heading + ), + self.ground_object.control_point, + self.game, + ) + return go.groups + + +class QGroundObjectBuyMenu(QDialog): + template_changed_signal = Signal(GroundObjectTemplate) + + def __init__( + self, + parent, + ground_object: TheaterGroundObject, + game: Game, + current_group_value: int, + ): + super(QGroundObjectBuyMenu, self).__init__(parent) + + self.setMinimumWidth(350) + + self.setWindowTitle("Buy ground object @ " + ground_object.obj_name) + self.setWindowIcon(EVENT_ICONS["capture"]) + + self.mainLayout = QGridLayout() + self.setLayout(self.mainLayout) + + self.template_selector = QComboBox() + self.template_selector.currentIndexChanged.connect(self.template_changed) + + # Get the templates and fill the combobox + template_sub_category = None + if isinstance(ground_object, SamGroundObject): + template_category = TemplateCategory.AirDefence + elif isinstance(ground_object, VehicleGroupGroundObject): + template_category = TemplateCategory.Armor + elif isinstance(ground_object, EwrGroundObject): + template_category = TemplateCategory.AirDefence + template_sub_category = "EWR" + else: + raise RuntimeError + + for template in game.blue.faction.templates.for_category( + template_category, template_sub_category + ): + self.template_selector.addItem(template.name, userData=template) + + template_selector_layout = QGridLayout() + template_selector_layout.addWidget(QLabel("Template :"), 0, 0, Qt.AlignLeft) + template_selector_layout.addWidget( + self.template_selector, 0, 1, alignment=Qt.AlignRight + ) + self.mainLayout.addLayout(template_selector_layout, 0, 0) + + self.template_layout = QGroundObjectTemplateLayout( + game, ground_object, self.template_changed_signal, current_group_value + ) + self.mainLayout.addWidget(self.template_layout, 1, 0) + self.setLayout(self.mainLayout) + + # Update UI + self.template_changed() + + def template_changed(self): + template = self.template_selector.itemData( + self.template_selector.currentIndex() + ) + self.template_changed_signal.emit(template) diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py index 750ee898..3553e3e8 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -1,17 +1,12 @@ import logging -from typing import List, Optional -from PySide2.QtGui import Qt from PySide2.QtWidgets import ( - QComboBox, QDialog, QGridLayout, QGroupBox, QHBoxLayout, QLabel, - QMessageBox, QPushButton, - QSpinBox, QVBoxLayout, ) from dcs import Point, vehicles @@ -19,6 +14,7 @@ from dcs import Point, vehicles from game import Game from game.config import REWARDS from game.data.building_data import FORTIFICATION_BUILDINGS +from game.data.groundunitclass import GroundUnitClass from game.dcs.groundunittype import GroundUnitType from game.theater import ControlPoint, TheaterGroundObject from game.theater.theatergroundobject import ( @@ -27,13 +23,11 @@ from game.theater.theatergroundobject import ( SamGroundObject, VehicleGroupGroundObject, ) -from gen.defenses.armor_group_generator import generate_armor_group_of_type_and_size -from gen.sam.ewr_group_generator import get_faction_possible_ewrs_generator -from gen.sam.sam_group_generator import get_faction_possible_sams_generator from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.QBudgetBox import QBudgetBox from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.groundobject.QBuildingInfo import QBuildingInfo +from qt_ui.windows.groundobject.QGroundObjectBuyMenu import QGroundObjectBuyMenu class QGroundObjectMenu(QDialog): @@ -41,17 +35,12 @@ class QGroundObjectMenu(QDialog): self, parent, ground_object: TheaterGroundObject, - buildings: Optional[List[TheaterGroundObject]], cp: ControlPoint, game: Game, ): super().__init__(parent) self.setMinimumWidth(350) self.ground_object = ground_object - if buildings is None: - self.buildings = [] - else: - self.buildings = buildings self.cp = cp self.game = game self.setWindowTitle( @@ -231,225 +220,7 @@ class QGroundObjectMenu(QDialog): GameUpdateSignal.get_instance().updateGame(self.game) def buy_group(self): - self.subwindow = QBuyGroupForGroundObjectDialog( - self, self.ground_object, self.cp, self.game, self.total_value + self.subwindow = QGroundObjectBuyMenu( + self, self.ground_object, self.game, self.total_value ) self.subwindow.show() - - -class QBuyGroupForGroundObjectDialog(QDialog): - def __init__( - self, - parent, - ground_object: TheaterGroundObject, - cp: ControlPoint, - game: Game, - current_group_value: int, - ): - super(QBuyGroupForGroundObjectDialog, self).__init__(parent) - - self.setMinimumWidth(350) - self.ground_object = ground_object - self.cp = cp - self.game = game - self.current_group_value = current_group_value - - self.setWindowTitle("Buy units @ " + self.ground_object.obj_name) - self.setWindowIcon(EVENT_ICONS["capture"]) - - self.buySamButton = QPushButton("Buy") - self.buyArmorButton = QPushButton("Buy") - self.buySamLayout = QGridLayout() - self.buyArmorLayout = QGridLayout() - self.amount = QSpinBox() - self.buyArmorCombo = QComboBox() - self.samCombo = QComboBox() - self.buySamBox = QGroupBox("Buy SAM site :") - self.buyArmorBox = QGroupBox("Buy defensive position :") - - faction = self.game.blue.faction - - # Sams - - possible_sams = get_faction_possible_sams_generator(faction) - for sam in possible_sams: - # Pre Generate SAM to get the real price - generator = sam(self.game, self.ground_object) - generator.generate() - self.samCombo.addItem( - generator.name + " [$" + str(generator.price) + "M]", userData=generator - ) - self.samCombo.currentIndexChanged.connect(self.samComboChanged) - - self.buySamLayout.addWidget(QLabel("Site Type :"), 0, 0, Qt.AlignLeft) - self.buySamLayout.addWidget(self.samCombo, 0, 1, alignment=Qt.AlignRight) - self.buySamLayout.addWidget(self.buySamButton, 1, 1, alignment=Qt.AlignRight) - stretch = QVBoxLayout() - stretch.addStretch() - self.buySamLayout.addLayout(stretch, 2, 0) - - self.buySamButton.clicked.connect(self.buySam) - - # EWRs - - buy_ewr_box = QGroupBox("Buy EWR:") - buy_ewr_layout = QGridLayout() - buy_ewr_box.setLayout(buy_ewr_layout) - - buy_ewr_layout.addWidget(QLabel("Radar type:"), 0, 0, Qt.AlignLeft) - - self.ewr_selector = QComboBox() - buy_ewr_layout.addWidget(self.ewr_selector, 0, 1, alignment=Qt.AlignRight) - ewr_types = get_faction_possible_ewrs_generator(faction) - for ewr_type in ewr_types: - # Pre Generate to get the real price - generator = ewr_type(self.game, self.ground_object) - generator.generate() - self.ewr_selector.addItem( - generator.name() + " [$" + str(generator.price) + "M]", - userData=generator, - ) - self.ewr_selector.currentIndexChanged.connect(self.on_ewr_selection_changed) - - self.buy_ewr_button = QPushButton("Buy") - self.buy_ewr_button.clicked.connect(self.buy_ewr) - buy_ewr_layout.addWidget(self.buy_ewr_button, 1, 1, alignment=Qt.AlignRight) - stretch = QVBoxLayout() - stretch.addStretch() - buy_ewr_layout.addLayout(stretch, 2, 0) - - # Armored units - for unit in set(faction.ground_units): - self.buyArmorCombo.addItem(f"{unit} [${unit.price}M]", userData=unit) - self.buyArmorCombo.currentIndexChanged.connect(self.armorComboChanged) - - self.amount.setMinimum(2) - self.amount.setMaximum(8) - self.amount.setValue(2) - self.amount.valueChanged.connect(self.amountComboChanged) - - self.buyArmorLayout.addWidget(QLabel("Unit type :"), 0, 0, Qt.AlignLeft) - self.buyArmorLayout.addWidget(self.buyArmorCombo, 0, 1, alignment=Qt.AlignRight) - self.buyArmorLayout.addWidget( - QLabel("Group size :"), 1, 0, alignment=Qt.AlignLeft - ) - self.buyArmorLayout.addWidget(self.amount, 1, 1, alignment=Qt.AlignRight) - self.buyArmorLayout.addWidget( - self.buyArmorButton, 2, 1, alignment=Qt.AlignRight - ) - stretch2 = QVBoxLayout() - stretch2.addStretch() - self.buyArmorLayout.addLayout(stretch2, 3, 0) - - self.buyArmorButton.clicked.connect(self.buyArmor) - - # Do layout - self.buySamBox.setLayout(self.buySamLayout) - self.buyArmorBox.setLayout(self.buyArmorLayout) - - self.mainLayout = QHBoxLayout() - - if isinstance(self.ground_object, SamGroundObject): - self.mainLayout.addWidget(self.buySamBox) - elif isinstance(self.ground_object, VehicleGroupGroundObject): - self.mainLayout.addWidget(self.buyArmorBox) - elif isinstance(self.ground_object, EwrGroundObject): - self.mainLayout.addWidget(buy_ewr_box) - - self.setLayout(self.mainLayout) - - try: - self.samComboChanged(0) - self.armorComboChanged(0) - self.on_ewr_selection_changed(0) - except: - pass - - def samComboChanged(self, index): - self.buySamButton.setText( - "Buy [$" - + str(self.samCombo.itemData(index).price) - + "M] [-$" - + str(self.current_group_value) - + "M]" - ) - - def on_ewr_selection_changed(self, index): - ewr = self.ewr_selector.itemData(index) - self.buy_ewr_button.setText( - f"Buy [${ewr.price}M][-${self.current_group_value}M]" - ) - - def armorComboChanged(self, index): - unit_type = self.buyArmorCombo.itemData(self.buyArmorCombo.currentIndex()) - price = unit_type.price * self.amount.value() - self.buyArmorButton.setText(f"Buy [${price}M][-${self.current_group_value}M]") - - def amountComboChanged(self): - unit_type = self.buyArmorCombo.itemData(self.buyArmorCombo.currentIndex()) - price = unit_type.price * self.amount.value() - self.buyArmorButton.setText(f"Buy [${price}M][-${self.current_group_value}M]") - - def buyArmor(self): - logging.info("Buying Armor ") - utype = self.buyArmorCombo.itemData(self.buyArmorCombo.currentIndex()) - price = utype.price * self.amount.value() - self.current_group_value - if price > self.game.blue.budget: - self.error_money() - self.close() - return - else: - self.game.blue.budget -= price - - # Generate Armor - group = generate_armor_group_of_type_and_size( - self.game, self.ground_object, utype, int(self.amount.value()) - ) - self.ground_object.groups = [group] - - # Replan redfor missions - self.game.initialize_turn(for_red=True, for_blue=False) - - GameUpdateSignal.get_instance().updateGame(self.game) - - def buySam(self): - sam_generator = self.samCombo.itemData(self.samCombo.currentIndex()) - price = sam_generator.price - self.current_group_value - if price > self.game.blue.budget: - self.error_money() - return - else: - self.game.blue.budget -= price - - self.ground_object.groups = list(sam_generator.groups) - - # Replan redfor missions - self.game.initialize_turn(for_red=True, for_blue=False) - - GameUpdateSignal.get_instance().updateGame(self.game) - - def buy_ewr(self): - ewr_generator = self.ewr_selector.itemData(self.ewr_selector.currentIndex()) - price = ewr_generator.price - self.current_group_value - if price > self.game.blue.budget: - self.error_money() - return - else: - self.game.blue.budget -= price - - self.ground_object.groups = [ewr_generator.vg] - - # Replan redfor missions - self.game.initialize_turn(for_red=True, for_blue=False) - - GameUpdateSignal.get_instance().updateGame(self.game) - - def error_money(self): - msg = QMessageBox() - msg.setIcon(QMessageBox.Information) - msg.setText("Not enough money to buy these units !") - msg.setWindowTitle("Not enough money") - msg.setStandardButtons(QMessageBox.Ok) - msg.setWindowFlags(Qt.WindowStaysOnTopHint) - msg.exec_() - self.close() diff --git a/resources/groundobject_templates.p b/resources/groundobject_templates.p deleted file mode 100644 index 59406efc..00000000 Binary files a/resources/groundobject_templates.p and /dev/null differ diff --git a/resources/tools/generate_groundobject_templates.py b/resources/tools/generate_groundobject_templates.py deleted file mode 100644 index 6e443aea..00000000 --- a/resources/tools/generate_groundobject_templates.py +++ /dev/null @@ -1,59 +0,0 @@ -import pickle -import typing - -from dcs.mission import Mission -from dcs.mapping import Point -from dcs.unit import * -from dcs.statics import warehouse_map, fortification_map - - -def load_templates(): - temp_mis = Mission() - temp_mis.load_file("resources/tools/groundobject_templates.miz") - - groups = {} # type: typing.Dict[str, typing.Dict[int, typing.List[Static]]] - - for static_group in ( - temp_mis.country("USA").static_group - + temp_mis.country("USAF Aggressors").static_group - ): - for static in static_group.units: - static_name = str(static.name).split()[0] - tpl_name, tpl_idx = static_name[:-1], int(static_name[-1]) - - groups[tpl_name] = groups.get(tpl_name, {}) - groups[tpl_name][tpl_idx] = groups[tpl_name].get(tpl_idx, []) - groups[tpl_name][tpl_idx].append(static) - - tpls = {name: {idx: [] for idx in groups[name].keys()} for name in groups.keys()} - for category_name, category_groups in groups.items(): - for idx, static_groups in category_groups.items(): - dist = -1 - a, b = None, None - for aa in static_groups: - for bb in static_groups: - if aa.position.distance_to_point(bb.position) > dist: - dist = aa.position.distance_to_point(bb.position) - a = aa - b = bb - - center = a.position.point_from_heading( - a.position.heading_between_point(b.position), dist / 2 - ) - for static in static_groups: - tpls[category_name][idx].append( - { - "type": static.type, - "offset": Point( - center.x - static.position.x, center.y - static.position.y - ), - "heading": static.heading, - } - ) - - tpls["aa"] = {0: [{"type": "AA", "offset": Point(0, 0), "heading": 0}]} - return tpls - - -with open("resources/groundobject_templates.p", "wb") as f: - pickle.dump(load_templates(), f) diff --git a/resources/tools/template_helper.py b/resources/tools/template_helper.py new file mode 100644 index 00000000..5bc5b697 --- /dev/null +++ b/resources/tools/template_helper.py @@ -0,0 +1,785 @@ +from __future__ import annotations + +import argparse +import json +import math +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Optional, Any, Iterator + +from tabulate import tabulate + +import dcs +from dcs import Point + +from game import Game +from game.campaignloader import CampaignAirWingConfig +from game.db import FACTIONS +from game.missiongenerator.tgogenerator import ( + GroundObjectGenerator, +) +from game.point_with_heading import PointWithHeading +from game.settings import Settings +from game.theater import CaucasusTheater, OffMapSpawn +from game.unitmap import UnitMap +from game.utils import Heading +from gen.to_remove.armored_group_generator import ( + FixedSizeArmorGroupGenerator, + FixedSizeArmorGroupGeneratorWithAA, +) +from gen.to_remove.carrier_group import ( + CarrierGroupGenerator, + CarrierStrikeGroup8Generator, +) +from gen.to_remove.lha_group import LHAGroupGenerator +from gen.to_remove.ship_group_generator import SHIP_MAP +from gen.to_remove.coastal_group_generator import COASTAL_MAP +from gen.templates import ( + GroundObjectTemplates, + TemplateCategory, + GroupTemplate, + UnitTemplate, + TemplateEncoder, + MissileTemplate, + BuildingTemplate, + CoastalTemplate, + NavalTemplate, + TemplateRandomizer, + ArmorTemplate, + TEMPLATE_TYPES, + AirDefenceTemplate, +) +from gen.to_remove.missiles_group_generator import MISSILES_MAP +from gen.to_remove.airdefensegroupgenerator import AirDefenseGroupGenerator +from gen.to_remove.ewr_group_generator import EWR_MAP +from gen.to_remove.ewrs import EwrGenerator +from gen.to_remove.sam_group_generator import SAM_MAP +from gen.naming import namegen +from qt_ui import liberation_install + + +TEMPLATES_MIZ = "resources/templates/templates.miz" +MIGRATE_MIZ = "resources/tools/groundobject_templates.miz" +TEMPLATES_BLOB = "resources/templates/templates.json" +TEMPLATES_MAP = "resources/templates/template_map.json" +TABLE_FILE = "doc/templates/template_list.md" + + +@dataclass +class GroupTemplateMapping: + name: str + optional: bool + unit_types: list[str] + unit_classes: list[UnitClass] + + def to_json(self) -> dict[str, Any]: + d = self.__dict__ + if not self.optional: + d.pop("optional") + if not self.unit_types: + d.pop("unit_types") + if not self.unit_classes: + d.pop("unit_classes") + return d + + @staticmethod + def from_dict(d: dict[str, Any]) -> GroupTemplateMapping: + optional = d["optional"] if "optional" in d else False + return GroupTemplateMapping( + d["name"], optional, d["unit_types"], d["unit_classes"] + ) + + +@dataclass +class TemplateMapping: + name: str + template_type: str + description: str + groups: list[GroupTemplateMapping] + statics: list[str] + static_mapping: Optional[GroupTemplateMapping] = None + + def to_json(self) -> dict[str, Any]: + d = self.__dict__ + if not self.static_mapping: + d.pop("static_mapping") + return d + + @staticmethod + def from_dict(d: dict[str, Any]) -> TemplateMapping: + groups = [GroupTemplateMapping.from_dict(group) for group in d["groups"]] + static_mapping = ( + GroupTemplateMapping.from_dict(d["static_mapping"]) + if "static_mapping" in d + else None + ) + return TemplateMapping( + d["name"], + d["template_type"], + d["description"], + groups, + d["statics"], + static_mapping, + ) + + +class TemplateMap: + _map: dict[str, list[TemplateMapping]] = {} + + def __init__(self): + self._map = {} + + def __getitem__(self, item: str) -> list[TemplateMapping]: + return self._map[item] + + def __setitem__(self, key, value) -> None: + self._map[key] = value + + def to_json(self) -> dict[str, Any]: + return self._map + + def mapping_for_template(self, template_name: str) -> tuple[str, TemplateMapping]: + for category, mappings in self._map.items(): + for mapping in mappings: + if mapping.name == template_name: + return category, mapping + raise RuntimeError(f"No mapping for template {template_name}") + + def mapping_for_group( + self, group_name: str + ) -> tuple[str, TemplateMapping, Optional[GroupTemplateMapping]]: + for category, mappings in self._map.items(): + for mapping in mappings: + for group_mapping in mapping.groups: + if group_mapping.name == group_name: + return category, mapping, group_mapping + if group_name in mapping.statics: + return category, mapping, mapping.static_mapping + raise RuntimeError(f"No mapping for group {group_name}") + + @property + def mappings(self) -> Iterator[TemplateMapping]: + for mapping in self._map.values(): + yield mapping + + +def import_templates( + miz_file: str, template_map_file: str, target_file: str, table_file: str +) -> None: + """Imports the template miz and the template_map as json into liberation""" + temp_mis = dcs.Mission() + temp_mis.load_file(miz_file) + + templates = GroundObjectTemplates() + + with open(template_map_file, "r") as f: + template_map_json = json.load(f) + + template_map = TemplateMap() + for name, mappings in template_map_json.items(): + template_map[TemplateCategory(name)] = [] + for mapping in mappings: + template_map[TemplateCategory(name)].append( + TemplateMapping.from_dict(mapping) + ) + + template_position: dict[str, Point] = {} + for static_group in temp_mis.country("USA").static_group: + category, mapping, group_mapping = template_map.mapping_for_group( + static_group.name + ) + template = templates.by_category_and_name( + TemplateCategory(category), mapping.name + ) + static_template = None + if not template: + template = BuildingTemplate( + mapping.name, mapping.template_type, mapping.description + ) + static_template = GroupTemplate(mapping.name, [], True) + if group_mapping: + static_template.randomizer = group_mapping.randomizer + static_template.optional = group_mapping.optional + template.groups.append(static_template) + templates.add_template(TemplateCategory(category), template) + + else: + for group in template.groups: + if mapping.name in group.name: + static_template = group + + if not static_template: + raise RuntimeError(f"No mapping for group {static_group.name}") + + for i, unit in enumerate(static_group.units): + unit_template = UnitTemplate.from_unit(unit) + if i == 0 and template.name not in template_position: + template_position[template.name] = unit.position + unit_template.position = ( + unit_template.position - template_position[template.name] + ) + static_template.units.append(unit_template) + pass + + for vehicle_group in ( + temp_mis.country("USA").vehicle_group + temp_mis.country("USA").ship_group + ): + category, mapping, group_mapping = template_map.mapping_for_group( + vehicle_group.name + ) + template = templates.by_category_and_name( + TemplateCategory(category), mapping.name + ) + if not template: + template = TEMPLATE_TYPES[TemplateCategory(category)]( + mapping.name, mapping.template_type, mapping.description + ) + templates.add_template(TemplateCategory(category), template) + for i, unit in enumerate(vehicle_group.units): + group_template = None + for group in template.groups: + if group.name == vehicle_group.name: + group_template = group + if not group_template: + group_template = GroupTemplate( + vehicle_group.name, + [], + False, + group_mapping.randomizer, + group_mapping.optional, + ) + template.groups.append(group_template) + unit_template = UnitTemplate.from_unit(unit) + if i == 0 and template.name not in template_position: + template_position[template.name] = unit.position + unit_template.position = ( + unit_template.position - template_position[template.name] + ) + group_template.units.append(unit_template) + pass + + with open(target_file, "w") as f: + json.dump(templates.to_json(), f, cls=TemplateEncoder, indent="\t") + + # print Table + print_table(target_file, table_file) + + +def export_templates( + templates_file: str, miz_file: str, template_map_file: str +) -> None: + """Exports the templates.json to the templates.miz and the mapping""" + # This function will only be used on mass jobs or specific fine tuning + + templates = GroundObjectTemplates.from_json(templates_file) + + # Prepare game + theater = CaucasusTheater() + initial_ground_position = Point(-500000, 250000) + initial_water_position = Point(-350000, 250000) + control_point_ground = OffMapSpawn(1, "Spawn Ground", initial_ground_position, True) + control_point_water = OffMapSpawn(2, "Spawn Water", initial_water_position, True) + theater.add_controlpoint(control_point_ground) + theater.add_controlpoint(control_point_water) + + game = Game( + FACTIONS["Bluefor Modern"], + FACTIONS["Russia 2010"], + theater, + CampaignAirWingConfig({control_point_ground: [], control_point_water: []}), + datetime.today(), + Settings(), + 10000, + 10000, + ) + + m = dcs.Mission(game.theater.terrain) + country = m.country("USA") + unit_map = UnitMap() + + template_map = TemplateMap() + offset_x = 0 + offset_y = 0 + + for count, template_category in enumerate(TemplateCategory): + template_map[template_category.value] = [] + temmplates_for_category = list(templates.for_category(template_category)) + + # Define the offset / separation + category_separation = 10000 + group_separation = 5000 + + if template_category in [TemplateCategory.Naval]: + initial_position = initial_water_position + control_point = control_point_water + else: + initial_position = initial_ground_position + control_point = control_point_ground + + current_separation = offset_y + category_separation + offset_x = 0 + offset_y = current_separation + max_cols = int(math.sqrt(len(temmplates_for_category))) + for template in temmplates_for_category: + mapping = TemplateMapping( + template.name, + template.template_type, + template.description, + [], + [], + ) + position = Point( + initial_position.x + offset_x, initial_position.y + offset_y + ) + + # Initialize the template + for group_template in template.groups: + if group_template.randomizer: + group_template.randomizer.init_randomization_for_faction( + game.blue.faction + ) + + ground_object = template.generate( + template.name, + PointWithHeading.from_point(position, Heading.from_degrees(0)), + control_point, + game, + False, # Do not use randomization during export! + ) + + for g_id, group in enumerate(ground_object.groups): + group.name = f"{template.name} {g_id}" + for u_id, unit in enumerate(group.units): + unit.name = f"{template.name} {g_id}-{u_id}" + group_template = template.groups[g_id] + group_mapping = GroupTemplateMapping( + group.name, group_template.optional, group_template.randomizer + ) + if group.static_group: + for unit in group.units: + mapping.statics.append(unit.name) + # No mapping as we have no randomization yet + else: + mapping.groups.append(group_mapping) + + generator = GroundObjectGenerator(ground_object, country, game, m, unit_map) + generator.generate(unique_name=False) # Prevent the ID prefix + + if ((offset_y - current_separation) / group_separation) < max_cols: + offset_y += group_separation + else: + offset_y = current_separation + offset_x += group_separation + + template_map[template_category.name].append(mapping) + # Dump the template_map as json + with open(template_map_file, "w") as f: + json.dump(template_map, f, cls=TemplateEncoder, sort_keys=True, indent="\t") + + m.save(miz_file) + + +def print_table(templates_file: str, table_file: str) -> None: + # Category # Sub Category # Template Name # Unit_types + + templates = GroundObjectTemplates.from_json(templates_file) + table = [] + + for template_category in TemplateCategory: + category_templates = list(templates.for_category(template_category)) + for template in sorted( + category_templates, key=lambda x: (x.template_type, x.name) + ): + groups = [] + options = [] + for group in template.groups: + units = [] + group_options = [] + for unit in group.units: + units.append(unit.type) + groups.append("