From 60c8c80480edb2e925cb8c8caae9239ddbf71fa9 Mon Sep 17 00:00:00 2001 From: RndName Date: Sat, 29 Jan 2022 00:42:58 +0100 Subject: [PATCH] Decoupling and generalization of templates Improvement for factions and templates which will allow decoupling of the templates from the actual units - Implement UnitGroup class which matches unit_types and possible templates as the needed abstraction layer for decoupling. - Refactor UnitType, Add ShipUnitType and all ships we currently use - Remove serialized template.json and migrated to multiple yaml templates (one for each template) and multiple .miz - Reorganized a lot of templates and started with generalization of many types (AAA, Flak, SHORAD, Navy) - Fixed a lot of bugs from the previous reworks (group name generation, strike targets...) - Reorganized the faction file completly. removed redundant lists, added presets for complex groups / families of units like sams - Reworked the building template handling. Some templates are unused like "village" - Reworked how groups from templates can be merged again for the dcs group creation (e.g. the skynet plugin requires them to be in the same group) - Allow to define alternative tasks --- game/data/doctrine.py | 46 +- game/data/groundunitclass.py | 17 - game/data/groups.py | 63 ++ game/data/units.py | 40 + game/dcs/aircrafttype.py | 35 +- game/dcs/groundunittype.py | 44 +- game/dcs/shipunittype.py | 74 ++ game/dcs/unitgroup.py | 157 ++++ game/dcs/unittype.py | 47 +- game/factions/faction.py | 374 +++++--- .../aircraft/waypoints/baiingress.py | 2 +- .../aircraft/waypoints/deadingress.py | 6 +- .../aircraft/waypoints/seadingress.py | 6 +- game/missiongenerator/flotgenerator.py | 10 +- game/missiongenerator/luagenerator.py | 2 +- game/missiongenerator/tgogenerator.py | 205 ++--- game/procurement.py | 14 +- game/theater/controlpoint.py | 23 +- game/theater/start_generator.py | 248 +++--- game/theater/theatergroundobject.py | 53 +- gen/flights/flightplan.py | 4 +- gen/ground_forces/ai_ground_planner.py | 18 +- gen/templates.py | 820 ++++++++++-------- .../groundobject/QGroundObjectBuyMenu.py | 89 +- .../windows/groundobject/QGroundObjectMenu.py | 9 +- tests/resources/valid_faction.json | 19 +- tests/test_factions.py | 14 +- 27 files changed, 1481 insertions(+), 958 deletions(-) delete mode 100644 game/data/groundunitclass.py create mode 100644 game/data/groups.py create mode 100644 game/data/units.py create mode 100644 game/dcs/shipunittype.py create mode 100644 game/dcs/unitgroup.py diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 0466d31d..0b9e19ed 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,15 +1,15 @@ from dataclasses import dataclass from datetime import timedelta -from game.data.groundunitclass import GroundUnitClass +from game.data.units import UnitClass from game.utils import Distance, feet, nautical_miles @dataclass class GroundUnitProcurementRatios: - ratios: dict[GroundUnitClass, float] + ratios: dict[UnitClass, float] - def for_unit_class(self, unit_class: GroundUnitClass) -> float: + def for_unit_class(self, unit_class: UnitClass) -> float: try: return self.ratios[unit_class] / sum(self.ratios.values()) except KeyError: @@ -104,13 +104,13 @@ MODERN_DOCTRINE = Doctrine( sweep_distance=nautical_miles(60), ground_unit_procurement_ratios=GroundUnitProcurementRatios( { - GroundUnitClass.Tank: 3, - GroundUnitClass.Atgm: 2, - GroundUnitClass.Apc: 2, - GroundUnitClass.Ifv: 3, - GroundUnitClass.Artillery: 1, - GroundUnitClass.Shorads: 2, - GroundUnitClass.Recon: 1, + UnitClass.Tank: 3, + UnitClass.Atgm: 2, + UnitClass.Apc: 2, + UnitClass.Ifv: 3, + UnitClass.Artillery: 1, + UnitClass.SHORAD: 2, + UnitClass.Recon: 1, } ), ) @@ -141,13 +141,13 @@ COLDWAR_DOCTRINE = Doctrine( sweep_distance=nautical_miles(40), ground_unit_procurement_ratios=GroundUnitProcurementRatios( { - GroundUnitClass.Tank: 4, - GroundUnitClass.Atgm: 2, - GroundUnitClass.Apc: 3, - GroundUnitClass.Ifv: 2, - GroundUnitClass.Artillery: 1, - GroundUnitClass.Shorads: 2, - GroundUnitClass.Recon: 1, + UnitClass.Tank: 4, + UnitClass.Atgm: 2, + UnitClass.Apc: 3, + UnitClass.Ifv: 2, + UnitClass.Artillery: 1, + UnitClass.SHORAD: 2, + UnitClass.Recon: 1, } ), ) @@ -178,12 +178,12 @@ WWII_DOCTRINE = Doctrine( sweep_distance=nautical_miles(10), ground_unit_procurement_ratios=GroundUnitProcurementRatios( { - GroundUnitClass.Tank: 3, - GroundUnitClass.Atgm: 3, - GroundUnitClass.Apc: 3, - GroundUnitClass.Artillery: 1, - GroundUnitClass.Shorads: 3, - GroundUnitClass.Recon: 1, + UnitClass.Tank: 3, + UnitClass.Atgm: 3, + UnitClass.Apc: 3, + UnitClass.Artillery: 1, + UnitClass.SHORAD: 3, + UnitClass.Recon: 1, } ), ) diff --git a/game/data/groundunitclass.py b/game/data/groundunitclass.py deleted file mode 100644 index e435267f..00000000 --- a/game/data/groundunitclass.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from enum import unique, Enum - - -@unique -class GroundUnitClass(Enum): - Tank = "Tank" - Atgm = "ATGM" - Ifv = "IFV" - Apc = "APC" - Artillery = "Artillery" - Logistics = "Logistics" - Recon = "Recon" - Infantry = "Infantry" - Shorads = "SHORADS" - Manpads = "MANPADS" diff --git a/game/data/groups.py b/game/data/groups.py new file mode 100644 index 00000000..cac3e805 --- /dev/null +++ b/game/data/groups.py @@ -0,0 +1,63 @@ +from enum import Enum + + +class GroupRole(Enum): + Unknow = "Unknown" + AntiAir = "AntiAir" + Building = "Building" + Naval = "Naval" + GroundForce = "GroundForce" + Defenses = "Defenses" + Air = "Air" + + +class GroupTask(Enum): + EWR = "EarlyWarningRadar" + AAA = "AAA" + SHORAD = "SHORAD" + MERAD = "MERAD" + LORAD = "LORAD" + AircraftCarrier = "AircraftCarrier" + HelicopterCarrier = "HelicopterCarrier" + Navy = "Navy" + BaseDefense = "BaseDefense" # Ground + FrontLine = "FrontLine" + Air = "Air" + Missile = "Missile" + Coastal = "Coastal" + Factory = "Factory" + Ammo = "Ammo" + Oil = "Oil" + FOB = "FOB" + StrikeTarget = "StrikeTarget" + Comms = "Comms" + Power = "Power" + + +ROLE_TASKINGS: dict[GroupRole, list[GroupTask]] = { + GroupRole.Unknow: [], # No Tasking + GroupRole.AntiAir: [ + GroupTask.EWR, + GroupTask.AAA, + GroupTask.SHORAD, + GroupTask.MERAD, + GroupTask.LORAD, + ], + GroupRole.GroundForce: [GroupTask.BaseDefense, GroupTask.FrontLine], + GroupRole.Naval: [ + GroupTask.AircraftCarrier, + GroupTask.HelicopterCarrier, + GroupTask.Navy, + ], + GroupRole.Building: [ + GroupTask.Factory, + GroupTask.Ammo, + GroupTask.Oil, + GroupTask.FOB, + GroupTask.StrikeTarget, + GroupTask.Comms, + GroupTask.Power, + ], + GroupRole.Defenses: [GroupTask.Missile, GroupTask.Coastal], + GroupRole.Air: [GroupTask.Air], +} diff --git a/game/data/units.py b/game/data/units.py new file mode 100644 index 00000000..41a5a479 --- /dev/null +++ b/game/data/units.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from enum import unique, Enum + +from game.data.groups import GroupRole, GroupTask + + +@unique +class UnitClass(Enum): + Unknown = "Unknown" + Tank = "Tank" + Atgm = "ATGM" + Ifv = "IFV" + Apc = "APC" + Artillery = "Artillery" + Logistics = "Logistics" + Recon = "Recon" + Infantry = "Infantry" + AAA = "AAA" + SHORAD = "SHORAD" + Manpad = "Manpad" + SR = "SearchRadar" + STR = "SearchTrackRadar" + LowAltSR = "LowAltSearchRadar" + TR = "TrackRadar" + LN = "Launcher" + EWR = "EarlyWarningRadar" + TELAR = "TELAR" + Missile = "Missile" + AircraftCarrier = "AircraftCarrier" + HelicopterCarrier = "HelicopterCarrier" + Destroyer = "Destroyer" + Cruiser = "Cruiser" + Submarine = "Submarine" + LandingShip = "LandingShip" + Boat = "Boat" + Plane = "Plane" + + def to_dict(self) -> str: + return self.value diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index 092fb6e4..1f7af4a5 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -12,6 +12,7 @@ from dcs.helicopters import helicopter_map from dcs.planes import plane_map from dcs.unittype import FlyingType +from game.data.units import UnitClass from game.dcs.unitproperty import UnitProperty from game.dcs.unittype import UnitType from game.radio.channels import ( @@ -180,19 +181,6 @@ class AircraftType(UnitType[Type[FlyingType]]): channel_allocator: Optional[RadioChannelAllocator] channel_namer: Type[ChannelNamer] - _by_name: ClassVar[dict[str, AircraftType]] = {} - _by_unit_type: ClassVar[dict[Type[FlyingType], list[AircraftType]]] = defaultdict( - list - ) - _loaded: ClassVar[bool] = False - - def __str__(self) -> str: - return self.name - - @property - def dcs_id(self) -> str: - return self.dcs_unit_type.id - @property def flyable(self) -> bool: return self.dcs_unit_type.flyable @@ -309,35 +297,27 @@ class AircraftType(UnitType[Type[FlyingType]]): state.update(updated.__dict__) self.__dict__.update(state) - @classmethod - def register(cls, aircraft_type: AircraftType) -> None: - cls._by_name[aircraft_type.name] = aircraft_type - cls._by_unit_type[aircraft_type.dcs_unit_type].append(aircraft_type) - @classmethod def named(cls, name: str) -> AircraftType: if not cls._loaded: cls._load_all() - return cls._by_name[name] + unit = cls._by_name[name] + assert isinstance(unit, AircraftType) + return unit @classmethod def for_dcs_type(cls, dcs_unit_type: Type[FlyingType]) -> Iterator[AircraftType]: if not cls._loaded: cls._load_all() - yield from cls._by_unit_type[dcs_unit_type] + for unit in cls._by_unit_type[dcs_unit_type]: + assert isinstance(unit, AircraftType) + yield unit @staticmethod def _each_unit_type() -> Iterator[Type[FlyingType]]: yield from helicopter_map.values() yield from plane_map.values() - @classmethod - def _load_all(cls) -> None: - for unit_type in cls._each_unit_type(): - for data in cls._each_variant_of(unit_type): - cls.register(data) - cls._loaded = True - @classmethod def _each_variant_of(cls, aircraft: Type[FlyingType]) -> Iterator[AircraftType]: data_path = Path("resources/units/aircraft") / f"{aircraft.id}.yaml" @@ -417,4 +397,5 @@ class AircraftType(UnitType[Type[FlyingType]]): channel_namer=radio_config.channel_namer, kneeboard_units=units, utc_kneeboard=data.get("utc_kneeboard", False), + unit_class=UnitClass.Plane, ) diff --git a/game/dcs/groundunittype.py b/game/dcs/groundunittype.py index db93aa49..cc1a6622 100644 --- a/game/dcs/groundunittype.py +++ b/game/dcs/groundunittype.py @@ -1,65 +1,42 @@ from __future__ import annotations import logging -from collections import defaultdict from dataclasses import dataclass from pathlib import Path -from typing import Type, Optional, ClassVar, Iterator +from typing import Type, Optional, Iterator import yaml from dcs.unittype import VehicleType from dcs.vehicles import vehicle_map -from game.data.groundunitclass import GroundUnitClass +from game.data.units import UnitClass from game.dcs.unittype import UnitType @dataclass(frozen=True) class GroundUnitType(UnitType[Type[VehicleType]]): - unit_class: Optional[GroundUnitClass] spawn_weight: int - _by_name: ClassVar[dict[str, GroundUnitType]] = {} - _by_unit_type: ClassVar[ - dict[Type[VehicleType], list[GroundUnitType]] - ] = defaultdict(list) - _loaded: ClassVar[bool] = False - - def __str__(self) -> str: - return self.name - - @property - def dcs_id(self) -> str: - return self.dcs_unit_type.id - - @classmethod - def register(cls, aircraft_type: GroundUnitType) -> None: - cls._by_name[aircraft_type.name] = aircraft_type - cls._by_unit_type[aircraft_type.dcs_unit_type].append(aircraft_type) - @classmethod def named(cls, name: str) -> GroundUnitType: if not cls._loaded: cls._load_all() - return cls._by_name[name] + unit = cls._by_name[name] + assert isinstance(unit, GroundUnitType) + return unit @classmethod def for_dcs_type(cls, dcs_unit_type: Type[VehicleType]) -> Iterator[GroundUnitType]: if not cls._loaded: cls._load_all() - yield from cls._by_unit_type[dcs_unit_type] + for unit in cls._by_unit_type[dcs_unit_type]: + assert isinstance(unit, GroundUnitType) + yield unit @staticmethod def _each_unit_type() -> Iterator[Type[VehicleType]]: yield from vehicle_map.values() - @classmethod - def _load_all(cls) -> None: - for unit_type in cls._each_unit_type(): - for data in cls._each_variant_of(unit_type): - cls.register(data) - cls._loaded = True - @classmethod def _each_variant_of(cls, vehicle: Type[VehicleType]) -> Iterator[GroundUnitType]: data_path = Path("resources/units/ground_units") / f"{vehicle.id}.yaml" @@ -78,9 +55,8 @@ class GroundUnitType(UnitType[Type[VehicleType]]): introduction = "No data." class_name = data.get("class") - unit_class: Optional[GroundUnitClass] = None - if class_name is not None: - unit_class = GroundUnitClass(class_name) + # TODO Exception handling for missing classes + unit_class = UnitClass(class_name) if class_name else UnitClass.Unknown for variant in data.get("variants", [vehicle.id]): yield GroundUnitType( diff --git a/game/dcs/shipunittype.py b/game/dcs/shipunittype.py new file mode 100644 index 00000000..25125e9e --- /dev/null +++ b/game/dcs/shipunittype.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Type, Optional, ClassVar, Iterator + +import yaml +from dcs.ships import ship_map +from dcs.unittype import VehicleType, ShipType +from dcs.vehicles import vehicle_map + +from game.data.units import UnitClass +from game.dcs.unittype import UnitType + + +@dataclass(frozen=True) +class ShipUnitType(UnitType[Type[ShipType]]): + @classmethod + def named(cls, name: str) -> ShipUnitType: + if not cls._loaded: + cls._load_all() + unit = cls._by_name[name] + assert isinstance(unit, ShipUnitType) + return unit + + @classmethod + def for_dcs_type(cls, dcs_unit_type: Type[ShipType]) -> Iterator[ShipUnitType]: + if not cls._loaded: + cls._load_all() + for unit in cls._by_unit_type[dcs_unit_type]: + assert isinstance(unit, ShipUnitType) + yield unit + + @staticmethod + def _each_unit_type() -> Iterator[Type[ShipType]]: + yield from ship_map.values() + + @classmethod + def _each_variant_of(cls, ship: Type[ShipType]) -> Iterator[ShipUnitType]: + data_path = Path("resources/units/ships") / f"{ship.id}.yaml" + if not data_path.exists(): + logging.warning(f"No data for {ship.id}; it will not be available") + return + + with data_path.open(encoding="utf-8") as data_file: + data = yaml.safe_load(data_file) + + try: + introduction = data["introduced"] + if introduction is None: + introduction = "N/A" + except KeyError: + introduction = "No data." + + class_name = data.get("class") + unit_class = UnitClass(class_name) + + for variant in data.get("variants", [ship.id]): + yield ShipUnitType( + dcs_unit_type=ship, + unit_class=unit_class, + name=variant, + description=data.get( + "description", + f"No data. Google {variant}", + ), + year_introduced=introduction, + country_of_origin=data.get("origin", "No data."), + manufacturer=data.get("manufacturer", "No data."), + role=data.get("role", "No data."), + price=data.get("price", 1), + ) diff --git a/game/dcs/unitgroup.py b/game/dcs/unitgroup.py new file mode 100644 index 00000000..2ec70e95 --- /dev/null +++ b/game/dcs/unitgroup.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import copy +import itertools +import random +from dataclasses import dataclass, field +from pathlib import Path +from typing import ClassVar, TYPE_CHECKING, Any, Iterator + +import yaml + +from game.data.groups import GroupRole, GroupTask +from game.dcs.groundunittype import GroundUnitType +from game.dcs.shipunittype import ShipUnitType +from game.dcs.unittype import UnitType +from game.point_with_heading import PointWithHeading +from gen.templates import GroundObjectTemplate + +if TYPE_CHECKING: + from game import Game + from game.factions.faction import Faction + from game.theater import TheaterGroundObject, ControlPoint + + +@dataclass +class UnitGroup: + name: str + ground_units: list[GroundUnitType] + ship_units: list[ShipUnitType] + statics: list[str] + role: GroupRole + tasks: list[GroupTask] = field(default_factory=list) + template_names: list[str] = field(default_factory=list) + + _by_name: ClassVar[dict[str, UnitGroup]] = {} + _by_role: ClassVar[dict[GroupRole, list[UnitGroup]]] = {} + _loaded: bool = False + _templates: list[GroundObjectTemplate] = field(default_factory=list) + + def __str__(self) -> str: + return self.name + + def update_from_unit_group(self, unit_group: UnitGroup) -> None: + # Update tasking and templates + self.tasks.extend([task for task in unit_group.tasks if task not in self.tasks]) + self._templates.extend( + [ + template + for template in unit_group.templates + if template not in self.templates + ] + ) + + @property + def templates(self) -> list[GroundObjectTemplate]: + return self._templates + + def add_template(self, faction_template: GroundObjectTemplate) -> None: + template = copy.deepcopy(faction_template) + updated_groups = [] + for group in template.groups: + unit_types = list( + itertools.chain( + [u.dcs_id for u in self.ground_units if group.can_use_unit(u)], + [s.dcs_id for s in self.ship_units if group.can_use_unit(s)], + [s for s in self.statics if group.can_use_unit_type(s)], + ) + ) + if unit_types: + group.set_possible_types(unit_types) + updated_groups.append(group) + template.groups = updated_groups + self._templates.append(template) + + def load_templates(self, faction: Faction) -> None: + self._templates = [] + if self.template_names: + # Preferred templates + for template_name in self.template_names: + template = faction.templates.by_name(template_name) + if template: + self.add_template(template) + + if not self._templates: + # Find all matching templates if no preferred set or available + for template in list( + faction.templates.for_role_and_tasks(self.role, self.tasks) + ): + if any(self.has_unit_type(unit) for unit in template.units): + self.add_template(template) + + def set_templates(self, templates: list[GroundObjectTemplate]) -> None: + self._templates = templates + + def has_unit_type(self, unit_type: UnitType[Any]) -> bool: + return unit_type in self.ground_units or unit_type in self.ship_units + + @property + def unit_types(self) -> Iterator[str]: + for unit in self.ground_units: + yield unit.dcs_id + for ship in self.ship_units: + yield ship.dcs_id + for static in self.statics: + yield static + + @classmethod + def named(cls, name: str) -> UnitGroup: + if not cls._loaded: + cls._load_all() + return cls._by_name[name] + + def generate( + self, + name: str, + position: PointWithHeading, + control_point: ControlPoint, + game: Game, + ) -> TheaterGroundObject: + template = random.choice(self.templates) + return template.generate(name, position, control_point, game) + + @classmethod + def _load_all(cls) -> None: + for file in Path("resources/units/unit_groups").glob("*.yaml"): + if not file.is_file(): + continue + + with file.open(encoding="utf-8") as data_file: + data = yaml.safe_load(data_file) + + group_role = GroupRole(data.get("role")) + + group_tasks = [GroupTask(n) for n in data.get("tasks", [])] + + ground_units = [ + GroundUnitType.named(n) for n in data.get("ground_units", []) + ] + ship_units = [ShipUnitType.named(n) for n in data.get("ship_units", [])] + + unit_group = UnitGroup( + name=data.get("name"), + ground_units=ground_units, + ship_units=ship_units, + statics=data.get("statics", []), + role=group_role, + tasks=group_tasks, + template_names=data.get("templates", []), + ) + + cls._by_name[unit_group.name] = unit_group + if group_role in cls._by_role: + cls._by_role[group_role].append(unit_group) + else: + cls._by_role[group_role] = [unit_group] + + cls._loaded = True diff --git a/game/dcs/unittype.py b/game/dcs/unittype.py index 2fc6ec9f..f5fac57b 100644 --- a/game/dcs/unittype.py +++ b/game/dcs/unittype.py @@ -1,14 +1,20 @@ +from __future__ import annotations + +from abc import ABC +from collections import defaultdict from dataclasses import dataclass from functools import cached_property -from typing import TypeVar, Generic, Type +from typing import TypeVar, Generic, Type, ClassVar, Any, Iterator, Optional from dcs.unittype import UnitType as DcsUnitType +from game.data.units import UnitClass + DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType]) @dataclass(frozen=True) -class UnitType(Generic[DcsUnitTypeT]): +class UnitType(ABC, Generic[DcsUnitTypeT]): dcs_unit_type: DcsUnitTypeT name: str description: str @@ -17,10 +23,47 @@ class UnitType(Generic[DcsUnitTypeT]): manufacturer: str role: str price: int + unit_class: UnitClass + + _by_name: ClassVar[dict[str, UnitType[Any]]] = {} + _by_unit_type: ClassVar[dict[DcsUnitTypeT, list[UnitType[Any]]]] = defaultdict(list) + _loaded: ClassVar[bool] = False def __str__(self) -> str: return self.name + @property + def dcs_id(self) -> str: + return self.dcs_unit_type.id + + @classmethod + def register(cls, unit_type: UnitType[Any]) -> None: + cls._by_name[unit_type.name] = unit_type + cls._by_unit_type[unit_type.dcs_unit_type].append(unit_type) + + @classmethod + def named(cls, name: str) -> UnitType[Any]: + raise NotImplementedError + + @classmethod + def for_dcs_type(cls, dcs_unit_type: DcsUnitTypeT) -> Iterator[UnitType[Any]]: + raise NotImplementedError + + @staticmethod + def _each_unit_type() -> Iterator[DcsUnitTypeT]: + raise NotImplementedError + + @classmethod + def _each_variant_of(cls, unit: DcsUnitTypeT) -> Iterator[UnitType[Any]]: + raise NotImplementedError + + @classmethod + def _load_all(cls) -> None: + for unit_type in cls._each_unit_type(): + for data in cls._each_variant_of(unit_type): + cls.register(data) + cls._loaded = True + @cached_property def eplrs_capable(self) -> bool: return getattr(self.dcs_unit_type, "eplrs", False) diff --git a/game/factions/faction.py b/game/factions/faction.py index 195623eb..27439824 100644 --- a/game/factions/faction.py +++ b/game/factions/faction.py @@ -3,12 +3,13 @@ from __future__ import annotations import copy import itertools import logging +import random from dataclasses import dataclass, field from typing import Optional, Dict, Type, List, Any, Iterator, TYPE_CHECKING import dcs from dcs.countries import country_dict -from dcs.unittype import ShipType, UnitType +from dcs.unittype import ShipType from game.data.building_data import ( WW2_ALLIES_BUILDINGS, @@ -22,10 +23,19 @@ from game.data.doctrine import ( COLDWAR_DOCTRINE, WWII_DOCTRINE, ) -from game.data.groundunitclass import GroundUnitClass +from game.data.units import UnitClass +from game.data.groups import GroupRole, GroupTask +from game import db from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType -from gen.templates import GroundObjectTemplates, TemplateCategory +from game.dcs.shipunittype import ShipUnitType +from game.dcs.unitgroup import UnitGroup +from game.dcs.unittype import UnitType +from gen.templates import ( + GroundObjectTemplates, + GroundObjectTemplate, + GroupTemplate, +) if TYPE_CHECKING: from game.theater.start_generator import ModSettings @@ -70,50 +80,26 @@ class Faction: # Logistics units used logistics_units: List[GroundUnitType] = field(default_factory=list) - # Possible SAMS site generators for this faction - air_defenses: List[str] = field(default_factory=list) + # Possible Air Defence units, Like EWRs + air_defense_units: List[GroundUnitType] = field(default_factory=list) - # Possible EWR generators for this faction. - ewrs: List[GroundUnitType] = field(default_factory=list) + # A list of all supported sets of units + preset_groups: list[UnitGroup] = field(default_factory=list) # Possible Missile site generators for this faction - missiles: List[str] = field(default_factory=list) - - # Possible costal site generators for this faction - coastal_defenses: List[str] = field(default_factory=list) + missiles: List[GroundUnitType] = field(default_factory=list) # Required mods or asset packs requirements: Dict[str, str] = field(default_factory=dict) - # possible aircraft carrier units - aircraft_carrier: List[Type[ShipType]] = field(default_factory=list) - - # possible helicopter carrier units - helicopter_carrier: List[Type[ShipType]] = field(default_factory=list) - # Possible carrier names carrier_names: List[str] = field(default_factory=list) # Possible helicopter carrier names helicopter_carrier_names: List[str] = field(default_factory=list) - # Navy group generators - navy_generators: List[str] = field(default_factory=list) - - # Available destroyers - destroyers: List[Type[ShipType]] = field(default_factory=list) - - # Available cruisers - cruisers: List[Type[ShipType]] = field(default_factory=list) - - # How many navy group should we try to generate per CP on startup for this faction - navy_group_count: int = field(default=1) - - # How many missiles group should we try to generate per CP on startup for this faction - missiles_group_count: int = field(default=1) - - # How many coastal group should we try to generate per CP on startup for this faction - coastal_group_count: int = field(default=1) + # Available Naval Units + naval_units: List[ShipUnitType] = field(default_factory=list) # Whether this faction has JTAC access has_jtac: bool = field(default=False) @@ -124,7 +110,7 @@ class Faction: # doctrine doctrine: Doctrine = field(default=MODERN_DOCTRINE) - # List of available buildings for this faction + # List of available building templates for this faction building_set: List[str] = field(default_factory=list) # List of default livery overrides @@ -142,64 +128,183 @@ class Faction: # All possible templates which can be generated by the faction templates: GroundObjectTemplates = field(default=GroundObjectTemplates()) + # All available unit_groups + unit_groups: dict[GroupRole, list[UnitGroup]] = field(default_factory=dict) + + # Save all accessible units for performance increase + _accessible_units: list[UnitType[Any]] = field(default_factory=list) + 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 + @property + def accessible_units(self) -> Iterator[UnitType[Any]]: + yield from self._accessible_units - 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 + @property + def air_defenses(self) -> list[str]: + """Returns the Air Defense types""" + air_defenses = [a.name for a in self.air_defense_units] + air_defenses.extend( + [pg.name for pg in self.preset_groups if pg.role == GroupRole.AntiAir] + ) + return sorted(air_defenses) + + def has_access_to_unit_type(self, unit_type: str) -> bool: + # GroundUnits + if any(unit_type == u.dcs_id for u in self.accessible_units): + return True + + # Statics + if db.static_type_from_name(unit_type) is not None: + # TODO Improve the statics checking + 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) + def has_access_to_unit_class(self, unit_class: UnitClass) -> bool: + return any(unit.unit_class is unit_class for unit in self.accessible_units) + + def _load_accessible_units(self, templates: GroundObjectTemplates) -> None: + self._accessible_units = [] + all_units: Iterator[UnitType[Any]] = itertools.chain( + self.ground_units, + self.infantry_units, + self.air_defense_units, + self.naval_units, + self.missiles, + ( + ground_unit + for preset_group in self.preset_groups + for ground_unit in preset_group.ground_units + ), + ( + ship_unit + for preset_group in self.preset_groups + for ship_unit in preset_group.ship_units + ), + ) + for unit in all_units: + if unit not in self._accessible_units: + self._accessible_units.append(unit) + + def initialize( + self, all_templates: GroundObjectTemplates, mod_settings: ModSettings + ) -> None: + # Apply the mod settings + self._apply_mod_settings(mod_settings) + # Load all accessible units and store them for performant later usage + self._load_accessible_units(all_templates) + # Load all faction compatible templates + self._load_templates(all_templates) + # Load Unit Groups + self._load_unit_groups() + + def _add_unit_group(self, unit_group: UnitGroup, merge: bool = True) -> None: + if not unit_group.templates: + unit_group.load_templates(self) + if not unit_group.templates: + # Empty templates will throw an error on generation + logging.error( + f"Skipping Unit group {unit_group.name} as no templates are available to generate the group" + ) + return + if unit_group.role in self.unit_groups: + for group in self.unit_groups[unit_group.role]: + if merge and all(task in group.tasks for task in unit_group.tasks): + # Update existing group if same tasking + group.update_from_unit_group(unit_group) + return + # Add new Unit_group + self.unit_groups[unit_group.role].append(unit_group) + else: + self.unit_groups[unit_group.role] = [unit_group] + + def _load_unit_groups(self) -> None: + # This function will create all the UnitGroups for the faction + # It will create a unit group for each global Template, Building or + # Legacy supported templates (not yet migrated from the generators). + # For every preset_group there will be a separate UnitGroup so no mixed + # UnitGroups will be generated for them. Special groups like complex SAM Systems + self.unit_groups = {} + + # Generate UnitGroups for all global templates + for role, template in self.templates.templates: + if template.generic or role == GroupRole.Building: + # Build groups for global templates and buildings + self._add_group_for_template(role, template) + + # Add preset groups + for preset_group in self.preset_groups: + # Add as separate group, do not merge with generic groups! + self._add_unit_group(preset_group, False) + + def _add_group_for_template( + self, role: GroupRole, template: GroundObjectTemplate + ) -> None: + unit_group = UnitGroup( + f"{role.value}: {', '.join([t.value for t in template.tasks])}", + [u for u in template.units if isinstance(u, GroundUnitType)], + [u for u in template.units if isinstance(u, ShipUnitType)], + list(template.statics), + role, + ) + unit_group.tasks = template.tasks + unit_group.set_templates([template]) + self._add_unit_group(unit_group) + + def initialize_group_template( + self, group: GroupTemplate, faction_sensitive: bool = True + ) -> bool: + # Sensitive defines if the initialization should check if the unit is available + # to this faction or not. It is disabled for migration only atm. + unit_types = [ + t + for t in group.unit_types + if not faction_sensitive or self.has_access_to_unit_type(t) + ] + + alternative_types = [] + for accessible_unit in self.accessible_units: + if accessible_unit.unit_class in group.unit_classes: + unit_types.append(accessible_unit.dcs_id) + if accessible_unit.unit_class in group.alternative_classes: + alternative_types.append(accessible_unit.dcs_id) + + if not unit_types and not alternative_types and not group.optional: + raise StopIteration + + types = unit_types or alternative_types + group.set_possible_types(types) + return len(types) > 0 + + def _load_templates(self, all_templates: GroundObjectTemplates) -> None: + self.templates = GroundObjectTemplates() + # This loads all templates which are usable by the faction + for role, template in all_templates.templates: + # 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) + try: + faction_template.groups[:] = [ + group_template + for group_template in faction_template.groups + if self.initialize_group_template(group_template) + ] + if ( + role == GroupRole.Building + and GroupTask.StrikeTarget in template.tasks + and faction_template.category not in self.building_set + ): + # Special handling for strike targets. Skip if not supported by faction + continue + if faction_template.groups: + self.templates.add_template(role, faction_template) + continue + except StopIteration: + pass + + logging.info(f"{self.name} can not use template {template.name}") @classmethod def from_json(cls: Type[Faction], json: Dict[str, Any]) -> Faction: @@ -240,34 +345,30 @@ class Faction: faction.logistics_units = [ GroundUnitType.named(n) for n in json.get("logistics_units", []) ] - faction.ewrs = [GroundUnitType.named(n) for n in json.get("ewrs", [])] + faction.air_defense_units = [ + GroundUnitType.named(n) for n in json.get("air_defense_units", []) + ] + faction.missiles = [GroundUnitType.named(n) for n in json.get("missiles", [])] - faction.air_defenses = json.get("air_defenses", []) - # Compatibility for older factions. All air defenses now belong to a - # single group and the generator decides what belongs where. - faction.air_defenses.extend(json.get("sams", [])) - faction.air_defenses.extend(json.get("shorads", [])) + faction.preset_groups = [ + UnitGroup.named(n) for n in json.get("preset_groups", []) + ] - faction.missiles = json.get("missiles", []) - faction.coastal_defenses = json.get("coastal_defenses", []) faction.requirements = json.get("requirements", {}) faction.carrier_names = json.get("carrier_names", []) faction.helicopter_carrier_names = json.get("helicopter_carrier_names", []) - faction.navy_generators = json.get("navy_generators", []) - faction.aircraft_carrier = load_all_ships(json.get("aircraft_carrier", [])) - faction.helicopter_carrier = load_all_ships(json.get("helicopter_carrier", [])) - faction.destroyers = load_all_ships(json.get("destroyers", [])) - faction.cruisers = load_all_ships(json.get("cruisers", [])) + + faction.naval_units = [ + ShipUnitType.named(n) for n in json.get("naval_units", []) + ] + faction.has_jtac = json.get("has_jtac", False) jtac_name = json.get("jtac_unit", None) if jtac_name is not None: faction.jtac_unit = AircraftType.named(jtac_name) else: faction.jtac_unit = None - faction.navy_group_count = int(json.get("navy_group_count", 1)) - faction.missiles_group_count = int(json.get("missiles_group_count", 0)) - faction.coastal_group_count = int(json.get("coastal_group_count", 0)) # Load doctrine doctrine = json.get("doctrine", "modern") @@ -304,6 +405,7 @@ class Faction: # Templates faction.templates = GroundObjectTemplates() + return faction @property @@ -312,14 +414,49 @@ class Faction: yield from self.frontline_units yield from self.logistics_units - def infantry_with_class( - self, unit_class: GroundUnitClass - ) -> Iterator[GroundUnitType]: + def infantry_with_class(self, unit_class: UnitClass) -> Iterator[GroundUnitType]: for unit in self.infantry_units: if unit.unit_class is unit_class: yield unit - def apply_mod_settings(self, mod_settings: ModSettings) -> Faction: + def groups_for_role_and_task( + self, group_role: GroupRole, group_task: Optional[GroupTask] = None + ) -> list[UnitGroup]: + if group_role not in self.unit_groups: + return [] + groups = [] + for unit_group in self.unit_groups[group_role]: + if not group_task or group_task in unit_group.tasks: + groups.append(unit_group) + return groups + + def groups_for_role_and_tasks( + self, group_role: GroupRole, tasks: list[GroupTask] + ) -> list[UnitGroup]: + groups = [] + for task in tasks: + for group in self.groups_for_role_and_task(group_role, task): + if group not in groups: + groups.append(group) + return groups + + def random_group_for_role(self, group_role: GroupRole) -> Optional[UnitGroup]: + unit_groups = self.groups_for_role_and_task(group_role) + return random.choice(unit_groups) if unit_groups else None + + def random_group_for_role_and_task( + self, group_role: GroupRole, group_task: GroupTask + ) -> Optional[UnitGroup]: + unit_groups = self.groups_for_role_and_task(group_role, group_task) + return random.choice(unit_groups) if unit_groups else None + + def random_group_for_role_and_tasks( + self, group_role: GroupRole, tasks: list[GroupTask] + ) -> Optional[UnitGroup]: + unit_groups = self.groups_for_role_and_tasks(group_role, tasks) + return random.choice(unit_groups) if unit_groups else None + + def _apply_mod_settings(self, mod_settings: ModSettings) -> None: # aircraft if not mod_settings.a4_skyhawk: self.remove_aircraft("A-4E-C") @@ -379,24 +516,23 @@ class Faction: self.remove_vehicle("KORNET") # high digit sams if not mod_settings.high_digit_sams: - 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 + self.remove_presets("SA-10B/S-300PS") + self.remove_presets("SA-12/S-300V") + self.remove_presets("SA-20/S-300PMU-1") + self.remove_presets("SA-20B/S-300PMU-2") + self.remove_presets("SA-23/S-300VM") + self.remove_presets("SA-17") + self.remove_presets("KS-19") def remove_aircraft(self, name: str) -> None: for i in self.aircrafts: if i.dcs_unit_type.id == name: self.aircrafts.remove(i) - def remove_air_defenses(self, name: str) -> None: - for i in self.air_defenses: - if i == name: - self.air_defenses.remove(i) + def remove_presets(self, name: str) -> None: + for pg in self.preset_groups: + if pg.name == name: + self.preset_groups.remove(pg) def remove_vehicle(self, name: str) -> None: for i in self.frontline_units: diff --git a/game/missiongenerator/aircraft/waypoints/baiingress.py b/game/missiongenerator/aircraft/waypoints/baiingress.py index bd8f679d..a977c265 100644 --- a/game/missiongenerator/aircraft/waypoints/baiingress.py +++ b/game/missiongenerator/aircraft/waypoints/baiingress.py @@ -15,7 +15,7 @@ class BaiIngressBuilder(PydcsWaypointBuilder): target = self.package.target if isinstance(target, TheaterGroundObject): for group in target.groups: - group_names.append(group.name) + group_names.append(group.group_name) elif isinstance(target, MultiGroupTransport): group_names.append(target.name) elif isinstance(target, NavalControlPoint): diff --git a/game/missiongenerator/aircraft/waypoints/deadingress.py b/game/missiongenerator/aircraft/waypoints/deadingress.py index b1e31485..5af65080 100644 --- a/game/missiongenerator/aircraft/waypoints/deadingress.py +++ b/game/missiongenerator/aircraft/waypoints/deadingress.py @@ -20,9 +20,11 @@ class DeadIngressBuilder(PydcsWaypointBuilder): return for group in target.groups: - miz_group = self.mission.find_group(group.name) + miz_group = self.mission.find_group(group.group_name) if miz_group is None: - logging.error(f"Could not find group for DEAD mission {group.name}") + logging.error( + f"Could not find group for DEAD mission {group.group_name}" + ) continue task = AttackGroup(miz_group.id, weapon_type=WeaponType.Auto) diff --git a/game/missiongenerator/aircraft/waypoints/seadingress.py b/game/missiongenerator/aircraft/waypoints/seadingress.py index ea0cee99..1c57f81c 100644 --- a/game/missiongenerator/aircraft/waypoints/seadingress.py +++ b/game/missiongenerator/aircraft/waypoints/seadingress.py @@ -20,9 +20,11 @@ class SeadIngressBuilder(PydcsWaypointBuilder): return for group in target.groups: - miz_group = self.mission.find_group(group.name) + miz_group = self.mission.find_group(group.group_name) if miz_group is None: - logging.error(f"Could not find group for SEAD mission {group.name}") + logging.error( + f"Could not find group for SEAD mission {group.group_name}" + ) continue task = AttackGroup(miz_group.id, weapon_type=WeaponType.Guided) diff --git a/game/missiongenerator/flotgenerator.py b/game/missiongenerator/flotgenerator.py index 9ef91a8f..293598e9 100644 --- a/game/missiongenerator/flotgenerator.py +++ b/game/missiongenerator/flotgenerator.py @@ -28,7 +28,7 @@ from dcs.triggers import Event, TriggerOnce from dcs.unit import Vehicle, Skill from dcs.unitgroup import VehicleGroup -from game.data.groundunitclass import GroundUnitClass +from game.data.units import UnitClass from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.theater.controlpoint import ControlPoint @@ -221,7 +221,7 @@ class FlotGenerator: if self.game.settings.manpads: # 50% of armored units protected by manpad if random.choice([True, False]): - manpads = list(faction.infantry_with_class(GroundUnitClass.Manpads)) + manpads = list(faction.infantry_with_class(UnitClass.Manpad)) if manpads: u = random.choices( manpads, weights=[m.spawn_weight for m in manpads] @@ -237,12 +237,10 @@ class FlotGenerator: ) return - possible_infantry_units = set( - faction.infantry_with_class(GroundUnitClass.Infantry) - ) + possible_infantry_units = set(faction.infantry_with_class(UnitClass.Infantry)) if self.game.settings.manpads: possible_infantry_units |= set( - faction.infantry_with_class(GroundUnitClass.Manpads) + faction.infantry_with_class(UnitClass.Manpad) ) if not possible_infantry_units: return diff --git a/game/missiongenerator/luagenerator.py b/game/missiongenerator/luagenerator.py index 60bc33df..5fd5a0d3 100644 --- a/game/missiongenerator/luagenerator.py +++ b/game/missiongenerator/luagenerator.py @@ -118,7 +118,7 @@ class LuaGenerator: faction = "BlueAA" if cp.captured else "RedAA" - lua_data[faction][g.name] = { + lua_data[faction][g.group_name] = { "name": ground_object.name, "range": threat_range.meters, "position": { diff --git a/game/missiongenerator/tgogenerator.py b/game/missiongenerator/tgogenerator.py index 77667a41..b35ed12c 100644 --- a/game/missiongenerator/tgogenerator.py +++ b/game/missiongenerator/tgogenerator.py @@ -22,7 +22,6 @@ from typing import ( TypeVar, List, Any, - Union, ) from dcs import Mission, Point, unitgroup @@ -110,108 +109,93 @@ class GroundObjectGenerator: logging.warning(f"Found empty group in {self.ground_object}") continue group_name = group.group_name if unique_name else group.name - if group.static_group: - # Static Group - for i, u in enumerate(group.units): - if isinstance(u, SceneryGroundUnit): - # Special handling for scenery objects: - # Only create a trigger zone and no "real" dcs unit - self.add_trigger_zone_for_scenery(u) - continue + moving_group: Optional[MovingGroup[Any]] = None + for i, unit in enumerate(group.units): + if isinstance(unit, SceneryGroundUnit): + # Special handling for scenery objects: + # Only create a trigger zone and no "real" dcs unit + self.add_trigger_zone_for_scenery(unit) + continue - # Only skip dead units after trigger zone for scenery created! - if not u.alive: - continue + # Only skip dead units after trigger zone for scenery created! + if not unit.alive: + continue - 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" - ) - - sg = self.m.static_group( - country=self.country, - name=u.unit_name if unique_name else u.name, - _type=unit_type, - position=u.position, - heading=u.position.heading.degrees, - dead=not u.alive, + unit_type = unit_type_from_name(unit.type) + if not unit_type: + raise RuntimeError( + f"Unit type {unit.type} is not a valid dcs unit type" ) - self._register_ground_unit(u, sg.units[0]) - else: - # Moving Group - moving_group: Optional[MovingGroup[Any]] = None - for i, unit in enumerate(group.units): - if not unit.alive: - continue - if unit.type in vehicle_map: - # Vehicle Group - unit_type = vehicle_map[unit.type] - elif unit.type in ship_map: - # Ship Group - unit_type = ship_map[group.units[0].type] - else: - raise RuntimeError( - f"Unit type {unit.type} is not a valid dcs unit type" - ) - unit_name = unit.unit_name if unique_name else unit.name - if moving_group is None: - # First unit of the group will create the dcs group - if issubclass(unit_type, VehicleType): - moving_group = self.m.vehicle_group( - self.country, - group_name, - unit_type, - position=unit.position, - heading=unit.position.heading.degrees, - ) - moving_group.units[0].player_can_drive = True - self.enable_eplrs(moving_group, unit_type) - if issubclass(unit_type, ShipType): - moving_group = self.m.ship_group( - self.country, - group_name, - unit_type, - position=unit.position, - heading=unit.position.heading.degrees, - ) - if moving_group: - moving_group.units[0].name = unit_name - self.set_alarm_state(moving_group) - self._register_ground_unit(unit, moving_group.units[0]) - else: - raise RuntimeError("DCS Group creation failed") + unit_name = unit.unit_name if unique_name else unit.name + if moving_group is None or group.static_group: + # First unit of the group will create the dcs group + if issubclass(unit_type, VehicleType): + moving_group = self.m.vehicle_group( + self.country, + group_name, + unit_type, + position=unit.position, + heading=unit.position.heading.degrees, + ) + moving_group.units[0].player_can_drive = True + self.enable_eplrs(moving_group, unit_type) + elif issubclass(unit_type, ShipType): + moving_group = self.m.ship_group( + self.country, + group_name, + unit_type, + position=unit.position, + heading=unit.position.heading.degrees, + ) + elif issubclass(unit_type, StaticType): + static_group = self.m.static_group( + country=self.country, + name=unit_name, + _type=unit_type, + position=unit.position, + heading=unit.position.heading.degrees, + dead=not unit.alive, + ) + self._register_ground_unit(unit, static_group.units[0]) + continue + + if moving_group: + moving_group.units[0].name = unit_name + self.set_alarm_state(moving_group) + self._register_ground_unit(unit, moving_group.units[0]) else: - # Additional Units in the group - dcs_unit: Optional[Unit] = None - if issubclass(unit_type, VehicleType): - dcs_unit = Vehicle( - self.m.next_unit_id(), - unit_name, - unit.type, - ) - dcs_unit.player_can_drive = True - elif issubclass(unit_type, ShipType): - dcs_unit = Ship( - self.m.next_unit_id(), - unit_name, - unit_type, - ) - if dcs_unit: - dcs_unit.position = unit.position - dcs_unit.heading = unit.position.heading.degrees - moving_group.add_unit(dcs_unit) - self._register_ground_unit(unit, dcs_unit) - else: - raise RuntimeError("DCS Unit creation failed") + raise RuntimeError("DCS Group creation failed") + else: + # Additional Units in the group + dcs_unit: Optional[Unit] = None + if issubclass(unit_type, VehicleType): + dcs_unit = Vehicle( + self.m.next_unit_id(), + unit_name, + unit.type, + ) + dcs_unit.player_can_drive = True + elif issubclass(unit_type, ShipType): + dcs_unit = Ship( + self.m.next_unit_id(), + unit_name, + unit_type, + ) + if dcs_unit: + dcs_unit.position = unit.position + dcs_unit.heading = unit.position.heading.degrees + moving_group.add_unit(dcs_unit) + self._register_ground_unit(unit, dcs_unit) + else: + raise RuntimeError("DCS Unit creation failed") @staticmethod def enable_eplrs(group: VehicleGroup, unit_type: Type[VehicleType]) -> None: if unit_type.eplrs: group.points[0].tasks.append(EPLRS(group.id)) - def set_alarm_state(self, group: Union[ShipGroup, VehicleGroup]) -> None: + def set_alarm_state(self, group: MovingGroup[Any]) -> None: if self.game.settings.perf_red_alert_state: group.points[0].tasks.append(OptAlarmState(2)) else: @@ -287,7 +271,7 @@ class MissileSiteGenerator(GroundObjectGenerator): # TODO : Should be pre-planned ? # TODO : Add delay to task to spread fire task over mission duration ? for group in self.ground_object.groups: - vg = self.m.find_group(group.name) + vg = self.m.find_group(group.group_name) if vg is not None: targets = self.possible_missile_targets() if targets: @@ -327,7 +311,7 @@ class MissileSiteGenerator(GroundObjectGenerator): """ site_range = 0 for group in self.ground_object.groups: - vg = self.m.find_group(group.name) + vg = self.m.find_group(group.group_name) if vg is not None: for u in vg.units: if u.type in vehicle_map: @@ -383,7 +367,7 @@ class GenericCarrierGenerator(GroundObjectGenerator): ship_group = self.m.ship_group( self.country, - group.name, + group.group_name if unique_name else group.name, unit_type, position=group.units[0].position, heading=group.units[0].position.heading.degrees, @@ -523,23 +507,20 @@ class CarrierGenerator(GenericCarrierGenerator): def tacan_callsign(self) -> str: # TODO: Assign these properly. - if self.control_point.name == "Carrier Strike Group 8": - return "TRU" - else: - return random.choice( - [ - "STE", - "CVN", - "CVH", - "CCV", - "ACC", - "ARC", - "GER", - "ABR", - "LIN", - "TRU", - ] - ) + return random.choice( + [ + "STE", + "CVN", + "CVH", + "CCV", + "ACC", + "ARC", + "GER", + "ABR", + "LIN", + "TRU", + ] + ) class LhaGenerator(GenericCarrierGenerator): diff --git a/game/procurement.py b/game/procurement.py index f13c5b5b..713938c4 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple from game.config import RUNWAY_REPAIR_COST -from game.data.groundunitclass import GroundUnitClass +from game.data.units import UnitClass from game.dcs.groundunittype import GroundUnitType from game.theater import ControlPoint, MissionTarget @@ -116,7 +116,7 @@ class ProcurementAi: return budget def affordable_ground_unit_of_class( - self, budget: float, unit_class: GroundUnitClass + self, budget: float, unit_class: UnitClass ) -> Optional[GroundUnitType]: faction_units = set(self.faction.frontline_units) | set( self.faction.artillery_units @@ -154,10 +154,10 @@ class ProcurementAi: return budget - def most_needed_unit_class(self, cp: ControlPoint) -> GroundUnitClass: - worst_balanced: Optional[GroundUnitClass] = None + def most_needed_unit_class(self, cp: ControlPoint) -> UnitClass: + worst_balanced: Optional[UnitClass] = None worst_fulfillment = math.inf - for unit_class in GroundUnitClass: + for unit_class in UnitClass: if not self.faction.has_access_to_unit_class(unit_class): continue @@ -176,7 +176,7 @@ class ProcurementAi: worst_fulfillment = fulfillment worst_balanced = unit_class if worst_balanced is None: - return GroundUnitClass.Tank + return UnitClass.Tank return worst_balanced @staticmethod @@ -300,7 +300,7 @@ class ProcurementAi: return understaffed def cost_ratio_of_ground_unit( - self, control_point: ControlPoint, unit_class: GroundUnitClass + self, control_point: ControlPoint, unit_class: UnitClass ) -> float: allocations = control_point.allocated_ground_units( self.game.coalition_for(self.is_player).transfers diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 61b027ff..3987db0f 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -48,6 +48,7 @@ from .theatergroundobject import ( GroundUnit, ) from ..ato.starttype import StartType +from ..data.units import UnitClass from ..dcs.aircrafttype import AircraftType from ..dcs.groundunittype import GroundUnitType from ..utils import nautical_miles @@ -521,20 +522,13 @@ class ControlPoint(MissionTarget, ABC): ControlPointType.LHA_GROUP, ]: for g in self.ground_objects: - if isinstance(g, CarrierGroundObject): - for group in g.groups: - for u in group.units: - if unit_type_from_name(u.type) in [ - Forrestal, - Stennis, - KUZNECOW, - ]: - return group.name - elif isinstance(g, LhaGroundObject): - for group in g.groups: - for u in group.units: - if unit_type_from_name(u.type) in [LHA_Tarawa]: - return group.name + for group in g.groups: + for u in group.units: + if u.unit_type and u.unit_type.unit_class in [ + UnitClass.AircraftCarrier, + UnitClass.HelicopterCarrier, + ]: + return group.group_name return None # TODO: Should be Airbase specific. @@ -668,6 +662,7 @@ class ControlPoint(MissionTarget, ABC): self._retreat_squadron(game, squadron) def depopulate_uncapturable_tgos(self) -> None: + # TODO Rework this. for tgo in self.connected_objectives: if not tgo.capturable: tgo.clear() diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 9b983a43..07ef0546 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -4,7 +4,7 @@ import logging import random from dataclasses import dataclass from datetime import datetime -from typing import List +from typing import List, Optional from game import Game from game.factions.faction import Faction @@ -18,7 +18,7 @@ from game.theater.theatergroundobject import ( ) from game.utils import Heading from game.version import VERSION -from gen.templates import GroundObjectTemplates, TemplateCategory, GroundObjectTemplate +from gen.templates import GroundObjectTemplates, GroundObjectTemplate from gen.naming import namegen from . import ( ConflictTheater, @@ -28,6 +28,9 @@ from . import ( OffMapSpawn, ) from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig +from ..data.units import UnitClass +from ..data.groups import GroupRole, GroupTask, ROLE_TASKINGS +from ..dcs.unitgroup import UnitGroup from ..profiling import logged_duration from ..settings import Settings @@ -68,15 +71,15 @@ class GameGenerator: generator_settings: GeneratorSettings, mod_settings: ModSettings, ) -> None: - self.player = player.apply_mod_settings(mod_settings) - self.enemy = enemy.apply_mod_settings(mod_settings) + self.player = player + self.enemy = enemy self.theater = theater self.air_wing_config = air_wing_config self.settings = settings self.generator_settings = generator_settings - with logged_duration(f"Initializing templates"): - self.load_templates() + with logged_duration(f"Initializing faction and templates"): + self.initialize_factions(mod_settings) def generate(self) -> Game: with logged_duration("TGO population"): @@ -123,12 +126,11 @@ 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) + def initialize_factions(self, mod_settings: ModSettings) -> None: + with logged_duration("Loading Templates from mapping"): + templates = GroundObjectTemplates.from_folder("resources/templates/") + self.player.initialize(templates, mod_settings) + self.enemy.initialize(templates, mod_settings) class ControlPointGroundObjectGenerator: @@ -152,21 +154,23 @@ class ControlPointGroundObjectGenerator: 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. - self.generate_navy() - + self.generate_navy() return True - def generate_random_from_templates( - self, templates: list[GroundObjectTemplate], position: PointWithHeading + def generate_random_ground_object( + self, unit_groups: list[UnitGroup], position: PointWithHeading + ) -> None: + self.generate_ground_object_from_group(random.choice(unit_groups), position) + + def generate_ground_object_from_group( + self, unit_group: UnitGroup, position: PointWithHeading ) -> None: try: - template = random.choice(templates) with logged_duration( - f"Ground Object generation from template {template.name}" + f"Ground Object generation for unit_group " + f"{unit_group.name} ({unit_group.role.value})" ): - ground_object = template.generate( + ground_object = unit_group.generate( namegen.random_objective_name(), position, self.control_point, @@ -176,23 +180,23 @@ class ControlPointGroundObjectGenerator: except NotImplementedError: logging.error("Template Generator not implemented yet") except IndexError: - logging.error(f"No templates to generate object") + logging.error(f"No templates to generate object from {unit_group.name}") 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 - - templates = list( - self.faction.templates.for_category(TemplateCategory.Naval, "ship") - ) - for position in self.control_point.preset_locations.ships: - self.generate_random_from_templates(templates, position) + unit_group = self.faction.random_group_for_role_and_task( + GroupRole.Naval, GroupTask.Navy + ) + if not unit_group: + logging.error(f"{self.faction_name} has no UnitGroup for Navy") + return + self.generate_ground_object_from_group(unit_group, position) class NoOpGroundObjectGenerator(ControlPointGroundObjectGenerator): @@ -213,12 +217,14 @@ class CarrierGroundObjectGenerator(ControlPointGroundObjectGenerator): ) return False - templates = list( - self.faction.templates.for_category(TemplateCategory.Naval, "carrier") + unit_group = self.faction.random_group_for_role_and_task( + GroupRole.Naval, GroupTask.AircraftCarrier ) - - self.generate_random_from_templates( - templates, + if not unit_group: + logging.error(f"{self.faction_name} has no UnitGroup for AircraftCarrier") + return False + self.generate_ground_object_from_group( + unit_group, PointWithHeading.from_point( self.control_point.position, self.control_point.heading ), @@ -240,12 +246,14 @@ class LhaGroundObjectGenerator(ControlPointGroundObjectGenerator): ) return False - templates = list( - self.faction.templates.for_category(TemplateCategory.Naval, "lha") + unit_group = self.faction.random_group_for_role_and_task( + GroupRole.Naval, GroupTask.HelicopterCarrier ) - - self.generate_random_from_templates( - templates, + if not unit_group: + logging.error(f"{self.faction_name} has no UnitGroup for HelicopterCarrier") + return False + self.generate_ground_object_from_group( + unit_group, PointWithHeading.from_point( self.control_point.position, self.control_point.heading ), @@ -280,110 +288,83 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.generate_offshore_strike_targets() self.generate_factories() self.generate_ammunition_depots() - - if self.faction.missiles: - self.generate_missile_sites() - - if self.faction.coastal_defenses: - self.generate_coastal_sites() + self.generate_missile_sites() + self.generate_coastal_sites() def generate_armor_groups(self) -> None: - 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 - for position in self.control_point.preset_locations.armor_groups: - self.generate_random_from_templates(templates, position) + unit_group = self.faction.random_group_for_role_and_tasks( + GroupRole.GroundForce, ROLE_TASKINGS[GroupRole.GroundForce] + ) + if not unit_group: + logging.error(f"{self.faction_name} has no templates for Armor Groups") + return + self.generate_ground_object_from_group(unit_group, position) def generate_aa(self) -> None: presets = self.control_point.preset_locations - for position in presets.long_range_sams: - self.generate_aa_at( - position, - [ - AirDefenseRange.Long, - AirDefenseRange.Medium, - AirDefenseRange.Short, - AirDefenseRange.AAA, - ], - ) - for position in presets.medium_range_sams: - self.generate_aa_at( - position, - [ - AirDefenseRange.Medium, - AirDefenseRange.Short, - AirDefenseRange.AAA, - ], - ) - for position in presets.short_range_sams: - self.generate_aa_at( - position, - [AirDefenseRange.Short, AirDefenseRange.AAA], - ) + aa_tasking = [GroupTask.AAA] for position in presets.aaa: - self.generate_aa_at( - position, - [AirDefenseRange.AAA], - ) + self.generate_aa_at(position, aa_tasking) + aa_tasking.insert(0, GroupTask.SHORAD) + for position in presets.short_range_sams: + self.generate_aa_at(position, aa_tasking) + aa_tasking.insert(0, GroupTask.MERAD) + for position in presets.medium_range_sams: + self.generate_aa_at(position, aa_tasking) + aa_tasking.insert(0, GroupTask.LORAD) + for position in presets.long_range_sams: + self.generate_aa_at(position, aa_tasking) def generate_ewrs(self) -> None: - 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 - for position in self.control_point.preset_locations.ewrs: - self.generate_random_from_templates(templates, position) + unit_group = self.faction.random_group_for_role_and_task( + GroupRole.AntiAir, GroupTask.EWR + ) + if not unit_group: + logging.error(f"{self.faction_name} has no UnitGroup for EWR") + return + self.generate_ground_object_from_group(unit_group, position) def generate_building_at( self, - template_category: TemplateCategory, - building_category: str, + group_task: GroupTask, position: PointWithHeading, ) -> None: - templates = list( - self.faction.templates.for_category(template_category, building_category) + unit_group = self.faction.random_group_for_role_and_task( + GroupRole.Building, group_task ) - if templates: - self.generate_random_from_templates(templates, position) - else: + if not unit_group: logging.error( - f"{self.faction_name} has no access to Building type {building_category}" + f"{self.faction_name} has no access to Building ({group_task.value})" ) + return + self.generate_ground_object_from_group(unit_group, position) def generate_ammunition_depots(self) -> None: for position in self.control_point.preset_locations.ammunition_depots: - self.generate_building_at(TemplateCategory.Building, "ammo", position) + self.generate_building_at(GroupTask.Ammo, position) def generate_factories(self) -> None: for position in self.control_point.preset_locations.factories: - self.generate_building_at(TemplateCategory.Building, "factory", position) + self.generate_building_at(GroupTask.Factory, position) def generate_aa_at( - self, position: PointWithHeading, ranges: list[AirDefenseRange] + self, position: PointWithHeading, tasks: list[GroupTask] ) -> None: - - 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: + for task in tasks: + unit_group = self.faction.random_group_for_role_and_task( + GroupRole.AntiAir, task + ) + if unit_group: # Only take next (smaller) aa_range when no template available for the # most requested range. Otherwise break the loop and continue - break + self.generate_ground_object_from_group(unit_group, position) + return - if templates: - self.generate_random_from_templates(templates, position) - else: - logging.error( - f"{self.faction_name} has no access to SAM Templates ({', '.join([range.name for range in ranges])})" - ) + logging.error( + f"{self.faction_name} has no access to SAM Templates ({', '.join([task.value for task in tasks])})" + ) def generate_scenery_sites(self) -> None: presets = self.control_point.preset_locations @@ -423,38 +404,32 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): 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_random_from_templates(templates, position) + unit_group = self.faction.random_group_for_role_and_task( + GroupRole.Defenses, GroupTask.Missile + ) + if not unit_group: + logging.error(f"{self.faction_name} has no UnitGroup for Missile") + return + self.generate_ground_object_from_group(unit_group, 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_random_from_templates(templates, position) + unit_group = self.faction.random_group_for_role_and_task( + GroupRole.Defenses, GroupTask.Coastal + ) + if not unit_group: + logging.error(f"{self.faction_name} has no UnitGroup for Coastal") + return + self.generate_ground_object_from_group(unit_group, position) def generate_strike_targets(self) -> None: - building_set = list(set(self.faction.building_set) - {"oil"}) - if not building_set: - 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_building_at(TemplateCategory.Building, category, position) + self.generate_building_at(GroupTask.StrikeTarget, position) def generate_offshore_strike_targets(self) -> None: - if "oil" not in self.faction.building_set: - 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_building_at(TemplateCategory.Building, "oil", position) + self.generate_building_at(GroupTask.Oil, position) class FobGroundObjectGenerator(AirbaseGroundObjectGenerator): @@ -466,8 +441,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator): def generate_fob(self) -> None: self.generate_building_at( - TemplateCategory.Building, - "fob", + GroupTask.FOB, PointWithHeading.from_point( self.control_point.position, self.control_point.heading ), diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index 81d5794b..3bd48901 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from enum import Enum from typing import Iterator, List, TYPE_CHECKING, Union, Optional, Any +from dcs.unittype import VehicleType, ShipType from dcs.vehicles import vehicle_map from dcs.ships import ship_map @@ -17,6 +18,7 @@ from dcs.triggers import TriggerZone 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.shipunittype import ShipUnitType from ..dcs.unittype import UnitType from ..point_with_heading import PointWithHeading from ..utils import Distance, Heading, meters @@ -90,11 +92,13 @@ class GroundUnit: _unit_type: Optional[UnitType[Any]] = None @staticmethod - def from_template(id: int, t: UnitTemplate, go: TheaterGroundObject) -> GroundUnit: + def from_template( + id: int, unit_type: str, t: UnitTemplate, go: TheaterGroundObject + ) -> GroundUnit: return GroundUnit( id, t.name, - t.type, + unit_type, PointWithHeading.from_point(t.position, Heading.from_degrees(t.heading)), go, ) @@ -103,20 +107,16 @@ class GroundUnit: def unit_type(self) -> Optional[UnitType[Any]]: if not self._unit_type: try: - if self.type in vehicle_map: - 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 + unit_type: Optional[UnitType[Any]] = None + dcs_type = db.unit_type_from_name(self.type) + if dcs_type and issubclass(dcs_type, VehicleType): + unit_type = next(GroundUnitType.for_dcs_type(dcs_type)) + elif dcs_type and issubclass(dcs_type, ShipType): + unit_type = next(ShipUnitType.for_dcs_type(dcs_type)) + self._unit_type = unit_type except StopIteration: - return None + logging.error(f"No UnitType for {self.type}") + pass return self._unit_type def kill(self) -> None: @@ -153,36 +153,15 @@ class GroundGroup: id: int, g: GroupTemplate, go: TheaterGroundObject, - randomization: bool = True, ) -> GroundGroup: - - units = [] - if g.randomizer: - g.randomizer.randomize() - - for u_id, unit in enumerate(g.units): - tgo_unit = GroundUnit.from_template(u_id, unit, go) - if randomization and g.randomizer: - if g.randomizer.unit_type: - tgo_unit.type = g.randomizer.unit_type - try: - # Check if unit can be assigned - g.randomizer.use_unit() - except IndexError: - # Do not generate the unit as no more units are available - continue - units.append(tgo_unit) - tgo_group = GroundGroup( id, g.name, PointWithHeading.from_point(go.position, go.heading), - units, + g.generate_units(go), go, ) - tgo_group.static_group = g.static - return tgo_group @property diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 43917615..ebfa212d 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -1305,7 +1305,7 @@ class FlightPlanBuilder: for group in location.groups: if group.units: targets.append( - StrikeTarget(f"{group.name} at {location.name}", group) + StrikeTarget(f"{group.group_name} at {location.name}", group) ) elif isinstance(location, Convoy): targets.append(StrikeTarget(location.name, location)) @@ -1318,7 +1318,7 @@ class FlightPlanBuilder: @staticmethod def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> List[StrikeTarget]: - return [StrikeTarget(f"{g.name} at {tgo.name}", g) for g in tgo.groups] + return [StrikeTarget(f"{g.group_name} at {tgo.name}", g) for g in tgo.groups] def generate_anti_ship(self, flight: Flight) -> StrikeFlightPlan: """Generates an anti-ship flight plan. diff --git a/gen/ground_forces/ai_ground_planner.py b/gen/ground_forces/ai_ground_planner.py index 45d98c01..aa422b37 100644 --- a/gen/ground_forces/ai_ground_planner.py +++ b/gen/ground_forces/ai_ground_planner.py @@ -5,7 +5,7 @@ import random from enum import Enum from typing import Dict, List, TYPE_CHECKING -from game.data.groundunitclass import GroundUnitClass +from game.data.units import UnitClass from game.dcs.groundunittype import GroundUnitType from game.theater import ControlPoint from gen.ground_forces.combat_stance import CombatStance @@ -100,28 +100,28 @@ class GroundPlanner: # Create combat groups and assign them randomly to each enemy CP for unit_type in self.cp.base.armor: unit_class = unit_type.unit_class - if unit_class is GroundUnitClass.Tank: + if unit_class is UnitClass.Tank: collection = self.tank_groups role = CombatGroupRole.TANK - elif unit_class is GroundUnitClass.Apc: + elif unit_class is UnitClass.Apc: collection = self.apc_group role = CombatGroupRole.APC - elif unit_class is GroundUnitClass.Artillery: + elif unit_class is UnitClass.Artillery: collection = self.art_group role = CombatGroupRole.ARTILLERY - elif unit_class is GroundUnitClass.Ifv: + elif unit_class is UnitClass.Ifv: collection = self.ifv_group role = CombatGroupRole.IFV - elif unit_class is GroundUnitClass.Logistics: + elif unit_class is UnitClass.Logistics: collection = self.logi_groups role = CombatGroupRole.LOGI - elif unit_class is GroundUnitClass.Atgm: + elif unit_class is UnitClass.Atgm: collection = self.atgm_group role = CombatGroupRole.ATGM - elif unit_class is GroundUnitClass.Shorads: + elif unit_class is UnitClass.SHORAD: collection = self.shorad_groups role = CombatGroupRole.SHORAD - elif unit_class is GroundUnitClass.Recon: + elif unit_class is UnitClass.Recon: collection = self.recon_groups role = CombatGroupRole.RECON else: diff --git a/gen/templates.py b/gen/templates.py index 522c40f6..d42357e4 100644 --- a/gen/templates.py +++ b/gen/templates.py @@ -1,22 +1,25 @@ from __future__ import annotations -import copy -import json +import itertools import logging import random -from abc import ABC, abstractmethod from dataclasses import dataclass, field -from enum import Enum +from pathlib import Path from typing import Iterator, Any, TYPE_CHECKING, Optional, Union +import dcs +import yaml 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 dcs.unitgroup import StaticGroup +from dcs.unittype import VehicleType, ShipType from game.data.radar_db import UNITS_WITH_RADAR +from game.data.units import UnitClass +from game.data.groups import GroupRole, GroupTask, ROLE_TASKINGS from game.dcs.groundunittype import GroundUnitType +from game.dcs.shipunittype import ShipUnitType +from game.dcs.unittype import UnitType from game.theater.theatergroundobject import ( SamGroundObject, EwrGroundObject, @@ -29,6 +32,7 @@ from game.theater.theatergroundobject import ( CoastalSiteGroundObject, VehicleGroupGroundObject, IadsGroundObject, + GroundUnit, ) from game.point_with_heading import PointWithHeading @@ -41,27 +45,9 @@ if TYPE_CHECKING: 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 @@ -69,20 +55,10 @@ class UnitTemplate: 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: @@ -92,183 +68,90 @@ class GroupTemplate: # Is Static group static: bool = False - # Every group can only have one Randomizer - randomizer: Optional[TemplateRandomizer] = None + # The group this template will be merged into + group: int = 1 + + # 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 + unit_count: list[int] = field(default_factory=list) + + # defintion which unit types are supported + unit_types: list[str] = field(default_factory=list) + unit_classes: list[UnitClass] = field(default_factory=list) + alternative_classes: list[UnitClass] = field(default_factory=list) # 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 + # Used to determine if the group should be generated or not _possible_types: list[str] = field(default_factory=list) - _random_unit_type: Optional[str] = None - _forced_unit_type: Optional[str] = None + _enabled: bool = True + _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 + def should_be_generated(self) -> bool: + if self.optional: + return self._enabled return True + def enable(self) -> None: + self._enabled = True + + def disable(self) -> None: + self._enabled = False + + def set_enabled(self, enabled: bool) -> None: + self._enabled = enabled + + def is_enabled(self) -> bool: + return self._enabled + + def set_unit_type(self, unit_type: str) -> None: + self._unit_type = unit_type + + def set_possible_types(self, unit_types: list[str]) -> None: + self._possible_types = unit_types + + def can_use_unit(self, unit_type: UnitType[Any]) -> bool: + return ( + self.can_use_unit_type(unit_type.dcs_id) + or unit_type.unit_class in self.unit_classes + ) + + def can_use_unit_type(self, unit_type: str) -> bool: + return unit_type in self.unit_types + + def reset_unit_counter(self) -> None: + count = len(self.units) + if self.unit_count: + if len(self.unit_count) == 1: + count = self.unit_count[0] + else: + count = random.choice(range(min(self.unit_count), max(self.unit_count))) + self._unit_counter = count + @property - def unit_count(self) -> int: + def unit_type(self) -> str: + if self._unit_type: + # Forced type + return self._unit_type + unit_types = self._possible_types or self.unit_types + if unit_types: + # Random type + return random.choice(unit_types) + raise RuntimeError("TemplateGroup has no unit_type") + + @property + def size(self) -> int: if not self._unit_counter: self.reset_unit_counter() - return self._unit_counter or 1 + assert self._unit_counter is not None + return self._unit_counter + + @property + def max_size(self) -> int: + return len(self.units) def use_unit(self) -> None: if self._unit_counter is None: @@ -279,29 +162,56 @@ class TemplateRandomizer: 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 + def can_be_modified(self) -> bool: + return len(self._possible_types) > 1 or len(self.unit_count) > 1 + + @property + def statics(self) -> Iterator[str]: + for unit_type in self._possible_types or self.unit_types: + if db.static_type_from_name(unit_type): + yield unit_type + + @property + def possible_units(self) -> Iterator[UnitType[Any]]: + for unit_type in self._possible_types or self.unit_types: + dcs_unit_type = db.unit_type_from_name(unit_type) + if dcs_unit_type is None: + raise RuntimeError(f"Unit Type {unit_type} not a valid dcs type") + try: + if issubclass(dcs_unit_type, VehicleType): + yield next(GroundUnitType.for_dcs_type(dcs_unit_type)) + elif issubclass(dcs_unit_type, ShipType): + yield next(ShipUnitType.for_dcs_type(dcs_unit_type)) + except StopIteration: + continue + + def generate_units(self, go: TheaterGroundObject) -> list[GroundUnit]: + self.reset_unit_counter() + units = [] + unit_type = self.unit_type + for u_id, unit in enumerate(self.units): + tgo_unit = GroundUnit.from_template(u_id, unit_type, unit, go) + try: + # Check if unit can be assigned + self.use_unit() + except IndexError: + # Do not generate the unit as no more units are available + continue + units.append(tgo_unit) + return units -class GroundObjectTemplate(ABC): - def __init__( - self, name: str, template_type: str = "", description: str = "" - ) -> None: +class GroundObjectTemplate: + def __init__(self, name: str, description: str = "") -> None: self.name = name - self.template_type = template_type self.description = description + self.tasks: list[GroupTask] = [] # The supported tasks self.groups: list[GroupTemplate] = [] + self.category: str = "" # Only used for building templates - @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 + # If the template is generic it will be used the generate the general + # UnitGroups during faction initialization. Generic Groups allow to be mixed + self.generic: bool = False def generate( self, @@ -309,27 +219,39 @@ class GroundObjectTemplate(ABC): position: PointWithHeading, control_point: ControlPoint, game: Game, - randomization: bool = True, + merge_groups: 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}" + if not group.should_be_generated: + continue + # Static and non Static groups have to be separated + unit_count = 0 + group_id = (group.group - 1) if merge_groups else g_id + if not merge_groups or len(ground_object.groups) <= group_id: + # Requested group was not yet created + ground_group = GroundGroup.from_template( + game.next_group_id(), + group, + ground_object, + ) + # Set Group Name + ground_group.name = f"{self.name} {group_id}" + ground_object.groups.append(ground_group) + units = ground_group.units + else: + ground_group = ground_object.groups[group_id] + units = group.generate_units(ground_object) + unit_count = len(ground_group.units) + ground_group.units.extend(units) # Assign UniqueID, name and align relative to ground_object - for u_id, unit in enumerate(tgo_group.units): + for u_id, unit in enumerate(units): unit.id = game.next_unit_id() - unit.name = f"{self.name} {g_id}-{u_id}" + unit.name = f"{self.name} {group_id}-{unit_count + u_id}" unit.position = PointWithHeading.from_point( Point( ground_object.position.x + unit.position.x, @@ -339,7 +261,7 @@ class GroundObjectTemplate(ABC): unit.position.heading + ground_object.heading, ) if ( - isinstance(self, AirDefenceTemplate) + isinstance(self, AntiAirTemplate) and unit.unit_type and unit.unit_type.dcs_unit_type in UNITS_WITH_RADAR ): @@ -350,11 +272,9 @@ class GroundObjectTemplate(ABC): ) # Rotate unit around the center to align the orientation of the group unit.position.rotate(ground_object.position, ground_object.heading) - ground_object.groups.append(tgo_group) return ground_object - @abstractmethod def _create_ground_object( self, name: str, @@ -363,78 +283,52 @@ class GroundObjectTemplate(ABC): ) -> 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 add_group(self, new_group: GroupTemplate, index: int = 0) -> None: + """Adds a group in the correct order to the template""" + if len(self.groups) > index: + self.groups.insert(index, new_group) + else: + self.groups.append(new_group) 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 + price = 0 + for group in self.groups: + if group.should_be_generated: + for unit in group.generate_units(go): + if unit.unit_type: + price += unit.unit_type.price + return 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) + def statics(self) -> Iterator[str]: + for group in self.groups: + yield from group.statics @property - def max_size(self) -> int: - return self._size_for_randomized(False) - - def _size_for_randomized(self, min_size: bool) -> int: - size = 0 + def units(self) -> Iterator[UnitType[Any]]: 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 + yield from group.possible_units -class AirDefenceTemplate(GroundObjectTemplate): +class AntiAirTemplate(GroundObjectTemplate): def _create_ground_object( self, name: str, position: PointWithHeading, control_point: ControlPoint, ) -> IadsGroundObject: - if self.template_type == "EWR": + + if GroupTask.EWR in self.tasks: return EwrGroundObject(name, position, position.heading, control_point) - elif self.template_type in ["Long", "Medium", "Short", "AAA"]: + elif any(tasking in self.tasks for tasking in ROLE_TASKINGS[GroupRole.AntiAir]): return SamGroundObject(name, position, position.heading, control_point) raise RuntimeError( - f" No Template Definition for AirDefence with subcategory {self.template_type}" + f" No Template for AntiAir tasking ({', '.join(task.value for task in self.tasks)})" ) @@ -447,11 +341,11 @@ class BuildingTemplate(GroundObjectTemplate): ) -> BuildingGroundObject: return BuildingGroundObject( name, - self.template_type, + self.category, position, Heading.from_degrees(0), control_point, - self.template_type == "fob", + self.category == "fob", ) @@ -462,26 +356,34 @@ class NavalTemplate(GroundObjectTemplate): position: PointWithHeading, control_point: ControlPoint, ) -> TheaterGroundObject: - if self.template_type == "ship": + if GroupTask.Navy in self.tasks: return ShipGroundObject(name, position, control_point) - elif self.template_type == "carrier": + elif GroupTask.AircraftCarrier in self.tasks: return CarrierGroundObject(name, control_point) - elif self.template_type == "lha": + elif GroupTask.HelicopterCarrier in self.tasks: return LhaGroundObject(name, control_point) raise NotImplementedError -class CoastalTemplate(GroundObjectTemplate): +class DefensesTemplate(GroundObjectTemplate): def _create_ground_object( self, name: str, position: PointWithHeading, control_point: ControlPoint, ) -> TheaterGroundObject: - return CoastalSiteGroundObject(name, position, control_point, position.heading) + if GroupTask.Missile in self.tasks: + return MissileSiteGroundObject( + name, position, position.heading, control_point + ) + elif GroupTask.Coastal in self.tasks: + return CoastalSiteGroundObject( + name, position, control_point, position.heading + ) + raise NotImplementedError -class ArmorTemplate(GroundObjectTemplate): +class GroundForceTemplate(GroundObjectTemplate): def _create_ground_object( self, name: str, @@ -491,58 +393,291 @@ class ArmorTemplate(GroundObjectTemplate): return VehicleGroupGroundObject(name, position, position.heading, control_point) -class MissileTemplate(GroundObjectTemplate): - def _create_ground_object( - self, - name: str, - position: PointWithHeading, - control_point: ControlPoint, - ) -> TheaterGroundObject: - return MissileSiteGroundObject(name, position, position.heading, control_point) - - TEMPLATE_TYPES = { - TemplateCategory.AirDefence: AirDefenceTemplate, - TemplateCategory.Building: BuildingTemplate, - TemplateCategory.Naval: NavalTemplate, - TemplateCategory.Armor: ArmorTemplate, - TemplateCategory.Missile: MissileTemplate, - TemplateCategory.Coastal: CoastalTemplate, + GroupRole.AntiAir: AntiAirTemplate, + GroupRole.Building: BuildingTemplate, + GroupRole.Naval: NavalTemplate, + GroupRole.GroundForce: GroundForceTemplate, + GroupRole.Defenses: DefensesTemplate, } +@dataclass +class GroupTemplateMapping: + # The group name used in the template.miz + name: str + + # Defines if the group is required for the template or can be skipped + optional: bool = False + + # All static units for the group + statics: list[str] = field(default_factory=list) + + # Defines to which tgo group the groupTemplate will be added + # This allows to merge groups back together. Default: Merge all to group 1 + group: int = field(default=1) + + # Randomization settings. If left empty the template will be generated with the + # exact values (amount of units and unit_type) defined in the template.miz + # How many units the template should generate. Will be used for randomization + unit_count: list[int] = field(default_factory=list) + # All unit types the template supports. + unit_types: list[str] = field(default_factory=list) + # All unit classes the template supports. + unit_classes: list[UnitClass] = field(default_factory=list) + alternative_classes: list[UnitClass] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + d = self.__dict__ + if not self.optional: + d.pop("optional") + if not self.statics: + d.pop("statics") + if not self.unit_types: + d.pop("unit_types") + if not self.unit_classes: + d.pop("unit_classes") + else: + d["unit_classes"] = [unit_class.value for unit_class in self.unit_classes] + if not self.alternative_classes: + d.pop("alternative_classes") + else: + d["alternative_classes"] = [ + unit_class.value for unit_class in self.alternative_classes + ] + if not self.unit_count: + d.pop("unit_count") + return d + + @staticmethod + def from_dict(d: dict[str, Any]) -> GroupTemplateMapping: + optional = d["optional"] if "optional" in d else False + statics = d["statics"] if "statics" in d else [] + unit_count = d["unit_count"] if "unit_count" in d else [] + unit_types = d["unit_types"] if "unit_types" in d else [] + group = d["group"] if "group" in d else 1 + unit_classes = ( + [UnitClass(u) for u in d["unit_classes"]] if "unit_classes" in d else [] + ) + alternative_classes = ( + [UnitClass(u) for u in d["alternative_classes"]] + if "alternative_classes" in d + else [] + ) + return GroupTemplateMapping( + d["name"], + optional, + statics, + group, + unit_count, + unit_types, + unit_classes, + alternative_classes, + ) + + +@dataclass +class TemplateMapping: + # The name of the Template + name: str + + # An optional description to give more information about the template + description: str + + # An optional description to give more information about the template + category: str + + # Optional field to define if the template can be used to create generic groups + generic: bool + + # The role the template can be used for + role: GroupRole + + # All taskings the template can be used for + tasks: list[GroupTask] + + # All Groups the template has + groups: list[GroupTemplateMapping] = field(default_factory=list) + + # Define the miz file for the template. Optional. If empty use the mapping name + template_file: str = field(default="") + + def to_dict(self) -> dict[str, Any]: + d = { + "name": self.name, + "description": self.description, + "category": self.category, + "generic": self.generic, + "role": self.role.value, + "tasks": [task.value for task in self.tasks], + "groups": [group.to_dict() for group in self.groups], + "template_file": self.template_file, + } + if not self.description: + d.pop("description") + if not self.category: + d.pop("category") + if not self.generic: + # Only save if true + d.pop("generic") + if not self.template_file: + d.pop("template_file") + return d + + @staticmethod + def from_dict(d: dict[str, Any], file_name: str) -> TemplateMapping: + groups = [GroupTemplateMapping.from_dict(group) for group in d["groups"]] + description = d["description"] if "description" in d else "" + category = d["category"] if "category" in d else "" + generic = d["generic"] if "generic" in d else False + template_file = ( + d["template_file"] + if "template_file" in d + else file_name.replace("yaml", "miz") + ) + tasks = [GroupTask(task) for task in d["tasks"]] + return TemplateMapping( + d["name"], + description, + category, + generic, + GroupRole(d["role"]), + tasks, + groups, + template_file, + ) + + def export(self, mapping_folder: str) -> None: + file_name = self.name + for char in ["\\", "/", " ", "'", '"']: + file_name = file_name.replace(char, "_") + + f = mapping_folder + file_name + ".yaml" + with open(f, "w", encoding="utf-8") as data_file: + yaml.dump(self.to_dict(), data_file, Dumper=MappingDumper, sort_keys=False) + + +# Custom Dumper to fix pyyaml indent https://github.com/yaml/pyyaml/issues/234 +class MappingDumper(yaml.Dumper): + def increase_indent(self, flow: bool = False, *args: Any, **kwargs: Any) -> None: + return super().increase_indent(flow=flow, indentless=False) + + class GroundObjectTemplates: # list of templates per category. e.g. AA or similar - _templates: dict[TemplateCategory, list[GroundObjectTemplate]] + _templates: dict[GroupRole, list[GroundObjectTemplate]] def __init__(self) -> None: self._templates = {} @property - def templates(self) -> Iterator[tuple[TemplateCategory, GroundObjectTemplate]]: + def templates(self) -> Iterator[tuple[GroupRole, 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 + def from_folder(cls, folder: str) -> GroundObjectTemplates: + templates = GroundObjectTemplates() + mappings: dict[str, list[TemplateMapping]] = {} + for file in Path(folder).rglob("*.yaml"): + if not file.is_file(): + continue + with file.open("r", encoding="utf-8") as f: + mapping_dict = yaml.safe_load(f) - 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 + template_map = TemplateMapping.from_dict(mapping_dict, f.name) - def to_json(self) -> dict[str, Any]: - return { - category.value: templates for category, templates in self._templates.items() - } + if template_map.template_file in mappings: + mappings[template_map.template_file].append(template_map) + else: + mappings[template_map.template_file] = [template_map] + + for miz, maps in mappings.items(): + for role, template in cls.load_from_miz(miz, maps).templates: + templates.add_template(role, template) + return templates + + @staticmethod + def mapping_for_group( + mappings: list[TemplateMapping], group_name: str + ) -> tuple[TemplateMapping, int, GroupTemplateMapping]: + for mapping in mappings: + for g_id, group_mapping in enumerate(mapping.groups): + if ( + group_mapping.name == group_name + or group_name in group_mapping.statics + ): + return mapping, g_id, group_mapping + raise KeyError + + @classmethod + def load_from_miz( + cls, miz: str, mappings: list[TemplateMapping] + ) -> GroundObjectTemplates: + template_position: dict[str, Point] = {} + templates = GroundObjectTemplates() + temp_mis = dcs.Mission() + temp_mis.load_file(miz) + + for country in itertools.chain( + temp_mis.coalition["red"].countries.values(), + temp_mis.coalition["blue"].countries.values(), + ): + for dcs_group in itertools.chain( + temp_mis.country(country.name).vehicle_group, + temp_mis.country(country.name).ship_group, + temp_mis.country(country.name).static_group, + ): + try: + mapping, group_id, group_mapping = cls.mapping_for_group( + mappings, dcs_group.name + ) + except KeyError: + logging.error(f"No mapping for dcs group {dcs_group.name}") + continue + template = templates.by_name(mapping.name) + if not template: + template = TEMPLATE_TYPES[mapping.role]( + mapping.name, mapping.description + ) + template.category = mapping.category + template.generic = mapping.generic + template.tasks = mapping.tasks + templates.add_template(mapping.role, template) + + for i, unit in enumerate(dcs_group.units): + group_template = None + for group in template.groups: + if group.name == dcs_group.name or ( + isinstance(dcs_group, StaticGroup) + and dcs_group.units[0].type in group.unit_types + ): + # MovingGroups are matched by name, statics by unit_type + group_template = group + if not group_template: + group_template = GroupTemplate( + dcs_group.name, + [], + True if isinstance(dcs_group, StaticGroup) else False, + group_mapping.group, + group_mapping.unit_count, + group_mapping.unit_types, + group_mapping.unit_classes, + group_mapping.alternative_classes, + ) + group_template.optional = group_mapping.optional + # Add the group at the correct position + template.add_group(group_template, group_id) + 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) + + return templates @property def all(self) -> Iterator[GroundObjectTemplate]: @@ -555,30 +690,27 @@ class GroundObjectTemplates: 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] + def add_template(self, role: GroupRole, template: GroundObjectTemplate) -> None: + if role not in self._templates: + self._templates[role] = [template] else: - self._templates[category].append(template) + self._templates[role].append(template) - def for_category( - self, category: TemplateCategory, sub_category: Optional[str] = None + def for_role_and_task( + self, group_role: GroupRole, group_task: Optional[GroupTask] = None ) -> Iterator[GroundObjectTemplate]: - - if category not in self._templates: + if group_role not in self._templates: return None - - for template in self._templates[category]: - if not sub_category or template.template_type == sub_category: + for template in self._templates[group_role]: + if not group_task or group_task in template.tasks: yield template + + def for_role_and_tasks( + self, group_role: GroupRole, group_tasks: list[GroupTask] + ) -> Iterator[GroundObjectTemplate]: + unique_templates = [] + for group_task in group_tasks: + for template in self.for_role_and_task(group_role, group_task): + if template not in unique_templates: + unique_templates.append(template) + yield from unique_templates diff --git a/qt_ui/windows/groundobject/QGroundObjectBuyMenu.py b/qt_ui/windows/groundobject/QGroundObjectBuyMenu.py index d1116b87..a7c78756 100644 --- a/qt_ui/windows/groundobject/QGroundObjectBuyMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectBuyMenu.py @@ -17,6 +17,7 @@ from PySide2.QtWidgets import ( ) from game import Game +from game.data.groups import GroupRole, ROLE_TASKINGS, GroupTask from game.point_with_heading import PointWithHeading from game.theater import TheaterGroundObject from game.theater.theatergroundobject import ( @@ -26,7 +27,6 @@ from game.theater.theatergroundobject import ( GroundGroup, ) from gen.templates import ( - TemplateCategory, GroundObjectTemplate, GroupTemplate, ) @@ -41,7 +41,9 @@ class QGroundObjectGroupTemplate(QGroupBox): # 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)) + super(QGroundObjectGroupTemplate, self).__init__( + f"{group_id + 1}: {group_template.name}" + ) self.group_template = group_template self.group_layout = QGridLayout() @@ -51,12 +53,12 @@ class QGroundObjectGroupTemplate(QGroupBox): self.unit_selector = QComboBox() self.group_selector = QCheckBox() - self.group_selector.setChecked(True) + self.group_selector.setChecked(self.group_template.should_be_generated) 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: + if self.group_template.can_be_modified: + # Group can be modified (more than 1 possible unit_type for the group) + for unit in self.group_template.possible_units: self.unit_selector.addItem(f"{unit} [${unit.price}M]", userData=unit) self.group_layout.addWidget( self.unit_selector, 0, 0, alignment=Qt.AlignRight @@ -66,17 +68,21 @@ class QGroundObjectGroupTemplate(QGroupBox): ) 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.amount_selector.setMaximum(self.group_template.max_size) + self.amount_selector.setValue(self.group_template.size) 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 - ) + try: + unit_name = next(self.group_template.possible_units) + except StopIteration: + unit_name = self.group_template.unit_type + group_info.addWidget( + QLabel(f"{self.group_template.size}x {unit_name}"), + 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) @@ -86,10 +92,11 @@ class QGroundObjectGroupTemplate(QGroupBox): 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.set_enabled(self.group_selector.isChecked()) + if self.group_template.can_be_modified: + unit_type = self.unit_selector.itemData(self.unit_selector.currentIndex()) + self.group_template.unit_count = [self.amount_selector.value()] + self.group_template.set_unit_type(unit_type.dcs_id) self.group_template_changed.emit(self.group_template) @@ -143,8 +150,6 @@ class QGroundObjectTemplateLayout(QGroupBox): 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): @@ -216,30 +221,40 @@ class QGroundObjectBuyMenu(QDialog): self.mainLayout = QGridLayout() self.setLayout(self.mainLayout) + self.unit_group_selector = QComboBox() self.template_selector = QComboBox() - self.template_selector.currentIndexChanged.connect(self.template_changed) + self.template_selector.setEnabled(False) # Get the templates and fill the combobox template_sub_category = None + tasks = [] if isinstance(ground_object, SamGroundObject): - template_category = TemplateCategory.AirDefence + role = GroupRole.AntiAir elif isinstance(ground_object, VehicleGroupGroundObject): - template_category = TemplateCategory.Armor + role = GroupRole.GroundForce elif isinstance(ground_object, EwrGroundObject): - template_category = TemplateCategory.AirDefence - template_sub_category = "EWR" + role = GroupRole.AntiAir + tasks.append(GroupTask.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) + if not tasks: + tasks = ROLE_TASKINGS[role] + + for unit_group in game.blue.faction.groups_for_role_and_tasks(role, tasks): + self.unit_group_selector.addItem(unit_group.name, userData=unit_group) + + self.template_selector.currentIndexChanged.connect(self.template_changed) + self.unit_group_selector.currentIndexChanged.connect(self.unit_group_changed) template_selector_layout = QGridLayout() - template_selector_layout.addWidget(QLabel("Template :"), 0, 0, Qt.AlignLeft) + template_selector_layout.addWidget(QLabel("UnitGroup :"), 0, 0, Qt.AlignLeft) template_selector_layout.addWidget( - self.template_selector, 0, 1, alignment=Qt.AlignRight + self.unit_group_selector, 0, 1, alignment=Qt.AlignRight + ) + template_selector_layout.addWidget(QLabel("Template :"), 1, 0, Qt.AlignLeft) + template_selector_layout.addWidget( + self.template_selector, 1, 1, alignment=Qt.AlignRight ) self.mainLayout.addLayout(template_selector_layout, 0, 0) @@ -250,10 +265,22 @@ class QGroundObjectBuyMenu(QDialog): self.setLayout(self.mainLayout) # Update UI - self.template_changed() + self.unit_group_changed() + + def unit_group_changed(self) -> None: + unit_group = self.unit_group_selector.itemData( + self.unit_group_selector.currentIndex() + ) + self.template_selector.clear() + if unit_group.templates: + for template in unit_group.templates: + self.template_selector.addItem(template.name, userData=template) + # Enable if more than one template is available + self.template_selector.setEnabled(len(unit_group.templates) > 1) def template_changed(self): template = self.template_selector.itemData( self.template_selector.currentIndex() ) - self.template_changed_signal.emit(template) + if template is not None: + self.template_changed_signal.emit(template) diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py index 3553e3e8..73cbdf31 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -14,7 +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.data.units import UnitClass from game.dcs.groundunittype import GroundUnitType from game.theater import ControlPoint, TheaterGroundObject from game.theater.theatergroundobject import ( @@ -181,8 +181,11 @@ class QGroundObjectMenu(QDialog): return for u in self.ground_object.units: # Hack: Unknown variant. - unit_type = next(GroundUnitType.for_dcs_type(vehicles.vehicle_map[u.type])) - total_value += unit_type.price + if u.type in vehicles.vehicle_map: + unit_type = next( + GroundUnitType.for_dcs_type(vehicles.vehicle_map[u.type]) + ) + total_value += unit_type.price if self.sell_all_button is not None: self.sell_all_button.setText("Disband (+$" + str(self.total_value) + "M)") self.total_value = total_value diff --git a/tests/resources/valid_faction.json b/tests/resources/valid_faction.json index bec78164..620fa9df 100644 --- a/tests/resources/valid_faction.json +++ b/tests/resources/valid_faction.json @@ -52,18 +52,9 @@ "sams": [ "HawkGenerator" ], - "aircraft_carrier": [ - "Stennis" - ], - "helicopter_carrier": [ - "LHA_Tarawa" - ], - "destroyers": [ - "PERRY", - "USS_Arleigh_Burke_IIa" - ], - "cruisers": [ - "TICONDEROG" + "naval_units": [ + "LHA-1 Tarawa", + "CVN-74 John C. Stennis" ], "requirements": {"mod": "Some mod is required"}, "carrier_names": [ @@ -79,10 +70,6 @@ "LHA-4 Nassau", "LHA-5 Peleliu" ], - "navy_generators": [ - "OliverHazardPerryGroupGenerator", - "ArleighBurkeGroupGenerator" - ], "has_jtac": true, "jtac_unit": "MQ_9_Reaper" } diff --git a/tests/test_factions.py b/tests/test_factions.py index 8b598c02..bec4c278 100644 --- a/tests/test_factions.py +++ b/tests/test_factions.py @@ -95,15 +95,8 @@ class TestFactionLoader(unittest.TestCase): self.assertIn(Infantry.Soldier_M4, faction.infantry_units) self.assertIn(Infantry.Soldier_M249, faction.infantry_units) - self.assertIn("AvengerGenerator", faction.air_defenses) - - self.assertIn("HawkGenerator", faction.air_defenses) - - self.assertIn(Stennis, faction.aircraft_carrier) - self.assertIn(LHA_Tarawa, faction.helicopter_carrier) - self.assertIn(PERRY, faction.destroyers) - self.assertIn(USS_Arleigh_Burke_IIa, faction.destroyers) - self.assertIn(TICONDEROG, faction.cruisers) + self.assertIn(Stennis.name, faction.naval_units) + self.assertIn(LHA_Tarawa.name, faction.naval_units) self.assertIn("mod", faction.requirements.keys()) self.assertIn("Some mod is required", faction.requirements.values()) @@ -111,9 +104,6 @@ class TestFactionLoader(unittest.TestCase): self.assertEqual(4, len(faction.carrier_names)) self.assertEqual(5, len(faction.helicopter_carrier_names)) - self.assertIn("OliverHazardPerryGroupGenerator", faction.navy_generators) - self.assertIn("ArleighBurkeGroupGenerator", faction.navy_generators) - @pytest.mark.skip(reason="Faction unit names in the json files are outdated") def test_load_valid_faction_with_invalid_country(self) -> None: