diff --git a/game/operation/operation.py b/game/operation/operation.py index 271a99aa..5bf7c4e7 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -22,6 +22,7 @@ from gen.airsupportgen import AirSupport, AirSupportConflictGenerator from gen.armor import GroundConflictGenerator, JtacInfo from gen.beacons import load_beacons_for_terrain from gen.briefinggen import BriefingGenerator, MissionInfoGenerator +from gen.convoys import ConvoyGenerator from gen.environmentgen import EnvironmentGenerator from gen.forcedoptionsgen import ForcedOptionsGenerator from gen.groundobjectsgen import GroundObjectsGenerator @@ -314,6 +315,7 @@ class Operation: cls.airgen.flights, cls.airsupportgen.air_support ) cls._generate_ground_conflicts() + cls._generate_convoys() # Triggers triggersgen = TriggersGenerator(cls.current_mission, cls.game) @@ -428,6 +430,11 @@ class Operation: ground_conflict_gen.generate() cls.jtacs.extend(ground_conflict_gen.jtacs) + @classmethod + def _generate_convoys(cls) -> None: + """Generates convoys for unit transfers by road.""" + ConvoyGenerator(cls.current_mission, cls.game, cls.unit_map).generate() + @classmethod def reset_naming_ids(cls): namegen.reset_numbers() diff --git a/game/transfers.py b/game/transfers.py index 3d6fa6fe..9372df65 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -1,6 +1,6 @@ import logging from dataclasses import dataclass, field -from typing import Dict, List, Type +from typing import Dict, Iterator, List, Type from dcs.unittype import VehicleType from game.theater import ControlPoint @@ -49,6 +49,9 @@ class PendingTransfers: def __init__(self) -> None: self.pending_transfers: List[RoadTransferOrder] = [] + def __iter__(self) -> Iterator[RoadTransferOrder]: + yield from self.pending_transfers + @property def pending_transfer_count(self) -> int: return len(self.pending_transfers) diff --git a/game/unitmap.py b/game/unitmap.py index 149cba40..119eae50 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -9,6 +9,7 @@ from dcs.unittype import VehicleType from game import db from game.theater import Airfield, ControlPoint, TheaterGroundObject from game.theater.theatergroundobject import BuildingGroundObject +from game.transfers import RoadTransferOrder from gen.flights.flight import Flight @@ -25,6 +26,12 @@ class GroundObjectUnit: unit: Unit +@dataclass(frozen=True) +class ConvoyUnit: + unit_type: Type[VehicleType] + transfer: RoadTransferOrder + + @dataclass(frozen=True) class Building: ground_object: BuildingGroundObject @@ -37,6 +44,7 @@ class UnitMap: self.front_line_units: Dict[str, FrontLineUnit] = {} self.ground_object_units: Dict[str, GroundObjectUnit] = {} self.buildings: Dict[str, Building] = {} + self.convoys: Dict[str, ConvoyUnit] = {} def add_aircraft(self, group: FlyingGroup, flight: Flight) -> None: for unit in group.units: @@ -113,6 +121,25 @@ class UnitMap: def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit]: return self.ground_object_units.get(name, None) + def add_convoy_units(self, group: Group, transfer: RoadTransferOrder) -> None: + for unit in group.units: + # The actual name is a String (the pydcs translatable string), which + # doesn't define __eq__. + name = str(unit.name) + if name in self.convoys: + raise RuntimeError(f"Duplicate convoy unit: {name}") + unit_type = db.unit_type_from_name(unit.type) + if unit_type is None: + raise RuntimeError(f"Unknown unit type: {unit.type}") + if not issubclass(unit_type, VehicleType): + raise RuntimeError( + f"{name} is a {unit_type.__name__}, expected a VehicleType" + ) + self.convoys[name] = ConvoyUnit(unit_type, transfer) + + def convoy_unit(self, name: str) -> Optional[ConvoyUnit]: + return self.convoys.get(name, None) + def add_building(self, ground_object: BuildingGroundObject, group: Group) -> None: # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. diff --git a/gen/convoys.py b/gen/convoys.py new file mode 100644 index 00000000..ff9f9ccc --- /dev/null +++ b/gen/convoys.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import itertools +from typing import Dict, TYPE_CHECKING, Type + +from dcs import Mission +from dcs.mapping import Point +from dcs.point import PointAction +from dcs.unit import Vehicle +from dcs.unitgroup import VehicleGroup +from dcs.unittype import VehicleType + +from game.transfers import RoadTransferOrder +from game.unitmap import UnitMap + +if TYPE_CHECKING: + from game import Game + + +class ConvoyGenerator: + def __init__(self, mission: Mission, game: Game, unit_map: UnitMap) -> None: + self.mission = mission + self.game = game + self.unit_map = unit_map + self.count = itertools.count() + + def generate(self) -> None: + # Reset the count to make generation deterministic. + self.count = itertools.count() + for transfer in self.game.transfers: + self.generate_convoy_for(transfer) + + def generate_convoy_for(self, transfer: RoadTransferOrder) -> None: + # TODO: Add convoy spawn points to campaign so these can start on/near a road. + # Groups that start with an on-road waypoint that are not on a road will move to + # the road one at a time. Spawning them arbitrarily at the control point spawns + # them on the runway (or in a FOB structure) and they'll take forever to get to + # a road. + origin = transfer.position.position + next_hop = transfer.path()[0] + destination = next_hop.position + + group = self._create_mixed_unit_group( + f"Convoy {next(self.count)}", + origin, + transfer.units, + transfer.player, + ) + group.add_waypoint(destination, move_formation=PointAction.OnRoad) + self.make_drivable(group) + self.unit_map.add_convoy_units(group, transfer) + + def _create_mixed_unit_group( + self, + name: str, + position: Point, + units: Dict[Type[VehicleType], int], + for_player: bool, + ) -> VehicleGroup: + country = self.mission.country( + self.game.player_country if for_player else self.game.enemy_country + ) + + unit_types = list(units.items()) + main_unit_type, main_unit_count = unit_types[0] + + group = self.mission.vehicle_group( + country, + name, + main_unit_type, + position=position, + group_size=main_unit_count, + move_formation=PointAction.OnRoad, + ) + + unit_name_counter = itertools.count(main_unit_count + 1) + # pydcs spreads units out by 20 in the Y axis by default. Pick up where it left + # off. + y = itertools.count(position.y + main_unit_count * 20, 20) + for unit_type, count in unit_types[1:]: + for i in range(count): + v = self.mission.vehicle( + f"{name} Unit #{next(unit_name_counter)}", unit_type + ) + v.position.x = position.x + v.position.y = next(y) + v.heading = 0 + group.add_unit(v) + + return group + + @staticmethod + def make_drivable(group: VehicleGroup) -> None: + for v in group.units: + if isinstance(v, Vehicle): + v.player_can_drive = True