dcs-retribution/game/campaignloader/mizcampaignloader.py
Eclipse/Druss99 31c80dfd02 refactor of previous commits
refactor to enum

typing and many other fixes

fix tests

attempt to fix some typescript

more typescript fixes

more typescript test fixes

revert all API changes

update to pydcs

mypy fixes

Use properties to check if player is blue/red/neutral

update requirements.txt

black -_-

bump pydcs and fix mypy

add opponent property

bump pydcs
2025-10-19 19:34:38 +02:00

703 lines
27 KiB
Python

from __future__ import annotations
import itertools
from functools import cached_property
from pathlib import Path
from typing import Iterator, List, TYPE_CHECKING, Tuple, Optional
from uuid import UUID
from dcs import Mission
from dcs.countries import CombinedJointTaskForcesBlue, CombinedJointTaskForcesRed
from dcs.country import Country
from dcs.planes import F_15C, A_10A, AJS37, C_130
from dcs.ships import HandyWind, LHA_Tarawa, Stennis, USS_Arleigh_Burke_IIa
from dcs.statics import Fortification, Warehouse
from dcs.terrain import Airport
from dcs.unitgroup import PlaneGroup, ShipGroup, StaticGroup, VehicleGroup
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
from game.controlpoint_influenceradius import ControlPointInfluenceRadius, point_in_zone
from game.point_with_heading import PointWithHeading
from game.positioned import Positioned
from game.profiling import logged_duration
from game.scenery_group import SceneryGroup
from game.theater.controlpoint import (
Airfield,
Carrier,
ControlPoint,
ControlPointType,
Player,
Fob,
Lha,
OffMapSpawn,
)
from game.theater.presetlocation import PresetLocation
from game.utils import Distance, meters, feet, Heading
if TYPE_CHECKING:
from game.theater.conflicttheater import ConflictTheater
from dcs import Point
class MizCampaignLoader:
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
RED_COUNTRY = CombinedJointTaskForcesRed()
OFF_MAP_UNIT_TYPE = F_15C.id
GROUND_SPAWN_UNIT_TYPE = A_10A.id
GROUND_SPAWN_ROADBASE_UNIT_TYPE = AJS37.id
GROUND_SPAWN_LARGE_UNIT_TYPE = C_130.id
CV_UNIT_TYPE = Stennis.id
LHA_UNIT_TYPE = LHA_Tarawa.id
FRONT_LINE_UNIT_TYPE = Armor.M_113.id
SHIPPING_LANE_UNIT_TYPE = HandyWind.id
CP_CONVOY_SPAWN_TYPE = Armor.M1043_HMMWV_Armament.id
FOB_UNIT_TYPE = Unarmed.SKP_11.id
FARP_HELIPADS_TYPE = ["Invisible FARP", "SINGLE_HELIPAD", "FARP"]
INVISIBLE_FOB_UNIT_TYPE = Unarmed.M_818.id
OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id
SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id
MISSILE_SITE_UNIT_TYPE = MissilesSS.Scud_B.id
COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.hy_launcher.id
COMMAND_CENTER_UNIT_TYPE = Fortification._Command_Center.id
CONNECTION_NODE_UNIT_TYPE = Fortification.Comms_tower_M.id
POWER_SOURCE_UNIT_TYPE = Fortification.GeneratorF.id
# Multiple options for air defenses so campaign designers can more accurately see
# the coverage of their IADS for the expected type.
LONG_RANGE_SAM_UNIT_TYPES = {
AirDefence.Patriot_ln.id,
AirDefence.S_300PS_5P85C_ln.id,
AirDefence.S_300PS_5P85D_ln.id,
}
MEDIUM_RANGE_SAM_UNIT_TYPES = {
AirDefence.Hawk_ln.id,
AirDefence.S_75M_Volhov.id,
AirDefence.x_5p73_s_125_ln.id,
AirDefence.NASAMS_LN_B.id,
AirDefence.NASAMS_LN_C.id,
}
SHORT_RANGE_SAM_UNIT_TYPES = {
AirDefence.M1097_Avenger.id,
AirDefence.rapier_fsa_launcher.id,
AirDefence.x_2S6_Tunguska.id,
AirDefence.Strela_1_9P31.id,
}
AAA_UNIT_TYPES = {
AirDefence.flak18.id,
AirDefence.Vulcan.id,
AirDefence.ZSU_23_4_Shilka.id,
}
EWR_UNIT_TYPE = AirDefence.x_1L13_EWR.id
ARMOR_GROUP_UNIT_TYPE = Armor.M_1_Abrams.id
FACTORY_UNIT_TYPE = Fortification.Workshop_A.id
AMMUNITION_DEPOT_UNIT_TYPE = Warehouse._Ammunition_depot.id
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
GROUND_SPAWN_WAYPOINT_DISTANCE = 1000
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
self.theater = theater
self.mission = Mission()
with logged_duration("Loading miz"):
self.mission.load_file(str(miz))
# If there are no red carriers there usually aren't red units. Make sure
# both countries are initialized so we don't have to deal with None.
if self.mission.country(self.BLUE_COUNTRY.name) is None:
self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY)
if self.mission.country(self.RED_COUNTRY.name) is None:
self.mission.coalition["red"].add_country(self.RED_COUNTRY)
def control_point_from_airport(
self, airport: Airport, ctld_zones: List[Tuple[Point, float]]
) -> ControlPoint:
if airport.dynamic_spawn:
starting_coalition = Player.NEUTRAL
elif airport.is_blue():
starting_coalition = Player.BLUE
else:
starting_coalition = Player.RED
cp = Airfield(airport, self.theater, starting_coalition, ctld_zones=ctld_zones)
# Use the unlimited aircraft option to determine if an airfield should
# be owned by the player when the campaign is "inverted".
cp.captured_invert = airport.unlimited_aircrafts
return cp
def country(self, blue: Player) -> Country:
if blue.is_blue:
country = self.mission.country(self.BLUE_COUNTRY.name)
else:
country = self.mission.country(self.RED_COUNTRY.name)
# Should be guaranteed because we initialized them.
assert country
return country
@property
def blue(self) -> Country:
return self.country(blue=Player.BLUE)
@property
def red(self) -> Country:
return self.country(blue=Player.RED)
def off_map_spawns(self, blue: Player) -> Iterator[PlaneGroup]:
for group in self.country(blue).plane_group:
if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
yield group
def carriers(self, blue: Player) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.CV_UNIT_TYPE:
yield group
def lhas(self, blue: Player) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.LHA_UNIT_TYPE:
yield group
def fobs(self, blue: Player) -> Iterator[VehicleGroup]:
for group in self.country(blue).vehicle_group:
if group.units[0].type == self.FOB_UNIT_TYPE:
yield group
def invisible_fobs(self, blue: Player) -> Iterator[VehicleGroup]:
for group in self.country(blue).vehicle_group:
if group.units[0].type == self.INVISIBLE_FOB_UNIT_TYPE:
yield group
@property
def ships(self) -> Iterator[ShipGroup]:
for group in self.red.ship_group:
if group.units[0].type == self.SHIP_UNIT_TYPE:
yield group
@property
def offshore_strike_targets(self) -> Iterator[StaticGroup]:
for group in self.red.static_group:
if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE:
yield group
@property
def missile_sites(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE:
yield group
@property
def coastal_defenses(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
yield group
@property
def long_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES:
yield group
@property
def medium_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES:
yield group
@property
def short_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES:
yield group
@property
def aaa(self) -> Iterator[VehicleGroup]:
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
if group.units[0].type in self.AAA_UNIT_TYPES:
yield group
@property
def ewrs(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.EWR_UNIT_TYPE:
yield group
@property
def armor_groups(self) -> Iterator[VehicleGroup]:
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE:
yield group
@property
def helipads(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.FARP_HELIPADS_TYPE:
yield group
@property
def ground_spawns_roadbase(self) -> Iterator[PlaneGroup]:
for group in itertools.chain(self.blue.plane_group, self.red.plane_group):
if group.units[0].type == self.GROUND_SPAWN_ROADBASE_UNIT_TYPE:
yield group
@property
def ground_spawns_large(self) -> Iterator[PlaneGroup]:
for group in itertools.chain(self.blue.plane_group, self.red.plane_group):
if group.units[0].type == self.GROUND_SPAWN_LARGE_UNIT_TYPE:
yield group
@property
def ground_spawns(self) -> Iterator[PlaneGroup]:
for group in itertools.chain(self.blue.plane_group, self.red.plane_group):
if group.units[0].type == self.GROUND_SPAWN_UNIT_TYPE:
yield group
@property
def factories(self) -> Iterator[StaticGroup]:
for group in self.blue.static_group:
if group.units[0].type in self.FACTORY_UNIT_TYPE:
yield group
@property
def ammunition_depots(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE:
yield group
@property
def strike_targets(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE:
yield group
@property
def scenery(self) -> List[SceneryGroup]:
return SceneryGroup.from_trigger_zones(self.mission.triggers._zones)
@cached_property
def cp_influence_zones(self) -> List[ControlPointInfluenceRadius]:
return ControlPointInfluenceRadius.from_trigger_zones(
self.mission.triggers._zones
)
@cached_property
def control_points(self) -> dict[UUID, ControlPoint]:
control_points = {}
for airport in self.mission.terrain.airport_list():
if airport.is_blue() or airport.is_red() or airport.is_neutral():
ctld_zones = self.get_ctld_zones(airport.name)
control_point = self.control_point_from_airport(airport, ctld_zones)
control_points[control_point.id] = control_point
for blue in (Player.RED, Player.BLUE):
for group in self.off_map_spawns(blue):
control_point = OffMapSpawn(
str(group.name), group.position, self.theater, starts_blue=blue
)
control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point
for ship in self.carriers(blue):
control_point = Carrier(
ship.name, ship.position, self.theater, starts_blue=blue
)
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for ship in self.lhas(blue):
control_point = Lha(
ship.name, ship.position, self.theater, starts_blue=blue
)
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for fob in self.fobs(blue):
ctld_zones = self.get_ctld_zones(fob.name)
control_point = Fob(
str(fob.name),
fob.position,
self.theater,
starts_blue=blue,
ctld_zones=ctld_zones,
)
control_point.captured_invert = fob.late_activation
control_points[control_point.id] = control_point
for fob in self.invisible_fobs(blue):
ctld_zones = self.get_ctld_zones(fob.name)
control_point = Fob(
str(fob.name),
fob.position,
self.theater,
starts_blue=blue,
ctld_zones=ctld_zones,
is_invisible=True,
)
control_point.captured_invert = fob.late_activation
control_points[control_point.id] = control_point
if self.cp_influence_zones:
for cp in control_points.values():
for influence_radius in self.cp_influence_zones:
if cp.full_name == influence_radius.cp_name:
cp.influence_radius = influence_radius
return control_points
@property
def front_line_path_groups(self) -> Iterator[VehicleGroup]:
for group in self.country(blue=Player.BLUE).vehicle_group:
if group.units[0].type == self.FRONT_LINE_UNIT_TYPE:
yield group
@property
def shipping_lane_groups(self) -> Iterator[ShipGroup]:
for group in self.country(blue=Player.BLUE).ship_group:
if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE:
yield group
@property
def iads_command_centers(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.COMMAND_CENTER_UNIT_TYPE:
yield group
@property
def iads_connection_nodes(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.CONNECTION_NODE_UNIT_TYPE:
yield group
@property
def iads_power_sources(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.POWER_SOURCE_UNIT_TYPE:
yield group
@property
def cp_convoy_spawns(self) -> Iterator[VehicleGroup]:
for group in self.country(blue=Player.BLUE).vehicle_group:
if group.units[0].type == self.CP_CONVOY_SPAWN_TYPE:
yield group
def _construct_cp_spawnpoints(self, start: Point) -> Tuple[Point, ...]:
closest = self._find_closest_cp_spawn(start)
if closest:
return self._interpolate_points(closest, start)
return tuple()
@staticmethod
def _interpolate_points(closest: VehicleGroup, start: Point) -> Tuple[Point, ...]:
points = [start]
waypoints = points + [wpt.position for wpt in closest.points]
last = waypoints[0]
meters100ft = feet(100).meters
residual = 0.0
for wpt in waypoints[1:]:
dist = wpt.distance_to_point(last)
fraction = meters100ft / dist # 100ft separation
interpol_count = int((dist + residual) / meters100ft - 1)
offset = (meters100ft - residual) / dist
if offset <= 1:
points.append(last.lerp(wpt, offset))
if offset + fraction <= 1:
for i in range(1, interpol_count + 1):
points.append(last.lerp(wpt, i * fraction + offset))
residual = (residual + dist - meters100ft * interpol_count) % meters100ft
last = wpt
return tuple(points)
def _find_closest_cp_spawn(self, start: Point) -> Optional[VehicleGroup]:
closest: Optional[VehicleGroup] = None
for spawn in self.cp_convoy_spawns:
if closest is None:
closest = spawn
continue
dist = start.distance_to_point(closest.position)
if start.distance_to_point(spawn.position) < dist:
closest = spawn
return closest
@staticmethod
def _add_helipad(helipads: list[PointWithHeading], static: StaticGroup) -> None:
helipads.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
def _add_ground_spawn(
self,
ground_spawns: list[tuple[PointWithHeading, Point]],
plane_group: PlaneGroup,
) -> None:
if len(plane_group.points) >= 2:
first_waypoint = plane_group.points[1].position
else:
first_waypoint = plane_group.position.point_from_heading(
plane_group.units[0].heading,
self.GROUND_SPAWN_WAYPOINT_DISTANCE,
)
ground_spawns.append(
(
PointWithHeading.from_point(
plane_group.position,
Heading.from_degrees(plane_group.units[0].heading),
),
first_waypoint,
)
)
def add_supply_routes(self) -> None:
for group in self.front_line_path_groups:
# The unit will have its first waypoint at the source CP and the final
# waypoint at the destination CP. Each waypoint defines the path of the
# cargo ship.
waypoints = [p.position for p in group.points]
origin = self.theater.closest_control_point(waypoints[0])
if origin is None:
raise RuntimeError(
f"No control point near the first waypoint of {group.name}"
)
destination = self.theater.closest_control_point(waypoints[-1])
if destination is None:
raise RuntimeError(
f"No control point near the final waypoint of {group.name}"
)
o_spawns = self._construct_cp_spawnpoints(waypoints[0])
d_spawns = self._construct_cp_spawnpoints(waypoints[-1])
self.control_points[origin.id].create_convoy_route(
destination, waypoints, o_spawns
)
self.control_points[destination.id].create_convoy_route(
origin, list(reversed(waypoints)), d_spawns
)
def add_shipping_lanes(self) -> None:
for group in self.shipping_lane_groups:
# The unit will have its first waypoint at the source CP and the final
# waypoint at the destination CP. Each waypoint defines the path of the
# cargo ship.
waypoints = [p.position for p in group.points]
origin = self.theater.closest_control_point(waypoints[0])
if origin is None:
raise RuntimeError(
f"No control point near the first waypoint of {group.name}"
)
destination = self.theater.closest_control_point(waypoints[-1])
if destination is None:
raise RuntimeError(
f"No control point near the final waypoint of {group.name}"
)
self.control_points[origin.id].create_shipping_lane(destination, waypoints)
self.control_points[destination.id].create_shipping_lane(
origin, list(reversed(waypoints))
)
def objective_info(
self, near: Positioned, allow_naval: bool = False
) -> Tuple[ControlPoint, Distance]:
zones_containing_point = [
z
for z in self.cp_influence_zones
if point_in_zone(z.zone_def, near.position)
]
# Ensure we only consider naval control points if allow_naval is True
candidates = [
self.theater.control_point_named(z.cp_name) for z in zones_containing_point
]
if not allow_naval:
candidates = [
cp
for cp in candidates
if cp.cptype
not in [
ControlPointType.AIRCRAFT_CARRIER_GROUP,
ControlPointType.LHA_GROUP,
]
]
if candidates:
closest = min(
candidates, key=lambda cp: cp.position.distance_to_point(near.position)
)
distance = meters(closest.position.distance_to_point(near.position))
return closest, distance
# If no zones contain the point, find the closest control point without an influence radius
if not allow_naval:
fallback_candidates = [
cp
for cp in self.theater.controlpoints
if cp.cptype
not in [
ControlPointType.AIRCRAFT_CARRIER_GROUP,
ControlPointType.LHA_GROUP,
]
]
else:
fallback_candidates = self.theater.controlpoints
fallback_candidates = [
cp for cp in fallback_candidates if not cp.influence_radius
]
if not fallback_candidates:
raise RuntimeError(
f"All control points have an influence zone but no zones contain {near} at {near.position}"
)
closest = min(
fallback_candidates,
key=lambda cp: cp.position.distance_to_point(near.position),
)
distance = meters(closest.position.distance_to_point(near.position))
return closest, distance
def add_preset_locations(self) -> None:
for static in self.offshore_strike_targets:
closest, distance = self.objective_info(static)
closest.preset_locations.offshore_strike_locations.append(
PresetLocation.from_group(static)
)
for ship in self.ships:
closest, distance = self.objective_info(ship, allow_naval=True)
closest.preset_locations.ships.append(PresetLocation.from_group(ship))
for group in self.missile_sites:
closest, distance = self.objective_info(group)
closest.preset_locations.missile_sites.append(
PresetLocation.from_group(group)
)
for group in self.coastal_defenses:
closest, distance = self.objective_info(group)
closest.preset_locations.coastal_defenses.append(
PresetLocation.from_group(group)
)
for group in self.long_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.long_range_sams.append(
PresetLocation.from_group(group)
)
for group in self.medium_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.medium_range_sams.append(
PresetLocation.from_group(group)
)
for group in self.short_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.short_range_sams.append(
PresetLocation.from_group(group)
)
for group in self.aaa:
closest, distance = self.objective_info(group)
closest.preset_locations.aaa.append(PresetLocation.from_group(group))
for group in self.ewrs:
closest, distance = self.objective_info(group)
closest.preset_locations.ewrs.append(PresetLocation.from_group(group))
for group in self.armor_groups:
closest, distance = self.objective_info(group)
closest.preset_locations.armor_groups.append(
PresetLocation.from_group(group)
)
for static in self.helipads:
closest, distance = self.objective_info(static)
if static.units[0].type == "SINGLE_HELIPAD":
self._add_helipad(closest.helipads, static)
elif static.units[0].type == "FARP":
self._add_helipad(closest.helipads_quad, static)
else:
self._add_helipad(closest.helipads_invisible, static)
for plane_group in self.ground_spawns_roadbase:
closest, distance = self.objective_info(plane_group)
self._add_ground_spawn(closest.ground_spawns_roadbase, plane_group)
for plane_group in self.ground_spawns_large:
closest, distance = self.objective_info(plane_group)
self._add_ground_spawn(closest.ground_spawns_large, plane_group)
for plane_group in self.ground_spawns:
closest, distance = self.objective_info(plane_group)
self._add_ground_spawn(closest.ground_spawns, plane_group)
for static in self.factories:
closest, distance = self.objective_info(static)
closest.preset_locations.factories.append(PresetLocation.from_group(static))
for static in self.ammunition_depots:
closest, distance = self.objective_info(static)
closest.preset_locations.ammunition_depots.append(
PresetLocation.from_group(static)
)
for static in self.strike_targets:
closest, distance = self.objective_info(static)
closest.preset_locations.strike_locations.append(
PresetLocation.from_group(static)
)
for iads_command_center in self.iads_command_centers:
closest, distance = self.objective_info(iads_command_center)
closest.preset_locations.iads_command_center.append(
PresetLocation.from_group(iads_command_center)
)
for iads_connection_node in self.iads_connection_nodes:
closest, distance = self.objective_info(iads_connection_node)
closest.preset_locations.iads_connection_node.append(
PresetLocation.from_group(iads_connection_node)
)
for iads_power_source in self.iads_power_sources:
closest, distance = self.objective_info(iads_power_source)
closest.preset_locations.iads_power_source.append(
PresetLocation.from_group(iads_power_source)
)
for scenery_group in self.scenery:
closest, distance = self.objective_info(scenery_group)
closest.preset_locations.scenery.append(scenery_group)
def populate_theater(self) -> None:
for control_point in self.control_points.values():
self.theater.add_controlpoint(control_point)
self.add_preset_locations()
self.add_supply_routes()
self.add_shipping_lanes()
self.add_rebel_zones()
def get_ctld_zones(self, prefix: str) -> List[Tuple[Point, float]]:
zones = [t for t in self.mission.triggers.zones() if prefix + " CTLD" in t.name]
for z in zones:
self.mission.triggers.zones().remove(z)
return [(z.position, z.radius) for z in zones]
def add_rebel_zones(self) -> None:
zones = [
t for t in self.mission.triggers.zones() if t.name.startswith("Rebels")
]
for z in zones:
self.mission.triggers.zones().remove(z)
self.theater.add_rebel_zones(zones)