mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
Merge branch 'develop' into helipads
This commit is contained in:
2
game/campaignloader/__init__.py
Normal file
2
game/campaignloader/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .campaign import Campaign
|
||||
from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig
|
||||
157
game/campaignloader/campaign.py
Normal file
157
game/campaignloader/campaign.py
Normal 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}")
|
||||
68
game/campaignloader/campaignairwingconfig.py
Normal file
68
game/campaignloader/campaignairwingconfig.py
Normal 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)
|
||||
142
game/campaignloader/defaultsquadronassigner.py
Normal file
142
game/campaignloader/defaultsquadronassigner.py
Normal 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
|
||||
466
game/campaignloader/mizcampaignloader.py
Normal file
466
game/campaignloader/mizcampaignloader.py
Normal 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()
|
||||
82
game/campaignloader/squadrondefgenerator.py
Normal file
82
game/campaignloader/squadrondefgenerator.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
24
game/game.py
24
game/game.py
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
180
game/purchaseadapter.py
Normal 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}"
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
3
game/squadrons/__init__.py
Normal file
3
game/squadrons/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .airwing import AirWing
|
||||
from .pilot import Pilot
|
||||
from .squadron import Squadron
|
||||
89
game/squadrons/airwing.py
Normal file
89
game/squadrons/airwing.py
Normal 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())
|
||||
33
game/squadrons/operatingbases.py
Normal file
33
game/squadrons/operatingbases.py
Normal 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
51
game/squadrons/pilot.py
Normal 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
301
game/squadrons/squadron.py
Normal 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,
|
||||
)
|
||||
99
game/squadrons/squadrondef.py
Normal file
99
game/squadrons/squadrondef.py
Normal 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,
|
||||
)
|
||||
68
game/squadrons/squadrondefloader.py
Normal file
68
game/squadrons/squadrondefloader.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user