Merge remote-tracking branch 'remotes/dcs-retribution/dcs-retribution/dev' into pretense-generator

This commit is contained in:
MetalStormGhost
2024-05-03 11:19:48 +03:00
151 changed files with 13076 additions and 6698 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import random
import uuid
from collections.abc import Iterator
from datetime import datetime, timedelta
@@ -9,6 +10,7 @@ from dcs import Point
from dcs.planes import C_101CC, C_101EB, Su_33, FA_18C_hornet
from game.dcs.aircrafttype import AircraftType
from game.theater import ControlPoint, MissionTarget
from pydcs_extensions.hercules.hercules import Hercules
from .flightmembers import FlightMembers
from .flightroster import FlightRoster
@@ -33,7 +35,6 @@ if TYPE_CHECKING:
from game.sim.gameupdateevents import GameUpdateEvents
from game.sim.simulationresults import SimulationResults
from game.squadrons import Squadron, Pilot
from game.theater import ControlPoint
from game.transfers import TransferOrder
from game.data.weapons import WeaponType
from .flightmember import FlightMember
@@ -88,6 +89,7 @@ class Flight(
self.initialize_fuel()
self.use_same_loadout_for_all_members = True
self.use_same_livery_for_all_members = True
# Only used by transport missions.
self.cargo = cargo
@@ -123,6 +125,11 @@ class Flight(
)
)
# altitude offset for planes
offset_factor = self.coalition.game.settings.max_plane_altitude_offset
offset_factor = random.randint(0, offset_factor)
self.plane_altitude_offset = 1000 * offset_factor * random.choice([-1, 1])
@property
def available_callsigns(self) -> List[str]:
callsigns = set()
@@ -218,6 +225,13 @@ class Flight(
def points(self) -> List[FlightWaypoint]:
return self.flight_plan.waypoints[1:]
@property
def custom_targets(self) -> List[MissionTarget]:
return [
MissionTarget(wpt.name, wpt.position)
for wpt in self.flight_plan.layout.custom_waypoints
]
def position(self) -> Point:
return self.state.estimate_position()

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Optional
from game.ato.loadouts import Loadout
from game.lasercodes import LaserCode
@@ -17,6 +17,7 @@ class FlightMember:
self.tgp_laser_code: LaserCode | None = None
self.weapon_laser_code: LaserCode | None = None
self.properties: dict[str, bool | float | int] = {}
self.livery: Optional[str] = None
def __setstate__(self, state: dict[str, Any]) -> None:
if "tgp_laser_code" not in state:

View File

@@ -95,6 +95,13 @@ class FlightMembers(IFlightRoster):
# across all flight members.
member.loadout = loadout
def use_same_livery_for_all_members(self) -> None:
if not self.members:
return
livery = self.members[0].livery
for member in self.members[1:]:
member.livery = livery
def use_distinct_loadouts_for_each_member(self) -> None:
for member in self.members:
member.loadout = member.loadout.clone()

View File

@@ -6,7 +6,7 @@ from typing import Type
from game.ato.flightplans.ibuilder import IBuilder
from game.ato.flightplans.patrolling import PatrollingFlightPlan, PatrollingLayout
from game.ato.flightplans.waypointbuilder import WaypointBuilder
from game.utils import Distance, Heading, Speed, feet, knots, meters, nautical_miles
from game.utils import Distance, Heading, Speed, knots, meters, nautical_miles
class AewcFlightPlan(PatrollingFlightPlan[PatrollingLayout]):
@@ -70,10 +70,7 @@ class Builder(IBuilder[AewcFlightPlan, PatrollingLayout]):
builder = WaypointBuilder(self.flight)
if self.flight.unit_type.patrol_altitude is not None:
altitude = self.flight.unit_type.patrol_altitude
else:
altitude = feet(25000)
altitude = builder.get_patrol_altitude
racetrack = builder.race_track(racetrack_start, racetrack_end, altitude)
@@ -90,6 +87,7 @@ class Builder(IBuilder[AewcFlightPlan, PatrollingLayout]):
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> AewcFlightPlan:

View File

@@ -49,6 +49,7 @@ class AirAssaultLayout(FormationAttackLayout):
if self.divert is not None:
yield self.divert
yield self.bullseye
yield from self.custom_waypoints
class AirAssaultFlightPlan(FormationAttackFlightPlan, UiZoneDisplay):
@@ -111,12 +112,11 @@ class Builder(FormationAttackBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
)
assert self.package.waypoints is not None
heli_alt = feet(self.coalition.game.settings.heli_cruise_alt_agl)
altitude = heli_alt if self.flight.is_helo else self.doctrine.ingress_altitude
altitude_is_agl = self.flight.is_helo
builder = WaypointBuilder(self.flight)
altitude = builder.get_cruise_altitude
altitude_is_agl = self.flight.is_helo
if self.flight.is_hercules or self.flight.departure.cptype in [
ControlPointType.AIRCRAFT_CARRIER_GROUP,
ControlPointType.LHA_GROUP,
@@ -133,13 +133,21 @@ class Builder(FormationAttackBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
self._generate_ctld_pickup(),
)
)
pickup.alt = heli_alt
pickup.alt = altitude
pickup_position = pickup.position
ingress = builder.ingress(
FlightWaypointType.INGRESS_AIR_ASSAULT,
self.package.waypoints.ingress,
self.package.target,
ingress = (
builder.ingress(
FlightWaypointType.INGRESS_AIR_ASSAULT,
self.package.waypoints.ingress,
self.package.target,
)
if not self.flight.is_hercules
else builder.ingress(
FlightWaypointType.INGRESS_AIR_ASSAULT,
self.package.waypoints.initial,
self.package.target,
)
)
assault_area = builder.assault_area(self.package.target)
@@ -159,8 +167,6 @@ class Builder(FormationAttackBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
drop_pos = tgt.position.point_from_heading(heading, 1200)
drop_off_zone = MissionTarget("Dropoff zone", drop_pos)
dz = builder.dropoff_zone(drop_off_zone) if self.flight.is_helo else None
if dz:
dz.alt = heli_alt
return AirAssaultLayout(
departure=builder.takeoff(self.flight.departure),
@@ -184,9 +190,10 @@ class Builder(FormationAttackBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
hold=None,
join=builder.join(ingress.position),
join=builder.join(self.package.waypoints.ingress),
split=builder.split(self.flight.arrival.position),
refuel=None,
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> AirAssaultFlightPlan:

View File

@@ -8,7 +8,7 @@ from typing import Optional
from typing import TYPE_CHECKING, Type
from game.theater.missiontarget import MissionTarget
from game.utils import feet, Distance
from game.utils import Distance
from ._common_ctld import generate_random_ctld_point
from .ibuilder import IBuilder
from .planningerror import PlanningError
@@ -92,6 +92,7 @@ class AirliftLayout(StandardLayout):
if self.divert is not None:
yield self.divert
yield self.bullseye
yield from self.custom_waypoints
class AirliftFlightPlan(StandardFlightPlan[AirliftLayout]):
@@ -132,12 +133,11 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]):
"Cannot plan transport mission for flight with no cargo."
)
heli_alt = feet(self.coalition.game.settings.heli_cruise_alt_agl)
altitude = heli_alt if self.flight.is_helo else self.doctrine.ingress_altitude
altitude_is_agl = self.flight.is_helo
builder = WaypointBuilder(self.flight)
altitude = builder.get_cruise_altitude
altitude_is_agl = self.flight.is_helo
pickup_ascent = None
pickup_descent = None
pickup = None
@@ -246,6 +246,7 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]):
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> AirliftFlightPlan:

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
import random
from datetime import timedelta
from typing import Type
from game.theater import FrontLine
from game.utils import Distance, Speed, feet
from game.utils import Distance, Speed
from .capbuilder import CapBuilder
from .invalidobjectivelocation import InvalidObjectiveLocation
from .patrolling import PatrollingFlightPlan, PatrollingLayout
@@ -41,14 +40,9 @@ class Builder(CapBuilder[BarCapFlightPlan, PatrollingLayout]):
start_pos, end_pos = self.cap_racetrack_for_objective(location, barcap=True)
preferred_alt = self.flight.unit_type.preferred_patrol_altitude
randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
patrol_alt = max(
self.doctrine.min_patrol_altitude,
min(self.doctrine.max_patrol_altitude, randomized_alt),
)
builder = WaypointBuilder(self.flight)
patrol_alt = builder.get_patrol_altitude
start, end = builder.race_track(start_pos, end_pos, patrol_alt)
return PatrollingLayout(
@@ -64,6 +58,7 @@ class Builder(CapBuilder[BarCapFlightPlan, PatrollingLayout]):
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> BarCapFlightPlan:

View File

@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Type
from game.theater import FrontLine
from game.utils import Distance, Speed, kph, dcs_to_shapely_point
from game.utils import feet, nautical_miles
from game.utils import nautical_miles
from .ibuilder import IBuilder
from .invalidobjectivelocation import InvalidObjectiveLocation
from .patrolling import PatrollingFlightPlan, PatrollingLayout
@@ -37,6 +37,7 @@ class CasLayout(PatrollingLayout):
if self.divert is not None:
yield self.divert
yield self.bullseye
yield from self.custom_waypoints
class CasFlightPlan(PatrollingFlightPlan[CasLayout], UiZoneDisplay):
@@ -104,11 +105,7 @@ class Builder(IBuilder[CasFlightPlan, CasLayout]):
builder = WaypointBuilder(self.flight)
is_helo = self.flight.unit_type.dcs_unit_type.helicopter
ingress_egress_altitude = (
self.doctrine.ingress_altitude
if not is_helo
else feet(self.coalition.game.settings.heli_combat_alt_agl)
)
ingress_egress_altitude = builder.get_combat_altitude
use_agl_patrol_altitude = is_helo
ip_solver = IpSolver(
@@ -167,6 +164,7 @@ class Builder(IBuilder[CasFlightPlan, CasLayout]):
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> CasFlightPlan:

View File

@@ -17,8 +17,6 @@ if TYPE_CHECKING:
@dataclass
class CustomLayout(Layout):
custom_waypoints: list[FlightWaypoint]
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield self.departure
yield from self.custom_waypoints

View File

@@ -11,7 +11,6 @@ from .formationattack import (
)
from .waypointbuilder import WaypointBuilder
from .. import FlightType
from ...utils import feet
class EscortFlightPlan(FormationAttackFlightPlan):
@@ -43,12 +42,9 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]):
split = builder.split(self._get_split())
ingress_alt = self.doctrine.ingress_altitude
is_helo = builder.flight.is_helo
heli_alt = feet(self.coalition.game.settings.heli_combat_alt_agl)
initial = builder.escort_hold(
target.position if is_helo else self.package.waypoints.initial,
min(heli_alt, ingress_alt) if is_helo else ingress_alt,
)
pf = self.package.primary_flight
@@ -69,9 +65,6 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]):
if layout.drop_off:
initial = builder.escort_hold(
layout.drop_off.position,
min(feet(200), ingress_alt)
if builder.flight.is_helo
else ingress_alt,
)
refuel = self._build_refuel(builder)
@@ -80,13 +73,13 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]):
nav_to = builder.nav_path(
hold.position if hold else departure.position,
join.position,
self.doctrine.ingress_altitude,
builder.get_cruise_altitude,
)
nav_from = builder.nav_path(
refuel.position if refuel else split.position,
self.flight.arrival.position,
self.doctrine.ingress_altitude,
builder.get_cruise_altitude,
)
return FormationAttackLayout(
@@ -103,6 +96,7 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]):
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> EscortFlightPlan:

View File

@@ -24,6 +24,7 @@ class FerryLayout(StandardLayout):
if self.divert is not None:
yield self.divert
yield self.bullseye
yield from self.custom_waypoints
class FerryFlightPlan(StandardFlightPlan[FerryLayout]):
@@ -60,14 +61,14 @@ class Builder(IBuilder[FerryFlightPlan, FerryLayout]):
f"{self.flight.departure}"
)
builder = WaypointBuilder(self.flight)
altitude_is_agl = self.flight.is_helo
altitude = (
feet(self.coalition.game.settings.heli_cruise_alt_agl)
if altitude_is_agl
else self.flight.unit_type.preferred_patrol_altitude
else builder.get_patrol_altitude
)
builder = WaypointBuilder(self.flight)
return FerryLayout(
departure=builder.takeoff(self.flight.departure),
nav_to=builder.nav_path(
@@ -80,6 +81,7 @@ class Builder(IBuilder[FerryFlightPlan, FerryLayout]):
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
nav_from=[],
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> FerryFlightPlan:

View File

@@ -34,6 +34,7 @@ if TYPE_CHECKING:
@dataclass
class Layout(ABC):
departure: FlightWaypoint
custom_waypoints: list[FlightWaypoint]
@property
def waypoints(self) -> list[FlightWaypoint]:

View File

@@ -11,7 +11,7 @@ from dcs import Point
from game.flightplan import HoldZoneGeometry
from game.theater import MissionTarget
from game.utils import Speed, meters, nautical_miles, feet
from game.utils import Speed, meters, nautical_miles
from .flightplan import FlightPlan
from .formation import FormationFlightPlan, FormationLayout
from .ibuilder import IBuilder
@@ -157,6 +157,7 @@ class FormationAttackLayout(FormationLayout):
if self.divert is not None:
yield self.divert
yield self.bullseye
yield from self.custom_waypoints
FlightPlanT = TypeVar("FlightPlanT", bound=FlightPlan[FormationAttackLayout])
@@ -209,14 +210,10 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
if self.flight.flight_type == FlightType.STRIKE:
hdg = self.package.target.position.heading_between_point(ingress.position)
pos = ingress.position.point_from_heading(hdg, nautical_miles(10).meters)
lineup = builder.nav(pos, self.flight.coalition.doctrine.ingress_altitude)
lineup = builder.nav(pos, builder.get_combat_altitude)
is_helo = self.flight.is_helo
ingress_egress_altitude = (
self.doctrine.ingress_altitude
if not is_helo
else feet(self.coalition.game.settings.heli_combat_alt_agl)
)
ingress_egress_altitude = builder.get_combat_altitude
use_agl_ingress_egress = is_helo
return FormationAttackLayout(
@@ -244,6 +241,7 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
custom_waypoints=list(),
)
def _build_refuel(self, builder: WaypointBuilder) -> Optional[FlightWaypoint]:

View File

@@ -25,6 +25,7 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
def __init__(self, flight: Flight) -> None:
self.flight = flight
self._flight_plan: FlightPlanT | None = None
self.settings = self.flight.coalition.game.settings
def get_or_build(self) -> FlightPlanT:
if self._flight_plan is None:

View File

@@ -5,7 +5,7 @@ from typing import Type
from dcs import Point
from game.utils import Distance, Heading, feet, meters
from game.utils import Distance, Heading, meters
from .ibuilder import IBuilder
from .patrolling import PatrollingLayout
from .refuelingflightplan import RefuelingFlightPlan
@@ -98,11 +98,7 @@ class Builder(IBuilder[PackageRefuelingFlightPlan, PatrollingLayout]):
builder = WaypointBuilder(self.flight)
tanker_type = self.flight.unit_type
if tanker_type.patrol_altitude is not None:
altitude = tanker_type.patrol_altitude
else:
altitude = feet(21000)
altitude = builder.get_patrol_altitude
racetrack = builder.race_track(racetrack_start, racetrack_end, altitude)
@@ -119,6 +115,7 @@ class Builder(IBuilder[PackageRefuelingFlightPlan, PatrollingLayout]):
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> PackageRefuelingFlightPlan:

View File

@@ -31,6 +31,7 @@ class PatrollingLayout(StandardLayout):
if self.divert is not None:
yield self.divert
yield self.bullseye
yield from self.custom_waypoints
LayoutT = TypeVar("LayoutT", bound=PatrollingLayout)

View File

@@ -27,6 +27,7 @@ class RtbLayout(StandardLayout):
if self.divert is not None:
yield self.divert
yield self.bullseye
yield from self.custom_waypoints
class RtbFlightPlan(StandardFlightPlan[RtbLayout]):
@@ -65,13 +66,13 @@ class Builder(IBuilder[RtbFlightPlan, RtbLayout]):
current_position = self.flight.state.estimate_position()
current_altitude, altitude_reference = self.flight.state.estimate_altitude()
builder = WaypointBuilder(self.flight)
altitude_is_agl = self.flight.is_helo
altitude = (
feet(self.coalition.game.settings.heli_cruise_alt_agl)
if altitude_is_agl
else self.flight.unit_type.preferred_patrol_altitude
else builder.get_patrol_altitude
)
builder = WaypointBuilder(self.flight)
abort_point = builder.nav(
current_position, current_altitude, altitude_reference == "RADIO"
)
@@ -91,6 +92,7 @@ class Builder(IBuilder[RtbFlightPlan, RtbLayout]):
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
nav_from=[],
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> RtbFlightPlan:

View File

@@ -70,6 +70,9 @@ class StandardLayout(Layout, ABC):
elif waypoint in self.nav_from:
self.nav_from.remove(waypoint)
return True
elif waypoint in self.custom_waypoints:
self.custom_waypoints.remove(waypoint)
return True
return False

View File

@@ -11,6 +11,7 @@ from .formationattack import (
from .invalidobjectivelocation import InvalidObjectiveLocation
from .waypointbuilder import StrikeTarget
from ..flightwaypointtype import FlightWaypointType
from ...theater.theatergroup import SceneryUnit
class StrikeFlightPlan(FormationAttackFlightPlan):
@@ -28,7 +29,10 @@ class Builder(FormationAttackBuilder[StrikeFlightPlan, FormationAttackLayout]):
targets: list[StrikeTarget] = []
for idx, unit in enumerate(location.strike_targets):
targets.append(StrikeTarget(f"{unit.type.id} #{idx}", unit))
name = unit.type.id
if isinstance(unit, SceneryUnit):
name = unit.name
targets.append(StrikeTarget(f"{name} #{idx}", unit))
return self._build(FlightWaypointType.INGRESS_STRIKE, targets)

View File

@@ -35,6 +35,7 @@ class SweepLayout(LoiterLayout):
if self.divert is not None:
yield self.divert
yield self.bullseye
yield from self.custom_waypoints
class SweepFlightPlan(LoiterFlightPlan):
@@ -104,26 +105,27 @@ class Builder(IBuilder[SweepFlightPlan, SweepLayout]):
)
builder = WaypointBuilder(self.flight)
start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude)
altitude = builder.get_patrol_altitude
start, end = builder.sweep(start_pos, target, altitude)
hold = builder.hold(self._hold_point())
return SweepLayout(
departure=builder.takeoff(self.flight.departure),
hold=hold,
nav_to=builder.nav_path(
hold.position, start.position, self.doctrine.ingress_altitude
),
nav_to=builder.nav_path(hold.position, start.position, altitude),
nav_from=builder.nav_path(
end.position,
self.flight.arrival.position,
self.doctrine.ingress_altitude,
altitude,
),
sweep_start=start,
sweep_end=end,
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
custom_waypoints=list(),
)
def _hold_point(self) -> Point:

View File

@@ -1,12 +1,11 @@
from __future__ import annotations
import random
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Type
from game.utils import Distance, Speed, feet
from game.utils import Distance, Speed
from .capbuilder import CapBuilder
from .patrolling import PatrollingFlightPlan, PatrollingLayout
from .waypointbuilder import WaypointBuilder
@@ -31,6 +30,7 @@ class TarCapLayout(PatrollingLayout):
if self.divert is not None:
yield self.divert
yield self.bullseye
yield from self.custom_waypoints
def delete_waypoint(self, waypoint: FlightWaypoint) -> bool:
if waypoint == self.refuel:
@@ -95,14 +95,9 @@ class Builder(CapBuilder[TarCapFlightPlan, TarCapLayout]):
def layout(self) -> TarCapLayout:
location = self.package.target
preferred_alt = self.flight.unit_type.preferred_patrol_altitude
randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
patrol_alt = max(
self.doctrine.min_patrol_altitude,
min(self.doctrine.max_patrol_altitude, randomized_alt),
)
builder = WaypointBuilder(self.flight)
patrol_alt = builder.get_patrol_altitude
orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False)
start, end = builder.race_track(orbit0p, orbit1p, patrol_alt)
@@ -128,6 +123,7 @@ class Builder(CapBuilder[TarCapFlightPlan, TarCapLayout]):
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> TarCapFlightPlan:

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import timedelta
from typing import Type
from game.utils import Heading, feet, meters, nautical_miles
from game.utils import Heading, meters, nautical_miles
from .ibuilder import IBuilder
from .patrolling import PatrollingLayout
from .refuelingflightplan import RefuelingFlightPlan
@@ -58,11 +58,7 @@ class Builder(IBuilder[TheaterRefuelingFlightPlan, PatrollingLayout]):
builder = WaypointBuilder(self.flight)
tanker_type = self.flight.unit_type
if tanker_type.patrol_altitude is not None:
altitude = tanker_type.patrol_altitude
else:
altitude = feet(21000)
altitude = builder.get_patrol_altitude
racetrack = builder.race_track(racetrack_start, racetrack_end, altitude)
@@ -79,6 +75,7 @@ class Builder(IBuilder[TheaterRefuelingFlightPlan, PatrollingLayout]):
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
custom_waypoints=list(),
)
def build(self, dump_debug_info: bool = False) -> TheaterRefuelingFlightPlan:

View File

