Finish moving gen into game.

This commit is contained in:
Dan Albert
2022-02-22 00:10:31 -08:00
parent 3e08e0e8b6
commit ac80c4adc1
68 changed files with 129 additions and 149 deletions

179
game/airfields.py Normal file
View File

@@ -0,0 +1,179 @@
"""Extra airfield data that is not exposed by pydcs.
Remove once https://github.com/pydcs/dcs/issues/69 tracks getting the missing
data added to pydcs. Until then, missing data can be manually filled in here.
"""
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ClassVar, Dict, Optional, TYPE_CHECKING, Tuple
import yaml
from dcs.terrain import Airport
from game.radio.radios import RadioFrequency
from game.radio.tacan import TacanChannel
if TYPE_CHECKING:
from game.theater import ConflictTheater
@dataclass
class AtcData:
hf: RadioFrequency
vhf_fm: RadioFrequency
vhf_am: RadioFrequency
uhf: RadioFrequency
@classmethod
def from_yaml(cls, data: dict[str, Any]) -> Optional[AtcData]:
atc_data = data.get("atc")
if atc_data is None:
return None
return AtcData(
RadioFrequency.parse(atc_data["hf"]),
RadioFrequency.parse(atc_data["vhf_low"]),
RadioFrequency.parse(atc_data["vhf_high"]),
RadioFrequency.parse(atc_data["uhf"]),
)
@dataclass
class AirfieldData:
"""Additional airfield data not included in pydcs."""
#: Airfield name for the UI. Not stable.
name: str
#: pydcs airport ID
id: int
#: ICAO airport code
icao: Optional[str] = None
#: Elevation (in ft).
elevation: int = 0
#: Runway length (in ft).
runway_length: int = 0
#: TACAN channel for the airfield.
tacan: Optional[TacanChannel] = None
#: TACAN callsign
tacan_callsign: Optional[str] = None
#: VOR as a tuple of (callsign, frequency).
vor: Optional[Tuple[str, RadioFrequency]] = None
#: RSBN channel as a tuple of (callsign, channel).
rsbn: Optional[Tuple[str, int]] = None
#: Radio channels used by the airfield's ATC. Note that not all airfields
#: have ATCs.
atc: Optional[AtcData] = None
#: Dict of runway heading -> ILS tuple of (callsign, frequency).
ils: Dict[str, Tuple[str, RadioFrequency]] = field(default_factory=dict)
#: Dict of runway heading -> PRMG tuple of (callsign, channel).
prmg: Dict[str, Tuple[str, int]] = field(default_factory=dict)
#: Dict of runway heading -> outer NDB tuple of (callsign, frequency).
outer_ndb: Dict[str, Tuple[str, RadioFrequency]] = field(default_factory=dict)
#: Dict of runway heading -> inner NDB tuple of (callsign, frequency).
inner_ndb: Dict[str, Tuple[str, RadioFrequency]] = field(default_factory=dict)
_airfields: ClassVar[dict[str, dict[int, AirfieldData]]] = {}
def ils_freq(self, runway: str) -> Optional[RadioFrequency]:
ils = self.ils.get(runway)
if ils is not None:
return ils[1]
return None
@classmethod
def from_file(cls, airfield_yaml: Path) -> AirfieldData:
with airfield_yaml.open() as yaml_file:
data = yaml.safe_load(yaml_file)
tacan_channel = None
tacan_callsign = None
if (tacan := data.get("tacan")) is not None:
tacan_channel = TacanChannel.parse(tacan["channel"])
tacan_callsign = tacan["callsign"]
vor = None
if (vor_data := data.get("vor")) is not None:
vor = (vor_data["callsign"], RadioFrequency.parse(vor_data["frequency"]))
rsbn = None
if (rsbn_data := data.get("rsbn")) is not None:
rsbn = (rsbn_data["callsign"], rsbn_data["channel"])
ils = {}
prmg = {}
outer_ndb = {}
inner_ndb = {}
for name, runway_data in data.get("runways", {}).items():
if (ils_data := runway_data.get("ils")) is not None:
ils[name] = (
ils_data["callsign"],
RadioFrequency.parse(ils_data["frequency"]),
)
if (prmg_data := runway_data.get("prmg")) is not None:
prmg[name] = (prmg_data["callsign"], prmg_data["channel"])
if (outer_ndb_data := runway_data.get("outer_ndb")) is not None:
outer_ndb[name] = (
outer_ndb_data["callsign"],
RadioFrequency.parse(outer_ndb_data["frequency"]),
)
if (inner_ndb_data := runway_data.get("inner_ndb")) is not None:
inner_ndb[name] = (
inner_ndb_data["callsign"],
RadioFrequency.parse(inner_ndb_data["frequency"]),
)
return AirfieldData(
data["name"],
data["id"],
data.get("icao"),
data["elevation"],
data["runway_length"],
tacan_channel,
tacan_callsign,
vor,
rsbn,
AtcData.from_yaml(data),
ils,
prmg,
outer_ndb,
inner_ndb,
)
@classmethod
def _load_for_theater_if_needed(cls, theater: ConflictTheater) -> None:
if theater.terrain.name in cls._airfields:
return
airfields = {}
base_path = Path("resources/airfields") / theater.terrain.name
for airfield_yaml in base_path.iterdir():
data = cls.from_file(airfield_yaml)
airfields[data.id] = data
cls._airfields[theater.terrain.name] = airfields
@classmethod
def for_airport(cls, theater: ConflictTheater, airport: Airport) -> AirfieldData:
return cls._airfields[theater.terrain.name][airport.id]
@classmethod
def for_theater(cls, theater: ConflictTheater) -> Iterator[AirfieldData]:
cls._load_for_theater_if_needed(theater)
yield from cls._airfields[theater.terrain.name].values()

View File

