Merge branch 'develop' into helipads

This commit is contained in:
C. Perreau 2021-08-16 12:20:43 +02:00 committed by GitHub
commit 707d13a65c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
115 changed files with 2908 additions and 2407 deletions

12
.github/ISSUE_TEMPLATE/mod_support.md vendored Normal file
View File

@ -0,0 +1,12 @@
---
name: Mod support request
about: Request Liberation support for new mods, or updates to existing mods
title: Add/update <mod name>
labels: mod support
assignees: ''
---
* Mod name:
* Mod URL:
* Update or new mod?

View File

@ -4,20 +4,40 @@ Saves from 4.x are not compatible with 5.0.
## Features/Improvements ## Features/Improvements
* **[Campaign]** Weather! Theaters now experience weather that is more realistic for the region and its current season. For example, Persian Gulf will have very hot, sunny summers and Marianas will experience lots of rain during fall. These changes affect pressure, temperature, clouds and precipitation. Additionally, temperature will drop during the night, by an amount that is somewhat realistic for the region.
* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. * **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated.
* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts. * **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts.
* **[Campaign]** FOBs control point can have FARP/helipad slot and host helicopters. To enable this feature on a FOB, add "Invisible FARP" statics objects near the FOB location in the campaign definition file. * **[Campaign]** FOBs control point can have FARP/helipad slot and host helicopters. To enable this feature on a FOB, add "Invisible FARP" statics objects near the FOB location in the campaign definition file.
* **[Campaign]** (WIP) Squadrons now have a home base and will not operate out of other bases. See https://github.com/dcs-liberation/dcs_liberation/issues/1145 for status.
* **[Campaign]** Aircraft now belong to squadrons rather than bases to support squadron location transfers.
* **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions.
* **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI.
* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points.
* **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken. * **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken.
* **[Campaign AI]** Transport aircraft will now be bought only if necessary at control points which can produce ground units and are capable to operate transport aircraft.
* **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron. * **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron.
* **[Mission Generation]** EWRs are now also headed towards the center of the conflict
* **[Modding]** Campaigns now specify the squadrons that are present in the campaign, their roles, and their starting bases. Players can customize this at game start but the campaign will choose the defaults.
* **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet). * **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet).
* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons. * **[Kneeboard]** QNH (pressure MSL) and temperature have been added to the kneeboard.
* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable, relocate, or rename squadrons.
* **[UI]** Sell Button for aircraft will be disabled if there are no units available to be sold or all are already assigned to a mission
## Fixes ## Fixes
* **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning. * **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning.
* **[Mission Generation]** Mission results and other files will now be opened with enforced utf-8 encoding to prevent an issue where destroyed ground units were untracked because of special characters in their names.
* **[UI]** Selling of Units is now visible again in the UI dialog and shows the correct amount of sold units
# 4.1.1
Saves from 4.1.0 are compatible with 4.1.1.
## Fixes
* **[Campaign]** Fixed broken support for Mariana Islands map.
* **[Mission Generation]** Fix SAM sites pointing towards the center of the conflict.
* **[Flight Planning]** No longer using Su-34 for CAP missions.
# 4.1.0 # 4.1.0
@ -30,6 +50,7 @@ Saves from 4.0.0 are compatible with 4.1.0.
* **[Campaign AI]** Adjustments to aircraft selection priorities for most mission types. * **[Campaign AI]** Adjustments to aircraft selection priorities for most mission types.
* **[Engine]** Support for DCS 2.7.4.9632 and newer, including the Marianas map, F-16 JSOWs, NASAMS, and Tin Shield EWR. * **[Engine]** Support for DCS 2.7.4.9632 and newer, including the Marianas map, F-16 JSOWs, NASAMS, and Tin Shield EWR.
* **[Flight Planning]** CAP patrol altitudes are now set per-aircraft. By default the altitude will be set based on the aircraft's maximum speed. * **[Flight Planning]** CAP patrol altitudes are now set per-aircraft. By default the altitude will be set based on the aircraft's maximum speed.
* **[Flight Planning]** CAP patrol speeds are now set per-aircraft to be more suitable/sensible. By default the speed will be set based on the aircraft's maximum speed.
* **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR * **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR
* **[Mission Generation]** SAM sites are now headed towards the center of the conflict * **[Mission Generation]** SAM sites are now headed towards the center of the conflict
* **[Mods]** Support for latest version of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts. * **[Mods]** Support for latest version of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts.
@ -49,6 +70,7 @@ Saves from 4.0.0 are compatible with 4.1.0.
* **[Data]** Fixed Introduction dates for targeting pods (ATFLIR and LITENING were both a few years too early). * **[Data]** Fixed Introduction dates for targeting pods (ATFLIR and LITENING were both a few years too early).
* **[Data]** Removed SA-10 from Syria 2011 faction. * **[Data]** Removed SA-10 from Syria 2011 faction.
* **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money * **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money
* **[Flight Planning]** Helicopters are now correctly identified, and will fly ingress/CAS/BAI/egress and similar at low altitude.
* **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles. * **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles.
* **[Mission Generation]** The lua data for other plugins is now generated correctly * **[Mission Generation]** The lua data for other plugins is now generated correctly
* **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs * **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs
@ -56,6 +78,7 @@ Saves from 4.0.0 are compatible with 4.1.0.
* **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured. * **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured.
* **[Mission Generation]** Planned transfers which will be impossible after a base capture will no longer prevent the mission result submit. * **[Mission Generation]** Planned transfers which will be impossible after a base capture will no longer prevent the mission result submit.
* **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed. * **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed.
* **[Mission Generation]** Fix for AAA Flak generator using Opel Blitz preventing the mission from being generated because duplicate unit names were used.
* **[Mission Generation]** Fixed a potential bug with laser code generation where it would generate invalid codes. * **[Mission Generation]** Fixed a potential bug with laser code generation where it would generate invalid codes.
* **[UI]** Statistics window tick marks are now always integers. * **[UI]** Statistics window tick marks are now always integers.
* **[UI]** Statistics window now shows the correct info for the turn * **[UI]** Statistics window now shows the correct info for the turn

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from typing import Any
from game.data.groundunitclass import GroundUnitClass from game.data.groundunitclass import GroundUnitClass
from game.savecompat import has_save_compat_for
from game.utils import Distance, feet, nautical_miles from game.utils import Distance, feet, nautical_miles
@ -79,32 +77,6 @@ class Doctrine:
ground_unit_procurement_ratios: GroundUnitProcurementRatios 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( MODERN_DOCTRINE = Doctrine(
cap=True, cap=True,

View File

@ -29,12 +29,20 @@ from game.radio.channels import (
ViggenRadioChannelAllocator, ViggenRadioChannelAllocator,
NoOpChannelAllocator, 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: if TYPE_CHECKING:
from gen.aircraft import FlightData from gen.aircraft import FlightData
from gen import AirSupport, RadioFrequency, RadioRegistry from gen.airsupport import AirSupport
from gen.radios import Radio from gen.radios import Radio, RadioFrequency, RadioRegistry
@dataclass(frozen=True) @dataclass(frozen=True)
@ -186,7 +194,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
@property @property
def preferred_patrol_altitude(self) -> Distance: def preferred_patrol_altitude(self) -> Distance:
if self.patrol_altitude: if self.patrol_altitude is not None:
return self.patrol_altitude return self.patrol_altitude
else: else:
# Estimate based on max speed. # Estimate based on max speed.
@ -212,6 +220,40 @@ class AircraftType(UnitType[Type[FlyingType]]):
min(altitude_for_highest_speed, rounded_altitude), 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: def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
from gen.radios import ChannelInUseError, kHz 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") logging.warning(f"No data for {aircraft.id}; it will not be available")
return return
with data_path.open() as data_file: with data_path.open(encoding="utf-8") as data_file:
data = yaml.safe_load(data_file) data = yaml.safe_load(data_file)
try: try:

View File

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

View File

@ -386,7 +386,7 @@ class PollDebriefingFileThread(threading.Thread):
os.path.isfile("state.json") os.path.isfile("state.json")
and os.path.getmtime("state.json") > last_modified 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) json_data = json.load(json_file)
debriefing = Debriefing(json_data, self.game, self.unit_map) debriefing = Debriefing(json_data, self.game, self.unit_map)
self.callback(debriefing) self.callback(debriefing)

View File

@ -7,13 +7,12 @@ from dcs.mapping import Point
from dcs.task import Task from dcs.task import Task
from game import persistency from game import persistency
from game.debriefing import AirLosses, Debriefing from game.debriefing import Debriefing
from game.infos.information import Information from game.infos.information import Information
from game.operation.operation import Operation from game.operation.operation import Operation
from game.theater import ControlPoint from game.theater import ControlPoint
from gen import AirTaskingOrder from gen.ato import AirTaskingOrder
from gen.ground_forces.combat_stance import CombatStance from gen.ground_forces.combat_stance import CombatStance
from ..dcs.groundunittype import GroundUnitType
from ..unitmap import UnitMap from ..unitmap import UnitMap
if TYPE_CHECKING: if TYPE_CHECKING:
@ -67,59 +66,6 @@ class Event:
) )
return unit_map 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: def commit_air_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.air_losses.losses: for loss in debriefing.air_losses.losses:
if loss.pilot is not None and ( if loss.pilot is not None and (
@ -127,18 +73,18 @@ class Event:
or not self.game.settings.invulnerable_player_pilots or not self.game.settings.invulnerable_player_pilots
): ):
loss.pilot.kill() loss.pilot.kill()
squadron = loss.flight.squadron
aircraft = loss.flight.unit_type aircraft = loss.flight.unit_type
cp = loss.flight.departure available = squadron.owned_aircraft
available = cp.base.total_units_of_type(aircraft)
if available <= 0: if available <= 0:
logging.error( logging.error(
f"Found killed {aircraft} from {cp} but that airbase has " f"Found killed {aircraft} from {squadron} but that airbase has "
"none available." "none available."
) )
continue continue
logging.info(f"{aircraft} destroyed from {cp}") logging.info(f"{aircraft} destroyed from {squadron}")
cp.base.aircraft[aircraft] -= 1 squadron.owned_aircraft -= 1
@staticmethod @staticmethod
def _commit_pilot_experience(ato: AirTaskingOrder) -> None: def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
@ -276,7 +222,6 @@ class Event:
self.commit_building_losses(debriefing) self.commit_building_losses(debriefing)
self.commit_damaged_runways(debriefing) self.commit_damaged_runways(debriefing)
self.commit_captures(debriefing) self.commit_captures(debriefing)
self.complete_aircraft_transfers(debriefing)
# Destroyed units carcass # Destroyed units carcass
# ------------------------- # -------------------------
@ -458,15 +403,10 @@ class Event:
source.base.commit_losses(moved_units) source.base.commit_losses(moved_units)
# Also transfer pending deliveries. # Also transfer pending deliveries.
for unit_type, count in source.pending_unit_deliveries.units.items(): for unit_type, count in source.ground_unit_orders.units.items():
if not isinstance(unit_type, GroundUnitType):
continue
if count <= 0:
# Don't transfer *sales*...
continue
move_count = int(count * move_factor) move_count = int(count * move_factor)
source.pending_unit_deliveries.sell({unit_type: move_count}) source.ground_unit_orders.sell({unit_type: move_count})
destination.pending_unit_deliveries.order({unit_type: move_count}) destination.ground_unit_orders.order({unit_type: move_count})
total_units_redeployed += move_count total_units_redeployed += move_count
if total_units_redeployed > 0: if total_units_redeployed > 0:

View File

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

View File

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

View File

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

View File

@ -56,10 +56,12 @@ class GameStats:
for cp in game.theater.controlpoints: for cp in game.theater.controlpoints:
if cp.captured: 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()) turn_data.allied_units.vehicles_count += sum(cp.base.armor.values())
else: 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()) turn_data.enemy_units.vehicles_count += sum(cp.base.armor.values())
self.data_per_turn.append(turn_data) self.data_per_turn.append(turn_data)

View File

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

View File

@ -2,14 +2,14 @@ from __future__ import annotations
import math import math
import random import random
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
from game import db from game import db
from game.data.groundunitclass import GroundUnitClass from game.data.groundunitclass import GroundUnitClass
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction from game.factions.faction import Faction
from game.squadrons import Squadron
from game.theater import ControlPoint, MissionTarget from game.theater import ControlPoint, MissionTarget
from game.utils import meters from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.ai_flight_planner_db import aircraft_for_task
@ -97,37 +97,10 @@ class ProcurementAi:
budget -= armor_budget budget -= armor_budget
budget += self.reinforce_front_line(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: if self.manage_aircraft:
budget = self.purchase_aircraft(budget) budget = self.purchase_aircraft(budget)
return 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: def repair_runways(self, budget: float) -> float:
for control_point in self.owned_points: for control_point in self.owned_points:
if budget < db.RUNWAY_REPAIR_COST: if budget < db.RUNWAY_REPAIR_COST:
@ -180,7 +153,7 @@ class ProcurementAi:
break break
budget -= unit.price budget -= unit.price
cp.pending_unit_deliveries.order({unit: 1}) cp.ground_unit_orders.order({unit: 1})
return budget return budget
@ -209,64 +182,29 @@ class ProcurementAi:
return GroundUnitClass.Tank return GroundUnitClass.Tank
return worst_balanced return worst_balanced
def affordable_aircraft_for( @staticmethod
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
def fulfill_aircraft_request( def fulfill_aircraft_request(
self, request: AircraftProcurementRequest, budget: float squadrons: list[Squadron], quantity: int, budget: float
) -> Tuple[float, bool]: ) -> Tuple[float, bool]:
for airbase in self.best_airbases_for(request): for squadron in squadrons:
unit = self.affordable_aircraft_for(request, airbase, budget) price = squadron.aircraft.price * quantity
if unit is None: if price > budget:
# 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.
continue continue
budget -= unit.price * request.number squadron.pending_deliveries += quantity
airbase.pending_unit_deliveries.order({unit: request.number}) budget -= price
return budget, True return budget, True
return budget, False return budget, False
def purchase_aircraft(self, budget: float) -> float: def purchase_aircraft(self, budget: float) -> float:
for request in self.game.coalition_for(self.is_player).procurement_requests: 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. # No airbases in range of this request. Skip it.
continue continue
budget, fulfilled = self.fulfill_aircraft_request(request, budget) budget, fulfilled = self.fulfill_aircraft_request(
squadrons, request.number, budget
)
if not fulfilled: if not fulfilled:
# The request was not fulfilled because we could not afford any suitable # The request was not fulfilled because we could not afford any suitable
# aircraft. Rather than continuing, which could proceed to buy tons of # aircraft. Rather than continuing, which could proceed to buy tons of
@ -283,9 +221,32 @@ class ProcurementAi:
else: else:
return self.game.theater.enemy_points() 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 self, request: AircraftProcurementRequest
) -> Iterator[ControlPoint]: ) -> Iterator[Squadron]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near) distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
threatened = [] threatened = []
for cp in distance_cache.operational_airfields: for cp in distance_cache.operational_airfields:
@ -295,8 +256,10 @@ class ProcurementAi:
continue continue
if self.threat_zones.threatened(cp.position): if self.threat_zones.threatened(cp.position):
threatened.append(cp) threatened.append(cp)
yield cp continue
yield from threatened 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]: def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
worst_supply = math.inf worst_supply = math.inf

180
game/purchaseadapter.py Normal file
View File

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

View File

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

View File

@ -16,78 +16,81 @@ class AutoAtoBehavior(Enum):
@dataclass @dataclass
class Settings: class Settings:
version: Optional[str] = None
# Difficulty settings # Difficulty settings
# AI Difficulty
player_skill: str = "Good" player_skill: str = "Good"
enemy_skill: str = "Average" enemy_skill: str = "Average"
ai_pilot_levelling: bool = True
enemy_vehicle_skill: str = "Average" 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 player_income_multiplier: float = 1.0
enemy_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. #: Feature flag for squadron limits.
enable_squadron_pilot_limits: bool = False enable_squadron_pilot_limits: bool = False
#: The maximum number of pilots a squadron can have at one time. Changing this after #: 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 #: 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 #: squadron will not be removed if the limit is lowered and pilots will not be
#: immediately created if the limit is raised. #: immediately created if the limit is raised.
squadron_pilot_limit: int = 12 squadron_pilot_limit: int = 12
#: The number of pilots a squadron can replace per turn. #: The number of pilots a squadron can replace per turn.
squadron_replenishment_rate: int = 4 squadron_replenishment_rate: int = 4
# HQ Automation
default_start_type: str = "Cold"
# Mission specific
desired_player_mission_duration: timedelta = timedelta(minutes=60)
# Campaign management
automate_runway_repair: bool = False automate_runway_repair: bool = False
automate_front_line_reinforcements: bool = False automate_front_line_reinforcements: bool = False
automate_aircraft_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_behavior: AutoAtoBehavior = AutoAtoBehavior.Default
auto_ato_player_missions_asap: bool = True auto_ato_player_missions_asap: bool = True
automate_front_line_stance: bool = True
reserves_procurement_target: int = 10
# Performance oriented # Mission Generator
perf_red_alert_state: bool = True # 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_gen: bool = True
perf_smoke_spacing = 1600 perf_smoke_spacing = 1600
perf_red_alert_state: bool = True
perf_artillery: bool = True perf_artillery: bool = True
perf_moving_units: bool = True perf_moving_units: bool = True
perf_infantry: bool = True perf_infantry: bool = True
perf_destroyed_units: bool = True perf_destroyed_units: bool = True
reserves_procurement_target: int = 10
# Performance culling # Performance culling
perf_culling: bool = False perf_culling: bool = False
perf_culling_distance: int = 100 perf_culling_distance: int = 100
perf_do_not_cull_carrier = True perf_do_not_cull_carrier = True
# LUA Plugins system
plugins: Dict[str, bool] = field(default_factory=dict)
# Cheating # Cheating
show_red_ato: bool = False show_red_ato: bool = False
enable_frontline_cheats: bool = False enable_frontline_cheats: bool = False
enable_base_capture_cheat: 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 @staticmethod
def plugin_settings_key(identifier: str) -> str: def plugin_settings_key(identifier: str) -> str:

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,11 @@
from __future__ import annotations from __future__ import annotations
import itertools
import math import math
from dataclasses import dataclass from dataclasses import dataclass
from functools import cached_property
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING 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.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 ( from dcs.terrain import (
caucasus, caucasus,
nevada, nevada,
@ -31,497 +15,23 @@ from dcs.terrain import (
thechannel, thechannel,
marianaislands, marianaislands,
) )
from dcs.terrain.terrain import Airport, Terrain from dcs.terrain.terrain import Terrain
from dcs.unitgroup import (
ShipGroup,
StaticGroup,
VehicleGroup,
PlaneGroup,
)
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
from pyproj import CRS, Transformer from pyproj import CRS, Transformer
from shapely import geometry, ops from shapely import geometry, ops
from .controlpoint import ( from .controlpoint import (
Airfield,
Carrier,
ControlPoint, ControlPoint,
Fob,
Lha,
MissionTarget, MissionTarget,
OffMapSpawn,
) )
from .frontline import FrontLine from .frontline import FrontLine
from .landmap import Landmap, load_landmap, poly_contains from .landmap import Landmap, load_landmap, poly_contains
from .latlon import LatLon from .latlon import LatLon
from .projections import TransverseMercator from .projections import TransverseMercator
from .seasonalconditions import SeasonalConditions 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: if TYPE_CHECKING:
from . import TheaterGroundObject 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 @dataclass
class ReferencePoint: class ReferencePoint:
world_coordinates: Point world_coordinates: Point
@ -730,29 +240,11 @@ class ConflictTheater:
return i return i
raise KeyError(f"Cannot find ControlPoint with ID {id}") raise KeyError(f"Cannot find ControlPoint with ID {id}")
@staticmethod def control_point_named(self, name: str) -> ControlPoint:
def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater: for cp in self.controlpoints:
theaters = { if cp.name == name:
"Caucasus": CaucasusTheater, return cp
"Nevada": NevadaTheater, raise KeyError(f"Cannot find ControlPoint named {name}")
"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
@property @property
def seasonal_conditions(self) -> SeasonalConditions: def seasonal_conditions(self) -> SeasonalConditions:
@ -779,7 +271,7 @@ class CaucasusTheater(ConflictTheater):
ReferencePoint(caucasus.Batumi.position, Point(1307, 1205)), ReferencePoint(caucasus.Batumi.position, Point(1307, 1205)),
) )
landmap = load_landmap("resources\\caulandmap.p") landmap = load_landmap(Path("resources/caulandmap.p"))
daytime_map = { daytime_map = {
"dawn": (6, 9), "dawn": (6, 9),
"day": (9, 18), "day": (9, 18),
@ -807,7 +299,7 @@ class PersianGulfTheater(ConflictTheater):
ReferencePoint(persiangulf.Jiroft.position, Point(1692, 1343)), ReferencePoint(persiangulf.Jiroft.position, Point(1692, 1343)),
ReferencePoint(persiangulf.Liwa_AFB.position, Point(358, 3238)), ReferencePoint(persiangulf.Liwa_AFB.position, Point(358, 3238)),
) )
landmap = load_landmap("resources\\gulflandmap.p") landmap = load_landmap(Path("resources/gulflandmap.p"))
daytime_map = { daytime_map = {
"dawn": (6, 8), "dawn": (6, 8),
"day": (8, 16), "day": (8, 16),
@ -835,7 +327,7 @@ class NevadaTheater(ConflictTheater):
ReferencePoint(nevada.Mina_Airport_3Q0.position, Point(252, 295)), ReferencePoint(nevada.Mina_Airport_3Q0.position, Point(252, 295)),
ReferencePoint(nevada.Laughlin_Airport.position, Point(844, 909)), ReferencePoint(nevada.Laughlin_Airport.position, Point(844, 909)),
) )
landmap = load_landmap("resources\\nevlandmap.p") landmap = load_landmap(Path("resources/nevlandmap.p"))
daytime_map = { daytime_map = {
"dawn": (4, 6), "dawn": (4, 6),
"day": (6, 17), "day": (6, 17),
@ -863,7 +355,7 @@ class NormandyTheater(ConflictTheater):
ReferencePoint(normandy.Needs_Oar_Point.position, Point(515, 329)), ReferencePoint(normandy.Needs_Oar_Point.position, Point(515, 329)),
ReferencePoint(normandy.Evreux.position, Point(2029, 1709)), ReferencePoint(normandy.Evreux.position, Point(2029, 1709)),
) )
landmap = load_landmap("resources\\normandylandmap.p") landmap = load_landmap(Path("resources/normandylandmap.p"))
daytime_map = { daytime_map = {
"dawn": (6, 8), "dawn": (6, 8),
"day": (10, 17), "day": (10, 17),
@ -891,7 +383,7 @@ class TheChannelTheater(ConflictTheater):
ReferencePoint(thechannel.Abbeville_Drucat.position, Point(2005, 2390)), ReferencePoint(thechannel.Abbeville_Drucat.position, Point(2005, 2390)),
ReferencePoint(thechannel.Detling.position, Point(706, 382)), ReferencePoint(thechannel.Detling.position, Point(706, 382)),
) )
landmap = load_landmap("resources\\channellandmap.p") landmap = load_landmap(Path("resources/channellandmap.p"))
daytime_map = { daytime_map = {
"dawn": (6, 8), "dawn": (6, 8),
"day": (10, 17), "day": (10, 17),
@ -919,7 +411,7 @@ class SyriaTheater(ConflictTheater):
ReferencePoint(syria.Eyn_Shemer.position, Point(564, 1289)), ReferencePoint(syria.Eyn_Shemer.position, Point(564, 1289)),
ReferencePoint(syria.Tabqa.position, Point(1329, 491)), ReferencePoint(syria.Tabqa.position, Point(1329, 491)),
) )
landmap = load_landmap("resources\\syrialandmap.p") landmap = load_landmap(Path("resources/syrialandmap.p"))
daytime_map = { daytime_map = {
"dawn": (6, 8), "dawn": (6, 8),
"day": (8, 16), "day": (8, 16),
@ -944,7 +436,7 @@ class MarianaIslandsTheater(ConflictTheater):
terrain = marianaislands.MarianaIslands() terrain = marianaislands.MarianaIslands()
overview_image = "marianaislands.gif" overview_image = "marianaislands.gif"
landmap = load_landmap("resources\\marianaislandslandmap.p") landmap = load_landmap(Path("resources/marianaislandslandmap.p"))
daytime_map = { daytime_map = {
"dawn": (6, 8), "dawn": (6, 8),
"day": (8, 16), "day": (8, 16),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,7 +30,7 @@ from game.theater.theatergroundobject import (
) )
from game.utils import Heading from game.utils import Heading
from game.version import VERSION 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.coastal.coastal_group_generator import generate_coastal_group
from gen.defenses.armor_group_generator import generate_armor_group from gen.defenses.armor_group_generator import generate_armor_group
from gen.fleet.ship_group_generator import ( from gen.fleet.ship_group_generator import (
@ -49,22 +49,12 @@ from . import (
Fob, Fob,
OffMapSpawn, OffMapSpawn,
) )
from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig
from ..profiling import logged_duration from ..profiling import logged_duration
from ..settings import Settings from ..settings import Settings
GroundObjectTemplates = Dict[str, Dict[str, Any]] 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) @dataclass(frozen=True)
class GeneratorSettings: class GeneratorSettings:
@ -96,6 +86,7 @@ class GameGenerator:
player: Faction, player: Faction,
enemy: Faction, enemy: Faction,
theater: ConflictTheater, theater: ConflictTheater,
air_wing_config: CampaignAirWingConfig,
settings: Settings, settings: Settings,
generator_settings: GeneratorSettings, generator_settings: GeneratorSettings,
mod_settings: ModSettings, mod_settings: ModSettings,
@ -103,6 +94,7 @@ class GameGenerator:
self.player = player self.player = player
self.enemy = enemy self.enemy = enemy
self.theater = theater self.theater = theater
self.air_wing_config = air_wing_config
self.settings = settings self.settings = settings
self.generator_settings = generator_settings self.generator_settings = generator_settings
self.mod_settings = mod_settings self.mod_settings = mod_settings
@ -116,6 +108,7 @@ class GameGenerator:
player_faction=self.player.apply_mod_settings(self.mod_settings), player_faction=self.player.apply_mod_settings(self.mod_settings),
enemy_faction=self.enemy.apply_mod_settings(self.mod_settings), enemy_faction=self.enemy.apply_mod_settings(self.mod_settings),
theater=self.theater, theater=self.theater,
air_wing_config=self.air_wing_config,
start_date=self.generator_settings.start_date, start_date=self.generator_settings.start_date,
settings=self.settings, settings=self.settings,
player_budget=self.generator_settings.player_budget, player_budget=self.generator_settings.player_budget,

View File

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

View File

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

View File

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

View File

@ -10,7 +10,6 @@ from typing import Optional, TYPE_CHECKING, Any
from dcs.cloud_presets import Clouds as PydcsClouds from dcs.cloud_presets import Clouds as PydcsClouds
from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind
from game.savecompat import has_save_compat_for
from game.settings import Settings from game.settings import Settings
from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg
@ -36,13 +35,6 @@ class AtmosphericConditions:
#: Temperature at sea level in Celcius. #: Temperature at sea level in Celcius.
temperature_celsius: float 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) @dataclass(frozen=True)
class WindConditions: class WindConditions:

View File

@ -1,13 +0,0 @@
from .aircraft import *
from .armor import *
from .airsupportgen import *
from .conflictgen import *
from .visualgen import *
from .triggergen import *
from .environmentgen import *
from .groundobjectsgen import *
from .briefinggen import *
from .forcedoptionsgen import *
from .kneeboard import *
from . import naming

View File

@ -69,7 +69,6 @@ from game.data.weapons import Pylon, WeaponType as WeaponTypeEnum
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.factions.faction import Faction from game.factions.faction import Faction
from game.settings import Settings from game.settings import Settings
from game.squadrons import Pilot
from game.theater.controlpoint import ( from game.theater.controlpoint import (
Airfield, Airfield,
ControlPoint, ControlPoint,
@ -109,6 +108,7 @@ from .naming import namegen
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
from game.squadrons import Pilot, Squadron
WARM_START_HELI_ALT = meters(500) WARM_START_HELI_ALT = meters(500)
WARM_START_ALTITUDE = meters(3000) WARM_START_ALTITUDE = meters(3000)
@ -644,8 +644,7 @@ class AircraftConflictGenerator:
def spawn_unused_aircraft( def spawn_unused_aircraft(
self, player_country: Country, enemy_country: Country self, player_country: Country, enemy_country: Country
) -> None: ) -> None:
inventories = self.game.aircraft_inventory.inventories for control_point in self.game.theater.controlpoints:
for control_point, inventory in inventories.items():
if not isinstance(control_point, Airfield): if not isinstance(control_point, Airfield):
continue continue
@ -655,11 +654,9 @@ class AircraftConflictGenerator:
else: else:
country = enemy_country country = enemy_country
for aircraft, available in inventory.all_aircraft: for squadron in control_point.squadrons:
try: try:
self._spawn_unused_at( self._spawn_unused_at(control_point, country, faction, squadron)
control_point, country, faction, aircraft, available
)
except NoParkingSlotError: except NoParkingSlotError:
# If we run out of parking, stop spawning aircraft. # If we run out of parking, stop spawning aircraft.
return return
@ -669,17 +666,16 @@ class AircraftConflictGenerator:
control_point: Airfield, control_point: Airfield,
country: Country, country: Country,
faction: Faction, faction: Faction,
aircraft: AircraftType, squadron: Squadron,
number: int,
) -> None: ) -> None:
for _ in range(number): for _ in range(squadron.untasked_aircraft):
# Creating a flight even those this isn't a fragged mission lets us # Creating a flight even those this isn't a fragged mission lets us
# reuse the existing debriefing code. # reuse the existing debriefing code.
# TODO: Special flight type? # TODO: Special flight type?
flight = Flight( flight = Flight(
Package(control_point), Package(control_point),
faction.country, faction.country,
self.game.air_wing_for(control_point.captured).squadron_for(aircraft), squadron,
1, 1,
FlightType.BARCAP, FlightType.BARCAP,
"Cold", "Cold",
@ -691,16 +687,13 @@ class AircraftConflictGenerator:
group = self._generate_at_airport( group = self._generate_at_airport(
name=namegen.next_aircraft_name(country, control_point.id, flight), name=namegen.next_aircraft_name(country, control_point.id, flight),
side=country, side=country,
unit_type=aircraft.dcs_unit_type, unit_type=squadron.aircraft.dcs_unit_type,
count=1, count=1,
start_type="Cold", start_type="Cold",
airport=control_point.airport, airport=control_point.airport,
) )
if aircraft in faction.liveries_overrides: self._setup_livery(flight, group)
livery = random.choice(faction.liveries_overrides[aircraft])
for unit in group.units:
unit.livery_id = livery
group.uncontrolled = True group.uncontrolled = True
self.unit_map.add_aircraft(group, flight) self.unit_map.add_aircraft(group, flight)
@ -1837,17 +1830,11 @@ class RaceTrackBuilder(PydcsWaypointBuilder):
) )
) )
# TODO: Set orbit speeds for all race tracks and remove this special case. orbit = OrbitAction(
if isinstance(flight_plan, RefuelingFlightPlan): altitude=waypoint.alt,
orbit = OrbitAction( pattern=OrbitAction.OrbitPattern.RaceTrack,
altitude=waypoint.alt, speed=int(flight_plan.patrol_speed.kph),
pattern=OrbitAction.OrbitPattern.RaceTrack, )
speed=int(flight_plan.patrol_speed.kph),
)
else:
orbit = OrbitAction(
altitude=waypoint.alt, pattern=OrbitAction.OrbitPattern.RaceTrack
)
racetrack = ControlledTask(orbit) racetrack = ControlledTask(orbit)
self.set_waypoint_tot(waypoint, flight_plan.patrol_start_time) self.set_waypoint_tot(waypoint, flight_plan.patrol_start_time)

View File

@ -5,7 +5,8 @@ from datetime import timedelta
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from gen import RadioFrequency, TacanChannel from gen.radios import RadioFrequency
from gen.tacan import TacanChannel
@dataclass @dataclass

View File

