Merge branch 'develop' into helipads

This commit is contained in:
C. Perreau
2021-08-16 12:20:43 +02:00
committed by GitHub
115 changed files with 2908 additions and 2407 deletions

View File

@@ -0,0 +1,2 @@
from .campaign import Campaign
from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
import json
import logging
from collections import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Tuple, Dict, Any
from packaging.version import Version
import yaml
from game.profiling import logged_duration
from game.theater import (
ConflictTheater,
CaucasusTheater,
NevadaTheater,
PersianGulfTheater,
NormandyTheater,
TheChannelTheater,
SyriaTheater,
MarianaIslandsTheater,
)
from game.version import CAMPAIGN_FORMAT_VERSION
from .campaignairwingconfig import CampaignAirWingConfig
from .mizcampaignloader import MizCampaignLoader
PERF_FRIENDLY = 0
PERF_MEDIUM = 1
PERF_HARD = 2
PERF_NASA = 3
@dataclass(frozen=True)
class Campaign:
name: str
icon_name: str
authors: str
description: str
#: The revision of the campaign format the campaign was built for. We do not attempt
#: to migrate old campaigns, but this is used to show a warning in the UI when
#: selecting a campaign that is not up to date.
version: Tuple[int, int]
recommended_player_faction: str
recommended_enemy_faction: str
performance: int
data: Dict[str, Any]
path: Path
@classmethod
def from_file(cls, path: Path) -> Campaign:
with path.open() as campaign_file:
if path.suffix == ".yaml":
data = yaml.safe_load(campaign_file)
else:
data = json.load(campaign_file)
sanitized_theater = data["theater"].replace(" ", "")
version_field = data.get("version", "0")
try:
version = Version(version_field)
except TypeError:
logging.warning(
f"Non-string campaign version in {path}. Parse may be incorrect."
)
version = Version(str(version_field))
return cls(
data["name"],
f"Terrain_{sanitized_theater}",
data.get("authors", "???"),
data.get("description", ""),
(version.major, version.minor),
data.get("recommended_player_faction", "USA 2005"),
data.get("recommended_enemy_faction", "Russia 1990"),
data.get("performance", 0),
data,
path,
)
def load_theater(self) -> ConflictTheater:
theaters = {
"Caucasus": CaucasusTheater,
"Nevada": NevadaTheater,
"Persian Gulf": PersianGulfTheater,
"Normandy": NormandyTheater,
"The Channel": TheChannelTheater,
"Syria": SyriaTheater,
"MarianaIslands": MarianaIslandsTheater,
}
theater = theaters[self.data["theater"]]
t = theater()
try:
miz = self.data["miz"]
except KeyError as ex:
raise RuntimeError(
"Old format (non-miz) campaigns are no longer supported."
) from ex
with logged_duration("Importing miz data"):
MizCampaignLoader(self.path.parent / miz, t).populate_theater()
return t
def load_air_wing_config(self, theater: ConflictTheater) -> CampaignAirWingConfig:
try:
squadron_data = self.data["squadrons"]
except KeyError:
logging.warning(f"Campaign {self.name} does not define any squadrons")
return CampaignAirWingConfig({})
return CampaignAirWingConfig.from_campaign_data(squadron_data, theater)
@property
def is_out_of_date(self) -> bool:
"""Returns True if this campaign is not up to date with the latest format.
This is more permissive than is_from_future, which is sensitive to minor version
bumps (the old game definitely doesn't support the minor features added in the
new version, and the campaign may require them. However, the minor version only
indicates *optional* new features, so we do not need to mark out of date
campaigns as incompatible if they are within the same major version.
"""
return self.version[0] < CAMPAIGN_FORMAT_VERSION[0]
@property
def is_from_future(self) -> bool:
"""Returns True if this campaign is newer than the supported format."""
return self.version > CAMPAIGN_FORMAT_VERSION
@property
def is_compatible(self) -> bool:
"""Returns True is this campaign was built for this version of the game."""
if self.version == (0, 0):
return False
if self.is_out_of_date:
return False
if self.is_from_future:
return False
return True
@staticmethod
def iter_campaign_defs() -> Iterator[Path]:
campaign_dir = Path("resources/campaigns")
yield from campaign_dir.glob("*.json")
yield from campaign_dir.glob("*.yaml")
@classmethod
def load_each(cls) -> Iterator[Campaign]:
for path in cls.iter_campaign_defs():
try:
logging.debug(f"Loading campaign from {path}...")
campaign = Campaign.from_file(path)
yield campaign
except RuntimeError:
logging.exception(f"Unable to load campaign from {path}")

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING, Union
from gen.flights.flight import FlightType
from game.theater.controlpoint import ControlPoint
if TYPE_CHECKING:
from game.theater import ConflictTheater
@dataclass(frozen=True)
class SquadronConfig:
primary: FlightType
secondary: list[FlightType]
aircraft: list[str]
@property
def auto_assignable(self) -> set[FlightType]:
return set(self.secondary) | {self.primary}
@classmethod
def from_data(cls, data: dict[str, Any]) -> SquadronConfig:
secondary_raw = data.get("secondary")
if secondary_raw is None:
secondary = []
elif isinstance(secondary_raw, str):
secondary = cls.expand_secondary_alias(secondary_raw)
else:
secondary = [FlightType(s) for s in secondary_raw]
return SquadronConfig(
FlightType(data["primary"]), secondary, data.get("aircraft", [])
)
@staticmethod
def expand_secondary_alias(alias: str) -> list[FlightType]:
if alias == "any":
return list(FlightType)
elif alias == "air-to-air":
return [t for t in FlightType if t.is_air_to_air]
elif alias == "air-to-ground":
return [t for t in FlightType if t.is_air_to_ground]
raise KeyError(f"Unknown secondary mission type: {alias}")
@dataclass(frozen=True)
class CampaignAirWingConfig:
by_location: dict[ControlPoint, list[SquadronConfig]]
@classmethod
def from_campaign_data(
cls, data: dict[Union[str, int], Any], theater: ConflictTheater
) -> CampaignAirWingConfig:
by_location: dict[ControlPoint, list[SquadronConfig]] = defaultdict(list)
for base_id, squadron_configs in data.items():
if isinstance(base_id, int):
base = theater.find_control_point_by_id(base_id)
else:
base = theater.control_point_named(base_id)
for squadron_data in squadron_configs:
by_location[base].append(SquadronConfig.from_data(squadron_data))
return CampaignAirWingConfig(by_location)

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
import logging
from typing import Optional, TYPE_CHECKING
from game.squadrons import Squadron
from game.squadrons.squadrondef import SquadronDef
from game.squadrons.squadrondefloader import SquadronDefLoader
from gen.flights.flight import FlightType
from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig
from .squadrondefgenerator import SquadronDefGenerator
from ..dcs.aircrafttype import AircraftType
from ..theater import ControlPoint
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
class DefaultSquadronAssigner:
def __init__(
self, config: CampaignAirWingConfig, game: Game, coalition: Coalition
) -> None:
self.config = config
self.game = game
self.coalition = coalition
self.air_wing = coalition.air_wing
self.squadron_defs = SquadronDefLoader(game, coalition).load()
self.squadron_def_generator = SquadronDefGenerator(self.coalition)
def claim_squadron_def(self, squadron: SquadronDef) -> None:
try:
self.squadron_defs[squadron.aircraft].remove(squadron)
except ValueError:
pass
def assign(self) -> None:
for control_point, squadron_configs in self.config.by_location.items():
if not control_point.is_friendly(self.coalition.player):
continue
for squadron_config in squadron_configs:
squadron_def = self.find_squadron_for(squadron_config, control_point)
if squadron_def is None:
logging.info(
f"{self.coalition.faction.name} has no aircraft compatible "
f"with {squadron_config.primary} at {control_point}"
)
continue
self.claim_squadron_def(squadron_def)
squadron = Squadron.create_from(
squadron_def, control_point, self.coalition, self.game
)
squadron.set_auto_assignable_mission_types(
squadron_config.auto_assignable
)
self.air_wing.add_squadron(squadron)
def find_squadron_for(
self, config: SquadronConfig, control_point: ControlPoint
) -> Optional[SquadronDef]:
for preferred_aircraft in config.aircraft:
squadron_def = self.find_preferred_squadron(
preferred_aircraft, config.primary, control_point
)
if squadron_def is not None:
return squadron_def
# If we didn't find any of the preferred types we should use any squadron
# compatible with the primary task.
squadron_def = self.find_squadron_for_task(config.primary, control_point)
if squadron_def is not None:
return squadron_def
# If we can't find any squadron matching the requirement, we should
# create one.
return self.squadron_def_generator.generate_for_task(
config.primary, control_point
)
def find_preferred_squadron(
self, preferred_aircraft: str, task: FlightType, control_point: ControlPoint
) -> Optional[SquadronDef]:
# Attempt to find a squadron with the name in the request.
squadron_def = self.find_squadron_by_name(
preferred_aircraft, task, control_point
)
if squadron_def is not None:
return squadron_def
# If the name didn't match a squadron available to this coalition, try to find
# an aircraft with the matching name that meets the requirements.
try:
aircraft = AircraftType.named(preferred_aircraft)
except KeyError:
# No aircraft with this name.
return None
if aircraft not in self.coalition.faction.aircrafts:
return None
squadron_def = self.find_squadron_for_airframe(aircraft, task, control_point)
if squadron_def is not None:
return squadron_def
# No premade squadron available for this aircraft that meets the requirements,
# so generate one if possible.
return self.squadron_def_generator.generate_for_aircraft(aircraft)
@staticmethod
def squadron_compatible_with(
squadron: SquadronDef, task: FlightType, control_point: ControlPoint
) -> bool:
return squadron.operates_from(control_point) and task in squadron.mission_types
def find_squadron_for_airframe(
self, aircraft: AircraftType, task: FlightType, control_point: ControlPoint
) -> Optional[SquadronDef]:
for squadron in self.squadron_defs[aircraft]:
if self.squadron_compatible_with(squadron, task, control_point):
return squadron
return None
def find_squadron_by_name(
self, name: str, task: FlightType, control_point: ControlPoint
) -> Optional[SquadronDef]:
for squadrons in self.squadron_defs.values():
for squadron in squadrons:
if squadron.name == name and self.squadron_compatible_with(
squadron, task, control_point
):
return squadron
return None
def find_squadron_for_task(
self, task: FlightType, control_point: ControlPoint
) -> Optional[SquadronDef]:
for squadrons in self.squadron_defs.values():
for squadron in squadrons:
if self.squadron_compatible_with(squadron, task, control_point):
return squadron
return None

View File