@@ -0,0 +1,560 @@
import logging
from collections.abc import Sequence
from typing import Type
from dcs.helicopters import (
AH_1W,
AH_64A,
AH_64D,
CH_47D,
CH_53E,
Ka_50,
Mi_24P,
Mi_24V,
Mi_26,
Mi_28N,
Mi_8MT,
OH_58D,
SA342L,
SA342M,
SH_60B,
UH_1H,
UH_60A,
)
from dcs.planes import (
AJS37,
AV8BNA,
A_10A,
A_10C,
A_10C_2,
A_20G,
A_50,
An_26B,
B_17G,
B_1B,
B_52H,
Bf_109K_4,
C_101CC,
C_130,
C_17A,
E_2C,
E_3A,
FA_18C_hornet,
FW_190A8,
FW_190D9,
F_117A,
F_14A,
F_14A_135_GR,
F_14B,
F_15C,
F_15E,
F_16A,
F_16C_50,
F_4E,
F_5E_3,
F_86F_Sabre,
H_6J,
IL_76MD,
IL_78M,
I_16,
JF_17,
J_11A,
Ju_88A4,
KC130,
KC135MPRS,
KC_135,
KJ_2000,
L_39ZA,
MQ_9_Reaper,
M_2000C,
MiG_15bis,
MiG_19P,
MiG_21Bis,
MiG_23MLD,
MiG_25PD,
MiG_27K,
MiG_29A,
MiG_29G,
MiG_29S,
MiG_31,
Mirage_2000_5,
MosquitoFBMkVI,
P_47D_30,
P_47D_30bl1,
P_47D_40,
P_51D,
P_51D_30_NA,
RQ_1A_Predator,
S_3B,
S_3B_Tanker,
SpitfireLFMkIX,
SpitfireLFMkIXCW,
Su_17M4,
Su_24M,
Su_25,
Su_25T,
Su_25TM,
Su_27,
Su_30,
Su_33,
Su_34,
Tornado_GR4,
Tornado_IDS,
Tu_142,
Tu_160,
Tu_22M3,
Tu_95MS,
WingLoong_I,
Yak_40,
)
from dcs.unittype import FlyingType
from game.dcs.aircrafttype import AircraftType
from pydcs_extensions.a4ec.a4ec import A_4E_C
from pydcs_extensions.f104.f104 import VSN_F104G, VSN_F104S, VSN_F104S_AG
from pydcs_extensions.f22a.f22a import F_22A
from pydcs_extensions.hercules.hercules import Hercules
from pydcs_extensions.jas39.jas39 import JAS39Gripen, JAS39Gripen_AG
from pydcs_extensions.su57.su57 import Su_57
from pydcs_extensions.uh60l.uh60l import KC130J, UH_60L
from .flighttype import FlightType
# All aircraft lists are in priority order. Aircraft higher in the list will be
# preferred over those lower in the list.
# TODO: These lists really ought to be era (faction) dependent.
# Factions which have F-5s, F-86s, and A-4s will should prefer F-5s for CAP, but
# factions that also have F-4s should not.
# Used for CAP, Escort, and intercept if there is not a specialised aircraft available
CAP_CAPABLE = [
Su_57,
F_22A,
F_15C,
F_14B,
F_14A_135_GR,
F_14A,
Su_33,
J_11A,
Su_30,
Su_27,
MiG_29S,
F_16C_50,
FA_18C_hornet,
JF_17,
JAS39Gripen,
F_16A,
F_4E,
MiG_31,
MiG_25PD,
MiG_29G,
MiG_29A,
MiG_23MLD,
MiG_21Bis,
Mirage_2000_5,
F_15E,
M_2000C,
F_5E_3,
VSN_F104S,
VSN_F104G,
MiG_19P,
A_4E_C,
F_86F_Sabre,
MiG_15bis,
C_101CC,
L_39ZA,
P_51D_30_NA,
P_51D,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
MosquitoFBMkVI,
Bf_109K_4,
FW_190D9,
FW_190A8,
P_47D_30,
P_47D_30bl1,
P_47D_40,
I_16,
]
# Used for CAS (Close air support) and BAI (Battlefield Interdiction)
CAS_CAPABLE = [
A_10C_2,
A_10C,
Hercules,
Su_34,
Su_25TM,
Su_25T,
Su_25,
F_15E,
F_16C_50,
FA_18C_hornet,
Tornado_GR4,
Tornado_IDS,
JAS39Gripen_AG,
JF_17,
AV8BNA,
A_10A,
B_1B,
A_4E_C,
F_14B,
F_14A_135_GR,
AJS37,
Su_24M,
Su_17M4,
Su_33,
F_4E,
S_3B,
Su_30,
MiG_29S,
MiG_27K,
MiG_29A,
MiG_21Bis,
AH_64D,
AH_64A,
AH_1W,
OH_58D,
SA342M,
SA342L,
Ka_50,
Mi_28N,
Mi_24P,
Mi_24V,
Mi_8MT,
H_6J,
MiG_19P,
MiG_15bis,
M_2000C,
F_5E_3,
F_86F_Sabre,
C_101CC,
L_39ZA,
UH_1H,
VSN_F104S_AG,
VSN_F104G,
A_20G,
Ju_88A4,
P_47D_40,
P_47D_30bl1,
P_47D_30,
P_51D_30_NA,
P_51D,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
MosquitoFBMkVI,
I_16,
Bf_109K_4,
FW_190D9,
FW_190A8,
WingLoong_I,
MQ_9_Reaper,
RQ_1A_Predator,
]
# Aircraft used for SEAD and SEAD Escort tasks. Must be capable of the CAS DCS task.
SEAD_CAPABLE = [
JF_17,
F_16C_50,
FA_18C_hornet,
Tornado_IDS,
Su_25T,
Su_25TM,
F_4E,
A_4E_C,
F_14B,
F_14A_135_GR,
JAS39Gripen_AG,
AV8BNA,
Su_24M,
Su_17M4,
Su_34,
Su_30,
MiG_27K,
Tornado_GR4,
]
# Aircraft used for DEAD tasks. Must be capable of the CAS DCS task.
DEAD_CAPABLE = [
AJS37,
F_14B,
F_14A_135_GR,
JAS39Gripen_AG,
B_1B,
B_52H,
Tu_160,
Tu_95MS,
H_6J,
A_20G,
Ju_88A4,
VSN_F104S_AG,
VSN_F104G,
P_47D_40,
P_47D_30bl1,
P_47D_30,
P_51D_30_NA,
P_51D,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
MosquitoFBMkVI,
Bf_109K_4,
FW_190D9,
FW_190A8,
] + SEAD_CAPABLE
# Aircraft used for Strike mission
STRIKE_CAPABLE = [
F_117A,
B_1B,
B_52H,
Tu_160,
Tu_95MS,
Tu_22M3,
H_6J,
F_15E,
AJS37,
Tornado_GR4,
F_16C_50,
FA_18C_hornet,
AV8BNA,
JF_17,
F_16A,
F_14B,
F_14A_135_GR,
JAS39Gripen_AG,
Tornado_IDS,
Su_17M4,
Su_24M,
Su_25TM,
Su_25T,
Su_25,
Su_34,
Su_33,
Su_30,
Su_27,
MiG_29S,
MiG_29G,
MiG_29A,
F_4E,
A_10C_2,
A_10C,
S_3B,
A_4E_C,
M_2000C,
MiG_27K,
MiG_21Bis,
MiG_15bis,
F_5E_3,
F_86F_Sabre,
C_101CC,
L_39ZA,
B_17G,
A_20G,
Ju_88A4,
VSN_F104S_AG,
VSN_F104G,
P_47D_40,
P_47D_30bl1,
P_47D_30,
P_51D_30_NA,
P_51D,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
MosquitoFBMkVI,
Bf_109K_4,
FW_190D9,
FW_190A8,
]
ANTISHIP_CAPABLE = [
AJS37,
Tu_142,
Tu_22M3,
H_6J,
FA_18C_hornet,
JAS39Gripen_AG,
Su_24M,
Su_17M4,
JF_17,
Su_34,
Su_30,
Tornado_IDS,
Tornado_GR4,
AV8BNA,
S_3B,
A_20G,
Ju_88A4,
MosquitoFBMkVI,
C_101CC,
SH_60B,
]
# This list does not "inherit" from the strike list because some strike aircraft can
# only carry guided weapons, and the AI cannot do runway attack with dguided weapons.
# https://github.com/dcs-liberation/dcs_liberation/issues/1703
RUNWAY_ATTACK_CAPABLE = [
JF_17,
Su_34,
Su_30,
Tornado_IDS,
M_2000C,
H_6J,
B_1B,
B_52H,
Tu_22M3,
H_6J,
F_15E,
AJS37,
F_16C_50,
FA_18C_hornet,
AV8BNA,
JF_17,
F_16A,
F_14B,
F_14A_135_GR,
JAS39Gripen_AG,
Tornado_IDS,
Su_17M4,
Su_24M,
Su_25TM,
Su_25T,
Su_25,
Su_34,
Su_33,
Su_30,
Su_27,
MiG_29S,
MiG_29G,
MiG_29A,
F_4E,
A_10C_2,
A_10C,
S_3B,
A_4E_C,
M_2000C,
MiG_27K,
MiG_21Bis,
MiG_15bis,
F_5E_3,
F_86F_Sabre,
C_101CC,
L_39ZA,
B_17G,
A_20G,
Ju_88A4,
VSN_F104S_AG,
VSN_F104G,
P_47D_40,
P_47D_30bl1,
P_47D_30,
P_51D_30_NA,
P_51D,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
MosquitoFBMkVI,
Bf_109K_4,
FW_190D9,
FW_190A8,
]
# For any aircraft that isn't necessarily directly involved in strike
# missions in a direct combat sense, but can transport objects and infantry.
TRANSPORT_CAPABLE = [
C_17A,
Hercules,
C_130,
IL_76MD,
An_26B,
Yak_40,
CH_53E,
CH_47D,
UH_60L,
SH_60B,
UH_60A,
UH_1H,
Mi_8MT,
Mi_8MT,
Mi_26,
]
DRONES = [MQ_9_Reaper, RQ_1A_Predator, WingLoong_I]
AEWC_CAPABLE = [
E_3A,
E_2C,
A_50,
KJ_2000,
]
# Priority is given to the tankers that can carry the most fuel.
REFUELING_CAPABALE = [
KC_135,
KC135MPRS,
IL_78M,
KC130J,
KC130,
S_3B_Tanker,
]
def dcs_types_for_task(task: FlightType) -> Sequence[Type[FlyingType]]:
cap_missions = (
FlightType.BARCAP,
FlightType.INTERCEPTION,
FlightType.SWEEP,
FlightType.TARCAP,
)
if task in cap_missions:
return CAP_CAPABLE
elif task == FlightType.ANTISHIP:
return ANTISHIP_CAPABLE
elif task == FlightType.BAI:
return CAS_CAPABLE
elif task == FlightType.CAS:
return CAS_CAPABLE
elif task == FlightType.SEAD:
return SEAD_CAPABLE
elif task == FlightType.SEAD_ESCORT:
return SEAD_CAPABLE
elif task == FlightType.DEAD:
return DEAD_CAPABLE
elif task == FlightType.OCA_AIRCRAFT:
return CAS_CAPABLE
elif task == FlightType.OCA_RUNWAY:
return RUNWAY_ATTACK_CAPABLE
elif task == FlightType.STRIKE:
return STRIKE_CAPABLE
elif task == FlightType.ESCORT:
return CAP_CAPABLE
elif task == FlightType.AEWC:
return AEWC_CAPABLE
elif task == FlightType.REFUELING:
return REFUELING_CAPABALE
elif task == FlightType.TRANSPORT:
return TRANSPORT_CAPABLE
else:
logging.error(f"Unplannable flight type: {task}")
return []
def aircraft_for_task(task: FlightType) -> list[AircraftType]:
dcs_types = dcs_types_for_task(task)
types: list[AircraftType] = []
for dcs_type in dcs_types:
types.extend(AircraftType.for_dcs_type(dcs_type))
return types
def tasks_for_aircraft(aircraft: AircraftType) -> list[FlightType]:
tasks: list[FlightType] = []
for task in FlightType:
if task is FlightType.FERRY:
# Not a plannable task, so skip it.
continue
if aircraft in aircraft_for_task(task):
tasks.append(task)
return tasks

View File

@@ -0,0 +1,80 @@
"""Objective adjacency lists."""
from __future__ import annotations
from typing import Dict, Iterator, List, Optional, TYPE_CHECKING
from game.utils import Distance
if TYPE_CHECKING:
from game.theater import ConflictTheater, ControlPoint, MissionTarget
class ClosestAirfields:
"""Precalculates which control points are closes to the given target."""
def __init__(
self, target: MissionTarget, all_control_points: List[ControlPoint]
) -> None:
self.target = target
# This cache is configured once on load, so it's important that it is
# complete and deterministic to avoid different behaviors across loads.
# E.g. https://github.com/dcs-liberation/dcs_liberation/issues/819
self.closest_airfields: List[ControlPoint] = sorted(
all_control_points, key=lambda c: self.target.distance_to(c)
)
@property
def operational_airfields(self) -> Iterator[ControlPoint]:
return (c for c in self.closest_airfields if c.runway_is_operational())
def _airfields_within(
self, distance: Distance, operational: bool
) -> Iterator[ControlPoint]:
airfields = (
self.operational_airfields if operational else self.closest_airfields
)
for cp in airfields:
if cp.distance_to(self.target) < distance.meters:
yield cp
else:
break
def operational_airfields_within(
self, distance: Distance
) -> Iterator[ControlPoint]:
"""Iterates over all airfields within the given range of the target.
Note that this iterates over *all* airfields, not just friendly
airfields.
"""
return self._airfields_within(distance, operational=True)
def all_airfields_within(self, distance: Distance) -> Iterator[ControlPoint]:
"""Iterates over all airfields within the given range of the target.
Note that this iterates over *all* airfields, not just friendly
airfields.
"""
return self._airfields_within(distance, operational=False)
class ObjectiveDistanceCache:
theater: Optional[ConflictTheater] = None
closest_airfields: Dict[str, ClosestAirfields] = {}
@classmethod
def set_theater(cls, theater: ConflictTheater) -> None:
if cls.theater is not None:
cls.closest_airfields = {}
cls.theater = theater
@classmethod
def get_closest_airfields(cls, location: MissionTarget) -> ClosestAirfields:
if cls.theater is None:
raise RuntimeError("Call ObjectiveDistanceCache.set_theater before using")
if location.name not in cls.closest_airfields:
cls.closest_airfields[location.name] = ClosestAirfields(
location, cls.theater.controlpoints
)
return cls.closest_airfields[location.name]

View File