@@ -26,6 +26,8 @@ from game.theater import (
)
from game.utils import Distance, meters, nautical_miles, feet
AGL_TRANSITION_ALT = 5000
if TYPE_CHECKING:
from game.transfers import MultiGroupTransport
from game.theater.theatergroup import TheaterGroup
@@ -51,11 +53,34 @@ class WaypointBuilder:
self.navmesh = coalition.nav_mesh
self.targets = targets
self._bullseye = coalition.bullseye
self.settings = self.flight.coalition.game.settings
@property
def is_helo(self) -> bool:
return self.flight.is_helo
@property
def get_patrol_altitude(self) -> Distance:
return self.get_altitude(self.flight.unit_type.preferred_patrol_altitude)
@property
def get_cruise_altitude(self) -> Distance:
return self.get_altitude(self.flight.unit_type.preferred_cruise_altitude)
@property
def get_combat_altitude(self) -> Distance:
return self.get_altitude(self.flight.unit_type.preferred_combat_altitude)
def get_altitude(self, alt: Distance) -> Distance:
randomized_alt = feet(round(alt.feet + self.flight.plane_altitude_offset))
altitude = max(
self.doctrine.min_combat_altitude,
min(self.doctrine.max_combat_altitude, randomized_alt),
)
return (
feet(self.settings.heli_combat_alt_agl) if self.flight.is_helo else altitude
)
def takeoff(self, departure: ControlPoint) -> FlightWaypoint:
"""Create takeoff waypoint for the given arrival airfield or carrier.
@@ -72,9 +97,7 @@ class WaypointBuilder:
"NAV",
FlightWaypointType.NAV,
position,
feet(self.flight.coalition.game.settings.heli_cruise_alt_agl)
if self.is_helo
else self.doctrine.rendezvous_altitude,
self.get_cruise_altitude,
description="Enter theater",
pretty_name="Enter theater",
)
@@ -101,9 +124,7 @@ class WaypointBuilder:
"NAV",
FlightWaypointType.NAV,
position,
feet(self.flight.coalition.game.settings.heli_cruise_alt_agl)
if self.is_helo
else self.doctrine.rendezvous_altitude,
self.get_cruise_altitude,
description="Exit theater",
pretty_name="Exit theater",
)
@@ -129,14 +150,10 @@ class WaypointBuilder:
return None
position = divert.position
altitude_type: AltitudeReference
altitude_type: AltitudeReference = "BARO"
if isinstance(divert, OffMapSpawn):
altitude = (
feet(self.flight.coalition.game.settings.heli_cruise_alt_agl)
if self.is_helo
else self.doctrine.rendezvous_altitude
)
altitude_type = "BARO"
altitude = self.get_cruise_altitude
altitude_type = "RADIO" if self.is_helo else altitude_type
else:
altitude = meters(0)
altitude_type = "RADIO"
@@ -166,16 +183,15 @@ class WaypointBuilder:
def hold(self, position: Point) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
if self.is_helo or self.get_combat_altitude.feet <= AGL_TRANSITION_ALT:
alt_type = "RADIO"
return FlightWaypoint(
"HOLD",
FlightWaypointType.LOITER,
position,
feet(self.flight.coalition.game.settings.heli_cruise_alt_agl)
if self.is_helo
else self.doctrine.ingress_altitude,
# TODO: dedicated altitude setting for holding
self.get_cruise_altitude if self.is_helo else self.get_combat_altitude,
alt_type,
description="Wait until push time",
pretty_name="Hold",
@@ -183,16 +199,14 @@ class WaypointBuilder:
def join(self, position: Point) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
if self.is_helo or self.get_cruise_altitude.feet <= AGL_TRANSITION_ALT:
alt_type = "RADIO"
return FlightWaypoint(
"JOIN",
FlightWaypointType.JOIN,
position,
feet(self.flight.coalition.game.settings.heli_cruise_alt_agl)
if self.is_helo
else self.doctrine.ingress_altitude,
self.get_cruise_altitude,
alt_type,
description="Rendezvous with package",
pretty_name="Join",
@@ -200,16 +214,14 @@ class WaypointBuilder:
def refuel(self, position: Point) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
if self.is_helo or self.get_cruise_altitude.feet <= AGL_TRANSITION_ALT:
alt_type = "RADIO"
return FlightWaypoint(
"REFUEL",
FlightWaypointType.REFUEL,
position,
feet(self.flight.coalition.game.settings.heli_cruise_alt_agl)
if self.is_helo
else self.doctrine.ingress_altitude,
self.get_cruise_altitude,
alt_type,
description="Refuel from tanker",
pretty_name="Refuel",
@@ -217,16 +229,14 @@ class WaypointBuilder:
def split(self, position: Point) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
if self.is_helo or self.get_combat_altitude.feet <= AGL_TRANSITION_ALT:
alt_type = "RADIO"
return FlightWaypoint(
"SPLIT",
FlightWaypointType.SPLIT,
position,
feet(self.flight.coalition.game.settings.heli_combat_alt_agl)
if self.is_helo
else self.doctrine.ingress_altitude,
self.get_combat_altitude,
alt_type,
description="Depart from package",
pretty_name="Split",
@@ -238,7 +248,7 @@ class WaypointBuilder:
position: Point,
objective: MissionTarget,
) -> FlightWaypoint:
alt = self.doctrine.ingress_altitude
alt = self.get_combat_altitude
alt_type: AltitudeReference = "BARO"
if self.is_helo or self.flight.is_hercules:
alt_type = "RADIO"
@@ -247,6 +257,8 @@ class WaypointBuilder:
if self.is_helo
else feet(1000)
)
elif alt.feet <= AGL_TRANSITION_ALT:
alt_type = "RADIO"
heading = objective.position.heading_between_point(position)
@@ -265,16 +277,14 @@ class WaypointBuilder:
def egress(self, position: Point, target: MissionTarget) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
if self.is_helo or self.get_combat_altitude.feet <= AGL_TRANSITION_ALT:
alt_type = "RADIO"
return FlightWaypoint(
"EGRESS",
FlightWaypointType.EGRESS,
position,
feet(self.flight.coalition.game.settings.heli_combat_alt_agl)
if self.is_helo
else self.doctrine.ingress_altitude,
self.get_combat_altitude,
alt_type,
description=f"EGRESS from {target.name}",
pretty_name=f"EGRESS from {target.name}",
@@ -312,11 +322,15 @@ class WaypointBuilder:
return self._target_area(f"STRIKE {target.name}", target)
def sead_area(self, target: MissionTarget) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.get_combat_altitude.feet <= AGL_TRANSITION_ALT:
alt_type = "RADIO"
return self._target_area(
f"SEAD on {target.name}",
target,
altitude=self.doctrine.ingress_altitude,
alt_type="BARO",
altitude=self.get_combat_altitude,
alt_type=alt_type,
)
def dead_area(self, target: MissionTarget) -> FlightWaypoint:
@@ -387,11 +401,13 @@ class WaypointBuilder:
position: Position of the waypoint.
altitude: Altitude of the racetrack.
"""
baro: AltitudeReference = "BARO"
return FlightWaypoint(
"RACETRACK START",
FlightWaypointType.PATROL_TRACK,
position,
altitude,
"RADIO" if altitude.feet <= AGL_TRANSITION_ALT else baro,
description="Orbit between this point and the next point",
pretty_name="Race-track start",
)
@@ -404,11 +420,13 @@ class WaypointBuilder:
position: Position of the waypoint.
altitude: Altitude of the racetrack.
"""
baro: AltitudeReference = "BARO"
return FlightWaypoint(
"RACETRACK END",
FlightWaypointType.PATROL,
position,
altitude,
"RADIO" if altitude.feet <= AGL_TRANSITION_ALT else baro,
description="Orbit between this point and the previous point",
pretty_name="Race-track end",
)
@@ -436,42 +454,39 @@ class WaypointBuilder:
start: Position of the waypoint.
altitude: Altitude of the racetrack.
"""
baro: AltitudeReference = "BARO"
return FlightWaypoint(
"ORBIT",
FlightWaypointType.LOITER,
start,
altitude,
"RADIO" if altitude.feet <= AGL_TRANSITION_ALT else baro,
description="Anchor and hold at this point",
pretty_name="Orbit",
)
def sead_search(self, target: MissionTarget) -> FlightWaypoint:
hold = self._sead_search_point(target)
baro: AltitudeReference = "BARO"
return FlightWaypoint(
"SEAD Search",
FlightWaypointType.NAV,
hold,
feet(self.flight.coalition.game.settings.heli_combat_alt_agl)
if self.is_helo
else self.doctrine.ingress_altitude,
alt_type="BARO",
self.get_combat_altitude,
"RADIO" if self.get_combat_altitude.feet <= AGL_TRANSITION_ALT else baro,
description="Anchor and search from this point",
pretty_name="SEAD Search",
)
def sead_sweep(self, target: MissionTarget) -> FlightWaypoint:
hold = self._sead_search_point(target)
baro: AltitudeReference = "BARO"
return FlightWaypoint(
"SEAD Sweep",
FlightWaypointType.NAV,
hold,
feet(self.flight.coalition.game.settings.heli_combat_alt_agl)
if self.is_helo
else self.doctrine.ingress_altitude,
alt_type="BARO",
self.get_combat_altitude,
"RADIO" if self.get_combat_altitude.feet <= AGL_TRANSITION_ALT else baro,
description="Anchor and search from this point",
pretty_name="SEAD Sweep",
)
@@ -499,15 +514,16 @@ class WaypointBuilder:
)
return hold
def escort_hold(self, start: Point, altitude: Distance) -> FlightWaypoint:
def escort_hold(self, start: Point) -> FlightWaypoint:
"""Creates custom waypoint for escort flights that need to hold.
Args:
start: Position of the waypoint.
altitude: Altitude of the holding pattern.
"""
altitude = self.get_combat_altitude
alt_type: Literal["BARO", "RADIO"] = "BARO"
if self.is_helo:
if self.is_helo or altitude.feet <= AGL_TRANSITION_ALT:
alt_type = "RADIO"
return FlightWaypoint(
@@ -528,11 +544,13 @@ class WaypointBuilder:
position: Position of the waypoint.
altitude: Altitude of the sweep in meters.
"""
baro: AltitudeReference = "BARO"
return FlightWaypoint(
"SWEEP START",
FlightWaypointType.INGRESS_SWEEP,
position,
altitude,
"RADIO" if altitude.feet <= AGL_TRANSITION_ALT else baro,
description="Proceed to the target and engage enemy aircraft",
pretty_name="Sweep start",
)
@@ -545,11 +563,13 @@ class WaypointBuilder:
position: Position of the waypoint.
altitude: Altitude of the sweep in meters.
"""
baro: AltitudeReference = "BARO"
return FlightWaypoint(
"SWEEP END",
FlightWaypointType.EGRESS,
position,
altitude,
"RADIO" if altitude.feet <= AGL_TRANSITION_ALT else baro,
description="End of sweep",
pretty_name="Sweep end",
)
@@ -578,7 +598,7 @@ class WaypointBuilder:
target: The mission target.
"""
alt_type: AltitudeReference = "BARO"
if self.is_helo:
if self.is_helo or self.get_combat_altitude.feet <= AGL_TRANSITION_ALT:
alt_type = "RADIO"
# This would preferably be no points at all, and instead the Escort task
@@ -592,9 +612,7 @@ class WaypointBuilder:
"TARGET",
FlightWaypointType.TARGET_GROUP_LOC,
target.position,
feet(self.flight.coalition.game.settings.heli_combat_alt_agl)
if self.is_helo
else self.doctrine.ingress_altitude,
self.get_combat_altitude,
alt_type,
description="Escort the package",
pretty_name="Target area",
@@ -616,17 +634,19 @@ class WaypointBuilder:
pretty_name="Pick-up zone",
)
@staticmethod
def dropoff_zone(drop_off: MissionTarget) -> FlightWaypoint:
def dropoff_zone(self, drop_off: MissionTarget) -> FlightWaypoint:
"""Creates a dropoff landing zone waypoint
This waypoint is used to generate the Trigger Zone used for AirAssault and
AirLift using the CTLD plugin (see LogisticsGenerator)
"""
heli_alt = feet(self.flight.coalition.game.settings.heli_cruise_alt_agl)
altitude = heli_alt if self.flight.is_helo else meters(0)
return FlightWaypoint(
"DROPOFFZONE",
FlightWaypointType.DROPOFF_ZONE,
drop_off.position,
meters(0),
altitude,
"RADIO",
description=f"Drop off cargo at {drop_off.name}",
pretty_name="Drop-off zone",
@@ -660,7 +680,7 @@ class WaypointBuilder:
altitude_is_agl: True for altitude is AGL. False if altitude is MSL.
"""
alt_type: AltitudeReference = "BARO"
if altitude_is_agl:
if altitude_is_agl or altitude.feet <= AGL_TRANSITION_ALT:
alt_type = "RADIO"
return FlightWaypoint(

View File

@@ -137,11 +137,15 @@ class Loadout:
continue
name = payload["name"]
pylons = payload["pylons"]
yield Loadout(
name,
{p["num"]: Weapon.with_clsid(p["CLSID"]) for p in pylons.values()},
date=None,
)
try:
yield Loadout(
name,
{p["num"]: Weapon.with_clsid(p["CLSID"]) for p in pylons.values()},
date=None,
)
except KeyError:
# invalid loadout
continue
@staticmethod
def valid_payload(pylons: Dict[int, Dict[str, str]]) -> bool:

View File

@@ -5,7 +5,7 @@ import logging
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Tuple
from typing import Any, Dict, Tuple, Optional
import yaml
from packaging.version import Version
@@ -19,8 +19,10 @@ from game.theater.iadsnetwork.iadsnetwork import IadsNetwork
from game.theater.theaterloader import TheaterLoader
from game.version import CAMPAIGN_FORMAT_VERSION
from .campaignairwingconfig import CampaignAirWingConfig
from .campaigncarrierconfig import CampaignCarrierConfig
from .campaigngroundconfig import TgoConfig
from .mizcampaignloader import MizCampaignLoader
from ..factions import FACTIONS, Faction
PERF_FRIENDLY = 0
PERF_MEDIUM = 1
@@ -90,6 +92,16 @@ class Campaign:
f"Invalid value for recommended_start_date in {path}: {start_date_raw}"
)
player_faction = data.get("recommended_player_faction", "USA 2005")
if isinstance(player_faction, dict):
faction_name = cls.register_faction(campaign_file.name, player_faction)
player_faction = faction_name if faction_name else "USA 2005"
enemy_faction = data.get("recommended_enemy_faction", "Russia 1990")
if isinstance(enemy_faction, dict):
faction_name = cls.register_faction(campaign_file.name, enemy_faction)
enemy_faction = faction_name if faction_name else "Russia 1990"
return cls(
data["name"],
TheaterLoader(data["theater"].lower()).menu_thumbnail_dcs_relative_path,
@@ -97,8 +109,8 @@ class Campaign:
data.get("authors", "???"),
data.get("description", ""),
(version.major, version.minor),
data.get("recommended_player_faction", "USA 2005"),
data.get("recommended_enemy_faction", "Russia 1990"),
player_faction,
enemy_faction,
start_date,
start_time,
data.get("recommended_player_money", DEFAULT_BUDGET),
@@ -112,6 +124,19 @@ class Campaign:
data.get("settings", {}),
)
@classmethod
def register_faction(
cls, filename: str, player_faction: dict[str, Any]
) -> Optional[str]:
try:
f = Faction.from_dict(player_faction)
FACTIONS.factions[f.name] = f
logging.info(f"Loaded faction from campaign: {filename}")
return f.name
except Exception:
logging.exception(f"Unable to load faction from campaign: {filename}")
return None
def load_theater(self, advanced_iads: bool) -> ConflictTheater:
t = TheaterLoader(self.data["theater"].lower()).load()
@@ -140,6 +165,13 @@ class Campaign:
return CampaignAirWingConfig({})
return CampaignAirWingConfig.from_campaign_data(squadron_data, theater)
def load_carrier_config(self) -> CampaignCarrierConfig:
try:
carrier_data = self.data["carriers"]
except KeyError:
return CampaignCarrierConfig({})
return CampaignCarrierConfig.from_campaign_data(carrier_data)
def load_ground_forces_config(self) -> TgoConfig:
ground_forces = self.data.get("ground_forces", {})
if not ground_forces:

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING
from game.dcs.shipunittype import ShipUnitType
if TYPE_CHECKING:
pass
@dataclass(frozen=True)
class CarrierConfig:
preferred_name: str
preferred_type: ShipUnitType
@classmethod
def from_data(cls, data: dict[str, Any]) -> CarrierConfig:
return CarrierConfig(
str(data["preferred_name"]), ShipUnitType.named(data["preferred_type"])
)
@dataclass(frozen=True)
class CampaignCarrierConfig:
by_original_name: dict[str, CarrierConfig]
@classmethod
def from_campaign_data(cls, data: dict[str, Any]) -> CampaignCarrierConfig:
by_original_name: dict[str, CarrierConfig] = defaultdict()
for original_name, carrier_config_data in data.items():
try:
carrier_config = CarrierConfig.from_data(carrier_config_data)
by_original_name[original_name] = carrier_config
except KeyError:
logging.warning(
f"Skipping invalid carrier config for '{original_name}'"
)
return CampaignCarrierConfig(by_original_name)

View File

@@ -47,6 +47,7 @@ class SquadronDefGenerator:
role="Flying Squadron",
aircraft=aircraft,
livery=None,
livery_set=[],
auto_assignable_mission_types=set(aircraft.iter_task_capabilities()),
radio_presets={},
operating_bases=OperatingBases.default_for_aircraft(aircraft),

View File

@@ -38,7 +38,7 @@ class PackageBuilder:
self.laser_code_registry = laser_code_registry
self.start_type = start_type
def plan_flight(self, plan: ProposedFlight) -> bool:
def plan_flight(self, plan: ProposedFlight, ignore_range: bool) -> bool:
"""Allocates aircraft for the given flight and adds them to the package.
If no suitable aircraft are available, False is returned. If the failed
@@ -55,6 +55,7 @@ class PackageBuilder:
heli,
this_turn=True,
preferred_type=plan.preferred_type,
ignore_range=ignore_range,
)
if squadron is None:
return False

View File

@@ -81,8 +81,9 @@ class PackageFulfiller:
builder: PackageBuilder,
missing_types: Set[FlightType],
purchase_multiplier: int,
ignore_range: bool = False,
) -> None:
if not builder.plan_flight(flight):
if not builder.plan_flight(flight, ignore_range):
pf = builder.package.primary_flight
heli = pf.is_helo if pf else False
missing_types.add(flight.task)
@@ -138,6 +139,7 @@ class PackageFulfiller:
purchase_multiplier: int,
now: datetime,
tracer: MultiEventTracer,
ignore_range: bool = False,
) -> Optional[Package]:
"""Allocates aircraft for a proposed mission and adds it to the ATO."""
builder = PackageBuilder(
@@ -175,6 +177,7 @@ class PackageFulfiller:
builder,
missing_types,
purchase_multiplier,
ignore_range,
)
if missing_types:

View File

@@ -26,8 +26,6 @@ class Doctrine:
strike: bool
antiship: bool
rendezvous_altitude: Distance
#: The minimum distance between the departure airfield and the hold point.
hold_distance: Distance
@@ -46,11 +44,14 @@ class Doctrine:
#: target.
min_ingress_distance: Distance
ingress_altitude: Distance
min_patrol_altitude: Distance
max_patrol_altitude: Distance
pattern_altitude: Distance
min_cruise_altitude: Distance
max_cruise_altitude: Distance
min_combat_altitude: Distance
max_combat_altitude: Distance
#: The duration that CAP flights will remain on-station.
cap_duration: timedelta
@@ -97,16 +98,17 @@ MODERN_DOCTRINE = Doctrine(
sead=True,
strike=True,
antiship=True,
rendezvous_altitude=feet(25000),
hold_distance=nautical_miles(25),
push_distance=nautical_miles(20),
join_distance=nautical_miles(20),
max_ingress_distance=nautical_miles(45),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(20000),
min_patrol_altitude=feet(15000),
max_patrol_altitude=feet(33000),
pattern_altitude=feet(5000),
min_cruise_altitude=feet(10000),
max_cruise_altitude=feet(40000),
min_combat_altitude=feet(1000),
max_combat_altitude=feet(35000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(15),
cap_max_track_length=nautical_miles(40),
@@ -140,16 +142,17 @@ COLDWAR_DOCTRINE = Doctrine(
sead=True,
strike=True,
antiship=True,
rendezvous_altitude=feet(22000),
hold_distance=nautical_miles(15),
push_distance=nautical_miles(10),
join_distance=nautical_miles(10),
max_ingress_distance=nautical_miles(30),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(18000),
min_patrol_altitude=feet(10000),
max_patrol_altitude=feet(24000),
pattern_altitude=feet(5000),
min_cruise_altitude=feet(10000),
max_cruise_altitude=feet(30000),
min_combat_altitude=feet(1000),
max_combat_altitude=feet(25000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(12),
cap_max_track_length=nautical_miles(24),
@@ -186,13 +189,14 @@ WWII_DOCTRINE = Doctrine(
hold_distance=nautical_miles(10),
push_distance=nautical_miles(5),
join_distance=nautical_miles(5),
rendezvous_altitude=feet(10000),
max_ingress_distance=nautical_miles(7),
min_ingress_distance=nautical_miles(5),
ingress_altitude=feet(8000),
min_patrol_altitude=feet(4000),
max_patrol_altitude=feet(15000),
pattern_altitude=feet(5000),
min_cruise_altitude=feet(5000),
max_cruise_altitude=feet(30000),
min_combat_altitude=feet(1000),
max_combat_altitude=feet(10000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(8),
cap_max_track_length=nautical_miles(18),

View File

@@ -132,6 +132,21 @@ class PatrolConfig:
)
@dataclass(frozen=True)
class AltitudesConfig:
cruise: Optional[Distance]
combat: Optional[Distance]
@classmethod
def from_data(cls, data: dict[str, Any]) -> AltitudesConfig:
cruise = data.get("cruise", None)
combat = data.get("combat", None)
return AltitudesConfig(
feet(cruise) if cruise is not None else None,
feet(combat) if combat is not None else None,
)
@dataclass(frozen=True)
class FuelConsumption:
#: The estimated taxi fuel requirement, in pounds.
@@ -182,6 +197,9 @@ class AircraftType(UnitType[Type[FlyingType]]):
patrol_altitude: Optional[Distance]
patrol_speed: Optional[Speed]
cruise_altitude: Optional[Distance]
combat_altitude: Optional[Distance]
#: The maximum range between the origin airfield and the target for which the auto-
#: planner will consider this aircraft usable for a mission.
max_mission_range: Distance
@@ -245,33 +263,13 @@ class AircraftType(UnitType[Type[FlyingType]]):
def max_speed(self) -> Speed:
return kph(self.dcs_unit_type.max_speed)
@property
@cached_property
def preferred_patrol_altitude(self) -> Distance:
if self.patrol_altitude is not None:
if self.patrol_altitude:
return self.patrol_altitude
else:
# Estimate based on max speed.
# Aircaft with max speed 600 kph will prefer patrol at 10 000 ft
# Aircraft with max speed 2800 kph will prefer pratrol at 33 000 ft
altitude_for_lowest_speed = feet(10 * 1000)
altitude_for_highest_speed = feet(33 * 1000)
lowest_speed = kph(600)
highest_speed = kph(2800)
factor = (self.max_speed - lowest_speed).kph / (
highest_speed - lowest_speed
).kph
altitude = (
altitude_for_lowest_speed
+ (altitude_for_highest_speed - altitude_for_lowest_speed) * factor
)
logging.debug(
f"Preferred patrol altitude for {self.dcs_unit_type.id}: {altitude.feet}"
)
rounded_altitude = feet(round(1000 * round(altitude.feet / 1000)))
return max(
altitude_for_lowest_speed,
min(altitude_for_highest_speed, rounded_altitude),
)
# TODO: somehow make the upper and lower limit configurable
return self.preferred_altitude(10, 33, "patrol")
def preferred_patrol_speed(self, altitude: Distance) -> Speed:
"""Preferred true airspeed when patrolling"""
@@ -309,6 +307,46 @@ class AircraftType(UnitType[Type[FlyingType]]):
)
return min(Speed.from_mach(0.35, altitude), max_speed * 0.5)
@cached_property
def preferred_cruise_altitude(self) -> Distance:
if self.cruise_altitude:
return self.cruise_altitude
else:
# TODO: somehow make the upper and lower limit configurable
return self.preferred_altitude(20, 20, "cruise")
@cached_property
def preferred_combat_altitude(self) -> Distance:
if self.combat_altitude:
return self.combat_altitude
else:
# TODO: somehow make the upper and lower limit configurable
return self.preferred_altitude(20, 20, "combat")
def preferred_altitude(self, low: int, high: int, type: str) -> Distance:
# Estimate based on max speed.
# Aircraft with max speed 600 kph will prefer low
# Aircraft with max speed 2800 kph will prefer high
altitude_for_lowest_speed = feet(low * 1000)
altitude_for_highest_speed = feet(high * 1000)
lowest_speed = kph(600)
highest_speed = kph(2800)
factor = (self.max_speed - lowest_speed).kph / (
highest_speed - lowest_speed
).kph
altitude = (
altitude_for_lowest_speed
+ (altitude_for_highest_speed - altitude_for_lowest_speed) * factor
)
logging.debug(
f"Preferred {type} altitude for {self.dcs_unit_type.id}: {altitude.feet}"
)
rounded_altitude = feet(round(1000 * round(altitude.feet / 1000)))
return max(
altitude_for_lowest_speed,
min(altitude_for_highest_speed, rounded_altitude),
)
def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
from game.radio.radios import ChannelInUseError, kHz
@@ -442,6 +480,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
radio_config = RadioConfig.from_data(data.get("radios", {}))
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
altitudes_config = AltitudesConfig.from_data(data.get("altitudes", {}))
try:
mission_range = nautical_miles(int(data["max_range"]))
@@ -510,6 +549,8 @@ class AircraftType(UnitType[Type[FlyingType]]):
max_group_size=data.get("max_group_size", aircraft.group_size_max),
patrol_altitude=patrol_config.altitude,
patrol_speed=patrol_config.speed,
cruise_altitude=altitudes_config.cruise,
combat_altitude=altitudes_config.combat,
max_mission_range=mission_range,
fuel_consumption=fuel_consumption,
default_livery=data.get("default_livery"),

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import itertools
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from functools import cached_property
from typing import Optional, Dict, Type, List, Any, Iterator, TYPE_CHECKING, Set
@@ -93,11 +94,8 @@ class Faction:
# Required mods or asset packs
requirements: Dict[str, str] = field(default_factory=dict)
# Possible carrier names
carrier_names: Set[str] = field(default_factory=set)
# Possible helicopter carrier names
helicopter_carrier_names: Set[str] = field(default_factory=set)
# Possible carrier units mapped to names
carriers: Dict[ShipUnitType, Set[str]] = field(default_factory=dict)
# Available Naval Units
naval_units: Set[ShipUnitType] = field(default_factory=set)
@@ -241,8 +239,30 @@ class Faction:
faction.requirements = json.get("requirements", {})
faction.carrier_names = json.get("carrier_names", [])
faction.helicopter_carrier_names = json.get("helicopter_carrier_names", [])
# First try to load the carriers in the new format which
# specifies different names for different carrier types
loaded_carriers = load_carriers(json)
carriers: List[ShipUnitType] = [
unit
for unit in faction.naval_units
if unit.unit_class
in [
UnitClass.AIRCRAFT_CARRIER,
UnitClass.HELICOPTER_CARRIER,
]
]
carrier_names = json.get("carrier_names", [])
helicopter_carrier_names = json.get("helicopter_carrier_names", [])
for c in carriers:
if c.variant_id not in loaded_carriers:
if c.unit_class == UnitClass.AIRCRAFT_CARRIER:
loaded_carriers[c] = carrier_names
elif c.unit_class == UnitClass.HELICOPTER_CARRIER:
loaded_carriers[c] = helicopter_carrier_names
faction.carriers = loaded_carriers
faction.naval_units.union(faction.carriers.keys())
faction.has_jtac = json.get("has_jtac", False)
jtac_name = json.get("jtac_unit", None)
@@ -340,6 +360,8 @@ class Faction:
if not mod_settings.f4bc_phantom:
self.remove_aircraft("VSN_F4B")
self.remove_aircraft("VSN_F4C")
if not mod_settings.f9f_panther:
self.remove_aircraft("VSN_F9F")
if not mod_settings.f15d_baz:
self.remove_aircraft("F-15D")
if not mod_settings.f_15_idf:
@@ -381,6 +403,8 @@ class Faction:
self.remove_aircraft("JAS39Gripen")
self.remove_aircraft("JAS39Gripen_BVR")
self.remove_aircraft("JAS39Gripen_AG")
if not mod_settings.super_etendard:
self.remove_aircraft("VSN_SEM")
if not mod_settings.su30_flanker_h:
self.remove_aircraft("Su-30MKA")
self.remove_aircraft("Su-30MKI")
@@ -592,3 +616,14 @@ def load_all_ships(data: list[str]) -> List[Type[ShipType]]:
if item is not None:
items.append(item)
return items
def load_carriers(json: Dict[str, Any]) -> Dict[ShipUnitType, Set[str]]:
# Load carriers
items: Dict[ShipUnitType, Set[str]] = defaultdict(Set[str])
carriers = json.get("carriers", {})
for carrier_shiptype, shipnames in carriers.items():
shiptype = ShipUnitType.named(carrier_shiptype)
if shiptype is not None:
items[shiptype] = shipnames
return items

View File

@@ -5,6 +5,8 @@ import logging
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Type
import yaml
from game import persistency
from game.factions.faction import Faction
@@ -27,7 +29,11 @@ class FactionLoader:
@staticmethod
def find_faction_files_in(path: Path) -> List[Path]:
return [f for f in path.glob("*.json") if f.is_file()]
return (
[f for f in path.glob("*.json") if f.is_file()]
+ [f for f in path.glob("*.yaml") if f.is_file()]
+ [f for f in path.glob("*.yml") if f.is_file()]
)
@classmethod
def load_factions(cls: Type[FactionLoader]) -> Dict[str, Faction]:
@@ -40,7 +46,10 @@ class FactionLoader:
for f in files:
try:
with f.open("r", encoding="utf-8") as fdata:
data = json.load(fdata)
if "yml" in f.name or "yaml" in f.name:
data = yaml.safe_load(fdata)
else:
data = json.load(fdata)
factions[data["name"]] = Faction.from_dict(data)
logging.info("Loaded faction : " + str(f))
except Exception:

View File

@@ -52,7 +52,7 @@ class Migrator:
continue
found = False
for d in doctrines:
if c.faction.doctrine.rendezvous_altitude == d.rendezvous_altitude:
if c.faction.doctrine.max_patrol_altitude == d.max_patrol_altitude:
c.faction.doctrine = d
found = True
break
@@ -78,6 +78,7 @@ class Migrator:
def _update_control_points(self) -> None:
is_sinai = self.game.theater.terrain.name == "SinaiMap"
for cp in self.game.theater.controlpoints:
cp.release_parking_slots()
is_carrier = cp.is_carrier
is_lha = cp.is_lha
is_fob = cp.category == "fob"
@@ -107,6 +108,7 @@ class Migrator:
layout = f.flight_plan.layout
try_set_attr(layout, "nav_to", [])
try_set_attr(layout, "nav_from", [])
try_set_attr(layout, "custom_waypoints", [])
if f.flight_type == FlightType.CAS:
try_set_attr(layout, "ingress", None)
if isinstance(layout, FormationLayout):
@@ -121,10 +123,14 @@ class Migrator:
try_set_attr(f, "tacan")
try_set_attr(f, "tcn_name")
try_set_attr(f, "fuel", f.unit_type.max_fuel)
try_set_attr(f, "plane_altitude_offset", 0)
try_set_attr(f, "use_same_livery_for_all_members", True)
if f.package in f.squadron.coalition.ato.packages:
self._update_flight_plan(f)
else:
to_remove.append(f.id)
for m in f.roster.members:
try_set_attr(m, "livery", None)
for fid in to_remove:
self.game.db.flights.remove(fid)
@@ -156,6 +162,7 @@ class Migrator:
try_set_attr(s, "primary_task", preferred_task)
try_set_attr(s, "max_size", 12)
try_set_attr(s, "radio_presets", {})
try_set_attr(s, "livery_set", [])
if isinstance(s.country, str):
c = country_dict.get(s.country, s.country)
s.country = countries_by_name[c]()
@@ -201,12 +208,6 @@ class Migrator:
c.faction.air_defense_units = set(c.faction.air_defense_units)
if isinstance(c.faction.missiles, list):
c.faction.missiles = set(c.faction.missiles)
if isinstance(c.faction.carrier_names, list):
c.faction.carrier_names = set(c.faction.carrier_names)
if isinstance(c.faction.helicopter_carrier_names, list):
c.faction.helicopter_carrier_names = set(
c.faction.helicopter_carrier_names
)
if isinstance(c.faction.naval_units, list):
c.faction.naval_units = set(c.faction.naval_units)
if isinstance(c.faction.building_set, list):

View File

@@ -1,6 +1,7 @@
import logging
from typing import Any, Optional, Type, List
from dcs.point import MovingPoint
from dcs.task import (
AWACS,
AWACSTaskAction,
@@ -31,8 +32,10 @@ from dcs.unitgroup import FlyingGroup
from game.ato import Flight, FlightType
from game.ato.flightplans.aewc import AewcFlightPlan
from game.ato.flightplans.formationattack import FormationAttackLayout
from game.ato.flightplans.packagerefueling import PackageRefuelingFlightPlan
from game.ato.flightplans.theaterrefueling import TheaterRefuelingFlightPlan
from game.utils import nautical_miles
class AircraftBehavior:
@@ -100,8 +103,25 @@ class AircraftBehavior:
flight.squadron.coalition.game.settings.ai_unlimited_fuel
)
# at IP, insert waypoint to orient aircraft in correct direction
layout = flight.flight_plan.layout
at_ip_or_combat = flight.state.is_at_ip or flight.state.in_combat
if at_ip_or_combat and isinstance(layout, FormationAttackLayout):
a = group.points[0].position
b = layout.targets[0].position
pos = a.point_from_heading(
a.heading_between_point(b), nautical_miles(1).meters
)
point = MovingPoint(pos)
point.alt = group.points[0].alt
point.alt_type = group.points[0].alt_type
point.ETA_locked = False
point.speed = group.points[0].speed
point.name = "Orientation WPT"
group.points.insert(1, point)
# Activate AI unlimited fuel for all flights at startup
if ai_unlimited_fuel and not (flight.state.is_at_ip or flight.state.in_combat):
if ai_unlimited_fuel and not at_ip_or_combat:
group.points[0].tasks.append(SetUnlimitedFuelCommand(True))
group.points[0].tasks.append(OptReactOnThreat(react_on_threat))

View File

@@ -27,7 +27,14 @@ class AircraftPainter:
def livery_from_squadron(self) -> Optional[str]:
return self.flight.squadron.livery
def livery_from_squadron_set(self) -> Optional[str]:
if not self.flight.squadron.livery_set:
return None
return random.choice(self.flight.squadron.livery_set)
def determine_livery(self) -> Optional[str]:
if (livery := self.livery_from_squadron_set()) is not None:
return livery
if (livery := self.livery_from_squadron()) is not None:
return livery
if (livery := self.livery_from_faction()) is not None:
@@ -37,8 +44,10 @@ class AircraftPainter:
return None
def apply_livery(self) -> None:
livery = self.determine_livery()
if livery is None:
return
for unit in self.group.units:
unit.livery_id = livery
for unit, member in zip(self.group.units, self.flight.iter_members()):
livery = self.determine_livery()
if not (livery or member.livery):
continue
unit.livery_id = member.livery if member.livery else livery
assert isinstance(unit.livery_id, str)
unit.livery_id = unit.livery_id.lower()

View File

@@ -1,6 +1,6 @@
import logging
import random
from typing import Any, Union, Tuple, Optional
from typing import Any, Union, Tuple, Optional, List
from dcs import Mission
from dcs.country import Country
@@ -19,7 +19,8 @@ from dcs.planes import (
)
from dcs.point import PointAction
from dcs.ships import KUZNECOW
from dcs.terrain import NoParkingSlotError
from dcs.terrain import NoParkingSlotError, Sinai, ParkingSlot
from dcs.terrain.sinai.airports import Nevatim
from dcs.unitgroup import (
FlyingGroup,
ShipGroup,
@@ -110,24 +111,31 @@ class FlightGroupSpawner:
def create_idle_aircraft(self) -> Optional[FlyingGroup[Any]]:
group = None
if (
self.flight.is_helo
or self.flight.is_lha
and isinstance(self.flight.squadron.location, Fob)
):
cp = self.flight.squadron.location
if self.flight.is_helo or self.flight.is_lha and isinstance(cp, Fob):
group = self._generate_at_cp_helipad(
name=namegen.next_aircraft_name(self.country, self.flight),
cp=self.flight.squadron.location,
)
elif isinstance(self.flight.squadron.location, Fob):
elif isinstance(cp, Fob):
group = self._generate_at_cp_ground_spawn(
name=namegen.next_aircraft_name(self.country, self.flight),
cp=self.flight.squadron.location,
)
elif isinstance(self.flight.squadron.location, Airfield):
elif isinstance(cp, Airfield):
# TODO: remove hack when fixed in DCS
slots = None
if self._check_nevatim_hack(cp):
ac_type = self.flight.unit_type.dcs_unit_type
slots = [
slot
for slot in cp.dcs_airport.free_parking_slots(ac_type)
if slot.slot_name in [str(n) for n in range(55, 66)]
]
group = self._generate_at_airfield(
name=namegen.next_aircraft_name(self.country, self.flight),
airfield=self.flight.squadron.location,
airfield=cp,
parking_slots=slots,
)
if group:
group.uncontrolled = True
@@ -196,7 +204,19 @@ class FlightGroupSpawner:
pad_group = self._generate_at_cp_ground_spawn(name, cp)
if pad_group is not None:
return pad_group
return self._generate_at_airfield(name, cp)
# TODO: get rid of the nevatim hack once fixed in DCS...
if self._check_nevatim_hack(cp):
slots = [
slot
for slot in cp.dcs_airport.free_parking_slots(
self.flight.squadron.aircraft.dcs_unit_type
)
if slot.slot_name in [str(n) for n in range(55, 66)]
]
return self._generate_at_airfield(name, cp, slots)
else:
return self._generate_at_airfield(name, cp)
else:
raise NotImplementedError(
f"Aircraft spawn behavior not implemented for {cp} ({cp.__class__})"
@@ -210,6 +230,13 @@ class FlightGroupSpawner:
group = self._generate_over_departure(name, cp)
return group
def _check_nevatim_hack(self, cp: ControlPoint) -> bool:
# TODO: get rid of the nevatim hack once fixed in DCS...
nevatim_hack = self.flight.coalition.game.settings.nevatim_parking_fix
nevatim_hack &= isinstance(self.mission.terrain, Sinai)
nevatim_hack &= isinstance(cp.dcs_airport, Nevatim)
return nevatim_hack
def generate_mid_mission(self) -> FlyingGroup[Any]:
assert isinstance(self.flight.state, InFlight)
name = namegen.next_aircraft_name(self.country, self.flight)
@@ -251,7 +278,12 @@ class FlightGroupSpawner:
group.points[0].alt_type = alt_type
return group
def _generate_at_airfield(self, name: str, airfield: Airfield) -> FlyingGroup[Any]:
def _generate_at_airfield(
self,
name: str,
airfield: Airfield,
parking_slots: Optional[List[ParkingSlot]] = None,
) -> FlyingGroup[Any]:
# TODO: Delayed runway starts should be converted to air starts for multiplayer.
# Runway starts do not work with late activated aircraft in multiplayer. Instead
# of spawning on the runway the aircraft will spawn on the taxiway, potentially
@@ -268,7 +300,7 @@ class FlightGroupSpawner:
maintask=None,
start_type=self._start_type_at_airfield(airfield),
group_size=self.flight.count,
parking_slots=None,
parking_slots=parking_slots,
callsign_name=self.flight.callsign.name if self.flight.callsign else None,
callsign_nr=self.flight.callsign.nr if self.flight.callsign else None,
)

View File

@@ -1,7 +1,7 @@
from dcs.point import MovingPoint
from dcs.task import Expend, WeaponType, CarpetBombing, OptROE
from dcs.task import Expend, WeaponType, CarpetBombing
from game.ato.flightwaypointtype import FlightWaypointType
from game.ato.flightplans.airassault import AirAssaultLayout
from game.utils import feet, knots
from pydcs_extensions.hercules.hercules import Hercules
from .pydcswaypointbuilder import PydcsWaypointBuilder
@@ -12,14 +12,14 @@ class AirAssaultIngressBuilder(PydcsWaypointBuilder):
self.register_special_ingress_points()
air_drop = self.group.units[0].unit_type in [Hercules]
if air_drop:
waypoint.speed = knots(230).meters_per_second
waypoint.speed = knots(200).meters_per_second
waypoint.speed_locked = True
waypoint.ETA_locked = False
tgt = self.flight.flight_plan.package.target.position
for wpt in self.flight.flight_plan.waypoints:
if wpt.waypoint_type == FlightWaypointType.TARGET_GROUP_LOC:
tgt = wpt.position
break
tgt = self.flight.package.target.position
layout = self.flight.flight_plan.layout
assert isinstance(layout, AirAssaultLayout)
heading = layout.ingress.position.heading_between_point(tgt)
tgt = tgt.point_from_heading(heading, feet(6000).meters)
bombing = CarpetBombing(
tgt,
weapon_type=WeaponType.Bombs,

View File

@@ -44,7 +44,16 @@ class DeadIngressBuilder(PydcsWaypointBuilder):
task = AttackGroup(
miz_group.id,
weapon_type=WeaponType.Guided,
weapon_type=WeaponType.ASM,
expend=Expend.All,
altitude=waypoint.alt,
group_attack=True,
)
waypoint.tasks.append(task)
task = AttackGroup(
miz_group.id,
weapon_type=WeaponType.GuidedBombs,
altitude=waypoint.alt,
)
waypoint.tasks.append(task)

View File

@@ -40,4 +40,4 @@ class HoldPointBuilder(PydcsWaypointBuilder):
if self.flight.is_helo:
waypoint.add_task(OptFormation.rotary_column())
else:
waypoint.add_task(OptFormation.finger_four_close())
waypoint.add_task(OptFormation.finger_four_open())

View File

@@ -8,9 +8,7 @@ class LandingPointBuilder(PydcsWaypointBuilder):
def build(self) -> MovingPoint:
waypoint = super().build()
if self.ai_despawn(waypoint):
waypoint.alt = round(
self.flight.coalition.doctrine.max_patrol_altitude.meters
)
waypoint.alt = round(self.flight.unit_type.preferred_patrol_altitude.meters)
waypoint.alt_type = "BARO"
else:
waypoint.type = "Land"

View File

@@ -124,7 +124,9 @@ class PydcsWaypointBuilder:
return False
def register_special_strike_points(
self, targets: Iterable[Union[MissionTarget, TheaterUnit]]
self,
targets: Iterable[Union[MissionTarget, TheaterUnit]],
start: int = 1,
) -> None:
"""Create special strike waypoints for various aircraft"""
for i, t in enumerate(targets):
@@ -135,7 +137,7 @@ class PydcsWaypointBuilder:
# Add F-15E mission target points as mission 1 (for JDAM for instance)
if self.group.units[0].unit_type == F_15ESE:
self.group.add_nav_target_point(
t.position, f"M{(i//8)+1}.{i%8+1}" f"\nH-1" f"\nA0" f"\nV0"
t.position, f"M{(i//8)+start}.{i%8+1}\nH-1\nA0\nV0"
)
def register_special_ingress_points(self) -> None:

View File

@@ -29,7 +29,7 @@ class SplitPointBuilder(PydcsWaypointBuilder):
if self.flight.is_helo:
waypoint.tasks.append(OptFormation.rotary_wedge())
else:
waypoint.tasks.append(OptFormation.finger_four_close())
waypoint.tasks.append(OptFormation.finger_four_open())
waypoint.speed_locked = True
waypoint.ETA_locked = False
if self.flight.is_helo:

View File

@@ -2,7 +2,7 @@ import copy
from typing import Union
from dcs import Point
from dcs.planes import B_17G, B_52H, Tu_22M3, B_1B
from dcs.planes import B_17G, B_52H, Tu_22M3, B_1B, F_15ESE
from dcs.point import MovingPoint
from dcs.task import Bombing, Expend, OptFormation, WeaponType, CarpetBombing
@@ -62,13 +62,31 @@ class StrikeIngressBuilder(PydcsWaypointBuilder):
def add_strike_tasks(
self, waypoint: MovingPoint, weapon_type: WeaponType = WeaponType.Auto
) -> None:
bomber = self.group.units[0].unit_type in [B_1B, B_52H]
ratio = len(self.group.units) / len(self.waypoint.targets)
for target in self.waypoint.targets:
bombing = Bombing(target.position, weapon_type=weapon_type)
# If there is only one target, drop all ordnance in one pass.
# If there is only one target, drop all ordnance in one pass with group attack.
if len(self.waypoint.targets) == 1:
bombing.params["expend"] = Expend.All.value
elif target.is_static:
bombing.params["groupAttack"] = True
elif ratio >= 1:
# #TGTs > 1 & #AC >= #TGTs => each AC drops entire payload per TGT
bombing.params["expend"] = Expend.All.value
elif 1 > ratio >= 0.5:
# #TGTs > 1 & 2 * #AC >= #TGTs => each AC drops half payload per TGT
bombing.params["expend"] = Expend.Half.value
elif 0.5 > ratio >= 0.25:
# #TGTs > 1 & 4 * #AC >= #TGTs => each AC drops quarter payload per TGT
bombing.params["expend"] = Expend.Quarter.value
elif 0.25 > ratio >= (1.0 / 6) and bomber:
# #TGTs > 1 & 4 * #AC < #TGTs & bomber => each AC drops 4 bombs per TGT
bombing.params["expend"] = Expend.Four.value
elif bomber:
# #TGTs > 1 & 6 * #AC < #TGTs & bomber => each AC drops 2 bombs per TGT
bombing.params["expend"] = Expend.Two.value
# else => Auto QTY
waypoint.tasks.append(bombing)
waypoint.speed = mach(0.85, meters(waypoint.alt)).meters_per_second
@@ -76,4 +94,6 @@ class StrikeIngressBuilder(PydcsWaypointBuilder):
# Register special waypoints
if not self._special_wpts_injected:
self.register_special_strike_points(self.waypoint.targets)
if self.flight.unit_type.dcs_unit_type == F_15ESE:
self.register_special_strike_points(self.flight.custom_targets, 2)
self._special_wpts_injected = True

View File

@@ -12,7 +12,7 @@ from dcs.task import StartCommand
from dcs.triggers import Event, TriggerOnce, TriggerRule
from dcs.unitgroup import FlyingGroup
from game.ato import Flight, FlightWaypoint
from game.ato import Flight, FlightWaypoint, FlightType
from game.ato.flightstate import InFlight, WaitingForStart
from game.ato.flightwaypointtype import FlightWaypointType
from game.ato.starttype import StartType
@@ -73,6 +73,18 @@ class WaypointGenerator:
if point.only_for_player and not self.flight.client_count:
continue
if isinstance(self.flight.state, InFlight):
if self.flight.flight_type in [
FlightType.ESCORT,
FlightType.SEAD_ESCORT,
]:
is_join = point.waypoint_type == FlightWaypointType.JOIN
join_passed = self.flight.state.has_passed_waypoint(point)
if (
is_join
and join_passed
and point != self.flight.state.current_waypoint
):
self.builder_for_waypoint(point).add_tasks(self.group.points[0])
if point == self.flight.state.current_waypoint:
# We don't need to build this waypoint because pydcs did that for
# us, but we do need to configure the tasks for it so that mid-

View File

@@ -50,6 +50,7 @@ from game.weather.weather import Weather
from .aircraft.flightdata import FlightData
from .briefinggenerator import CommInfo, JtacInfo, MissionInfoGenerator
from .missiondata import AwacsInfo, TankerInfo
from ..persistency import kneeboards_dir
if TYPE_CHECKING:
from game import Game
@@ -440,13 +441,14 @@ class BriefingPage(KneeboardPage):
sun = Sun(start_pos.lat, start_pos.lng)
date = fl.squadron.coalition.game.date
dt = datetime.datetime(date.year, date.month, date.day)
tz = fl.squadron.coalition.game.theater.timezone
# Get today's sunrise and sunset in UTC
sr_utc = sun.get_sunrise_time(date)
ss_utc = sun.get_sunset_time(date)
sr = sr_utc + tz.utcoffset(sun.get_sunrise_time(date))
ss = ss_utc + tz.utcoffset(sun.get_sunset_time(date))
sr_utc = sun.get_sunrise_time(dt)
ss_utc = sun.get_sunset_time(dt)
sr = sr_utc + tz.utcoffset(sun.get_sunrise_time(dt))
ss = ss_utc + tz.utcoffset(sun.get_sunset_time(dt))
writer.text(
f"Sunrise - Sunset: {sr.strftime('%H:%M')} - {ss.strftime('%H:%M')}"
@@ -820,6 +822,14 @@ class KneeboardGenerator(MissionInfoGenerator):
page_path = aircraft_dir / f"page{idx:02}.png"
page.write(page_path)
self.mission.add_aircraft_kneeboard(aircraft.dcs_unit_type, page_path)
if not kneeboards_dir().exists():
return
for type in kneeboards_dir().iterdir():
if type.is_dir():
for kneeboard in type.iterdir():
self.mission.custom_kneeboards[type.name].append(kneeboard)
else:
self.mission.custom_kneeboards[""].append(type)
def pages_by_airframe(self) -> Dict[AircraftType, List[KneeboardPage]]:
"""Returns a list of kneeboard pages per airframe in the mission.

View File

@@ -39,7 +39,13 @@ from dcs.task import (
OptAlarmState,
)
from dcs.translation import String
from dcs.triggers import Event, TriggerOnce, TriggerStart, TriggerZone
from dcs.triggers import (
Event,
TriggerOnce,
TriggerStart,
TriggerZone,
TriggerZoneQuadPoint,
)
from dcs.unit import Unit, InvisibleFARP, BaseFARP, SingleHeliPad, FARP
from dcs.unitgroup import MovingGroup, ShipGroup, StaticGroup, VehicleGroup
from dcs.unittype import ShipType, VehicleType
@@ -404,14 +410,24 @@ class GroundObjectGenerator:
# is minimized. As long as the triggerzone is over the scenery object, we're ok.
smallest_valid_radius = feet(16).meters
trigger_zone = self.m.triggers.add_triggerzone(
scenery.zone.position,
smallest_valid_radius,
scenery.zone.hidden,
scenery.zone.name,
color,
scenery.zone.properties,
)
if isinstance(scenery.zone, TriggerZoneQuadPoint):
trigger_zone: TriggerZone = self.m.triggers.add_triggerzone_quad(
scenery.zone.position,
scenery.zone.verticies,
scenery.zone.hidden,
scenery.zone.name,
color,
scenery.zone.properties,
)
else:
trigger_zone = self.m.triggers.add_triggerzone(
scenery.zone.position,
smallest_valid_radius,
scenery.zone.hidden,
scenery.zone.name,
color,
scenery.zone.properties,
)
# DCS only visually shows a scenery object is dead when
# this trigger rule is applied. Otherwise you can kill a
# structure twice.

View File

@@ -111,6 +111,10 @@ def airwing_dir() -> Path:
return base_path() / "Retribution" / "AirWing"
def kneeboards_dir() -> Path:
return base_path() / "Retribution" / "Kneeboards"
def payloads_dir(backup: bool = False) -> Path:
payloads = base_path() / "MissionEditor" / "UnitPayloads"
if backup:

View File

@@ -1,4 +1,4 @@
from asyncio import wait
from asyncio import wait, tasks
from fastapi import APIRouter, WebSocket
from fastapi.encoders import jsonable_encoder
@@ -17,7 +17,7 @@ class ConnectionManager:
async def shutdown(self) -> None:
futures = []
for connection in self.active_connections:
futures.append(connection.close())
futures.append(tasks.create_task(connection.close()))
await wait(futures)
async def connect(self, websocket: WebSocket) -> None:
@@ -30,7 +30,9 @@ class ConnectionManager:
async def broadcast(self, events: GameUpdateEventsJs) -> None:
futures = []
for connection in self.active_connections:
futures.append(connection.send_json(jsonable_encoder(events)))
futures.append(
tasks.create_task(connection.send_json(jsonable_encoder(events)))
)
await wait(futures)

View File

@@ -314,8 +314,18 @@ class Settings:
page=CAMPAIGN_DOCTRINE_PAGE,
section=GENERAL_SECTION,
default=False,
detail=("AI will jettison their fuel tanks as soon as they're empty."),
detail="AI will jettison their fuel tanks as soon as they're empty.",
)
max_plane_altitude_offset: int = bounded_int_option(
"Maximum randomized altitude offset (x1000 ft) for airplanes.",
page=CAMPAIGN_DOCTRINE_PAGE,
section=GENERAL_SECTION,
min=0,
max=5,
default=2,
detail="Creates a randomized altitude offset for airplanes.",
)
# Doctrine Distances Section
airbase_threat_range: int = bounded_int_option(
"Airbase threat range (nmi)",
page=CAMPAIGN_DOCTRINE_PAGE,
@@ -388,6 +398,32 @@ class Settings:
"planned to known threat zones."
),
)
max_mission_range_planes: int = bounded_int_option(
"Auto-planner maximum mission range for airplanes (NM)",
page=CAMPAIGN_DOCTRINE_PAGE,
section=DOCTRINE_DISTANCES_SECTION,
default=150,
min=150,
max=1000,
detail=(
"The maximum mission distance that's used by the auto-planner for airplanes. "
"This setting won't take effect when a larger "
"range is defined in the airplane's yaml specification."
),
)
max_mission_range_helicopters: int = bounded_int_option(
"Auto-planner maximum mission range for helicopters (NM)",
page=CAMPAIGN_DOCTRINE_PAGE,
section=DOCTRINE_DISTANCES_SECTION,
default=100,
min=50,
max=1000,
detail=(
"The maximum mission distance that's used by the auto-planner for helicopters. "
"This setting won't take effect when a larger "
"range is defined in the helicopter's yaml specification."
),
)
# Pilots and Squadrons
ai_pilot_levelling: bool = boolean_option(
"Allow AI pilot leveling",
@@ -720,6 +756,16 @@ class Settings:
"will not be included in automatically planned OCA packages."
),
)
nevatim_parking_fix: bool = boolean_option(
"Force air-starts for all aircraft at Nevatim",
page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION,
default=True, # TODO: set to False or remove this when DCS is fixed
detail=(
"Air-starts forced for all aircraft at Nevatim except parking slots "
"55 till 65, since those are the only ones that work."
),
)
# Mission specific
desired_player_mission_duration: timedelta = minutes_option(
"Desired mission duration",
@@ -1150,6 +1196,7 @@ class Settings:
enable_transfer_cheat: bool = False
enable_runway_state_cheat: bool = False
enable_air_wing_adjustments: bool = False
enable_enemy_buy_sell: bool = False
# LUA Plugins system
plugins: Dict[str, bool] = field(default_factory=dict)

View File

@@ -52,6 +52,7 @@ class AirWing:
heli: bool,
this_turn: bool,
preferred_type: Optional[AircraftType] = None,
ignore_range: bool = False,
) -> list[Squadron]:
airfield_cache = ObjectiveDistanceCache.get_closest_airfields(location)
best_aircraft = AircraftType.priority_list_for_task(task)
@@ -68,7 +69,7 @@ class AirWing:
]
for squadron in squadrons:
if squadron.can_auto_assign_mission(
location, task, size, heli, this_turn
location, task, size, heli, this_turn, ignore_range
):
capable_at_base.append(squadron)
if squadron.aircraft not in best_aircraft:
@@ -100,9 +101,10 @@ class AirWing:
heli: bool,
this_turn: bool,
preferred_type: Optional[AircraftType] = None,
ignore_range: bool = False,
) -> Optional[Squadron]:
for squadron in self.best_squadrons_for(
location, task, size, heli, this_turn, preferred_type
location, task, size, heli, this_turn, preferred_type, ignore_range
):
return squadron
return None