@@ -0,0 +1,466 @@
from __future__ import annotations
import itertools
from functools import cached_property
from pathlib import Path
from typing import Iterator, List, Dict, Tuple, TYPE_CHECKING
from dcs import Mission
from dcs.countries import CombinedJointTaskForcesBlue, CombinedJointTaskForcesRed
from dcs.country import Country
from dcs.planes import F_15C
from dcs.ships import Stennis, LHA_Tarawa, HandyWind, USS_Arleigh_Burke_IIa
from dcs.statics import Fortification, Warehouse
from dcs.terrain import Airport
from dcs.unitgroup import PlaneGroup, ShipGroup, VehicleGroup, StaticGroup
from dcs.vehicles import Armor, Unarmed, MissilesSS, AirDefence
from game.point_with_heading import PointWithHeading
from game.positioned import Positioned
from game.profiling import logged_duration
from game.scenery_group import SceneryGroup
from game.utils import Distance, meters, Heading
from game.theater.controlpoint import (
Airfield,
Carrier,
ControlPoint,
Fob,
Lha,
OffMapSpawn,
)
if TYPE_CHECKING:
from game.theater.conflicttheater import ConflictTheater
class MizCampaignLoader:
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
RED_COUNTRY = CombinedJointTaskForcesRed()
OFF_MAP_UNIT_TYPE = F_15C.id
CV_UNIT_TYPE = Stennis.id
LHA_UNIT_TYPE = LHA_Tarawa.id
FRONT_LINE_UNIT_TYPE = Armor.M_113.id
SHIPPING_LANE_UNIT_TYPE = HandyWind.id
FOB_UNIT_TYPE = Unarmed.SKP_11.id
FARP_HELIPAD = "SINGLE_HELIPAD"
OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id
SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id
MISSILE_SITE_UNIT_TYPE = MissilesSS.Scud_B.id
COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.Hy_launcher.id
# Multiple options for air defenses so campaign designers can more accurately see
# the coverage of their IADS for the expected type.
LONG_RANGE_SAM_UNIT_TYPES = {
AirDefence.Patriot_ln.id,
AirDefence.S_300PS_5P85C_ln.id,
AirDefence.S_300PS_5P85D_ln.id,
}
MEDIUM_RANGE_SAM_UNIT_TYPES = {
AirDefence.Hawk_ln.id,
AirDefence.S_75M_Volhov.id,
AirDefence._5p73_s_125_ln.id,
}
SHORT_RANGE_SAM_UNIT_TYPES = {
AirDefence.M1097_Avenger.id,
AirDefence.Rapier_fsa_launcher.id,
AirDefence._2S6_Tunguska.id,
AirDefence.Strela_1_9P31.id,
}
AAA_UNIT_TYPES = {
AirDefence.Flak18.id,
AirDefence.Vulcan.id,
AirDefence.ZSU_23_4_Shilka.id,
}
EWR_UNIT_TYPE = AirDefence._1L13_EWR.id
ARMOR_GROUP_UNIT_TYPE = Armor.M_1_Abrams.id
FACTORY_UNIT_TYPE = Fortification.Workshop_A.id
AMMUNITION_DEPOT_UNIT_TYPE = Warehouse._Ammunition_depot.id
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
self.theater = theater
self.mission = Mission()
with logged_duration("Loading miz"):
self.mission.load_file(str(miz))
self.control_point_id = itertools.count(1000)
# If there are no red carriers there usually aren't red units. Make sure
# both countries are initialized so we don't have to deal with None.
if self.mission.country(self.BLUE_COUNTRY.name) is None:
self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY)
if self.mission.country(self.RED_COUNTRY.name) is None:
self.mission.coalition["red"].add_country(self.RED_COUNTRY)
@staticmethod
def control_point_from_airport(airport: Airport) -> ControlPoint:
cp = Airfield(airport)
cp.captured = airport.is_blue()
# Use the unlimited aircraft option to determine if an airfield should
# be owned by the player when the campaign is "inverted".
cp.captured_invert = airport.unlimited_aircrafts
return cp
def country(self, blue: bool) -> Country:
country = self.mission.country(
self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name
)
# Should be guaranteed because we initialized them.
assert country
return country
@property
def blue(self) -> Country:
return self.country(blue=True)
@property
def red(self) -> Country:
return self.country(blue=False)
def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]:
for group in self.country(blue).plane_group:
if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
yield group
def carriers(self, blue: bool) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.CV_UNIT_TYPE:
yield group
def lhas(self, blue: bool) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.LHA_UNIT_TYPE:
yield group
def fobs(self, blue: bool) -> Iterator[VehicleGroup]:
for group in self.country(blue).vehicle_group:
if group.units[0].type == self.FOB_UNIT_TYPE:
yield group
@property
def ships(self) -> Iterator[ShipGroup]:
for group in self.red.ship_group:
if group.units[0].type == self.SHIP_UNIT_TYPE:
yield group
@property
def offshore_strike_targets(self) -> Iterator[StaticGroup]:
for group in self.red.static_group:
if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE:
yield group
@property
def missile_sites(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE:
yield group
@property
def coastal_defenses(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
yield group
@property
def long_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES:
yield group
@property
def medium_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES:
yield group
@property
def short_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES:
yield group
@property
def aaa(self) -> Iterator[VehicleGroup]:
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
if group.units[0].type in self.AAA_UNIT_TYPES:
yield group
@property
def ewrs(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.EWR_UNIT_TYPE:
yield group
@property
def armor_groups(self) -> Iterator[VehicleGroup]:
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE:
yield group
@property
def helipads(self) -> Iterator[StaticGroup]:
for group in self.blue.static_group:
if group.units[0].type == self.FARP_HELIPAD:
yield group
@property
def factories(self) -> Iterator[StaticGroup]:
for group in self.blue.static_group:
if group.units[0].type in self.FACTORY_UNIT_TYPE:
yield group
@property
def ammunition_depots(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE:
yield group
@property
def strike_targets(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE:
yield group
@property
def scenery(self) -> List[SceneryGroup]:
return SceneryGroup.from_trigger_zones(self.mission.triggers._zones)
@cached_property
def control_points(self) -> Dict[int, ControlPoint]:
control_points = {}
for airport in self.mission.terrain.airport_list():
if airport.is_blue() or airport.is_red():
control_point = self.control_point_from_airport(airport)
control_points[control_point.id] = control_point
for blue in (False, True):
for group in self.off_map_spawns(blue):
control_point = OffMapSpawn(
next(self.control_point_id), str(group.name), group.position
)
control_point.captured = blue
control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point
for ship in self.carriers(blue):
control_point = Carrier(
ship.name, ship.position, next(self.control_point_id)
)
control_point.captured = blue
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for ship in self.lhas(blue):
control_point = Lha(
ship.name, ship.position, next(self.control_point_id)
)
control_point.captured = blue
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for fob in self.fobs(blue):
control_point = Fob(
str(fob.name), fob.position, next(self.control_point_id)
)
control_point.captured = blue
control_point.captured_invert = fob.late_activation
control_points[control_point.id] = control_point
return control_points
@property
def front_line_path_groups(self) -> Iterator[VehicleGroup]:
for group in self.country(blue=True).vehicle_group:
if group.units[0].type == self.FRONT_LINE_UNIT_TYPE:
yield group
@property
def shipping_lane_groups(self) -> Iterator[ShipGroup]:
for group in self.country(blue=True).ship_group:
if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE:
yield group
def add_supply_routes(self) -> None:
for group in self.front_line_path_groups:
# The unit will have its first waypoint at the source CP and the final
# waypoint at the destination CP. Each waypoint defines the path of the
# cargo ship.
waypoints = [p.position for p in group.points]
origin = self.theater.closest_control_point(waypoints[0])
if origin is None:
raise RuntimeError(
f"No control point near the first waypoint of {group.name}"
)
destination = self.theater.closest_control_point(waypoints[-1])
if destination is None:
raise RuntimeError(
f"No control point near the final waypoint of {group.name}"
)
self.control_points[origin.id].create_convoy_route(destination, waypoints)
self.control_points[destination.id].create_convoy_route(
origin, list(reversed(waypoints))
)
def add_shipping_lanes(self) -> None:
for group in self.shipping_lane_groups:
# The unit will have its first waypoint at the source CP and the final
# waypoint at the destination CP. Each waypoint defines the path of the
# cargo ship.
waypoints = [p.position for p in group.points]
origin = self.theater.closest_control_point(waypoints[0])
if origin is None:
raise RuntimeError(
f"No control point near the first waypoint of {group.name}"
)
destination = self.theater.closest_control_point(waypoints[-1])
if destination is None:
raise RuntimeError(
f"No control point near the final waypoint of {group.name}"
)
self.control_points[origin.id].create_shipping_lane(destination, waypoints)
self.control_points[destination.id].create_shipping_lane(
origin, list(reversed(waypoints))
)
def objective_info(
self, near: Positioned, allow_naval: bool = False
) -> Tuple[ControlPoint, Distance]:
closest = self.theater.closest_control_point(near.position, allow_naval)
distance = meters(closest.position.distance_to_point(near.position))
return closest, distance
def add_preset_locations(self) -> None:
for static in self.offshore_strike_targets:
closest, distance = self.objective_info(static)
closest.preset_locations.offshore_strike_locations.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for ship in self.ships:
closest, distance = self.objective_info(ship, allow_naval=True)
closest.preset_locations.ships.append(
PointWithHeading.from_point(
ship.position, Heading.from_degrees(ship.units[0].heading)
)
)
for group in self.missile_sites:
closest, distance = self.objective_info(group)
closest.preset_locations.missile_sites.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.coastal_defenses:
closest, distance = self.objective_info(group)
closest.preset_locations.coastal_defenses.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.long_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.long_range_sams.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.medium_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.medium_range_sams.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.short_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.short_range_sams.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.aaa:
closest, distance = self.objective_info(group)
closest.preset_locations.aaa.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.ewrs:
closest, distance = self.objective_info(group)
closest.preset_locations.ewrs.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.armor_groups:
closest, distance = self.objective_info(group)
closest.preset_locations.armor_groups.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for static in self.helipads:
closest, distance = self.objective_info(static)
closest.helipads.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for static in self.factories:
closest, distance = self.objective_info(static)
closest.preset_locations.factories.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for static in self.ammunition_depots:
closest, distance = self.objective_info(static)
closest.preset_locations.ammunition_depots.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for static in self.strike_targets:
closest, distance = self.objective_info(static)
closest.preset_locations.strike_locations.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for scenery_group in self.scenery:
closest, distance = self.objective_info(scenery_group)
closest.preset_locations.scenery.append(scenery_group)
def populate_theater(self) -> None:
for control_point in self.control_points.values():
self.theater.add_controlpoint(control_point)
self.add_preset_locations()
self.add_supply_routes()
self.add_shipping_lanes()

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
import itertools
import random
from typing import TYPE_CHECKING, Optional
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 gen.flights.flight import FlightType
if TYPE_CHECKING:
from game.coalition import Coalition
class SquadronDefGenerator:
def __init__(self, coalition: Coalition) -> None:
self.coalition = coalition
self.count = itertools.count(1)
self.used_nicknames: set[str] = set()
def generate_for_task(
self, task: FlightType, control_point: ControlPoint
) -> Optional[SquadronDef]:
aircraft_choice: Optional[AircraftType] = None
for aircraft in aircraft_for_task(task):
if aircraft not in self.coalition.faction.aircrafts:
continue
if not control_point.can_operate(aircraft):
continue
aircraft_choice = aircraft
# 50/50 chance to keep looking for an aircraft that isn't as far up the
# priority list to maintain some unit variety.
if random.choice([True, False]):
break
if aircraft_choice is None:
return None
return self.generate_for_aircraft(aircraft_choice)
def generate_for_aircraft(self, aircraft: AircraftType) -> SquadronDef:
return SquadronDef(
name=f"Squadron {next(self.count):03}",
nickname=self.random_nickname(),
country=self.coalition.country_name,
role="Flying Squadron",
aircraft=aircraft,
livery=None,
mission_types=tuple(tasks_for_aircraft(aircraft)),
operating_bases=OperatingBases.default_for_aircraft(aircraft),
pilot_pool=[],
)
@staticmethod
def _make_random_nickname() -> str:
from gen.naming import ANIMALS
animal = random.choice(ANIMALS)
adjective = random.choice(
(
None,
"Red",
"Blue",
"Green",
"Golden",
"Black",
"Fighting",
"Flying",
)
)
if adjective is None:
return animal.title()
return f"{adjective} {animal}".title()
def random_nickname(self) -> str:
while True:
nickname = self._make_random_nickname()
if nickname not in self.used_nicknames:
self.used_nicknames.add(nickname)
return nickname

View File

@@ -5,14 +5,15 @@ from typing import TYPE_CHECKING, Any, Optional
from dcs import Point
from faker import Faker
from game.campaignloader import CampaignAirWingConfig
from game.campaignloader.defaultsquadronassigner import DefaultSquadronAssigner
from game.commander import TheaterCommander
from game.commander.missionscheduler import MissionScheduler
from game.income import Income
from game.inventory import GlobalAircraftInventory
from game.navmesh import NavMesh
from game.orderedset import OrderedSet
from game.profiling import logged_duration, MultiEventTracer
from game.savecompat import has_save_compat_for
from game.squadrons import AirWing
from game.threatzones import ThreatZones
from game.transfers import PendingTransfers
@@ -21,10 +22,9 @@ if TYPE_CHECKING:
from game.data.doctrine import Doctrine
from game.factions.faction import Faction
from game.procurement import AircraftProcurementRequest, ProcurementAi
from game.squadrons import AirWing
from game.theater.bullseye import Bullseye
from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
from gen import AirTaskingOrder
from gen.ato import AirTaskingOrder
class Coalition:
@@ -40,7 +40,7 @@ class Coalition:
self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet()
self.bullseye = Bullseye(Point(0, 0))
self.faker = Faker(self.faction.locales)
self.air_wing = AirWing(game, self)
self.air_wing = AirWing(game)
self.transfers = PendingTransfers(game, player)
# Late initialized because the two coalitions in the game are mutually
@@ -87,10 +87,6 @@ class Coalition:
assert self._navmesh is not None
return self._navmesh
@property
def aircraft_inventory(self) -> GlobalAircraftInventory:
return self.game.aircraft_inventory
def __getstate__(self) -> dict[str, Any]:
state = self.__dict__.copy()
# Avoid persisting any volatile types that can be deterministically
@@ -100,14 +96,7 @@ class Coalition:
del state["faker"]
return state
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
# Begin save compat
old_procurement_requests = state["procurement_requests"]
if isinstance(old_procurement_requests, list):
state["procurement_requests"] = OrderedSet(old_procurement_requests)
# End save compat
self.__dict__.update(state)
# Regenerate any state that was not persisted.
self.on_load()
@@ -120,6 +109,11 @@ class Coalition:
raise RuntimeError("Double-initialization of Coalition.opponent")
self._opponent = opponent
def configure_default_air_wing(
self, air_wing_config: CampaignAirWingConfig
) -> None:
DefaultSquadronAssigner(air_wing_config, self.game, self).assign()
def adjust_budget(self, amount: float) -> None:
self.budget += amount
@@ -197,7 +191,9 @@ class Coalition:
return
for cp in self.game.theater.control_points_for(self.player):
cp.pending_unit_deliveries.refund_all(self)
cp.ground_unit_orders.refund_all(self)
for squadron in self.air_wing.iter_squadrons():
squadron.refund_orders()
def plan_missions(self) -> None:
color = "Blue" if self.player else "Red"

View File

@@ -1,8 +1,8 @@
from typing import Optional, Tuple
from game.commander.missionproposals import ProposedFlight
from game.inventory import GlobalAircraftInventory
from game.squadrons import AirWing, Squadron
from game.squadrons.airwing import AirWing
from game.squadrons.squadron import Squadron
from game.theater import ControlPoint, MissionTarget
from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task
@@ -14,15 +14,10 @@ class AircraftAllocator:
"""Finds suitable aircraft for proposed missions."""
def __init__(
self,
air_wing: AirWing,
closest_airfields: ClosestAirfields,
global_inventory: GlobalAircraftInventory,
is_player: bool,
self, air_wing: AirWing, closest_airfields: ClosestAirfields, is_player: bool
) -> None:
self.air_wing = air_wing
self.closest_airfields = closest_airfields
self.global_inventory = global_inventory
self.is_player = is_player
def find_squadron_for_flight(
@@ -55,24 +50,20 @@ class AircraftAllocator:
for airfield in self.closest_airfields.operational_airfields:
if not airfield.is_friendly(self.is_player):
continue
inventory = self.global_inventory.for_control_point(airfield)
for aircraft in types:
if not airfield.can_operate(aircraft):
continue
if inventory.available(aircraft) < flight.num_aircraft:
continue
distance_to_target = meters(target.distance_to(airfield))
if distance_to_target > aircraft.max_mission_range:
continue
# Valid location with enough aircraft available. Find a squadron to fit
# the role.
squadrons = self.air_wing.auto_assignable_for_task_with_type(
aircraft, task
aircraft, task, airfield
)
for squadron in squadrons:
if squadron.operates_from(airfield) and squadron.can_provide_pilots(
if squadron.operates_from(airfield) and squadron.can_fulfill_flight(
flight.num_aircraft
):
inventory.remove_aircraft(aircraft, flight.num_aircraft)
return airfield, squadron
return None

View File

@@ -157,7 +157,10 @@ class ObjectiveFinder:
for control_point in self.enemy_control_points():
if not isinstance(control_point, Airfield):
continue
if control_point.base.total_aircraft >= min_aircraft:
if (
control_point.allocated_aircraft(self.game).total_present
>= min_aircraft
):
airfields.append(control_point)
return self._targets_by_range(airfields)

View File

@@ -1,13 +1,12 @@
from typing import Optional
from game.commander.aircraftallocator import AircraftAllocator
from game.commander.missionproposals import ProposedFlight
from game.dcs.aircrafttype import AircraftType
from game.inventory import GlobalAircraftInventory
from game.squadrons import AirWing
from game.squadrons.airwing import AirWing
from game.theater import MissionTarget, OffMapSpawn, ControlPoint
from game.utils import nautical_miles
from gen import Package
from game.commander.aircraftallocator import AircraftAllocator
from gen.ato import Package
from gen.flights.closestairfields import ClosestAirfields
from gen.flights.flight import Flight
@@ -19,7 +18,6 @@ class PackageBuilder:
self,
location: MissionTarget,
closest_airfields: ClosestAirfields,
global_inventory: GlobalAircraftInventory,
air_wing: AirWing,
is_player: bool,
package_country: str,
@@ -30,10 +28,7 @@ class PackageBuilder:
self.is_player = is_player
self.package_country = package_country
self.package = Package(location, auto_asap=asap)
self.allocator = AircraftAllocator(
air_wing, closest_airfields, global_inventory, is_player
)
self.global_inventory = global_inventory
self.allocator = AircraftAllocator(air_wing, closest_airfields, is_player)
self.start_type = start_type
def plan_flight(self, plan: ProposedFlight) -> bool:
@@ -93,6 +88,5 @@ class PackageBuilder:
"""Returns any planned flights to the inventory."""
flights = list(self.package.flights)
for flight in flights:
self.global_inventory.return_from_flight(flight)
flight.clear_roster()
flight.return_pilots_and_aircraft()
self.package.remove_flight(flight)

View File

@@ -5,16 +5,15 @@ from collections import defaultdict
from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional
from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType
from game.commander.packagebuilder import PackageBuilder
from game.data.doctrine import Doctrine
from game.inventory import GlobalAircraftInventory
from game.procurement import AircraftProcurementRequest
from game.profiling import MultiEventTracer
from game.settings import Settings
from game.squadrons import AirWing
from game.theater import ConflictTheater
from game.threatzones import ThreatZones
from gen import AirTaskingOrder, Package
from game.commander.packagebuilder import PackageBuilder
from gen.ato import AirTaskingOrder, Package
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
from gen.flights.flightplan import FlightPlanBuilder
@@ -27,15 +26,10 @@ class PackageFulfiller:
"""Responsible for package aircraft allocation and flight plan layout."""
def __init__(
self,
coalition: Coalition,
theater: ConflictTheater,
aircraft_inventory: GlobalAircraftInventory,
settings: Settings,
self, coalition: Coalition, theater: ConflictTheater, settings: Settings
) -> None:
self.coalition = coalition
self.theater = theater
self.aircraft_inventory = aircraft_inventory
self.player_missions_asap = settings.auto_ato_player_missions_asap
self.default_start_type = settings.default_start_type
@@ -137,7 +131,6 @@ class PackageFulfiller:
builder = PackageBuilder(
mission.location,
ObjectiveDistanceCache.get_closest_airfields(mission.location),
self.aircraft_inventory,
self.air_wing,
self.is_player,
self.coalition.country_name,

View File

@@ -11,12 +11,11 @@ from game.commander.missionproposals import ProposedFlight, EscortType, Proposed
from game.commander.packagefulfiller import PackageFulfiller
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.settings import AutoAtoBehavior
from game.theater import MissionTarget
from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
from game.utils import Distance, meters
from gen import Package
from gen.ato import Package
from gen.flights.flight import FlightType
if TYPE_CHECKING:
@@ -54,8 +53,6 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
def execute(self, coalition: Coalition) -> None:
if self.package is None:
raise RuntimeError("Attempted to execute failed package planning task")
for flight in self.package.flights:
coalition.aircraft_inventory.claim_for_flight(flight)
coalition.ato.add_package(self.package)
@abstractmethod
@@ -100,7 +97,6 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
fulfiller = PackageFulfiller(
state.context.coalition,
state.context.theater,
state.available_aircraft,
state.context.settings,
)
self.package = fulfiller.plan_mission(

View File

@@ -10,9 +10,9 @@ from typing import TYPE_CHECKING, Any, Union, Optional
from game.commander.garrisons import Garrisons
from game.commander.objectivefinder import ObjectiveFinder
from game.htn import WorldState
from game.inventory import GlobalAircraftInventory
from game.profiling import MultiEventTracer
from game.settings import Settings
from game.squadrons import AirWing
from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater
from game.theater.theatergroundobject import (
TheaterGroundObject,
@@ -58,7 +58,6 @@ class TheaterState(WorldState["TheaterState"]):
strike_targets: list[TheaterGroundObject[Any]]
enemy_barcaps: list[ControlPoint]
threat_zones: ThreatZones
available_aircraft: GlobalAircraftInventory
def _rebuild_threat_zones(self) -> None:
"""Recreates the theater's threat zones based on the current planned state."""
@@ -122,7 +121,6 @@ class TheaterState(WorldState["TheaterState"]):
strike_targets=list(self.strike_targets),
enemy_barcaps=list(self.enemy_barcaps),
threat_zones=self.threat_zones,
available_aircraft=self.available_aircraft.clone(),
# Persistent properties are not copied. These are a way for failed subtasks
# to communicate requirements to other tasks. For example, the task to
# attack enemy garrisons might fail because the target area has IADS
@@ -172,5 +170,4 @@ class TheaterState(WorldState["TheaterState"]):
strike_targets=list(finder.strike_targets()),
enemy_barcaps=list(game.theater.control_points_for(not player)),
threat_zones=game.threat_zone_for(not player),
available_aircraft=game.aircraft_inventory.clone(),
)

View File

@@ -1,9 +1,7 @@
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from game.data.groundunitclass import GroundUnitClass
from game.savecompat import has_save_compat_for
from game.utils import Distance, feet, nautical_miles
@@ -79,32 +77,6 @@ class Doctrine:
ground_unit_procurement_ratios: GroundUnitProcurementRatios
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
if "max_ingress_distance" not in state:
try:
state["max_ingress_distance"] = state["ingress_distance"]
del state["ingress_distance"]
except KeyError:
state["max_ingress_distance"] = state["ingress_egress_distance"]
del state["ingress_egress_distance"]
max_ip: Distance = state["max_ingress_distance"]
if "min_ingress_distance" not in state:
if max_ip < nautical_miles(10):
min_ip = nautical_miles(5)
else:
min_ip = nautical_miles(10)
state["min_ingress_distance"] = min_ip
self.__dict__.update(state)
class MissionPlannerMaxRanges:
@has_save_compat_for(5)
def __init__(self) -> None:
pass
MODERN_DOCTRINE = Doctrine(
cap=True,

View File

@@ -29,12 +29,20 @@ from game.radio.channels import (
ViggenRadioChannelAllocator,
NoOpChannelAllocator,
)
from game.utils import Distance, Speed, feet, kph, knots, nautical_miles
from game.utils import (
Distance,
SPEED_OF_SOUND_AT_SEA_LEVEL,
Speed,
feet,
kph,
knots,
nautical_miles,
)
if TYPE_CHECKING:
from gen.aircraft import FlightData
from gen import AirSupport, RadioFrequency, RadioRegistry
from gen.radios import Radio
from gen.airsupport import AirSupport
from gen.radios import Radio, RadioFrequency, RadioRegistry
@dataclass(frozen=True)
@@ -186,7 +194,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
@property
def preferred_patrol_altitude(self) -> Distance:
if self.patrol_altitude:
if self.patrol_altitude is not None:
return self.patrol_altitude
else:
# Estimate based on max speed.
@@ -212,6 +220,40 @@ class AircraftType(UnitType[Type[FlyingType]]):
min(altitude_for_highest_speed, rounded_altitude),
)
def preferred_patrol_speed(self, altitude: Distance) -> Speed:
"""Preferred true airspeed when patrolling"""
if self.patrol_speed is not None:
return self.patrol_speed
else:
# Estimate based on max speed.
max_speed = self.max_speed
if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.6:
# Fast airplanes, should manage pretty high patrol speed
return (
Speed.from_mach(0.85, altitude)
if altitude.feet > 20000
else Speed.from_mach(0.7, altitude)
)
elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.2:
# Medium-fast like F/A-18C
return (
Speed.from_mach(0.8, altitude)
if altitude.feet > 20000
else Speed.from_mach(0.65, altitude)
)
elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 0.7:
# Semi-fast like airliners or similar
return (
Speed.from_mach(0.5, altitude)
if altitude.feet > 20000
else Speed.from_mach(0.4, altitude)
)
else:
# Slow like warbirds or helicopters
# Use whichever is slowest - mach 0.35 or 70% of max speed
logging.debug(f"{self.name} max_speed * 0.7 is {max_speed * 0.7}")
return min(Speed.from_mach(0.35, altitude), max_speed * 0.7)
def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
from gen.radios import ChannelInUseError, kHz
@@ -287,7 +329,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
logging.warning(f"No data for {aircraft.id}; it will not be available")
return
with data_path.open() as data_file:
with data_path.open(encoding="utf-8") as data_file:
data = yaml.safe_load(data_file)
try:

View File

@@ -67,7 +67,7 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
logging.warning(f"No data for {vehicle.id}; it will not be available")
return
with data_path.open() as data_file:
with data_path.open(encoding="utf-8") as data_file:
data = yaml.safe_load(data_file)
try:

View File

@@ -386,7 +386,7 @@ class PollDebriefingFileThread(threading.Thread):
os.path.isfile("state.json")
and os.path.getmtime("state.json") > last_modified
):
with open("state.json", "r") as json_file:
with open("state.json", "r", encoding="utf-8") as json_file:
json_data = json.load(json_file)
debriefing = Debriefing(json_data, self.game, self.unit_map)
self.callback(debriefing)

View File

@@ -7,13 +7,12 @@ from dcs.mapping import Point
from dcs.task import Task
from game import persistency
from game.debriefing import AirLosses, Debriefing
from game.debriefing import Debriefing
from game.infos.information import Information
from game.operation.operation import Operation
from game.theater import ControlPoint
from gen import AirTaskingOrder
from gen.ato import AirTaskingOrder
from gen.ground_forces.combat_stance import CombatStance
from ..dcs.groundunittype import GroundUnitType
from ..unitmap import UnitMap
if TYPE_CHECKING:
@@ -67,59 +66,6 @@ class Event:
)
return unit_map
@staticmethod
def _transfer_aircraft(
ato: AirTaskingOrder, losses: AirLosses, for_player: bool
) -> None:
for package in ato.packages:
for flight in package.flights:
# No need to transfer to the same location.
if flight.departure == flight.arrival:
continue
# Don't transfer to bases that were captured. Note that if the
# airfield was back-filling transfers it may overflow. We could
# attempt to be smarter in the future by performing transfers in
# order up a graph to prevent transfers to full airports and
# send overflow off-map, but overflow is fine for now.
if flight.arrival.captured != for_player:
logging.info(
f"Not transferring {flight} because {flight.arrival} "
"was captured"
)
continue
transfer_count = losses.surviving_flight_members(flight)
if transfer_count < 0:
logging.error(
f"{flight} had {flight.count} aircraft but "
f"{transfer_count} losses were recorded."
)
continue
aircraft = flight.unit_type
available = flight.departure.base.total_units_of_type(aircraft)
if available < transfer_count:
logging.error(
f"Found killed {aircraft} from {flight.departure} but "
f"that airbase has only {available} available."
)
continue
flight.departure.base.aircraft[aircraft] -= transfer_count
if aircraft not in flight.arrival.base.aircraft:
# TODO: Should use defaultdict.
flight.arrival.base.aircraft[aircraft] = 0
flight.arrival.base.aircraft[aircraft] += transfer_count
def complete_aircraft_transfers(self, debriefing: Debriefing) -> None:
self._transfer_aircraft(
self.game.blue.ato, debriefing.air_losses, for_player=True
)
self._transfer_aircraft(
self.game.red.ato, debriefing.air_losses, for_player=False
)
def commit_air_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.air_losses.losses:
if loss.pilot is not None and (
@@ -127,18 +73,18 @@ class Event:
or not self.game.settings.invulnerable_player_pilots
):
loss.pilot.kill()
squadron = loss.flight.squadron
aircraft = loss.flight.unit_type
cp = loss.flight.departure
available = cp.base.total_units_of_type(aircraft)
available = squadron.owned_aircraft
if available <= 0:
logging.error(
f"Found killed {aircraft} from {cp} but that airbase has "
f"Found killed {aircraft} from {squadron} but that airbase has "
"none available."
)
continue
logging.info(f"{aircraft} destroyed from {cp}")
cp.base.aircraft[aircraft] -= 1
logging.info(f"{aircraft} destroyed from {squadron}")
squadron.owned_aircraft -= 1
@staticmethod
def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
@@ -276,7 +222,6 @@ class Event:
self.commit_building_losses(debriefing)
self.commit_damaged_runways(debriefing)
self.commit_captures(debriefing)
self.complete_aircraft_transfers(debriefing)
# Destroyed units carcass
# -------------------------
@@ -458,15 +403,10 @@ class Event:
source.base.commit_losses(moved_units)
# Also transfer pending deliveries.
for unit_type, count in source.pending_unit_deliveries.units.items():
if not isinstance(unit_type, GroundUnitType):
continue
if count <= 0:
# Don't transfer *sales*...
continue
for unit_type, count in source.ground_unit_orders.units.items():
move_count = int(count * move_factor)
source.pending_unit_deliveries.sell({unit_type: move_count})
destination.pending_unit_deliveries.order({unit_type: move_count})
source.ground_unit_orders.sell({unit_type: move_count})
destination.ground_unit_orders.order({unit_type: move_count})
total_units_redeployed += move_count
if total_units_redeployed > 0:

View File

@@ -1,10 +1,12 @@
from __future__ import annotations
import itertools
import logging
import math
from collections import Iterator
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Any, List, Type, Union, cast
from typing import Any, List, Type, Union, cast, TYPE_CHECKING
from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors
from dcs.country import Country
@@ -13,7 +15,6 @@ from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence
from faker import Faker
from game.inventory import GlobalAircraftInventory
from game.models.game_stats import GameStats
from game.plugins import LuaPluginManager
from gen import naming
@@ -23,6 +24,7 @@ from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
from gen.ground_forces.ai_ground_planner import GroundPlanner
from . import persistency
from .campaignloader import CampaignAirWingConfig
from .coalition import Coalition
from .debriefing import Debriefing
from .event.event import Event
@@ -32,7 +34,6 @@ from .infos.information import Information
from .navmesh import NavMesh
from .profiling import logged_duration
from .settings import Settings
from .squadrons import AirWing
from .theater import ConflictTheater, ControlPoint
from .theater.bullseye import Bullseye
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
@@ -40,6 +41,9 @@ from .threatzones import ThreatZones
from .unitmap import UnitMap
from .weather import Conditions, TimeOfDay
if TYPE_CHECKING:
from .squadrons import AirWing
COMMISION_UNIT_VARIETY = 4
COMMISION_LIMITS_SCALE = 1.5
COMMISION_LIMITS_FACTORS = {
@@ -86,6 +90,7 @@ class Game:
player_faction: Faction,
enemy_faction: Faction,
theater: ConflictTheater,
air_wing_config: CampaignAirWingConfig,
start_date: datetime,
settings: Settings,
player_budget: float,
@@ -120,7 +125,8 @@ class Game:
self.blue.set_opponent(self.red)
self.red.set_opponent(self.blue)
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
self.blue.configure_default_air_wing(air_wing_config)
self.red.configure_default_air_wing(air_wing_config)
self.on_load(game_still_initializing=True)
@@ -396,9 +402,9 @@ class Game:
# Plan Coalition specific turn
if for_blue:
self.initialize_turn_for(player=True)
self.blue.initialize_turn()
if for_red:
self.initialize_turn_for(player=False)
self.red.initialize_turn()
# Plan GroundWar
self.ground_planners = {}
@@ -408,12 +414,6 @@ class Game:
gplanner.plan_groundwar()
self.ground_planners[cp.id] = gplanner
def initialize_turn_for(self, player: bool) -> None:
self.aircraft_inventory.reset(player)
for cp in self.theater.control_points_for(player):
self.aircraft_inventory.set_from_control_point(cp)
self.coalition_for(player).initialize_turn()
def message(self, text: str) -> None:
self.informations.append(Information(text, turn=self.turn))

View File

@@ -2,13 +2,11 @@ from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Optional, TYPE_CHECKING, Any
from typing import Optional, TYPE_CHECKING
from game.theater import ControlPoint
from .coalition import Coalition
from .dcs.groundunittype import GroundUnitType
from .dcs.unittype import UnitType
from .theater.transitnetwork import (
NoPathError,
TransitNetwork,
@@ -19,59 +17,41 @@ if TYPE_CHECKING:
from .game import Game
@dataclass(frozen=True)
class GroundUnitSource:
control_point: ControlPoint
class PendingUnitDeliveries:
class GroundUnitOrders:
def __init__(self, destination: ControlPoint) -> None:
self.destination = destination
# Maps unit type to order quantity.
self.units: dict[UnitType[Any], int] = defaultdict(int)
self.units: dict[GroundUnitType, int] = defaultdict(int)
def __str__(self) -> str:
return f"Pending delivery to {self.destination}"
return f"Pending ground unit delivery to {self.destination}"
def order(self, units: dict[UnitType[Any], int]) -> None:
def order(self, units: dict[GroundUnitType, int]) -> None:
for k, v in units.items():
self.units[k] += v
def sell(self, units: dict[UnitType[Any], int]) -> None:
def sell(self, units: dict[GroundUnitType, int]) -> None:
for k, v in units.items():
if self.units[k] > v:
self.units[k] -= v
else:
self.units[k] -= v
if self.units[k] == 0:
del self.units[k]
def refund_all(self, coalition: Coalition) -> None:
self.refund(coalition, self.units)
self._refund(coalition, self.units)
self.units = defaultdict(int)
def refund_ground_units(self, coalition: Coalition) -> None:
ground_units: dict[UnitType[Any], int] = {
u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType)
}
self.refund(coalition, ground_units)
for gu in ground_units.keys():
del self.units[gu]
def refund(self, coalition: Coalition, units: dict[UnitType[Any], int]) -> None:
def _refund(self, coalition: Coalition, units: dict[GroundUnitType, int]) -> None:
for unit_type, count in units.items():
logging.info(f"Refunding {count} {unit_type} at {self.destination.name}")
coalition.adjust_budget(unit_type.price * count)
def pending_orders(self, unit_type: UnitType[Any]) -> int:
def pending_orders(self, unit_type: GroundUnitType) -> int:
pending_units = self.units.get(unit_type)
if pending_units is None:
pending_units = 0
return pending_units
def available_next_turn(self, unit_type: UnitType[Any]) -> int:
current_units = self.destination.base.total_units_of_type(unit_type)
return self.pending_orders(unit_type) + current_units
def process(self, game: Game) -> None:
coalition = game.coalition_for(self.destination.captured)
ground_unit_source = self.find_ground_unit_source(game)
@@ -80,36 +60,33 @@ class PendingUnitDeliveries:
f"{self.destination.name} lost its source for ground unit "
"reinforcements. Refunding purchase price."
)
self.refund_ground_units(coalition)
self.refund_all(coalition)
bought_units: dict[UnitType[Any], int] = {}
bought_units: dict[GroundUnitType, int] = {}
units_needing_transfer: dict[GroundUnitType, int] = {}
sold_units: dict[UnitType[Any], int] = {}
for unit_type, count in self.units.items():
allegiance = "Ally" if self.destination.captured else "Enemy"
d: dict[Any, int]
if (
isinstance(unit_type, GroundUnitType)
and self.destination != ground_unit_source
):
d: dict[GroundUnitType, int]
if self.destination != ground_unit_source:
source = ground_unit_source
d = units_needing_transfer
else:
source = self.destination
d = bought_units
if count >= 0:
if count < 0:
logging.error(
f"Attempted sale of {unit_type} at {self.destination} but ground "
"units cannot be sold"
)
elif count > 0:
d[unit_type] = count
game.message(
f"{allegiance} reinforcements: {unit_type} x {count} at {source}"
)
else:
sold_units[unit_type] = -count
game.message(f"{allegiance} sold: {unit_type} x {-count} at {source}")
self.units = defaultdict(int)
self.destination.base.commission_units(bought_units)
self.destination.base.commit_losses(sold_units)
if units_needing_transfer:
if ground_unit_source is None:

View File

@@ -1,142 +0,0 @@
"""Inventory management APIs."""
from __future__ import annotations
from collections import defaultdict, Iterator, Iterable
from typing import TYPE_CHECKING
from game.dcs.aircrafttype import AircraftType
from gen.flights.flight import Flight
if TYPE_CHECKING:
from game.theater import ControlPoint
class ControlPointAircraftInventory:
"""Aircraft inventory for a single control point."""
def __init__(self, control_point: ControlPoint) -> None:
self.control_point = control_point
self.inventory: dict[AircraftType, int] = defaultdict(int)
def clone(self) -> ControlPointAircraftInventory:
new = ControlPointAircraftInventory(self.control_point)
new.inventory = self.inventory.copy()
return new
def add_aircraft(self, aircraft: AircraftType, count: int) -> None:
"""Adds aircraft to the inventory.
Args:
aircraft: The type of aircraft to add.
count: The number of aircraft to add.
"""
self.inventory[aircraft] += count
def remove_aircraft(self, aircraft: AircraftType, count: int) -> None:
"""Removes aircraft from the inventory.
Args:
aircraft: The type of aircraft to remove.
count: The number of aircraft to remove.
Raises:
ValueError: The control point cannot fulfill the requested number of
aircraft.
"""
available = self.inventory[aircraft]
if available < count:
raise ValueError(
f"Cannot remove {count} {aircraft} from "
f"{self.control_point.name}. Only have {available}."
)
self.inventory[aircraft] -= count
def available(self, aircraft: AircraftType) -> int:
"""Returns the number of available aircraft of the given type.
Args:
aircraft: The type of aircraft to query.
"""
try:
return self.inventory[aircraft]
except KeyError:
return 0
@property
def types_available(self) -> Iterator[AircraftType]:
"""Iterates over all available aircraft types."""
for aircraft, count in self.inventory.items():
if count > 0:
yield aircraft
@property
def all_aircraft(self) -> Iterator[tuple[AircraftType, int]]:
"""Iterates over all available aircraft types, including amounts."""
for aircraft, count in self.inventory.items():
if count > 0:
yield aircraft, count
def clear(self) -> None:
"""Clears all aircraft from the inventory."""
self.inventory.clear()
class GlobalAircraftInventory:
"""Game-wide aircraft inventory."""
def __init__(self, control_points: Iterable[ControlPoint]) -> None:
self.inventories: dict[ControlPoint, ControlPointAircraftInventory] = {
cp: ControlPointAircraftInventory(cp) for cp in control_points
}
def clone(self) -> GlobalAircraftInventory:
new = GlobalAircraftInventory([])
new.inventories = {
cp: inventory.clone() for cp, inventory in self.inventories.items()
}
return new
def reset(self, for_player: bool) -> None:
"""Clears the inventory of every control point owned by the given coalition."""
for inventory in self.inventories.values():
if inventory.control_point.captured == for_player:
inventory.clear()
def set_from_control_point(self, control_point: ControlPoint) -> None:
"""Set the control point's aircraft inventory.
If the inventory for the given control point has already been set for
the turn, it will be overwritten.
"""
inventory = self.inventories[control_point]
for aircraft, count in control_point.base.aircraft.items():
inventory.add_aircraft(aircraft, count)
def for_control_point(
self, control_point: ControlPoint
) -> ControlPointAircraftInventory:
"""Returns the inventory specific to the given control point."""
return self.inventories[control_point]
@property
def available_types_for_player(self) -> Iterator[AircraftType]:
"""Iterates over all aircraft types available to the player."""
seen: set[AircraftType] = set()
for control_point, inventory in self.inventories.items():
if control_point.captured:
for aircraft in inventory.types_available:
if not control_point.can_operate(aircraft):
continue
if aircraft not in seen:
seen.add(aircraft)
yield aircraft
def claim_for_flight(self, flight: Flight) -> None:
"""Removes aircraft from the inventory for the given flight."""
inventory = self.for_control_point(flight.from_cp)
inventory.remove_aircraft(flight.unit_type, flight.count)
def return_from_flight(self, flight: Flight) -> None:
"""Returns a flight's aircraft to the inventory."""
inventory = self.for_control_point(flight.from_cp)
inventory.add_aircraft(flight.unit_type, flight.count)

View File

@@ -56,10 +56,12 @@ class GameStats:
for cp in game.theater.controlpoints:
if cp.captured:
turn_data.allied_units.aircraft_count += sum(cp.base.aircraft.values())
for squadron in cp.squadrons:
turn_data.allied_units.aircraft_count += squadron.owned_aircraft
turn_data.allied_units.vehicles_count += sum(cp.base.armor.values())
else:
turn_data.enemy_units.aircraft_count += sum(cp.base.aircraft.values())
for squadron in cp.squadrons:
turn_data.enemy_units.aircraft_count += squadron.owned_aircraft
turn_data.enemy_units.vehicles_count += sum(cp.base.armor.values())
self.data_per_turn.append(turn_data)

View File

@@ -16,16 +16,18 @@ from dcs.triggers import TriggerStart
from game.plugins import LuaPluginManager
from game.theater.theatergroundobject import TheaterGroundObject
from gen import Conflict, FlightType, VisualGenerator, Bullseye, AirSupport
from gen.aircraft import AircraftConflictGenerator, FlightData
from gen.airfields import AIRFIELD_DATA
from gen.airsupport import AirSupport
from gen.airsupportgen import AirSupportConflictGenerator
from gen.armor import GroundConflictGenerator
from gen.beacons import load_beacons_for_terrain
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
from gen.cargoshipgen import CargoShipGenerator
from gen.conflictgen import Conflict
from gen.convoygen import ConvoyGenerator
from gen.environmentgen import EnvironmentGenerator
from gen.flights.flight import FlightType
from gen.forcedoptionsgen import ForcedOptionsGenerator
from gen.groundobjectsgen import GroundObjectsGenerator
from gen.kneeboard import KneeboardGenerator
@@ -34,6 +36,7 @@ from gen.naming import namegen
from gen.radios import RadioFrequency, RadioRegistry
from gen.tacan import TacanRegistry
from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator
from gen.visualgen import VisualGenerator
from .. import db
from ..theater import Airfield, FrontLine
from ..unitmap import UnitMap
@@ -65,7 +68,7 @@ class Operation:
@classmethod
def prepare(cls, game: Game) -> None:
with open("resources/default_options.lua", "r") as f:
with open("resources/default_options.lua", "r", encoding="utf-8") as f:
options_dict = loads(f.read())["options"]
cls._set_mission(Mission(game.theater.terrain))
cls.game = game

View File

@@ -2,14 +2,14 @@ from __future__ import annotations
import math
import random
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
from game import db
from game.data.groundunitclass import GroundUnitClass
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction
from game.squadrons import Squadron
from game.theater import ControlPoint, MissionTarget
from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task
@@ -97,37 +97,10 @@ class ProcurementAi:
budget -= armor_budget
budget += self.reinforce_front_line(armor_budget)
# Don't sell overstock aircraft until after we've bought runways and
# front lines. Any budget we free up should be earmarked for aircraft.
if not self.is_player:
budget += self.sell_incomplete_squadrons()
if self.manage_aircraft:
budget = self.purchase_aircraft(budget)
return budget
def sell_incomplete_squadrons(self) -> float:
# Selling incomplete squadrons gives us more money to spend on the next
# turn. This serves as a short term fix for
# https://github.com/dcs-liberation/dcs_liberation/issues/41.
#
# Only incomplete squadrons which are unlikely to get used will be sold
# rather than all unused aircraft because the unused aircraft are what
# make OCA strikes worthwhile.
#
# This option is only used by the AI since players cannot cancel sales
# (https://github.com/dcs-liberation/dcs_liberation/issues/365).
total = 0.0
for cp in self.game.theater.control_points_for(self.is_player):
inventory = self.game.aircraft_inventory.for_control_point(cp)
for aircraft, available in inventory.all_aircraft:
# We only ever plan even groups, so the odd aircraft is unlikely
# to get used.
if available % 2 == 0:
continue
inventory.remove_aircraft(aircraft, 1)
total += aircraft.price
return total
def repair_runways(self, budget: float) -> float:
for control_point in self.owned_points:
if budget < db.RUNWAY_REPAIR_COST:
@@ -180,7 +153,7 @@ class ProcurementAi:
break
budget -= unit.price
cp.pending_unit_deliveries.order({unit: 1})
cp.ground_unit_orders.order({unit: 1})
return budget
@@ -209,64 +182,29 @@ class ProcurementAi:
return GroundUnitClass.Tank
return worst_balanced
def affordable_aircraft_for(
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
) -> Optional[AircraftType]:
best_choice: Optional[AircraftType] = None
for unit in aircraft_for_task(request.task_capability):
if unit not in self.faction.aircrafts:
continue
if unit.price * request.number > budget:
continue
if not airbase.can_operate(unit):
continue
distance_to_target = meters(request.near.distance_to(airbase))
if distance_to_target > unit.max_mission_range:
continue
for squadron in self.air_wing.squadrons_for(unit):
if (
squadron.operates_from(airbase)
and request.task_capability
in squadron.auto_assignable_mission_types
):
break
else:
continue
# Affordable, compatible, and we have a squadron capable of the task. To
# keep some variety, skip with a 50/50 chance. Might be a good idea to have
# the chance to skip based on the price compared to the rest of the choices.
best_choice = unit
if random.choice([True, False]):
break
return best_choice
@staticmethod
def fulfill_aircraft_request(
self, request: AircraftProcurementRequest, budget: float
squadrons: list[Squadron], quantity: int, budget: float
) -> Tuple[float, bool]:
for airbase in self.best_airbases_for(request):
unit = self.affordable_aircraft_for(request, airbase, budget)
if unit is None:
# Can't afford any aircraft capable of performing the
# required mission that can operate from this airbase. We
# might be able to afford aircraft at other airbases though,
# in the case where the airbase we attempted to use is only
# able to operate expensive aircraft.
for squadron in squadrons:
price = squadron.aircraft.price * quantity
if price > budget:
continue
budget -= unit.price * request.number
airbase.pending_unit_deliveries.order({unit: request.number})
squadron.pending_deliveries += quantity
budget -= price
return budget, True
return budget, False
def purchase_aircraft(self, budget: float) -> float:
for request in self.game.coalition_for(self.is_player).procurement_requests:
if not list(self.best_airbases_for(request)):
squadrons = list(self.best_squadrons_for(request))
if not squadrons:
# No airbases in range of this request. Skip it.
continue
budget, fulfilled = self.fulfill_aircraft_request(request, budget)
budget, fulfilled = self.fulfill_aircraft_request(
squadrons, request.number, budget
)
if not fulfilled:
# The request was not fulfilled because we could not afford any suitable
# aircraft. Rather than continuing, which could proceed to buy tons of
@@ -283,9 +221,32 @@ class ProcurementAi:
else:
return self.game.theater.enemy_points()
def best_airbases_for(
@staticmethod
def squadron_rank_for_task(squadron: Squadron, task: FlightType) -> int:
return aircraft_for_task(task).index(squadron.aircraft)
def compatible_squadrons_at_airbase(
self, airbase: ControlPoint, request: AircraftProcurementRequest
) -> Iterator[Squadron]:
compatible: list[Squadron] = []
for squadron in airbase.squadrons:
if not squadron.can_auto_assign(request.task_capability):
continue
if not squadron.can_provide_pilots(request.number):
continue
distance_to_target = meters(request.near.distance_to(airbase))
if distance_to_target > squadron.aircraft.max_mission_range:
continue
compatible.append(squadron)
yield from sorted(
compatible,
key=lambda s: self.squadron_rank_for_task(s, request.task_capability),
)
def best_squadrons_for(
self, request: AircraftProcurementRequest
) -> Iterator[ControlPoint]:
) -> Iterator[Squadron]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
threatened = []
for cp in distance_cache.operational_airfields:
@@ -295,8 +256,10 @@ class ProcurementAi:
continue
if self.threat_zones.threatened(cp.position):
threatened.append(cp)
yield cp
yield from threatened
continue
yield from self.compatible_squadrons_at_airbase(cp, request)
for threatened_base in threatened:
yield from self.compatible_squadrons_at_airbase(threatened_base, request)
def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
worst_supply = math.inf

180
game/purchaseadapter.py Normal file
View File

@@ -0,0 +1,180 @@
from abc import abstractmethod
from typing import TypeVar, Generic
from game import Game
from game.coalition import Coalition
from game.dcs.groundunittype import GroundUnitType
from game.squadrons import Squadron
from game.theater import ControlPoint
ItemType = TypeVar("ItemType")
class TransactionError(RuntimeError):
def __init__(self, message: str) -> None:
super().__init__(message)
class PurchaseAdapter(Generic[ItemType]):
def __init__(self, coalition: Coalition) -> None:
self.coalition = coalition
def buy(self, item: ItemType, quantity: int) -> None:
for _ in range(quantity):
if self.has_pending_sales(item):
self.do_cancel_sale(item)
elif self.can_buy(item):
self.do_purchase(item)
else:
raise TransactionError(f"Cannot buy more {item}")
self.coalition.adjust_budget(-self.price_of(item))
def sell(self, item: ItemType, quantity: int) -> None:
for _ in range(quantity):
if self.has_pending_orders(item):
self.do_cancel_purchase(item)
elif self.can_sell(item):
self.do_sale(item)
else:
raise TransactionError(f"Cannot sell more {item}")
self.coalition.adjust_budget(self.price_of(item))
def has_pending_orders(self, item: ItemType) -> bool:
return self.pending_delivery_quantity(item) > 0
def has_pending_sales(self, item: ItemType) -> bool:
return self.pending_delivery_quantity(item) < 0
@abstractmethod
def current_quantity_of(self, item: ItemType) -> int:
...
@abstractmethod
def pending_delivery_quantity(self, item: ItemType) -> int:
...
def expected_quantity_next_turn(self, item: ItemType) -> int:
return self.current_quantity_of(item) + self.pending_delivery_quantity(item)
def can_buy(self, item: ItemType) -> bool:
return self.coalition.budget >= self.price_of(item)
def can_sell_or_cancel(self, item: ItemType) -> bool:
return self.can_sell(item) or self.has_pending_orders(item)
@abstractmethod
def can_sell(self, item: ItemType) -> bool:
...
@abstractmethod
def do_purchase(self, item: ItemType) -> None:
...
@abstractmethod
def do_cancel_purchase(self, item: ItemType) -> None:
...
@abstractmethod
def do_sale(self, item: ItemType) -> None:
...
@abstractmethod
def do_cancel_sale(self, item: ItemType) -> None:
...
@abstractmethod
def price_of(self, item: ItemType) -> int:
...
@abstractmethod
def name_of(self, item: ItemType, multiline: bool = False) -> str:
...
class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]):
def __init__(
self, control_point: ControlPoint, coalition: Coalition, game: Game
) -> None:
super().__init__(coalition)
self.control_point = control_point
self.game = game
def pending_delivery_quantity(self, item: Squadron) -> int:
return item.pending_deliveries
def current_quantity_of(self, item: Squadron) -> int:
return item.owned_aircraft
def can_buy(self, item: Squadron) -> bool:
return (
super().can_buy(item)
and self.control_point.unclaimed_parking(self.game) > 0
)
def can_sell(self, item: Squadron) -> bool:
return item.untasked_aircraft > 0
def do_purchase(self, item: Squadron) -> None:
item.pending_deliveries += 1
def do_cancel_purchase(self, item: Squadron) -> None:
item.pending_deliveries -= 1
def do_sale(self, item: Squadron) -> None:
item.untasked_aircraft -= 1
item.pending_deliveries -= 1
def do_cancel_sale(self, item: Squadron) -> None:
item.untasked_aircraft += 1
item.pending_deliveries += 1
def price_of(self, item: Squadron) -> int:
return item.aircraft.price
def name_of(self, item: Squadron, multiline: bool = False) -> str:
if multiline:
separator = "<br />"
else:
separator = " "
return separator.join([item.aircraft.name, str(item)])
class GroundUnitPurchaseAdapter(PurchaseAdapter[GroundUnitType]):
def __init__(
self, control_point: ControlPoint, coalition: Coalition, game: Game
) -> None:
super().__init__(coalition)
self.control_point = control_point
self.game = game
def pending_delivery_quantity(self, item: GroundUnitType) -> int:
return self.control_point.ground_unit_orders.pending_orders(item)
def current_quantity_of(self, item: GroundUnitType) -> int:
return self.control_point.base.total_units_of_type(item)
def can_buy(self, item: GroundUnitType) -> bool:
return super().can_buy(item) and self.control_point.has_ground_unit_source(
self.game
)
def can_sell(self, item: GroundUnitType) -> bool:
return False
def do_purchase(self, item: GroundUnitType) -> None:
self.control_point.ground_unit_orders.order({item: 1})
def do_cancel_purchase(self, item: GroundUnitType) -> None:
self.control_point.ground_unit_orders.sell({item: 1})
def do_sale(self, item: GroundUnitType) -> None:
raise TransactionError("Sale of ground units not allowed")
def do_cancel_sale(self, item: GroundUnitType) -> None:
raise TransactionError("Sale of ground units not allowed")
def price_of(self, item: GroundUnitType) -> int:
return item.price
def name_of(self, item: GroundUnitType, multiline: bool = False) -> str:
return f"{item}"

View File

@@ -4,7 +4,8 @@ from dataclasses import dataclass
from typing import Optional, Any, TYPE_CHECKING
if TYPE_CHECKING:
from gen import FlightData, AirSupport
from gen.aircraft import FlightData
from gen.airsupport import AirSupport
class RadioChannelAllocator:

View File

@@ -16,78 +16,81 @@ class AutoAtoBehavior(Enum):
@dataclass
class Settings:
version: Optional[str] = None
# Difficulty settings
# AI Difficulty
player_skill: str = "Good"
enemy_skill: str = "Average"
ai_pilot_levelling: bool = True
enemy_vehicle_skill: str = "Average"
map_coalition_visibility: ForcedOptions.Views = ForcedOptions.Views.All
labels: str = "Full"
only_player_takeoff: bool = True # Legacy parameter do not use
night_disabled: bool = False
external_views_allowed: bool = True
supercarrier: bool = False
generate_marks: bool = True
manpads: bool = True
version: Optional[str] = None
player_income_multiplier: float = 1.0
enemy_income_multiplier: float = 1.0
invulnerable_player_pilots: bool = True
# Mission Difficulty
night_disabled: bool = False
manpads: bool = True
# Mission Restrictions
labels: str = "Full"
map_coalition_visibility: ForcedOptions.Views = ForcedOptions.Views.All
external_views_allowed: bool = True
battle_damage_assessment: Optional[bool] = None
# Campaign management
# General
restrict_weapons_by_date: bool = False
disable_legacy_aewc: bool = True
disable_legacy_tanker: bool = True
# Pilots and Squadrons
ai_pilot_levelling: bool = True
#: Feature flag for squadron limits.
enable_squadron_pilot_limits: bool = False
#: The maximum number of pilots a squadron can have at one time. Changing this after
#: the campaign has started will have no immediate effect; pilots already in the
#: squadron will not be removed if the limit is lowered and pilots will not be
#: immediately created if the limit is raised.
squadron_pilot_limit: int = 12
#: The number of pilots a squadron can replace per turn.
squadron_replenishment_rate: int = 4
default_start_type: str = "Cold"
# Mission specific
desired_player_mission_duration: timedelta = timedelta(minutes=60)
# Campaign management
# HQ Automation
automate_runway_repair: bool = False
automate_front_line_reinforcements: bool = False
automate_aircraft_reinforcements: bool = False
automate_front_line_stance: bool = True
restrict_weapons_by_date: bool = False
disable_legacy_aewc: bool = True
disable_legacy_tanker: bool = True
generate_dark_kneeboard: bool = False
invulnerable_player_pilots: bool = True
auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default
auto_ato_player_missions_asap: bool = True
automate_front_line_stance: bool = True
reserves_procurement_target: int = 10
# Performance oriented
perf_red_alert_state: bool = True
# Mission Generator
# Gameplay
supercarrier: bool = False
generate_marks: bool = True
generate_dark_kneeboard: bool = False
never_delay_player_flights: bool = False
default_start_type: str = "Cold"
# Mission specific
desired_player_mission_duration: timedelta = timedelta(minutes=60)
# Performance
perf_smoke_gen: bool = True
perf_smoke_spacing = 1600
perf_red_alert_state: bool = True
perf_artillery: bool = True
perf_moving_units: bool = True
perf_infantry: bool = True
perf_destroyed_units: bool = True
reserves_procurement_target: int = 10
# Performance culling
perf_culling: bool = False
perf_culling_distance: int = 100
perf_do_not_cull_carrier = True
# LUA Plugins system
plugins: Dict[str, bool] = field(default_factory=dict)
# Cheating
show_red_ato: bool = False
enable_frontline_cheats: bool = False
enable_base_capture_cheat: bool = False
never_delay_player_flights: bool = False
# LUA Plugins system
plugins: Dict[str, bool] = field(default_factory=dict)
only_player_takeoff: bool = True # Legacy parameter do not use
@staticmethod
def plugin_settings_key(identifier: str) -> str:

View File

@@ -1,503 +0,0 @@
from __future__ import annotations
import dataclasses
import itertools
import logging
import random
from collections import defaultdict
from dataclasses import dataclass, field
from enum import unique, Enum
from pathlib import Path
from typing import (
Tuple,
TYPE_CHECKING,
Optional,
Iterator,
Sequence,
Any,
)
import yaml
from faker import Faker
from game.dcs.aircrafttype import AircraftType
from game.settings import AutoAtoBehavior, Settings
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
from gen.flights.flight import FlightType
from game.theater import ControlPoint
@dataclass
class PilotRecord:
missions_flown: int = field(default=0)
@unique
class PilotStatus(Enum):
Active = "Active"
OnLeave = "On leave"
Dead = "Dead"
@dataclass
class Pilot:
name: str
player: bool = field(default=False)
status: PilotStatus = field(default=PilotStatus.Active)
record: PilotRecord = field(default_factory=PilotRecord)
@property
def alive(self) -> bool:
return self.status is not PilotStatus.Dead
@property
def on_leave(self) -> bool:
return self.status is PilotStatus.OnLeave
def send_on_leave(self) -> None:
if self.status is not PilotStatus.Active:
raise RuntimeError("Only active pilots may be sent on leave")
self.status = PilotStatus.OnLeave
def return_from_leave(self) -> None:
if self.status is not PilotStatus.OnLeave:
raise RuntimeError("Only pilots on leave may be returned from leave")
self.status = PilotStatus.Active
def kill(self) -> None:
self.status = PilotStatus.Dead
@classmethod
def random(cls, faker: Faker) -> Pilot:
return Pilot(faker.name())
@dataclass(frozen=True)
class OperatingBases:
shore: bool
carrier: bool
lha: bool
@classmethod
def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases:
if aircraft.dcs_unit_type.helicopter:
# Helicopters operate from anywhere by default.
return OperatingBases(shore=True, carrier=True, lha=True)
if aircraft.lha_capable:
# Marine aircraft operate from LHAs and the shore by default.
return OperatingBases(shore=True, carrier=False, lha=True)
if aircraft.carrier_capable:
# Carrier aircraft operate from carriers by default.
return OperatingBases(shore=False, carrier=True, lha=False)
# And the rest are only capable of shore operation.
return OperatingBases(shore=True, carrier=False, lha=False)
@classmethod
def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases:
return dataclasses.replace(
OperatingBases.default_for_aircraft(aircraft), **data
)
@dataclass
class Squadron:
name: str
nickname: Optional[str]
country: str
role: str
aircraft: AircraftType
livery: Optional[str]
mission_types: tuple[FlightType, ...]
operating_bases: OperatingBases
#: The pool of pilots that have not yet been assigned to the squadron. This only
#: happens when a preset squadron defines more preset pilots than the squadron limit
#: allows. This pool will be consumed before random pilots are generated.
pilot_pool: list[Pilot]
current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False)
available_pilots: list[Pilot] = field(
default_factory=list, init=False, hash=False, compare=False
)
auto_assignable_mission_types: set[FlightType] = field(
init=False, hash=False, compare=False
)
coalition: Coalition = field(hash=False, compare=False)
settings: Settings = field(hash=False, compare=False)
def __post_init__(self) -> None:
self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str:
if self.nickname is None:
return self.name
return f'{self.name} "{self.nickname}"'
@property
def player(self) -> bool:
return self.coalition.player
@property
def pilot_limits_enabled(self) -> bool:
return self.settings.enable_squadron_pilot_limits
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
if self.pilot_limits_enabled:
return None
self._recruit_pilots(1)
return self.available_pilots.pop()
def claim_available_pilot(self) -> Optional[Pilot]:
if not self.available_pilots:
return self.claim_new_pilot_if_allowed()
# For opfor, so player/AI option is irrelevant.
if not self.player:
return self.available_pilots.pop()
preference = self.settings.auto_ato_behavior
# No preference, so the first pilot is fine.
if preference is AutoAtoBehavior.Default:
return self.available_pilots.pop()
prefer_players = preference is AutoAtoBehavior.Prefer
for pilot in self.available_pilots:
if pilot.player == prefer_players:
self.available_pilots.remove(pilot)
return pilot
# No pilot was found that matched the user's preference.
#
# If they chose to *never* assign players and only players remain in the pool,
# we cannot fill the slot with the available pilots.
#
# If they only *prefer* players and we're out of players, just return an AI
# pilot.
if not prefer_players:
return self.claim_new_pilot_if_allowed()
return self.available_pilots.pop()
def claim_pilot(self, pilot: Pilot) -> None:
if pilot not in self.available_pilots:
raise ValueError(
f"Cannot assign {pilot} to {self} because they are not available"
)
self.available_pilots.remove(pilot)
def return_pilot(self, pilot: Pilot) -> None:
self.available_pilots.append(pilot)
def return_pilots(self, pilots: Sequence[Pilot]) -> None:
# Return in reverse so that returning two pilots and then getting two more
# results in the same ordering. This happens commonly when resetting rosters in
# the UI, when we clear the roster because the UI is updating, then end up
# repopulating the same size flight from the same squadron.
self.available_pilots.extend(reversed(pilots))
def _recruit_pilots(self, count: int) -> None:
new_pilots = self.pilot_pool[:count]
self.pilot_pool = self.pilot_pool[count:]
count -= len(new_pilots)
new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)])
self.current_roster.extend(new_pilots)
self.available_pilots.extend(new_pilots)
def populate_for_turn_0(self) -> None:
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
raise ValueError("Squadrons can only be created with active pilots.")
self._recruit_pilots(self.settings.squadron_pilot_limit)
def replenish_lost_pilots(self) -> None:
if not self.pilot_limits_enabled:
return
replenish_count = min(
self.settings.squadron_replenishment_rate,
self._number_of_unfilled_pilot_slots,
)
if replenish_count > 0:
self._recruit_pilots(replenish_count)
def return_all_pilots(self) -> None:
self.available_pilots = list(self.active_pilots)
@staticmethod
def send_on_leave(pilot: Pilot) -> None:
pilot.send_on_leave()
def return_from_leave(self, pilot: Pilot) -> None:
if not self.has_unfilled_pilot_slots:
raise RuntimeError(
f"Cannot return {pilot} from leave because {self} is full"
)
pilot.return_from_leave()
@property
def faker(self) -> Faker:
return self.coalition.faker
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster if p.status == status]
def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster if p.status != status]
@property
def active_pilots(self) -> list[Pilot]:
return self._pilots_with_status(PilotStatus.Active)
@property
def pilots_on_leave(self) -> list[Pilot]:
return self._pilots_with_status(PilotStatus.OnLeave)
@property
def number_of_pilots_including_inactive(self) -> int:
return len(self.current_roster)
@property
def _number_of_unfilled_pilot_slots(self) -> int:
return self.settings.squadron_pilot_limit - len(self.active_pilots)
@property
def number_of_available_pilots(self) -> int:
return len(self.available_pilots)
def can_provide_pilots(self, count: int) -> bool:
return not self.pilot_limits_enabled or self.number_of_available_pilots >= count
@property
def has_available_pilots(self) -> bool:
return not self.pilot_limits_enabled or bool(self.available_pilots)
@property
def has_unfilled_pilot_slots(self) -> bool:
return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
def operates_from(self, control_point: ControlPoint) -> bool:
if control_point.is_carrier:
return self.operating_bases.carrier
elif control_point.is_lha:
return self.operating_bases.lha
else:
return self.operating_bases.shore
def pilot_at_index(self, index: int) -> Pilot:
return self.current_roster[index]
@classmethod
def from_yaml(cls, path: Path, game: Game, coalition: Coalition) -> Squadron:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
from gen.flights.flight import FlightType
with path.open(encoding="utf8") as squadron_file:
data = yaml.safe_load(squadron_file)
name = data["aircraft"]
try:
unit_type = AircraftType.named(name)
except KeyError as ex:
raise KeyError(f"Could not find any aircraft named {name}") from ex
pilots = [Pilot(n, player=False) for n in data.get("pilots", [])]
pilots.extend([Pilot(n, player=True) for n in data.get("players", [])])
mission_types = [FlightType.from_name(n) for n in data["mission_types"]]
tasks = tasks_for_aircraft(unit_type)
for mission_type in list(mission_types):
if mission_type not in tasks:
logging.error(
f"Squadron has mission type {mission_type} but {unit_type} is not "
f"capable of that task: {path}"
)
mission_types.remove(mission_type)
return Squadron(
name=data["name"],
nickname=data.get("nickname"),
country=data["country"],
role=data["role"],
aircraft=unit_type,
livery=data.get("livery"),
mission_types=tuple(mission_types),
operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),
pilot_pool=pilots,
coalition=coalition,
settings=game.settings,
)
def __setstate__(self, state: dict[str, Any]) -> None:
# TODO: Remove save compat.
if "auto_assignable_mission_types" not in state:
state["auto_assignable_mission_types"] = set(state["mission_types"])
self.__dict__.update(state)
class SquadronLoader:
def __init__(self, game: Game, coalition: Coalition) -> None:
self.game = game
self.coalition = coalition
@staticmethod
def squadron_directories() -> Iterator[Path]:
from game import persistency
yield Path(persistency.base_path()) / "Liberation/Squadrons"
yield Path("resources/squadrons")
def load(self) -> dict[AircraftType, list[Squadron]]:
squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
country = self.coalition.country_name
faction = self.coalition.faction
any_country = country.startswith("Combined Joint Task Forces ")
for directory in self.squadron_directories():
for path, squadron in self.load_squadrons_from(directory):
if not any_country and squadron.country != country:
logging.debug(
"Not using squadron for non-matching country (is "
f"{squadron.country}, need {country}: {path}"
)
continue
if squadron.aircraft not in faction.aircrafts:
logging.debug(
f"Not using squadron because {faction.name} cannot use "
f"{squadron.aircraft}: {path}"
)
continue
logging.debug(
f"Found {squadron.name} {squadron.aircraft} {squadron.role} "
f"compatible with {faction.name}"
)
squadrons[squadron.aircraft].append(squadron)
# Convert away from defaultdict because defaultdict doesn't unpickle so we don't
# want it in the save state.
return dict(squadrons)
def load_squadrons_from(self, directory: Path) -> Iterator[Tuple[Path, Squadron]]:
logging.debug(f"Looking for factions in {directory}")
# First directory level is the aircraft type so that historical squadrons that
# have flown multiple airframes can be defined as many times as needed. The main
# load() method is responsible for filtering out squadrons that aren't
# compatible with the faction.
for squadron_path in directory.glob("*/*.yaml"):
try:
yield squadron_path, Squadron.from_yaml(
squadron_path, self.game, self.coalition
)
except Exception as ex:
raise RuntimeError(
f"Failed to load squadron defined by {squadron_path}"
) from ex
class AirWing:
def __init__(self, game: Game, coalition: Coalition) -> None:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
self.game = game
self.squadrons = SquadronLoader(game, coalition).load()
count = itertools.count(1)
for aircraft in coalition.faction.aircrafts:
if aircraft in self.squadrons:
continue
self.squadrons[aircraft] = [
Squadron(
name=f"Squadron {next(count):03}",
nickname=self.random_nickname(),
country=coalition.country_name,
role="Flying Squadron",
aircraft=aircraft,
livery=None,
mission_types=tuple(tasks_for_aircraft(aircraft)),
operating_bases=OperatingBases.default_for_aircraft(aircraft),
pilot_pool=[],
coalition=coalition,
settings=game.settings,
)
]
def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]:
return self.squadrons[aircraft]
def can_auto_plan(self, task: FlightType) -> bool:
try:
next(self.auto_assignable_for_task(task))
return True
except StopIteration:
return False
def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]:
for squadron in self.iter_squadrons():
if squadron.can_auto_assign(task):
yield squadron
def auto_assignable_for_task_with_type(
self, aircraft: AircraftType, task: FlightType
) -> Iterator[Squadron]:
for squadron in self.squadrons_for(aircraft):
if squadron.can_auto_assign(task) and squadron.has_available_pilots:
yield squadron
def squadron_for(self, aircraft: AircraftType) -> Squadron:
return self.squadrons_for(aircraft)[0]
def iter_squadrons(self) -> Iterator[Squadron]:
return itertools.chain.from_iterable(self.squadrons.values())
def squadron_at_index(self, index: int) -> Squadron:
return list(self.iter_squadrons())[index]
def populate_for_turn_0(self) -> None:
for squadron in self.iter_squadrons():
squadron.populate_for_turn_0()
def replenish(self) -> None:
for squadron in self.iter_squadrons():
squadron.replenish_lost_pilots()
def reset(self) -> None:
for squadron in self.iter_squadrons():
squadron.return_all_pilots()
@property
def size(self) -> int:
return sum(len(s) for s in self.squadrons.values())
@staticmethod
def _make_random_nickname() -> str:
from gen.naming import ANIMALS
animal = random.choice(ANIMALS)
adjective = random.choice(
(
None,
"Red",
"Blue",
"Green",
"Golden",
"Black",
"Fighting",
"Flying",
)
)
if adjective is None:
return animal.title()
return f"{adjective} {animal}".title()
def random_nickname(self) -> str:
while True:
nickname = self._make_random_nickname()
for squadron in self.iter_squadrons():
if squadron.nickname == nickname:
break
else:
return nickname