@@ -7,10 +7,10 @@ from typing import Any, List, Optional, TYPE_CHECKING
from dcs import Point
from dcs.planes import C_101CC, C_101EB, Su_33
from gen.flights.loadouts import Loadout
from game.savecompat import has_save_compat_for
from .flightroster import FlightRoster
from .flightstate import FlightState, Uninitialized
from ..savecompat import has_save_compat_for
from .loadouts import Loadout
if TYPE_CHECKING:
from game.dcs.aircrafttype import AircraftType
@@ -74,7 +74,7 @@ class Flight:
# Will be replaced with a more appropriate FlightPlan by
# FlightPlanBuilder, but an empty flight plan the flight begins with an
# empty flight plan.
from gen.flights.flightplan import FlightPlan, CustomFlightPlan
from game.ato.flightplan import FlightPlan, CustomFlightPlan
self.flight_plan: FlightPlan = CustomFlightPlan(
package=package, flight=self, custom_waypoints=[]

2133
game/ato/flightplan.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from dcs import Point
from gen.flights.traveltime import TotEstimator
from game.ato.traveltime import TotEstimator
from .flightstate import FlightState
from ..starttype import StartType

190
game/ato/loadouts.py Normal file
View File

@@ -0,0 +1,190 @@
from __future__ import annotations
import datetime
from collections.abc import Iterable
from typing import Iterator, Mapping, Optional, TYPE_CHECKING
from game.data.weapons import Pylon, Weapon, WeaponType
from game.dcs.aircrafttype import AircraftType
if TYPE_CHECKING:
from .flight import Flight
class Loadout:
def __init__(
self,
name: str,
pylons: Mapping[int, Optional[Weapon]],
date: Optional[datetime.date],
is_custom: bool = False,
) -> None:
self.name = name
# We clear unused pylon entries on initialization, but UI actions can still
# cause a pylon to be emptied, so make the optional type explicit.
self.pylons: Mapping[int, Optional[Weapon]] = {
k: v for k, v in pylons.items() if v is not None
}
self.date = date
self.is_custom = is_custom
def derive_custom(self, name: str) -> Loadout:
return Loadout(name, self.pylons, self.date, is_custom=True)
def has_weapon_of_type(self, weapon_type: WeaponType) -> bool:
for weapon in self.pylons.values():
if weapon is not None and weapon.weapon_group.type is weapon_type:
return True
return False
@staticmethod
def _fallback_for(
weapon: Weapon,
pylon: Pylon,
date: datetime.date,
skip_types: Optional[Iterable[WeaponType]] = None,
) -> Optional[Weapon]:
if skip_types is None:
skip_types = set()
for fallback in weapon.fallbacks:
if not pylon.can_equip(fallback):
continue
if not fallback.available_on(date):
continue
if fallback.weapon_group.type in skip_types:
continue
return fallback
return None
def degrade_for_date(self, unit_type: AircraftType, date: datetime.date) -> Loadout:
if self.date is not None and self.date <= date:
return Loadout(self.name, self.pylons, self.date, self.is_custom)
new_pylons = dict(self.pylons)
for pylon_number, weapon in self.pylons.items():
if weapon is None:
del new_pylons[pylon_number]
continue
if not weapon.available_on(date):
pylon = Pylon.for_aircraft(unit_type, pylon_number)
fallback = self._fallback_for(weapon, pylon, date)
if fallback is None:
del new_pylons[pylon_number]
else:
new_pylons[pylon_number] = fallback
loadout = Loadout(self.name, new_pylons, date, self.is_custom)
# If this is not a custom loadout, we should replace any LGBs with iron bombs if
# the loadout lost its TGP.
#
# If the loadout was chosen explicitly by the user, assume they know what
# they're doing. They may be coordinating buddy-lase.
if not loadout.is_custom:
loadout.replace_lgbs_if_no_tgp(unit_type, date)
return loadout
def replace_lgbs_if_no_tgp(
self, unit_type: AircraftType, date: datetime.date
) -> None:
if self.has_weapon_of_type(WeaponType.TGP):
return
new_pylons = dict(self.pylons)
for pylon_number, weapon in self.pylons.items():
if weapon is not None and weapon.weapon_group.type is WeaponType.LGB:
pylon = Pylon.for_aircraft(unit_type, pylon_number)
fallback = self._fallback_for(
weapon, pylon, date, skip_types={WeaponType.LGB}
)
if fallback is None:
del new_pylons[pylon_number]
else:
new_pylons[pylon_number] = fallback
self.pylons = new_pylons
@classmethod
def iter_for(cls, flight: Flight) -> Iterator[Loadout]:
# Dict of payload ID (numeric) to:
#
# {
# "name": The name the user set in the ME
# "pylons": List (as a dict) of dicts of:
# {"CLSID": class ID, "num": pylon number}
# "tasks": List (as a dict) of task IDs the payload is used by.
# }
payloads = flight.unit_type.dcs_unit_type.load_payloads()
for payload in payloads.values():
name = payload["name"]
pylons = payload["pylons"]
yield Loadout(
name,
{p["num"]: Weapon.with_clsid(p["CLSID"]) for p in pylons.values()},
date=None,
)
@classmethod
def default_loadout_names_for(cls, flight: Flight) -> Iterator[str]:
from game.ato.flighttype import FlightType
# This is a list of mappings from the FlightType of a Flight to the type of
# payload defined in the resources/payloads/UNIT_TYPE.lua file. A Flight has no
# concept of a PyDCS task, so COMMON_OVERRIDE cannot be used here. This is used
# in the payload editor, for setting the default loadout of an object. The left
# element is the FlightType name, and the right element is a tuple containing
# what is used in the lua file. Some aircraft differ from the standard loadout
# names, so those have been included here too. The priority goes from first to
# last - the first element in the tuple will be tried first, then the second,
# etc.
loadout_names = {t: [f"Liberation {t.value}"] for t in FlightType}
legacy_names = {
FlightType.TARCAP: ("CAP HEAVY", "CAP", "Liberation BARCAP"),
FlightType.BARCAP: ("CAP HEAVY", "CAP", "Liberation TARCAP"),
FlightType.CAS: ("CAS MAVERICK F", "CAS"),
FlightType.STRIKE: ("STRIKE",),
FlightType.ANTISHIP: ("ANTISHIP",),
FlightType.DEAD: ("DEAD",),
FlightType.SEAD: ("SEAD",),
FlightType.BAI: ("BAI",),
FlightType.OCA_RUNWAY: ("RUNWAY_ATTACK", "RUNWAY_STRIKE"),
FlightType.OCA_AIRCRAFT: ("OCA",),
}
for flight_type, names in legacy_names.items():
loadout_names[flight_type].extend(names)
# A SEAD escort typically does not need a different loadout than a regular
# SEAD flight, so fall back to SEAD if needed.
loadout_names[FlightType.SEAD_ESCORT].extend(loadout_names[FlightType.SEAD])
# Sweep and escort can fall back to TARCAP.
loadout_names[FlightType.ESCORT].extend(loadout_names[FlightType.TARCAP])
loadout_names[FlightType.SWEEP].extend(loadout_names[FlightType.TARCAP])
# Intercept can fall back to BARCAP.
loadout_names[FlightType.INTERCEPTION].extend(loadout_names[FlightType.BARCAP])
# OCA/Aircraft falls back to BAI, which falls back to CAS.
loadout_names[FlightType.BAI].extend(loadout_names[FlightType.CAS])
loadout_names[FlightType.OCA_AIRCRAFT].extend(loadout_names[FlightType.BAI])
# DEAD also falls back to BAI.
loadout_names[FlightType.DEAD].extend(loadout_names[FlightType.BAI])
# OCA/Runway falls back to Strike
loadout_names[FlightType.OCA_RUNWAY].extend(loadout_names[FlightType.STRIKE])
yield from loadout_names[flight.flight_type]
@classmethod
def default_for(cls, flight: Flight) -> Loadout:
# Iterate through each possible payload type for a given aircraft.
# Some aircraft have custom loadouts that in aren't the standard set.
for name in cls.default_loadout_names_for(flight):
# This operation is cached, but must be called before load_by_name will
# work.
flight.unit_type.dcs_unit_type.load_payloads()
payload = flight.unit_type.dcs_unit_type.loadout_by_name(name)
if payload is not None:
return Loadout(
name,
{i: Weapon.with_clsid(d["clsid"]) for i, d in payload},
date=None,
)
# TODO: Try group.load_task_default_loadout(loadout_for_task)
return cls.empty_loadout()
@classmethod
def empty_loadout(cls) -> Loadout:
return Loadout("Empty", {}, date=None)

View File

@@ -6,12 +6,13 @@ from dataclasses import dataclass, field
from datetime import timedelta
from typing import Dict, List, Optional, TYPE_CHECKING
from game.ato import Flight, FlightType
from game.ato.packagewaypoints import PackageWaypoints
from game.db import Database
from game.utils import Speed
from gen.flights.flightplan import FormationFlightPlan
from gen.flights.traveltime import TotEstimator
from .flight import Flight
from .flightplan import FormationFlightPlan
from .flighttype import FlightType
from .packagewaypoints import PackageWaypoints
from .traveltime import TotEstimator
if TYPE_CHECKING:
from game.theater import MissionTarget

115
game/ato/traveltime.py Normal file
View File

@@ -0,0 +1,115 @@
from __future__ import annotations
import logging
import math
from datetime import timedelta
from typing import TYPE_CHECKING
from dcs.mapping import Point
from game.utils import (
Distance,
SPEED_OF_SOUND_AT_SEA_LEVEL,
Speed,
mach,
meters,
)
if TYPE_CHECKING:
from .flight import Flight
from .package import Package
class GroundSpeed:
@classmethod
def for_flight(cls, flight: Flight, altitude: Distance) -> Speed:
# TODO: Expose both a cruise speed and target speed.
# The cruise speed can be used for ascent, hold, join, and RTB to save
# on fuel, but mission speed will be fast enough to keep the flight
# safer.
# DCS's max speed is in kph at 0 MSL.
max_speed = flight.unit_type.max_speed
if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL:
# Aircraft is supersonic. Limit to mach 0.85 to conserve fuel and
# account for heavily loaded jets.
return mach(0.85, altitude)
# For subsonic aircraft, assume the aircraft can reasonably perform at
# 80% of its maximum, and that it can maintain the same mach at altitude
# as it can at sea level. This probably isn't great assumption, but
# might. be sufficient given the wiggle room. We can come up with
# another heuristic if needed.
cruise_mach = max_speed.mach() * 0.85
return mach(cruise_mach, altitude)
class TravelTime:
@staticmethod
def between_points(a: Point, b: Point, speed: Speed) -> timedelta:
error_factor = 1.05
distance = meters(a.distance_to_point(b))
return timedelta(hours=distance.nautical_miles / speed.knots * error_factor)
# TODO: Most if not all of this should move into FlightPlan.
class TotEstimator:
def __init__(self, package: Package) -> None:
self.package = package
@staticmethod
def mission_start_time(flight: Flight) -> timedelta:
startup_time = flight.flight_plan.startup_time()
if startup_time is None:
# Could not determine takeoff time, probably due to a custom flight
# plan. Start immediately.
return timedelta()
return startup_time
def earliest_tot(self) -> timedelta:
if not self.package.flights:
return timedelta(0)
earliest_tot = max(
(self.earliest_tot_for_flight(f) for f in self.package.flights)
)
# Trim microseconds. DCS doesn't handle sub-second resolution for tasks,
# and they're not interesting from a mission planning perspective so we
# don't want them in the UI.
#
# Round up so we don't get negative start times.
return timedelta(seconds=math.ceil(earliest_tot.total_seconds()))
@staticmethod
def earliest_tot_for_flight(flight: Flight) -> timedelta:
"""Estimate fastest time from mission start to the target position.
For BARCAP flights, this is time to race track start. This ensures that
they are on station at the same time any other package members reach
their ingress point.
For other mission types this is the time to the mission target.
Args:
flight: The flight to get the earliest TOT time for.
Returns:
The earliest possible TOT for the given flight in seconds. Returns 0
if an ingress point cannot be found.
"""
# Clear the TOT, calculate the startup time. Negating the result gives
# the earliest possible start time.
orig_tot = flight.package.time_over_target
try:
flight.package.time_over_target = timedelta()
time = flight.flight_plan.startup_time()
finally:
flight.package.time_over_target = orig_tot
if time is None:
logging.warning(f"Cannot estimate TOT for {flight}")
# Return 0 so this flight's travel time does not affect the rest
# of the package.
return timedelta()
return -time

620
game/ato/waypointbuilder.py Normal file
View File

@@ -0,0 +1,620 @@
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import (
Iterable,
Iterator,
List,
Optional,
TYPE_CHECKING,
Tuple,
Union,
)
from dcs.mapping import Point
from game.theater import (
ControlPoint,
MissionTarget,
OffMapSpawn,
TheaterGroundObject,
TheaterUnit,
)
from game.utils import Distance, meters, nautical_miles
from .flightwaypoint import AltitudeReference, FlightWaypoint
from .flightwaypointtype import FlightWaypointType
if TYPE_CHECKING:
from game.coalition import Coalition
from game.transfers import MultiGroupTransport
from game.theater.theatergroup import TheaterGroup
from .flight import Flight
@dataclass(frozen=True)
class StrikeTarget:
name: str
target: Union[TheaterGroundObject, TheaterGroup, TheaterUnit, MultiGroupTransport]
class WaypointBuilder:
def __init__(
self,
flight: Flight,
coalition: Coalition,
targets: Optional[List[StrikeTarget]] = None,
) -> None:
self.flight = flight
self.doctrine = coalition.doctrine
self.threat_zones = coalition.opponent.threat_zone
self.navmesh = coalition.nav_mesh
self.targets = targets
self._bullseye = coalition.bullseye
@property
def is_helo(self) -> bool:
return self.flight.unit_type.dcs_unit_type.helicopter
def takeoff(self, departure: ControlPoint) -> FlightWaypoint:
"""Create takeoff waypoint for the given arrival airfield or carrier.
Note that the takeoff waypoint will automatically be created by pydcs
when we create the group, but creating our own before generation makes
the planning code simpler.
Args:
departure: Departure airfield or carrier.
"""
position = departure.position
if isinstance(departure, OffMapSpawn):
return FlightWaypoint(
"NAV",
FlightWaypointType.NAV,
position.x,
position.y,
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
description="Enter theater",
pretty_name="Enter theater",
)
return FlightWaypoint(
"TAKEOFF",
FlightWaypointType.TAKEOFF,
position.x,
position.y,
meters(0),
alt_type="RADIO",
description="Takeoff",
pretty_name="Takeoff",
)
def land(self, arrival: ControlPoint) -> FlightWaypoint:
"""Create descent waypoint for the given arrival airfield or carrier.
Args:
arrival: Arrival airfield or carrier.
"""
position = arrival.position
if isinstance(arrival, OffMapSpawn):
return FlightWaypoint(
"NAV",
FlightWaypointType.NAV,
position.x,
position.y,
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
description="Exit theater",
pretty_name="Exit theater",
)
return FlightWaypoint(
"LANDING",
FlightWaypointType.LANDING_POINT,
position.x,
position.y,
meters(0),
alt_type="RADIO",
description="Land",
pretty_name="Land",
control_point=arrival,
)
def divert(self, divert: Optional[ControlPoint]) -> Optional[FlightWaypoint]:
"""Create divert waypoint for the given arrival airfield or carrier.
Args:
divert: Divert airfield or carrier.
"""
if divert is None:
return None
position = divert.position
altitude_type: AltitudeReference
if isinstance(divert, OffMapSpawn):
if self.is_helo:
altitude = meters(500)
else:
altitude = self.doctrine.rendezvous_altitude
altitude_type = "BARO"
else:
altitude = meters(0)
altitude_type = "RADIO"
return FlightWaypoint(
"DIVERT",
FlightWaypointType.DIVERT,
position.x,
position.y,
altitude,
alt_type=altitude_type,
description="Divert",
pretty_name="Divert",
only_for_player=True,
control_point=divert,
)
def bullseye(self) -> FlightWaypoint:
return FlightWaypoint(
"BULLSEYE",
FlightWaypointType.BULLSEYE,
self._bullseye.position.x,
self._bullseye.position.y,
meters(0),
description="Bullseye",
pretty_name="Bullseye",
only_for_player=True,
)
def hold(self, position: Point) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
alt_type = "RADIO"
return FlightWaypoint(
"HOLD",
FlightWaypointType.LOITER,
position.x,
position.y,
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
alt_type,
description="Wait until push time",
pretty_name="Hold",
)
def join(self, position: Point) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
alt_type = "RADIO"
return FlightWaypoint(
"JOIN",
FlightWaypointType.JOIN,
position.x,
position.y,
meters(80) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description="Rendezvous with package",
pretty_name="Join",
)
def refuel(self, position: Point) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
alt_type = "RADIO"
return FlightWaypoint(
"REFUEL",
FlightWaypointType.REFUEL,
position.x,
position.y,
meters(80) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description="Refuel from tanker",
pretty_name="Refuel",
)
def split(self, position: Point) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
alt_type = "RADIO"
return FlightWaypoint(
"SPLIT",
FlightWaypointType.SPLIT,
position.x,
position.y,
meters(80) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description="Depart from package",
pretty_name="Split",
)
def ingress(
self,
ingress_type: FlightWaypointType,
position: Point,
objective: MissionTarget,
) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
alt_type = "RADIO"
return FlightWaypoint(
"INGRESS",
ingress_type,
position.x,
position.y,
meters(60) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description=f"INGRESS on {objective.name}",
pretty_name=f"INGRESS on {objective.name}",
targets=objective.strike_targets,
)
def egress(self, position: Point, target: MissionTarget) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
alt_type = "RADIO"
return FlightWaypoint(
"EGRESS",
FlightWaypointType.EGRESS,
position.x,
position.y,
meters(60) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description=f"EGRESS from {target.name}",
pretty_name=f"EGRESS from {target.name}",
)
def bai_group(self, target: StrikeTarget) -> FlightWaypoint:
return self._target_point(target, f"ATTACK {target.name}")
def dead_point(self, target: StrikeTarget) -> FlightWaypoint:
return self._target_point(target, f"STRIKE {target.name}")
def sead_point(self, target: StrikeTarget) -> FlightWaypoint:
return self._target_point(target, f"STRIKE {target.name}")
def strike_point(self, target: StrikeTarget) -> FlightWaypoint:
return self._target_point(target, f"STRIKE {target.name}")
@staticmethod
def _target_point(target: StrikeTarget, description: str) -> FlightWaypoint:
return FlightWaypoint(
target.name,
FlightWaypointType.TARGET_POINT,
target.target.position.x,
target.target.position.y,
meters(0),
"RADIO",
description=description,
pretty_name=description,
# The target waypoints are only for the player's benefit. AI tasks for
# the target are set on the ingress point so that they begin their attack
# *before* reaching the target.
only_for_player=True,
)
def strike_area(self, target: MissionTarget) -> FlightWaypoint:
return self._target_area(f"STRIKE {target.name}", target)
def sead_area(self, target: MissionTarget) -> FlightWaypoint:
return self._target_area(f"SEAD on {target.name}", target, flyover=True)
def dead_area(self, target: MissionTarget) -> FlightWaypoint:
return self._target_area(f"DEAD on {target.name}", target)
def oca_strike_area(self, target: MissionTarget) -> FlightWaypoint:
return self._target_area(f"ATTACK {target.name}", target, flyover=True)
@staticmethod
def _target_area(
name: str, location: MissionTarget, flyover: bool = False
) -> FlightWaypoint:
waypoint = FlightWaypoint(
name,
FlightWaypointType.TARGET_GROUP_LOC,
location.position.x,
location.position.y,
meters(0),
"RADIO",
description=name,
pretty_name=name,
)
# Most target waypoints are only for the player's benefit. AI tasks for
# the target are set on the ingress point so they begin their attack
# *before* reaching the target.
#
# The exception is for flight plans that require passing over the
# target. For example, OCA strikes need to get close enough to detect
# the targets in their engagement zone or they will RTB immediately.
if flyover:
waypoint.flyover = True
else:
waypoint.only_for_player = True
return waypoint
def cas(self, position: Point) -> FlightWaypoint:
return FlightWaypoint(
"CAS",
FlightWaypointType.CAS,
position.x,
position.y,
meters(60) if self.is_helo else meters(1000),
"RADIO",
description="Provide CAS",
pretty_name="CAS",
)
@staticmethod
def race_track_start(position: Point, altitude: Distance) -> FlightWaypoint:
"""Creates a racetrack start waypoint.
Args:
position: Position of the waypoint.
altitude: Altitude of the racetrack.
"""
return FlightWaypoint(
"RACETRACK START",
FlightWaypointType.PATROL_TRACK,
position.x,
position.y,
altitude,
description="Orbit between this point and the next point",
pretty_name="Race-track start",
)
@staticmethod
def race_track_end(position: Point, altitude: Distance) -> FlightWaypoint:
"""Creates a racetrack end waypoint.
Args:
position: Position of the waypoint.
altitude: Altitude of the racetrack.
"""
return FlightWaypoint(
"RACETRACK END",
FlightWaypointType.PATROL,
position.x,
position.y,
altitude,
description="Orbit between this point and the previous point",
pretty_name="Race-track end",
)
def race_track(
self, start: Point, end: Point, altitude: Distance
) -> Tuple[FlightWaypoint, FlightWaypoint]:
"""Creates two waypoint for a racetrack orbit.
Args:
start: The beginning racetrack waypoint.
end: The ending racetrack waypoint.
altitude: The racetrack altitude.
"""
return (
self.race_track_start(start, altitude),
self.race_track_end(end, altitude),
)
@staticmethod
def orbit(start: Point, altitude: Distance) -> FlightWaypoint:
"""Creates an circular orbit point.
Args:
start: Position of the waypoint.
altitude: Altitude of the racetrack.
"""
return FlightWaypoint(
"ORBIT",
FlightWaypointType.LOITER,
start.x,
start.y,
altitude,
description="Anchor and hold at this point",
pretty_name="Orbit",
)
@staticmethod
def sweep_start(position: Point, altitude: Distance) -> FlightWaypoint:
"""Creates a sweep start waypoint.
Args:
position: Position of the waypoint.
altitude: Altitude of the sweep in meters.
"""
return FlightWaypoint(
"SWEEP START",
FlightWaypointType.INGRESS_SWEEP,
position.x,
position.y,
altitude,
description="Proceed to the target and engage enemy aircraft",
pretty_name="Sweep start",
)
@staticmethod
def sweep_end(position: Point, altitude: Distance) -> FlightWaypoint:
"""Creates a sweep end waypoint.
Args:
position: Position of the waypoint.
altitude: Altitude of the sweep in meters.
"""
return FlightWaypoint(
"SWEEP END",
FlightWaypointType.EGRESS,
position.x,
position.y,
altitude,
description="End of sweep",
pretty_name="Sweep end",
)
def sweep(
self, start: Point, end: Point, altitude: Distance
) -> Tuple[FlightWaypoint, FlightWaypoint]:
"""Creates two waypoint for a racetrack orbit.
Args:
start: The beginning of the sweep.
end: The end of the sweep.
altitude: The sweep altitude.
"""
return self.sweep_start(start, altitude), self.sweep_end(end, altitude)
def escort(
self,
ingress: Point,
target: MissionTarget,
) -> Tuple[FlightWaypoint, FlightWaypoint]:
"""Creates the waypoints needed to escort the package.
Args:
ingress: The package ingress point.
target: The mission target.
"""
alt_type: AltitudeReference = "BARO"
if self.is_helo:
alt_type = "RADIO"
# This would preferably be no points at all, and instead the Escort task
# would begin on the join point and end on the split point, however the
# escort task does not appear to work properly (see the longer
# description in gen.aircraft.JoinPointBuilder), so instead we give
# the escort flights a flight plan including the ingress point and target area.
ingress_wp = self.ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target)
return ingress_wp, FlightWaypoint(
"TARGET",
FlightWaypointType.TARGET_GROUP_LOC,
target.position.x,
target.position.y,
meters(60) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description="Escort the package",
pretty_name="Target area",
)
@staticmethod
def pickup(control_point: ControlPoint) -> FlightWaypoint:
"""Creates a cargo pickup waypoint.
Args:
control_point: Pick up location.
"""
return FlightWaypoint(
"PICKUP",
FlightWaypointType.PICKUP,
control_point.position.x,
control_point.position.y,
meters(0),
"RADIO",
description=f"Pick up cargo from {control_point}",
pretty_name="Pick up location",
)
@staticmethod
def drop_off(control_point: ControlPoint) -> FlightWaypoint:
"""Creates a cargo drop-off waypoint.
Args:
control_point: Drop-off location.
"""
return FlightWaypoint(
"DROP OFF",
FlightWaypointType.PICKUP,
control_point.position.x,
control_point.position.y,
meters(0),
"RADIO",
description=f"Drop off cargo at {control_point}",
pretty_name="Drop off location",
control_point=control_point,
)
@staticmethod
def nav(
position: Point, altitude: Distance, altitude_is_agl: bool = False
) -> FlightWaypoint:
"""Creates a navigation point.
Args:
position: Position of the waypoint.
altitude: Altitude of the waypoint.
altitude_is_agl: True for altitude is AGL. False if altitude is MSL.
"""
alt_type: AltitudeReference = "BARO"
if altitude_is_agl:
alt_type = "RADIO"
return FlightWaypoint(
"NAV",
FlightWaypointType.NAV,
position.x,
position.y,
altitude,
alt_type,
description="NAV",
pretty_name="Nav",
)
def nav_path(
self, a: Point, b: Point, altitude: Distance, altitude_is_agl: bool = False
) -> List[FlightWaypoint]:
path = self.clean_nav_points(self.navmesh.shortest_path(a, b))
return [self.nav(self.perturb(p), altitude, altitude_is_agl) for p in path]
def clean_nav_points(self, points: Iterable[Point]) -> Iterator[Point]:
# Examine a sliding window of three waypoints. `current` is the waypoint
# being checked for prunability. `previous` is the last emitted waypoint
# before `current`. `nxt` is the waypoint after `current`.
previous: Optional[Point] = None
current: Optional[Point] = None
for nxt in points:
if current is None:
current = nxt
continue
if previous is None:
previous = current
current = nxt
continue
if self.nav_point_prunable(previous, current, nxt):
current = nxt
continue
yield current
previous = current
current = nxt
def nav_point_prunable(self, previous: Point, current: Point, nxt: Point) -> bool:
previous_threatened = self.threat_zones.path_threatened(previous, current)
next_threatened = self.threat_zones.path_threatened(current, nxt)
pruned_threatened = self.threat_zones.path_threatened(previous, nxt)
previous_distance = meters(previous.distance_to_point(current))
distance = meters(current.distance_to_point(nxt))
distance_without = previous_distance + distance
if distance > distance_without:
# Don't prune paths to make them longer.
return False
# We could shorten the path by removing the intermediate
# waypoint. Do so if the new path isn't higher threat.
if not pruned_threatened:
# The new path is not threatened, so safe to prune.
return True
# The new path is threatened. Only allow if both paths were
# threatened anyway.
return previous_threatened and next_threatened
@staticmethod
def perturb(point: Point) -> Point:
deviation = nautical_miles(1)
x_adj = random.randint(int(-deviation.meters), int(deviation.meters))
y_adj = random.randint(int(-deviation.meters), int(deviation.meters))
return Point(point.x + x_adj, point.y + y_adj)

