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