View File

@@ -0,0 +1,3 @@
from .airwing import AirWing
from .pilot import Pilot
from .squadron import Squadron

89
game/squadrons/airwing.py Normal file
View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import itertools
from collections import defaultdict
from typing import Sequence, Iterator, TYPE_CHECKING
from game.dcs.aircrafttype import AircraftType
from .squadron import Squadron
from ..theater import ControlPoint
if TYPE_CHECKING:
from game import Game
from gen.flights.flight import FlightType
class AirWing:
def __init__(self, game: Game) -> None:
self.game = game
self.squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
def add_squadron(self, squadron: Squadron) -> None:
squadron.location.squadrons.append(squadron)
self.squadrons[squadron.aircraft].append(squadron)
def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]:
return self.squadrons[aircraft]
def can_auto_plan(self, task: FlightType) -> bool:
try:
next(self.auto_assignable_for_task(task))
return True
except StopIteration:
return False
@property
def available_aircraft_types(self) -> Iterator[AircraftType]:
for aircraft, squadrons in self.squadrons.items():
for squadron in squadrons:
if squadron.untasked_aircraft:
yield aircraft
break
def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]:
for squadron in self.iter_squadrons():
if squadron.can_auto_assign(task):
yield squadron
def auto_assignable_for_task_at(
self, task: FlightType, base: ControlPoint
) -> Iterator[Squadron]:
for squadron in self.iter_squadrons():
if squadron.can_auto_assign(task) and squadron.location == base:
yield squadron
def auto_assignable_for_task_with_type(
self, aircraft: AircraftType, task: FlightType, base: ControlPoint
) -> Iterator[Squadron]:
for squadron in self.squadrons_for(aircraft):
if (
squadron.location == base
and squadron.can_auto_assign(task)
and squadron.has_available_pilots
):
yield squadron
def squadron_for(self, aircraft: AircraftType) -> Squadron:
return self.squadrons_for(aircraft)[0]
def iter_squadrons(self) -> Iterator[Squadron]:
return itertools.chain.from_iterable(self.squadrons.values())
def squadron_at_index(self, index: int) -> Squadron:
return list(self.iter_squadrons())[index]
def populate_for_turn_0(self) -> None:
for squadron in self.iter_squadrons():
squadron.populate_for_turn_0()
def replenish(self) -> None:
for squadron in self.iter_squadrons():
squadron.replenish_lost_pilots()
def reset(self) -> None:
for squadron in self.iter_squadrons():
squadron.return_all_pilots_and_aircraft()
@property
def size(self) -> int:
return sum(len(s) for s in self.squadrons.values())

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import dataclasses
from dataclasses import dataclass
from game.dcs.aircrafttype import AircraftType
@dataclass(frozen=True)
class OperatingBases:
shore: bool
carrier: bool
lha: bool
@classmethod
def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases:
if aircraft.dcs_unit_type.helicopter:
# Helicopters operate from anywhere by default.
return OperatingBases(shore=True, carrier=True, lha=True)
if aircraft.lha_capable:
# Marine aircraft operate from LHAs and the shore by default.
return OperatingBases(shore=True, carrier=False, lha=True)
if aircraft.carrier_capable:
# Carrier aircraft operate from carriers by default.
return OperatingBases(shore=False, carrier=True, lha=False)
# And the rest are only capable of shore operation.
return OperatingBases(shore=True, carrier=False, lha=False)
@classmethod
def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases:
return dataclasses.replace(
OperatingBases.default_for_aircraft(aircraft), **data
)