35
game/callsigns.py Normal file
View File

@@ -0,0 +1,35 @@
"""Support for working with DCS group callsigns."""
import logging
import re
from typing import Any
from dcs.unitgroup import FlyingGroup
from dcs.flyingunit import FlyingUnit
def callsign_for_support_unit(group: FlyingGroup[Any]) -> str:
# Either something like Overlord11 for Western AWACS, or else just a number.
# Convert to either "Overlord" or "Flight 123".
lead = group.units[0]
raw_callsign = lead.callsign_as_str()
try:
return f"Flight {int(raw_callsign)}"
except ValueError:
return raw_callsign.rstrip("1234567890")
def create_group_callsign_from_unit(lead: FlyingUnit) -> str:
raw_callsign = lead.callsign_as_str()
if not lead.callsign_is_western:
# Callsigns for non-Western countries are just a number per flight,
# similar to tail numbers.
return f"Flight {raw_callsign}"
# Callsign from pydcs is in the format `<name><group ID><unit ID>`,
# where unit ID is guaranteed to be a single digit but the group ID may
# be more.
match = re.search(r"^(\D+)(\d+)(\d)$", raw_callsign)
if match is None:
logging.error(f"Could not parse unit callsign: {raw_callsign}")
return f"Flight {raw_callsign}"
return f"{match.group(1)} {match.group(2)}"