@ -16,8 +16,7 @@ from dcs.task import (
from dcs.unittype import UnitType from dcs.unittype import UnitType
from game.utils import Heading from game.utils import Heading
from . import AirSupport from .airsupport import AirSupport, TankerInfo, AwacsInfo
from .airsupport import TankerInfo, AwacsInfo
from .callsigns import callsign_for_support_unit from .callsigns import callsign_for_support_unit
from .conflictgen import Conflict from .conflictgen import Conflict
from .flights.ai_flight_planner_db import AEWC_CAPABLE from .flights.ai_flight_planner_db import AEWC_CAPABLE

View File

@ -129,7 +129,6 @@ CAP_CAPABLE = [
F_14B, F_14B,
F_14A_135_GR, F_14A_135_GR,
Su_33, Su_33,
Su_34,
J_11A, J_11A,
Su_30, Su_30,
Su_27, Su_27,

View File

@ -2,20 +2,19 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from enum import Enum from enum import Enum
from typing import List, Optional, TYPE_CHECKING, Union, Sequence, Any from typing import List, Optional, TYPE_CHECKING, Union, Sequence
from dcs.mapping import Point from dcs.mapping import Point
from dcs.point import MovingPoint, PointAction from dcs.point import MovingPoint, PointAction
from dcs.unit import Unit from dcs.unit import Unit
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.savecompat import has_save_compat_for
from game.squadrons import Pilot, Squadron
from game.theater.controlpoint import ControlPoint, MissionTarget from game.theater.controlpoint import ControlPoint, MissionTarget
from game.utils import Distance, meters from game.utils import Distance, meters
from gen.flights.loadouts import Loadout from gen.flights.loadouts import Loadout
if TYPE_CHECKING: if TYPE_CHECKING:
from game.squadrons import Pilot, Squadron
from game.transfers import TransferOrder from game.transfers import TransferOrder
from gen.ato import Package from gen.ato import Package
from gen.flights.flightplan import FlightPlan from gen.flights.flightplan import FlightPlan
@ -50,6 +49,8 @@ class FlightType(Enum):
strike-like missions will need more specialized control. strike-like missions will need more specialized control.
* ai_flight_planner.py: Use the new mission type in propose_missions so the AI will * ai_flight_planner.py: Use the new mission type in propose_missions so the AI will
plan the new mission type. plan the new mission type.
* FlightType.is_air_to_air and FlightType.is_air_to_ground: If the new mission type
fits either of these categories, update those methods accordingly.
""" """
TARCAP = "TARCAP" TARCAP = "TARCAP"
@ -80,6 +81,30 @@ class FlightType(Enum):
return entry return entry
raise KeyError(f"No FlightType with name {name}") raise KeyError(f"No FlightType with name {name}")
@property
def is_air_to_air(self) -> bool:
return self in {
FlightType.TARCAP,
FlightType.BARCAP,
FlightType.INTERCEPTION,
FlightType.ESCORT,
FlightType.SWEEP,
}
@property
def is_air_to_ground(self) -> bool:
return self in {
FlightType.CAS,
FlightType.STRIKE,
FlightType.ANTISHIP,
FlightType.SEAD,
FlightType.DEAD,
FlightType.BAI,
FlightType.OCA_RUNWAY,
FlightType.OCA_AIRCRAFT,
FlightType.SEAD_ESCORT,
}
class FlightWaypointType(Enum): class FlightWaypointType(Enum):
"""Enumeration of waypoint types. """Enumeration of waypoint types.
@ -141,8 +166,8 @@ class FlightWaypoint:
waypoint_type: The waypoint type. waypoint_type: The waypoint type.
x: X coordinate of the waypoint. x: X coordinate of the waypoint.
y: Y coordinate of the waypoint. y: Y coordinate of the waypoint.
alt: Altitude of the waypoint. By default this is AGL, but it can be alt: Altitude of the waypoint. By default this is MSL, but it can be
changed to MSL by setting alt_type to "RADIO". changed to AGL by setting alt_type to "RADIO"
""" """
self.waypoint_type = waypoint_type self.waypoint_type = waypoint_type
self.x = x self.x = x
@ -169,12 +194,6 @@ class FlightWaypoint:
self.tot: Optional[timedelta] = None self.tot: Optional[timedelta] = None
self.departure_time: Optional[timedelta] = None self.departure_time: Optional[timedelta] = None
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
if "min_fuel" not in state:
state["min_fuel"] = None
self.__dict__.update(state)
@property @property
def position(self) -> Point: def position(self) -> Point:
return Point(self.x, self.y) return Point(self.x, self.y)
@ -273,6 +292,7 @@ class Flight:
self.package = package self.package = package
self.country = country self.country = country
self.squadron = squadron self.squadron = squadron
self.squadron.claim_inventory(count)
if roster is None: if roster is None:
self.roster = FlightRoster(self.squadron, initial_size=count) self.roster = FlightRoster(self.squadron, initial_size=count)
else: else:
@ -321,6 +341,7 @@ class Flight:
return self.flight_plan.waypoints[1:] return self.flight_plan.waypoints[1:]
def resize(self, new_size: int) -> None: def resize(self, new_size: int) -> None:
self.squadron.claim_inventory(new_size - self.count)
self.roster.resize(new_size) self.roster.resize(new_size)
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
@ -330,8 +351,9 @@ class Flight:
def missing_pilots(self) -> int: def missing_pilots(self) -> int:
return self.roster.missing_pilots return self.roster.missing_pilots
def clear_roster(self) -> None: def return_pilots_and_aircraft(self) -> None:
self.roster.clear() self.roster.clear()
self.squadron.claim_inventory(-self.count)
def __repr__(self) -> str: def __repr__(self) -> str:
if self.custom_name: if self.custom_name:

View File

@ -411,6 +411,9 @@ class PatrollingFlightPlan(FlightPlan):
#: Maximum time to remain on station. #: Maximum time to remain on station.
patrol_duration: timedelta patrol_duration: timedelta
#: Racetrack speed TAS.
patrol_speed: Speed
#: The engagement range of any Search Then Engage task, or the radius of a #: The engagement range of any Search Then Engage task, or the radius of a
#: Search Then Engage in Zone task. Any enemies of the appropriate type for #: Search Then Engage in Zone task. Any enemies of the appropriate type for
#: this mission within this range of the flight's current position (or the #: this mission within this range of the flight's current position (or the
@ -779,9 +782,6 @@ class RefuelingFlightPlan(PatrollingFlightPlan):
divert: Optional[FlightWaypoint] divert: Optional[FlightWaypoint]
bullseye: FlightWaypoint bullseye: FlightWaypoint
#: Racetrack speed.
patrol_speed: Speed
def iter_waypoints(self) -> Iterator[FlightWaypoint]: def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield self.takeoff yield self.takeoff
yield from self.nav_to yield from self.nav_to
@ -1115,7 +1115,7 @@ class FlightPlanBuilder:
if isinstance(location, FrontLine): if isinstance(location, FrontLine):
raise InvalidObjectiveLocation(flight.flight_type, location) raise InvalidObjectiveLocation(flight.flight_type, location)
start_pos, end_pos = self.racetrack_for_objective(location, barcap=True) start_pos, end_pos = self.cap_racetrack_for_objective(location, barcap=True)
preferred_alt = flight.unit_type.preferred_patrol_altitude preferred_alt = flight.unit_type.preferred_patrol_altitude
randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000) randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
@ -1124,6 +1124,11 @@ class FlightPlanBuilder:
min(self.doctrine.max_patrol_altitude, randomized_alt), min(self.doctrine.max_patrol_altitude, randomized_alt),
) )
patrol_speed = flight.unit_type.preferred_patrol_speed(patrol_alt)
logging.debug(
f"BARCAP patrol speed for {flight.unit_type.name} at {patrol_alt.feet}ft: {patrol_speed.knots} KTAS"
)
builder = WaypointBuilder(flight, self.coalition) builder = WaypointBuilder(flight, self.coalition)
start, end = builder.race_track(start_pos, end_pos, patrol_alt) start, end = builder.race_track(start_pos, end_pos, patrol_alt)
@ -1131,6 +1136,7 @@ class FlightPlanBuilder:
package=self.package, package=self.package,
flight=flight, flight=flight,
patrol_duration=self.doctrine.cap_duration, patrol_duration=self.doctrine.cap_duration,
patrol_speed=patrol_speed,
engagement_distance=self.doctrine.cap_engagement_range, engagement_distance=self.doctrine.cap_engagement_range,
takeoff=builder.takeoff(flight.departure), takeoff=builder.takeoff(flight.departure),
nav_to=builder.nav_path( nav_to=builder.nav_path(
@ -1238,7 +1244,7 @@ class FlightPlanBuilder:
bullseye=builder.bullseye(), bullseye=builder.bullseye(),
) )
def racetrack_for_objective( def cap_racetrack_for_objective(
self, location: MissionTarget, barcap: bool self, location: MissionTarget, barcap: bool
) -> Tuple[Point, Point]: ) -> Tuple[Point, Point]:
closest_cache = ObjectiveDistanceCache.get_closest_airfields(location) closest_cache = ObjectiveDistanceCache.get_closest_airfields(location)
@ -1270,6 +1276,7 @@ class FlightPlanBuilder:
- self.doctrine.cap_engagement_range - self.doctrine.cap_engagement_range
- nautical_miles(5) - nautical_miles(5)
) )
max_track_length = self.doctrine.cap_max_track_length
else: else:
# Other race tracks (TARCAPs, currently) just try to keep some # Other race tracks (TARCAPs, currently) just try to keep some
# distance from the nearest enemy airbase, but since they are by # distance from the nearest enemy airbase, but since they are by
@ -1283,6 +1290,11 @@ class FlightPlanBuilder:
) )
distance_to_no_fly = distance_to_airfield - min_distance_from_enemy distance_to_no_fly = distance_to_airfield - min_distance_from_enemy
# TARCAPs fly short racetracks because they need to react faster.
max_track_length = self.doctrine.cap_min_track_length + 0.3 * (
self.doctrine.cap_max_track_length - self.doctrine.cap_min_track_length
)
min_cap_distance = min( min_cap_distance = min(
self.doctrine.cap_min_distance_from_cp, distance_to_no_fly self.doctrine.cap_min_distance_from_cp, distance_to_no_fly
) )
@ -1294,11 +1306,12 @@ class FlightPlanBuilder:
heading.degrees, heading.degrees,
random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)), random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)),
) )
diameter = random.randint(
track_length = random.randint(
int(self.doctrine.cap_min_track_length.meters), int(self.doctrine.cap_min_track_length.meters),
int(self.doctrine.cap_max_track_length.meters), int(max_track_length.meters),
) )
start = end.point_from_heading(heading.opposite.degrees, diameter) start = end.point_from_heading(heading.opposite.degrees, track_length)
return start, end return start, end
def aewc_orbit(self, location: MissionTarget) -> Point: def aewc_orbit(self, location: MissionTarget) -> Point:
@ -1321,33 +1334,6 @@ class FlightPlanBuilder:
orbit_heading.degrees, orbit_distance.meters orbit_heading.degrees, orbit_distance.meters
) )
def racetrack_for_frontline(
self, origin: Point, front_line: FrontLine
) -> Tuple[Point, Point]:
# Find targets waypoints
ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater)
center = ingress.point_from_heading(heading.degrees, distance / 2)
orbit_center = center.point_from_heading(
heading.left.degrees,
random.randint(
int(nautical_miles(6).meters), int(nautical_miles(15).meters)
),
)
combat_width = distance / 2
if combat_width > 500000:
combat_width = 500000
if combat_width < 35000:
combat_width = 35000
radius = combat_width * 1.25
start = orbit_center.point_from_heading(heading.degrees, radius)
end = orbit_center.point_from_heading(heading.opposite.degrees, radius)
if end.distance_to_point(origin) < start.distance_to_point(origin):
start, end = end, start
return start, end
def generate_tarcap(self, flight: Flight) -> TarCapFlightPlan: def generate_tarcap(self, flight: Flight) -> TarCapFlightPlan:
"""Generate a CAP flight plan for the given front line. """Generate a CAP flight plan for the given front line.
@ -1362,16 +1348,14 @@ class FlightPlanBuilder:
self.doctrine.min_patrol_altitude, self.doctrine.min_patrol_altitude,
min(self.doctrine.max_patrol_altitude, randomized_alt), min(self.doctrine.max_patrol_altitude, randomized_alt),
) )
patrol_speed = flight.unit_type.preferred_patrol_speed(patrol_alt)
logging.debug(
f"TARCAP patrol speed for {flight.unit_type.name} at {patrol_alt.feet}ft: {patrol_speed.knots} KTAS"
)
# Create points # Create points
builder = WaypointBuilder(flight, self.coalition) builder = WaypointBuilder(flight, self.coalition)
orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False)
if isinstance(location, FrontLine):
orbit0p, orbit1p = self.racetrack_for_frontline(
flight.departure.position, location
)
else:
orbit0p, orbit1p = self.racetrack_for_objective(location, barcap=False)
start, end = builder.race_track(orbit0p, orbit1p, patrol_alt) start, end = builder.race_track(orbit0p, orbit1p, patrol_alt)
return TarCapFlightPlan( return TarCapFlightPlan(
@ -1383,6 +1367,7 @@ class FlightPlanBuilder:
# requests an escort the CAP flight will remain on station for the # requests an escort the CAP flight will remain on station for the
# duration of the escorted mission, or until it is winchester/bingo. # duration of the escorted mission, or until it is winchester/bingo.
patrol_duration=self.doctrine.cap_duration, patrol_duration=self.doctrine.cap_duration,
patrol_speed=patrol_speed,
engagement_distance=self.doctrine.cap_engagement_range, engagement_distance=self.doctrine.cap_engagement_range,
takeoff=builder.takeoff(flight.departure), takeoff=builder.takeoff(flight.departure),
nav_to=builder.nav_path(flight.departure.position, orbit0p, patrol_alt), nav_to=builder.nav_path(flight.departure.position, orbit0p, patrol_alt),
@ -1546,16 +1531,33 @@ class FlightPlanBuilder:
builder = WaypointBuilder(flight, self.coalition) builder = WaypointBuilder(flight, self.coalition)
# 2021-08-02: patrol_speed will currently have no effect because
# CAS doesn't use OrbitAction. But all PatrollingFlightPlan are expected
# to have patrol_speed
is_helo = flight.unit_type.dcs_unit_type.helicopter
ingress_egress_altitude = (
self.doctrine.ingress_altitude if not is_helo else meters(50)
)
patrol_speed = flight.unit_type.preferred_patrol_speed(ingress_egress_altitude)
use_agl_ingress_egress = is_helo
return CasFlightPlan( return CasFlightPlan(
package=self.package, package=self.package,
flight=flight, flight=flight,
patrol_duration=self.doctrine.cas_duration, patrol_duration=self.doctrine.cas_duration,
patrol_speed=patrol_speed,
takeoff=builder.takeoff(flight.departure), takeoff=builder.takeoff(flight.departure),
nav_to=builder.nav_path( nav_to=builder.nav_path(
flight.departure.position, ingress, self.doctrine.ingress_altitude flight.departure.position,
ingress,
ingress_egress_altitude,
use_agl_ingress_egress,
), ),
nav_from=builder.nav_path( nav_from=builder.nav_path(
egress, flight.arrival.position, self.doctrine.ingress_altitude egress,
flight.arrival.position,
ingress_egress_altitude,
use_agl_ingress_egress,
), ),
patrol_start=builder.ingress( patrol_start=builder.ingress(
FlightWaypointType.INGRESS_CAS, ingress, location FlightWaypointType.INGRESS_CAS, ingress, location
@ -1608,6 +1610,7 @@ class FlightPlanBuilder:
else: else:
altitude = feet(21000) altitude = feet(21000)
# TODO: Could use flight.unit_type.preferred_patrol_speed(altitude) instead.
if tanker_type.patrol_speed is not None: if tanker_type.patrol_speed is not None:
speed = tanker_type.patrol_speed speed = tanker_type.patrol_speed
else: else:

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
import logging
import random import random
from dataclasses import dataclass from dataclasses import dataclass
@ -55,7 +56,7 @@ class WaypointBuilder:
@property @property
def is_helo(self) -> bool: def is_helo(self) -> bool:
return getattr(self.flight.unit_type, "helicopter", False) return self.flight.unit_type.dcs_unit_type.helicopter
def takeoff(self, departure: ControlPoint) -> FlightWaypoint: def takeoff(self, departure: ControlPoint) -> FlightWaypoint:
"""Create takeoff waypoint for the given arrival airfield or carrier. """Create takeoff waypoint for the given arrival airfield or carrier.
@ -167,6 +168,8 @@ class WaypointBuilder:
position.y, position.y,
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude, meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
) )
if self.is_helo:
waypoint.alt_type = "RADIO"
waypoint.pretty_name = "Hold" waypoint.pretty_name = "Hold"
waypoint.description = "Wait until push time" waypoint.description = "Wait until push time"
waypoint.name = "HOLD" waypoint.name = "HOLD"
@ -210,7 +213,7 @@ class WaypointBuilder:
ingress_type, ingress_type,
position.x, position.x,
position.y, position.y,
meters(50) if self.is_helo else self.doctrine.ingress_altitude, meters(60) if self.is_helo else self.doctrine.ingress_altitude,
) )
if self.is_helo: if self.is_helo:
waypoint.alt_type = "RADIO" waypoint.alt_type = "RADIO"
@ -225,7 +228,7 @@ class WaypointBuilder:
FlightWaypointType.EGRESS, FlightWaypointType.EGRESS,
position.x, position.x,
position.y, position.y,
meters(50) if self.is_helo else self.doctrine.ingress_altitude, meters(60) if self.is_helo else self.doctrine.ingress_altitude,
) )
if self.is_helo: if self.is_helo:
waypoint.alt_type = "RADIO" waypoint.alt_type = "RADIO"
@ -309,7 +312,7 @@ class WaypointBuilder:
FlightWaypointType.CAS, FlightWaypointType.CAS,
position.x, position.x,
position.y, position.y,
meters(50) if self.is_helo else meters(1000), meters(60) if self.is_helo else meters(1000),
) )
waypoint.alt_type = "RADIO" waypoint.alt_type = "RADIO"
waypoint.description = "Provide CAS" waypoint.description = "Provide CAS"
@ -445,7 +448,7 @@ class WaypointBuilder:
FlightWaypointType.TARGET_GROUP_LOC, FlightWaypointType.TARGET_GROUP_LOC,
target.position.x, target.position.x,
target.position.y, target.position.y,
meters(50) if self.is_helo else self.doctrine.ingress_altitude, meters(60) if self.is_helo else self.doctrine.ingress_altitude,
) )
if self.is_helo: if self.is_helo:
waypoint.alt_type = "RADIO" waypoint.alt_type = "RADIO"

View File

@ -43,8 +43,14 @@ class ForcedOptionsGenerator:
if blue.unrestricted_satnav or red.unrestricted_satnav: if blue.unrestricted_satnav or red.unrestricted_satnav:
self.mission.forced_options.unrestricted_satnav = True self.mission.forced_options.unrestricted_satnav = True
def _set_battle_damage_assessment(self) -> None:
self.mission.forced_options.battle_damage_assessment = (
self.game.settings.battle_damage_assessment
)
def generate(self) -> None: def generate(self) -> None:
self._set_options_view() self._set_options_view()
self._set_external_views() self._set_external_views()
self._set_labels() self._set_labels()
self._set_unrestricted_satnav() self._set_unrestricted_satnav()
self._set_battle_damage_assessment()

View File