51
game/squadrons/pilot.py Normal file
View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import unique, Enum
from faker import Faker
@dataclass
class PilotRecord:
missions_flown: int = field(default=0)
@unique
class PilotStatus(Enum):
Active = "Active"
OnLeave = "On leave"
Dead = "Dead"
@dataclass
class Pilot:
name: str
player: bool = field(default=False)
status: PilotStatus = field(default=PilotStatus.Active)
record: PilotRecord = field(default_factory=PilotRecord)
@property
def alive(self) -> bool:
return self.status is not PilotStatus.Dead
@property
def on_leave(self) -> bool:
return self.status is PilotStatus.OnLeave
def send_on_leave(self) -> None:
if self.status is not PilotStatus.Active:
raise RuntimeError("Only active pilots may be sent on leave")
self.status = PilotStatus.OnLeave
def return_from_leave(self) -> None:
if self.status is not PilotStatus.OnLeave:
raise RuntimeError("Only pilots on leave may be returned from leave")
self.status = PilotStatus.Active
def kill(self) -> None:
self.status = PilotStatus.Dead
@classmethod
def random(cls, faker: Faker) -> Pilot:
return Pilot(faker.name())

301
game/squadrons/squadron.py Normal file
View File

@@ -0,0 +1,301 @@
from __future__ import annotations
import logging
from collections import Iterable
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Optional,
Sequence,
)
from faker import Faker
from game.dcs.aircrafttype import AircraftType
from game.settings import AutoAtoBehavior, Settings
from game.squadrons.operatingbases import OperatingBases
from game.squadrons.pilot import Pilot, PilotStatus
from game.squadrons.squadrondef import SquadronDef
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
from gen.flights.flight import FlightType
from game.theater import ControlPoint
@dataclass
class Squadron:
name: str
nickname: Optional[str]
country: str
role: str
aircraft: AircraftType
livery: Optional[str]
mission_types: tuple[FlightType, ...]
operating_bases: OperatingBases
#: The pool of pilots that have not yet been assigned to the squadron. This only
#: happens when a preset squadron defines more preset pilots than the squadron limit
#: allows. This pool will be consumed before random pilots are generated.
pilot_pool: list[Pilot]
current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False)
available_pilots: list[Pilot] = field(
default_factory=list, init=False, hash=False, compare=False
)
auto_assignable_mission_types: set[FlightType] = field(
init=False, hash=False, compare=False
)
coalition: Coalition = field(hash=False, compare=False)
settings: Settings = field(hash=False, compare=False)
location: ControlPoint
owned_aircraft: int = field(init=False, hash=False, compare=False, default=0)
untasked_aircraft: int = field(init=False, hash=False, compare=False, default=0)
pending_deliveries: int = field(init=False, hash=False, compare=False, default=0)
def __post_init__(self) -> None:
self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str:
if self.nickname is None:
return self.name
return f'{self.name} "{self.nickname}"'
def __hash__(self) -> int:
return hash(
(
self.name,
self.nickname,
self.country,
self.role,
self.aircraft,
)
)
@property
def player(self) -> bool:
return self.coalition.player
def assign_to_base(self, base: ControlPoint) -> None:
self.location.squadrons.remove(self)
self.location = base
self.location.squadrons.append(self)
logging.debug(f"Assigned {self} to {base}")
@property
def pilot_limits_enabled(self) -> bool:
return self.settings.enable_squadron_pilot_limits
def set_allowed_mission_types(self, mission_types: Iterable[FlightType]) -> None:
self.mission_types = tuple(mission_types)
self.auto_assignable_mission_types.intersection_update(self.mission_types)
def set_auto_assignable_mission_types(
self, mission_types: Iterable[FlightType]
) -> None:
self.auto_assignable_mission_types = set(self.mission_types).intersection(
mission_types
)
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
if self.pilot_limits_enabled:
return None
self._recruit_pilots(1)
return self.available_pilots.pop()
def claim_available_pilot(self) -> Optional[Pilot]:
if not self.available_pilots:
return self.claim_new_pilot_if_allowed()
# For opfor, so player/AI option is irrelevant.
if not self.player:
return self.available_pilots.pop()
preference = self.settings.auto_ato_behavior
# No preference, so the first pilot is fine.
if preference is AutoAtoBehavior.Default:
return self.available_pilots.pop()
prefer_players = preference is AutoAtoBehavior.Prefer
for pilot in self.available_pilots:
if pilot.player == prefer_players:
self.available_pilots.remove(pilot)
return pilot
# No pilot was found that matched the user's preference.
#
# If they chose to *never* assign players and only players remain in the pool,
# we cannot fill the slot with the available pilots.
#
# If they only *prefer* players and we're out of players, just return an AI
# pilot.
if not prefer_players:
return self.claim_new_pilot_if_allowed()
return self.available_pilots.pop()
def claim_pilot(self, pilot: Pilot) -> None:
if pilot not in self.available_pilots:
raise ValueError(
f"Cannot assign {pilot} to {self} because they are not available"
)
self.available_pilots.remove(pilot)
def return_pilot(self, pilot: Pilot) -> None:
self.available_pilots.append(pilot)
def return_pilots(self, pilots: Sequence[Pilot]) -> None:
# Return in reverse so that returning two pilots and then getting two more
# results in the same ordering. This happens commonly when resetting rosters in
# the UI, when we clear the roster because the UI is updating, then end up
# repopulating the same size flight from the same squadron.
self.available_pilots.extend(reversed(pilots))
def _recruit_pilots(self, count: int) -> None:
new_pilots = self.pilot_pool[:count]
self.pilot_pool = self.pilot_pool[count:]
count -= len(new_pilots)
new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)])
self.current_roster.extend(new_pilots)
self.available_pilots.extend(new_pilots)
def populate_for_turn_0(self) -> None:
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
raise ValueError("Squadrons can only be created with active pilots.")
self._recruit_pilots(self.settings.squadron_pilot_limit)
def replenish_lost_pilots(self) -> None:
if not self.pilot_limits_enabled:
return
replenish_count = min(
self.settings.squadron_replenishment_rate,
self._number_of_unfilled_pilot_slots,
)
if replenish_count > 0:
self._recruit_pilots(replenish_count)
def return_all_pilots_and_aircraft(self) -> None:
self.available_pilots = list(self.active_pilots)
self.untasked_aircraft = self.owned_aircraft
@staticmethod
def send_on_leave(pilot: Pilot) -> None:
pilot.send_on_leave()
def return_from_leave(self, pilot: Pilot) -> None:
if not self.has_unfilled_pilot_slots:
raise RuntimeError(
f"Cannot return {pilot} from leave because {self} is full"
)
pilot.return_from_leave()
@property
def faker(self) -> Faker:
return self.coalition.faker
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster if p.status == status]
def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster if p.status != status]
@property
def max_size(self) -> int:
return self.settings.squadron_pilot_limit
@property
def active_pilots(self) -> list[Pilot]:
return self._pilots_with_status(PilotStatus.Active)
@property
def pilots_on_leave(self) -> list[Pilot]:
return self._pilots_with_status(PilotStatus.OnLeave)
@property
def number_of_pilots_including_inactive(self) -> int:
return len(self.current_roster)
@property
def _number_of_unfilled_pilot_slots(self) -> int:
return self.max_size - len(self.active_pilots)
@property
def number_of_available_pilots(self) -> int:
return len(self.available_pilots)
def can_provide_pilots(self, count: int) -> bool:
return not self.pilot_limits_enabled or self.number_of_available_pilots >= count
@property
def has_available_pilots(self) -> bool:
return not self.pilot_limits_enabled or bool(self.available_pilots)
@property
def has_unfilled_pilot_slots(self) -> bool:
return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
def operates_from(self, control_point: ControlPoint) -> bool:
if control_point.is_carrier:
return self.operating_bases.carrier
elif control_point.is_lha:
return self.operating_bases.lha
else:
return self.operating_bases.shore
def pilot_at_index(self, index: int) -> Pilot:
return self.current_roster[index]
def claim_inventory(self, count: int) -> None:
if self.untasked_aircraft < count:
raise ValueError(
f"Cannot remove {count} from {self.name}. Only have "
f"{self.untasked_aircraft}."
)
self.untasked_aircraft -= count
def can_fulfill_flight(self, count: int) -> bool:
return self.can_provide_pilots(count) and self.untasked_aircraft >= count
def refund_orders(self) -> None:
self.coalition.adjust_budget(self.aircraft.price * self.pending_deliveries)
self.pending_deliveries = 0
def deliver_orders(self) -> None:
self.owned_aircraft += self.pending_deliveries
self.pending_deliveries = 0
@property
def max_fulfillable_aircraft(self) -> int:
return max(self.number_of_available_pilots, self.untasked_aircraft)
@classmethod
def create_from(
cls,
squadron_def: SquadronDef,
base: ControlPoint,
coalition: Coalition,
game: Game,
) -> Squadron:
return Squadron(
squadron_def.name,
squadron_def.nickname,
squadron_def.country,
squadron_def.role,
squadron_def.aircraft,
squadron_def.livery,
squadron_def.mission_types,
squadron_def.operating_bases,
squadron_def.pilot_pool,
coalition,
game.settings,
base,
)

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import logging
from collections import Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import (
TYPE_CHECKING,
Optional,
)
import yaml
from game.dcs.aircrafttype import AircraftType
from game.squadrons.operatingbases import OperatingBases
from game.squadrons.pilot import Pilot
if TYPE_CHECKING:
from gen.flights.flight import FlightType
from game.theater import ControlPoint
@dataclass
class SquadronDef:
name: str
nickname: Optional[str]
country: str
role: str
aircraft: AircraftType
livery: Optional[str]
mission_types: tuple[FlightType, ...]
operating_bases: OperatingBases
pilot_pool: list[Pilot]
auto_assignable_mission_types: set[FlightType] = field(
init=False, hash=False, compare=False
)
def __post_init__(self) -> None:
self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str:
if self.nickname is None:
return self.name
return f'{self.name} "{self.nickname}"'
def set_allowed_mission_types(self, mission_types: Iterable[FlightType]) -> None:
self.mission_types = tuple(mission_types)
self.auto_assignable_mission_types.intersection_update(self.mission_types)
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
def operates_from(self, control_point: ControlPoint) -> bool:
if control_point.is_carrier:
return self.operating_bases.carrier
elif control_point.is_lha:
return self.operating_bases.lha
else:
return self.operating_bases.shore
@classmethod
def from_yaml(cls, path: Path) -> SquadronDef:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
from gen.flights.flight import FlightType
with path.open(encoding="utf8") as squadron_file:
data = yaml.safe_load(squadron_file)
name = data["aircraft"]
try:
unit_type = AircraftType.named(name)
except KeyError as ex:
raise KeyError(f"Could not find any aircraft named {name}") from ex
pilots = [Pilot(n, player=False) for n in data.get("pilots", [])]
pilots.extend([Pilot(n, player=True) for n in data.get("players", [])])
mission_types = [FlightType.from_name(n) for n in data["mission_types"]]
tasks = tasks_for_aircraft(unit_type)
for mission_type in list(mission_types):
if mission_type not in tasks:
logging.error(
f"Squadron has mission type {mission_type} but {unit_type} is not "
f"capable of that task: {path}"
)
mission_types.remove(mission_type)
return SquadronDef(
name=data["name"],
nickname=data.get("nickname"),
country=data["country"],
role=data["role"],
aircraft=unit_type,
livery=data.get("livery"),
mission_types=tuple(mission_types),
operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),
pilot_pool=pilots,
)

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import logging
from collections import defaultdict
from pathlib import Path
from typing import Iterator, Tuple, TYPE_CHECKING
from game.dcs.aircrafttype import AircraftType
from .squadrondef import SquadronDef
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
class SquadronDefLoader:
def __init__(self, game: Game, coalition: Coalition) -> None:
self.game = game
self.coalition = coalition
@staticmethod
def squadron_directories() -> Iterator[Path]:
from game import persistency
yield Path(persistency.base_path()) / "Liberation/Squadrons"
yield Path("resources/squadrons")
def load(self) -> dict[AircraftType, list[SquadronDef]]:
squadrons: dict[AircraftType, list[SquadronDef]] = defaultdict(list)
country = self.coalition.country_name
faction = self.coalition.faction
any_country = country.startswith("Combined Joint Task Forces ")
for directory in self.squadron_directories():
for path, squadron_def in self.load_squadrons_from(directory):
if not any_country and squadron_def.country != country:
logging.debug(
"Not using squadron for non-matching country (is "
f"{squadron_def.country}, need {country}: {path}"
)
continue
if squadron_def.aircraft not in faction.aircrafts:
logging.debug(
f"Not using squadron because {faction.name} cannot use "
f"{squadron_def.aircraft}: {path}"
)
continue
logging.debug(
f"Found {squadron_def.name} {squadron_def.aircraft} "
f"{squadron_def.role} compatible with {faction.name}"
)
squadrons[squadron_def.aircraft].append(squadron_def)
return squadrons
@staticmethod
def load_squadrons_from(directory: Path) -> Iterator[Tuple[Path, SquadronDef]]:
logging.debug(f"Looking for factions in {directory}")
# First directory level is the aircraft type so that historical squadrons that
# have flown multiple airframes can be defined as many times as needed. The main
# load() method is responsible for filtering out squadrons that aren't
# compatible with the faction.
for squadron_path in directory.glob("*/*.yaml"):
try:
yield squadron_path, SquadronDef.from_yaml(squadron_path)
except Exception as ex:
raise RuntimeError(
f"Failed to load squadron defined by {squadron_path}"
) from ex