View File

@@ -2,14 +2,14 @@ from __future__ import annotations
import itertools
import random
from typing import TYPE_CHECKING, Optional
from typing import Optional, TYPE_CHECKING
from game.ato.flighttype import FlightType
from game.dcs.aircrafttype import AircraftType
from game.squadrons.operatingbases import OperatingBases
from game.squadrons.squadrondef import SquadronDef
from game.theater import ControlPoint
from gen.flights.ai_flight_planner_db import aircraft_for_task, tasks_for_aircraft
from game.ato.flighttype import FlightType
from game.ato.ai_flight_planner_db import aircraft_for_task, tasks_for_aircraft
if TYPE_CHECKING:
from game.factions.faction import Faction
@@ -56,7 +56,7 @@ class SquadronDefGenerator:
@staticmethod
def _make_random_nickname() -> str:
from gen.naming import ANIMALS
from game.naming import ANIMALS
animal = random.choice(ANIMALS)
adjective = random.choice(

View File

@@ -8,7 +8,7 @@ from typing import Iterator, Dict, TYPE_CHECKING
from game.theater import MissionTarget
from game.ato.flighttype import FlightType
from gen.flights.traveltime import TotEstimator
from game.ato.traveltime import TotEstimator
if TYPE_CHECKING:
from game.coalition import Coalition

View File

@@ -19,7 +19,7 @@ from game.theater.theatergroundobject import (
NavalGroundObject,
)
from game.utils import meters, nautical_miles
from gen.flights.closestairfields import ClosestAirfields, ObjectiveDistanceCache
from game.ato.closestairfields import ClosestAirfields, ObjectiveDistanceCache
if TYPE_CHECKING:
from game import Game

View File

@@ -12,7 +12,7 @@ from ..db.database import Database
if TYPE_CHECKING:
from game.dcs.aircrafttype import AircraftType
from game.squadrons.airwing import AirWing
from gen.flights.closestairfields import ClosestAirfields
from game.ato.closestairfields import ClosestAirfields
from .missionproposals import ProposedFlight

View File

@@ -17,8 +17,8 @@ from game.settings import Settings
from game.squadrons import AirWing
from game.theater import ConflictTheater
from game.threatzones import ThreatZones
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flightplan import FlightPlanBuilder
from game.ato.closestairfields import ObjectiveDistanceCache
from game.ato.flightplan import FlightPlanBuilder
if TYPE_CHECKING:
from game.ato import Flight

View File

@@ -6,8 +6,8 @@ from typing import TYPE_CHECKING
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
from game.commander.theaterstate import TheaterState
from game.ground_forces.combat_stance import CombatStance
from game.theater import FrontLine
from gen.ground_forces.combat_stance import CombatStance
if TYPE_CHECKING:
from game.coalition import Coalition

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from gen.ground_forces.combat_stance import CombatStance
from game.ground_forces.combat_stance import CombatStance
class AggressiveAttack(FrontLineStanceTask):

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from game.commander.theaterstate import TheaterState
from gen.ground_forces.combat_stance import CombatStance
from game.ground_forces.combat_stance import CombatStance
class BreakthroughAttack(FrontLineStanceTask):

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from gen.ground_forces.combat_stance import CombatStance
from game.ground_forces.combat_stance import CombatStance
class DefensiveStance(FrontLineStanceTask):

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from gen.ground_forces.combat_stance import CombatStance
from game.ground_forces.combat_stance import CombatStance
class EliminationAttack(FrontLineStanceTask):

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from gen.ground_forces.combat_stance import CombatStance
from game.ground_forces.combat_stance import CombatStance
class RetreatStance(FrontLineStanceTask):

View File

@@ -5,11 +5,12 @@ import itertools
import math
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any, Optional, TYPE_CHECKING, Union
from typing import Optional, TYPE_CHECKING, Union
from game.commander.garrisons import Garrisons
from game.commander.objectivefinder import ObjectiveFinder
from game.db import GameDb
from game.ground_forces.combat_stance import CombatStance
from game.htn import WorldState
from game.profiling import MultiEventTracer
from game.settings import Settings
@@ -22,7 +23,6 @@ from game.theater.theatergroundobject import (
VehicleGroupGroundObject,
)
from game.threatzones import ThreatZones
from gen.ground_forces.combat_stance import CombatStance
if TYPE_CHECKING:
from game import Game

View File

@@ -15,13 +15,12 @@ from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence
from faker import Faker
from game.ground_forces.ai_ground_planner import GroundPlanner
from game.models.game_stats import GameStats
from game.plugins import LuaPluginManager
from game.utils import Distance
from gen import naming
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.ground_forces.ai_ground_planner import GroundPlanner
from . import persistency
from game.ato.closestairfields import ObjectiveDistanceCache
from . import naming, persistency
from .ato.flighttype import FlightType
from .campaignloader import CampaignAirWingConfig
from .coalition import Coalition

View File

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import logging
import random
from enum import Enum
from typing import Dict, List, TYPE_CHECKING
from game.data.units import UnitClass
from game.dcs.groundunittype import GroundUnitType
from .combat_stance import CombatStance
if TYPE_CHECKING:
from game import Game
from game.theater import ControlPoint
MAX_COMBAT_GROUP_PER_CP = 10
class CombatGroupRole(Enum):
TANK = 1
APC = 2
IFV = 3
ARTILLERY = 4
SHORAD = 5
LOGI = 6
INFANTRY = 7
ATGM = 8
RECON = 9
DISTANCE_FROM_FRONTLINE = {
CombatGroupRole.TANK: (2200, 3200),
CombatGroupRole.APC: (2700, 3700),
CombatGroupRole.IFV: (2700, 3700),
CombatGroupRole.ARTILLERY: (16000, 18000),
CombatGroupRole.SHORAD: (5000, 8000),
CombatGroupRole.LOGI: (18000, 20000),
CombatGroupRole.INFANTRY: (2800, 3300),
CombatGroupRole.ATGM: (5200, 6200),
CombatGroupRole.RECON: (2000, 3000),
}
GROUP_SIZES_BY_COMBAT_STANCE = {
CombatStance.DEFENSIVE: [2, 4, 6],
CombatStance.AGGRESSIVE: [2, 4, 6],
CombatStance.RETREAT: [2, 4, 6, 8],
CombatStance.BREAKTHROUGH: [4, 6, 6, 8],
CombatStance.ELIMINATION: [2, 4, 4, 4, 6],
CombatStance.AMBUSH: [1, 1, 2, 2, 2, 2, 4],
}
class CombatGroup:
def __init__(
self, role: CombatGroupRole, unit_type: GroundUnitType, size: int
) -> None:
self.unit_type = unit_type
self.size = size
self.role = role
self.start_position = None
def __str__(self) -> str:
s = f"ROLE : {self.role}\n"
if self.size:
s += f"UNITS {self.unit_type} * {self.size}"
return s
class GroundPlanner:
def __init__(self, cp: ControlPoint, game: Game) -> None:
self.cp = cp
self.game = game
self.connected_enemy_cp = [
cp for cp in self.cp.connected_points if cp.captured != self.cp.captured
]
self.tank_groups: List[CombatGroup] = []
self.apc_group: List[CombatGroup] = []
self.ifv_group: List[CombatGroup] = []
self.art_group: List[CombatGroup] = []
self.atgm_group: List[CombatGroup] = []
self.logi_groups: List[CombatGroup] = []
self.shorad_groups: List[CombatGroup] = []
self.recon_groups: List[CombatGroup] = []
self.units_per_cp: Dict[int, List[CombatGroup]] = {}
for cp in self.connected_enemy_cp:
self.units_per_cp[cp.id] = []
self.reserve: List[CombatGroup] = []
def plan_groundwar(self) -> None:
ground_unit_limit = self.cp.frontline_unit_count_limit
remaining_available_frontline_units = ground_unit_limit
# TODO: Fix to handle the per-front stances.
# https://github.com/dcs-liberation/dcs_liberation/issues/1417
group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE]
# Create combat groups and assign them randomly to each enemy CP
for unit_type in self.cp.base.armor:
unit_class = unit_type.unit_class
if unit_class is UnitClass.TANK:
collection = self.tank_groups
role = CombatGroupRole.TANK
elif unit_class is UnitClass.APC:
collection = self.apc_group
role = CombatGroupRole.APC
elif unit_class is UnitClass.ARTILLERY:
collection = self.art_group
role = CombatGroupRole.ARTILLERY
elif unit_class is UnitClass.IFV:
collection = self.ifv_group
role = CombatGroupRole.IFV
elif unit_class is UnitClass.LOGISTICS:
collection = self.logi_groups
role = CombatGroupRole.LOGI
elif unit_class is UnitClass.ATGM:
collection = self.atgm_group
role = CombatGroupRole.ATGM
elif unit_class is UnitClass.SHORAD:
collection = self.shorad_groups
role = CombatGroupRole.SHORAD
elif unit_class is UnitClass.RECON:
collection = self.recon_groups
role = CombatGroupRole.RECON
else:
logging.warning(
f"Unused front line vehicle at base {unit_type}: unknown unit class"
)
continue
available = self.cp.base.armor[unit_type]
if available > remaining_available_frontline_units:
available = remaining_available_frontline_units
remaining_available_frontline_units -= available
while available > 0:
if role == CombatGroupRole.SHORAD:
count = 1
else:
count = random.choice(group_size_choice)
if count > available:
if available >= 2:
count = 2
else:
count = 1
available -= count
group = CombatGroup(role, unit_type, count)
if len(self.connected_enemy_cp) > 0:
enemy_cp = random.choice(self.connected_enemy_cp).id
self.units_per_cp[enemy_cp].append(group)
else:
self.reserve.append(group)
collection.append(group)
if remaining_available_frontline_units == 0:
break