@ -65,7 +65,8 @@ class KneeboardPageWriter:
else: else:
self.foreground_fill = (15, 15, 15) self.foreground_fill = (15, 15, 15)
self.background_fill = (255, 252, 252) self.background_fill = (255, 252, 252)
self.image = Image.new("RGB", (768, 1024), self.background_fill) self.image_size = (768, 1024)
self.image = Image.new("RGB", self.image_size, self.background_fill)
# These font sizes create a relatively full page for current sorties. If # These font sizes create a relatively full page for current sorties. If
# we start generating more complicated flight plans, or start including # we start generating more complicated flight plans, or start including
# more information in the comm ladder (the latter of which we should # more information in the comm ladder (the latter of which we should
@ -84,6 +85,7 @@ class KneeboardPageWriter:
"resources/fonts/Inconsolata.otf", 20, layout_engine=ImageFont.LAYOUT_BASIC "resources/fonts/Inconsolata.otf", 20, layout_engine=ImageFont.LAYOUT_BASIC
) )
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.page_margin = page_margin
self.x = page_margin self.x = page_margin
self.y = page_margin self.y = page_margin
self.line_spacing = line_spacing self.line_spacing = line_spacing
@ -97,12 +99,21 @@ class KneeboardPageWriter:
text: str, text: str,
font: Optional[ImageFont.FreeTypeFont] = None, font: Optional[ImageFont.FreeTypeFont] = None,
fill: Optional[Tuple[int, int, int]] = None, fill: Optional[Tuple[int, int, int]] = None,
wrap: bool = False,
) -> None: ) -> None:
if font is None: if font is None:
font = self.content_font font = self.content_font
if fill is None: if fill is None:
fill = self.foreground_fill fill = self.foreground_fill
if wrap:
text = "\n".join(
self.wrap_line_with_font(
line, self.image_size[0] - self.page_margin - self.x, font
)
for line in text.splitlines()
)
self.draw.text(self.position, text, font=font, fill=fill) self.draw.text(self.position, text, font=font, fill=fill)
width, height = self.draw.textsize(text, font=font) width, height = self.draw.textsize(text, font=font)
self.y += height + self.line_spacing self.y += height + self.line_spacing
@ -146,6 +157,24 @@ class KneeboardPageWriter:
output = combo output = combo
return "".join(segments + [output]).strip() return "".join(segments + [output]).strip()
@staticmethod
def wrap_line_with_font(
inputstr: str, max_width: int, font: ImageFont.FreeTypeFont
) -> str:
if font.getsize(inputstr)[0] <= max_width:
return inputstr
tokens = inputstr.split(" ")
output = ""
segments = []
for token in tokens:
combo = output + " " + token
if font.getsize(combo)[0] > max_width:
segments.append(output + "\n")
output = token
else:
output = combo
return "".join(segments + [output]).strip()
class KneeboardPage: class KneeboardPage:
"""Base class for all kneeboard pages.""" """Base class for all kneeboard pages."""
@ -631,7 +660,7 @@ class NotesPage(KneeboardPage):
def write(self, path: Path) -> None: def write(self, path: Path) -> None:
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
writer.title(f"Notes") writer.title(f"Notes")
writer.text(self.notes) writer.text(self.notes, wrap=True)
writer.write(path) writer.write(path)

View File

@ -1,12 +1,16 @@
from __future__ import annotations
import random import random
import time import time
from typing import List, Any from typing import List, Any, TYPE_CHECKING
from dcs.country import Country from dcs.country import Country
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.dcs.unittype import UnitType from game.dcs.unittype import UnitType
from gen.flights.flight import Flight
if TYPE_CHECKING:
from gen.flights.flight import Flight
ALPHA_MILITARY = [ ALPHA_MILITARY = [
"Alpha", "Alpha",

View File

@ -2,7 +2,7 @@
import itertools import itertools
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, Iterator, List, Set from typing import Dict, FrozenSet, Iterator, List, Reversible, Set, Tuple
@dataclass(frozen=True) @dataclass(frozen=True)
@ -45,14 +45,8 @@ def kHz(num: int) -> RadioFrequency:
@dataclass(frozen=True) @dataclass(frozen=True)
class Radio: class RadioRange:
"""A radio. """Defines the minimum (inclusive) and maximum (exclusive) range of the radio."""
Defines the minimum (inclusive) and maximum (exclusive) range of the radio.
"""
#: The name of the radio.
name: str
#: The minimum (inclusive) frequency tunable by this radio. #: The minimum (inclusive) frequency tunable by this radio.
minimum: RadioFrequency minimum: RadioFrequency
@ -63,19 +57,51 @@ class Radio:
#: The spacing between adjacent frequencies. #: The spacing between adjacent frequencies.
step: RadioFrequency step: RadioFrequency
def __str__(self) -> str: #: Specific frequencies to exclude. (e.g. Guard channels)
return self.name excludes: FrozenSet[RadioFrequency] = frozenset()
def range(self) -> Iterator[RadioFrequency]: def range(self) -> Iterator[RadioFrequency]:
"""Returns an iterator over the usable frequencies of this radio.""" """Returns an iterator over the usable frequencies of this radio."""
return ( return (
RadioFrequency(x) RadioFrequency(x)
for x in range(self.minimum.hertz, self.maximum.hertz, self.step.hertz) for x in range(self.minimum.hertz, self.maximum.hertz, self.step.hertz)
if RadioFrequency(x) not in self.excludes
) )
@property @property
def last_channel(self) -> RadioFrequency: def last_channel(self) -> RadioFrequency:
return RadioFrequency(self.maximum.hertz - self.step.hertz) return next(
RadioFrequency(x)
for x in reversed(
range(self.minimum.hertz, self.maximum.hertz, self.step.hertz)
)
if RadioFrequency(x) not in self.excludes
)
@dataclass(frozen=True)
class Radio:
"""A radio.
Defines ranges of usable frequencies of the radio.
"""
#: The name of the radio.
name: str
#: List of usable frequency range of this radio.
ranges: Tuple[RadioRange, ...]
def __str__(self) -> str:
return self.name
def range(self) -> Iterator[RadioFrequency]:
"""Returns an iterator over the usable frequencies of this radio."""
return itertools.chain.from_iterable(rng.range() for rng in self.ranges)
@property
def last_channel(self) -> RadioFrequency:
return self.ranges[-1].last_channel
class ChannelInUseError(RuntimeError): class ChannelInUseError(RuntimeError):
@ -88,53 +114,58 @@ class ChannelInUseError(RuntimeError):
# TODO: Figure out appropriate steps for each radio. These are just guesses. # TODO: Figure out appropriate steps for each radio. These are just guesses.
#: List of all known radios used by aircraft in the game. #: List of all known radios used by aircraft in the game.
RADIOS: List[Radio] = [ RADIOS: List[Radio] = [
Radio("AN/ARC-164", MHz(225), MHz(400), step=MHz(1)), Radio("AN/ARC-164", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)),
Radio("AN/ARC-186(V) AM", MHz(116), MHz(152), step=MHz(1)), Radio("AN/ARC-186(V) AM", (RadioRange(MHz(116), MHz(152), step=MHz(1)),)),
Radio("AN/ARC-186(V) FM", MHz(30), MHz(76), step=MHz(1)), Radio("AN/ARC-186(V) FM", (RadioRange(MHz(30), MHz(76), step=MHz(1)),)),
# The AN/ARC-210 can also use [30, 88) and [108, 118), but the current Radio(
# implementation can't implement the gap and the radio can't transmit on the "AN/ARC-210",
# latter. There's still plenty of channels between 118 MHz and 400 MHz, so (
# not worth worrying about. RadioRange(MHz(225), MHz(400), MHz(1), frozenset((MHz(243),))),
Radio("AN/ARC-210", MHz(118), MHz(400), step=MHz(1)), RadioRange(MHz(136), MHz(155), MHz(1)),
Radio("AN/ARC-222", MHz(116), MHz(174), step=MHz(1)), RadioRange(MHz(156), MHz(174), MHz(1)),
Radio("SCR-522", MHz(100), MHz(156), step=MHz(1)), RadioRange(MHz(118), MHz(136), MHz(1)),
Radio("A.R.I. 1063", MHz(100), MHz(156), step=MHz(1)), RadioRange(MHz(30), MHz(88), MHz(1)),
Radio("BC-1206", kHz(200), kHz(400), step=kHz(10)), ),
),
Radio("AN/ARC-222", (RadioRange(MHz(116), MHz(174), step=MHz(1)),)),
Radio("SCR-522", (RadioRange(MHz(100), MHz(156), step=MHz(1)),)),
Radio("A.R.I. 1063", (RadioRange(MHz(100), MHz(156), step=MHz(1)),)),
Radio("BC-1206", (RadioRange(kHz(200), kHz(400), step=kHz(10)),)),
# Note: The M2000C V/UHF can operate in both ranges, but has a gap between # Note: The M2000C V/UHF can operate in both ranges, but has a gap between
# 150 MHz and 225 MHz. We can't allocate in that gap, and the current # 150 MHz and 225 MHz. We can't allocate in that gap, and the current
# system doesn't model gaps, so just pretend it ends at 150 MHz for now. We # system doesn't model gaps, so just pretend it ends at 150 MHz for now. We
# can model gaps later if needed. # can model gaps later if needed.
Radio("TRT ERA 7000 V/UHF", MHz(118), MHz(150), step=MHz(1)), Radio("TRT ERA 7000 V/UHF", (RadioRange(MHz(118), MHz(150), step=MHz(1)),)),
Radio("TRT ERA 7200 UHF", MHz(225), MHz(400), step=MHz(1)), Radio("TRT ERA 7200 UHF", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)),
# Tomcat radios # Tomcat radios
# # https://www.heatblur.se/F-14Manual/general.html#an-arc-159-uhf-1-radio # # https://www.heatblur.se/F-14Manual/general.html#an-arc-159-uhf-1-radio
Radio("AN/ARC-159", MHz(225), MHz(400), step=MHz(1)), Radio("AN/ARC-159", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)),
# AN/ARC-182 can also operate from 30 MHz to 88 MHz, as well as from 225 MHz # AN/ARC-182 can also operate from 30 MHz to 88 MHz, as well as from 225 MHz
# to 400 MHz range, but we can't model gaps with the current implementation. # to 400 MHz range, but we can't model gaps with the current implementation.
# https://www.heatblur.se/F-14Manual/general.html#an-arc-182-v-uhf-2-radio # https://www.heatblur.se/F-14Manual/general.html#an-arc-182-v-uhf-2-radio
Radio("AN/ARC-182", MHz(108), MHz(174), step=MHz(1)), Radio("AN/ARC-182", (RadioRange(MHz(108), MHz(174), step=MHz(1)),)),
# Also capable of [103, 156) at 25 kHz intervals, but we can't do gaps. # Also capable of [103, 156) at 25 kHz intervals, but we can't do gaps.
Radio("FR 22", MHz(225), MHz(400), step=kHz(50)), Radio("FR 22", (RadioRange(MHz(225), MHz(400), step=kHz(50)),)),
# P-51 / P-47 Radio # P-51 / P-47 Radio
# 4 preset channels (A/B/C/D) # 4 preset channels (A/B/C/D)
Radio("SCR522", MHz(100), MHz(156), step=kHz(25)), Radio("SCR522", (RadioRange(MHz(100), MHz(156), step=kHz(25)),)),
Radio("R&S M3AR VHF", MHz(120), MHz(174), step=MHz(1)), Radio("R&S M3AR VHF", (RadioRange(MHz(120), MHz(174), step=MHz(1)),)),
Radio("R&S M3AR UHF", MHz(225), MHz(400), step=MHz(1)), Radio("R&S M3AR UHF", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)),
# MiG-15bis # MiG-15bis
Radio("RSI-6K HF", MHz(3, 750), MHz(5), step=kHz(25)), Radio("RSI-6K HF", (RadioRange(MHz(3, 750), MHz(5), step=kHz(25)),)),
# MiG-19P # MiG-19P
Radio("RSIU-4V", MHz(100), MHz(150), step=MHz(1)), Radio("RSIU-4V", (RadioRange(MHz(100), MHz(150), step=MHz(1)),)),
# MiG-21bis # MiG-21bis
Radio("RSIU-5V", MHz(118), MHz(140), step=MHz(1)), Radio("RSIU-5V", (RadioRange(MHz(118), MHz(140), step=MHz(1)),)),
# Ka-50 # Ka-50
# Note: Also capable of 100MHz-150MHz, but we can't model gaps. # Note: Also capable of 100MHz-150MHz, but we can't model gaps.
Radio("R-800L1", MHz(220), MHz(400), step=kHz(25)), Radio("R-800L1", (RadioRange(MHz(220), MHz(400), step=kHz(25)),)),
Radio("R-828", MHz(20), MHz(60), step=kHz(25)), Radio("R-828", (RadioRange(MHz(20), MHz(60), step=kHz(25)),)),
# UH-1H # UH-1H
Radio("AN/ARC-51BX", MHz(225), MHz(400), step=kHz(50)), Radio("AN/ARC-51BX", (RadioRange(MHz(225), MHz(400), step=kHz(50)),)),
Radio("AN/ARC-131", MHz(30), MHz(76), step=kHz(50)), Radio("AN/ARC-131", (RadioRange(MHz(30), MHz(76), step=kHz(50)),)),
Radio("AN/ARC-134", MHz(116), MHz(150), step=kHz(25)), Radio("AN/ARC-134", (RadioRange(MHz(116), MHz(150), step=kHz(25)),)),
Radio("R&S Series 6000", MHz(100), MHz(156), step=kHz(25)), Radio("R&S Series 6000", (RadioRange(MHz(100), MHz(156), step=kHz(25)),)),
] ]
@ -175,7 +206,7 @@ class RadioRegistry:
# Not a real radio, but useful for allocating a channel usable for # Not a real radio, but useful for allocating a channel usable for
# inter-flight communications. # inter-flight communications.
BLUFOR_UHF = Radio("BLUFOR UHF", MHz(225), MHz(400), step=MHz(1)) BLUFOR_UHF = Radio("BLUFOR UHF", (RadioRange(MHz(225), MHz(400), step=MHz(1)),))
def __init__(self) -> None: def __init__(self) -> None:
self.allocated_channels: Set[RadioFrequency] = set() self.allocated_channels: Set[RadioFrequency] = set()

View File