View File

@@ -18,7 +18,7 @@ from game.theater import ParkingType
from .pilot import Pilot, PilotStatus
from ..db.database import Database
from ..radio.radios import RadioFrequency
from ..utils import meters
from ..utils import meters, nautical_miles
if TYPE_CHECKING:
from game import Game
@@ -40,6 +40,7 @@ class Squadron:
aircraft: AircraftType
max_size: int
livery: Optional[str]
livery_set: list[str] # will override livery if not empty
primary_task: FlightType
auto_assignable_mission_types: set[FlightType]
radio_presets: dict[Union[str, int], list[RadioFrequency]]
@@ -281,6 +282,7 @@ class Squadron:
size: int,
heli: bool,
this_turn: bool,
ignore_range: bool = False,
) -> bool:
if (
self.location.cptype.name in ["FOB", "FARP"]
@@ -304,8 +306,23 @@ class Squadron:
if heli and task == FlightType.REFUELING:
return False
if ignore_range:
return True
distance_to_target = meters(location.distance_to(self.location))
return distance_to_target <= self.aircraft.max_mission_range
max_plane_dist = nautical_miles(
self.coalition.game.settings.max_mission_range_planes
)
max_heli_dist = nautical_miles(
self.coalition.game.settings.max_mission_range_helicopters
)
if self.aircraft.helicopter:
return distance_to_target <= max(
self.aircraft.max_mission_range, max_heli_dist
)
return distance_to_target <= max(
self.aircraft.max_mission_range, max_plane_dist
)
def operates_from(self, control_point: ControlPoint) -> bool:
if not control_point.can_operate(self.aircraft):
@@ -487,6 +504,7 @@ class Squadron:
squadron_def.aircraft,
max_size,
squadron_def.livery,
squadron_def.livery_set,
primary_task,
squadron_def.auto_assignable_mission_types,
squadron_def.radio_presets,

View File

@@ -27,6 +27,7 @@ class SquadronDef:
role: str
aircraft: AircraftType
livery: Optional[str]
livery_set: list[str]
auto_assignable_mission_types: set[FlightType]
radio_presets: dict[Union[str, int], list[RadioFrequency]]
operating_bases: OperatingBases
@@ -103,6 +104,7 @@ class SquadronDef:
role=data["role"],
aircraft=unit_type,
livery=data.get("livery"),
livery_set=data.get("livery_set", []),
auto_assignable_mission_types=set(unit_type.iter_task_capabilities()),
radio_presets=radio_presets,
operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),