View File

@@ -0,0 +1,12 @@
from enum import Enum
class CombatStance(Enum):
DEFENSIVE = 0 # Unit will adopt defensive stance with medium group of units
AGGRESSIVE = (
1 # Unit will attempt to make progress with medium sized group of units
)
RETREAT = 2 # Unit will retreat
BREAKTHROUGH = 3 # Unit will attempt a breakthrough, rushing forward very aggresively with big group of armored units, and even less armored units will move aggresively
ELIMINATION = 4 # Unit will progress aggresively toward anemy units, attempting to eliminate the ennemy force
AMBUSH = 5 # Units will adopt a defensive stance a bit different from 'DEFENSIVE', ATGM & INFANTRY with RPG will be located on frontline with the armored units. (The groups of units will be smaller)

View File

@@ -1,5 +1,4 @@
import logging
from datetime import datetime
from typing import Any, Optional
from dcs.task import (
@@ -24,7 +23,7 @@ from dcs.task import (
from dcs.unitgroup import FlyingGroup
from game.ato import Flight, FlightType
from gen.flights.flightplan import AwacsFlightPlan, RefuelingFlightPlan
from game.ato.flightplan import AwacsFlightPlan, RefuelingFlightPlan
class AircraftBehavior:

View File

@@ -21,13 +21,13 @@ from game.missiongenerator.airsupport import AirSupport
from game.missiongenerator.lasercoderegistry import LaserCodeRegistry
from game.radio.radios import RadioRegistry
from game.radio.tacan import TacanRegistry
from game.runways import RunwayData
from game.settings import Settings
from game.theater.controlpoint import (
Airfield,
ControlPoint,
)
from game.unitmap import UnitMap
from gen.runways import RunwayData
from .aircraftpainter import AircraftPainter
from .flightdata import FlightData
from .flightgroupconfigurator import FlightGroupConfigurator

View File

@@ -6,13 +6,13 @@ from typing import Optional, TYPE_CHECKING
from dcs.flyingunit import FlyingUnit
from gen.callsigns import create_group_callsign_from_unit
from game.callsigns import create_group_callsign_from_unit
if TYPE_CHECKING:
from game.ato import FlightType, FlightWaypoint, Package
from game.dcs.aircrafttype import AircraftType
from game.radio.radios import RadioFrequency
from gen.runways import RunwayData
from game.runways import RunwayData
@dataclass(frozen=True)

View File

@@ -11,15 +11,15 @@ from dcs.unit import Skill
from dcs.unitgroup import FlyingGroup
from game.ato import Flight, FlightType
from game.callsigns import callsign_for_support_unit
from game.data.weapons import Pylon, WeaponType as WeaponTypeEnum
from game.missiongenerator.airsupport import AirSupport, AwacsInfo, TankerInfo
from game.missiongenerator.lasercoderegistry import LaserCodeRegistry
from game.radio.radios import RadioFrequency, RadioRegistry
from game.radio.tacan import TacanBand, TacanRegistry, TacanUsage
from game.runways import RunwayData
from game.squadrons import Pilot
from gen.callsigns import callsign_for_support_unit
from gen.flights.flightplan import AwacsFlightPlan, RefuelingFlightPlan
from gen.runways import RunwayData
from game.ato.flightplan import AwacsFlightPlan, RefuelingFlightPlan
from .aircraftbehavior import AircraftBehavior
from .aircraftpainter import AircraftPainter
from .flightdata import FlightData

View File

@@ -15,10 +15,10 @@ from dcs.unitgroup import FlyingGroup, ShipGroup, StaticGroup
from game.ato import Flight
from game.ato.flightstate import InFlight
from game.ato.starttype import StartType
from game.naming import namegen
from game.theater import Airfield, ControlPoint, Fob, NavalControlPoint, OffMapSpawn
from game.utils import feet, meters
from gen.flights.traveltime import GroundSpeed
from gen.naming import namegen
from game.ato.traveltime import GroundSpeed
WARM_START_HELI_ALT = meters(500)
WARM_START_ALTITUDE = meters(3000)

View File

@@ -4,7 +4,7 @@ from dcs.point import MovingPoint
from dcs.task import EngageTargets, EngageTargetsInZone, Targets
from game.utils import nautical_miles
from gen.flights.flightplan import CasFlightPlan
from game.ato.flightplan import CasFlightPlan
from .pydcswaypointbuilder import PydcsWaypointBuilder

View File

@@ -3,7 +3,7 @@ import logging
from dcs.point import MovingPoint
from dcs.task import ControlledTask, OptFormation, OrbitAction
from gen.flights.flightplan import LoiterFlightPlan
from game.ato.flightplan import LoiterFlightPlan
from .pydcswaypointbuilder import PydcsWaypointBuilder

View File

@@ -11,7 +11,7 @@ from dcs.task import (
)
from game.ato import FlightType
from gen.flights.flightplan import PatrollingFlightPlan
from game.ato.flightplan import PatrollingFlightPlan
from .pydcswaypointbuilder import PydcsWaypointBuilder

View File

@@ -2,7 +2,7 @@ import logging
from dcs.point import MovingPoint
from gen.flights.flightplan import PatrollingFlightPlan
from game.ato.flightplan import PatrollingFlightPlan
from .pydcswaypointbuilder import PydcsWaypointBuilder

View File

@@ -4,7 +4,7 @@ from dcs.point import MovingPoint
from dcs.task import EngageTargets, OptFormation, Targets
from game.utils import nautical_miles
from gen.flights.flightplan import SweepFlightPlan
from game.ato.flightplan import SweepFlightPlan
from .pydcswaypointbuilder import PydcsWaypointBuilder

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import logging
from typing import List, Type, Tuple, TYPE_CHECKING
from typing import List, TYPE_CHECKING, Tuple, Type
from dcs.mission import Mission, StartType
from dcs.planes import IL_78M, KC130, KC135MPRS, KC_135, PlaneType
@@ -15,14 +15,13 @@ from dcs.task import (
)
from dcs.unittype import UnitType
from game.utils import Heading
from gen.callsigns import callsign_for_support_unit
from gen.flights.ai_flight_planner_db import AEWC_CAPABLE
from gen.naming import namegen
from game.callsigns import callsign_for_support_unit
from game.naming import namegen
from game.radio.radios import RadioRegistry
from game.radio.tacan import TacanBand, TacanRegistry, TacanUsage
from .airsupport import AirSupport, TankerInfo, AwacsInfo
from game.utils import Heading
from game.ato.ai_flight_planner_db import AEWC_CAPABLE
from .airsupport import AirSupport, AwacsInfo, TankerInfo
from .frontlineconflictdescription import FrontLineConflictDescription
if TYPE_CHECKING:

View File

@@ -11,17 +11,15 @@ from typing import Dict, List, TYPE_CHECKING
from dcs.mission import Mission
from jinja2 import Environment, FileSystemLoader, select_autoescape
from game.theater import ControlPoint, FrontLine
from game.ato.flightwaypoint import FlightWaypoint
from gen.ground_forces.combat_stance import CombatStance
from game.ground_forces.combat_stance import CombatStance
from game.radio.radios import RadioFrequency
from gen.runways import RunwayData
from game.runways import RunwayData
from game.theater import ControlPoint, FrontLine
from .aircraft.flightdata import FlightData
from .airsupportgenerator import AwacsInfo, TankerInfo
from .flotgenerator import JtacInfo
if TYPE_CHECKING:
from game import Game

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import math
import random
from typing import TYPE_CHECKING, List, Optional, Tuple
from typing import List, Optional, TYPE_CHECKING, Tuple
from dcs import Mission
from dcs.action import AITaskPush
@@ -13,9 +13,9 @@ from dcs.mapping import Point
from dcs.point import PointAction
from dcs.task import (
AFAC,
EPLRS,
AttackGroup,
ControlledTask,
EPLRS,
FAC,
FireAtPoint,
GoToWaypoint,
@@ -25,25 +25,24 @@ from dcs.task import (
SetInvisibleCommand,
)
from dcs.triggers import Event, TriggerOnce
from dcs.unit import Vehicle, Skill
from dcs.unit import Skill, Vehicle
from dcs.unitgroup import VehicleGroup
from game.callsigns import callsign_for_support_unit
from game.data.units import UnitClass
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.ground_forces.ai_ground_planner import (
CombatGroup,
CombatGroupRole,
DISTANCE_FROM_FRONTLINE,
)
from game.ground_forces.combat_stance import CombatStance
from game.naming import namegen
from game.radio.radios import RadioRegistry
from game.theater.controlpoint import ControlPoint
from game.unitmap import UnitMap
from game.utils import Heading
from gen.ground_forces.ai_ground_planner import (
DISTANCE_FROM_FRONTLINE,
CombatGroup,
CombatGroupRole,
)
from gen.callsigns import callsign_for_support_unit
from gen.ground_forces.combat_stance import CombatStance
from gen.naming import namegen
from game.radio.radios import RadioRegistry
from .airsupport import AirSupport, JtacInfo
from .frontlineconflictdescription import FrontLineConflictDescription
from .lasercoderegistry import LaserCodeRegistry

View File

@@ -40,15 +40,14 @@ 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.runways import RunwayData
from game.theater import ConflictTheater, LatLon, TheaterGroundObject, TheaterUnit
from game.theater.bullseye import Bullseye
from game.utils import Distance, UnitSystem, meters, mps, pounds
from game.weather import Weather
from gen.runways import RunwayData
from .aircraft.flightdata import FlightData
from .airsupportgenerator import AwacsInfo, TankerInfo
from .briefinggenerator import CommInfo, JtacInfo, MissionInfoGenerator
from ..dcs.helpers import unit_type_from_name
if TYPE_CHECKING:
from game import Game

View File

@@ -10,17 +10,17 @@ from dcs import Mission, Point
from dcs.coalition import Coalition
from dcs.countries import country_dict
from game.airfields import AirfieldData
from game.dcs.helpers import unit_type_from_name
from game.missiongenerator.aircraft.aircraftgenerator import (
AircraftGenerator,
)
from game.naming import namegen
from game.radio.radios import RadioFrequency, RadioRegistry
from game.radio.tacan import TacanRegistry
from game.theater import Airfield, FrontLine
from game.theater.bullseye import Bullseye
from game.unitmap import UnitMap
from gen.airfields import AirfieldData
from gen.naming import namegen
from .aircraft.flightdata import FlightData
from .airsupport import AirSupport
from .airsupportgenerator import AirSupportGenerator

View File

@@ -43,6 +43,7 @@ from dcs.vehicles import vehicle_map
from game.radio.radios import RadioFrequency, RadioRegistry
from game.radio.tacan import TacanBand, TacanChannel, TacanRegistry, TacanUsage
from game.runways import RunwayData
from game.theater import ControlPoint, TheaterGroundObject, TheaterUnit
from game.theater.theatergroundobject import (
CarrierGroundObject,
@@ -53,7 +54,6 @@ from game.theater.theatergroundobject import (
from game.theater.theatergroup import SceneryUnit, TheaterGroup
from game.unitmap import UnitMap
from game.utils import Heading, feet, knots, mps
from gen.runways import RunwayData
if TYPE_CHECKING:
from game import Game

567
game/naming.py Normal file
View File

@@ -0,0 +1,567 @@
from __future__ import annotations
import random
import time
from typing import List, Any, TYPE_CHECKING
from dcs.country import Country
from game.dcs.aircrafttype import AircraftType
from game.dcs.unittype import UnitType
if TYPE_CHECKING:
from game.ato.flight import Flight
ALPHA_MILITARY = [
"Alpha",
"Bravo",
"Charlie",
"Delta",
"Echo",
"Foxtrot",
"Golf",
"Hotel",
"India",
"Juliet",
"Kilo",
"Lima",
"Mike",
"November",
"Oscar",
"Papa",
"Quebec",
"Romeo",
"Sierra",
"Tango",
"Uniform",
"Victor",
"Whisky",
"XRay",
"Yankee",
"Zulu",
"Zero",
]
ANIMALS: tuple[str, ...] = (
"AARDVARK",
"AARDWOLF",
"ADDER",
"ALBACORE",
"ALBATROSS",
"ALLIGATOR",
"ALPACA",
"ANACONDA",
"ANOLE",
"ANTEATER",
"ANTELOPE",
"ANTLION",
"ARAPAIMA",
"ARCHERFISH",
"ARGALI",
"ARMADILLO",
"ASP",
"AUROCHS",
"AXOLOTL",
"BABIRUSA",
"BABOON",
"BADGER",
"BANDICOOT",
"BARRACUDA",
"BARRAMUNDI",
"BASILISK",
"BASS",
"BAT",
"BEAR",
"BEAVER",
"BEETLE",
"BELUGA",
"BETTONG",
"BINTURONG",
"BISON",
"BLOODHOUND",
"BOA",
"BOBCAT",
"BONGO",
"BONITO",
"BUFFALO",
"BULLDOG",
"BULLFROG",
"BULLSHARK",
"BUMBLEBEE",
"BUNNY",
"BUTTERFLY",
"CAIMAN",
"CAMEL",
"CANARY",
"CAPYBARA",
"CARACAL",
"CARP",
"CASTOR",
"CAT",
"CATERPILLAR",
"CATFISH",
"CENTIPEDE",
"CHAMELEON",
"CHEETAH",
"CHICKEN",
"CHIMAERA",
"CICADA",
"CICHLID",
"CIVET",
"COBIA",
"COBRA",
"COCKATOO",
"COD",
"COELACANTH",
"COLT",
"CONDOR",
"COPPERHEAD",
"CORAL",
"CORGI",
"COTTONMOUTH",
"COUGAR",
"COW",
"COYOTE",
"CRAB",
"CRANE",
"CRICKET",
"CROCODILE",
"CROW",
"CUTTLEFISH",
"DACHSHUND",
"DEER",
"DINGO",
"DIREWOLF",
"DODO",
"DOG",
"DOLPHIN",
"DONKEY",
"DOVE",
"DRACO",
"DRAGON",
"DRAGONFLY",
"DUCK",
"DUGONG",
"EAGLE",
"EARWIG",
"ECHIDNA",
"EEL",
"ELEPHANT",
"ELK",
"EMU",
"ERMINE",
"FALCON",
"FANGTOOTH",
"FAWN",
"FENNEC",
"FERRET",
"FINCH",
"FIREFLY",
"FISH",
"FLAMINGO",
"FLEA",
"FLOUNDER",
"FOX",
"FRINGEHEAD",
"FROG",
"FROGMOUTH",
"GAR",
"GAZELLE",
"GECKO",
"GENET",
"GERBIL",
"GHARIAL",
"GIBBON",
"GIRAFFE",
"GOOSE",
"GOPHER",
"GORILLA",
"GOSHAWK",
"GRASSHOPPER",
"GREYHOUND",
"GRIZZLY",
"GROUPER",
"GROUSE",
"GRYPHON",
"GUANACO",
"GULL",
"GUPPY",
"HADDOCK",
"HAGFISH",
"HALIBUT",
"HAMSTER",
"HARAMBE",
"HARE",
"HARRIER",
"HAWK",
"HEDGEHOG",
"HERMITCRAB",
"HERON",
"HERRING",
"HIPPO",
"HORNBILL",
"HORNET",
"HORSE",
"HUNTSMAN",
"HUSKY",
"HYENA",
"IBEX",
"IBIS",
"IGUANA",
"IMPALA",
"INSECT",
"IRUKANDJI",
"ISOPOD",
"JACKAL",
"JAGUAR",
"JELLYFISH",
"JERBOA",
"KAKAPO",
"KANGAROO",
"KATYDID",
"KEA",
"KINGFISHER",
"KITTEN",
"KIWI",
"KOALA",
"KOMODO",
"KRAIT",
"LADYBUG",
"LAMPREY",
"LEMUR",
"LEOPARD",
"LIGHTFOOT",
"LION",
"LIONFISH",
"LIZARD",
"LLAMA",
"LOACH",
"LOBSTER",
"LOCUST",
"LORIKEET",
"LUNGFISH",
"LYNX",
"MACAW",
"MAGPIE",
"MALLARD",
"MAMBA",
"MAMMOTH",
"MANATEE",
"MANDRILL",
"MANTA",
"MANTIS",
"MARE",
"MARLIN",
"MARMOT",
"MARTEN",
"MASTIFF",
"MASTODON",
"MAVERICK",
"MAYFLY",
"MEERKAT",
"MILLIPEDE",
"MINK",
"MOA",
"MOCKINGBIRD",
"MOLE",
"MOLERAT",
"MOLLY",
"MONGOOSE",
"MONKEY",
"MONKFISH",
"MONSTER",
"MOOSE",
"MORAY",
"MOSQUITO",
"MOTH",
"MOUSE",
"MUDSKIPPER",
"MULE",
"MUSK",
"MYNA",
"NARWHAL",
"NAUTILUS",
"NEWT",
"NIGHTINGALE",
"NUMBAT",
"OCELOT",
"OCTOPUS",
"OKAPI",
"OLM",
"OPAH",
"OPOSSUM",
"ORCA",
"ORYX",
"OSPREY",
"OSTRICH",
"OTTER",
"OWL",
"OX",
"OYSTER",
"PADDLEFISH",
"PADEMELON",
"PANDA",
"PANGOLIN",
"PANTHER",
"PARAKEET",
"PARROT",
"PEACOCK",
"PELICAN",
"PENGUIN",
"PERCH",
"PEREGRINE",
"PETRAL",
"PHEASANT",
"PIG",
"PIGEON",
"PIGLET",
"PIKE",
"PIRANHA",
"PLATYPUS",
"POODLE",
"PORCUPINE",
"PORPOISE",
"POSSUM",
"POTOROO",
"PRONGHORN",
"PUFFERFISH",
"PUFFIN",
"PUG",
"PUMA",
"PYTHON",
"QUAGGA",
"QUAIL",
"QUOKKA",
"QUOLL",
"RABBIT",
"RACOON",
"RAGDOLL",
"RAT",
"RATTLESNAKE",
"RAVEN",
"REINDEER",
"RHINO",
"ROACH",
"ROBIN",
"SABERTOOTH",
"SAILFISH",
"SALAMANDER",
"SALMON",
"SANDFLY",
"SARDINE",
"SAWFISH",
"SCARAB",
"SCORPION",
"SEAHORSE",
"SEAL",
"SEALION",
"SERVAL",
"SHARK",
"SHEEP",
"SHOEBILL",
"SHRIKE",
"SHRIMP",
"SIDEWINDER",
"SILKWORM",
"SKATE",
"SKINK",
"SKUNK",
"SLOTH",
"SLUG",
"SNAIL",
"SNAKE",
"SNAPPER",
"SNOOK",
"SPARROW",
"SPIDER",
"SPRINGBOK",
"SQUID",
"SQUIRREL",
"STAGHORN",
"STARFISH",
"STINGRAY",
"STINKBUG",
"STOUT",
"STURGEON",
"SUGARGLIDER",
"SUNBEAR",
"SWALLOW",
"SWAN",
"SWIFT",
"SWORDFISH",
"TAIPAN",
"TAKAHE",
"TAMARIN",
"TANG",
"TAPIR",
"TARANTULA",
"TARPON",
"TARSIER",
"TAURUS",
"TERMITE",
"TERRIER",
"TETRA",
"THRUSH",
"THYLACINE",
"TIGER",
"TOAD",
"TORTOISE",
"TOUCAN",
"TREADFIN",
"TREVALLY",
"TRIGGERFISH",
"TROUT",
"TUATARA",
"TUNA",
"TURKEY",
"TURTLE",
"URCHIN",
"VIPER",
"VULTURE",
"WALLABY",
"WALLAROO",
"WALLEYE",
"WALRUS",
"WARTHOG",
"WASP",
"WATERBUCK",
"WEASEL",
"WEEVIL",
"WEKA",
"WHALE",
"WILDCAT",
"WILDEBEEST",
"WOLF",
"WOLFHOUND",
"WOLVERINE",
"WOMBAT",
"WOODCHUCK",
"WOODPECKER",
"WORM",
"WRASSE",
"WYVERN",
"YAK",
"ZEBRA",
"ZEBU",
)
class NameGenerator:
number = 0
infantry_number = 0
aircraft_number = 0
convoy_number = 0
cargo_ship_number = 0
animals: list[str] = list(ANIMALS)
existing_alphas: List[str] = []
@classmethod
def reset(cls) -> None:
cls.number = 0
cls.infantry_number = 0
cls.convoy_number = 0
cls.cargo_ship_number = 0
cls.animals = list(ANIMALS)
cls.existing_alphas = []
@classmethod
def reset_numbers(cls) -> None:
cls.number = 0
cls.infantry_number = 0
cls.aircraft_number = 0
cls.convoy_number = 0
cls.cargo_ship_number = 0
@classmethod
def next_aircraft_name(
cls, country: Country, parent_base_id: int, flight: Flight
) -> str:
cls.aircraft_number += 1
try:
if flight.custom_name:
name_str = flight.custom_name
else:
name_str = "{} {}".format(
flight.package.target.name, flight.flight_type
)
except AttributeError: # Here to maintain save compatibility with 2.3
name_str = "{} {}".format(flight.package.target.name, flight.flight_type)
return "{}|{}|{}|{}|{}|".format(
name_str,
country.id,
cls.aircraft_number,
parent_base_id,
flight.unit_type.name,
)
@classmethod
def next_unit_name(
cls, country: Country, parent_base_id: int, unit_type: UnitType[Any]
) -> str:
cls.number += 1
return "unit|{}|{}|{}|{}|".format(
country.id, cls.number, parent_base_id, unit_type.name
)
@classmethod
def next_infantry_name(
cls, country: Country, parent_base_id: int, unit_type: UnitType[Any]
) -> str:
cls.infantry_number += 1
return "infantry|{}|{}|{}|{}|".format(
country.id,
cls.infantry_number,
parent_base_id,
unit_type.name,
)
@classmethod
def next_awacs_name(cls, country: Country) -> str:
cls.number += 1
return "awacs|{}|{}|0|".format(country.id, cls.number)
@classmethod
def next_tanker_name(cls, country: Country, unit_type: AircraftType) -> str:
cls.number += 1
return "tanker|{}|{}|0|{}".format(country.id, cls.number, unit_type.name)
@classmethod
def next_carrier_name(cls, country: Country) -> str:
cls.number += 1
return "carrier|{}|{}|0|".format(country.id, cls.number)
@classmethod
def next_convoy_name(cls) -> str:
cls.convoy_number += 1
return f"Convoy {cls.convoy_number:03}"
@classmethod
def next_cargo_ship_name(cls) -> str:
cls.cargo_ship_number += 1
return f"Cargo Ship {cls.cargo_ship_number:03}"
@classmethod
def random_objective_name(cls) -> str:
if cls.animals:
animal = random.choice(cls.animals)
cls.animals.remove(animal)
return animal
for _ in range(10):
alpha = random.choice(ALPHA_MILITARY).upper()
number = random.randint(0, 100)
alpha_mil_name = f"{alpha} #{number:02}"
if alpha_mil_name not in cls.existing_alphas:
cls.existing_alphas.append(alpha_mil_name)
return alpha_mil_name
# At this point, give up trying - something has gone wrong and we haven't been
# able to make a new name in 10 tries. We'll just make a longer name using the
# current unix epoch in nanoseconds. That should be unique... right?
last_chance_name = alpha_mil_name + str(time.time_ns())
cls.existing_alphas.append(last_chance_name)
return last_chance_name
namegen = NameGenerator

134
game/runways.py Normal file
View File

@@ -0,0 +1,134 @@
"""Runway information and selection."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Iterator, Optional, TYPE_CHECKING
from dcs.terrain.terrain import Airport
from game.airfields import AirfieldData
from game.radio.radios import RadioFrequency
from game.radio.tacan import TacanChannel
from game.utils import Heading
from game.weather import Conditions
if TYPE_CHECKING:
from game.theater import ConflictTheater
@dataclass(frozen=True)
class RunwayData:
airfield_name: str
runway_heading: Heading
runway_name: str
atc: Optional[RadioFrequency] = None
tacan: Optional[TacanChannel] = None
tacan_callsign: Optional[str] = None
ils: Optional[RadioFrequency] = None
icls: Optional[int] = None
@classmethod
def for_airfield(
cls,
theater: ConflictTheater,
airport: Airport,
runway_heading: Heading,
runway_name: str,
) -> RunwayData:
"""Creates RunwayData for the given runway of an airfield.
Args:
theater: The theater the airport is in.
airport: The airfield the runway belongs to.
runway_heading: Heading of the runway.
runway_name: Identifier of the runway to use. e.g. "03" or "20L".
"""
atc: Optional[RadioFrequency] = None
tacan: Optional[TacanChannel] = None
tacan_callsign: Optional[str] = None
ils: Optional[RadioFrequency] = None
try:
airfield = AirfieldData.for_airport(theater, airport)
if airfield.atc is not None:
atc = airfield.atc.uhf
else:
atc = None
tacan = airfield.tacan
tacan_callsign = airfield.tacan_callsign
ils = airfield.ils_freq(runway_name)
except KeyError:
logging.warning(f"No airfield data for {airport.name} ({airport.id}")
return cls(
airfield_name=airport.name,
runway_heading=runway_heading,
runway_name=runway_name,
atc=atc,
tacan=tacan,
tacan_callsign=tacan_callsign,
ils=ils,
)
@classmethod
def for_pydcs_airport(
cls, theater: ConflictTheater, airport: Airport
) -> Iterator[RunwayData]:
for runway in airport.runways:
runway_number = runway.heading // 10
runway_side = ["", "L", "R"][runway.leftright]
runway_name = f"{runway_number:02}{runway_side}"
yield cls.for_airfield(
theater, airport, Heading.from_degrees(runway.heading), runway_name
)
# pydcs only exposes one runway per physical runway, so to expose
# both sides of the runway we need to generate the other.
heading = Heading.from_degrees(runway.heading).opposite
runway_number = heading.degrees // 10
runway_side = ["", "R", "L"][runway.leftright]
runway_name = f"{runway_number:02}{runway_side}"
yield cls.for_airfield(theater, airport, heading, runway_name)
class RunwayAssigner:
def __init__(self, conditions: Conditions):
self.conditions = conditions
def angle_off_headwind(self, runway: RunwayData) -> Heading:
wind = Heading.from_degrees(self.conditions.weather.wind.at_0m.direction)
ideal_heading = wind.opposite
return runway.runway_heading.angle_between(ideal_heading)
def get_preferred_runway(
self, theater: ConflictTheater, airport: Airport
) -> RunwayData:
"""Returns the preferred runway for the given airport.
Right now we're only selecting runways based on whether or not
they have
ILS, but we could also choose based on wind conditions, or which
direction flight plans should follow.
"""
runways = list(RunwayData.for_pydcs_airport(theater, airport))
# Find the runway with the best headwind first.
best_runways = [runways[0]]
best_angle_off_headwind = self.angle_off_headwind(best_runways[0])
for runway in runways[1:]:
angle_off_headwind = self.angle_off_headwind(runway)
if angle_off_headwind == best_angle_off_headwind:
best_runways.append(runway)
elif angle_off_headwind < best_angle_off_headwind:
best_runways = [runway]
best_angle_off_headwind = angle_off_headwind
for runway in best_runways:
# But if there are multiple runways with the same heading,
# prefer
# and ILS capable runway.
if runway.ils is not None:
return runway
# Otherwise the only difference between the two is the distance from
# parking, which we don't know, so just pick the first one.
return best_runways[0]

View File

@@ -6,7 +6,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint
from game import Game
from game.server import GameContext
from game.server.leaflet import LeafletPoly, ShapelyUtil
from gen.flights.flightplan import CasFlightPlan, PatrollingFlightPlan
from game.ato.flightplan import CasFlightPlan, PatrollingFlightPlan
router: APIRouter = APIRouter(prefix="/flights")

View File

@@ -16,7 +16,7 @@ from game.ato.flightstate import (
WaitingForStart,
)
from game.ato.starttype import StartType
from gen.flights.traveltime import TotEstimator
from game.ato.traveltime import TotEstimator
from .combat import CombatInitiator, FrozenCombat
if TYPE_CHECKING:

View File

@@ -4,8 +4,8 @@ import logging
from typing import TYPE_CHECKING
from game.debriefing import Debriefing
from game.ground_forces.combat_stance import CombatStance
from game.theater import ControlPoint
from gen.ground_forces.combat_stance import CombatStance
from ..ato.airtaaskingorder import AirTaskingOrder
if TYPE_CHECKING:

View File

@@ -5,9 +5,8 @@ from collections import defaultdict
from typing import Sequence, Iterator, TYPE_CHECKING, Optional
from game.dcs.aircrafttype import AircraftType
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from .squadrondef import SquadronDef
from game.ato.ai_flight_planner_db import aircraft_for_task
from game.ato.closestairfields import ObjectiveDistanceCache
from .squadrondefloader import SquadronDefLoader
from ..campaignloader.squadrondefgenerator import SquadronDefGenerator
from ..factions.faction import Faction

View File

@@ -10,7 +10,7 @@ from faker import Faker
from game.ato import Flight, FlightType, Package
from game.settings import AutoAtoBehavior, Settings
from gen.flights.flightplan import FlightPlanBuilder
from game.ato.flightplan import FlightPlanBuilder
from .pilot import Pilot, PilotStatus
from ..db.database import Database
from ..utils import meters

View File

@@ -62,7 +62,7 @@ class SquadronDef:
@classmethod
def from_yaml(cls, path: Path) -> SquadronDef:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
from game.ato.ai_flight_planner_db import tasks_for_aircraft
from game.ato import FlightType
with path.open(encoding="utf8") as squadron_file:

View File

@@ -27,13 +27,12 @@ from dcs.ships import Forrestal, KUZNECOW, LHA_Tarawa, Stennis, Type_071
from dcs.terrain.terrain import Airport, ParkingSlot
from dcs.unitgroup import ShipGroup, StaticGroup
from game.dcs.helpers import unit_type_from_name
from game.ground_forces.combat_stance import CombatStance
from game.point_with_heading import PointWithHeading
from game.runways import RunwayAssigner, RunwayData
from game.scenery_group import SceneryGroup
from game.utils import Heading
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.ground_forces.combat_stance import CombatStance
from gen.runways import RunwayAssigner, RunwayData
from game.ato.closestairfields import ObjectiveDistanceCache
from .base import Base
from .missiontarget import MissionTarget
from .theatergroundobject import (

View File

@@ -4,21 +4,20 @@ import logging
import random
from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional
from typing import List
import dcs.statics
from game import Game
from game.factions.faction import Faction
from game.naming import namegen
from game.scenery_group import SceneryGroup
from game.theater import PointWithHeading
from game.theater.theatergroundobject import (
BuildingGroundObject,
)
from .theatergroup import SceneryUnit, TheaterGroup
from game.utils import Heading
from game.version import VERSION
from gen.naming import namegen
from . import (
ConflictTheater,
ControlPoint,
@@ -26,10 +25,11 @@ from . import (
Fob,
OffMapSpawn,
)
from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig
from ..data.groups import GroupRole, GroupTask
from ..armedforces.forcegroup import ForceGroup
from .theatergroup import SceneryUnit, TheaterGroup
from ..armedforces.armedforces import ArmedForces
from ..armedforces.forcegroup import ForceGroup
from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig
from ..data.groups import GroupTask
from ..profiling import logged_duration
from ..settings import Settings

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from functools import singledispatchmethod
from typing import Optional, TYPE_CHECKING, Union, Iterable, Any
from typing import Optional, TYPE_CHECKING, Union, Iterable
from dcs.mapping import Point as DcsPoint
from shapely.geometry import (
@@ -16,7 +16,7 @@ from shapely.ops import nearest_points, unary_union
from game.data.doctrine import Doctrine
from game.theater import ControlPoint, MissionTarget, TheaterGroundObject
from game.utils import Distance, meters, nautical_miles
from gen.flights.closestairfields import ObjectiveDistanceCache
from game.ato.closestairfields import ObjectiveDistanceCache
from game.ato import Flight, FlightWaypoint
if TYPE_CHECKING:

View File

@@ -45,6 +45,7 @@ from game.ato.flighttype import FlightType
from game.ato.package import Package
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.naming import namegen
from game.procurement import AircraftProcurementRequest
from game.theater import ControlPoint, MissionTarget
from game.theater.transitnetwork import (
@@ -52,10 +53,9 @@ from game.theater.transitnetwork import (
TransitNetwork,
)
from game.utils import meters, nautical_miles
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flightplan import FlightPlanBuilder
from gen.naming import namegen
from game.ato.ai_flight_planner_db import aircraft_for_task
from game.ato.closestairfields import ObjectiveDistanceCache
from game.ato.flightplan import FlightPlanBuilder
if TYPE_CHECKING:
from game import Game