mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
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
405 lines
16 KiB
Python
405 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import random
|
|
from datetime import datetime
|
|
from typing import Any, Optional, TYPE_CHECKING
|
|
|
|
from dcs import Mission, Point
|
|
from dcs.action import DoScript
|
|
from dcs.flyingunit import FlyingUnit
|
|
from dcs.task import OptReactOnThreat
|
|
from dcs.translation import String
|
|
from dcs.triggers import TriggerStart
|
|
from dcs.unit import Skill
|
|
from dcs.unitgroup import FlyingGroup
|
|
|
|
from game.ato import Flight, FlightType
|
|
from game.ato.flightplans.shiprecoverytanker import RecoveryTankerFlightPlan
|
|
from game.callsigns import callsign_for_support_unit
|
|
from game.data.weapons import Pylon, WeaponType
|
|
from game.missiongenerator.logisticsgenerator import LogisticsGenerator
|
|
from game.missiongenerator.missiondata import MissionData, AwacsInfo, TankerInfo
|
|
from game.radio.radios import RadioFrequency, RadioRegistry
|
|
from game.radio.tacan import (
|
|
TacanBand,
|
|
TacanRegistry,
|
|
TacanUsage,
|
|
OutOfTacanChannelsError,
|
|
)
|
|
from game.runways import RunwayData
|
|
from game.squadrons import Pilot
|
|
from .aircraftbehavior import AircraftBehavior
|
|
from .aircraftpainter import AircraftPainter
|
|
from .bingoestimator import BingoEstimator
|
|
from .flightdata import FlightData
|
|
from .waypoints import WaypointGenerator
|
|
from ...ato.flightmember import FlightMember
|
|
from ...ato.flightplans.aewc import AewcFlightPlan
|
|
from ...ato.flightplans.packagerefueling import PackageRefuelingFlightPlan
|
|
from ...ato.flightplans.theaterrefueling import TheaterRefuelingFlightPlan
|
|
from ...radio.datalink import (
|
|
DataLinkRegistry,
|
|
DataLinkKey,
|
|
DataLinkIdentifier,
|
|
VOICE_CALLSIGN_LABEL,
|
|
VOICE_CALLSIGN_NUMBER,
|
|
OWNSHIP_CALLSIGN,
|
|
)
|
|
from ...theater import Fob
|
|
|
|
if TYPE_CHECKING:
|
|
from game import Game
|
|
|
|
|
|
class FlightGroupConfigurator:
|
|
def __init__(
|
|
self,
|
|
flight: Flight,
|
|
group: FlyingGroup[Any],
|
|
game: Game,
|
|
mission: Mission,
|
|
time: datetime,
|
|
radio_registry: RadioRegistry,
|
|
tacan_registry: TacanRegistry,
|
|
datalink_registry: DataLinkRegistry,
|
|
mission_data: MissionData,
|
|
dynamic_runways: dict[str, RunwayData],
|
|
use_client: bool,
|
|
) -> None:
|
|
self.flight = flight
|
|
self.group = group
|
|
self.game = game
|
|
self.mission = mission
|
|
self.time = time
|
|
self.radio_registry = radio_registry
|
|
self.tacan_registry = tacan_registry
|
|
self.datalink_registry = datalink_registry
|
|
self.mission_data = mission_data
|
|
self.dynamic_runways = dynamic_runways
|
|
self.use_client = use_client
|
|
|
|
def configure(self) -> FlightData:
|
|
flight_channel = self.setup_radios()
|
|
AircraftBehavior(self.flight.flight_type, self.mission_data).apply_to(
|
|
self.flight, self.group
|
|
)
|
|
AircraftPainter(self.flight, self.group).apply_livery()
|
|
self.setup_props()
|
|
self.setup_payloads()
|
|
self.setup_fuel()
|
|
|
|
laser_codes: list[Optional[int]] = []
|
|
for unit, member in zip(self.group.units, self.flight.iter_members()):
|
|
self.configure_flight_member(unit, member, laser_codes)
|
|
|
|
divert = None
|
|
if self.flight.divert is not None:
|
|
divert = self.flight.divert.active_runway(
|
|
self.game.theater, self.game.conditions, self.dynamic_runways
|
|
)
|
|
|
|
if self.flight.flight_type in [
|
|
FlightType.TRANSPORT,
|
|
FlightType.AIR_ASSAULT,
|
|
] and self.game.settings.plugin_option("ctld"):
|
|
transfer = None
|
|
if self.flight.flight_type == FlightType.TRANSPORT:
|
|
coalition = self.game.coalition_for(player=self.flight.blue)
|
|
transfer = coalition.transfers.transfer_for_flight(self.flight)
|
|
self.mission_data.logistics.append(
|
|
LogisticsGenerator(
|
|
self.flight, self.group, self.mission, self.game.settings, transfer
|
|
).generate_logistics()
|
|
)
|
|
|
|
mission_start_time, waypoints = WaypointGenerator(
|
|
self.flight,
|
|
self.group,
|
|
self.mission,
|
|
self.time,
|
|
self.game.settings,
|
|
self.mission_data,
|
|
).create_waypoints()
|
|
|
|
# Special handling for landing waypoints when:
|
|
# 1. It's an AI-only flight
|
|
# 2. Aircraft are not helicopters/VTOL
|
|
# 3. Landing waypoint does not point to an airfield
|
|
if (
|
|
self.flight.client_count < 1
|
|
and not self.flight.unit_type.helicopter
|
|
and not self.flight.unit_type.lha_capable
|
|
and isinstance(self.flight.squadron.location, Fob)
|
|
):
|
|
# Need to set uncontrolled to false, otherwise the AI will skip the mission and just land
|
|
self.group.uncontrolled = False
|
|
|
|
divert_position: Point | None = None
|
|
if self.flight.divert is not None:
|
|
divert_position = self.flight.divert.position
|
|
bingo_estimator = BingoEstimator(
|
|
self.flight.unit_type.fuel_consumption,
|
|
self.flight.arrival.position,
|
|
divert_position,
|
|
self.flight.flight_plan.waypoints,
|
|
)
|
|
|
|
return FlightData(
|
|
package=self.flight.package,
|
|
aircraft_type=self.flight.unit_type,
|
|
squadron=self.flight.squadron,
|
|
flight_type=self.flight.flight_type,
|
|
units=self.group.units,
|
|
size=len(self.group.units),
|
|
friendly=self.flight.departure.captured,
|
|
departure_delay=mission_start_time,
|
|
departure=self.flight.departure.active_runway(
|
|
self.game.theater, self.game.conditions, self.dynamic_runways
|
|
),
|
|
arrival=self.flight.arrival.active_runway(
|
|
self.game.theater, self.game.conditions, self.dynamic_runways
|
|
),
|
|
divert=divert,
|
|
waypoints=waypoints,
|
|
intra_flight_channel=flight_channel,
|
|
bingo_fuel=bingo_estimator.estimate_bingo(),
|
|
joker_fuel=bingo_estimator.estimate_joker(),
|
|
custom_name=self.flight.custom_name,
|
|
laser_codes=laser_codes,
|
|
)
|
|
|
|
def configure_flight_member(
|
|
self, unit: FlyingUnit, member: FlightMember, laser_codes: list[Optional[int]]
|
|
) -> None:
|
|
self.set_skill(unit, member)
|
|
|
|
if (code := member.tgp_laser_code) is not None:
|
|
laser_codes.append(code.code)
|
|
else:
|
|
laser_codes.append(None)
|
|
|
|
self.handle_ew_jamming(member, unit)
|
|
|
|
def handle_ew_jamming(self, member: FlightMember, unit: FlyingUnit) -> None:
|
|
if not member.is_player:
|
|
return
|
|
settings = self.flight.coalition.game.settings
|
|
if not settings.plugin_option("ewrj"):
|
|
return
|
|
# Check if ecm_required option is enabled
|
|
jammer_required = settings.plugin_option("ewrj.ecm_required")
|
|
offensive_jammer = member.loadout.has_weapon_of_type(
|
|
WeaponType.OFFENSIVE_JAMMER
|
|
)
|
|
offensive_inbuilt = self.flight.squadron.aircraft.has_built_in_jamming
|
|
has_jammer = (
|
|
member.loadout.has_weapon_of_type(WeaponType.JAMMER) or offensive_jammer
|
|
)
|
|
built_in_jammer = (
|
|
self.flight.squadron.aircraft.has_built_in_ecm or offensive_inbuilt
|
|
)
|
|
if jammer_required and not (has_jammer or built_in_jammer):
|
|
return
|
|
# Create the original ewrj_menu_trigger for player flight members
|
|
ewrj_menu_trigger = TriggerStart(comment=f"EWRJ-{unit.name}")
|
|
ewrj_menu_trigger.add_action(DoScript(String(f'EWJamming("{unit.name}")')))
|
|
self.mission.triggerrules.triggers.append(ewrj_menu_trigger)
|
|
self.group.points[0].tasks[0] = OptReactOnThreat(
|
|
OptReactOnThreat.Values.PassiveDefense
|
|
)
|
|
# Create LUA Flags for Offensive Jamming in EW Script for Player Flights
|
|
if not (offensive_jammer or offensive_inbuilt):
|
|
return
|
|
ewrj_offensive_trigger = TriggerStart(
|
|
comment=f"Offensive Jammer Flag {unit.name}"
|
|
)
|
|
ewrj_offensive_trigger.add_action(
|
|
DoScript(
|
|
String(
|
|
f'trigger.action.setUserFlag("offensive_jamming_{unit.name}", 1)'
|
|
)
|
|
)
|
|
)
|
|
self.mission.triggerrules.triggers.append(ewrj_offensive_trigger)
|
|
|
|
def setup_radios(self) -> RadioFrequency:
|
|
freq = self.flight.frequency
|
|
if freq is None and (freq := self.flight.package.frequency) is None:
|
|
freq = self.radio_registry.alloc_uhf()
|
|
self.flight.package.frequency = freq
|
|
if freq not in self.radio_registry.allocated_channels:
|
|
self.radio_registry.reserve(freq)
|
|
|
|
if self.flight.flight_type in {
|
|
FlightType.AEWC,
|
|
FlightType.REFUELING,
|
|
FlightType.RECOVERY,
|
|
}:
|
|
self.register_air_support(freq)
|
|
elif self.flight.client_count and self.flight.squadron.radio_presets:
|
|
freq = self.flight.squadron.radio_presets["intra_flight"][0]
|
|
elif self.flight.frequency is None and self.flight.client_count:
|
|
freq = self.flight.unit_type.alloc_flight_radio(self.radio_registry)
|
|
|
|
self.group.set_frequency(freq.mhz)
|
|
return freq
|
|
|
|
def register_air_support(self, channel: RadioFrequency) -> None:
|
|
callsign = callsign_for_support_unit(self.group)
|
|
if isinstance(self.flight.flight_plan, AewcFlightPlan):
|
|
self.mission_data.awacs.append(
|
|
AwacsInfo(
|
|
group_name=str(self.group.name),
|
|
callsign=callsign,
|
|
freq=channel,
|
|
depature_location=self.flight.departure.name,
|
|
start_time=self.flight.flight_plan.patrol_start_time,
|
|
end_time=self.flight.flight_plan.patrol_end_time,
|
|
blue=self.flight.departure.captured,
|
|
unit=self.group.units[0],
|
|
)
|
|
)
|
|
elif (
|
|
isinstance(self.flight.flight_plan, TheaterRefuelingFlightPlan)
|
|
or isinstance(self.flight.flight_plan, PackageRefuelingFlightPlan)
|
|
or isinstance(self.flight.flight_plan, RecoveryTankerFlightPlan)
|
|
):
|
|
tacan = self.flight.tacan
|
|
if tacan is None and self.flight.squadron.aircraft.dcs_unit_type.tacan:
|
|
try:
|
|
tacan = self.tacan_registry.alloc_for_band(
|
|
TacanBand.Y, TacanUsage.AirToAir
|
|
)
|
|
except OutOfTacanChannelsError:
|
|
tacan = random.choice(list(self.tacan_registry.allocated_channels))
|
|
else:
|
|
tacan = self.flight.tacan
|
|
self.mission_data.tankers.append(
|
|
TankerInfo(
|
|
group_name=str(self.group.name),
|
|
callsign=callsign,
|
|
variant=self.flight.unit_type.display_name,
|
|
freq=channel,
|
|
tacan=tacan,
|
|
start_time=self.flight.flight_plan.patrol_start_time,
|
|
end_time=self.flight.flight_plan.patrol_end_time,
|
|
blue=self.flight.departure.captured,
|
|
)
|
|
)
|
|
|
|
def set_skill(self, unit: FlyingUnit, member: FlightMember) -> None:
|
|
if not member.is_player:
|
|
unit.skill = self.skill_level_for(unit, member.pilot)
|
|
return
|
|
|
|
if self.use_client or "Pilot #1" not in unit.name:
|
|
unit.set_client()
|
|
else:
|
|
unit.set_player()
|
|
|
|
def skill_level_for(self, unit: FlyingUnit, pilot: Optional[Pilot]) -> Skill:
|
|
if self.flight.squadron.player.is_blue:
|
|
base_skill = Skill(self.game.settings.player_skill)
|
|
else:
|
|
base_skill = Skill(self.game.settings.enemy_skill)
|
|
|
|
if pilot is None:
|
|
logging.error(f"Cannot determine skill level: {unit.name} has not pilot")
|
|
return base_skill
|
|
|
|
levels = [
|
|
Skill.Average,
|
|
Skill.Good,
|
|
Skill.High,
|
|
Skill.Excellent,
|
|
]
|
|
current_level = levels.index(base_skill)
|
|
missions_for_skill_increase = 4
|
|
increase = pilot.record.missions_flown // missions_for_skill_increase
|
|
capped_increase = min(current_level + increase, len(levels) - 1)
|
|
|
|
if self.game.settings.ai_pilot_levelling:
|
|
new_level = capped_increase
|
|
else:
|
|
new_level = current_level
|
|
|
|
return levels[new_level]
|
|
|
|
def setup_props(self) -> None:
|
|
unit: FlyingUnit
|
|
member: FlightMember
|
|
for unit, member in zip(self.group.units, self.flight.iter_members()):
|
|
props = dict(member.properties)
|
|
if (code := member.weapon_laser_code) is not None:
|
|
for laser_code_config in self.flight.unit_type.laser_code_configs:
|
|
props.update(laser_code_config.property_dict_for_code(code.code))
|
|
if unit.unit_type.datalink_networkable() and self.no_datalink_set(props):
|
|
self.set_datalink(props, unit.callsign_as_str())
|
|
for prop_id, value in props.items():
|
|
unit.set_property(prop_id, value)
|
|
|
|
@staticmethod
|
|
def no_datalink_set(props: dict[str, bool | float | int | str]) -> bool:
|
|
for type in DataLinkKey:
|
|
if type.value in props:
|
|
return False
|
|
return True
|
|
|
|
def set_datalink(
|
|
self, props: dict[str, bool | float | int | str], callsign: str
|
|
) -> None:
|
|
vcl = callsign[:-2][0] + callsign[:-2][-1]
|
|
vcn = callsign[-2:]
|
|
identifier = self.datalink_registry.alloc_for_aircraft(self.flight.unit_type)
|
|
self.set_datalink_props(props, vcl.upper(), vcn, identifier)
|
|
|
|
@staticmethod
|
|
def set_datalink_props(
|
|
props: dict[str, bool | float | int | str],
|
|
label: str,
|
|
number: str,
|
|
identifier: DataLinkIdentifier,
|
|
) -> None:
|
|
if identifier.type in [DataLinkKey.LINK16, DataLinkKey.SADL]:
|
|
if not props.get(VOICE_CALLSIGN_LABEL):
|
|
props[VOICE_CALLSIGN_LABEL] = label
|
|
if not props.get(VOICE_CALLSIGN_NUMBER):
|
|
props[VOICE_CALLSIGN_NUMBER] = number
|
|
elif identifier.type in [DataLinkKey.IDM]:
|
|
if not props.get(OWNSHIP_CALLSIGN):
|
|
props[OWNSHIP_CALLSIGN] = f"G-{identifier.id}"
|
|
props[identifier.type.value] = identifier.id
|
|
|
|
def setup_payloads(self) -> None:
|
|
for unit, member in zip(self.group.units, self.flight.iter_members()):
|
|
self.setup_payload(unit, member)
|
|
|
|
def setup_payload(self, unit: FlyingUnit, member: FlightMember) -> None:
|
|
unit.pylons.clear()
|
|
|
|
loadout = member.loadout
|
|
if self.game.settings.restrict_weapons_by_date:
|
|
loadout = loadout.degrade_for_date(self.flight.unit_type, self.game.date)
|
|
|
|
for pylon_number, weapon in loadout.pylons.items():
|
|
if weapon is None:
|
|
continue
|
|
pylon = Pylon.for_aircraft(self.flight.unit_type, pylon_number)
|
|
pylon.equip(unit, weapon)
|
|
|
|
def setup_fuel(self) -> None:
|
|
fuel = self.flight.state.estimate_fuel()
|
|
if fuel < 0:
|
|
logging.warning(
|
|
f"Flight {self.flight} is estimated to have no fuel at mission start. "
|
|
"This estimate does not account for external fuel tanks. Setting "
|
|
"starting fuel to 100kg."
|
|
)
|
|
fuel = 100
|
|
for unit, pilot in zip(self.group.units, self.flight.roster.iter_pilots()):
|
|
if pilot is not None and pilot.player:
|
|
unit.fuel = fuel
|
|
else:
|
|
unit.fuel = self.flight.fuel
|