View File

@@ -1021,6 +1021,8 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC):
# clear the ATO and replan the airlifts with the correct time.
self.ground_unit_orders.process(game, game.conditions.start_time)
self.release_parking_slots()
runway_status = self.runway_status
if runway_status is not None:
runway_status.process_turn()
@@ -1393,6 +1395,11 @@ class NavalControlPoint(
L02,
L52,
L61,
CV_1143_5,
CVN_71,
CVN_72,
CVN_73,
CVN_75,
]:
return True
return False

View File

@@ -130,6 +130,8 @@ class IadsNetwork:
# but if it does, we want to know because it's supposed to be impossible afaict
skynet_node = SkynetNode.from_group(node.group)
for connection in node.connections.values():
if not any([x.alive for x in connection.units]):
continue
if connection.ground_object.is_friendly(
skynet_node.player
) and not game.iads_considerate_culling(connection.ground_object):

View File

@@ -7,12 +7,13 @@ from datetime import datetime, time
from typing import List, Optional
import dcs.statics
from dcs.countries import country_dict
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, PresetLocation
from game.theater import PointWithHeading, PresetLocation, NavalControlPoint
from game.theater.theatergroundobject import (
BuildingGroundObject,
IadsBuildingGroundObject,
@@ -26,12 +27,21 @@ from . import (
Fob,
OffMapSpawn,
)
from .theatergroup import IadsGroundGroup, IadsRole, SceneryUnit, TheaterGroup
from .theatergroup import (
IadsGroundGroup,
IadsRole,
SceneryUnit,
TheaterGroup,
TheaterUnit,
)
from ..armedforces.armedforces import ArmedForces
from ..armedforces.forcegroup import ForceGroup
from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig
from ..campaignloader.campaigncarrierconfig import CampaignCarrierConfig
from ..campaignloader.campaigngroundconfig import TgoConfig
from ..data.groups import GroupTask
from ..data.units import UnitClass
from ..dcs.shipunittype import ShipUnitType
from ..profiling import logged_duration
from ..settings import Settings
@@ -49,6 +59,7 @@ class GeneratorSettings:
no_player_navy: bool
no_enemy_navy: bool
tgo_config: TgoConfig
carrier_config: CampaignCarrierConfig
squadrons_start_full: bool
@@ -58,6 +69,7 @@ class ModSettings:
a6a_intruder: bool = False
a7e_corsair2: bool = False
f4bc_phantom: bool = False
f9f_panther: bool = False
f15d_baz: bool = False
f_15_idf: bool = False
f_16_idf: bool = False
@@ -72,6 +84,7 @@ class ModSettings:
irondome: bool = False
uh_60l: bool = False
jas39_gripen: bool = False
super_etendard: bool = False
su30_flanker_h: bool = False
su57_felon: bool = False
frenchpack: bool = False
@@ -125,11 +138,13 @@ class GameGenerator:
def should_remove_carrier(self, player: bool) -> bool:
faction = self.player if player else self.enemy
return self.generator_settings.no_carrier or not faction.carrier_names
return self.generator_settings.no_carrier or not faction.carriers
def should_remove_lha(self, player: bool) -> bool:
faction = self.player if player else self.enemy
return self.generator_settings.no_lha or not faction.helicopter_carrier_names
return self.generator_settings.no_lha or not [
x for x in faction.carriers if x.unit_class == UnitClass.HELICOPTER_CARRIER
]
def prepare_theater(self) -> None:
to_remove: List[ControlPoint] = []
@@ -221,14 +236,63 @@ class GenericCarrierGroundObjectGenerator(ControlPointGroundObjectGenerator):
carrier = next(self.control_point.ground_objects[-1].units)
carrier.name = carrier_name
def apply_carrier_config(self) -> None:
assert isinstance(self.control_point, NavalControlPoint)
# If the campaign designer has specified a preferred name, use that
# Note that the preferred name needs to exist in the faction, so we
# don't end up with Kuznetsov carriers called CV-59 Forrestal
preferred_name = None
preferred_type = None
carrier_map = self.generator_settings.carrier_config.by_original_name
if ccfg := carrier_map.get(self.control_point.name):
preferred_name = ccfg.preferred_name
preferred_type = ccfg.preferred_type
carrier_unit = self.get_carrier_unit()
if preferred_type and preferred_type.dcs_unit_type in [
v
for k, v in country_dict[self.faction.country.id].Ship.__dict__.items() # type: ignore
if "__" not in k
]:
carrier_unit.type = preferred_type.dcs_unit_type
if preferred_name:
self.control_point.name = preferred_name
else:
carrier_type = preferred_type if preferred_type else carrier_unit.unit_type
assert isinstance(carrier_type, ShipUnitType)
# Otherwise pick randomly from the names specified for that particular carrier type
carrier_names = self.faction.carriers.get(carrier_type)
if carrier_names:
self.control_point.name = random.choice(list(carrier_names))
else:
self.control_point.name = carrier_type.display_name
carrier_unit.name = self.control_point.name
# Prevents duplicate carrier or LHA names in campaigns with more that one of either.
for carrier_type_key in self.faction.carriers:
for carrier_name in self.faction.carriers[carrier_type_key]:
if carrier_name == self.control_point.name:
self.faction.carriers[carrier_type_key].remove(
self.control_point.name
)
def get_carrier_unit(self) -> TheaterUnit:
carrier_go = [
go
for go in self.control_point.ground_objects
if go.category in ["CARRIER", "LHA"]
][0]
groups = [
g for g in carrier_go.groups if "Carrier" in g.name or "LHA" in g.name
]
return groups[0].units[0]
class CarrierGroundObjectGenerator(GenericCarrierGroundObjectGenerator):
def generate(self) -> bool:
if not super().generate():
return False
carrier_names = self.faction.carrier_names
if not carrier_names:
carriers = self.faction.carriers
if not carriers:
logging.info(
f"Skipping generation of {self.control_point.name} because "
f"{self.faction_name} has no carriers"
@@ -239,6 +303,7 @@ class CarrierGroundObjectGenerator(GenericCarrierGroundObjectGenerator):
if not unit_group:
logging.error(f"{self.faction_name} has no access to AircraftCarrier")
return False
self.generate_ground_object_from_group(
unit_group,
PresetLocation(
@@ -248,7 +313,7 @@ class CarrierGroundObjectGenerator(GenericCarrierGroundObjectGenerator):
),
GroupTask.AIRCRAFT_CARRIER,
)
self.update_carrier_name(random.choice(list(carrier_names)))
self.apply_carrier_config()
return True
@@ -257,8 +322,8 @@ class LhaGroundObjectGenerator(GenericCarrierGroundObjectGenerator):
if not super().generate():
return False
lha_names = self.faction.helicopter_carrier_names
if not lha_names:
lhas = self.faction.carriers
if not lhas:
logging.info(
f"Skipping generation of {self.control_point.name} because "
f"{self.faction_name} has no LHAs"
@@ -280,7 +345,7 @@ class LhaGroundObjectGenerator(GenericCarrierGroundObjectGenerator):
),
GroupTask.HELICOPTER_CARRIER,
)
self.update_carrier_name(random.choice(list(lha_names)))
self.apply_carrier_config()
return True

View File

@@ -2,7 +2,7 @@ from pathlib import Path
MAJOR_VERSION = 1
MINOR_VERSION = 3
MINOR_VERSION = 4
MICRO_VERSION = 0