View File

@@ -1,10 +1,4 @@
import itertools
import logging
from typing import Any
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.dcs.unittype import UnitType
BASE_MAX_STRENGTH = 1.0
BASE_MIN_STRENGTH = 0.0
@@ -12,14 +6,9 @@ BASE_MIN_STRENGTH = 0.0
class Base:
def __init__(self) -> None:
self.aircraft: dict[AircraftType, int] = {}
self.armor: dict[GroundUnitType, int] = {}
self.strength = 1.0
@property
def total_aircraft(self) -> int:
return sum(self.aircraft.values())
@property
def total_armor(self) -> int:
return sum(self.armor.values())
@@ -31,49 +20,24 @@ class Base:
total += unit_type.price * count
return total
def total_units_of_type(self, unit_type: UnitType[Any]) -> int:
return sum(
[
c
for t, c in itertools.chain(self.aircraft.items(), self.armor.items())
if t == unit_type
]
)
def total_units_of_type(self, unit_type: GroundUnitType) -> int:
return sum([c for t, c in self.armor.items() if t == unit_type])
def commission_units(self, units: dict[Any, int]) -> None:
def commission_units(self, units: dict[GroundUnitType, int]) -> None:
for unit_type, unit_count in units.items():
if unit_count <= 0:
continue
self.armor[unit_type] = self.armor.get(unit_type, 0) + unit_count
target_dict: dict[Any, int]
if isinstance(unit_type, AircraftType):
target_dict = self.aircraft
elif isinstance(unit_type, GroundUnitType):
target_dict = self.armor
else:
logging.error(f"Unexpected unit type of {unit_type}")
return
target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count
def commit_losses(self, units_lost: dict[Any, int]) -> None:
def commit_losses(self, units_lost: dict[GroundUnitType, int]) -> None:
for unit_type, count in units_lost.items():
target_dict: dict[Any, int]
if unit_type in self.aircraft:
target_dict = self.aircraft
elif unit_type in self.armor:
target_dict = self.armor
else:
print("Base didn't find event type {}".format(unit_type))
if unit_type not in self.armor:
print("Base didn't find unit type {}".format(unit_type))
continue
if unit_type not in target_dict:
print("Base didn't find event type {}".format(unit_type))
continue
target_dict[unit_type] = max(target_dict[unit_type] - count, 0)
if target_dict[unit_type] == 0:
del target_dict[unit_type]
self.armor[unit_type] = max(self.armor[unit_type] - count, 0)
if self.armor[unit_type] == 0:
del self.armor[unit_type]
def affect_strength(self, amount: float) -> None:
self.strength += amount

