From d154069877c43eb10b5931a28690c6de7eb0cb98 Mon Sep 17 00:00:00 2001 From: RndName Date: Tue, 18 Jan 2022 09:56:04 +0100 Subject: [PATCH] Refactor ground objects and prepare template system - completly refactored the way TGO handles groups and replaced the usage of the pydcs ground groups (vehicle, ship, static) with an own Group and Unit class. - this allows us to only take care of dcs group generation during miz generation, where it should have always been. - We can now have any type of unit (even statics) in the same logic ground group we handle in liberation. this is independent from the dcs group handling. the dcs group will only be genarted when takeoff is pressed. - Refactored the unitmap and the scenery object handling to adopt to changes that now TGOs can hold all Units we want. - Cleaned up many many many lines of uneeded hacks to build stuff around dcs groups. - Removed IDs for TGOs as the names we generate are unique and for liberation to work we need no ids. Unique IDs for dcs will be generated for the units and groups only. --- game/commander/tasks/primitive/strike.py | 2 +- game/commander/theaterstate.py | 2 +- game/data/alic.py | 5 +- game/dcs/helpers.py | 15 + game/debriefing.py | 71 +-- game/game.py | 1 + .../aircraft/waypoints/baiingress.py | 10 +- .../waypoints/pydcswaypointbuilder.py | 4 +- game/missiongenerator/kneeboard.py | 19 +- game/missiongenerator/tgogenerator.py | 508 ++++++++---------- game/sim/combat/defendingsam.py | 4 +- game/sim/combat/samengagementzones.py | 8 +- game/sim/missionresultsprocessor.py | 25 +- game/theater/conflicttheater.py | 26 +- game/theater/controlpoint.py | 21 +- game/theater/missiontarget.py | 3 +- game/theater/theatergroundobject.py | 327 +++++------ game/threatzones.py | 2 +- game/unitmap.py | 118 ++-- gen/flights/flightplan.py | 23 +- gen/flights/waypointbuilder.py | 9 +- .../QPredefinedWaypointSelectionComboBox.py | 4 +- .../combos/QStrikeTargetSelectionComboBox.py | 3 +- qt_ui/widgets/map/model/groundobjectjs.py | 40 +- qt_ui/windows/QDebriefingWindow.py | 3 +- .../windows/QWaitingForMissionResultWindow.py | 6 +- qt_ui/windows/groundobject/QBuildingInfo.py | 19 +- .../windows/groundobject/QGroundObjectMenu.py | 70 +-- 28 files changed, 603 insertions(+), 745 deletions(-) diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py index e5fcec20..8bb4a9c5 100644 --- a/game/commander/tasks/primitive/strike.py +++ b/game/commander/tasks/primitive/strike.py @@ -10,7 +10,7 @@ from game.ato.flighttype import FlightType @dataclass -class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]): +class PlanStrike(PackagePlanningTask[TheaterGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: if self.target not in state.strike_targets: return False diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index 0465fe59..1b868683 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -57,7 +57,7 @@ class TheaterState(WorldState["TheaterState"]): enemy_ships: list[NavalGroundObject] enemy_garrisons: dict[ControlPoint, Garrisons] oca_targets: list[ControlPoint] - strike_targets: list[TheaterGroundObject[Any]] + strike_targets: list[TheaterGroundObject] enemy_barcaps: list[ControlPoint] threat_zones: ThreatZones diff --git a/game/data/alic.py b/game/data/alic.py index f8bc5e43..bcdd5991 100644 --- a/game/data/alic.py +++ b/game/data/alic.py @@ -1,4 +1,3 @@ -from dcs.unit import Unit from dcs.vehicles import AirDefence @@ -38,5 +37,5 @@ class AlicCodes: } @classmethod - def code_for(cls, unit: Unit) -> int: - return cls.CODES[unit.type] + def code_for(cls, unit_type: str) -> int: + return cls.CODES[unit_type] diff --git a/game/dcs/helpers.py b/game/dcs/helpers.py index e2b7ef72..b8bcb751 100644 --- a/game/dcs/helpers.py +++ b/game/dcs/helpers.py @@ -5,6 +5,7 @@ from dcs.planes import plane_map from dcs.ships import ship_map from dcs.unittype import UnitType from dcs.vehicles import vehicle_map +from dcs.statics import fortification_map, groundobject_map, warehouse_map, cargo_map def unit_type_from_name(name: str) -> Optional[Type[UnitType]]: @@ -16,5 +17,19 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]: return ship_map[name] if name in helicopter_map: return helicopter_map[name] + else: + # Try statics + return static_type_from_name(name) + + +def static_type_from_name(name: str) -> Optional[Type[UnitType]]: + if name in fortification_map: + return fortification_map[name] + elif name in groundobject_map: + return groundobject_map[name] + elif name in warehouse_map: + return warehouse_map[name] + if name in cargo_map: + return cargo_map[name] else: return None diff --git a/game/debriefing.py b/game/debriefing.py index 0304951d..0029cfc9 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -23,12 +23,12 @@ if TYPE_CHECKING: from game.transfers import CargoShip from game.unitmap import ( AirliftUnits, - Building, ConvoyUnit, FlyingUnit, FrontLineUnit, - GroundObjectUnit, + GroundObjectMapping, UnitMap, + SceneryObjectMapping, ) DEBRIEFING_LOG_EXTENSION = "log" @@ -72,11 +72,11 @@ class GroundLosses: player_airlifts: List[AirliftUnits] = field(default_factory=list) enemy_airlifts: List[AirliftUnits] = field(default_factory=list) - player_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list) - enemy_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list) + player_ground_objects: List[GroundObjectMapping] = field(default_factory=list) + enemy_ground_objects: List[GroundObjectMapping] = field(default_factory=list) - player_buildings: List[Building] = field(default_factory=list) - enemy_buildings: List[Building] = field(default_factory=list) + player_scenery: List[SceneryObjectMapping] = field(default_factory=list) + enemy_scenery: List[SceneryObjectMapping] = field(default_factory=list) player_airfields: List[Airfield] = field(default_factory=list) enemy_airfields: List[Airfield] = field(default_factory=list) @@ -158,14 +158,14 @@ class Debriefing: yield from self.ground_losses.enemy_airlifts @property - def ground_object_losses(self) -> Iterator[GroundObjectUnit[Any]]: + def ground_object_losses(self) -> Iterator[GroundObjectMapping]: yield from self.ground_losses.player_ground_objects yield from self.ground_losses.enemy_ground_objects @property - def building_losses(self) -> Iterator[Building]: - yield from self.ground_losses.player_buildings - yield from self.ground_losses.enemy_buildings + def scenery_object_losses(self) -> Iterator[SceneryObjectMapping]: + yield from self.ground_losses.player_scenery + yield from self.ground_losses.enemy_scenery @property def damaged_runways(self) -> Iterator[Airfield]: @@ -217,17 +217,32 @@ class Debriefing: losses_by_type[unit_type] += 1 return losses_by_type - def building_losses_by_type(self, player: bool) -> Dict[str, int]: + def ground_object_losses_by_type(self, player: bool) -> Dict[str, int]: losses_by_type: Dict[str, int] = defaultdict(int) if player: - losses = self.ground_losses.player_buildings + losses = self.ground_losses.player_ground_objects else: - losses = self.ground_losses.enemy_buildings + losses = self.ground_losses.enemy_ground_objects for loss in losses: - if loss.ground_object.control_point.captured != player: - continue + # We do not have handling for ships and statics UniType yet so we have to + # take more care here. Fallback for ship and static is to use the type str + # which is the dcs_type.id + unit_type = ( + loss.ground_unit.unit_type.name + if loss.ground_unit.unit_type + else loss.ground_unit.type + ) + losses_by_type[unit_type] += 1 + return losses_by_type - losses_by_type[loss.ground_object.dcs_identifier] += 1 + def scenery_losses_by_type(self, player: bool) -> Dict[str, int]: + losses_by_type: Dict[str, int] = defaultdict(int) + if player: + losses = self.ground_losses.player_scenery + else: + losses = self.ground_losses.enemy_scenery + for loss in losses: + losses_by_type[loss.trigger_zone.name] += 1 return losses_by_type def dead_aircraft(self) -> AirLosses: @@ -271,25 +286,21 @@ class Debriefing: losses.enemy_cargo_ships.append(cargo_ship) continue - ground_object_unit = self.unit_map.ground_object_unit(unit_name) - if ground_object_unit is not None: - if ground_object_unit.ground_object.control_point.captured: - losses.player_ground_objects.append(ground_object_unit) + ground_object = self.unit_map.ground_object(unit_name) + if ground_object is not None: + if ground_object.ground_unit.ground_object.is_friendly(to_player=True): + losses.player_ground_objects.append(ground_object) else: - losses.enemy_ground_objects.append(ground_object_unit) + losses.enemy_ground_objects.append(ground_object) continue - building = self.unit_map.building_or_fortification(unit_name) + scenery_object = self.unit_map.scenery_object(unit_name) # Try appending object to the name, because we do this for building statics. - if building is None: - building = self.unit_map.building_or_fortification( - f"{unit_name} object" - ) - if building is not None: - if building.ground_object.control_point.captured: - losses.player_buildings.append(building) + if scenery_object is not None: + if scenery_object.ground_unit.ground_object.is_friendly(to_player=True): + losses.player_scenery.append(scenery_object) else: - losses.enemy_buildings.append(building) + losses.enemy_scenery.append(scenery_object) continue airfield = self.unit_map.airfield(unit_name) diff --git a/game/game.py b/game/game.py index 51305c20..ab4adf54 100644 --- a/game/game.py +++ b/game/game.py @@ -45,6 +45,7 @@ if TYPE_CHECKING: from .navmesh import NavMesh from .squadrons import AirWing from .threatzones import ThreatZones + from .factions.faction import Faction COMMISION_UNIT_VARIETY = 4 COMMISION_LIMITS_SCALE = 1.5 diff --git a/game/missiongenerator/aircraft/waypoints/baiingress.py b/game/missiongenerator/aircraft/waypoints/baiingress.py index b757b651..bd8f679d 100644 --- a/game/missiongenerator/aircraft/waypoints/baiingress.py +++ b/game/missiongenerator/aircraft/waypoints/baiingress.py @@ -19,7 +19,9 @@ class BaiIngressBuilder(PydcsWaypointBuilder): elif isinstance(target, MultiGroupTransport): group_names.append(target.name) elif isinstance(target, NavalControlPoint): - group_names.append(target.get_carrier_group_name()) + carrier_name = target.get_carrier_group_name() + if carrier_name: + group_names.append(carrier_name) else: logging.error( "Unexpected target type for BAI mission: %s", @@ -28,12 +30,12 @@ class BaiIngressBuilder(PydcsWaypointBuilder): return for group_name in group_names: - group = self.mission.find_group(group_name) - if group is None: + miz_group = self.mission.find_group(group_name) + if miz_group is None: logging.error("Could not find group for BAI mission %s", group_name) continue - task = AttackGroup(group.id, weapon_type=WeaponType.Auto) + task = AttackGroup(miz_group.id, weapon_type=WeaponType.Auto) task.params["attackQtyLimit"] = False task.params["directionEnabled"] = False task.params["altitudeEnabled"] = False diff --git a/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py b/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py index 9d6c4861..16e8fd28 100644 --- a/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py +++ b/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py @@ -12,7 +12,7 @@ from dcs.unitgroup import FlyingGroup from game.ato import Flight, FlightWaypoint from game.ato.flightwaypointtype import FlightWaypointType from game.missiongenerator.airsupport import AirSupport -from game.theater import MissionTarget +from game.theater import MissionTarget, GroundUnit TARGET_WAYPOINTS = ( FlightWaypointType.TARGET_GROUP_LOC, @@ -82,7 +82,7 @@ class PydcsWaypointBuilder: return False def register_special_waypoints( - self, targets: Iterable[Union[MissionTarget, Unit]] + self, targets: Iterable[Union[MissionTarget, GroundUnit]] ) -> None: """Create special target waypoints for various aircraft""" for i, t in enumerate(targets): diff --git a/game/missiongenerator/kneeboard.py b/game/missiongenerator/kneeboard.py index 133d3c47..8a27aa10 100644 --- a/game/missiongenerator/kneeboard.py +++ b/game/missiongenerator/kneeboard.py @@ -32,7 +32,6 @@ from typing import Dict, Iterator, List, Optional, TYPE_CHECKING, Tuple from PIL import Image, ImageDraw, ImageFont from dcs.mission import Mission -from dcs.unit import Unit from tabulate import tabulate from game.ato.flighttype import FlightType @@ -41,7 +40,7 @@ from game.ato.flightwaypointtype import FlightWaypointType from game.data.alic import AlicCodes from game.dcs.aircrafttype import AircraftType from game.radio.radios import RadioFrequency -from game.theater import ConflictTheater, LatLon, TheaterGroundObject +from game.theater import ConflictTheater, LatLon, TheaterGroundObject, GroundUnit from game.theater.bullseye import Bullseye from game.utils import Distance, UnitSystem, meters, mps, pounds from game.weather import Weather @@ -608,14 +607,14 @@ class SeadTaskPage(KneeboardPage): self.theater = theater @property - def target_units(self) -> Iterator[Unit]: + def target_units(self) -> Iterator[GroundUnit]: if isinstance(self.flight.package.target, TheaterGroundObject): - yield from self.flight.package.target.units + yield from self.flight.package.target.strike_targets @staticmethod - def alic_for(unit: Unit) -> str: + def alic_for(unit_type: str) -> str: try: - return str(AlicCodes.code_for(unit)) + return str(AlicCodes.code_for(unit_type)) except KeyError: return "" @@ -635,11 +634,15 @@ class SeadTaskPage(KneeboardPage): writer.write(path) - def target_info_row(self, unit: Unit) -> List[str]: + def target_info_row(self, unit: GroundUnit) -> List[str]: ll = self.theater.point_to_ll(unit.position) unit_type = unit_type_from_name(unit.type) name = unit.name if unit_type is None else unit_type.name - return [name, self.alic_for(unit), ll.format_dms(include_decimal_seconds=True)] + return [ + name, + self.alic_for(unit.type), + ll.format_dms(include_decimal_seconds=True), + ] class StrikeTaskPage(KneeboardPage): diff --git a/game/missiongenerator/tgogenerator.py b/game/missiongenerator/tgogenerator.py index a2cadb93..5dc0ad98 100644 --- a/game/missiongenerator/tgogenerator.py +++ b/game/missiongenerator/tgogenerator.py @@ -20,6 +20,8 @@ from typing import ( TYPE_CHECKING, Type, TypeVar, + List, + Any, Union, ) @@ -49,24 +51,23 @@ from dcs.task import ( from dcs.translation import String from dcs.triggers import Event, TriggerOnce, TriggerStart, TriggerZone from dcs.unit import InvisibleFARP, Ship, Unit, Vehicle -from dcs.unitgroup import ShipGroup, StaticGroup, VehicleGroup +from dcs.unitgroup import ShipGroup, StaticGroup, VehicleGroup, MovingGroup from dcs.unittype import ShipType, StaticType, VehicleType from dcs.vehicles import vehicle_map from game.data.building_data import FORTIFICATION_UNITS, FORTIFICATION_UNITS_ID -from game.dcs.helpers import unit_type_from_name +from game.dcs.helpers import static_type_from_name, unit_type_from_name from game.radio.radios import RadioFrequency, RadioRegistry from game.radio.tacan import TacanBand, TacanChannel, TacanRegistry, TacanUsage from game.theater import ControlPoint, TheaterGroundObject from game.theater.theatergroundobject import ( - BuildingGroundObject, CarrierGroundObject, - FactoryGroundObject, GenericCarrierGroundObject, LhaGroundObject, MissileSiteGroundObject, - SceneryGroundObject, - ShipGroundObject, + GroundGroup, + GroundUnit, + SceneryGroundUnit, ) from game.unitmap import UnitMap from game.utils import Heading, feet, knots, mps @@ -79,18 +80,12 @@ FARP_FRONTLINE_DISTANCE = 10000 AA_CP_MIN_DISTANCE = 40000 -TgoT = TypeVar("TgoT", bound=TheaterGroundObject[Any]) - - -class GenericGroundObjectGenerator(Generic[TgoT]): - """An unspecialized ground object generator. - - Currently used only for SAM - """ +class GroundObjectGenerator: + """generates the DCS groups and units from the TheaterGroundObject""" def __init__( self, - ground_object: TgoT, + ground_object: TheaterGroundObject, country: Country, game: Game, mission: Mission, @@ -106,7 +101,7 @@ class GenericGroundObjectGenerator(Generic[TgoT]): def culled(self) -> bool: return self.game.iads_considerate_culling(self.ground_object) - def generate(self) -> None: + def generate(self, unique_name: bool = True) -> None: if self.culled: return @@ -114,29 +109,102 @@ class GenericGroundObjectGenerator(Generic[TgoT]): if not group.units: 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 - unit_type = vehicle_map[group.units[0].type] - vg = self.m.vehicle_group( - self.country, - group.name, - unit_type, - position=group.position, - heading=group.units[0].heading, - ) - vg.units[0].name = group.units[0].name - vg.units[0].player_can_drive = True - for i, u in enumerate(group.units): - if i > 0: - vehicle = Vehicle(self.m.next_unit_id(), u.name, u.type) - vehicle.position.x = u.position.x - vehicle.position.y = u.position.y - vehicle.heading = u.heading - vehicle.player_can_drive = True - vg.add_unit(vehicle) + # Only skip dead units after trigger zone for scenery created! + if not u.alive: + continue - self.enable_eplrs(vg, unit_type) - self.set_alarm_state(vg) - self._register_unit_group(group, vg) + unit_type = static_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, + ) + 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") + 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: @@ -149,24 +217,71 @@ class GenericGroundObjectGenerator(Generic[TgoT]): else: group.points[0].tasks.append(OptAlarmState(1)) - def _register_unit_group( + def _register_ground_unit( self, - persistence_group: Union[ShipGroup, VehicleGroup], - miz_group: Union[ShipGroup, VehicleGroup], + ground_unit: GroundUnit, + dcs_unit: Unit, ) -> None: - self.unit_map.add_ground_object_units( - self.ground_object, persistence_group, miz_group + self.unit_map.add_ground_object_mapping(ground_unit, dcs_unit) + + def add_trigger_zone_for_scenery(self, scenery: SceneryGroundUnit) -> None: + # Align the trigger zones to the faction color on the DCS briefing/F10 map. + color = ( + {1: 0.2, 2: 0.7, 3: 1, 4: 0.15} + if scenery.ground_object.is_friendly(to_player=True) + else {1: 1, 2: 0.2, 3: 0.2, 4: 0.15} ) + # Create the smallest valid size trigger zone (16 feet) so that risk of overlap + # is minimized. As long as the triggerzone is over the scenery object, we're ok. + smallest_valid_radius = feet(16).meters -class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject]): + trigger_zone = self.m.triggers.add_triggerzone( + scenery.zone.position, + smallest_valid_radius, + scenery.zone.hidden, + scenery.zone.name, + color, + scenery.zone.properties, + ) + # DCS only visually shows a scenery object is dead when + # this trigger rule is applied. Otherwise you can kill a + # structure twice. + if not scenery.alive: + self.generate_destruction_trigger_rule(trigger_zone) + else: + self.generate_on_dead_trigger_rule(trigger_zone) + + self.unit_map.add_scenery(scenery, trigger_zone) + + def generate_destruction_trigger_rule(self, trigger_zone: TriggerZone) -> None: + # Add destruction zone trigger + t = TriggerStart(comment="Destruction") + t.actions.append( + SceneryDestructionZone(destruction_level=100, zone=trigger_zone.id) + ) + self.m.triggerrules.triggers.append(t) + + def generate_on_dead_trigger_rule(self, trigger_zone: TriggerZone) -> None: + # Add a TriggerRule with the MapObjectIsDead condition to recognize killed + # map objects and add them to the state.json with a DoScript + t = TriggerOnce(Event.NoEvent, f"MapObjectIsDead Trigger {trigger_zone.id}") + t.add_condition(MapObjectIsDead(trigger_zone.id)) + script_string = String( + f'killed_ground_units[#killed_ground_units + 1] = "{trigger_zone.name}"' + ) + t.actions.append(DoScript(script_string)) + self.m.triggerrules.triggers.append(t) + + +class MissileSiteGenerator(GroundObjectGenerator): @property def culled(self) -> bool: # Don't cull missile sites - their range is long enough to make them easily # culled despite being a threat. return False - def generate(self) -> None: + def generate(self, unique_name: bool = True) -> None: super(MissileSiteGenerator, self).generate() # Note : Only the SCUD missiles group can fire (V1 site cannot fire in game right now) # TODO : Should be pre-planned ? @@ -221,148 +336,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject] return site_range -class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]): - """Generator for building sites. - - Building sites are the primary type of non-airbase objective locations that - appear on the map. They come in a handful of variants each with different - types of buildings and ground units. - """ - - def generate(self) -> None: - if self.game.position_culled(self.ground_object.position): - return - - if self.ground_object.dcs_identifier in warehouse_map: - static_type = warehouse_map[self.ground_object.dcs_identifier] - self.generate_static(static_type) - elif self.ground_object.dcs_identifier in fortification_map: - static_type = fortification_map[self.ground_object.dcs_identifier] - self.generate_static(static_type) - elif self.ground_object.dcs_identifier in FORTIFICATION_UNITS_ID: - for f in FORTIFICATION_UNITS: - if f.id == self.ground_object.dcs_identifier: - unit_type = f - self.generate_vehicle_group(unit_type) - break - else: - logging.error( - f"{self.ground_object.dcs_identifier} not found in static maps" - ) - - def generate_vehicle_group(self, unit_type: Type[VehicleType]) -> None: - if not self.ground_object.is_dead: - group = self.m.vehicle_group( - country=self.country, - name=self.ground_object.group_name, - _type=unit_type, - position=self.ground_object.position, - heading=self.ground_object.heading.degrees, - ) - self._register_fortification(group) - - def generate_static(self, static_type: Type[StaticType]) -> None: - group = self.m.static_group( - country=self.country, - name=self.ground_object.group_name, - _type=static_type, - position=self.ground_object.position, - heading=self.ground_object.heading.degrees, - dead=self.ground_object.is_dead, - ) - self._register_building(group) - - def _register_fortification(self, fortification: VehicleGroup) -> None: - assert isinstance(self.ground_object, BuildingGroundObject) - self.unit_map.add_fortification(self.ground_object, fortification) - - def _register_building(self, building: StaticGroup) -> None: - assert isinstance(self.ground_object, BuildingGroundObject) - self.unit_map.add_building(self.ground_object, building) - - -class FactoryGenerator(BuildingSiteGenerator): - """Generator for factory sites. - - Factory sites are the buildings that allow the recruitment of ground units. - Destroying these prevents the owner from recruiting ground units at the connected - control point. - """ - - def generate(self) -> None: - if self.game.position_culled(self.ground_object.position): - return - - # TODO: Faction specific? - self.generate_static(Fortification.Workshop_A) - - -class SceneryGenerator(BuildingSiteGenerator): - def generate(self) -> None: - assert isinstance(self.ground_object, SceneryGroundObject) - - trigger_zone = self.generate_trigger_zone(self.ground_object) - - # DCS only visually shows a scenery object is dead when - # this trigger rule is applied. Otherwise you can kill a - # structure twice. - if self.ground_object.is_dead: - self.generate_destruction_trigger_rule(trigger_zone) - else: - self.generate_on_dead_trigger_rule(trigger_zone) - - # Tell Liberation to manage this groundobjectsgen as part of the campaign. - self.register_scenery() - - def generate_trigger_zone(self, scenery: SceneryGroundObject) -> TriggerZone: - - zone = scenery.zone - - # Align the trigger zones to the faction color on the DCS briefing/F10 map. - if scenery.is_friendly(to_player=True): - color = {1: 0.2, 2: 0.7, 3: 1, 4: 0.15} - else: - color = {1: 1, 2: 0.2, 3: 0.2, 4: 0.15} - - # Create the smallest valid size trigger zone (16 feet) so that risk of overlap - # is minimized. As long as the triggerzone is over the scenery object, we're ok. - smallest_valid_radius = feet(16).meters - - return self.m.triggers.add_triggerzone( - zone.position, - smallest_valid_radius, - zone.hidden, - zone.name, - color, - zone.properties, - ) - - def generate_destruction_trigger_rule(self, trigger_zone: TriggerZone) -> None: - # Add destruction zone trigger - t = TriggerStart(comment="Destruction") - t.actions.append( - SceneryDestructionZone(destruction_level=100, zone=trigger_zone.id) - ) - self.m.triggerrules.triggers.append(t) - - def generate_on_dead_trigger_rule(self, trigger_zone: TriggerZone) -> None: - # Add a TriggerRule with the MapObjectIsDead condition to recognize killed - # map objects and add them to the state.json with a DoScript - t = TriggerOnce(Event.NoEvent, f"MapObjectIsDead Trigger {trigger_zone.id}") - t.add_condition(MapObjectIsDead(trigger_zone.id)) - script_string = String( - f'killed_ground_units[#killed_ground_units + 1] = "{trigger_zone.name}"' - ) - t.actions.append(DoScript(script_string)) - self.m.triggerrules.triggers.append(t) - - def register_scenery(self) -> None: - scenery = self.ground_object - assert isinstance(scenery, SceneryGroundObject) - self.unit_map.add_scenery(scenery) - - -class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundObject]): +class GenericCarrierGenerator(GroundObjectGenerator): """Base type for carrier group generation. Used by both CV(N) groups and LHA groups. @@ -389,67 +363,73 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO self.icls_alloc = icls_alloc self.runways = runways - def generate(self) -> None: - # TODO: Require single group? - for group in self.ground_object.groups: + def generate(self, unique_name: bool = True) -> None: + + # This can also be refactored as the general generation was updated + atc = self.radio_registry.alloc_uhf() + + for g_id, group in enumerate(self.ground_object.groups): if not group.units: logging.warning(f"Found empty carrier group in {self.control_point}") continue - atc = self.radio_registry.alloc_uhf() - ship_group = self.configure_carrier(group, atc) - for unit in group.units[1:]: - ship_group.add_unit(self.create_ship(unit, atc)) - - tacan = self.tacan_registry.alloc_for_band( - TacanBand.X, TacanUsage.TransmitReceive + # Correct unit type for the carrier. + # This is only used for the super carrier setting + unit_type = ( + self.get_carrier_type(group) + if g_id == 0 + else ship_map[group.units[0].type] ) - tacan_callsign = self.tacan_callsign() - icls = next(self.icls_alloc) + + ship_group = self.m.ship_group( + self.country, + group.name, + unit_type, + position=group.units[0].position, + heading=group.units[0].position.heading.degrees, + ) + + ship_group.set_frequency(atc.hertz) + ship_group.units[0].name = ( + group.units[0].unit_name if unique_name else group.units[0].name + ) + self._register_ground_unit(group.units[0], ship_group.units[0]) + + for unit in group.units[1:]: + ship = Ship( + self.m.next_unit_id(), + unit.unit_name if unique_name else unit.name, + unit_type_from_name(unit.type), + ) + ship.position.x = unit.position.x + ship.position.y = unit.position.y + ship.heading = unit.position.heading.degrees + ship.set_frequency(atc.hertz) + ship_group.add_unit(ship) + self._register_ground_unit(unit, ship) # Always steam into the wind, even if the carrier is being moved. # There are multiple unsimulated hours between turns, so we can # count those as the time the carrier uses to move and the mission # time as the recovery window. brc = self.steam_into_wind(ship_group) - self.activate_beacons(ship_group, tacan, tacan_callsign, icls) - self.add_runway_data( - brc or Heading.from_degrees(0), atc, tacan, tacan_callsign, icls - ) - self._register_unit_group(group, ship_group) - def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]: + # Set Carrier Specific Options + if g_id == 0: + tacan = self.tacan_registry.alloc_for_band( + TacanBand.X, TacanUsage.TransmitReceive + ) + tacan_callsign = self.tacan_callsign() + icls = next(self.icls_alloc) + + self.activate_beacons(ship_group, tacan, tacan_callsign, icls) + self.add_runway_data( + brc or Heading.from_degrees(0), atc, tacan, tacan_callsign, icls + ) + + def get_carrier_type(self, group: GroundGroup) -> Type[ShipType]: return ship_map[group.units[0].type] - def configure_carrier( - self, group: ShipGroup, atc_channel: RadioFrequency - ) -> ShipGroup: - unit_type = self.get_carrier_type(group) - - ship_group = self.m.ship_group( - self.country, - group.name, - unit_type, - position=group.position, - heading=group.units[0].heading, - ) - ship_group.set_frequency(atc_channel.hertz) - ship_group.units[0].name = group.units[0].name - return ship_group - - def create_ship(self, unit: Unit, atc_channel: RadioFrequency) -> Ship: - ship = Ship( - self.m.next_unit_id(), - unit.name, - unit_type_from_name(unit.type), - ) - ship.position.x = unit.position.x - ship.position.y = unit.position.y - ship.heading = unit.heading - # TODO: Verify. - ship.set_frequency(atc_channel.hertz) - return ship - def steam_into_wind(self, group: ShipGroup) -> Optional[Heading]: wind = self.game.conditions.weather.wind.at_0m brc = Heading.from_degrees(wind.direction).opposite @@ -515,7 +495,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO class CarrierGenerator(GenericCarrierGenerator): """Generator for CV(N) groups.""" - def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]: + def get_carrier_type(self, group: GroundGroup) -> Type[ShipType]: unit_type = super().get_carrier_type(group) if self.game.settings.supercarrier: unit_type = self.upgrade_to_supercarrier(unit_type, self.control_point.name) @@ -579,42 +559,6 @@ class LhaGenerator(GenericCarrierGenerator): ) -class ShipObjectGenerator(GenericGroundObjectGenerator[ShipGroundObject]): - """Generator for non-carrier naval groups.""" - - def generate(self) -> None: - if self.game.position_culled(self.ground_object.position): - return - - for group in self.ground_object.groups: - if not group.units: - logging.warning(f"Found empty group in {self.ground_object}") - continue - self.generate_group(group, ship_map[group.units[0].type]) - - def generate_group( - self, group_def: ShipGroup, first_unit_type: Type[ShipType] - ) -> None: - group = self.m.ship_group( - self.country, - group_def.name, - first_unit_type, - position=group_def.position, - heading=group_def.units[0].heading, - ) - group.units[0].name = group_def.units[0].name - # TODO: Skipping the first unit looks like copy pasta from the carrier. - for unit in group_def.units[1:]: - unit_type = unit_type_from_name(unit.type) - ship = Ship(self.m.next_unit_id(), unit.name, unit_type) - ship.position.x = unit.position.x - ship.position.y = unit.position.y - ship.heading = unit.heading - group.add_unit(ship) - self.set_alarm_state(group) - self._register_unit_group(group_def, group) - - class HelipadGenerator: """ Generates helipads for given control point @@ -726,20 +670,8 @@ class TgoGenerator: self.helipads[cp] = helipad_gen.helipads for ground_object in cp.ground_objects: - generator: GenericGroundObjectGenerator[Any] - if isinstance(ground_object, FactoryGroundObject): - generator = FactoryGenerator( - ground_object, country, self.game, self.m, self.unit_map - ) - elif isinstance(ground_object, SceneryGroundObject): - generator = SceneryGenerator( - ground_object, country, self.game, self.m, self.unit_map - ) - elif isinstance(ground_object, BuildingGroundObject): - generator = BuildingSiteGenerator( - ground_object, country, self.game, self.m, self.unit_map - ) - elif isinstance(ground_object, CarrierGroundObject): + generator: GroundObjectGenerator + if isinstance(ground_object, CarrierGroundObject): generator = CarrierGenerator( ground_object, cp, @@ -753,7 +685,7 @@ class TgoGenerator: self.unit_map, ) elif isinstance(ground_object, LhaGroundObject): - generator = CarrierGenerator( + generator = LhaGenerator( ground_object, cp, country, @@ -765,16 +697,12 @@ class TgoGenerator: self.runways, self.unit_map, ) - elif isinstance(ground_object, ShipGroundObject): - generator = ShipObjectGenerator( - ground_object, country, self.game, self.m, self.unit_map - ) elif isinstance(ground_object, MissileSiteGroundObject): generator = MissileSiteGenerator( ground_object, country, self.game, self.m, self.unit_map ) else: - generator = GenericGroundObjectGenerator( + generator = GroundObjectGenerator( ground_object, country, self.game, self.m, self.unit_map ) generator.generate() diff --git a/game/sim/combat/defendingsam.py b/game/sim/combat/defendingsam.py index fca901ac..108bc408 100644 --- a/game/sim/combat/defendingsam.py +++ b/game/sim/combat/defendingsam.py @@ -11,9 +11,7 @@ if TYPE_CHECKING: class DefendingSam(FrozenCombat): - def __init__( - self, flight: Flight, air_defenses: list[TheaterGroundObject[Any]] - ) -> None: + def __init__(self, flight: Flight, air_defenses: list[TheaterGroundObject]) -> None: super().__init__() self.flight = flight self.air_defenses = air_defenses diff --git a/game/sim/combat/samengagementzones.py b/game/sim/combat/samengagementzones.py index 7f58c981..0d4c9177 100644 --- a/game/sim/combat/samengagementzones.py +++ b/game/sim/combat/samengagementzones.py @@ -17,7 +17,7 @@ class SamEngagementZones: def __init__( self, threat_zones: ThreatPoly, - individual_zones: list[tuple[TheaterGroundObject[Any], ThreatPoly]], + individual_zones: list[tuple[TheaterGroundObject, ThreatPoly]], ) -> None: self.threat_zones = threat_zones self.individual_zones = individual_zones @@ -25,9 +25,7 @@ class SamEngagementZones: def covers(self, position: Point) -> bool: return self.threat_zones.intersects(dcs_to_shapely_point(position)) - def iter_threatening_sams( - self, position: Point - ) -> Iterator[TheaterGroundObject[Any]]: + def iter_threatening_sams(self, position: Point) -> Iterator[TheaterGroundObject]: for tgo, zone in self.individual_zones: if zone.intersects(dcs_to_shapely_point(position)): yield tgo @@ -44,7 +42,7 @@ class SamEngagementZones: return SamEngagementZones(unary_union(commit_regions), individual_zones) @classmethod - def threat_region(cls, tgo: TheaterGroundObject[Any]) -> Optional[ThreatPoly]: + def threat_region(cls, tgo: TheaterGroundObject) -> Optional[ThreatPoly]: threat_range = tgo.max_threat_range() if threat_range <= meters(0): return None diff --git a/game/sim/missionresultsprocessor.py b/game/sim/missionresultsprocessor.py index 2403fe7f..22974154 100644 --- a/game/sim/missionresultsprocessor.py +++ b/game/sim/missionresultsprocessor.py @@ -29,8 +29,7 @@ class MissionResultsProcessor: self.commit_convoy_losses(debriefing) self.commit_cargo_ship_losses(debriefing) self.commit_airlift_losses(debriefing) - self.commit_ground_object_losses(debriefing) - self.commit_building_losses(debriefing) + self.commit_ground_losses(debriefing) self.commit_damaged_runways(debriefing) self.commit_captures(debriefing) self.commit_front_line_battle_impact(debriefing) @@ -131,23 +130,11 @@ class MissionResultsProcessor: ) @staticmethod - def commit_ground_object_losses(debriefing: Debriefing) -> None: - for loss in debriefing.ground_object_losses: - # TODO: This should be stored in the TGO, not in the pydcs Group. - if not hasattr(loss.group, "units_losts"): - loss.group.units_losts = [] # type: ignore - - loss.group.units.remove(loss.unit) - loss.group.units_losts.append(loss.unit) # type: ignore - - def commit_building_losses(self, debriefing: Debriefing) -> None: - for loss in debriefing.building_losses: - loss.ground_object.kill() - self.game.message( - "Building destroyed", - f"{loss.ground_object.dcs_identifier} has been destroyed at " - f"location {loss.ground_object.obj_name}", - ) + def commit_ground_losses(debriefing: Debriefing) -> None: + for ground_object_loss in debriefing.ground_object_losses: + ground_object_loss.ground_unit.kill() + for scenery_object_loss in debriefing.scenery_object_losses: + scenery_object_loss.ground_unit.kill() @staticmethod def commit_damaged_runways(debriefing: Debriefing) -> None: diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index bdaa1007..93336835 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -25,6 +25,7 @@ from .landmap import Landmap, load_landmap, poly_contains from .latlon import LatLon from .projections import TransverseMercator from .seasonalconditions import SeasonalConditions +from ..utils import Heading if TYPE_CHECKING: from .controlpoint import ControlPoint, MissionTarget @@ -85,7 +86,7 @@ class ConflictTheater: def find_ground_objects_by_obj_name( self, obj_name: str - ) -> list[TheaterGroundObject[Any]]: + ) -> list[TheaterGroundObject]: found = [] for cp in self.controlpoints: for g in cp.ground_objects: @@ -265,6 +266,29 @@ class ConflictTheater: x, y = self.ll_to_point_transformer.transform(ll.lat, ll.lng) return Point(x, y) + def heading_to_conflict_from(self, position: Point) -> Optional[Heading]: + # Heading for a Group to the enemy. + # Should be the point between the nearest and the most distant conflict + conflicts: dict[MissionTarget, float] = {} + + for conflict in self.conflicts(): + conflicts[conflict] = conflict.position.distance_to_point(position) + + if len(conflicts) == 0: + return None + + sorted_conflicts = [ + k for k, v in sorted(conflicts.items(), key=lambda item: item[1]) + ] + last = len(sorted_conflicts) - 1 + + conflict_center = Point( + (sorted_conflicts[0].position.x + sorted_conflicts[last].position.x) / 2, + (sorted_conflicts[0].position.y + sorted_conflicts[last].position.y) / 2, + ) + + return Heading.from_degrees(position.heading_between_point(conflict_center)) + class CaucasusTheater(ConflictTheater): terrain = caucasus.Caucasus() diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index a0c57d53..61b027ff 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -42,6 +42,10 @@ from .theatergroundobject import ( BuildingGroundObject, GenericCarrierGroundObject, TheaterGroundObject, + BuildingGroundObject, + CarrierGroundObject, + LhaGroundObject, + GroundUnit, ) from ..ato.starttype import StartType from ..dcs.aircrafttype import AircraftType @@ -306,7 +310,7 @@ class ControlPoint(MissionTarget, ABC): self.full_name = name self.at = at self.starts_blue = starts_blue - self.connected_objectives: List[TheaterGroundObject[Any]] = [] + self.connected_objectives: List[TheaterGroundObject] = [] self.preset_locations = PresetLocations() self.helipads: List[PointWithHeading] = [] @@ -345,7 +349,7 @@ class ControlPoint(MissionTarget, ABC): return self.coalition.player @property - def ground_objects(self) -> List[TheaterGroundObject[Any]]: + def ground_objects(self) -> List[TheaterGroundObject]: return list(self.connected_objectives) @property @@ -517,7 +521,7 @@ class ControlPoint(MissionTarget, ABC): ControlPointType.LHA_GROUP, ]: for g in self.ground_objects: - if g.dcs_identifier == "CARRIER": + if isinstance(g, CarrierGroundObject): for group in g.groups: for u in group.units: if unit_type_from_name(u.type) in [ @@ -526,7 +530,7 @@ class ControlPoint(MissionTarget, ABC): KUZNECOW, ]: return group.name - elif g.dcs_identifier == "LHA": + elif isinstance(g, LhaGroundObject): for group in g.groups: for u in group.units: if unit_type_from_name(u.type) in [LHA_Tarawa]: @@ -539,7 +543,7 @@ class ControlPoint(MissionTarget, ABC): def find_ground_objects_by_obj_name( self, obj_name: str - ) -> list[TheaterGroundObject[Any]]: + ) -> list[TheaterGroundObject]: found = [] for g in self.ground_objects: if g.obj_name == obj_name: @@ -857,7 +861,7 @@ class ControlPoint(MissionTarget, ABC): return len([obj for obj in self.connected_objectives if obj.category == "fuel"]) @property - def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: + def strike_targets(self) -> list[GroundUnit]: return [] @property @@ -1000,10 +1004,7 @@ class NavalControlPoint(ControlPoint, ABC): def find_main_tgo(self) -> GenericCarrierGroundObject: for g in self.ground_objects: - if isinstance(g, GenericCarrierGroundObject) and g.dcs_identifier in [ - "CARRIER", - "LHA", - ]: + if isinstance(g, GenericCarrierGroundObject): return g raise RuntimeError(f"Found no carrier/LHA group for {self.name}") diff --git a/game/theater/missiontarget.py b/game/theater/missiontarget.py index 5942bb0e..7a72d645 100644 --- a/game/theater/missiontarget.py +++ b/game/theater/missiontarget.py @@ -8,6 +8,7 @@ from dcs.unit import Unit if TYPE_CHECKING: from game.ato.flighttype import FlightType + from game.theater.theatergroundobject import GroundUnit class MissionTarget: @@ -46,5 +47,5 @@ class MissionTarget: ] @property - def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: + def strike_targets(self) -> list[GroundUnit]: return [] diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index 2be1c41d..6265e241 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -4,19 +4,23 @@ import itertools import logging from abc import ABC from collections.abc import Sequence -from typing import Generic, Iterator, List, TYPE_CHECKING, TypeVar, Union +from dataclasses import dataclass +from enum import Enum +from typing import Iterator, List, TYPE_CHECKING, Union, Optional from dcs.mapping import Point from dcs.triggers import TriggerZone from dcs.unit import Unit -from dcs.unitgroup import ShipGroup, VehicleGroup from dcs.vehicles import vehicle_map from game.dcs.helpers import unit_type_from_name from ..data.radar_db import LAUNCHER_TRACKER_PAIRS, TELARS, TRACK_RADARS +from ..dcs.groundunittype import GroundUnitType +from ..point_with_heading import PointWithHeading from ..utils import Distance, Heading, meters if TYPE_CHECKING: + from gen.templates import UnitTemplate, GroupTemplate, TemplateRandomizer from .controlpoint import ControlPoint from ..ato.flighttype import FlightType @@ -45,56 +49,187 @@ NAME_BY_CATEGORY = { } -GroupT = TypeVar("GroupT", ShipGroup, VehicleGroup) +class SkynetRole(Enum): + #: A radar SAM that should be controlled by Skynet. + Sam = "Sam" + + #: A radar SAM that should be controlled and used as an EWR by Skynet. + SamAsEwr = "SamAsEwr" + + #: An air defense unit that should be used as point defense by Skynet. + PointDefense = "PD" + + #: All other types of groups that might be present in a SAM TGO. This includes + #: SHORADS, AAA, supply trucks, etc. Anything that shouldn't be controlled by Skynet + #: should use this role. + NoSkynetBehavior = "NoSkynetBehavior" -class TheaterGroundObject(MissionTarget, Generic[GroupT]): +class AirDefenseRange(Enum): + AAA = ("AAA", SkynetRole.NoSkynetBehavior) + Short = ("short", SkynetRole.NoSkynetBehavior) + Medium = ("medium", SkynetRole.Sam) + Long = ("long", SkynetRole.SamAsEwr) + + def __init__(self, description: str, default_role: SkynetRole) -> None: + self.range_name = description + self.default_role = default_role + + +@dataclass +class GroundUnit: + # Units can be everything.. Static, Vehicle, Ship. + id: int + name: str + type: str # dcs.UnitType as string + position: PointWithHeading + ground_object: TheaterGroundObject + alive: bool = True + _dcs_type: Optional[GroundUnitType] = None + + @staticmethod + def from_template(id: int, t: UnitTemplate, go: TheaterGroundObject) -> GroundUnit: + return GroundUnit( + id, + t.name, + t.type, + PointWithHeading.from_point(t.position, Heading.from_degrees(t.heading)), + go, + ) + + @property + def unit_type(self) -> Optional[GroundUnitType]: + if not self._dcs_type: + try: + if self.type in vehicle_map: + dcs_unit_type = vehicle_map[self.type] + else: + return None + self._dcs_type = next(GroundUnitType.for_dcs_type(dcs_unit_type)) + except StopIteration: + return None + return self._dcs_type + + def kill(self) -> None: + self.alive = False + + @property + def unit_name(self) -> str: + return f"{str(self.id).zfill(4)} | {self.name}" + + @property + def display_name(self) -> str: + dead_label = " [DEAD]" if not self.alive else "" + unit_label = self.unit_type or self.type or self.name + return f"{str(self.id).zfill(4)} | {unit_label}{dead_label}" + + +class SceneryGroundUnit(GroundUnit): + """Special GroundUnit for handling scenery ground objects""" + + zone: TriggerZone + + +@dataclass +class GroundGroup: + id: int + name: str + position: PointWithHeading + units: list[GroundUnit] + ground_object: TheaterGroundObject + static_group: bool = False + + @staticmethod + def from_template( + 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, + go, + ) + + tgo_group.static_group = g.static + + return tgo_group + + @property + def group_name(self) -> str: + return f"{str(self.id).zfill(4)} | {self.name}" + + @property + def alive_units(self) -> int: + return sum([unit.alive for unit in self.units]) + + +class TheaterGroundObject(MissionTarget): def __init__( self, name: str, category: str, - group_id: int, position: Point, heading: Heading, control_point: ControlPoint, - dcs_identifier: str, sea_object: bool, ) -> None: super().__init__(name, position) self.category = category - self.group_id = group_id self.heading = heading self.control_point = control_point - self.dcs_identifier = dcs_identifier self.sea_object = sea_object - self.groups: List[GroupT] = [] + self.groups: List[GroundGroup] = [] @property def is_dead(self) -> bool: return self.alive_unit_count == 0 @property - def units(self) -> List[Unit]: + def units(self) -> Iterator[GroundUnit]: """ :return: all the units at this location """ - return list(itertools.chain.from_iterable([g.units for g in self.groups])) + yield from itertools.chain.from_iterable([g.units for g in self.groups]) @property - def dead_units(self) -> List[Unit]: + def statics(self) -> Iterator[GroundUnit]: + for group in self.groups: + if group.static_group: + yield from group.units + + @property + def dead_units(self) -> list[GroundUnit]: """ :return: all the dead units at this location """ - return list( - itertools.chain.from_iterable( - [getattr(g, "units_losts", []) for g in self.groups] - ) - ) + return [unit for unit in self.units if not unit.alive] @property def group_name(self) -> str: """The name of the unit group.""" - return f"{self.category}|{self.group_id}" + return f"{self.category}|{self.name}" @property def waypoint_name(self) -> str: @@ -103,9 +238,6 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): def __str__(self) -> str: return NAME_BY_CATEGORY[self.category] - def is_same_group(self, identifier: str) -> bool: - return self.group_id == identifier - @property def obj_name(self) -> str: return self.name @@ -135,7 +267,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): @property def alive_unit_count(self) -> int: - return sum(len(g.units) for g in self.groups) + return sum([g.alive_units for g in self.groups]) @property def might_have_aa(self) -> bool: @@ -149,7 +281,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): return True return False - def _max_range_of_type(self, group: GroupT, range_type: str) -> Distance: + def _max_range_of_type(self, group: GroundGroup, range_type: str) -> Distance: if not self.might_have_aa: return meters(0) @@ -170,7 +302,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): def max_detection_range(self) -> Distance: return max(self.detection_range(g) for g in self.groups) - def detection_range(self, group: GroupT) -> Distance: + def detection_range(self, group: GroundGroup) -> Distance: return self._max_range_of_type(group, "detection_range") def max_threat_range(self) -> Distance: @@ -178,7 +310,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): max(self.threat_range(g) for g in self.groups) if self.groups else meters(0) ) - def threat_range(self, group: GroupT, radar_only: bool = False) -> Distance: + def threat_range(self, group: GroundGroup, radar_only: bool = False) -> Distance: return self._max_range_of_type(group, "threat_range") @property @@ -195,8 +327,8 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): return False @property - def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: - return self.units + def strike_targets(self) -> list[GroundUnit]: + return [unit for unit in self.units if unit.alive] @property def mark_locations(self) -> Iterator[Point]: @@ -214,70 +346,31 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): raise NotImplementedError -class BuildingGroundObject(TheaterGroundObject[VehicleGroup]): +class BuildingGroundObject(TheaterGroundObject): def __init__( self, name: str, category: str, - group_id: int, - object_id: int, position: Point, heading: Heading, control_point: ControlPoint, - dcs_identifier: str, is_fob_structure: bool = False, ) -> None: super().__init__( name=name, category=category, - group_id=group_id, position=position, heading=heading, control_point=control_point, - dcs_identifier=dcs_identifier, sea_object=False, ) self.is_fob_structure = is_fob_structure - self.object_id = object_id - # Other TGOs track deadness based on the number of alive units, but - # buildings don't have groups assigned to the TGO. - self._dead = False - - @property - def group_name(self) -> str: - """The name of the unit group.""" - return f"{self.category}|{self.group_id}|{self.object_id}" - - @property - def waypoint_name(self) -> str: - return f"{super().waypoint_name} #{self.object_id}" - - @property - def is_dead(self) -> bool: - if not hasattr(self, "_dead"): - self._dead = False - return self._dead - - def kill(self) -> None: - self._dead = True - - def iter_building_group(self) -> Iterator[BuildingGroundObject]: - for tgo in self.control_point.ground_objects: - if ( - tgo.obj_name == self.obj_name - and not tgo.is_dead - and isinstance(tgo, BuildingGroundObject) - ): - yield tgo - - @property - def strike_targets(self) -> List[BuildingGroundObject]: - return list(self.iter_building_group()) @property def mark_locations(self) -> Iterator[Point]: - for building in self.iter_building_group(): - yield building.position + # Special handling to mark all buildings of the TGO + for unit in self.strike_targets: + yield unit.position @property def is_control_point(self) -> bool: @@ -298,55 +391,7 @@ class BuildingGroundObject(TheaterGroundObject[VehicleGroup]): return meters(0) -class SceneryGroundObject(BuildingGroundObject): - def __init__( - self, - name: str, - category: str, - group_id: int, - object_id: int, - position: Point, - control_point: ControlPoint, - dcs_identifier: str, - zone: TriggerZone, - ) -> None: - super().__init__( - name=name, - category=category, - group_id=group_id, - object_id=object_id, - position=position, - heading=Heading.from_degrees(0), - control_point=control_point, - dcs_identifier=dcs_identifier, - is_fob_structure=False, - ) - self.zone = zone - - -class FactoryGroundObject(BuildingGroundObject): - def __init__( - self, - name: str, - group_id: int, - position: Point, - heading: Heading, - control_point: ControlPoint, - ) -> None: - super().__init__( - name=name, - category="factory", - group_id=group_id, - object_id=0, - position=position, - heading=heading, - control_point=control_point, - dcs_identifier="Workshop A", - is_fob_structure=False, - ) - - -class NavalGroundObject(TheaterGroundObject[ShipGroup]): +class NavalGroundObject(TheaterGroundObject): def mission_types(self, for_player: bool) -> Iterator[FlightType]: from game.ato import FlightType @@ -375,15 +420,13 @@ class GenericCarrierGroundObject(NavalGroundObject): # TODO: Why is this both a CP and a TGO? class CarrierGroundObject(GenericCarrierGroundObject): - def __init__(self, name: str, group_id: int, control_point: ControlPoint) -> None: + def __init__(self, name: str, control_point: ControlPoint) -> None: super().__init__( name=name, category="CARRIER", - group_id=group_id, position=control_point.position, heading=Heading.from_degrees(0), control_point=control_point, - dcs_identifier="CARRIER", sea_object=True, ) @@ -399,15 +442,13 @@ class CarrierGroundObject(GenericCarrierGroundObject): # TODO: Why is this both a CP and a TGO? class LhaGroundObject(GenericCarrierGroundObject): - def __init__(self, name: str, group_id: int, control_point: ControlPoint) -> None: + def __init__(self, name: str, control_point: ControlPoint) -> None: super().__init__( name=name, category="LHA", - group_id=group_id, position=control_point.position, heading=Heading.from_degrees(0), control_point=control_point, - dcs_identifier="LHA", sea_object=True, ) @@ -421,18 +462,14 @@ class LhaGroundObject(GenericCarrierGroundObject): return f"LHA {self.name}" -class MissileSiteGroundObject(TheaterGroundObject[VehicleGroup]): - def __init__( - self, name: str, group_id: int, position: Point, control_point: ControlPoint - ) -> None: +class MissileSiteGroundObject(TheaterGroundObject): + def __init__(self, name: str, position: Point, control_point: ControlPoint) -> None: super().__init__( name=name, category="missile", - group_id=group_id, position=position, heading=Heading.from_degrees(0), control_point=control_point, - dcs_identifier="AA", sea_object=False, ) @@ -445,11 +482,10 @@ class MissileSiteGroundObject(TheaterGroundObject[VehicleGroup]): return False -class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]): +class CoastalSiteGroundObject(TheaterGroundObject): def __init__( self, name: str, - group_id: int, position: Point, control_point: ControlPoint, heading: Heading, @@ -457,11 +493,9 @@ class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]): super().__init__( name=name, category="coastal", - group_id=group_id, position=position, heading=heading, control_point=control_point, - dcs_identifier="AA", sea_object=False, ) @@ -474,7 +508,7 @@ class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]): return False -class IadsGroundObject(TheaterGroundObject[VehicleGroup], ABC): +class IadsGroundObject(TheaterGroundObject, ABC): def mission_types(self, for_player: bool) -> Iterator[FlightType]: from game.ato import FlightType @@ -490,18 +524,15 @@ class SamGroundObject(IadsGroundObject): def __init__( self, name: str, - group_id: int, position: Point, control_point: ControlPoint, ) -> None: super().__init__( name=name, category="aa", - group_id=group_id, position=position, heading=Heading.from_degrees(0), control_point=control_point, - dcs_identifier="AA", sea_object=False, ) @@ -521,7 +552,7 @@ class SamGroundObject(IadsGroundObject): def might_have_aa(self) -> bool: return True - def threat_range(self, group: VehicleGroup, radar_only: bool = False) -> Distance: + def threat_range(self, group: GroundGroup, radar_only: bool = False) -> Distance: max_non_radar = meters(0) live_trs = set() max_telar_range = meters(0) @@ -554,22 +585,19 @@ class SamGroundObject(IadsGroundObject): return True -class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]): +class VehicleGroupGroundObject(TheaterGroundObject): def __init__( self, name: str, - group_id: int, position: Point, control_point: ControlPoint, ) -> None: super().__init__( name=name, category="armor", - group_id=group_id, position=position, heading=Heading.from_degrees(0), control_point=control_point, - dcs_identifier="AA", sea_object=False, ) @@ -586,18 +614,15 @@ class EwrGroundObject(IadsGroundObject): def __init__( self, name: str, - group_id: int, position: Point, control_point: ControlPoint, ) -> None: super().__init__( name=name, category="ewr", - group_id=group_id, position=position, heading=Heading.from_degrees(0), control_point=control_point, - dcs_identifier="EWR", sea_object=False, ) @@ -605,7 +630,7 @@ class EwrGroundObject(IadsGroundObject): def group_name(self) -> str: # Prefix the group names with the side color so Skynet can find them. # Use Group Id and uppercase EWR - return f"{self.faction_color}|EWR|{self.group_id}" + return f"{self.faction_color}|EWR|{self.name}" @property def might_have_aa(self) -> bool: @@ -621,17 +646,13 @@ class EwrGroundObject(IadsGroundObject): class ShipGroundObject(NavalGroundObject): - def __init__( - self, name: str, group_id: int, position: Point, control_point: ControlPoint - ) -> None: + def __init__(self, name: str, position: Point, control_point: ControlPoint) -> None: super().__init__( name=name, category="ship", - group_id=group_id, position=position, heading=Heading.from_degrees(0), control_point=control_point, - dcs_identifier="AA", sea_object=True, ) diff --git a/game/threatzones.py b/game/threatzones.py index f916bd8e..bb4f297f 100644 --- a/game/threatzones.py +++ b/game/threatzones.py @@ -208,7 +208,7 @@ class ThreatZones: cls, doctrine: Doctrine, barcap_locations: Iterable[ControlPoint], - air_defenses: Iterable[TheaterGroundObject[Any]], + air_defenses: Iterable[TheaterGroundObject], ) -> ThreatZones: """Generates the threat zones projected by the given locations. diff --git a/game/unitmap.py b/game/unitmap.py index b73f6984..b43a1cef 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -4,16 +4,17 @@ from __future__ import annotations import itertools import math from dataclasses import dataclass -from typing import Dict, Optional, Any, TYPE_CHECKING, Union, TypeVar, Generic +from typing import Dict, Optional, Any, TYPE_CHECKING -from dcs.unit import Vehicle, Ship -from dcs.unitgroup import FlyingGroup, VehicleGroup, StaticGroup, ShipGroup, MovingGroup +from dcs.triggers import TriggerZone +from dcs.unit import Unit +from dcs.unitgroup import FlyingGroup, VehicleGroup, ShipGroup from game.dcs.groundunittype import GroundUnitType from game.squadrons import Pilot -from game.theater import Airfield, ControlPoint, TheaterGroundObject -from game.theater.theatergroundobject import BuildingGroundObject, SceneryGroundObject +from game.theater import Airfield, ControlPoint, GroundUnit from game.ato.flight import Flight +from game.theater.theatergroundobject import SceneryGroundUnit if TYPE_CHECKING: from game.transfers import CargoShip, Convoy, TransferOrder @@ -31,14 +32,16 @@ class FrontLineUnit: origin: ControlPoint -UnitT = TypeVar("UnitT", Ship, Vehicle) +@dataclass(frozen=True) +class GroundObjectMapping: + ground_unit: GroundUnit + dcs_unit: Unit @dataclass(frozen=True) -class GroundObjectUnit(Generic[UnitT]): - ground_object: TheaterGroundObject[Any] - group: MovingGroup[UnitT] - unit: UnitT +class SceneryObjectMapping: + ground_unit: GroundUnit + trigger_zone: TriggerZone @dataclass(frozen=True) @@ -53,18 +56,13 @@ class AirliftUnits: transfer: TransferOrder -@dataclass(frozen=True) -class Building: - ground_object: BuildingGroundObject - - class UnitMap: def __init__(self) -> None: self.aircraft: Dict[str, FlyingUnit] = {} self.airfields: Dict[str, Airfield] = {} self.front_line_units: Dict[str, FrontLineUnit] = {} - self.ground_object_units: Dict[str, GroundObjectUnit[Any]] = {} - self.buildings: Dict[str, Building] = {} + self.ground_objects: Dict[str, GroundObjectMapping] = {} + self.scenery_objects: Dict[str, SceneryObjectMapping] = {} self.convoys: Dict[str, ConvoyUnit] = {} self.cargo_ships: Dict[str, CargoShip] = {} self.airlifts: Dict[str, AirliftUnits] = {} @@ -105,41 +103,18 @@ class UnitMap: def front_line_unit(self, name: str) -> Optional[FrontLineUnit]: return self.front_line_units.get(name, None) - def add_ground_object_units( - self, - ground_object: TheaterGroundObject[Any], - persistence_group: Union[ShipGroup, VehicleGroup], - miz_group: Union[ShipGroup, VehicleGroup], + def add_ground_object_mapping( + self, ground_unit: GroundUnit, dcs_unit: Unit ) -> None: - """Adds a group associated with a TGO to the unit map. + # Deaths for units at TGOs are recorded in the corresponding GroundUnit within + # the GroundGroup, so we have to match the dcs unit with the liberation unit + name = str(dcs_unit.name) + if name in self.ground_objects: + raise RuntimeError(f"Duplicate TGO unit: {name}") + self.ground_objects[name] = GroundObjectMapping(ground_unit, dcs_unit) - Args: - ground_object: The TGO the group is associated with. - persistence_group: The Group tracked by the TGO itself. - miz_group: The Group spawned for the miz to match persistence_group. - """ - # Deaths for units at TGOs are recorded in the Group that is contained - # by the TGO, but when groundobjectsgen populates the miz it creates new - # groups based on that template, so the units and groups in the miz are - # not a direct match for the units and groups that persist in the TGO. - # - # This means that we need to map the spawned unit names back to the - # original TGO units, not the ones in the miz. - if len(persistence_group.units) != len(miz_group.units): - raise ValueError("Persistent group does not match generated group") - unit_pairs = zip(persistence_group.units, miz_group.units) - for persistent_unit, miz_unit in unit_pairs: - # The actual name is a String (the pydcs translatable string), which - # doesn't define __eq__. - name = str(miz_unit.name) - if name in self.ground_object_units: - raise RuntimeError(f"Duplicate TGO unit: {name}") - self.ground_object_units[name] = GroundObjectUnit( - ground_object, persistence_group, persistent_unit - ) - - def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit[Any]]: - return self.ground_object_units.get(name, None) + def ground_object(self, name: str) -> Optional[GroundObjectMapping]: + return self.ground_objects.get(name, None) def add_convoy_units(self, group: VehicleGroup, convoy: Convoy) -> None: for unit, unit_type in zip(group.units, convoy.iter_units()): @@ -195,40 +170,13 @@ class UnitMap: def airlift_unit(self, name: str) -> Optional[AirliftUnits]: return self.airlifts.get(name, None) - def add_building( - self, ground_object: BuildingGroundObject, group: StaticGroup + def add_scenery( + self, scenery_unit: SceneryGroundUnit, trigger_zone: TriggerZone ) -> None: - # The actual name is a String (the pydcs translatable string), which - # doesn't define __eq__. - # The name of the initiator in the DCS dead event will have " object" - # appended for statics. - name = f"{group.name} object" - if name in self.buildings: - raise RuntimeError(f"Duplicate TGO unit: {name}") - self.buildings[name] = Building(ground_object) + name = str(trigger_zone.name) + if name in self.scenery_objects: + raise RuntimeError(f"Duplicate scenery object {name} (TriggerZone)") + self.scenery_objects[name] = SceneryObjectMapping(scenery_unit, trigger_zone) - def add_fortification( - self, ground_object: BuildingGroundObject, group: VehicleGroup - ) -> None: - if len(group.units) != 1: - raise ValueError("Fortification groups must have exactly one unit.") - unit = group.units[0] - # The actual name is a String (the pydcs translatable string), which - # doesn't define __eq__. - name = str(unit.name) - if name in self.buildings: - raise RuntimeError(f"Duplicate TGO unit: {name}") - self.buildings[name] = Building(ground_object) - - def add_scenery(self, ground_object: SceneryGroundObject) -> None: - name = str(ground_object.zone.name) - if name in self.buildings: - raise RuntimeError( - f"Duplicate TGO unit: {name}. TriggerZone name: " - f"{ground_object.dcs_identifier}" - ) - - self.buildings[name] = Building(ground_object) - - def building_or_fortification(self, name: str) -> Optional[Building]: - return self.buildings.get(name, None) + def scenery_object(self, name: str) -> Optional[SceneryObjectMapping]: + return self.scenery_objects.get(name, None) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 224440c7..43917615 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -46,9 +46,9 @@ from game.theater import ( TheaterGroundObject, ) from game.theater.theatergroundobject import ( - BuildingGroundObject, EwrGroundObject, NavalGroundObject, + GroundUnit, ) from game.typeguard import self_type_guard from game.utils import Distance, Heading, Speed, feet, knots, meters, nautical_miles @@ -1086,7 +1086,7 @@ class FlightPlanBuilder: self, flight: Flight, # TODO: Custom targets should be an attribute of the flight. - custom_targets: Optional[List[Unit]] = None, + custom_targets: Optional[List[GroundUnit]] = None, ) -> None: """Creates a default flight plan for the given mission.""" if flight not in self.package.flights: @@ -1106,7 +1106,7 @@ class FlightPlanBuilder: ) from ex def generate_flight_plan( - self, flight: Flight, custom_targets: Optional[List[Unit]] + self, flight: Flight, custom_targets: Optional[List[GroundUnit]] ) -> FlightPlan: # TODO: Flesh out mission types. task = flight.flight_type @@ -1207,16 +1207,9 @@ class FlightPlanBuilder: raise InvalidObjectiveLocation(flight.flight_type, location) targets: List[StrikeTarget] = [] - if isinstance(location, BuildingGroundObject): - # A building "group" is implemented as multiple TGOs with the same name. - for building in location.strike_targets: - targets.append(StrikeTarget(building.category, building)) - else: - # TODO: Replace with DEAD? - # Strike missions on SEAD targets target units. - for g in location.groups: - for j, u in enumerate(g.units): - targets.append(StrikeTarget(f"{u.type} #{j}", u)) + + for j, u in enumerate(location.strike_targets): + targets.append(StrikeTarget(f"{u.type} #{j}", u)) return self.strike_flightplan( flight, location, FlightWaypointType.INGRESS_STRIKE, targets @@ -1675,7 +1668,7 @@ class FlightPlanBuilder: ) def generate_dead( - self, flight: Flight, custom_targets: Optional[List[Unit]] + self, flight: Flight, custom_targets: Optional[List[GroundUnit]] ) -> StrikeFlightPlan: """Generate a DEAD flight at a given location. @@ -1745,7 +1738,7 @@ class FlightPlanBuilder: ) def generate_sead( - self, flight: Flight, custom_targets: Optional[List[Unit]] + self, flight: Flight, custom_targets: Optional[List[GroundUnit]] ) -> StrikeFlightPlan: """Generate a SEAD flight at a given location. diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index cdee7676..58d89439 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -1,5 +1,4 @@ from __future__ import annotations -import logging import random from dataclasses import dataclass @@ -11,12 +10,9 @@ from typing import ( TYPE_CHECKING, Tuple, Union, - Any, ) from dcs.mapping import Point -from dcs.unit import Unit -from dcs.unitgroup import VehicleGroup, ShipGroup from game.theater import ( ControlPoint, @@ -32,14 +28,13 @@ if TYPE_CHECKING: from game.ato.flight import Flight from game.coalition import Coalition from game.transfers import MultiGroupTransport + from game.theater.theatergroundobject import GroundUnit, GroundGroup @dataclass(frozen=True) class StrikeTarget: name: str - target: Union[ - VehicleGroup, TheaterGroundObject[Any], Unit, ShipGroup, MultiGroupTransport - ] + target: Union[TheaterGroundObject, GroundGroup, GroundUnit, MultiGroupTransport] class WaypointBuilder: diff --git a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py index f857dfd6..4a9d1435 100644 --- a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py +++ b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py @@ -2,6 +2,7 @@ from PySide2.QtGui import QStandardItem, QStandardItemModel from game import Game from game.theater import ControlPointType, BuildingGroundObject +from game.theater.theatergroundobject import IadsGroundObject from game.utils import Distance from game.missiongenerator.frontlineconflictdescription import ( FrontLineConflictDescription, @@ -115,8 +116,7 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox): ): for ground_object in cp.ground_objects: if not ground_object.is_dead and ( - ground_object.dcs_identifier == "AA" - or ground_object.dcs_identifier == "EWR" + isinstance(ground_object, IadsGroundObject) ): for g in ground_object.groups: for j, u in enumerate(g.units): diff --git a/qt_ui/widgets/combos/QStrikeTargetSelectionComboBox.py b/qt_ui/widgets/combos/QStrikeTargetSelectionComboBox.py index d31c501c..7074d0fb 100644 --- a/qt_ui/widgets/combos/QStrikeTargetSelectionComboBox.py +++ b/qt_ui/widgets/combos/QStrikeTargetSelectionComboBox.py @@ -1,6 +1,7 @@ from PySide2.QtGui import QStandardItem, QStandardItemModel from game import Game +from game.theater import SamGroundObject from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox @@ -53,7 +54,7 @@ class QStrikeTargetSelectionComboBox(QFilteredComboBox): target.location = g target.name = g.obj_name - if g.dcs_identifier == "AA": + if isinstance(g, SamGroundObject): target.name = g.obj_name + " [units]" for group in g.groups: for u in group.units: diff --git a/qt_ui/widgets/map/model/groundobjectjs.py b/qt_ui/widgets/map/model/groundobjectjs.py index 36a278b7..7377567e 100644 --- a/qt_ui/widgets/map/model/groundobjectjs.py +++ b/qt_ui/widgets/map/model/groundobjectjs.py @@ -3,11 +3,8 @@ from __future__ import annotations from typing import List, Optional from PySide2.QtCore import Property, QObject, Signal, Slot -from dcs.unit import Unit -from dcs.vehicles import vehicle_map from game import Game -from game.dcs.groundunittype import GroundUnitType from game.server.leaflet import LeafletLatLon from game.theater import TheaterGroundObject from qt_ui.dialogs import Dialog @@ -30,7 +27,6 @@ class GroundObjectJs(QObject): self.tgo = tgo self.game = game self.theater = game.theater - self.buildings = self.theater.find_ground_objects_by_obj_name(self.tgo.obj_name) self.dialog: Optional[QGroundObjectMenu] = None @Slot() @@ -39,7 +35,6 @@ class GroundObjectJs(QObject): self.dialog = QGroundObjectMenu( None, self.tgo, - self.buildings, self.tgo.control_point, self.game, ) @@ -61,38 +56,9 @@ class GroundObjectJs(QObject): def category(self) -> str: return self.tgo.category - @staticmethod - def make_unit_name(unit: Unit, dead: bool) -> str: - dead_label = " [DEAD]" if dead else "" - unit_display_name = unit.type - dcs_unit_type = vehicle_map.get(unit.type) - if dcs_unit_type is not None: - # TODO: Make the TGO contain GroundUnitType instead of the pydcs Group. - # This is a hack because we can't know which variant was used. - try: - unit_display_name = next( - GroundUnitType.for_dcs_type(dcs_unit_type) - ).name - except StopIteration: - pass - return f"Unit #{unit.id} - {unit_display_name}{dead_label}" - @Property(list, notify=unitsChanged) def units(self) -> List[str]: - units = [] - # TGOs with a non-empty group set are non-building TGOs. Building TGOs have no - # groups set, but instead are one TGO per building "group" (DCS doesn't support - # groups of statics) all with the same name. - if self.tgo.groups: - for unit in self.tgo.units: - units.append(self.make_unit_name(unit, dead=False)) - for unit in self.tgo.dead_units: - units.append(self.make_unit_name(unit, dead=True)) - else: - for building in self.buildings: - dead = " [DEAD]" if building.is_dead else "" - units.append(f"{building.dcs_identifier}{dead}") - return units + return [unit.display_name for unit in self.tgo.units] @Property(bool, notify=blueChanged) def blue(self) -> bool: @@ -105,9 +71,7 @@ class GroundObjectJs(QObject): @Property(bool, notify=deadChanged) def dead(self) -> bool: - if not self.tgo.groups: - return all(b.is_dead for b in self.buildings) - return not any(g.units for g in self.tgo.groups) + return not any(g.alive_units > 0 for g in self.tgo.groups) @Property(list, notify=samThreatRangesChanged) def samThreatRanges(self) -> List[float]: diff --git a/qt_ui/windows/QDebriefingWindow.py b/qt_ui/windows/QDebriefingWindow.py index 20e01cc3..f658a162 100644 --- a/qt_ui/windows/QDebriefingWindow.py +++ b/qt_ui/windows/QDebriefingWindow.py @@ -35,7 +35,8 @@ class LossGrid(QGridLayout): self.add_loss_rows( debriefing.airlift_losses_by_type(player), lambda u: f"{u} from airlift" ) - self.add_loss_rows(debriefing.building_losses_by_type(player), lambda u: u) + self.add_loss_rows(debriefing.ground_object_losses_by_type(player), lambda u: u) + self.add_loss_rows(debriefing.scenery_losses_by_type(player), lambda u: u) # TODO: Display dead ground object units and runways. diff --git a/qt_ui/windows/QWaitingForMissionResultWindow.py b/qt_ui/windows/QWaitingForMissionResultWindow.py index 5c07b4cb..37c64d84 100644 --- a/qt_ui/windows/QWaitingForMissionResultWindow.py +++ b/qt_ui/windows/QWaitingForMissionResultWindow.py @@ -169,12 +169,14 @@ class QWaitingForMissionResultWindow(QDialog): update_layout, ) self.add_update_row( - "Ground units lost at objective areas", + "Ground Objects destroyed", len(list(debriefing.ground_object_losses)), update_layout, ) self.add_update_row( - "Buildings destroyed", len(list(debriefing.building_losses)), update_layout + "Scenery Objects destroyed", + len(list(debriefing.scenery_object_losses)), + update_layout, ) self.add_update_row( "Base capture events", len(debriefing.base_captures), update_layout diff --git a/qt_ui/windows/groundobject/QBuildingInfo.py b/qt_ui/windows/groundobject/QBuildingInfo.py index 05c1b8f6..811e7270 100644 --- a/qt_ui/windows/groundobject/QBuildingInfo.py +++ b/qt_ui/windows/groundobject/QBuildingInfo.py @@ -2,6 +2,7 @@ import os from PySide2.QtGui import QPixmap from PySide2.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QVBoxLayout +from game.theater import GroundUnit from game.config import REWARDS @@ -9,16 +10,16 @@ from game.config import REWARDS class QBuildingInfo(QGroupBox): def __init__(self, building, ground_object): super(QBuildingInfo, self).__init__() - self.building = building + self.building: GroundUnit = building self.ground_object = ground_object self.init_ui() def init_ui(self): self.header = QLabel() path = os.path.join( - "./resources/ui/units/buildings/" + self.building.dcs_identifier + ".png" + "./resources/ui/units/buildings/" + self.building.type + ".png" ) - if self.building.is_dead: + if not self.building.alive: pixmap = QPixmap("./resources/ui/units/buildings/dead.png") elif os.path.isfile(path): pixmap = QPixmap(path) @@ -26,8 +27,8 @@ class QBuildingInfo(QGroupBox): pixmap = QPixmap("./resources/ui/units/buildings/missing.png") self.header.setPixmap(pixmap) name = "{} {}".format( - self.building.dcs_identifier[0:18], - "[DEAD]" if self.building.is_dead else "", + self.building.type[0:18], + "[DEAD]" if not self.building.alive else "", ) self.name = QLabel(name) self.name.setProperty("style", "small") @@ -35,9 +36,11 @@ class QBuildingInfo(QGroupBox): layout.addWidget(self.header) layout.addWidget(self.name) - if self.building.category in REWARDS.keys(): - income_label_text = "Value: " + str(REWARDS[self.building.category]) + "M" - if self.building.is_dead: + if self.ground_object.category in REWARDS.keys(): + income_label_text = ( + "Value: " + str(REWARDS[self.ground_object.category]) + "M" + ) + if not self.building.alive: income_label_text = "" + income_label_text + "" self.reward = QLabel(income_label_text) layout.addWidget(self.reward) diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py index ef4b32b9..750ee898 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -107,58 +107,21 @@ class QGroundObjectMenu(QDialog): self.intelLayout = QGridLayout() i = 0 for g in self.ground_object.groups: - if not hasattr(g, "units_losts"): - g.units_losts = [] for unit in g.units: - unit_display_name = unit.type - dcs_unit_type = vehicles.vehicle_map.get(unit.type) - if dcs_unit_type is not None: - # Hack: Don't know which variant is used. - try: - unit_display_name = next( - GroundUnitType.for_dcs_type(dcs_unit_type) - ).name - except StopIteration: - pass self.intelLayout.addWidget( - QLabel( - "Unit #" - + str(unit.id) - + " - " - + str(unit_display_name) - + "" - ), - i, - 0, + QLabel(f"Unit {str(unit.display_name)}"), i, 0 ) - i = i + 1 - for unit in g.units_losts: - dcs_unit_type = vehicles.vehicle_map.get(unit.type) - if dcs_unit_type is None: - continue - - # Hack: Don't know which variant is used. - - try: - unit_type = next(GroundUnitType.for_dcs_type(dcs_unit_type)) - name = unit_type.name - price = unit_type.price - except StopIteration: - name = dcs_unit_type.name - price = 0 - - self.intelLayout.addWidget( - QLabel(f"Unit #{unit.id} - {name} [DEAD]"), i, 0 - ) - if self.cp.captured: + if not unit.alive and self.cp.captured: + price = unit.unit_type.price if unit.unit_type else 0 repair = QPushButton(f"Repair [{price}M]") repair.setProperty("style", "btn-success") repair.clicked.connect( - lambda u=unit, g=g, p=unit_type.price: self.repair_unit(g, u, p) + lambda u=unit, p=price: self.repair_unit(u, p) ) self.intelLayout.addWidget(repair, i, 1) - i = i + 1 + i += 1 + stretch = QVBoxLayout() stretch.addStretch() self.intelLayout.addLayout(stretch, i, 0) @@ -169,19 +132,19 @@ class QGroundObjectMenu(QDialog): j = 0 total_income = 0 received_income = 0 - for i, building in enumerate(self.buildings): - if building.dcs_identifier not in FORTIFICATION_BUILDINGS: + for static in self.ground_object.statics: + if static not in FORTIFICATION_BUILDINGS: self.buildingsLayout.addWidget( - QBuildingInfo(building, self.ground_object), j / 3, j % 3 + QBuildingInfo(static, self.ground_object), j / 3, j % 3 ) j = j + 1 - if building.category in REWARDS.keys(): - total_income = total_income + REWARDS[building.category] - if not building.is_dead: - received_income = received_income + REWARDS[building.category] + if self.ground_object.category in REWARDS.keys(): + total_income += REWARDS[self.ground_object.category] + if static.alive: + received_income += REWARDS[self.ground_object.category] else: - logging.warning(building.category + " not in REWARDS") + logging.warning(self.ground_object.category + " not in REWARDS") self.financesBox = QGroupBox("Finances: ") self.financesBoxLayout = QGridLayout() @@ -235,11 +198,10 @@ class QGroundObjectMenu(QDialog): self.sell_all_button.setText("Disband (+$" + str(self.total_value) + "M)") self.total_value = total_value - def repair_unit(self, group, unit, price): + def repair_unit(self, unit, price): if self.game.blue.budget > price: self.game.blue.budget -= price - group.units_losts = [u for u in group.units_losts if u.id != unit.id] - group.units.append(unit) + unit.alive = True GameUpdateSignal.get_instance().updateGame(self.game) # Remove destroyed units in the vicinity