@ -82,8 +82,10 @@ class FlakGenerator(AirDefenseGroupGenerator):
) )
# Some Opel Blitz trucks # Some Opel Blitz trucks
index = 0
for i in range(int(max(1, 2))): for i in range(int(max(1, 2))):
for j in range(int(max(1, 2))): for j in range(int(max(1, 2))):
index += 1
self.add_unit( self.add_unit(
Unarmed.Blitz_36_6700A, Unarmed.Blitz_36_6700A,
"BLITZ#" + str(index), "BLITZ#" + str(index),

View File

@ -16,7 +16,11 @@ class EwrGenerator(VehicleGroupGenerator[EwrGroundObject]):
def generate(self) -> None: def generate(self) -> None:
self.add_unit( self.add_unit(
self.unit_type, "EWR", self.position.x, self.position.y, self.heading self.unit_type,
"EWR",
self.position.x,
self.position.y,
self.heading_to_conflict(),
) )

View File

@ -27,7 +27,7 @@ from qt_ui import (
) )
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.QLiberationWindow import QLiberationWindow from qt_ui.windows.QLiberationWindow import QLiberationWindow
from qt_ui.windows.newgame.QCampaignList import Campaign from game.campaignloader.campaign import Campaign
from qt_ui.windows.newgame.QNewGameWizard import DEFAULT_BUDGET from qt_ui.windows.newgame.QNewGameWizard import DEFAULT_BUDGET
from qt_ui.windows.preferences.QLiberationFirstStartWindow import ( from qt_ui.windows.preferences.QLiberationFirstStartWindow import (
QLiberationFirstStartWindow, QLiberationFirstStartWindow,
@ -64,7 +64,8 @@ def run_ui(game: Optional[Game]) -> None:
# init the theme and load the stylesheet based on the theme index # init the theme and load the stylesheet based on the theme index
liberation_theme.init() liberation_theme.init()
with open( with open(
"./resources/stylesheets/" + liberation_theme.get_theme_css_file() "./resources/stylesheets/" + liberation_theme.get_theme_css_file(),
encoding="utf-8",
) as stylesheet: ) as stylesheet:
logging.info("Loading stylesheet: %s", liberation_theme.get_theme_css_file()) logging.info("Loading stylesheet: %s", liberation_theme.get_theme_css_file())
app.setStyleSheet(stylesheet.read()) app.setStyleSheet(stylesheet.read())
@ -231,11 +232,13 @@ def create_game(
# for loadouts) without saving the generated campaign and reloading it the normal # for loadouts) without saving the generated campaign and reloading it the normal
# way. # way.
inject_custom_payloads(Path(persistency.base_path())) inject_custom_payloads(Path(persistency.base_path()))
campaign = Campaign.from_json(campaign_path) campaign = Campaign.from_file(campaign_path)
theater = campaign.load_theater()
generator = GameGenerator( generator = GameGenerator(
FACTIONS[blue], FACTIONS[blue],
FACTIONS[red], FACTIONS[red],
campaign.load_theater(), theater,
campaign.load_air_wing_config(theater),
Settings( Settings(
supercarrier=supercarrier, supercarrier=supercarrier,
automate_runway_repair=auto_procurement, automate_runway_repair=auto_procurement,

View File

@ -13,7 +13,7 @@ from PySide2.QtCore import (
from PySide2.QtGui import QIcon from PySide2.QtGui import QIcon
from game.game import Game from game.game import Game
from game.squadrons import Squadron, Pilot from game.squadrons.squadron import Pilot, Squadron
from game.theater.missiontarget import MissionTarget from game.theater.missiontarget import MissionTarget
from game.transfers import TransferOrder, PendingTransfers from game.transfers import TransferOrder, PendingTransfers
from gen.ato import AirTaskingOrder, Package from gen.ato import AirTaskingOrder, Package
@ -165,8 +165,7 @@ class PackageModel(QAbstractListModel):
self.beginRemoveRows(QModelIndex(), index, index) self.beginRemoveRows(QModelIndex(), index, index)
if flight.cargo is not None: if flight.cargo is not None:
flight.cargo.transport = None flight.cargo.transport = None
self.game_model.game.aircraft_inventory.return_from_flight(flight) flight.return_pilots_and_aircraft()
flight.clear_roster()
self.package.remove_flight(flight) self.package.remove_flight(flight)
self.endRemoveRows() self.endRemoveRows()
self.update_tot() self.update_tot()
@ -258,8 +257,7 @@ class AtoModel(QAbstractListModel):
self.beginRemoveRows(QModelIndex(), index, index) self.beginRemoveRows(QModelIndex(), index, index)
self.ato.remove_package(package) self.ato.remove_package(package)
for flight in package.flights: for flight in package.flights:
self.game.aircraft_inventory.return_from_flight(flight) flight.return_pilots_and_aircraft()
flight.clear_roster()
if flight.cargo is not None: if flight.cargo is not None:
flight.cargo.transport = None flight.cargo.transport = None
self.endRemoveRows() self.endRemoveRows()

View File

@ -1,69 +0,0 @@
"""Combo box for selecting a departure airfield."""
from typing import Iterable, Optional
from PySide2.QtCore import Signal
from PySide2.QtWidgets import QComboBox
from dcs.unittype import FlyingType
from game.dcs.aircrafttype import AircraftType
from game.inventory import GlobalAircraftInventory
from game.theater.controlpoint import ControlPoint
class QOriginAirfieldSelector(QComboBox):
"""A combo box for selecting a flight's departure airfield.
The combo box will automatically be populated with all departure airfields
that have unassigned inventory of the given aircraft type.
"""
availability_changed = Signal(int)
def __init__(
self,
global_inventory: GlobalAircraftInventory,
origins: Iterable[ControlPoint],
aircraft: Optional[AircraftType],
) -> None:
super().__init__()
self.global_inventory = global_inventory
self.origins = list(origins)
self.aircraft = aircraft
self.rebuild_selector()
self.currentIndexChanged.connect(self.index_changed)
self.setSizeAdjustPolicy(self.AdjustToContents)
def change_aircraft(self, aircraft: Optional[FlyingType]) -> None:
if self.aircraft == aircraft:
return
self.aircraft = aircraft
self.rebuild_selector()
def rebuild_selector(self) -> None:
self.clear()
if self.aircraft is None:
return
for origin in self.origins:
if not origin.can_operate(self.aircraft):
continue
inventory = self.global_inventory.for_control_point(origin)
available = inventory.available(self.aircraft)
if available:
self.addItem(f"{origin.name} ({available} available)", origin)
self.model().sort(0)
@property
def available(self) -> int:
origin = self.currentData()
if origin is None:
return 0
inventory = self.global_inventory.for_control_point(origin)
return inventory.available(self.aircraft)
def index_changed(self, index: int) -> None:
origin = self.itemData(index)
if origin is None:
return
inventory = self.global_inventory.for_control_point(origin)
self.availability_changed.emit(inventory.available(self.aircraft))

View File

@ -1,10 +1,10 @@
from PySide2.QtGui import QStandardItem, QStandardItemModel from PySide2.QtGui import QStandardItem, QStandardItemModel
from game import Game from game import Game
from game.theater import ControlPointType from game.theater import ControlPointType, BuildingGroundObject
from game.utils import Distance from game.utils import Distance
from gen import BuildingGroundObject, Conflict, FlightWaypointType from gen.conflictgen import Conflict
from gen.flights.flight import FlightWaypoint from gen.flights.flight import FlightWaypoint, FlightWaypointType
from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox

View File

@ -1,4 +1,4 @@
from typing import Optional, Callable from typing import Optional, Callable, Iterable
from PySide2.QtCore import ( from PySide2.QtCore import (
QItemSelectionModel, QItemSelectionModel,
@ -24,11 +24,13 @@ from PySide2.QtWidgets import (
QHBoxLayout, QHBoxLayout,
QStackedLayout, QStackedLayout,
QTabWidget, QTabWidget,
QComboBox,
) )
from game import Game from game import Game
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.squadrons import Squadron, AirWing, Pilot from game.squadrons import AirWing, Pilot, Squadron
from game.theater import ControlPoint, ConflictTheater
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
from qt_ui.models import AirWingModel, SquadronModel from qt_ui.models import AirWingModel, SquadronModel
from qt_ui.uiconstants import AIRCRAFT_ICONS from qt_ui.uiconstants import AIRCRAFT_ICONS
@ -96,8 +98,33 @@ class AllowedMissionTypeControls(QVBoxLayout):
self.allowed_mission_types.remove(task) self.allowed_mission_types.remove(task)
class SquadronBaseSelector(QComboBox):
"""A combo box for selecting a squadrons home air base.
The combo box will automatically be populated with all air bases compatible with the
squadron.
"""
def __init__(
self,
bases: Iterable[ControlPoint],
squadron: Squadron,
) -> None:
super().__init__()
self.bases = list(bases)
self.squadron = squadron
self.setSizeAdjustPolicy(self.AdjustToContents)
for base in self.bases:
if not base.can_operate(self.squadron.aircraft):
continue
self.addItem(base.name, base)
self.model().sort(0)
self.setCurrentText(self.squadron.location.name)
class SquadronConfigurationBox(QGroupBox): class SquadronConfigurationBox(QGroupBox):
def __init__(self, squadron: Squadron) -> None: def __init__(self, squadron: Squadron, theater: ConflictTheater) -> None:
super().__init__() super().__init__()
self.setCheckable(True) self.setCheckable(True)
self.squadron = squadron self.squadron = squadron
@ -119,6 +146,13 @@ class SquadronConfigurationBox(QGroupBox):
self.nickname_edit.textChanged.connect(self.on_nickname_changed) self.nickname_edit.textChanged.connect(self.on_nickname_changed)
left_column.addWidget(self.nickname_edit) left_column.addWidget(self.nickname_edit)
left_column.addWidget(QLabel("Base:"))
self.base_selector = SquadronBaseSelector(
theater.control_points_for(squadron.player), squadron
)
self.base_selector.currentIndexChanged.connect(self.on_base_changed)
left_column.addWidget(self.base_selector)
if squadron.player: if squadron.player:
player_label = QLabel( player_label = QLabel(
"Players (one per line, leave empty for an AI-only squadron):" "Players (one per line, leave empty for an AI-only squadron):"
@ -149,6 +183,12 @@ class SquadronConfigurationBox(QGroupBox):
def on_nickname_changed(self, text: str) -> None: def on_nickname_changed(self, text: str) -> None:
self.squadron.nickname = text self.squadron.nickname = text
def on_base_changed(self, index: int) -> None:
base = self.base_selector.itemData(index)
if base is None:
raise RuntimeError("Base cannot be none")
self.squadron.assign_to_base(base)
def reset_title(self) -> None: def reset_title(self) -> None:
self.setTitle(f"{self.squadron.name} - {self.squadron.aircraft}") self.setTitle(f"{self.squadron.name} - {self.squadron.aircraft}")
@ -158,16 +198,18 @@ class SquadronConfigurationBox(QGroupBox):
self.squadron.pilot_pool = [ self.squadron.pilot_pool = [
Pilot(n, player=True) for n in player_names Pilot(n, player=True) for n in player_names
] + self.squadron.pilot_pool ] + self.squadron.pilot_pool
self.squadron.mission_types = tuple(self.allowed_missions.allowed_mission_types) self.squadron.set_allowed_mission_types(
self.allowed_missions.allowed_mission_types
)
return self.squadron return self.squadron
class SquadronConfigurationLayout(QVBoxLayout): class SquadronConfigurationLayout(QVBoxLayout):
def __init__(self, squadrons: list[Squadron]) -> None: def __init__(self, squadrons: list[Squadron], theater: ConflictTheater) -> None:
super().__init__() super().__init__()
self.squadron_configs = [] self.squadron_configs = []
for squadron in squadrons: for squadron in squadrons:
squadron_config = SquadronConfigurationBox(squadron) squadron_config = SquadronConfigurationBox(squadron, theater)
self.squadron_configs.append(squadron_config) self.squadron_configs.append(squadron_config)
self.addWidget(squadron_config) self.addWidget(squadron_config)
@ -180,12 +222,12 @@ class SquadronConfigurationLayout(QVBoxLayout):
class AircraftSquadronsPage(QWidget): class AircraftSquadronsPage(QWidget):
def __init__(self, squadrons: list[Squadron]) -> None: def __init__(self, squadrons: list[Squadron], theater: ConflictTheater) -> None:
super().__init__() super().__init__()
layout = QVBoxLayout() layout = QVBoxLayout()
self.setLayout(layout) self.setLayout(layout)
self.squadrons_config = SquadronConfigurationLayout(squadrons) self.squadrons_config = SquadronConfigurationLayout(squadrons, theater)
scrolling_widget = QWidget() scrolling_widget = QWidget()
scrolling_widget.setLayout(self.squadrons_config) scrolling_widget.setLayout(self.squadrons_config)
@ -203,12 +245,12 @@ class AircraftSquadronsPage(QWidget):
class AircraftSquadronsPanel(QStackedLayout): class AircraftSquadronsPanel(QStackedLayout):
def __init__(self, air_wing: AirWing) -> None: def __init__(self, air_wing: AirWing, theater: ConflictTheater) -> None:
super().__init__() super().__init__()
self.air_wing = air_wing self.air_wing = air_wing
self.squadrons_pages: dict[AircraftType, AircraftSquadronsPage] = {} self.squadrons_pages: dict[AircraftType, AircraftSquadronsPage] = {}
for aircraft, squadrons in self.air_wing.squadrons.items(): for aircraft, squadrons in self.air_wing.squadrons.items():
page = AircraftSquadronsPage(squadrons) page = AircraftSquadronsPage(squadrons, theater)
self.addWidget(page) self.addWidget(page)
self.squadrons_pages[aircraft] = page self.squadrons_pages[aircraft] = page
@ -260,7 +302,7 @@ class AircraftTypeList(QListView):
class AirWingConfigurationTab(QWidget): class AirWingConfigurationTab(QWidget):
def __init__(self, air_wing: AirWing) -> None: def __init__(self, air_wing: AirWing, theater: ConflictTheater) -> None:
super().__init__() super().__init__()
layout = QHBoxLayout() layout = QHBoxLayout()
@ -270,7 +312,7 @@ class AirWingConfigurationTab(QWidget):
type_list.page_index_changed.connect(self.on_aircraft_changed) type_list.page_index_changed.connect(self.on_aircraft_changed)
layout.addWidget(type_list) layout.addWidget(type_list)
self.squadrons_panel = AircraftSquadronsPanel(air_wing) self.squadrons_panel = AircraftSquadronsPanel(air_wing, theater)
layout.addLayout(self.squadrons_panel) layout.addLayout(self.squadrons_panel)
def apply(self) -> None: def apply(self) -> None:
@ -315,7 +357,7 @@ class AirWingConfigurationDialog(QDialog):
self.tabs = [] self.tabs = []
for coalition in game.coalitions: for coalition in game.coalitions:
coalition_tab = AirWingConfigurationTab(coalition.air_wing) coalition_tab = AirWingConfigurationTab(coalition.air_wing, game.theater)
name = "Blue" if coalition.player else "Red" name = "Blue" if coalition.player else "Red"
tab_widget.addTab(coalition_tab, name) tab_widget.addTab(coalition_tab, name)
self.tabs.append(coalition_tab) self.tabs.append(coalition_tab)

View File

@ -16,7 +16,6 @@ from PySide2.QtWidgets import (
QWidget, QWidget,
) )
from game.inventory import ControlPointAircraftInventory
from game.squadrons import Squadron from game.squadrons import Squadron
from gen.flights.flight import Flight from gen.flights.flight import Flight
from qt_ui.delegates import TwoColumnRowDelegate from qt_ui.delegates import TwoColumnRowDelegate
@ -34,13 +33,17 @@ class SquadronDelegate(TwoColumnRowDelegate):
return index.data(AirWingModel.SquadronRole) return index.data(AirWingModel.SquadronRole)
def text_for(self, index: QModelIndex, row: int, column: int) -> str: def text_for(self, index: QModelIndex, row: int, column: int) -> str:
squadron = self.squadron(index)
if (row, column) == (0, 0): if (row, column) == (0, 0):
return self.squadron(index).name if squadron.nickname:
nickname = f' "{squadron.nickname}"'
else:
nickname = ""
return f"{squadron.name}{nickname}"
elif (row, column) == (0, 1): elif (row, column) == (0, 1):
squadron = self.air_wing_model.data(index, AirWingModel.SquadronRole)
return squadron.aircraft.name return squadron.aircraft.name
elif (row, column) == (1, 0): elif (row, column) == (1, 0):
return self.squadron(index).nickname or "" return squadron.location.name
elif (row, column) == (1, 1): elif (row, column) == (1, 1):
squadron = self.squadron(index) squadron = self.squadron(index)
active = len(squadron.active_pilots) active = len(squadron.active_pilots)
@ -123,19 +126,13 @@ class AircraftInventoryData:
) )
@classmethod @classmethod
def each_from_inventory( def each_untasked_from_squadron(
cls, inventory: ControlPointAircraftInventory cls, squadron: Squadron
) -> Iterator[AircraftInventoryData]: ) -> Iterator[AircraftInventoryData]:
for unit_type, num_units in inventory.all_aircraft: for _ in range(0, squadron.untasked_aircraft):
for _ in range(0, num_units): yield AircraftInventoryData(
yield AircraftInventoryData( squadron.name, squadron.aircraft.name, "Idle", "N/A", "N/A", "N/A"
inventory.control_point.name, )
unit_type.name,
"Idle",
"N/A",
"N/A",
"N/A",
)
class AirInventoryView(QWidget): class AirInventoryView(QWidget):
@ -184,9 +181,8 @@ class AirInventoryView(QWidget):
def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]: def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]:
game = self.game_model.game game = self.game_model.game
for control_point, inventory in game.aircraft_inventory.inventories.items(): for squadron in game.blue.air_wing.iter_squadrons():
if control_point.captured: yield from AircraftInventoryData.each_untasked_from_squadron(squadron)
yield from AircraftInventoryData.each_from_inventory(inventory)
def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]: def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]:
yield from self.iter_unallocated_aircraft() yield from self.iter_unallocated_aircraft()

View File

@ -228,7 +228,7 @@ class QWaitingForMissionResultWindow(QDialog):
) )
print(file) print(file)
try: try:
with open(file[0], "r") as json_file: with open(file[0], "r", encoding="utf-8") as json_file:
json_data = json.load(json_file) json_data = json.load(json_file)
json_data["mission_ended"] = True json_data["mission_ended"] = True
debriefing = Debriefing(json_data, self.game, self.unit_map) debriefing = Debriefing(json_data, self.game, self.unit_map)

View File

@ -14,7 +14,6 @@ from PySide2.QtWidgets import (
QVBoxLayout, QVBoxLayout,
QPushButton, QPushButton,
QHBoxLayout, QHBoxLayout,
QGridLayout,
QLabel, QLabel,
QCheckBox, QCheckBox,
) )

View File

@ -24,7 +24,7 @@ from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog
from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs
from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour from qt_ui.windows.basemenu.UnitTransactionFrame import UnitTransactionFrame
class QBaseMenu2(QDialog): class QBaseMenu2(QDialog):
@ -108,7 +108,7 @@ class QBaseMenu2(QDialog):
capture_button.clicked.connect(self.cheat_capture) capture_button.clicked.connect(self.cheat_capture)
self.budget_display = QLabel( self.budget_display = QLabel(
QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.blue.budget) UnitTransactionFrame.BUDGET_FORMAT.format(self.game_model.game.blue.budget)
) )
self.budget_display.setAlignment(Qt.AlignRight | Qt.AlignBottom) self.budget_display.setAlignment(Qt.AlignRight | Qt.AlignBottom)
self.budget_display.setProperty("style", "budget-label") self.budget_display.setProperty("style", "budget-label")
@ -190,7 +190,7 @@ class QBaseMenu2(QDialog):
self.repair_button.setDisabled(True) self.repair_button.setDisabled(True)
def update_intel_summary(self) -> None: def update_intel_summary(self) -> None:
aircraft = self.cp.base.total_aircraft aircraft = self.cp.allocated_aircraft(self.game_model.game).total_present
parking = self.cp.total_aircraft_parking parking = self.cp.total_aircraft_parking
ground_unit_limit = self.cp.frontline_unit_count_limit ground_unit_limit = self.cp.frontline_unit_count_limit
deployable_unit_info = "" deployable_unit_info = ""
@ -258,5 +258,5 @@ class QBaseMenu2(QDialog):
def update_budget(self, game: Game) -> None: def update_budget(self, game: Game) -> None:
self.budget_display.setText( self.budget_display.setText(
QRecruitBehaviour.BUDGET_FORMAT.format(game.blue.budget) UnitTransactionFrame.BUDGET_FORMAT.format(game.blue.budget)
) )

View File

@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from enum import Enum
from typing import TypeVar, Generic
from PySide2.QtCore import Qt from PySide2.QtCore import Qt
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QGroupBox, QGroupBox,
@ -11,15 +14,15 @@ from PySide2.QtWidgets import (
QSpacerItem, QSpacerItem,
QGridLayout, QGridLayout,
QApplication, QApplication,
QFrame,
QMessageBox,
) )
from game.dcs.unittype import UnitType from game.dcs.unittype import UnitType
from game.theater import ControlPoint
from game.unitdelivery import PendingUnitDeliveries
from qt_ui.models import GameModel from qt_ui.models import GameModel
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.QUnitInfoWindow import QUnitInfoWindow from qt_ui.windows.QUnitInfoWindow import QUnitInfoWindow
from enum import Enum from game.purchaseadapter import PurchaseAdapter, TransactionError
class RecruitType(Enum): class RecruitType(Enum):
@ -27,21 +30,28 @@ class RecruitType(Enum):
SELL = 1 SELL = 1
class PurchaseGroup(QGroupBox): TransactionItemType = TypeVar("TransactionItemType")
def __init__(self, unit_type: UnitType, recruiter: QRecruitBehaviour) -> None:
class PurchaseGroup(QGroupBox, Generic[TransactionItemType]):
def __init__(
self,
item: TransactionItemType,
recruiter: UnitTransactionFrame[TransactionItemType],
) -> None:
super().__init__() super().__init__()
self.unit_type = unit_type self.item = item
self.recruiter = recruiter self.recruiter = recruiter
self.setProperty("style", "buy-box") self.setProperty("style", "buy-box")
self.setMaximumHeight(36) self.setMaximumHeight(72)
self.setMinimumHeight(36) self.setMinimumHeight(36)
layout = QHBoxLayout() layout = QHBoxLayout()
self.setLayout(layout) self.setLayout(layout)
self.sell_button = QPushButton("-") self.sell_button = QPushButton("-")
self.sell_button.setProperty("style", "btn-sell") self.sell_button.setProperty("style", "btn-sell")
self.sell_button.setDisabled(not recruiter.enable_sale(unit_type)) self.sell_button.setDisabled(not recruiter.enable_sale(item))
self.sell_button.setMinimumSize(16, 16) self.sell_button.setMinimumSize(16, 16)
self.sell_button.setMaximumSize(16, 16) self.sell_button.setMaximumSize(16, 16)
self.sell_button.setSizePolicy( self.sell_button.setSizePolicy(
@ -49,7 +59,7 @@ class PurchaseGroup(QGroupBox):
) )
self.sell_button.clicked.connect( self.sell_button.clicked.connect(
lambda: self.recruiter.recruit_handler(RecruitType.SELL, self.unit_type) lambda: self.recruiter.recruit_handler(RecruitType.SELL, self.item)
) )
self.amount_bought = QLabel() self.amount_bought = QLabel()
@ -59,12 +69,12 @@ class PurchaseGroup(QGroupBox):
self.buy_button = QPushButton("+") self.buy_button = QPushButton("+")
self.buy_button.setProperty("style", "btn-buy") self.buy_button.setProperty("style", "btn-buy")
self.buy_button.setDisabled(not recruiter.enable_purchase(unit_type)) self.buy_button.setDisabled(not recruiter.enable_purchase(item))
self.buy_button.setMinimumSize(16, 16) self.buy_button.setMinimumSize(16, 16)
self.buy_button.setMaximumSize(16, 16) self.buy_button.setMaximumSize(16, 16)
self.buy_button.clicked.connect( self.buy_button.clicked.connect(
lambda: self.recruiter.recruit_handler(RecruitType.BUY, self.unit_type) lambda: self.recruiter.recruit_handler(RecruitType.BUY, self.item)
) )
self.buy_button.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) self.buy_button.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
@ -76,30 +86,53 @@ class PurchaseGroup(QGroupBox):
@property @property
def pending_units(self) -> int: def pending_units(self) -> int:
return self.recruiter.pending_deliveries.units.get(self.unit_type, 0) return self.recruiter.pending_delivery_quantity(self.item)
def update_state(self) -> None: def update_state(self) -> None:
self.buy_button.setEnabled(self.recruiter.enable_purchase(self.unit_type)) self.buy_button.setEnabled(self.recruiter.enable_purchase(self.item))
self.sell_button.setEnabled(self.recruiter.enable_sale(self.unit_type)) self.buy_button.setToolTip(
self.recruiter.purchase_tooltip(self.buy_button.isEnabled())
)
self.sell_button.setEnabled(self.recruiter.enable_sale(self.item))
self.sell_button.setToolTip(
self.recruiter.sell_tooltip(self.sell_button.isEnabled())
)
self.amount_bought.setText(f"<b>{self.pending_units}</b>") self.amount_bought.setText(f"<b>{self.pending_units}</b>")
class QRecruitBehaviour: class UnitTransactionFrame(QFrame, Generic[TransactionItemType]):
game_model: GameModel
cp: ControlPoint
purchase_groups: dict[UnitType, PurchaseGroup]
existing_units_labels = None
maximum_units = -1
BUDGET_FORMAT = "Available Budget: <b>${:.2f}M</b>" BUDGET_FORMAT = "Available Budget: <b>${:.2f}M</b>"
def __init__(self) -> None: def __init__(
self,
game_model: GameModel,
purchase_adapter: PurchaseAdapter[TransactionItemType],
) -> None:
super().__init__()
self.game_model = game_model
self.purchase_adapter = purchase_adapter
self.existing_units_labels = {} self.existing_units_labels = {}
self.purchase_groups = {} self.purchase_groups: dict[
TransactionItemType, PurchaseGroup[TransactionItemType]
] = {}
self.update_available_budget() self.update_available_budget()
@property def current_quantity_of(self, item: TransactionItemType) -> int:
def pending_deliveries(self) -> PendingUnitDeliveries: return self.purchase_adapter.current_quantity_of(item)
return self.cp.pending_unit_deliveries
def pending_delivery_quantity(self, item: TransactionItemType) -> int:
return self.purchase_adapter.pending_delivery_quantity(item)
def expected_quantity_next_turn(self, item: TransactionItemType) -> int:
return self.purchase_adapter.expected_quantity_next_turn(item)
def display_name_of(
self, item: TransactionItemType, multiline: bool = False
) -> str:
return self.purchase_adapter.name_of(item, multiline)
def price_of(self, item: TransactionItemType) -> int:
return self.purchase_adapter.price_of(item)
@property @property
def budget(self) -> float: def budget(self) -> float:
@ -111,20 +144,20 @@ class QRecruitBehaviour:
def add_purchase_row( def add_purchase_row(
self, self,
unit_type: UnitType, item: TransactionItemType,
layout: QGridLayout, layout: QGridLayout,
row: int, row: int,
) -> None: ) -> None:
exist = QGroupBox() exist = QGroupBox()
exist.setProperty("style", "buy-box") exist.setProperty("style", "buy-box")
exist.setMaximumHeight(36) exist.setMaximumHeight(72)
exist.setMinimumHeight(36) exist.setMinimumHeight(36)
existLayout = QHBoxLayout() existLayout = QHBoxLayout()
exist.setLayout(existLayout) exist.setLayout(existLayout)
existing_units = self.cp.base.total_units_of_type(unit_type) existing_units = self.current_quantity_of(item)
unitName = QLabel(f"<b>{unit_type.name}</b>") unitName = QLabel(f"<b>{self.display_name_of(item, multiline=True)}</b>")
unitName.setSizePolicy( unitName.setSizePolicy(
QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
) )
@ -132,17 +165,17 @@ class QRecruitBehaviour:
existing_units = QLabel(str(existing_units)) existing_units = QLabel(str(existing_units))
existing_units.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) existing_units.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
self.existing_units_labels[unit_type] = existing_units self.existing_units_labels[item] = existing_units
price = QLabel(f"<b>$ {unit_type.price}</b> M") price = QLabel(f"<b>$ {self.price_of(item)}</b> M")
price.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) price.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
purchase_group = PurchaseGroup(unit_type, self) purchase_group = PurchaseGroup(item, self)
self.purchase_groups[unit_type] = purchase_group self.purchase_groups[item] = purchase_group
info = QGroupBox() info = QGroupBox()
info.setProperty("style", "buy-box") info.setProperty("style", "buy-box")
info.setMaximumHeight(36) info.setMaximumHeight(72)
info.setMinimumHeight(36) info.setMinimumHeight(36)
infolayout = QHBoxLayout() infolayout = QHBoxLayout()
info.setLayout(infolayout) info.setLayout(infolayout)
@ -151,7 +184,7 @@ class QRecruitBehaviour:
unitInfo.setProperty("style", "btn-info") unitInfo.setProperty("style", "btn-info")
unitInfo.setMinimumSize(16, 16) unitInfo.setMinimumSize(16, 16)
unitInfo.setMaximumSize(16, 16) unitInfo.setMaximumSize(16, 16)
unitInfo.clicked.connect(lambda: self.info(unit_type)) unitInfo.clicked.connect(lambda: self.info(item))
unitInfo.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) unitInfo.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
existLayout.addWidget(unitName) existLayout.addWidget(unitName)
@ -173,7 +206,9 @@ class QRecruitBehaviour:
def update_available_budget(self) -> None: def update_available_budget(self) -> None:
GameUpdateSignal.get_instance().updateBudget(self.game_model.game) GameUpdateSignal.get_instance().updateBudget(self.game_model.game)
def recruit_handler(self, recruit_type: RecruitType, unit_type: UnitType) -> None: def recruit_handler(
self, recruit_type: RecruitType, item: TransactionItemType
) -> None:
# Lookup if Keyboard Modifiers were pressed # Lookup if Keyboard Modifiers were pressed
# Shift = 10 times # Shift = 10 times
# CTRL = 5 Times # CTRL = 5 Times
@ -185,50 +220,59 @@ class QRecruitBehaviour:
else: else:
amount = 1 amount = 1
for i in range(amount): if recruit_type == RecruitType.SELL:
if recruit_type == RecruitType.SELL: self.sell(item, amount)
if not self.sell(unit_type): elif recruit_type == RecruitType.BUY:
return self.buy(item, amount)
elif recruit_type == RecruitType.BUY:
if not self.buy(unit_type):
return
def buy(self, unit_type: UnitType) -> bool: def post_transaction_update(self) -> None:
if not self.enable_purchase(unit_type):
logging.error(f"Purchase of {unit_type} not allowed at {self.cp.name}")
return False
self.pending_deliveries.order({unit_type: 1})
self.budget -= unit_type.price
self.update_purchase_controls() self.update_purchase_controls()
self.update_available_budget() self.update_available_budget()
def buy(self, item: TransactionItemType, quantity: int) -> bool:
try:
self.purchase_adapter.buy(item, quantity)
except TransactionError as ex:
logging.exception(f"Purchase of {self.display_name_of(item)} failed")
QMessageBox.warning(self, "Purchase failed", str(ex), QMessageBox.Ok)
return False
self.post_transaction_update()
return True return True
def sell(self, unit_type: UnitType) -> bool: def sell(self, item: TransactionItemType, quantity: int) -> bool:
if self.pending_deliveries.available_next_turn(unit_type) > 0: try:
self.budget += unit_type.price self.purchase_adapter.sell(item, quantity)
self.pending_deliveries.sell({unit_type: 1}) except TransactionError as ex:
self.update_purchase_controls() logging.exception(f"Sale of {self.display_name_of(item)} failed")
self.update_available_budget() QMessageBox.warning(self, "Sale failed", str(ex), QMessageBox.Ok)
return False
self.post_transaction_update()
return True return True
def update_purchase_controls(self) -> None: def update_purchase_controls(self) -> None:
for group in self.purchase_groups.values(): for group in self.purchase_groups.values():
group.update_state() group.update_state()
def enable_purchase(self, unit_type: UnitType) -> bool: def enable_purchase(self, item: TransactionItemType) -> bool:
return self.budget >= unit_type.price return self.purchase_adapter.can_buy(item)
def enable_sale(self, unit_type: UnitType) -> bool: def enable_sale(self, item: TransactionItemType) -> bool:
return True return self.purchase_adapter.can_sell_or_cancel(item)
@staticmethod
def purchase_tooltip(is_enabled: bool) -> str:
if is_enabled:
return "Buy unit. Use Shift or Ctrl key to buy multiple units at once."
else:
return "Unit can not be bought."
@staticmethod
def sell_tooltip(is_enabled: bool) -> str:
if is_enabled:
return "Sell unit. Use Shift or Ctrl key to buy multiple units at once."
else:
return "Unit can not be sold."
def info(self, unit_type: UnitType) -> None: def info(self, unit_type: UnitType) -> None:
self.info_window = QUnitInfoWindow(self.game_model.game, unit_type) self.info_window = QUnitInfoWindow(self.game_model.game, unit_type)
self.info_window.show() self.info_window.show()
def set_maximum_units(self, maximum_units):
"""
Set the maximum number of units that can be bought
"""
self.maximum_units = maximum_units

View File

@ -1,38 +1,38 @@
import logging
from typing import Set from typing import Set
from PySide2.QtCore import Qt from PySide2.QtCore import Qt
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QFrame,
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QMessageBox,
QScrollArea, QScrollArea,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from dcs.helicopters import helicopter_map
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.theater import ControlPoint, ControlPointType from game.squadrons import Squadron
from game.theater import ControlPoint
from qt_ui.models import GameModel from qt_ui.models import GameModel
from qt_ui.uiconstants import ICONS from qt_ui.uiconstants import ICONS
from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour from qt_ui.windows.basemenu.UnitTransactionFrame import UnitTransactionFrame
from game.purchaseadapter import AircraftPurchaseAdapter
class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): class QAircraftRecruitmentMenu(UnitTransactionFrame[Squadron]):
def __init__(self, cp: ControlPoint, game_model: GameModel) -> None: def __init__(self, cp: ControlPoint, game_model: GameModel) -> None:
QFrame.__init__(self) super().__init__(
game_model,
AircraftPurchaseAdapter(
cp, game_model.game.coalition_for(cp.captured), game_model.game
),
)
self.cp = cp self.cp = cp
self.game_model = game_model self.game_model = game_model
self.purchase_groups = {} self.purchase_groups = {}
self.bought_amount_labels = {} self.bought_amount_labels = {}
self.existing_units_labels = {} self.existing_units_labels = {}
# Determine maximum number of aircrafts that can be bought
self.set_maximum_units(self.cp.total_aircraft_parking)
self.bought_amount_labels = {} self.bought_amount_labels = {}
self.existing_units_labels = {} self.existing_units_labels = {}
@ -45,24 +45,14 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
row = 0 row = 0
unit_types: Set[AircraftType] = set() unit_types: Set[AircraftType] = set()
for unit_type in self.game_model.game.blue.faction.aircrafts:
if self.cp.is_carrier and not unit_type.carrier_capable:
continue
if self.cp.is_lha and not unit_type.lha_capable:
continue
if (
self.cp.cptype in [ControlPointType.FOB, ControlPointType.FARP]
and not unit_type.helicopter
):
continue
unit_types.add(unit_type)
sorted_units = sorted( for squadron in cp.squadrons:
unit_types, unit_types.add(squadron.aircraft)
key=lambda u: u.name,
) sorted_squadrons = sorted(cp.squadrons, key=lambda s: (s.aircraft.name, s.name))
for row, unit_type in enumerate(sorted_units): for row, squadron in enumerate(sorted_squadrons):
self.add_purchase_row(unit_type, task_box_layout, row) self.add_purchase_row(squadron, task_box_layout, row)
stretch = QVBoxLayout() stretch = QVBoxLayout()
stretch.addStretch() stretch.addStretch()
task_box_layout.addLayout(stretch, row, 0) task_box_layout.addLayout(stretch, row, 0)
@ -77,63 +67,18 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
main_layout.addWidget(scroll) main_layout.addWidget(scroll)
self.setLayout(main_layout) self.setLayout(main_layout)
def enable_purchase(self, unit_type: AircraftType) -> bool: def sell_tooltip(self, is_enabled: bool) -> str:
if not super().enable_purchase(unit_type): if is_enabled:
return False return "Sell unit. Use Shift or Ctrl key to sell multiple units at once."
if not self.cp.can_operate(unit_type): else:
return False return (
return True "Can not be sold because either no aircraft are available or are "
"already assigned to a mission."
)
def enable_sale(self, unit_type: AircraftType) -> bool: def post_transaction_update(self) -> None:
if not self.cp.can_operate(unit_type): super().post_transaction_update()
return False
return True
def buy(self, unit_type: AircraftType) -> bool:
if self.maximum_units > 0:
if self.cp.unclaimed_parking(self.game_model.game) <= 0:
logging.debug(f"No space for additional aircraft at {self.cp}.")
QMessageBox.warning(
self,
"No space for additional aircraft",
f"There is no parking space left at {self.cp.name} to accommodate "
"another plane.",
QMessageBox.Ok,
)
return False
# If we change our mind about selling, we want the aircraft to be put
# back in the inventory immediately.
elif self.pending_deliveries.units.get(unit_type, 0) < 0:
global_inventory = self.game_model.game.aircraft_inventory
inventory = global_inventory.for_control_point(self.cp)
inventory.add_aircraft(unit_type, 1)
super().buy(unit_type)
self.hangar_status.update_label() self.hangar_status.update_label()
return True
def sell(self, unit_type: AircraftType) -> bool:
# Don't need to remove aircraft from the inventory if we're canceling
# orders.
if self.pending_deliveries.units.get(unit_type, 0) <= 0:
global_inventory = self.game_model.game.aircraft_inventory
inventory = global_inventory.for_control_point(self.cp)
try:
inventory.remove_aircraft(unit_type, 1)
except ValueError:
QMessageBox.critical(
self,
"Could not sell aircraft",
f"Attempted to sell one {unit_type} at {self.cp.name} "
"but none are available. Are all aircraft currently "
"assigned to a mission?",
QMessageBox.Ok,
)
return False
super().sell(unit_type)
self.hangar_status.update_label()
return True
class QHangarStatus(QHBoxLayout): class QHangarStatus(QHBoxLayout):

View File

@ -1,30 +1,27 @@
from PySide2.QtCore import Qt from PySide2.QtCore import Qt
from PySide2.QtWidgets import ( from PySide2.QtWidgets import QGridLayout, QScrollArea, QVBoxLayout, QWidget
QFrame,
QGridLayout,
QScrollArea,
QVBoxLayout,
QWidget,
)
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.theater import ControlPoint from game.theater import ControlPoint
from qt_ui.models import GameModel from qt_ui.models import GameModel
from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour from qt_ui.windows.basemenu.UnitTransactionFrame import UnitTransactionFrame
from game.purchaseadapter import GroundUnitPurchaseAdapter
class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): class QArmorRecruitmentMenu(UnitTransactionFrame[GroundUnitType]):
def __init__(self, cp: ControlPoint, game_model: GameModel): def __init__(self, cp: ControlPoint, game_model: GameModel):
QFrame.__init__(self) super().__init__(
game_model,
GroundUnitPurchaseAdapter(
cp, game_model.game.coalition_for(cp.captured), game_model.game
),
)
self.cp = cp self.cp = cp
self.game_model = game_model self.game_model = game_model
self.purchase_groups = {} self.purchase_groups = {}
self.bought_amount_labels = {} self.bought_amount_labels = {}
self.existing_units_labels = {} self.existing_units_labels = {}
self.init_ui()
def init_ui(self):
main_layout = QVBoxLayout() main_layout = QVBoxLayout()
scroll_content = QWidget() scroll_content = QWidget()
@ -50,11 +47,3 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour):
scroll.setWidget(scroll_content) scroll.setWidget(scroll_content)
main_layout.addWidget(scroll) main_layout.addWidget(scroll)
self.setLayout(main_layout) self.setLayout(main_layout)
def enable_purchase(self, unit_type: GroundUnitType) -> bool:
if not super().enable_purchase(unit_type):
return False
return self.cp.has_ground_unit_source(self.game_model.game)
def enable_sale(self, unit_type: GroundUnitType) -> bool:
return self.pending_deliveries.pending_orders(unit_type) > 0

View File

@ -26,7 +26,7 @@ class QIntelInfo(QFrame):
intel_layout = QVBoxLayout() intel_layout = QVBoxLayout()
units_by_task: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) units_by_task: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for unit_type, count in self.cp.base.aircraft.items(): for unit_type, count in self.cp.allocated_aircraft(game).present.items():
if count: if count:
task_type = unit_type.dcs_unit_type.task_default.name task_type = unit_type.dcs_unit_type.task_default.name
units_by_task[task_type][unit_type.name] += count units_by_task[task_type][unit_type.name] += count

View File

@ -77,14 +77,15 @@ class AircraftIntelLayout(IntelTableLayout):
total = 0 total = 0
for control_point in game.theater.control_points_for(player): for control_point in game.theater.control_points_for(player):
base = control_point.base allocation = control_point.allocated_aircraft(game)
total += base.total_aircraft base_total = allocation.total_present
if not base.total_aircraft: total += base_total
if not base_total:
continue continue
self.add_header(f"{control_point.name} ({base.total_aircraft})") self.add_header(f"{control_point.name} ({base_total})")
for airframe in sorted(base.aircraft, key=lambda k: k.name): for airframe in sorted(allocation.present, key=lambda k: k.name):
count = base.aircraft[airframe] count = allocation.present[airframe]
if not count: if not count:
continue continue
self.add_row(f" {airframe.name}", count) self.add_row(f" {airframe.name}", count)

View File

@ -177,7 +177,6 @@ class QPackageDialog(QDialog):
def add_flight(self, flight: Flight) -> None: def add_flight(self, flight: Flight) -> None:
"""Adds the new flight to the package.""" """Adds the new flight to the package."""
self.game.aircraft_inventory.claim_for_flight(flight)
self.package_model.add_flight(flight) self.package_model.add_flight(flight)
planner = FlightPlanBuilder( planner = FlightPlanBuilder(
self.package_model.package, self.game.blue, self.game.theater self.package_model.package, self.game.blue, self.game.theater
@ -251,8 +250,7 @@ class QNewPackageDialog(QPackageDialog):
def on_cancel(self) -> None: def on_cancel(self) -> None:
super().on_cancel() super().on_cancel()
for flight in self.package_model.package.flights: for flight in self.package_model.package.flights:
self.game.aircraft_inventory.return_from_flight(flight) flight.return_pilots_and_aircraft()
flight.clear_roster()
class QEditPackageDialog(QPackageDialog): class QEditPackageDialog(QPackageDialog):

View File

@ -14,7 +14,7 @@ from PySide2.QtWidgets import (
from dcs.unittype import FlyingType from dcs.unittype import FlyingType
from game import Game from game import Game
from game.squadrons import Squadron from game.squadrons.squadron import Squadron
from game.theater import ControlPoint, OffMapSpawn from game.theater import ControlPoint, OffMapSpawn
from gen.ato import Package from gen.ato import Package
from gen.flights.flight import Flight, FlightRoster from gen.flights.flight import Flight, FlightRoster
@ -24,7 +24,6 @@ from qt_ui.widgets.QLabeledWidget import QLabeledWidget
from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector
from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelector from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelector
from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox
from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector
from qt_ui.windows.mission.flight.SquadronSelector import SquadronSelector from qt_ui.windows.mission.flight.SquadronSelector import SquadronSelector
from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import FlightRosterEditor from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import FlightRosterEditor
@ -34,6 +33,7 @@ class QFlightCreator(QDialog):
def __init__(self, game: Game, package: Package, parent=None) -> None: def __init__(self, game: Game, package: Package, parent=None) -> None:
super().__init__(parent=parent) super().__init__(parent=parent)
self.setMinimumWidth(400)
self.game = game self.game = game
self.package = package self.package = package
@ -51,7 +51,7 @@ class QFlightCreator(QDialog):
layout.addLayout(QLabeledWidget("Task:", self.task_selector)) layout.addLayout(QLabeledWidget("Task:", self.task_selector))
self.aircraft_selector = QAircraftTypeSelector( self.aircraft_selector = QAircraftTypeSelector(
self.game.aircraft_inventory.available_types_for_player, self.game.blue.air_wing.available_aircraft_types,
self.task_selector.currentData(), self.task_selector.currentData(),
) )
self.aircraft_selector.setCurrentIndex(0) self.aircraft_selector.setCurrentIndex(0)
@ -66,22 +66,6 @@ class QFlightCreator(QDialog):
self.squadron_selector.setCurrentIndex(0) self.squadron_selector.setCurrentIndex(0)
layout.addLayout(QLabeledWidget("Squadron:", self.squadron_selector)) layout.addLayout(QLabeledWidget("Squadron:", self.squadron_selector))
self.departure = QOriginAirfieldSelector(
self.game.aircraft_inventory,
[cp for cp in game.theater.controlpoints if cp.captured],
self.aircraft_selector.currentData(),
)
self.departure.availability_changed.connect(self.update_max_size)
self.departure.currentIndexChanged.connect(self.on_departure_changed)
layout.addLayout(QLabeledWidget("Departure:", self.departure))
self.arrival = QArrivalAirfieldSelector(
[cp for cp in game.theater.controlpoints if cp.captured],
self.aircraft_selector.currentData(),
"Same as departure",
)
layout.addLayout(QLabeledWidget("Arrival:", self.arrival))
self.divert = QArrivalAirfieldSelector( self.divert = QArrivalAirfieldSelector(
[cp for cp in game.theater.controlpoints if cp.captured], [cp for cp in game.theater.controlpoints if cp.captured],
self.aircraft_selector.currentData(), self.aircraft_selector.currentData(),
@ -90,7 +74,7 @@ class QFlightCreator(QDialog):
layout.addLayout(QLabeledWidget("Divert:", self.divert)) layout.addLayout(QLabeledWidget("Divert:", self.divert))
self.flight_size_spinner = QFlightSizeSpinner() self.flight_size_spinner = QFlightSizeSpinner()
self.update_max_size(self.departure.available) self.update_max_size(self.squadron_selector.aircraft_available)
layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner)) layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner))
squadron = self.squadron_selector.currentData() squadron = self.squadron_selector.currentData()
@ -144,8 +128,6 @@ class QFlightCreator(QDialog):
self.setLayout(layout) self.setLayout(layout)
self.on_departure_changed(self.departure.currentIndex())
def reject(self) -> None: def reject(self) -> None:
super().reject() super().reject()
# Clear the roster to return pilots to the pool. # Clear the roster to return pilots to the pool.
@ -161,25 +143,19 @@ class QFlightCreator(QDialog):
def verify_form(self) -> Optional[str]: def verify_form(self) -> Optional[str]:
aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData() aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData()
squadron: Optional[Squadron] = self.squadron_selector.currentData() squadron: Optional[Squadron] = self.squadron_selector.currentData()
origin: Optional[ControlPoint] = self.departure.currentData()
arrival: Optional[ControlPoint] = self.arrival.currentData()
divert: Optional[ControlPoint] = self.divert.currentData() divert: Optional[ControlPoint] = self.divert.currentData()
size: int = self.flight_size_spinner.value() size: int = self.flight_size_spinner.value()
if aircraft is None: if aircraft is None:
return "You must select an aircraft type." return "You must select an aircraft type."
if squadron is None: if squadron is None:
return "You must select a squadron." return "You must select a squadron."
if not origin.captured:
return f"{origin.name} is not owned by your coalition."
if arrival is not None and not arrival.captured:
return f"{arrival.name} is not owned by your coalition."
if divert is not None and not divert.captured: if divert is not None and not divert.captured:
return f"{divert.name} is not owned by your coalition." return f"{divert.name} is not owned by your coalition."
available = origin.base.aircraft.get(aircraft, 0) available = squadron.untasked_aircraft
if not available: if not available:
return f"{origin.name} has no {aircraft.id} available." return f"{squadron} has no aircraft available."
if size > available: if size > available:
return f"{origin.name} has only {available} {aircraft.id} available." return f"{squadron} has only {available} aircraft available."
if size <= 0: if size <= 0:
return f"Flight must have at least one aircraft." return f"Flight must have at least one aircraft."
if self.custom_name_text and "|" in self.custom_name_text: if self.custom_name_text and "|" in self.custom_name_text:
@ -194,14 +170,9 @@ class QFlightCreator(QDialog):
task = self.task_selector.currentData() task = self.task_selector.currentData()
squadron = self.squadron_selector.currentData() squadron = self.squadron_selector.currentData()
origin = self.departure.currentData()
arrival = self.arrival.currentData()
divert = self.divert.currentData() divert = self.divert.currentData()
roster = self.roster_editor.roster roster = self.roster_editor.roster
if arrival is None:
arrival = origin
flight = Flight( flight = Flight(
self.package, self.package,
self.country, self.country,
@ -211,8 +182,8 @@ class QFlightCreator(QDialog):
roster.max_size, roster.max_size,
task, task,
self.start_type.currentText(), self.start_type.currentText(),
origin, squadron.location,
arrival, squadron.location,
divert, divert,
custom_name=self.custom_name_text, custom_name=self.custom_name_text,
roster=roster, roster=roster,
@ -228,11 +199,9 @@ class QFlightCreator(QDialog):
self.task_selector.currentData(), new_aircraft self.task_selector.currentData(), new_aircraft
) )
self.departure.change_aircraft(new_aircraft) self.departure.change_aircraft(new_aircraft)
self.arrival.change_aircraft(new_aircraft)
self.divert.change_aircraft(new_aircraft) self.divert.change_aircraft(new_aircraft)
def on_departure_changed(self, index: int) -> None: def on_departure_changed(self, departure: ControlPoint) -> None:
departure = self.departure.itemData(index)
if isinstance(departure, OffMapSpawn): if isinstance(departure, OffMapSpawn):
previous_type = self.start_type.currentText() previous_type = self.start_type.currentText()
if previous_type != "In Flight": if previous_type != "In Flight":
@ -248,12 +217,12 @@ class QFlightCreator(QDialog):
def on_task_changed(self, index: int) -> None: def on_task_changed(self, index: int) -> None:
task = self.task_selector.itemData(index) task = self.task_selector.itemData(index)
self.aircraft_selector.update_items( self.aircraft_selector.update_items(
task, self.game.aircraft_inventory.available_types_for_player task, self.game.blue.air_wing.available_aircraft_types
) )
self.squadron_selector.update_items(task, self.aircraft_selector.currentData()) self.squadron_selector.update_items(task, self.aircraft_selector.currentData())
def on_squadron_changed(self, index: int) -> None: def on_squadron_changed(self, index: int) -> None:
squadron = self.squadron_selector.itemData(index) squadron: Optional[Squadron] = self.squadron_selector.itemData(index)
# Clear the roster first so we return the pilots to the pool. This way if we end # Clear the roster first so we return the pilots to the pool. This way if we end
# up repopulating from the same squadron we'll get the same pilots back. # up repopulating from the same squadron we'll get the same pilots back.
self.roster_editor.replace(None) self.roster_editor.replace(None)
@ -261,6 +230,7 @@ class QFlightCreator(QDialog):
self.roster_editor.replace( self.roster_editor.replace(
FlightRoster(squadron, self.flight_size_spinner.value()) FlightRoster(squadron, self.flight_size_spinner.value())
) )
self.on_departure_changed(squadron.location)
def update_max_size(self, available: int) -> None: def update_max_size(self, available: int) -> None:
aircraft = self.aircraft_selector.currentData() aircraft = self.aircraft_selector.currentData()

View File

@ -1,10 +1,10 @@
"""Combo box for selecting squadrons.""" """Combo box for selecting squadrons."""
from typing import Type, Optional from typing import Optional
from PySide2.QtWidgets import QComboBox from PySide2.QtWidgets import QComboBox
from dcs.unittype import FlyingType
from game.squadrons import AirWing from game.dcs.aircrafttype import AircraftType
from game.squadrons.airwing import AirWing
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
@ -15,7 +15,7 @@ class SquadronSelector(QComboBox):
self, self,
air_wing: AirWing, air_wing: AirWing,
task: Optional[FlightType], task: Optional[FlightType],
aircraft: Optional[Type[FlyingType]], aircraft: Optional[AircraftType],
) -> None: ) -> None:
super().__init__() super().__init__()
self.air_wing = air_wing self.air_wing = air_wing
@ -24,8 +24,15 @@ class SquadronSelector(QComboBox):
self.setSizeAdjustPolicy(self.AdjustToContents) self.setSizeAdjustPolicy(self.AdjustToContents)
self.update_items(task, aircraft) self.update_items(task, aircraft)
@property
def aircraft_available(self) -> int:
squadron = self.currentData()
if squadron is None:
return 0
return squadron.untasked_aircraft
def update_items( def update_items(
self, task: Optional[FlightType], aircraft: Optional[Type[FlyingType]] self, task: Optional[FlightType], aircraft: Optional[AircraftType]
) -> None: ) -> None:
current_squadron = self.currentData() current_squadron = self.currentData()
self.blockSignals(True) self.blockSignals(True)
@ -41,12 +48,12 @@ class SquadronSelector(QComboBox):
return return
for squadron in self.air_wing.squadrons_for(aircraft): for squadron in self.air_wing.squadrons_for(aircraft):
if task in squadron.mission_types: if task in squadron.mission_types and squadron.untasked_aircraft:
self.addItem(f"{squadron}", squadron) self.addItem(f"{squadron.location}: {squadron}", squadron)
if self.count() == 0: if self.count() == 0:
self.addItem("No capable aircraft available", None) self.addItem("No capable aircraft available", None)
return return
if current_squadron is not None: if current_squadron is not None:
self.setCurrentText(f"{current_squadron}") self.setCurrentText(f"{current_squadron.location}: {current_squadron}")

View File

@ -14,7 +14,7 @@ from PySide2.QtWidgets import (
) )
from game import Game from game import Game
from game.squadrons import Pilot from game.squadrons.pilot import Pilot
from gen.flights.flight import Flight, FlightRoster from gen.flights.flight import Flight, FlightRoster
from qt_ui.models import PackageModel from qt_ui.models import PackageModel
@ -195,8 +195,7 @@ class QFlightSlotEditor(QGroupBox):
self.package_model = package_model self.package_model = package_model
self.flight = flight self.flight = flight
self.game = game self.game = game
self.inventory = self.game.aircraft_inventory.for_control_point(flight.from_cp) available = self.flight.squadron.untasked_aircraft
available = self.inventory.available(self.flight.unit_type)
max_count = self.flight.count + available max_count = self.flight.count + available
if max_count > 4: if max_count > 4:
max_count = 4 max_count = 4
@ -225,21 +224,18 @@ class QFlightSlotEditor(QGroupBox):
def _changed_aircraft_count(self): def _changed_aircraft_count(self):
old_count = self.flight.count old_count = self.flight.count
new_count = int(self.aircraft_count_spinner.value()) new_count = int(self.aircraft_count_spinner.value())
self.game.aircraft_inventory.return_from_flight(self.flight)
self.flight.resize(new_count)
try: try:
self.game.aircraft_inventory.claim_for_flight(self.flight) self.flight.resize(new_count)
except ValueError: except ValueError:
# The UI should have prevented this, but if we ran out of aircraft # The UI should have prevented this, but if we ran out of aircraft
# then roll back the inventory change. # then roll back the inventory change.
difference = new_count - self.flight.count difference = new_count - self.flight.count
available = self.inventory.available(self.flight.unit_type) available = self.flight.squadron.untasked_aircraft
logging.error( logging.error(
f"Could not add {difference} additional aircraft to " f"Could not add {difference} additional aircraft to "
f"{self.flight} because {self.flight.departure} has only " f"{self.flight} because {self.flight.departure} has only "
f"{available} {self.flight.unit_type} remaining" f"{available} {self.flight.unit_type} remaining"
) )
self.game.aircraft_inventory.claim_for_flight(self.flight)
self.flight.resize(old_count) self.flight.resize(old_count)
return return
self.roster_editor.resize(new_count) self.roster_editor.resize(new_count)

View File

@ -1,116 +1,14 @@
from __future__ import annotations from __future__ import annotations
import json from typing import Optional
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Union, Tuple
import packaging.version
from PySide2 import QtGui from PySide2 import QtGui
from PySide2.QtCore import QItemSelectionModel, QModelIndex, Qt from PySide2.QtCore import QItemSelectionModel, QModelIndex, Qt
from PySide2.QtGui import QStandardItem, QStandardItemModel from PySide2.QtGui import QStandardItem, QStandardItemModel
from PySide2.QtWidgets import QAbstractItemView, QListView from PySide2.QtWidgets import QAbstractItemView, QListView
import qt_ui.uiconstants as CONST import qt_ui.uiconstants as CONST
from game.theater import ConflictTheater from game.campaignloader.campaign import Campaign
from game.version import CAMPAIGN_FORMAT_VERSION
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: Union[PERF_FRIENDLY, PERF_MEDIUM, PERF_HARD, PERF_NASA]
data: Dict[str, Any]
path: Path
@classmethod
def from_json(cls, path: Path) -> Campaign:
with path.open() as campaign_file:
data = json.load(campaign_file)
sanitized_theater = data["theater"].replace(" ", "")
version_field = data.get("version", "0")
try:
version = packaging.version.parse(version_field)
except TypeError:
logging.warning(
f"Non-string campaign version in {path}. Parse may be incorrect."
)
version = packaging.version.parse(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:
return ConflictTheater.from_json(self.path.parent, self.data)
@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 not self.version:
return False
if self.is_out_of_date:
return False
if self.is_from_future:
return False
return True
def load_campaigns() -> List[Campaign]:
campaign_dir = Path("resources\\campaigns")
campaigns = []
for path in campaign_dir.glob("*.json"):
try:
logging.debug(f"Loading campaign from {path}...")
campaign = Campaign.from_json(path)
campaigns.append(campaign)
except RuntimeError:
logging.exception(f"Unable to load campaign from {path}")
return sorted(campaigns, key=lambda x: x.name)
class QCampaignItem(QStandardItem): class QCampaignItem(QStandardItem):
@ -140,7 +38,7 @@ class QCampaignList(QListView):
self.setup_content(show_incompatible) self.setup_content(show_incompatible)
@property @property
def selected_campaign(self) -> Campaign: def selected_campaign(self) -> Optional[Campaign]:
return self.currentIndex().data(QCampaignList.CampaignRole) return self.currentIndex().data(QCampaignList.CampaignRole)
def setup_content(self, show_incompatible: bool) -> None: def setup_content(self, show_incompatible: bool) -> None:

View File

@ -10,17 +10,14 @@ from PySide2.QtWidgets import QVBoxLayout, QTextEdit, QLabel, QCheckBox
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from game import db from game import db
from game.campaignloader.campaign import Campaign
from game.settings import Settings from game.settings import Settings
from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings
from game.factions.faction import Faction from game.factions.faction import Faction
from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar
from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner
from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog
from qt_ui.windows.newgame.QCampaignList import ( from qt_ui.windows.newgame.QCampaignList import QCampaignList
Campaign,
QCampaignList,
load_campaigns,
)
jinja_env = Environment( jinja_env = Environment(
loader=FileSystemLoader("resources/ui/templates"), loader=FileSystemLoader("resources/ui/templates"),
@ -41,7 +38,7 @@ class NewGameWizard(QtWidgets.QWizard):
def __init__(self, parent=None): def __init__(self, parent=None):
super(NewGameWizard, self).__init__(parent) super(NewGameWizard, self).__init__(parent)
self.campaigns = load_campaigns() self.campaigns = list(sorted(Campaign.load_each(), key=lambda x: x.name))
self.faction_selection_page = FactionSelection() self.faction_selection_page = FactionSelection()
self.addPage(IntroPage()) self.addPage(IntroPage())
@ -116,10 +113,12 @@ class NewGameWizard(QtWidgets.QWizard):
blue_faction = self.faction_selection_page.selected_blue_faction blue_faction = self.faction_selection_page.selected_blue_faction
red_faction = self.faction_selection_page.selected_red_faction red_faction = self.faction_selection_page.selected_red_faction
theater = campaign.load_theater()
generator = GameGenerator( generator = GameGenerator(
blue_faction, blue_faction,
red_faction, red_faction,
campaign.load_theater(), theater,
campaign.load_air_wing_config(theater),
settings, settings,
generator_settings, generator_settings,
mod_settings, mod_settings,
@ -369,6 +368,11 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
) )
campaign = campaignList.selected_campaign campaign = campaignList.selected_campaign
self.setField("selectedCampaign", campaign) self.setField("selectedCampaign", campaign)
if campaign is None:
self.campaignMapDescription.setText("No campaign selected")
self.performanceText.setText("No campaign selected")
return
self.campaignMapDescription.setText(template.render({"campaign": campaign})) self.campaignMapDescription.setText(template.render({"campaign": campaign}))
self.faction_selection.setDefaultFactions(campaign) self.faction_selection.setDefaultFactions(campaign)
self.performanceText.setText( self.performanceText.setText(

View File

@ -230,7 +230,7 @@ class PilotSettingsBox(QGroupBox):
) )
enable_squadron_pilot_limits_label = QLabel( enable_squadron_pilot_limits_label = QLabel(
"Enable per-squadron pilot limtits (WIP)" "Enable per-squadron pilot limits (WIP)"
) )
enable_squadron_pilot_limits_label.setToolTip(enable_squadron_pilot_limits_info) enable_squadron_pilot_limits_label.setToolTip(enable_squadron_pilot_limits_info)
enable_squadron_pilot_limits = QCheckBox() enable_squadron_pilot_limits = QCheckBox()
@ -517,6 +517,18 @@ class QSettingsWindow(QDialog):
self.ext_views.setChecked(self.game.settings.external_views_allowed) self.ext_views.setChecked(self.game.settings.external_views_allowed)
self.ext_views.toggled.connect(self.applySettings) self.ext_views.toggled.connect(self.applySettings)
self.battleDamageAssessment = QComboBox()
self.battleDamageAssessment.addItem("Player preference", None)
self.battleDamageAssessment.addItem("Enforced on", True)
self.battleDamageAssessment.addItem("Enforced off", False)
if self.game.settings.battle_damage_assessment is None:
self.battleDamageAssessment.setCurrentIndex(0)
elif self.game.settings.battle_damage_assessment is True:
self.battleDamageAssessment.setCurrentIndex(1)
else:
self.battleDamageAssessment.setCurrentIndex(2)
self.battleDamageAssessment.currentIndexChanged.connect(self.applySettings)
def set_invulnerable_player_pilots(checked: bool) -> None: def set_invulnerable_player_pilots(checked: bool) -> None:
self.game.settings.invulnerable_player_pilots = checked self.game.settings.invulnerable_player_pilots = checked
@ -568,6 +580,14 @@ class QSettingsWindow(QDialog):
) )
self.missionRestrictionsLayout.addWidget(QLabel("Allow external views"), 2, 0) self.missionRestrictionsLayout.addWidget(QLabel("Allow external views"), 2, 0)
self.missionRestrictionsLayout.addWidget(self.ext_views, 2, 1, Qt.AlignRight) self.missionRestrictionsLayout.addWidget(self.ext_views, 2, 1, Qt.AlignRight)
self.missionRestrictionsLayout.addWidget(
QLabel("Battle damage assessment"), 3, 0
)
self.missionRestrictionsLayout.addWidget(
self.battleDamageAssessment, 3, 1, Qt.AlignRight
)
self.missionRestrictionsSettings.setLayout(self.missionRestrictionsLayout) self.missionRestrictionsSettings.setLayout(self.missionRestrictionsLayout)
self.difficultyLayout.addWidget(self.missionRestrictionsSettings) self.difficultyLayout.addWidget(self.missionRestrictionsSettings)
@ -909,6 +929,9 @@ class QSettingsWindow(QDialog):
self.mapVisibiitySelection.currentData() self.mapVisibiitySelection.currentData()
) )
self.game.settings.external_views_allowed = self.ext_views.isChecked() self.game.settings.external_views_allowed = self.ext_views.isChecked()
self.game.settings.battle_damage_assessment = (
self.battleDamageAssessment.currentData()
)
self.game.settings.generate_marks = self.generate_marks.isChecked() self.game.settings.generate_marks = self.generate_marks.isChecked()
self.game.settings.never_delay_player_flights = ( self.game.settings.never_delay_player_flights = (
self.never_delay_players.isChecked() self.never_delay_players.isChecked()

View File

@ -1,11 +0,0 @@
{
"name": "Syria - Operation Atilla",
"theater": "Syria",
"authors": "Malakhit",
"recommended_player_faction": "Turkey 2005",
"recommended_enemy_faction": "Greece 2005",
"description": "<p>This is based on the Turkish invasion of Cyprus, and the Greek defense of the island. You must make sure to keep your beachhead at all costs, otherwise reclaiming it will be tricky. It is recommended to reduce the per-turn income rate for both factions in this scenario due to the large oil depots both sides have - setting it to around 15-20% is probably reasonable.</p>",
"version": "7.0",
"miz": "Operation_Atilla.miz",
"performance": 2
}

View File

@ -1,11 +0,0 @@
{
"name": "Syria - Russian Intervention 2015",
"theater": "Syria",
"authors": "Malakhit",
"recommended_player_faction": "Russia 2010",
"recommended_enemy_faction": "Insurgents (Hard)",
"description": "<p>This short campaign is loosely based on the 2015 Russian military intervention in Syria. It should be perfect for COIN operations, especially with the Hind. Not designed for campaign inversion.</p>",
"version": "7.0",
"miz": "Russian_Intervention_2015.miz",
"performance": 1
}

View File

@ -1,11 +0,0 @@
{
"name": "Persian Gulf - Battle of Abu Dhabi",
"theater": "Persian Gulf",
"authors": "Colonel Panic",
"recommended_player_faction": "Iran 2015",
"recommended_enemy_faction": "United Arab Emirates 2015",
"description": "<p>You have managed to establish a foothold near Ras Al Khaima. Continue pushing south.</p>",
"miz": "battle_of_abu_dhabi.miz",
"performance": 2,
"version": "8.0"
}

View File

@ -0,0 +1,10 @@
---
name: Persian Gulf - Battle of Abu Dhabi
theater: Persian Gulf
authors: Colonel Panic
recommended_player_faction: Iran 2015
recommended_enemy_faction: United Arab Emirates 2015
description: <p>You have managed to establish a foothold near Ras Al Khaima. Continue pushing south.</p>
miz: battle_of_abu_dhabi.miz
performance: 2
version": "8.0"

View File

@ -1,9 +0,0 @@
{
"name": "Caucasus - Black Sea",
"theater": "Caucasus",
"authors": "Colonel Panic",
"description": "<p>A medium sized theater with bases along the coast of the Black Sea.</p>",
"miz": "black_sea.miz",
"performance": 2,
"version": "8.0"
}

Binary file not shown.

View File

@ -0,0 +1,151 @@
---
name: Caucasus - Black Sea
theater: Caucasus
authors: Colonel Panic
description: <p>A medium sized theater with bases along the coast of the Black Sea.</p>
miz: black_sea.miz
performance: 2,
version: "9.0"
squadrons:
# Anapa-Vityazevo
12:
- primary: BARCAP
aircraft:
- Su-30 Flanker-C
- Su-27 Flanker-B
- primary: AEW&C
aircraft:
- A-50
- primary: Refueling
aircraft:
- IL-78M
- primary: Transport
aircraft:
- IL-78MD
- primary: Strike
secondary: air-to-ground
aircraft:
- Tu-160 Blackjack
# Krasnodar-Center
13:
- primary: BARCAP
secondary: air-to-air
aircraft:
- MiG-31 Foxhound
- MiG-25PD Foxbat-E
- primary: SEAD
secondary: any
- primary: DEAD
secondary: any
# Maykop-Khanskaya
16:
- primary: CAS
secondary: air-to-ground
aircraft:
- Su-25 Frogfoot
- primary: BAI
secondary: air-to-ground
aircraft:
- Su-34 Fullback
- Su-24M Fencer-D
- primary: DEAD
secondary: air-to-ground
- primary: CAS
secondary: air-to-ground
aircraft:
- Mi-24P Hind-F
- Mi-24P Hind-E
# Senaki-Kholki
23:
- primary: CAS
secondary: air-to-ground
aircraft:
- A-10C Thunderbolt II (Suite 7)
- A-10C Thunderbolt II (Suite 3)
- primary: BAI
secondary: air-to-ground
aircraft:
- F-15E Strike Eagle
- primary: DEAD
secondary: air-to-ground
aircraft:
- F-16CM Fighting Falcon (Block 50)
- primary: Transport
aircraft:
- UH-60A
# Kobuleti
24:
- primary: BARCAP
secondary: air-to-air
aircraft:
- F-15C Eagle
- primary: SEAD
secondary: any
aircraft:
- F-16CM Fighting Falcon (Block 50)
- primary: DEAD
secondary: any
aircraft:
- F-16CM Fighting Falcon (Block 50)
# Kutaisi
25:
- primary: BARCAP
aircraft:
- F-15C Eagle
- primary: AEW&C
aircraft:
- E-3A
- primary: Refueling
aircraft:
- KC-135 Stratotanker
- primary: Transport
aircraft:
- C-17A
- primary: Strike
secondary: air-to-ground
aircraft:
- B-1B Lancer
Blue CV:
- primary: BARCAP
secondary: air-to-air
aircraft:
- F-14B Tomcat
- primary: BARCAP
secondary: any
aircraft:
- F-14B Tomcat
- primary: Strike
secondary: any
aircraft:
- F/A-18C Hornet (Lot 20)
- primary: BAI
secondary: any
aircraft:
- F/A-18C Hornet (Lot 20)
- primary: Refueling
aircraft:
- S-3B Tanker
Blue LHA:
- primary: BAI
secondary: air-to-ground
aircraft:
- AV-8B Harrier II Night Attack
- primary: CAS
secondary: air-to-ground
aircraft:
- UH-1H Iroquois
Red CV:
- primary: BARCAP
secondary: air-to-air
- primary: BARCAP
secondary: any
- primary: Strike
secondary: any
- primary: BAI
secondary: any
- primary: Refueling
Red LHA:
- primary: BAI
secondary: air-to-ground
- primary: CAS
secondary: air-to-ground

View File

@ -1,11 +0,0 @@
{
"name": "Mariana Islands - Battle for Guam",
"theater": "MarianaIslands",
"authors": "Khopa",
"recommended_player_faction": "USA 2005",
"recommended_enemy_faction": "China 2010",
"description": "<p>As USA, repel a Chinese invasion of Guam Island.</p>",
"miz": "guam.miz",
"performance": 1,
"version": "7.0"
}

Binary file not shown.

View File

@ -7,5 +7,5 @@
"description": "<p>In this scenario, you start in Israel in an high intensity conflict with Syria, backed by a Russian Expeditiary Force. Your goal is to take the heavily fortified city of Damascus, as fast as you can. The longer you wait, the more resources the enemy can pump into the defense of the city or even might try to take chunks of Israel. ATTENTION: CAMPAIGN INVERTING IS NOT YET IMPLEMENTED!!! Feedback: @Headiii in the DCSLiberation Discord</p>", "description": "<p>In this scenario, you start in Israel in an high intensity conflict with Syria, backed by a Russian Expeditiary Force. Your goal is to take the heavily fortified city of Damascus, as fast as you can. The longer you wait, the more resources the enemy can pump into the defense of the city or even might try to take chunks of Israel. ATTENTION: CAMPAIGN INVERTING IS NOT YET IMPLEMENTED!!! Feedback: @Headiii in the DCSLiberation Discord</p>",
"miz": "humble_helper.miz", "miz": "humble_helper.miz",
"performance": 1, "performance": 1,
"version": "7.0" "version": "8.0"
} }

Some files were not shown because too many files have changed in this diff Show More