View File

@@ -1,27 +1,11 @@
from __future__ import annotations
import itertools
import math
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
from dcs import Mission
from dcs.countries import (
CombinedJointTaskForcesBlue,
CombinedJointTaskForcesRed,
)
from dcs.country import Country
from dcs.mapping import Point
from dcs.planes import F_15C
from dcs.ships import (
HandyWind,
Stennis,
USS_Arleigh_Burke_IIa,
LHA_Tarawa,
)
from dcs.statics import Fortification, Warehouse
from dcs.terrain import (
caucasus,
nevada,
@@ -31,497 +15,23 @@ from dcs.terrain import (
thechannel,
marianaislands,
)
from dcs.terrain.terrain import Airport, Terrain
from dcs.unitgroup import (
ShipGroup,
StaticGroup,
VehicleGroup,
PlaneGroup,
)
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
from dcs.terrain.terrain import Terrain
from pyproj import CRS, Transformer
from shapely import geometry, ops
from .controlpoint import (
Airfield,
Carrier,
ControlPoint,
Fob,
Lha,
MissionTarget,
OffMapSpawn,
)
from .frontline import FrontLine
from .landmap import Landmap, load_landmap, poly_contains
from .latlon import LatLon
from .projections import TransverseMercator
from .seasonalconditions import SeasonalConditions
from ..point_with_heading import PointWithHeading
from ..positioned import Positioned
from ..profiling import logged_duration
from ..scenery_group import SceneryGroup
from ..utils import Distance, Heading, meters
if TYPE_CHECKING:
from . import TheaterGroundObject
SIZE_TINY = 150
SIZE_SMALL = 600
SIZE_REGULAR = 1000
SIZE_BIG = 2000
SIZE_LARGE = 3000
IMPORTANCE_LOW = 1
IMPORTANCE_MEDIUM = 1.2
IMPORTANCE_HIGH = 1.4
class MizCampaignLoader:
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
RED_COUNTRY = CombinedJointTaskForcesRed()
OFF_MAP_UNIT_TYPE = F_15C.id
CV_UNIT_TYPE = Stennis.id
LHA_UNIT_TYPE = LHA_Tarawa.id
FRONT_LINE_UNIT_TYPE = Armor.M_113.id
SHIPPING_LANE_UNIT_TYPE = HandyWind.id
FOB_UNIT_TYPE = Unarmed.SKP_11.id
FARP_HELIPAD = "Invisible FARP"
OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id
SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id
MISSILE_SITE_UNIT_TYPE = MissilesSS.Scud_B.id
COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.Hy_launcher.id
# Multiple options for air defenses so campaign designers can more accurately see
# the coverage of their IADS for the expected type.
LONG_RANGE_SAM_UNIT_TYPES = {
AirDefence.Patriot_ln.id,
AirDefence.S_300PS_5P85C_ln.id,
AirDefence.S_300PS_5P85D_ln.id,
}
MEDIUM_RANGE_SAM_UNIT_TYPES = {
AirDefence.Hawk_ln.id,
AirDefence.S_75M_Volhov.id,
AirDefence._5p73_s_125_ln.id,
}
SHORT_RANGE_SAM_UNIT_TYPES = {
AirDefence.M1097_Avenger.id,
AirDefence.Rapier_fsa_launcher.id,
AirDefence._2S6_Tunguska.id,
AirDefence.Strela_1_9P31.id,
}
AAA_UNIT_TYPES = {
AirDefence.Flak18.id,
AirDefence.Vulcan.id,
AirDefence.ZSU_23_4_Shilka.id,
}
EWR_UNIT_TYPE = AirDefence._1L13_EWR.id
ARMOR_GROUP_UNIT_TYPE = Armor.M_1_Abrams.id
FACTORY_UNIT_TYPE = Fortification.Workshop_A.id
AMMUNITION_DEPOT_UNIT_TYPE = Warehouse._Ammunition_depot.id
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
self.theater = theater
self.mission = Mission()
with logged_duration("Loading miz"):
self.mission.load_file(str(miz))
self.control_point_id = itertools.count(1000)
# If there are no red carriers there usually aren't red units. Make sure
# both countries are initialized so we don't have to deal with None.
if self.mission.country(self.BLUE_COUNTRY.name) is None:
self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY)
if self.mission.country(self.RED_COUNTRY.name) is None:
self.mission.coalition["red"].add_country(self.RED_COUNTRY)
@staticmethod
def control_point_from_airport(airport: Airport) -> ControlPoint:
# The wiki says this is a legacy property and to just use regular.
size = SIZE_REGULAR
# The importance is taken from the periodicity of the airport's
# warehouse divided by 10. 30 is the default, and out of range (valid
# values are between 1.0 and 1.4). If it is used, pick the default
# importance.
if airport.periodicity == 30:
importance = IMPORTANCE_MEDIUM
else:
importance = airport.periodicity / 10
cp = Airfield(airport, size, importance)
cp.captured = airport.is_blue()
# Use the unlimited aircraft option to determine if an airfield should
# be owned by the player when the campaign is "inverted".
cp.captured_invert = airport.unlimited_aircrafts
return cp
def country(self, blue: bool) -> Country:
country = self.mission.country(
self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name
)
# Should be guaranteed because we initialized them.
assert country
return country
@property
def blue(self) -> Country:
return self.country(blue=True)
@property
def red(self) -> Country:
return self.country(blue=False)
def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]:
for group in self.country(blue).plane_group:
if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
yield group
def carriers(self, blue: bool) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.CV_UNIT_TYPE:
yield group
def lhas(self, blue: bool) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.LHA_UNIT_TYPE:
yield group
def fobs(self, blue: bool) -> Iterator[VehicleGroup]:
for group in self.country(blue).vehicle_group:
if group.units[0].type == self.FOB_UNIT_TYPE:
yield group
@property
def ships(self) -> Iterator[ShipGroup]:
for group in self.red.ship_group:
if group.units[0].type == self.SHIP_UNIT_TYPE:
yield group
@property
def offshore_strike_targets(self) -> Iterator[StaticGroup]:
for group in self.red.static_group:
if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE:
yield group
@property
def missile_sites(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE:
yield group
@property
def coastal_defenses(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
yield group
@property
def long_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES:
yield group
@property
def medium_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES:
yield group
@property
def short_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES:
yield group
@property
def aaa(self) -> Iterator[VehicleGroup]:
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
if group.units[0].type in self.AAA_UNIT_TYPES:
yield group
@property
def ewrs(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.EWR_UNIT_TYPE:
yield group
@property
def armor_groups(self) -> Iterator[VehicleGroup]:
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE:
yield group
@property
def helipads(self) -> Iterator[StaticGroup]:
for group in self.blue.static_group:
if group.units[0].type == self.FARP_HELIPAD:
yield group
@property
def factories(self) -> Iterator[StaticGroup]:
for group in self.blue.static_group:
if group.units[0].type in self.FACTORY_UNIT_TYPE:
yield group
@property
def ammunition_depots(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE:
yield group
@property
def strike_targets(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE:
yield group
@property
def scenery(self) -> List[SceneryGroup]:
return SceneryGroup.from_trigger_zones(self.mission.triggers._zones)
@cached_property
def control_points(self) -> Dict[int, ControlPoint]:
control_points = {}
for airport in self.mission.terrain.airport_list():
if airport.is_blue() or airport.is_red():
control_point = self.control_point_from_airport(airport)
control_points[control_point.id] = control_point
for blue in (False, True):
for group in self.off_map_spawns(blue):
control_point = OffMapSpawn(
next(self.control_point_id), str(group.name), group.position
)
control_point.captured = blue
control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point
for ship in self.carriers(blue):
# TODO: Name the carrier.
control_point = Carrier(
"carrier", ship.position, next(self.control_point_id)
)
control_point.captured = blue
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for ship in self.lhas(blue):
# TODO: Name the LHA.db
control_point = Lha("lha", ship.position, next(self.control_point_id))
control_point.captured = blue
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for fob in self.fobs(blue):
control_point = Fob(
str(fob.name), fob.position, next(self.control_point_id)
)
control_point.captured = blue
control_point.captured_invert = fob.late_activation
control_points[control_point.id] = control_point
return control_points
@property
def front_line_path_groups(self) -> Iterator[VehicleGroup]:
for group in self.country(blue=True).vehicle_group:
if group.units[0].type == self.FRONT_LINE_UNIT_TYPE:
yield group
@property
def shipping_lane_groups(self) -> Iterator[ShipGroup]:
for group in self.country(blue=True).ship_group:
if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE:
yield group
def add_supply_routes(self) -> None:
for group in self.front_line_path_groups:
# The unit will have its first waypoint at the source CP and the final
# waypoint at the destination CP. Each waypoint defines the path of the
# cargo ship.
waypoints = [p.position for p in group.points]
origin = self.theater.closest_control_point(waypoints[0])
if origin is None:
raise RuntimeError(
f"No control point near the first waypoint of {group.name}"
)
destination = self.theater.closest_control_point(waypoints[-1])
if destination is None:
raise RuntimeError(
f"No control point near the final waypoint of {group.name}"
)
self.control_points[origin.id].create_convoy_route(destination, waypoints)
self.control_points[destination.id].create_convoy_route(
origin, list(reversed(waypoints))
)
def add_shipping_lanes(self) -> None:
for group in self.shipping_lane_groups:
# The unit will have its first waypoint at the source CP and the final
# waypoint at the destination CP. Each waypoint defines the path of the
# cargo ship.
waypoints = [p.position for p in group.points]
origin = self.theater.closest_control_point(waypoints[0])
if origin is None:
raise RuntimeError(
f"No control point near the first waypoint of {group.name}"
)
destination = self.theater.closest_control_point(waypoints[-1])
if destination is None:
raise RuntimeError(
f"No control point near the final waypoint of {group.name}"
)
self.control_points[origin.id].create_shipping_lane(destination, waypoints)
self.control_points[destination.id].create_shipping_lane(
origin, list(reversed(waypoints))
)
def objective_info(
self, near: Positioned, allow_naval: bool = False
) -> Tuple[ControlPoint, Distance]:
closest = self.theater.closest_control_point(near.position, allow_naval)
distance = meters(closest.position.distance_to_point(near.position))
return closest, distance
def add_preset_locations(self) -> None:
for static in self.offshore_strike_targets:
closest, distance = self.objective_info(static)
closest.preset_locations.offshore_strike_locations.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for ship in self.ships:
closest, distance = self.objective_info(ship, allow_naval=True)
closest.preset_locations.ships.append(
PointWithHeading.from_point(
ship.position, Heading.from_degrees(ship.units[0].heading)
)
)
for group in self.missile_sites:
closest, distance = self.objective_info(group)
closest.preset_locations.missile_sites.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.coastal_defenses:
closest, distance = self.objective_info(group)
closest.preset_locations.coastal_defenses.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.long_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.long_range_sams.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.medium_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.medium_range_sams.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.short_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.short_range_sams.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.aaa:
closest, distance = self.objective_info(group)
closest.preset_locations.aaa.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.ewrs:
closest, distance = self.objective_info(group)
closest.preset_locations.ewrs.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.armor_groups:
closest, distance = self.objective_info(group)
closest.preset_locations.armor_groups.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for static in self.helipads:
closest, distance = self.objective_info(static)
closest.helipads.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for static in self.factories:
closest, distance = self.objective_info(static)
closest.preset_locations.factories.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for static in self.ammunition_depots:
closest, distance = self.objective_info(static)
closest.preset_locations.ammunition_depots.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for static in self.strike_targets:
closest, distance = self.objective_info(static)
closest.preset_locations.strike_locations.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for scenery_group in self.scenery:
closest, distance = self.objective_info(scenery_group)
closest.preset_locations.scenery.append(scenery_group)
def populate_theater(self) -> None:
for control_point in self.control_points.values():
self.theater.add_controlpoint(control_point)
self.add_preset_locations()
self.add_supply_routes()
self.add_shipping_lanes()
@dataclass
class ReferencePoint:
world_coordinates: Point
@@ -730,29 +240,11 @@ class ConflictTheater:
return i
raise KeyError(f"Cannot find ControlPoint with ID {id}")
@staticmethod
def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater:
theaters = {
"Caucasus": CaucasusTheater,
"Nevada": NevadaTheater,
"Persian Gulf": PersianGulfTheater,
"Normandy": NormandyTheater,
"The Channel": TheChannelTheater,
"Syria": SyriaTheater,
"MarianaIslands": MarianaIslandsTheater,
}
theater = theaters[data["theater"]]
t = theater()
miz = data.get("miz", None)
if miz is None:
raise RuntimeError(
"Old format (non-miz) campaigns are no longer supported."
)
with logged_duration("Importing miz data"):
MizCampaignLoader(directory / miz, t).populate_theater()
return t
def control_point_named(self, name: str) -> ControlPoint:
for cp in self.controlpoints:
if cp.name == name:
return cp
raise KeyError(f"Cannot find ControlPoint named {name}")
@property
def seasonal_conditions(self) -> SeasonalConditions:
@@ -779,7 +271,7 @@ class CaucasusTheater(ConflictTheater):
ReferencePoint(caucasus.Batumi.position, Point(1307, 1205)),
)
landmap = load_landmap("resources\\caulandmap.p")
landmap = load_landmap(Path("resources/caulandmap.p"))
daytime_map = {
"dawn": (6, 9),
"day": (9, 18),
@@ -807,7 +299,7 @@ class PersianGulfTheater(ConflictTheater):
ReferencePoint(persiangulf.Jiroft.position, Point(1692, 1343)),
ReferencePoint(persiangulf.Liwa_AFB.position, Point(358, 3238)),
)
landmap = load_landmap("resources\\gulflandmap.p")
landmap = load_landmap(Path("resources/gulflandmap.p"))
daytime_map = {
"dawn": (6, 8),
"day": (8, 16),
@@ -835,7 +327,7 @@ class NevadaTheater(ConflictTheater):
ReferencePoint(nevada.Mina_Airport_3Q0.position, Point(252, 295)),
ReferencePoint(nevada.Laughlin_Airport.position, Point(844, 909)),
)
landmap = load_landmap("resources\\nevlandmap.p")
landmap = load_landmap(Path("resources/nevlandmap.p"))
daytime_map = {
"dawn": (4, 6),
"day": (6, 17),
@@ -863,7 +355,7 @@ class NormandyTheater(ConflictTheater):
ReferencePoint(normandy.Needs_Oar_Point.position, Point(515, 329)),
ReferencePoint(normandy.Evreux.position, Point(2029, 1709)),
)
landmap = load_landmap("resources\\normandylandmap.p")
landmap = load_landmap(Path("resources/normandylandmap.p"))
daytime_map = {
"dawn": (6, 8),
"day": (10, 17),
@@ -891,7 +383,7 @@ class TheChannelTheater(ConflictTheater):
ReferencePoint(thechannel.Abbeville_Drucat.position, Point(2005, 2390)),
ReferencePoint(thechannel.Detling.position, Point(706, 382)),
)
landmap = load_landmap("resources\\channellandmap.p")
landmap = load_landmap(Path("resources/channellandmap.p"))
daytime_map = {
"dawn": (6, 8),
"day": (10, 17),
@@ -919,7 +411,7 @@ class SyriaTheater(ConflictTheater):
ReferencePoint(syria.Eyn_Shemer.position, Point(564, 1289)),
ReferencePoint(syria.Tabqa.position, Point(1329, 491)),
)
landmap = load_landmap("resources\\syrialandmap.p")
landmap = load_landmap(Path("resources/syrialandmap.p"))
daytime_map = {
"dawn": (6, 8),
"day": (8, 16),
@@ -944,7 +436,7 @@ class MarianaIslandsTheater(ConflictTheater):
terrain = marianaislands.MarianaIslands()
overview_image = "marianaislands.gif"
landmap = load_landmap("resources\\marianaislandslandmap.p")
landmap = load_landmap(Path("resources/marianaislandslandmap.p"))
daytime_map = {
"dawn": (6, 8),
"day": (8, 16),

View File

@@ -55,6 +55,7 @@ from ..weather import Conditions
if TYPE_CHECKING:
from game import Game
from gen.flights.flight import FlightType
from game.squadrons.squadron import Squadron
from ..transfers import PendingTransfers
FREE_FRONTLINE_UNIT_SUPPLY: int = 15
@@ -294,8 +295,6 @@ class ControlPoint(MissionTarget, ABC):
name: str,
position: Point,
at: db.StartingPosition,
size: int,
importance: float,
has_frontline: bool = True,
cptype: ControlPointType = ControlPointType.AIRBASE,
) -> None:
@@ -308,9 +307,6 @@ class ControlPoint(MissionTarget, ABC):
self.preset_locations = PresetLocations()
self.helipads: List[PointWithHeading] = []
# TODO: Should be Airbase specific.
self.size = size
self.importance = importance
self.captured = False
self.captured_invert = False
# TODO: Should be Airbase specific.
@@ -322,12 +318,14 @@ class ControlPoint(MissionTarget, ABC):
self.cptype = cptype
# TODO: Should be Airbase specific.
self.stances: Dict[int, CombatStance] = {}
from ..unitdelivery import PendingUnitDeliveries
from ..groundunitorders import GroundUnitOrders
self.pending_unit_deliveries = PendingUnitDeliveries(self)
self.ground_unit_orders = GroundUnitOrders(self)
self.target_position: Optional[Point] = None
self.squadrons: list[Squadron] = []
def __repr__(self) -> str:
return f"<{self.__class__}: {self.name}>"
@@ -588,25 +586,14 @@ class ControlPoint(MissionTarget, ABC):
return airbase
return None
def _retreat_air_units(
self, game: Game, airframe: AircraftType, count: int
) -> None:
while count:
logging.debug(f"Retreating {count} {airframe} from {self.name}")
destination = self.aircraft_retreat_destination(game, airframe)
if destination is None:
self.capture_aircraft(game, airframe, count)
return
parking = destination.unclaimed_parking(game)
transfer_amount = min([parking, count])
destination.base.commission_units({airframe: transfer_amount})
count -= transfer_amount
@staticmethod
def _retreat_squadron(squadron: Squadron) -> None:
logging.error("Air unit retreat not currently implemented")
def retreat_air_units(self, game: Game) -> None:
# TODO: Capture in order of price to retain maximum value?
while self.base.aircraft:
airframe, count = self.base.aircraft.popitem()
self._retreat_air_units(game, airframe, count)
for squadron in self.squadrons:
self._retreat_squadron(squadron)
def depopulate_uncapturable_tgos(self) -> None:
for tgo in self.connected_objectives:
@@ -615,7 +602,10 @@ class ControlPoint(MissionTarget, ABC):
# TODO: Should be Airbase specific.
def capture(self, game: Game, for_player: bool) -> None:
self.pending_unit_deliveries.refund_all(game.coalition_for(for_player))
coalition = game.coalition_for(for_player)
self.ground_unit_orders.refund_all(coalition)
for squadron in self.squadrons:
squadron.refund_orders()
self.retreat_ground_units(game)
self.retreat_air_units(game)
self.depopulate_uncapturable_tgos()
@@ -631,19 +621,6 @@ class ControlPoint(MissionTarget, ABC):
def can_operate(self, aircraft: AircraftType) -> bool:
...
def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]:
ato = game.coalition_for(self.captured).ato
transferring: defaultdict[AircraftType, int] = defaultdict(int)
for package in ato.packages:
for flight in package.flights:
if flight.departure == flight.arrival:
continue
if flight.departure == self:
transferring[flight.unit_type] -= flight.count
elif flight.arrival == self:
transferring[flight.unit_type] += flight.count
return transferring
def unclaimed_parking(self, game: Game) -> int:
return self.total_aircraft_parking - self.allocated_aircraft(game).total
@@ -673,7 +650,9 @@ class ControlPoint(MissionTarget, ABC):
self.runway_status.begin_repair()
def process_turn(self, game: Game) -> None:
self.pending_unit_deliveries.process(game)
self.ground_unit_orders.process(game)
for squadron in self.squadrons:
squadron.deliver_orders()
runway_status = self.runway_status
if runway_status is not None:
@@ -695,21 +674,22 @@ class ControlPoint(MissionTarget, ABC):
u.position.x = u.position.x + delta.x
u.position.y = u.position.y + delta.y
def allocated_aircraft(self, game: Game) -> AircraftAllocations:
on_order = {}
for unit_bought, count in self.pending_unit_deliveries.units.items():
if isinstance(unit_bought, AircraftType):
on_order[unit_bought] = count
def allocated_aircraft(self, _game: Game) -> AircraftAllocations:
present: dict[AircraftType, int] = defaultdict(int)
on_order: dict[AircraftType, int] = defaultdict(int)
for squadron in self.squadrons:
present[squadron.aircraft] += squadron.owned_aircraft
# TODO: Only if this is the squadron destination, not location.
on_order[squadron.aircraft] += squadron.pending_deliveries
return AircraftAllocations(
self.base.aircraft, on_order, self.aircraft_transferring(game)
)
# TODO: Implement squadron transfers.
return AircraftAllocations(present, on_order, transferring={})
def allocated_ground_units(
self, transfers: PendingTransfers
) -> GroundUnitAllocations:
on_order = {}
for unit_bought, count in self.pending_unit_deliveries.units.items():
for unit_bought, count in self.ground_unit_orders.units.items():
if isinstance(unit_bought, GroundUnitType):
on_order[unit_bought] = count
@@ -815,16 +795,12 @@ class ControlPoint(MissionTarget, ABC):
class Airfield(ControlPoint):
def __init__(
self, airport: Airport, size: int, importance: float, has_frontline: bool = True
) -> None:
def __init__(self, airport: Airport, has_frontline: bool = True) -> None:
super().__init__(
airport.id,
airport.name,
airport.position,
airport,
size,
importance,
has_frontline,
cptype=ControlPointType.AIRBASE,
)
@@ -990,15 +966,11 @@ class NavalControlPoint(ControlPoint, ABC):
class Carrier(NavalControlPoint):
def __init__(self, name: str, at: Point, cp_id: int):
import game.theater.conflicttheater
super().__init__(
cp_id,
name,
at,
at,
game.theater.conflicttheater.SIZE_SMALL,
1,
has_frontline=False,
cptype=ControlPointType.AIRCRAFT_CARRIER_GROUP,
)
@@ -1034,15 +1006,11 @@ class Carrier(NavalControlPoint):
class Lha(NavalControlPoint):
def __init__(self, name: str, at: Point, cp_id: int):
import game.theater.conflicttheater
super().__init__(
cp_id,
name,
at,
at,
game.theater.conflicttheater.SIZE_SMALL,
1,
has_frontline=False,
cptype=ControlPointType.LHA_GROUP,
)
@@ -1071,15 +1039,11 @@ class OffMapSpawn(ControlPoint):
return True
def __init__(self, cp_id: int, name: str, position: Point):
from . import IMPORTANCE_MEDIUM, SIZE_REGULAR
super().__init__(
cp_id,
name,
position,
at=position,
size=SIZE_REGULAR,
importance=IMPORTANCE_MEDIUM,
has_frontline=False,
cptype=ControlPointType.OFF_MAP,
)
@@ -1128,15 +1092,11 @@ class OffMapSpawn(ControlPoint):
class Fob(ControlPoint):
def __init__(self, name: str, at: Point, cp_id: int):
import game.theater.conflicttheater
super().__init__(
cp_id,
name,
at,
at,
game.theater.conflicttheater.SIZE_SMALL,
1,
has_frontline=True,
cptype=ControlPointType.FOB,
)

View File

@@ -3,6 +3,7 @@ import pickle
from functools import cached_property
from typing import Optional, Tuple, Union
import logging
from pathlib import Path
from shapely import geometry
from shapely.geometry import MultiPolygon, Polygon
@@ -27,7 +28,7 @@ class Landmap:
return self.inclusion_zones - self.exclusion_zones - self.sea_zones
def load_landmap(filename: str) -> Optional[Landmap]:
def load_landmap(filename: Path) -> Optional[Landmap]:
try:
with open(filename, "rb") as f:
return pickle.load(f)

View File

@@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions(
Season.Summer: WeatherTypeChances(
thunderstorm=1,
raining=10,
cloudy=30,
clear_skies=60,
cloudy=35,
clear_skies=55,
),
Season.Fall: WeatherTypeChances(
thunderstorm=1,

View File

@@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions(
Season.Summer: WeatherTypeChances(
thunderstorm=1,
raining=5,
cloudy=25,
clear_skies=70,
cloudy=30,
clear_skies=65,
),
Season.Fall: WeatherTypeChances(
thunderstorm=1,

View File

@@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions(
Season.Summer: WeatherTypeChances(
thunderstorm=1,
raining=10,
cloudy=30,
clear_skies=60,
cloudy=35,
clear_skies=55,
),
Season.Fall: WeatherTypeChances(
thunderstorm=1,

View File

@@ -12,26 +12,26 @@ CONDITIONS = SeasonalConditions(
# Winter there is some rain in PG (Dubai)
thunderstorm=1,
raining=15,
cloudy=35,
clear_skies=50,
cloudy=40,
clear_skies=45,
),
Season.Spring: WeatherTypeChances(
thunderstorm=1,
raining=2,
cloudy=18,
clear_skies=80,
cloudy=28,
clear_skies=70,
),
Season.Summer: WeatherTypeChances(
thunderstorm=1,
raining=1,
cloudy=8,
clear_skies=90,
cloudy=18,
clear_skies=80,
),
Season.Fall: WeatherTypeChances(
thunderstorm=1,
raining=2,
cloudy=18,
clear_skies=80,
cloudy=28,
clear_skies=70,
),
},
)

View File

@@ -11,8 +11,8 @@ CONDITIONS = SeasonalConditions(
Season.Winter: WeatherTypeChances(
thunderstorm=1,
raining=25,
cloudy=25,
clear_skies=50,
cloudy=35,
clear_skies=40,
),
Season.Spring: WeatherTypeChances(
thunderstorm=1,
@@ -22,15 +22,15 @@ CONDITIONS = SeasonalConditions(
),
Season.Summer: WeatherTypeChances(
thunderstorm=1,
raining=3,
cloudy=20,
clear_skies=77,
raining=5,
cloudy=30,
clear_skies=65,
),
Season.Fall: WeatherTypeChances(
thunderstorm=1,
raining=10,
cloudy=30,
clear_skies=60,
raining=15,
cloudy=35,
clear_skies=50,
),
},
)

View File

@@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions(
Season.Summer: WeatherTypeChances(
thunderstorm=1,
raining=10,
cloudy=30,
clear_skies=60,
cloudy=35,
clear_skies=55,
),
Season.Fall: WeatherTypeChances(
thunderstorm=1,

View File

@@ -30,7 +30,7 @@ from game.theater.theatergroundobject import (
)
from game.utils import Heading
from game.version import VERSION
from gen import namegen
from gen.naming import namegen
from gen.coastal.coastal_group_generator import generate_coastal_group
from gen.defenses.armor_group_generator import generate_armor_group
from gen.fleet.ship_group_generator import (
@@ -49,22 +49,12 @@ from . import (
Fob,
OffMapSpawn,
)
from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig
from ..profiling import logged_duration
from ..settings import Settings
GroundObjectTemplates = Dict[str, Dict[str, Any]]
UNIT_VARIETY = 6
UNIT_AMOUNT_FACTOR = 16
UNIT_COUNT_IMPORTANCE_LOG = 1.3
COUNT_BY_TASK = {
PinpointStrike: 12,
CAP: 8,
CAS: 4,
AirDefence: 1,
}
@dataclass(frozen=True)
class GeneratorSettings:
@@ -96,6 +86,7 @@ class GameGenerator:
player: Faction,
enemy: Faction,
theater: ConflictTheater,
air_wing_config: CampaignAirWingConfig,
settings: Settings,
generator_settings: GeneratorSettings,
mod_settings: ModSettings,
@@ -103,6 +94,7 @@ class GameGenerator:
self.player = player
self.enemy = enemy
self.theater = theater
self.air_wing_config = air_wing_config
self.settings = settings
self.generator_settings = generator_settings
self.mod_settings = mod_settings
@@ -116,6 +108,7 @@ class GameGenerator:
player_faction=self.player.apply_mod_settings(self.mod_settings),
enemy_faction=self.enemy.apply_mod_settings(self.mod_settings),
theater=self.theater,
air_wing_config=self.air_wing_config,
start_date=self.generator_settings.start_date,
settings=self.settings,
player_budget=self.generator_settings.player_budget,

View File

@@ -51,7 +51,6 @@ from dcs.mapping import Point
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.procurement import AircraftProcurementRequest
from game.squadrons import Squadron
from game.theater import ControlPoint, MissionTarget
from game.theater.transitnetwork import (
TransitConnection,
@@ -67,7 +66,7 @@ from gen.naming import namegen
if TYPE_CHECKING:
from game import Game
from game.inventory import ControlPointAircraftInventory
from game.squadrons import Squadron
class Transport:
@@ -315,29 +314,20 @@ class AirliftPlanner:
if cp.captured != self.for_player:
continue
inventory = self.game.aircraft_inventory.for_control_point(cp)
for unit_type, available in inventory.all_aircraft:
squadrons = air_wing.auto_assignable_for_task_with_type(
unit_type, FlightType.TRANSPORT
)
for squadron in squadrons:
if self.compatible_with_mission(unit_type, cp):
while (
available
and squadron.has_available_pilots
and self.transfer.transport is None
):
flight_size = self.create_airlift_flight(
squadron, inventory
)
available -= flight_size
squadrons = air_wing.auto_assignable_for_task_at(FlightType.TRANSPORT, cp)
for squadron in squadrons:
if self.compatible_with_mission(squadron.aircraft, cp):
while (
squadron.untasked_aircraft
and squadron.has_available_pilots
and self.transfer.transport is None
):
self.create_airlift_flight(squadron)
if self.package.flights:
self.game.ato_for(self.for_player).add_package(self.package)
def create_airlift_flight(
self, squadron: Squadron, inventory: ControlPointAircraftInventory
) -> int:
available_aircraft = inventory.available(squadron.aircraft)
def create_airlift_flight(self, squadron: Squadron) -> int:
available_aircraft = squadron.untasked_aircraft
capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2
required = math.ceil(self.transfer.size / capacity_each)
flight_size = min(
@@ -348,8 +338,8 @@ class AirliftPlanner:
# TODO: Use number_of_available_pilots directly once feature flag is gone.
# The number of currently available pilots is not relevant when pilot limits
# are disabled.
if not squadron.can_provide_pilots(flight_size):
flight_size = squadron.number_of_available_pilots
if not squadron.can_fulfill_flight(flight_size):
flight_size = squadron.max_fulfillable_aircraft
capacity = flight_size * capacity_each
if capacity < self.transfer.size:
@@ -359,16 +349,15 @@ class AirliftPlanner:
else:
transfer = self.transfer
player = inventory.control_point.captured
flight = Flight(
self.package,
self.game.country_for(player),
self.game.country_for(squadron.player),
squadron,
flight_size,
FlightType.TRANSPORT,
self.game.settings.default_start_type,
departure=inventory.control_point,
arrival=inventory.control_point,
departure=squadron.location,
arrival=squadron.location,
divert=None,
cargo=transfer,
)
@@ -381,7 +370,6 @@ class AirliftPlanner:
self.package, self.game.coalition_for(self.for_player), self.game.theater
)
planner.populate_flight_plan(flight)
self.game.aircraft_inventory.claim_for_flight(flight)
return flight_size
@@ -652,8 +640,7 @@ class PendingTransfers:
flight.package.remove_flight(flight)
if not flight.package.flights:
self.game.ato_for(self.player).remove_package(flight.package)
self.game.aircraft_inventory.return_from_flight(flight)
flight.clear_roster()
flight.return_pilots_and_aircraft()
@cancel_transport.register
def _cancel_transport_convoy(
@@ -722,26 +709,59 @@ class PendingTransfers:
):
self.order_airlift_assets_at(control_point)
@staticmethod
def desired_airlift_capacity(control_point: ControlPoint) -> int:
return 4 if control_point.has_factory else 0
def desired_airlift_capacity(self, control_point: ControlPoint) -> int:
def current_airlift_capacity(self, control_point: ControlPoint) -> int:
inventory = self.game.aircraft_inventory.for_control_point(control_point)
squadrons = self.game.air_wing_for(
control_point.captured
).auto_assignable_for_task(FlightType.TRANSPORT)
unit_types = {s.aircraft for s in squadrons}
if control_point.has_factory:
is_major_hub = control_point.total_aircraft_parking > 0
# Check if there is a CP which is only reachable via Airlift
transit_network = self.network_for(control_point)
for cp in self.game.theater.control_points_for(self.player):
# check if the CP has no factory, is reachable from the current
# position and can only be reached with airlift connections
if (
cp.can_deploy_ground_units
and not cp.has_factory
and transit_network.has_link(control_point, cp)
and not any(
link_type
for link, link_type in transit_network.nodes[cp].items()
if not link_type == TransitConnection.Airlift
)
):
return 4
if (
is_major_hub
and cp.has_factory
and cp.total_aircraft_parking > control_point.total_aircraft_parking
):
is_major_hub = False
if is_major_hub:
# If the current CP is a major hub keep always 2 planes on reserve
return 2
return 0
@staticmethod
def current_airlift_capacity(control_point: ControlPoint) -> int:
return sum(
count
for unit_type, count in inventory.all_aircraft
if unit_type in unit_types
s.owned_aircraft
for s in control_point.squadrons
if s.can_auto_assign(FlightType.TRANSPORT)
)
def order_airlift_assets_at(self, control_point: ControlPoint) -> None:
gap = self.desired_airlift_capacity(
control_point
) - self.current_airlift_capacity(control_point)
unclaimed_parking = control_point.unclaimed_parking(self.game)
# Buy a maximum of unclaimed_parking only to prevent that aircraft procurement
# take place at another base
gap = min(
[
self.desired_airlift_capacity(control_point)
- self.current_airlift_capacity(control_point),
unclaimed_parking,
]
)
if gap <= 0:
return
@@ -751,6 +771,10 @@ class PendingTransfers:
# aesthetic.
gap += 1
if gap > unclaimed_parking:
# Prevent to buy more aircraft than possible
return
self.game.coalition_for(self.player).add_procurement_request(
AircraftProcurementRequest(control_point, FlightType.TRANSPORT, gap)
)

View File

@@ -62,6 +62,8 @@ class Distance:
def __mul__(self, other: Union[float, int]) -> Distance:
return meters(self.meters * other)
__rmul__ = __mul__
def __truediv__(self, other: Union[float, int]) -> Distance:
return meters(self.meters / other)
@@ -147,6 +149,8 @@ class Speed:
def __mul__(self, other: Union[float, int]) -> Speed:
return kph(self.kph * other)
__rmul__ = __mul__
def __truediv__(self, other: Union[float, int]) -> Speed:
return kph(self.kph / other)

View File

@@ -12,7 +12,7 @@ def _build_version_string() -> str:
]
build_number_path = Path("resources/buildnumber")
if build_number_path.exists():
with build_number_path.open("r") as build_number_file:
with build_number_path.open("r", encoding="utf-8") as build_number_file:
components.append(build_number_file.readline())
if not Path("resources/final").exists():
@@ -114,4 +114,8 @@ VERSION = _build_version_string()
#: Version 8.1
#: * You can now add "Invisible FARP" static to FOB to add helicopter slots
#:
CAMPAIGN_FORMAT_VERSION = (8, 1)
#: Version 9.0
#: * Campaign files now define the initial squadron layouts. See TODO.
#: * CV and LHA control points now get their names from the group name in the campaign
#: miz.
CAMPAIGN_FORMAT_VERSION = (9, 0)

View File

@@ -10,7 +10,6 @@ from typing import Optional, TYPE_CHECKING, Any
from dcs.cloud_presets import Clouds as PydcsClouds
from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind
from game.savecompat import has_save_compat_for
from game.settings import Settings
from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg
@@ -36,13 +35,6 @@ class AtmosphericConditions:
#: Temperature at sea level in Celcius.
temperature_celsius: float
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
if "qnh" not in state:
state["qnh"] = inches_hg(state["qnh_inches_mercury"])
del state["qnh_inches_mercury"]
self.__dict__.update(state)
@dataclass(frozen=True)
class WindConditions: