mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
Merge branch 'develop' into helipads
This commit is contained in:
commit
707d13a65c
12
.github/ISSUE_TEMPLATE/mod_support.md
vendored
Normal file
12
.github/ISSUE_TEMPLATE/mod_support.md
vendored
Normal 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?
|
||||
25
changelog.md
25
changelog.md
@ -4,20 +4,40 @@ Saves from 4.x are not compatible with 5.0.
|
||||
|
||||
## 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]** 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]** (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]** 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]** 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.
|
||||
* **[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).
|
||||
* **[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
|
||||
|
||||
* **[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
|
||||
|
||||
@ -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.
|
||||
* **[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 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]** 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.
|
||||
@ -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]** 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
|
||||
* **[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.
|
||||
* **[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
|
||||
@ -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]** 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 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.
|
||||
* **[UI]** Statistics window tick marks are now always integers.
|
||||
* **[UI]** Statistics window now shows the correct info for the turn
|
||||
|
||||
2
game/campaignloader/__init__.py
Normal file
2
game/campaignloader/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .campaign import Campaign
|
||||
from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig
|
||||
157
game/campaignloader/campaign.py
Normal file
157
game/campaignloader/campaign.py
Normal file
@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Dict, Any
|
||||
|
||||
from packaging.version import Version
|
||||
import yaml
|
||||
|
||||
from game.profiling import logged_duration
|
||||
from game.theater import (
|
||||
ConflictTheater,
|
||||
CaucasusTheater,
|
||||
NevadaTheater,
|
||||
PersianGulfTheater,
|
||||
NormandyTheater,
|
||||
TheChannelTheater,
|
||||
SyriaTheater,
|
||||
MarianaIslandsTheater,
|
||||
)
|
||||
from game.version import CAMPAIGN_FORMAT_VERSION
|
||||
from .campaignairwingconfig import CampaignAirWingConfig
|
||||
from .mizcampaignloader import MizCampaignLoader
|
||||
|
||||
|
||||
PERF_FRIENDLY = 0
|
||||
PERF_MEDIUM = 1
|
||||
PERF_HARD = 2
|
||||
PERF_NASA = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Campaign:
|
||||
name: str
|
||||
icon_name: str
|
||||
authors: str
|
||||
description: str
|
||||
|
||||
#: The revision of the campaign format the campaign was built for. We do not attempt
|
||||
#: to migrate old campaigns, but this is used to show a warning in the UI when
|
||||
#: selecting a campaign that is not up to date.
|
||||
version: Tuple[int, int]
|
||||
|
||||
recommended_player_faction: str
|
||||
recommended_enemy_faction: str
|
||||
performance: int
|
||||
data: Dict[str, Any]
|
||||
path: Path
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> Campaign:
|
||||
with path.open() as campaign_file:
|
||||
if path.suffix == ".yaml":
|
||||
data = yaml.safe_load(campaign_file)
|
||||
else:
|
||||
data = json.load(campaign_file)
|
||||
|
||||
sanitized_theater = data["theater"].replace(" ", "")
|
||||
version_field = data.get("version", "0")
|
||||
try:
|
||||
version = Version(version_field)
|
||||
except TypeError:
|
||||
logging.warning(
|
||||
f"Non-string campaign version in {path}. Parse may be incorrect."
|
||||
)
|
||||
version = Version(str(version_field))
|
||||
return cls(
|
||||
data["name"],
|
||||
f"Terrain_{sanitized_theater}",
|
||||
data.get("authors", "???"),
|
||||
data.get("description", ""),
|
||||
(version.major, version.minor),
|
||||
data.get("recommended_player_faction", "USA 2005"),
|
||||
data.get("recommended_enemy_faction", "Russia 1990"),
|
||||
data.get("performance", 0),
|
||||
data,
|
||||
path,
|
||||
)
|
||||
|
||||
def load_theater(self) -> ConflictTheater:
|
||||
theaters = {
|
||||
"Caucasus": CaucasusTheater,
|
||||
"Nevada": NevadaTheater,
|
||||
"Persian Gulf": PersianGulfTheater,
|
||||
"Normandy": NormandyTheater,
|
||||
"The Channel": TheChannelTheater,
|
||||
"Syria": SyriaTheater,
|
||||
"MarianaIslands": MarianaIslandsTheater,
|
||||
}
|
||||
theater = theaters[self.data["theater"]]
|
||||
t = theater()
|
||||
|
||||
try:
|
||||
miz = self.data["miz"]
|
||||
except KeyError as ex:
|
||||
raise RuntimeError(
|
||||
"Old format (non-miz) campaigns are no longer supported."
|
||||
) from ex
|
||||
|
||||
with logged_duration("Importing miz data"):
|
||||
MizCampaignLoader(self.path.parent / miz, t).populate_theater()
|
||||
return t
|
||||
|
||||
def load_air_wing_config(self, theater: ConflictTheater) -> CampaignAirWingConfig:
|
||||
try:
|
||||
squadron_data = self.data["squadrons"]
|
||||
except KeyError:
|
||||
logging.warning(f"Campaign {self.name} does not define any squadrons")
|
||||
return CampaignAirWingConfig({})
|
||||
return CampaignAirWingConfig.from_campaign_data(squadron_data, theater)
|
||||
|
||||
@property
|
||||
def is_out_of_date(self) -> bool:
|
||||
"""Returns True if this campaign is not up to date with the latest format.
|
||||
|
||||
This is more permissive than is_from_future, which is sensitive to minor version
|
||||
bumps (the old game definitely doesn't support the minor features added in the
|
||||
new version, and the campaign may require them. However, the minor version only
|
||||
indicates *optional* new features, so we do not need to mark out of date
|
||||
campaigns as incompatible if they are within the same major version.
|
||||
"""
|
||||
return self.version[0] < CAMPAIGN_FORMAT_VERSION[0]
|
||||
|
||||
@property
|
||||
def is_from_future(self) -> bool:
|
||||
"""Returns True if this campaign is newer than the supported format."""
|
||||
return self.version > CAMPAIGN_FORMAT_VERSION
|
||||
|
||||
@property
|
||||
def is_compatible(self) -> bool:
|
||||
"""Returns True is this campaign was built for this version of the game."""
|
||||
if self.version == (0, 0):
|
||||
return False
|
||||
if self.is_out_of_date:
|
||||
return False
|
||||
if self.is_from_future:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def iter_campaign_defs() -> Iterator[Path]:
|
||||
campaign_dir = Path("resources/campaigns")
|
||||
yield from campaign_dir.glob("*.json")
|
||||
yield from campaign_dir.glob("*.yaml")
|
||||
|
||||
@classmethod
|
||||
def load_each(cls) -> Iterator[Campaign]:
|
||||
for path in cls.iter_campaign_defs():
|
||||
try:
|
||||
logging.debug(f"Loading campaign from {path}...")
|
||||
campaign = Campaign.from_file(path)
|
||||
yield campaign
|
||||
except RuntimeError:
|
||||
logging.exception(f"Unable to load campaign from {path}")
|
||||
68
game/campaignloader/campaignairwingconfig.py
Normal file
68
game/campaignloader/campaignairwingconfig.py
Normal file
@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TYPE_CHECKING, Union
|
||||
|
||||
from gen.flights.flight import FlightType
|
||||
from game.theater.controlpoint import ControlPoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater import ConflictTheater
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SquadronConfig:
|
||||
primary: FlightType
|
||||
secondary: list[FlightType]
|
||||
aircraft: list[str]
|
||||
|
||||
@property
|
||||
def auto_assignable(self) -> set[FlightType]:
|
||||
return set(self.secondary) | {self.primary}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: dict[str, Any]) -> SquadronConfig:
|
||||
secondary_raw = data.get("secondary")
|
||||
if secondary_raw is None:
|
||||
secondary = []
|
||||
elif isinstance(secondary_raw, str):
|
||||
secondary = cls.expand_secondary_alias(secondary_raw)
|
||||
else:
|
||||
secondary = [FlightType(s) for s in secondary_raw]
|
||||
|
||||
return SquadronConfig(
|
||||
FlightType(data["primary"]), secondary, data.get("aircraft", [])
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def expand_secondary_alias(alias: str) -> list[FlightType]:
|
||||
if alias == "any":
|
||||
return list(FlightType)
|
||||
elif alias == "air-to-air":
|
||||
return [t for t in FlightType if t.is_air_to_air]
|
||||
elif alias == "air-to-ground":
|
||||
return [t for t in FlightType if t.is_air_to_ground]
|
||||
raise KeyError(f"Unknown secondary mission type: {alias}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CampaignAirWingConfig:
|
||||
by_location: dict[ControlPoint, list[SquadronConfig]]
|
||||
|
||||
@classmethod
|
||||
def from_campaign_data(
|
||||
cls, data: dict[Union[str, int], Any], theater: ConflictTheater
|
||||
) -> CampaignAirWingConfig:
|
||||
by_location: dict[ControlPoint, list[SquadronConfig]] = defaultdict(list)
|
||||
for base_id, squadron_configs in data.items():
|
||||
if isinstance(base_id, int):
|
||||
base = theater.find_control_point_by_id(base_id)
|
||||
else:
|
||||
base = theater.control_point_named(base_id)
|
||||
|
||||
for squadron_data in squadron_configs:
|
||||
by_location[base].append(SquadronConfig.from_data(squadron_data))
|
||||
|
||||
return CampaignAirWingConfig(by_location)
|
||||
142
game/campaignloader/defaultsquadronassigner.py
Normal file
142
game/campaignloader/defaultsquadronassigner.py
Normal file
@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from game.squadrons import Squadron
|
||||
from game.squadrons.squadrondef import SquadronDef
|
||||
from game.squadrons.squadrondefloader import SquadronDefLoader
|
||||
from gen.flights.flight import FlightType
|
||||
from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig
|
||||
from .squadrondefgenerator import SquadronDefGenerator
|
||||
from ..dcs.aircrafttype import AircraftType
|
||||
from ..theater import ControlPoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class DefaultSquadronAssigner:
|
||||
def __init__(
|
||||
self, config: CampaignAirWingConfig, game: Game, coalition: Coalition
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.game = game
|
||||
self.coalition = coalition
|
||||
self.air_wing = coalition.air_wing
|
||||
self.squadron_defs = SquadronDefLoader(game, coalition).load()
|
||||
self.squadron_def_generator = SquadronDefGenerator(self.coalition)
|
||||
|
||||
def claim_squadron_def(self, squadron: SquadronDef) -> None:
|
||||
try:
|
||||
self.squadron_defs[squadron.aircraft].remove(squadron)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def assign(self) -> None:
|
||||
for control_point, squadron_configs in self.config.by_location.items():
|
||||
if not control_point.is_friendly(self.coalition.player):
|
||||
continue
|
||||
for squadron_config in squadron_configs:
|
||||
squadron_def = self.find_squadron_for(squadron_config, control_point)
|
||||
if squadron_def is None:
|
||||
logging.info(
|
||||
f"{self.coalition.faction.name} has no aircraft compatible "
|
||||
f"with {squadron_config.primary} at {control_point}"
|
||||
)
|
||||
continue
|
||||
|
||||
self.claim_squadron_def(squadron_def)
|
||||
squadron = Squadron.create_from(
|
||||
squadron_def, control_point, self.coalition, self.game
|
||||
)
|
||||
squadron.set_auto_assignable_mission_types(
|
||||
squadron_config.auto_assignable
|
||||
)
|
||||
self.air_wing.add_squadron(squadron)
|
||||
|
||||
def find_squadron_for(
|
||||
self, config: SquadronConfig, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
for preferred_aircraft in config.aircraft:
|
||||
squadron_def = self.find_preferred_squadron(
|
||||
preferred_aircraft, config.primary, control_point
|
||||
)
|
||||
if squadron_def is not None:
|
||||
return squadron_def
|
||||
|
||||
# If we didn't find any of the preferred types we should use any squadron
|
||||
# compatible with the primary task.
|
||||
squadron_def = self.find_squadron_for_task(config.primary, control_point)
|
||||
if squadron_def is not None:
|
||||
return squadron_def
|
||||
|
||||
# If we can't find any squadron matching the requirement, we should
|
||||
# create one.
|
||||
return self.squadron_def_generator.generate_for_task(
|
||||
config.primary, control_point
|
||||
)
|
||||
|
||||
def find_preferred_squadron(
|
||||
self, preferred_aircraft: str, task: FlightType, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
# Attempt to find a squadron with the name in the request.
|
||||
squadron_def = self.find_squadron_by_name(
|
||||
preferred_aircraft, task, control_point
|
||||
)
|
||||
if squadron_def is not None:
|
||||
return squadron_def
|
||||
|
||||
# If the name didn't match a squadron available to this coalition, try to find
|
||||
# an aircraft with the matching name that meets the requirements.
|
||||
try:
|
||||
aircraft = AircraftType.named(preferred_aircraft)
|
||||
except KeyError:
|
||||
# No aircraft with this name.
|
||||
return None
|
||||
|
||||
if aircraft not in self.coalition.faction.aircrafts:
|
||||
return None
|
||||
|
||||
squadron_def = self.find_squadron_for_airframe(aircraft, task, control_point)
|
||||
if squadron_def is not None:
|
||||
return squadron_def
|
||||
|
||||
# No premade squadron available for this aircraft that meets the requirements,
|
||||
# so generate one if possible.
|
||||
return self.squadron_def_generator.generate_for_aircraft(aircraft)
|
||||
|
||||
@staticmethod
|
||||
def squadron_compatible_with(
|
||||
squadron: SquadronDef, task: FlightType, control_point: ControlPoint
|
||||
) -> bool:
|
||||
return squadron.operates_from(control_point) and task in squadron.mission_types
|
||||
|
||||
def find_squadron_for_airframe(
|
||||
self, aircraft: AircraftType, task: FlightType, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
for squadron in self.squadron_defs[aircraft]:
|
||||
if self.squadron_compatible_with(squadron, task, control_point):
|
||||
return squadron
|
||||
return None
|
||||
|
||||
def find_squadron_by_name(
|
||||
self, name: str, task: FlightType, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
for squadrons in self.squadron_defs.values():
|
||||
for squadron in squadrons:
|
||||
if squadron.name == name and self.squadron_compatible_with(
|
||||
squadron, task, control_point
|
||||
):
|
||||
return squadron
|
||||
return None
|
||||
|
||||
def find_squadron_for_task(
|
||||
self, task: FlightType, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
for squadrons in self.squadron_defs.values():
|
||||
for squadron in squadrons:
|
||||
if self.squadron_compatible_with(squadron, task, control_point):
|
||||
return squadron
|
||||
return None
|
||||
466
game/campaignloader/mizcampaignloader.py
Normal file
466
game/campaignloader/mizcampaignloader.py
Normal file
@ -0,0 +1,466 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import Iterator, List, Dict, Tuple, TYPE_CHECKING
|
||||
|
||||
from dcs import Mission
|
||||
from dcs.countries import CombinedJointTaskForcesBlue, CombinedJointTaskForcesRed
|
||||
from dcs.country import Country
|
||||
from dcs.planes import F_15C
|
||||
from dcs.ships import Stennis, LHA_Tarawa, HandyWind, USS_Arleigh_Burke_IIa
|
||||
from dcs.statics import Fortification, Warehouse
|
||||
from dcs.terrain import Airport
|
||||
from dcs.unitgroup import PlaneGroup, ShipGroup, VehicleGroup, StaticGroup
|
||||
from dcs.vehicles import Armor, Unarmed, MissilesSS, AirDefence
|
||||
|
||||
from game.point_with_heading import PointWithHeading
|
||||
from game.positioned import Positioned
|
||||
from game.profiling import logged_duration
|
||||
from game.scenery_group import SceneryGroup
|
||||
from game.utils import Distance, meters, Heading
|
||||
from game.theater.controlpoint import (
|
||||
Airfield,
|
||||
Carrier,
|
||||
ControlPoint,
|
||||
Fob,
|
||||
Lha,
|
||||
OffMapSpawn,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater.conflicttheater import ConflictTheater
|
||||
|
||||
|
||||
class MizCampaignLoader:
|
||||
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
|
||||
RED_COUNTRY = CombinedJointTaskForcesRed()
|
||||
|
||||
OFF_MAP_UNIT_TYPE = F_15C.id
|
||||
|
||||
CV_UNIT_TYPE = Stennis.id
|
||||
LHA_UNIT_TYPE = LHA_Tarawa.id
|
||||
FRONT_LINE_UNIT_TYPE = Armor.M_113.id
|
||||
SHIPPING_LANE_UNIT_TYPE = HandyWind.id
|
||||
|
||||
FOB_UNIT_TYPE = Unarmed.SKP_11.id
|
||||
FARP_HELIPAD = "SINGLE_HELIPAD"
|
||||
|
||||
OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id
|
||||
SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id
|
||||
MISSILE_SITE_UNIT_TYPE = MissilesSS.Scud_B.id
|
||||
COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.Hy_launcher.id
|
||||
|
||||
# Multiple options for air defenses so campaign designers can more accurately see
|
||||
# the coverage of their IADS for the expected type.
|
||||
LONG_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.Patriot_ln.id,
|
||||
AirDefence.S_300PS_5P85C_ln.id,
|
||||
AirDefence.S_300PS_5P85D_ln.id,
|
||||
}
|
||||
|
||||
MEDIUM_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.Hawk_ln.id,
|
||||
AirDefence.S_75M_Volhov.id,
|
||||
AirDefence._5p73_s_125_ln.id,
|
||||
}
|
||||
|
||||
SHORT_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.M1097_Avenger.id,
|
||||
AirDefence.Rapier_fsa_launcher.id,
|
||||
AirDefence._2S6_Tunguska.id,
|
||||
AirDefence.Strela_1_9P31.id,
|
||||
}
|
||||
|
||||
AAA_UNIT_TYPES = {
|
||||
AirDefence.Flak18.id,
|
||||
AirDefence.Vulcan.id,
|
||||
AirDefence.ZSU_23_4_Shilka.id,
|
||||
}
|
||||
|
||||
EWR_UNIT_TYPE = AirDefence._1L13_EWR.id
|
||||
|
||||
ARMOR_GROUP_UNIT_TYPE = Armor.M_1_Abrams.id
|
||||
|
||||
FACTORY_UNIT_TYPE = Fortification.Workshop_A.id
|
||||
|
||||
AMMUNITION_DEPOT_UNIT_TYPE = Warehouse._Ammunition_depot.id
|
||||
|
||||
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
|
||||
|
||||
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
|
||||
self.theater = theater
|
||||
self.mission = Mission()
|
||||
with logged_duration("Loading miz"):
|
||||
self.mission.load_file(str(miz))
|
||||
self.control_point_id = itertools.count(1000)
|
||||
|
||||
# If there are no red carriers there usually aren't red units. Make sure
|
||||
# both countries are initialized so we don't have to deal with None.
|
||||
if self.mission.country(self.BLUE_COUNTRY.name) is None:
|
||||
self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY)
|
||||
if self.mission.country(self.RED_COUNTRY.name) is None:
|
||||
self.mission.coalition["red"].add_country(self.RED_COUNTRY)
|
||||
|
||||
@staticmethod
|
||||
def control_point_from_airport(airport: Airport) -> ControlPoint:
|
||||
cp = Airfield(airport)
|
||||
cp.captured = airport.is_blue()
|
||||
|
||||
# Use the unlimited aircraft option to determine if an airfield should
|
||||
# be owned by the player when the campaign is "inverted".
|
||||
cp.captured_invert = airport.unlimited_aircrafts
|
||||
|
||||
return cp
|
||||
|
||||
def country(self, blue: bool) -> Country:
|
||||
country = self.mission.country(
|
||||
self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name
|
||||
)
|
||||
# Should be guaranteed because we initialized them.
|
||||
assert country
|
||||
return country
|
||||
|
||||
@property
|
||||
def blue(self) -> Country:
|
||||
return self.country(blue=True)
|
||||
|
||||
@property
|
||||
def red(self) -> Country:
|
||||
return self.country(blue=False)
|
||||
|
||||
def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]:
|
||||
for group in self.country(blue).plane_group:
|
||||
if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def carriers(self, blue: bool) -> Iterator[ShipGroup]:
|
||||
for group in self.country(blue).ship_group:
|
||||
if group.units[0].type == self.CV_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def lhas(self, blue: bool) -> Iterator[ShipGroup]:
|
||||
for group in self.country(blue).ship_group:
|
||||
if group.units[0].type == self.LHA_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def fobs(self, blue: bool) -> Iterator[VehicleGroup]:
|
||||
for group in self.country(blue).vehicle_group:
|
||||
if group.units[0].type == self.FOB_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ships(self) -> Iterator[ShipGroup]:
|
||||
for group in self.red.ship_group:
|
||||
if group.units[0].type == self.SHIP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def offshore_strike_targets(self) -> Iterator[StaticGroup]:
|
||||
for group in self.red.static_group:
|
||||
if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def missile_sites(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def coastal_defenses(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def long_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def medium_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def short_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def aaa(self) -> Iterator[VehicleGroup]:
|
||||
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
|
||||
if group.units[0].type in self.AAA_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ewrs(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.EWR_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def armor_groups(self) -> Iterator[VehicleGroup]:
|
||||
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
|
||||
if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def helipads(self) -> Iterator[StaticGroup]:
|
||||
for group in self.blue.static_group:
|
||||
if group.units[0].type == self.FARP_HELIPAD:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def factories(self) -> Iterator[StaticGroup]:
|
||||
for group in self.blue.static_group:
|
||||
if group.units[0].type in self.FACTORY_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ammunition_depots(self) -> Iterator[StaticGroup]:
|
||||
for group in itertools.chain(self.blue.static_group, self.red.static_group):
|
||||
if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def strike_targets(self) -> Iterator[StaticGroup]:
|
||||
for group in itertools.chain(self.blue.static_group, self.red.static_group):
|
||||
if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def scenery(self) -> List[SceneryGroup]:
|
||||
return SceneryGroup.from_trigger_zones(self.mission.triggers._zones)
|
||||
|
||||
@cached_property
|
||||
def control_points(self) -> Dict[int, ControlPoint]:
|
||||
control_points = {}
|
||||
for airport in self.mission.terrain.airport_list():
|
||||
if airport.is_blue() or airport.is_red():
|
||||
control_point = self.control_point_from_airport(airport)
|
||||
control_points[control_point.id] = control_point
|
||||
|
||||
for blue in (False, True):
|
||||
for group in self.off_map_spawns(blue):
|
||||
control_point = OffMapSpawn(
|
||||
next(self.control_point_id), str(group.name), group.position
|
||||
)
|
||||
control_point.captured = blue
|
||||
control_point.captured_invert = group.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for ship in self.carriers(blue):
|
||||
control_point = Carrier(
|
||||
ship.name, ship.position, next(self.control_point_id)
|
||||
)
|
||||
control_point.captured = blue
|
||||
control_point.captured_invert = ship.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for ship in self.lhas(blue):
|
||||
control_point = Lha(
|
||||
ship.name, ship.position, next(self.control_point_id)
|
||||
)
|
||||
control_point.captured = blue
|
||||
control_point.captured_invert = ship.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for fob in self.fobs(blue):
|
||||
control_point = Fob(
|
||||
str(fob.name), fob.position, next(self.control_point_id)
|
||||
)
|
||||
control_point.captured = blue
|
||||
control_point.captured_invert = fob.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
|
||||
return control_points
|
||||
|
||||
@property
|
||||
def front_line_path_groups(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.country(blue=True).vehicle_group:
|
||||
if group.units[0].type == self.FRONT_LINE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def shipping_lane_groups(self) -> Iterator[ShipGroup]:
|
||||
for group in self.country(blue=True).ship_group:
|
||||
if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def add_supply_routes(self) -> None:
|
||||
for group in self.front_line_path_groups:
|
||||
# The unit will have its first waypoint at the source CP and the final
|
||||
# waypoint at the destination CP. Each waypoint defines the path of the
|
||||
# cargo ship.
|
||||
waypoints = [p.position for p in group.points]
|
||||
origin = self.theater.closest_control_point(waypoints[0])
|
||||
if origin is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the first waypoint of {group.name}"
|
||||
)
|
||||
destination = self.theater.closest_control_point(waypoints[-1])
|
||||
if destination is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the final waypoint of {group.name}"
|
||||
)
|
||||
|
||||
self.control_points[origin.id].create_convoy_route(destination, waypoints)
|
||||
self.control_points[destination.id].create_convoy_route(
|
||||
origin, list(reversed(waypoints))
|
||||
)
|
||||
|
||||
def add_shipping_lanes(self) -> None:
|
||||
for group in self.shipping_lane_groups:
|
||||
# The unit will have its first waypoint at the source CP and the final
|
||||
# waypoint at the destination CP. Each waypoint defines the path of the
|
||||
# cargo ship.
|
||||
waypoints = [p.position for p in group.points]
|
||||
origin = self.theater.closest_control_point(waypoints[0])
|
||||
if origin is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the first waypoint of {group.name}"
|
||||
)
|
||||
destination = self.theater.closest_control_point(waypoints[-1])
|
||||
if destination is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the final waypoint of {group.name}"
|
||||
)
|
||||
|
||||
self.control_points[origin.id].create_shipping_lane(destination, waypoints)
|
||||
self.control_points[destination.id].create_shipping_lane(
|
||||
origin, list(reversed(waypoints))
|
||||
)
|
||||
|
||||
def objective_info(
|
||||
self, near: Positioned, allow_naval: bool = False
|
||||
) -> Tuple[ControlPoint, Distance]:
|
||||
closest = self.theater.closest_control_point(near.position, allow_naval)
|
||||
distance = meters(closest.position.distance_to_point(near.position))
|
||||
return closest, distance
|
||||
|
||||
def add_preset_locations(self) -> None:
|
||||
for static in self.offshore_strike_targets:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.offshore_strike_locations.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for ship in self.ships:
|
||||
closest, distance = self.objective_info(ship, allow_naval=True)
|
||||
closest.preset_locations.ships.append(
|
||||
PointWithHeading.from_point(
|
||||
ship.position, Heading.from_degrees(ship.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.missile_sites:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.missile_sites.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.coastal_defenses:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.coastal_defenses.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.long_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.long_range_sams.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.medium_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.medium_range_sams.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.short_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.short_range_sams.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.aaa:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.aaa.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.ewrs:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.ewrs.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.armor_groups:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.armor_groups.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.helipads:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.helipads.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.factories:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.factories.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.ammunition_depots:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.ammunition_depots.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.strike_targets:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.strike_locations.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for scenery_group in self.scenery:
|
||||
closest, distance = self.objective_info(scenery_group)
|
||||
closest.preset_locations.scenery.append(scenery_group)
|
||||
|
||||
def populate_theater(self) -> None:
|
||||
for control_point in self.control_points.values():
|
||||
self.theater.add_controlpoint(control_point)
|
||||
self.add_preset_locations()
|
||||
self.add_supply_routes()
|
||||
self.add_shipping_lanes()
|
||||
82
game/campaignloader/squadrondefgenerator.py
Normal file
82
game/campaignloader/squadrondefgenerator.py
Normal file
@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import random
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.squadrons.operatingbases import OperatingBases
|
||||
from game.squadrons.squadrondef import SquadronDef
|
||||
from game.theater import ControlPoint
|
||||
from gen.flights.ai_flight_planner_db import aircraft_for_task, tasks_for_aircraft
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class SquadronDefGenerator:
|
||||
def __init__(self, coalition: Coalition) -> None:
|
||||
self.coalition = coalition
|
||||
self.count = itertools.count(1)
|
||||
self.used_nicknames: set[str] = set()
|
||||
|
||||
def generate_for_task(
|
||||
self, task: FlightType, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
aircraft_choice: Optional[AircraftType] = None
|
||||
for aircraft in aircraft_for_task(task):
|
||||
if aircraft not in self.coalition.faction.aircrafts:
|
||||
continue
|
||||
if not control_point.can_operate(aircraft):
|
||||
continue
|
||||
aircraft_choice = aircraft
|
||||
# 50/50 chance to keep looking for an aircraft that isn't as far up the
|
||||
# priority list to maintain some unit variety.
|
||||
if random.choice([True, False]):
|
||||
break
|
||||
|
||||
if aircraft_choice is None:
|
||||
return None
|
||||
return self.generate_for_aircraft(aircraft_choice)
|
||||
|
||||
def generate_for_aircraft(self, aircraft: AircraftType) -> SquadronDef:
|
||||
return SquadronDef(
|
||||
name=f"Squadron {next(self.count):03}",
|
||||
nickname=self.random_nickname(),
|
||||
country=self.coalition.country_name,
|
||||
role="Flying Squadron",
|
||||
aircraft=aircraft,
|
||||
livery=None,
|
||||
mission_types=tuple(tasks_for_aircraft(aircraft)),
|
||||
operating_bases=OperatingBases.default_for_aircraft(aircraft),
|
||||
pilot_pool=[],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_random_nickname() -> str:
|
||||
from gen.naming import ANIMALS
|
||||
|
||||
animal = random.choice(ANIMALS)
|
||||
adjective = random.choice(
|
||||
(
|
||||
None,
|
||||
"Red",
|
||||
"Blue",
|
||||
"Green",
|
||||
"Golden",
|
||||
"Black",
|
||||
"Fighting",
|
||||
"Flying",
|
||||
)
|
||||
)
|
||||
if adjective is None:
|
||||
return animal.title()
|
||||
return f"{adjective} {animal}".title()
|
||||
|
||||
def random_nickname(self) -> str:
|
||||
while True:
|
||||
nickname = self._make_random_nickname()
|
||||
if nickname not in self.used_nicknames:
|
||||
self.used_nicknames.add(nickname)
|
||||
return nickname
|
||||
@ -5,14 +5,15 @@ from typing import TYPE_CHECKING, Any, Optional
|
||||
from dcs import Point
|
||||
from faker import Faker
|
||||
|
||||
from game.campaignloader import CampaignAirWingConfig
|
||||
from game.campaignloader.defaultsquadronassigner import DefaultSquadronAssigner
|
||||
from game.commander import TheaterCommander
|
||||
from game.commander.missionscheduler import MissionScheduler
|
||||
from game.income import Income
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
from game.navmesh import NavMesh
|
||||
from game.orderedset import OrderedSet
|
||||
from game.profiling import logged_duration, MultiEventTracer
|
||||
from game.savecompat import has_save_compat_for
|
||||
from game.squadrons import AirWing
|
||||
from game.threatzones import ThreatZones
|
||||
from game.transfers import PendingTransfers
|
||||
|
||||
@ -21,10 +22,9 @@ if TYPE_CHECKING:
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.factions.faction import Faction
|
||||
from game.procurement import AircraftProcurementRequest, ProcurementAi
|
||||
from game.squadrons import AirWing
|
||||
from game.theater.bullseye import Bullseye
|
||||
from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
|
||||
from gen import AirTaskingOrder
|
||||
from gen.ato import AirTaskingOrder
|
||||
|
||||
|
||||
class Coalition:
|
||||
@ -40,7 +40,7 @@ class Coalition:
|
||||
self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet()
|
||||
self.bullseye = Bullseye(Point(0, 0))
|
||||
self.faker = Faker(self.faction.locales)
|
||||
self.air_wing = AirWing(game, self)
|
||||
self.air_wing = AirWing(game)
|
||||
self.transfers = PendingTransfers(game, player)
|
||||
|
||||
# Late initialized because the two coalitions in the game are mutually
|
||||
@ -87,10 +87,6 @@ class Coalition:
|
||||
assert self._navmesh is not None
|
||||
return self._navmesh
|
||||
|
||||
@property
|
||||
def aircraft_inventory(self) -> GlobalAircraftInventory:
|
||||
return self.game.aircraft_inventory
|
||||
|
||||
def __getstate__(self) -> dict[str, Any]:
|
||||
state = self.__dict__.copy()
|
||||
# Avoid persisting any volatile types that can be deterministically
|
||||
@ -100,14 +96,7 @@ class Coalition:
|
||||
del state["faker"]
|
||||
return state
|
||||
|
||||
@has_save_compat_for(5)
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# Begin save compat
|
||||
old_procurement_requests = state["procurement_requests"]
|
||||
if isinstance(old_procurement_requests, list):
|
||||
state["procurement_requests"] = OrderedSet(old_procurement_requests)
|
||||
# End save compat
|
||||
|
||||
self.__dict__.update(state)
|
||||
# Regenerate any state that was not persisted.
|
||||
self.on_load()
|
||||
@ -120,6 +109,11 @@ class Coalition:
|
||||
raise RuntimeError("Double-initialization of Coalition.opponent")
|
||||
self._opponent = opponent
|
||||
|
||||
def configure_default_air_wing(
|
||||
self, air_wing_config: CampaignAirWingConfig
|
||||
) -> None:
|
||||
DefaultSquadronAssigner(air_wing_config, self.game, self).assign()
|
||||
|
||||
def adjust_budget(self, amount: float) -> None:
|
||||
self.budget += amount
|
||||
|
||||
@ -197,7 +191,9 @@ class Coalition:
|
||||
return
|
||||
|
||||
for cp in self.game.theater.control_points_for(self.player):
|
||||
cp.pending_unit_deliveries.refund_all(self)
|
||||
cp.ground_unit_orders.refund_all(self)
|
||||
for squadron in self.air_wing.iter_squadrons():
|
||||
squadron.refund_orders()
|
||||
|
||||
def plan_missions(self) -> None:
|
||||
color = "Blue" if self.player else "Red"
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from game.commander.missionproposals import ProposedFlight
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
from game.squadrons import AirWing, Squadron
|
||||
from game.squadrons.airwing import AirWing
|
||||
from game.squadrons.squadron import Squadron
|
||||
from game.theater import ControlPoint, MissionTarget
|
||||
from game.utils import meters
|
||||
from gen.flights.ai_flight_planner_db import aircraft_for_task
|
||||
@ -14,15 +14,10 @@ class AircraftAllocator:
|
||||
"""Finds suitable aircraft for proposed missions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
air_wing: AirWing,
|
||||
closest_airfields: ClosestAirfields,
|
||||
global_inventory: GlobalAircraftInventory,
|
||||
is_player: bool,
|
||||
self, air_wing: AirWing, closest_airfields: ClosestAirfields, is_player: bool
|
||||
) -> None:
|
||||
self.air_wing = air_wing
|
||||
self.closest_airfields = closest_airfields
|
||||
self.global_inventory = global_inventory
|
||||
self.is_player = is_player
|
||||
|
||||
def find_squadron_for_flight(
|
||||
@ -55,24 +50,20 @@ class AircraftAllocator:
|
||||
for airfield in self.closest_airfields.operational_airfields:
|
||||
if not airfield.is_friendly(self.is_player):
|
||||
continue
|
||||
inventory = self.global_inventory.for_control_point(airfield)
|
||||
for aircraft in types:
|
||||
if not airfield.can_operate(aircraft):
|
||||
continue
|
||||
if inventory.available(aircraft) < flight.num_aircraft:
|
||||
continue
|
||||
distance_to_target = meters(target.distance_to(airfield))
|
||||
if distance_to_target > aircraft.max_mission_range:
|
||||
continue
|
||||
# Valid location with enough aircraft available. Find a squadron to fit
|
||||
# the role.
|
||||
squadrons = self.air_wing.auto_assignable_for_task_with_type(
|
||||
aircraft, task
|
||||
aircraft, task, airfield
|
||||
)
|
||||
for squadron in squadrons:
|
||||
if squadron.operates_from(airfield) and squadron.can_provide_pilots(
|
||||
if squadron.operates_from(airfield) and squadron.can_fulfill_flight(
|
||||
flight.num_aircraft
|
||||
):
|
||||
inventory.remove_aircraft(aircraft, flight.num_aircraft)
|
||||
return airfield, squadron
|
||||
return None
|
||||
|
||||
@ -157,7 +157,10 @@ class ObjectiveFinder:
|
||||
for control_point in self.enemy_control_points():
|
||||
if not isinstance(control_point, Airfield):
|
||||
continue
|
||||
if control_point.base.total_aircraft >= min_aircraft:
|
||||
if (
|
||||
control_point.allocated_aircraft(self.game).total_present
|
||||
>= min_aircraft
|
||||
):
|
||||
airfields.append(control_point)
|
||||
return self._targets_by_range(airfields)
|
||||
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
from typing import Optional
|
||||
|
||||
from game.commander.aircraftallocator import AircraftAllocator
|
||||
from game.commander.missionproposals import ProposedFlight
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
from game.squadrons import AirWing
|
||||
from game.squadrons.airwing import AirWing
|
||||
from game.theater import MissionTarget, OffMapSpawn, ControlPoint
|
||||
from game.utils import nautical_miles
|
||||
from gen import Package
|
||||
from game.commander.aircraftallocator import AircraftAllocator
|
||||
from gen.ato import Package
|
||||
from gen.flights.closestairfields import ClosestAirfields
|
||||
from gen.flights.flight import Flight
|
||||
|
||||
@ -19,7 +18,6 @@ class PackageBuilder:
|
||||
self,
|
||||
location: MissionTarget,
|
||||
closest_airfields: ClosestAirfields,
|
||||
global_inventory: GlobalAircraftInventory,
|
||||
air_wing: AirWing,
|
||||
is_player: bool,
|
||||
package_country: str,
|
||||
@ -30,10 +28,7 @@ class PackageBuilder:
|
||||
self.is_player = is_player
|
||||
self.package_country = package_country
|
||||
self.package = Package(location, auto_asap=asap)
|
||||
self.allocator = AircraftAllocator(
|
||||
air_wing, closest_airfields, global_inventory, is_player
|
||||
)
|
||||
self.global_inventory = global_inventory
|
||||
self.allocator = AircraftAllocator(air_wing, closest_airfields, is_player)
|
||||
self.start_type = start_type
|
||||
|
||||
def plan_flight(self, plan: ProposedFlight) -> bool:
|
||||
@ -93,6 +88,5 @@ class PackageBuilder:
|
||||
"""Returns any planned flights to the inventory."""
|
||||
flights = list(self.package.flights)
|
||||
for flight in flights:
|
||||
self.global_inventory.return_from_flight(flight)
|
||||
flight.clear_roster()
|
||||
flight.return_pilots_and_aircraft()
|
||||
self.package.remove_flight(flight)
|
||||
|
||||
@ -5,16 +5,15 @@ from collections import defaultdict
|
||||
from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional
|
||||
|
||||
from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType
|
||||
from game.commander.packagebuilder import PackageBuilder
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
from game.procurement import AircraftProcurementRequest
|
||||
from game.profiling import MultiEventTracer
|
||||
from game.settings import Settings
|
||||
from game.squadrons import AirWing
|
||||
from game.theater import ConflictTheater
|
||||
from game.threatzones import ThreatZones
|
||||
from gen import AirTaskingOrder, Package
|
||||
from game.commander.packagebuilder import PackageBuilder
|
||||
from gen.ato import AirTaskingOrder, Package
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import FlightType
|
||||
from gen.flights.flightplan import FlightPlanBuilder
|
||||
@ -27,15 +26,10 @@ class PackageFulfiller:
|
||||
"""Responsible for package aircraft allocation and flight plan layout."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coalition: Coalition,
|
||||
theater: ConflictTheater,
|
||||
aircraft_inventory: GlobalAircraftInventory,
|
||||
settings: Settings,
|
||||
self, coalition: Coalition, theater: ConflictTheater, settings: Settings
|
||||
) -> None:
|
||||
self.coalition = coalition
|
||||
self.theater = theater
|
||||
self.aircraft_inventory = aircraft_inventory
|
||||
self.player_missions_asap = settings.auto_ato_player_missions_asap
|
||||
self.default_start_type = settings.default_start_type
|
||||
|
||||
@ -137,7 +131,6 @@ class PackageFulfiller:
|
||||
builder = PackageBuilder(
|
||||
mission.location,
|
||||
ObjectiveDistanceCache.get_closest_airfields(mission.location),
|
||||
self.aircraft_inventory,
|
||||
self.air_wing,
|
||||
self.is_player,
|
||||
self.coalition.country_name,
|
||||
|
||||
@ -11,12 +11,11 @@ from game.commander.missionproposals import ProposedFlight, EscortType, Proposed
|
||||
from game.commander.packagefulfiller import PackageFulfiller
|
||||
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.settings import AutoAtoBehavior
|
||||
from game.theater import MissionTarget
|
||||
from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
|
||||
from game.utils import Distance, meters
|
||||
from gen import Package
|
||||
from gen.ato import Package
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -54,8 +53,6 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
|
||||
def execute(self, coalition: Coalition) -> None:
|
||||
if self.package is None:
|
||||
raise RuntimeError("Attempted to execute failed package planning task")
|
||||
for flight in self.package.flights:
|
||||
coalition.aircraft_inventory.claim_for_flight(flight)
|
||||
coalition.ato.add_package(self.package)
|
||||
|
||||
@abstractmethod
|
||||
@ -100,7 +97,6 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
|
||||
fulfiller = PackageFulfiller(
|
||||
state.context.coalition,
|
||||
state.context.theater,
|
||||
state.available_aircraft,
|
||||
state.context.settings,
|
||||
)
|
||||
self.package = fulfiller.plan_mission(
|
||||
|
||||
@ -10,9 +10,9 @@ from typing import TYPE_CHECKING, Any, Union, Optional
|
||||
from game.commander.garrisons import Garrisons
|
||||
from game.commander.objectivefinder import ObjectiveFinder
|
||||
from game.htn import WorldState
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
from game.profiling import MultiEventTracer
|
||||
from game.settings import Settings
|
||||
from game.squadrons import AirWing
|
||||
from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater
|
||||
from game.theater.theatergroundobject import (
|
||||
TheaterGroundObject,
|
||||
@ -58,7 +58,6 @@ class TheaterState(WorldState["TheaterState"]):
|
||||
strike_targets: list[TheaterGroundObject[Any]]
|
||||
enemy_barcaps: list[ControlPoint]
|
||||
threat_zones: ThreatZones
|
||||
available_aircraft: GlobalAircraftInventory
|
||||
|
||||
def _rebuild_threat_zones(self) -> None:
|
||||
"""Recreates the theater's threat zones based on the current planned state."""
|
||||
@ -122,7 +121,6 @@ class TheaterState(WorldState["TheaterState"]):
|
||||
strike_targets=list(self.strike_targets),
|
||||
enemy_barcaps=list(self.enemy_barcaps),
|
||||
threat_zones=self.threat_zones,
|
||||
available_aircraft=self.available_aircraft.clone(),
|
||||
# Persistent properties are not copied. These are a way for failed subtasks
|
||||
# to communicate requirements to other tasks. For example, the task to
|
||||
# attack enemy garrisons might fail because the target area has IADS
|
||||
@ -172,5 +170,4 @@ class TheaterState(WorldState["TheaterState"]):
|
||||
strike_targets=list(finder.strike_targets()),
|
||||
enemy_barcaps=list(game.theater.control_points_for(not player)),
|
||||
threat_zones=game.threat_zone_for(not player),
|
||||
available_aircraft=game.aircraft_inventory.clone(),
|
||||
)
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from game.data.groundunitclass import GroundUnitClass
|
||||
from game.savecompat import has_save_compat_for
|
||||
from game.utils import Distance, feet, nautical_miles
|
||||
|
||||
|
||||
@ -79,32 +77,6 @@ class Doctrine:
|
||||
|
||||
ground_unit_procurement_ratios: GroundUnitProcurementRatios
|
||||
|
||||
@has_save_compat_for(5)
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
if "max_ingress_distance" not in state:
|
||||
try:
|
||||
state["max_ingress_distance"] = state["ingress_distance"]
|
||||
del state["ingress_distance"]
|
||||
except KeyError:
|
||||
state["max_ingress_distance"] = state["ingress_egress_distance"]
|
||||
del state["ingress_egress_distance"]
|
||||
|
||||
max_ip: Distance = state["max_ingress_distance"]
|
||||
if "min_ingress_distance" not in state:
|
||||
if max_ip < nautical_miles(10):
|
||||
min_ip = nautical_miles(5)
|
||||
else:
|
||||
min_ip = nautical_miles(10)
|
||||
state["min_ingress_distance"] = min_ip
|
||||
|
||||
self.__dict__.update(state)
|
||||
|
||||
|
||||
class MissionPlannerMaxRanges:
|
||||
@has_save_compat_for(5)
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
MODERN_DOCTRINE = Doctrine(
|
||||
cap=True,
|
||||
|
||||
@ -29,12 +29,20 @@ from game.radio.channels import (
|
||||
ViggenRadioChannelAllocator,
|
||||
NoOpChannelAllocator,
|
||||
)
|
||||
from game.utils import Distance, Speed, feet, kph, knots, nautical_miles
|
||||
from game.utils import (
|
||||
Distance,
|
||||
SPEED_OF_SOUND_AT_SEA_LEVEL,
|
||||
Speed,
|
||||
feet,
|
||||
kph,
|
||||
knots,
|
||||
nautical_miles,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen.aircraft import FlightData
|
||||
from gen import AirSupport, RadioFrequency, RadioRegistry
|
||||
from gen.radios import Radio
|
||||
from gen.airsupport import AirSupport
|
||||
from gen.radios import Radio, RadioFrequency, RadioRegistry
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -186,7 +194,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
|
||||
@property
|
||||
def preferred_patrol_altitude(self) -> Distance:
|
||||
if self.patrol_altitude:
|
||||
if self.patrol_altitude is not None:
|
||||
return self.patrol_altitude
|
||||
else:
|
||||
# Estimate based on max speed.
|
||||
@ -212,6 +220,40 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
min(altitude_for_highest_speed, rounded_altitude),
|
||||
)
|
||||
|
||||
def preferred_patrol_speed(self, altitude: Distance) -> Speed:
|
||||
"""Preferred true airspeed when patrolling"""
|
||||
if self.patrol_speed is not None:
|
||||
return self.patrol_speed
|
||||
else:
|
||||
# Estimate based on max speed.
|
||||
max_speed = self.max_speed
|
||||
if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.6:
|
||||
# Fast airplanes, should manage pretty high patrol speed
|
||||
return (
|
||||
Speed.from_mach(0.85, altitude)
|
||||
if altitude.feet > 20000
|
||||
else Speed.from_mach(0.7, altitude)
|
||||
)
|
||||
elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.2:
|
||||
# Medium-fast like F/A-18C
|
||||
return (
|
||||
Speed.from_mach(0.8, altitude)
|
||||
if altitude.feet > 20000
|
||||
else Speed.from_mach(0.65, altitude)
|
||||
)
|
||||
elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 0.7:
|
||||
# Semi-fast like airliners or similar
|
||||
return (
|
||||
Speed.from_mach(0.5, altitude)
|
||||
if altitude.feet > 20000
|
||||
else Speed.from_mach(0.4, altitude)
|
||||
)
|
||||
else:
|
||||
# Slow like warbirds or helicopters
|
||||
# Use whichever is slowest - mach 0.35 or 70% of max speed
|
||||
logging.debug(f"{self.name} max_speed * 0.7 is {max_speed * 0.7}")
|
||||
return min(Speed.from_mach(0.35, altitude), max_speed * 0.7)
|
||||
|
||||
def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
|
||||
from gen.radios import ChannelInUseError, kHz
|
||||
|
||||
@ -287,7 +329,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
logging.warning(f"No data for {aircraft.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open() as data_file:
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
try:
|
||||
|
||||
@ -67,7 +67,7 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
|
||||
logging.warning(f"No data for {vehicle.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open() as data_file:
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
try:
|
||||
|
||||
@ -386,7 +386,7 @@ class PollDebriefingFileThread(threading.Thread):
|
||||
os.path.isfile("state.json")
|
||||
and os.path.getmtime("state.json") > last_modified
|
||||
):
|
||||
with open("state.json", "r") as json_file:
|
||||
with open("state.json", "r", encoding="utf-8") as json_file:
|
||||
json_data = json.load(json_file)
|
||||
debriefing = Debriefing(json_data, self.game, self.unit_map)
|
||||
self.callback(debriefing)
|
||||
|
||||
@ -7,13 +7,12 @@ from dcs.mapping import Point
|
||||
from dcs.task import Task
|
||||
|
||||
from game import persistency
|
||||
from game.debriefing import AirLosses, Debriefing
|
||||
from game.debriefing import Debriefing
|
||||
from game.infos.information import Information
|
||||
from game.operation.operation import Operation
|
||||
from game.theater import ControlPoint
|
||||
from gen import AirTaskingOrder
|
||||
from gen.ato import AirTaskingOrder
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
from ..dcs.groundunittype import GroundUnitType
|
||||
from ..unitmap import UnitMap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -67,59 +66,6 @@ class Event:
|
||||
)
|
||||
return unit_map
|
||||
|
||||
@staticmethod
|
||||
def _transfer_aircraft(
|
||||
ato: AirTaskingOrder, losses: AirLosses, for_player: bool
|
||||
) -> None:
|
||||
for package in ato.packages:
|
||||
for flight in package.flights:
|
||||
# No need to transfer to the same location.
|
||||
if flight.departure == flight.arrival:
|
||||
continue
|
||||
|
||||
# Don't transfer to bases that were captured. Note that if the
|
||||
# airfield was back-filling transfers it may overflow. We could
|
||||
# attempt to be smarter in the future by performing transfers in
|
||||
# order up a graph to prevent transfers to full airports and
|
||||
# send overflow off-map, but overflow is fine for now.
|
||||
if flight.arrival.captured != for_player:
|
||||
logging.info(
|
||||
f"Not transferring {flight} because {flight.arrival} "
|
||||
"was captured"
|
||||
)
|
||||
continue
|
||||
|
||||
transfer_count = losses.surviving_flight_members(flight)
|
||||
if transfer_count < 0:
|
||||
logging.error(
|
||||
f"{flight} had {flight.count} aircraft but "
|
||||
f"{transfer_count} losses were recorded."
|
||||
)
|
||||
continue
|
||||
|
||||
aircraft = flight.unit_type
|
||||
available = flight.departure.base.total_units_of_type(aircraft)
|
||||
if available < transfer_count:
|
||||
logging.error(
|
||||
f"Found killed {aircraft} from {flight.departure} but "
|
||||
f"that airbase has only {available} available."
|
||||
)
|
||||
continue
|
||||
|
||||
flight.departure.base.aircraft[aircraft] -= transfer_count
|
||||
if aircraft not in flight.arrival.base.aircraft:
|
||||
# TODO: Should use defaultdict.
|
||||
flight.arrival.base.aircraft[aircraft] = 0
|
||||
flight.arrival.base.aircraft[aircraft] += transfer_count
|
||||
|
||||
def complete_aircraft_transfers(self, debriefing: Debriefing) -> None:
|
||||
self._transfer_aircraft(
|
||||
self.game.blue.ato, debriefing.air_losses, for_player=True
|
||||
)
|
||||
self._transfer_aircraft(
|
||||
self.game.red.ato, debriefing.air_losses, for_player=False
|
||||
)
|
||||
|
||||
def commit_air_losses(self, debriefing: Debriefing) -> None:
|
||||
for loss in debriefing.air_losses.losses:
|
||||
if loss.pilot is not None and (
|
||||
@ -127,18 +73,18 @@ class Event:
|
||||
or not self.game.settings.invulnerable_player_pilots
|
||||
):
|
||||
loss.pilot.kill()
|
||||
squadron = loss.flight.squadron
|
||||
aircraft = loss.flight.unit_type
|
||||
cp = loss.flight.departure
|
||||
available = cp.base.total_units_of_type(aircraft)
|
||||
available = squadron.owned_aircraft
|
||||
if available <= 0:
|
||||
logging.error(
|
||||
f"Found killed {aircraft} from {cp} but that airbase has "
|
||||
f"Found killed {aircraft} from {squadron} but that airbase has "
|
||||
"none available."
|
||||
)
|
||||
continue
|
||||
|
||||
logging.info(f"{aircraft} destroyed from {cp}")
|
||||
cp.base.aircraft[aircraft] -= 1
|
||||
logging.info(f"{aircraft} destroyed from {squadron}")
|
||||
squadron.owned_aircraft -= 1
|
||||
|
||||
@staticmethod
|
||||
def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
|
||||
@ -276,7 +222,6 @@ class Event:
|
||||
self.commit_building_losses(debriefing)
|
||||
self.commit_damaged_runways(debriefing)
|
||||
self.commit_captures(debriefing)
|
||||
self.complete_aircraft_transfers(debriefing)
|
||||
|
||||
# Destroyed units carcass
|
||||
# -------------------------
|
||||
@ -458,15 +403,10 @@ class Event:
|
||||
source.base.commit_losses(moved_units)
|
||||
|
||||
# Also transfer pending deliveries.
|
||||
for unit_type, count in source.pending_unit_deliveries.units.items():
|
||||
if not isinstance(unit_type, GroundUnitType):
|
||||
continue
|
||||
if count <= 0:
|
||||
# Don't transfer *sales*...
|
||||
continue
|
||||
for unit_type, count in source.ground_unit_orders.units.items():
|
||||
move_count = int(count * move_factor)
|
||||
source.pending_unit_deliveries.sell({unit_type: move_count})
|
||||
destination.pending_unit_deliveries.order({unit_type: move_count})
|
||||
source.ground_unit_orders.sell({unit_type: move_count})
|
||||
destination.ground_unit_orders.order({unit_type: move_count})
|
||||
total_units_redeployed += move_count
|
||||
|
||||
if total_units_redeployed > 0:
|
||||
|
||||
24
game/game.py
24
game/game.py
@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import math
|
||||
from collections import Iterator
|
||||
from datetime import date, datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any, List, Type, Union, cast
|
||||
from typing import Any, List, Type, Union, cast, TYPE_CHECKING
|
||||
|
||||
from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors
|
||||
from dcs.country import Country
|
||||
@ -13,7 +15,6 @@ from dcs.task import CAP, CAS, PinpointStrike
|
||||
from dcs.vehicles import AirDefence
|
||||
from faker import Faker
|
||||
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
from game.models.game_stats import GameStats
|
||||
from game.plugins import LuaPluginManager
|
||||
from gen import naming
|
||||
@ -23,6 +24,7 @@ from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import FlightType
|
||||
from gen.ground_forces.ai_ground_planner import GroundPlanner
|
||||
from . import persistency
|
||||
from .campaignloader import CampaignAirWingConfig
|
||||
from .coalition import Coalition
|
||||
from .debriefing import Debriefing
|
||||
from .event.event import Event
|
||||
@ -32,7 +34,6 @@ from .infos.information import Information
|
||||
from .navmesh import NavMesh
|
||||
from .profiling import logged_duration
|
||||
from .settings import Settings
|
||||
from .squadrons import AirWing
|
||||
from .theater import ConflictTheater, ControlPoint
|
||||
from .theater.bullseye import Bullseye
|
||||
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
|
||||
@ -40,6 +41,9 @@ from .threatzones import ThreatZones
|
||||
from .unitmap import UnitMap
|
||||
from .weather import Conditions, TimeOfDay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .squadrons import AirWing
|
||||
|
||||
COMMISION_UNIT_VARIETY = 4
|
||||
COMMISION_LIMITS_SCALE = 1.5
|
||||
COMMISION_LIMITS_FACTORS = {
|
||||
@ -86,6 +90,7 @@ class Game:
|
||||
player_faction: Faction,
|
||||
enemy_faction: Faction,
|
||||
theater: ConflictTheater,
|
||||
air_wing_config: CampaignAirWingConfig,
|
||||
start_date: datetime,
|
||||
settings: Settings,
|
||||
player_budget: float,
|
||||
@ -120,7 +125,8 @@ class Game:
|
||||
self.blue.set_opponent(self.red)
|
||||
self.red.set_opponent(self.blue)
|
||||
|
||||
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
|
||||
self.blue.configure_default_air_wing(air_wing_config)
|
||||
self.red.configure_default_air_wing(air_wing_config)
|
||||
|
||||
self.on_load(game_still_initializing=True)
|
||||
|
||||
@ -396,9 +402,9 @@ class Game:
|
||||
|
||||
# Plan Coalition specific turn
|
||||
if for_blue:
|
||||
self.initialize_turn_for(player=True)
|
||||
self.blue.initialize_turn()
|
||||
if for_red:
|
||||
self.initialize_turn_for(player=False)
|
||||
self.red.initialize_turn()
|
||||
|
||||
# Plan GroundWar
|
||||
self.ground_planners = {}
|
||||
@ -408,12 +414,6 @@ class Game:
|
||||
gplanner.plan_groundwar()
|
||||
self.ground_planners[cp.id] = gplanner
|
||||
|
||||
def initialize_turn_for(self, player: bool) -> None:
|
||||
self.aircraft_inventory.reset(player)
|
||||
for cp in self.theater.control_points_for(player):
|
||||
self.aircraft_inventory.set_from_control_point(cp)
|
||||
self.coalition_for(player).initialize_turn()
|
||||
|
||||
def message(self, text: str) -> None:
|
||||
self.informations.append(Information(text, turn=self.turn))
|
||||
|
||||
|
||||
@ -2,13 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, TYPE_CHECKING, Any
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from game.theater import ControlPoint
|
||||
from .coalition import Coalition
|
||||
from .dcs.groundunittype import GroundUnitType
|
||||
from .dcs.unittype import UnitType
|
||||
from .theater.transitnetwork import (
|
||||
NoPathError,
|
||||
TransitNetwork,
|
||||
@ -19,59 +17,41 @@ if TYPE_CHECKING:
|
||||
from .game import Game
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroundUnitSource:
|
||||
control_point: ControlPoint
|
||||
|
||||
|
||||
class PendingUnitDeliveries:
|
||||
class GroundUnitOrders:
|
||||
def __init__(self, destination: ControlPoint) -> None:
|
||||
self.destination = destination
|
||||
|
||||
# Maps unit type to order quantity.
|
||||
self.units: dict[UnitType[Any], int] = defaultdict(int)
|
||||
self.units: dict[GroundUnitType, int] = defaultdict(int)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Pending delivery to {self.destination}"
|
||||
return f"Pending ground unit delivery to {self.destination}"
|
||||
|
||||
def order(self, units: dict[UnitType[Any], int]) -> None:
|
||||
def order(self, units: dict[GroundUnitType, int]) -> None:
|
||||
for k, v in units.items():
|
||||
self.units[k] += v
|
||||
|
||||
def sell(self, units: dict[UnitType[Any], int]) -> None:
|
||||
def sell(self, units: dict[GroundUnitType, int]) -> None:
|
||||
for k, v in units.items():
|
||||
if self.units[k] > v:
|
||||
self.units[k] -= v
|
||||
else:
|
||||
self.units[k] -= v
|
||||
if self.units[k] == 0:
|
||||
del self.units[k]
|
||||
|
||||
def refund_all(self, coalition: Coalition) -> None:
|
||||
self.refund(coalition, self.units)
|
||||
self._refund(coalition, self.units)
|
||||
self.units = defaultdict(int)
|
||||
|
||||
def refund_ground_units(self, coalition: Coalition) -> None:
|
||||
ground_units: dict[UnitType[Any], int] = {
|
||||
u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType)
|
||||
}
|
||||
self.refund(coalition, ground_units)
|
||||
for gu in ground_units.keys():
|
||||
del self.units[gu]
|
||||
|
||||
def refund(self, coalition: Coalition, units: dict[UnitType[Any], int]) -> None:
|
||||
def _refund(self, coalition: Coalition, units: dict[GroundUnitType, int]) -> None:
|
||||
for unit_type, count in units.items():
|
||||
logging.info(f"Refunding {count} {unit_type} at {self.destination.name}")
|
||||
coalition.adjust_budget(unit_type.price * count)
|
||||
|
||||
def pending_orders(self, unit_type: UnitType[Any]) -> int:
|
||||
def pending_orders(self, unit_type: GroundUnitType) -> int:
|
||||
pending_units = self.units.get(unit_type)
|
||||
if pending_units is None:
|
||||
pending_units = 0
|
||||
return pending_units
|
||||
|
||||
def available_next_turn(self, unit_type: UnitType[Any]) -> int:
|
||||
current_units = self.destination.base.total_units_of_type(unit_type)
|
||||
return self.pending_orders(unit_type) + current_units
|
||||
|
||||
def process(self, game: Game) -> None:
|
||||
coalition = game.coalition_for(self.destination.captured)
|
||||
ground_unit_source = self.find_ground_unit_source(game)
|
||||
@ -80,36 +60,33 @@ class PendingUnitDeliveries:
|
||||
f"{self.destination.name} lost its source for ground unit "
|
||||
"reinforcements. Refunding purchase price."
|
||||
)
|
||||
self.refund_ground_units(coalition)
|
||||
self.refund_all(coalition)
|
||||
|
||||
bought_units: dict[UnitType[Any], int] = {}
|
||||
bought_units: dict[GroundUnitType, int] = {}
|
||||
units_needing_transfer: dict[GroundUnitType, int] = {}
|
||||
sold_units: dict[UnitType[Any], int] = {}
|
||||
for unit_type, count in self.units.items():
|
||||
allegiance = "Ally" if self.destination.captured else "Enemy"
|
||||
d: dict[Any, int]
|
||||
if (
|
||||
isinstance(unit_type, GroundUnitType)
|
||||
and self.destination != ground_unit_source
|
||||
):
|
||||
d: dict[GroundUnitType, int]
|
||||
if self.destination != ground_unit_source:
|
||||
source = ground_unit_source
|
||||
d = units_needing_transfer
|
||||
else:
|
||||
source = self.destination
|
||||
d = bought_units
|
||||
|
||||
if count >= 0:
|
||||
if count < 0:
|
||||
logging.error(
|
||||
f"Attempted sale of {unit_type} at {self.destination} but ground "
|
||||
"units cannot be sold"
|
||||
)
|
||||
elif count > 0:
|
||||
d[unit_type] = count
|
||||
game.message(
|
||||
f"{allegiance} reinforcements: {unit_type} x {count} at {source}"
|
||||
)
|
||||
else:
|
||||
sold_units[unit_type] = -count
|
||||
game.message(f"{allegiance} sold: {unit_type} x {-count} at {source}")
|
||||
|
||||
self.units = defaultdict(int)
|
||||
self.destination.base.commission_units(bought_units)
|
||||
self.destination.base.commit_losses(sold_units)
|
||||
|
||||
if units_needing_transfer:
|
||||
if ground_unit_source is None:
|
||||
@ -1,142 +0,0 @@
|
||||
"""Inventory management APIs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, Iterator, Iterable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from gen.flights.flight import Flight
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater import ControlPoint
|
||||
|
||||
|
||||
class ControlPointAircraftInventory:
|
||||
"""Aircraft inventory for a single control point."""
|
||||
|
||||
def __init__(self, control_point: ControlPoint) -> None:
|
||||
self.control_point = control_point
|
||||
self.inventory: dict[AircraftType, int] = defaultdict(int)
|
||||
|
||||
def clone(self) -> ControlPointAircraftInventory:
|
||||
new = ControlPointAircraftInventory(self.control_point)
|
||||
new.inventory = self.inventory.copy()
|
||||
return new
|
||||
|
||||
def add_aircraft(self, aircraft: AircraftType, count: int) -> None:
|
||||
"""Adds aircraft to the inventory.
|
||||
|
||||
Args:
|
||||
aircraft: The type of aircraft to add.
|
||||
count: The number of aircraft to add.
|
||||
"""
|
||||
self.inventory[aircraft] += count
|
||||
|
||||
def remove_aircraft(self, aircraft: AircraftType, count: int) -> None:
|
||||
"""Removes aircraft from the inventory.
|
||||
|
||||
Args:
|
||||
aircraft: The type of aircraft to remove.
|
||||
count: The number of aircraft to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: The control point cannot fulfill the requested number of
|
||||
aircraft.
|
||||
"""
|
||||
available = self.inventory[aircraft]
|
||||
if available < count:
|
||||
raise ValueError(
|
||||
f"Cannot remove {count} {aircraft} from "
|
||||
f"{self.control_point.name}. Only have {available}."
|
||||
)
|
||||
self.inventory[aircraft] -= count
|
||||
|
||||
def available(self, aircraft: AircraftType) -> int:
|
||||
"""Returns the number of available aircraft of the given type.
|
||||
|
||||
Args:
|
||||
aircraft: The type of aircraft to query.
|
||||
"""
|
||||
try:
|
||||
return self.inventory[aircraft]
|
||||
except KeyError:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def types_available(self) -> Iterator[AircraftType]:
|
||||
"""Iterates over all available aircraft types."""
|
||||
for aircraft, count in self.inventory.items():
|
||||
if count > 0:
|
||||
yield aircraft
|
||||
|
||||
@property
|
||||
def all_aircraft(self) -> Iterator[tuple[AircraftType, int]]:
|
||||
"""Iterates over all available aircraft types, including amounts."""
|
||||
for aircraft, count in self.inventory.items():
|
||||
if count > 0:
|
||||
yield aircraft, count
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clears all aircraft from the inventory."""
|
||||
self.inventory.clear()
|
||||
|
||||
|
||||
class GlobalAircraftInventory:
|
||||
"""Game-wide aircraft inventory."""
|
||||
|
||||
def __init__(self, control_points: Iterable[ControlPoint]) -> None:
|
||||
self.inventories: dict[ControlPoint, ControlPointAircraftInventory] = {
|
||||
cp: ControlPointAircraftInventory(cp) for cp in control_points
|
||||
}
|
||||
|
||||
def clone(self) -> GlobalAircraftInventory:
|
||||
new = GlobalAircraftInventory([])
|
||||
new.inventories = {
|
||||
cp: inventory.clone() for cp, inventory in self.inventories.items()
|
||||
}
|
||||
return new
|
||||
|
||||
def reset(self, for_player: bool) -> None:
|
||||
"""Clears the inventory of every control point owned by the given coalition."""
|
||||
for inventory in self.inventories.values():
|
||||
if inventory.control_point.captured == for_player:
|
||||
inventory.clear()
|
||||
|
||||
def set_from_control_point(self, control_point: ControlPoint) -> None:
|
||||
"""Set the control point's aircraft inventory.
|
||||
|
||||
If the inventory for the given control point has already been set for
|
||||
the turn, it will be overwritten.
|
||||
"""
|
||||
inventory = self.inventories[control_point]
|
||||
for aircraft, count in control_point.base.aircraft.items():
|
||||
inventory.add_aircraft(aircraft, count)
|
||||
|
||||
def for_control_point(
|
||||
self, control_point: ControlPoint
|
||||
) -> ControlPointAircraftInventory:
|
||||
"""Returns the inventory specific to the given control point."""
|
||||
return self.inventories[control_point]
|
||||
|
||||
@property
|
||||
def available_types_for_player(self) -> Iterator[AircraftType]:
|
||||
"""Iterates over all aircraft types available to the player."""
|
||||
seen: set[AircraftType] = set()
|
||||
for control_point, inventory in self.inventories.items():
|
||||
if control_point.captured:
|
||||
for aircraft in inventory.types_available:
|
||||
if not control_point.can_operate(aircraft):
|
||||
continue
|
||||
if aircraft not in seen:
|
||||
seen.add(aircraft)
|
||||
yield aircraft
|
||||
|
||||
def claim_for_flight(self, flight: Flight) -> None:
|
||||
"""Removes aircraft from the inventory for the given flight."""
|
||||
inventory = self.for_control_point(flight.from_cp)
|
||||
inventory.remove_aircraft(flight.unit_type, flight.count)
|
||||
|
||||
def return_from_flight(self, flight: Flight) -> None:
|
||||
"""Returns a flight's aircraft to the inventory."""
|
||||
inventory = self.for_control_point(flight.from_cp)
|
||||
inventory.add_aircraft(flight.unit_type, flight.count)
|
||||
@ -56,10 +56,12 @@ class GameStats:
|
||||
|
||||
for cp in game.theater.controlpoints:
|
||||
if cp.captured:
|
||||
turn_data.allied_units.aircraft_count += sum(cp.base.aircraft.values())
|
||||
for squadron in cp.squadrons:
|
||||
turn_data.allied_units.aircraft_count += squadron.owned_aircraft
|
||||
turn_data.allied_units.vehicles_count += sum(cp.base.armor.values())
|
||||
else:
|
||||
turn_data.enemy_units.aircraft_count += sum(cp.base.aircraft.values())
|
||||
for squadron in cp.squadrons:
|
||||
turn_data.enemy_units.aircraft_count += squadron.owned_aircraft
|
||||
turn_data.enemy_units.vehicles_count += sum(cp.base.armor.values())
|
||||
|
||||
self.data_per_turn.append(turn_data)
|
||||
|
||||
@ -16,16 +16,18 @@ from dcs.triggers import TriggerStart
|
||||
|
||||
from game.plugins import LuaPluginManager
|
||||
from game.theater.theatergroundobject import TheaterGroundObject
|
||||
from gen import Conflict, FlightType, VisualGenerator, Bullseye, AirSupport
|
||||
from gen.aircraft import AircraftConflictGenerator, FlightData
|
||||
from gen.airfields import AIRFIELD_DATA
|
||||
from gen.airsupport import AirSupport
|
||||
from gen.airsupportgen import AirSupportConflictGenerator
|
||||
from gen.armor import GroundConflictGenerator
|
||||
from gen.beacons import load_beacons_for_terrain
|
||||
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
|
||||
from gen.cargoshipgen import CargoShipGenerator
|
||||
from gen.conflictgen import Conflict
|
||||
from gen.convoygen import ConvoyGenerator
|
||||
from gen.environmentgen import EnvironmentGenerator
|
||||
from gen.flights.flight import FlightType
|
||||
from gen.forcedoptionsgen import ForcedOptionsGenerator
|
||||
from gen.groundobjectsgen import GroundObjectsGenerator
|
||||
from gen.kneeboard import KneeboardGenerator
|
||||
@ -34,6 +36,7 @@ from gen.naming import namegen
|
||||
from gen.radios import RadioFrequency, RadioRegistry
|
||||
from gen.tacan import TacanRegistry
|
||||
from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator
|
||||
from gen.visualgen import VisualGenerator
|
||||
from .. import db
|
||||
from ..theater import Airfield, FrontLine
|
||||
from ..unitmap import UnitMap
|
||||
@ -65,7 +68,7 @@ class Operation:
|
||||
|
||||
@classmethod
|
||||
def prepare(cls, game: Game) -> None:
|
||||
with open("resources/default_options.lua", "r") as f:
|
||||
with open("resources/default_options.lua", "r", encoding="utf-8") as f:
|
||||
options_dict = loads(f.read())["options"]
|
||||
cls._set_mission(Mission(game.theater.terrain))
|
||||
cls.game = game
|
||||
|
||||
@ -2,14 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
|
||||
|
||||
from game import db
|
||||
from game.data.groundunitclass import GroundUnitClass
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.factions.faction import Faction
|
||||
from game.squadrons import Squadron
|
||||
from game.theater import ControlPoint, MissionTarget
|
||||
from game.utils import meters
|
||||
from gen.flights.ai_flight_planner_db import aircraft_for_task
|
||||
@ -97,37 +97,10 @@ class ProcurementAi:
|
||||
budget -= armor_budget
|
||||
budget += self.reinforce_front_line(armor_budget)
|
||||
|
||||
# Don't sell overstock aircraft until after we've bought runways and
|
||||
# front lines. Any budget we free up should be earmarked for aircraft.
|
||||
if not self.is_player:
|
||||
budget += self.sell_incomplete_squadrons()
|
||||
if self.manage_aircraft:
|
||||
budget = self.purchase_aircraft(budget)
|
||||
return budget
|
||||
|
||||
def sell_incomplete_squadrons(self) -> float:
|
||||
# Selling incomplete squadrons gives us more money to spend on the next
|
||||
# turn. This serves as a short term fix for
|
||||
# https://github.com/dcs-liberation/dcs_liberation/issues/41.
|
||||
#
|
||||
# Only incomplete squadrons which are unlikely to get used will be sold
|
||||
# rather than all unused aircraft because the unused aircraft are what
|
||||
# make OCA strikes worthwhile.
|
||||
#
|
||||
# This option is only used by the AI since players cannot cancel sales
|
||||
# (https://github.com/dcs-liberation/dcs_liberation/issues/365).
|
||||
total = 0.0
|
||||
for cp in self.game.theater.control_points_for(self.is_player):
|
||||
inventory = self.game.aircraft_inventory.for_control_point(cp)
|
||||
for aircraft, available in inventory.all_aircraft:
|
||||
# We only ever plan even groups, so the odd aircraft is unlikely
|
||||
# to get used.
|
||||
if available % 2 == 0:
|
||||
continue
|
||||
inventory.remove_aircraft(aircraft, 1)
|
||||
total += aircraft.price
|
||||
return total
|
||||
|
||||
def repair_runways(self, budget: float) -> float:
|
||||
for control_point in self.owned_points:
|
||||
if budget < db.RUNWAY_REPAIR_COST:
|
||||
@ -180,7 +153,7 @@ class ProcurementAi:
|
||||
break
|
||||
|
||||
budget -= unit.price
|
||||
cp.pending_unit_deliveries.order({unit: 1})
|
||||
cp.ground_unit_orders.order({unit: 1})
|
||||
|
||||
return budget
|
||||
|
||||
@ -209,64 +182,29 @@ class ProcurementAi:
|
||||
return GroundUnitClass.Tank
|
||||
return worst_balanced
|
||||
|
||||
def affordable_aircraft_for(
|
||||
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
|
||||
) -> Optional[AircraftType]:
|
||||
best_choice: Optional[AircraftType] = None
|
||||
for unit in aircraft_for_task(request.task_capability):
|
||||
if unit not in self.faction.aircrafts:
|
||||
continue
|
||||
if unit.price * request.number > budget:
|
||||
continue
|
||||
if not airbase.can_operate(unit):
|
||||
continue
|
||||
|
||||
distance_to_target = meters(request.near.distance_to(airbase))
|
||||
if distance_to_target > unit.max_mission_range:
|
||||
continue
|
||||
|
||||
for squadron in self.air_wing.squadrons_for(unit):
|
||||
if (
|
||||
squadron.operates_from(airbase)
|
||||
and request.task_capability
|
||||
in squadron.auto_assignable_mission_types
|
||||
):
|
||||
break
|
||||
else:
|
||||
continue
|
||||
|
||||
# Affordable, compatible, and we have a squadron capable of the task. To
|
||||
# keep some variety, skip with a 50/50 chance. Might be a good idea to have
|
||||
# the chance to skip based on the price compared to the rest of the choices.
|
||||
best_choice = unit
|
||||
if random.choice([True, False]):
|
||||
break
|
||||
return best_choice
|
||||
|
||||
@staticmethod
|
||||
def fulfill_aircraft_request(
|
||||
self, request: AircraftProcurementRequest, budget: float
|
||||
squadrons: list[Squadron], quantity: int, budget: float
|
||||
) -> Tuple[float, bool]:
|
||||
for airbase in self.best_airbases_for(request):
|
||||
unit = self.affordable_aircraft_for(request, airbase, budget)
|
||||
if unit is None:
|
||||
# Can't afford any aircraft capable of performing the
|
||||
# required mission that can operate from this airbase. We
|
||||
# might be able to afford aircraft at other airbases though,
|
||||
# in the case where the airbase we attempted to use is only
|
||||
# able to operate expensive aircraft.
|
||||
for squadron in squadrons:
|
||||
price = squadron.aircraft.price * quantity
|
||||
if price > budget:
|
||||
continue
|
||||
|
||||
budget -= unit.price * request.number
|
||||
airbase.pending_unit_deliveries.order({unit: request.number})
|
||||
squadron.pending_deliveries += quantity
|
||||
budget -= price
|
||||
return budget, True
|
||||
return budget, False
|
||||
|
||||
def purchase_aircraft(self, budget: float) -> float:
|
||||
for request in self.game.coalition_for(self.is_player).procurement_requests:
|
||||
if not list(self.best_airbases_for(request)):
|
||||
squadrons = list(self.best_squadrons_for(request))
|
||||
if not squadrons:
|
||||
# No airbases in range of this request. Skip it.
|
||||
continue
|
||||
budget, fulfilled = self.fulfill_aircraft_request(request, budget)
|
||||
budget, fulfilled = self.fulfill_aircraft_request(
|
||||
squadrons, request.number, budget
|
||||
)
|
||||
if not fulfilled:
|
||||
# The request was not fulfilled because we could not afford any suitable
|
||||
# aircraft. Rather than continuing, which could proceed to buy tons of
|
||||
@ -283,9 +221,32 @@ class ProcurementAi:
|
||||
else:
|
||||
return self.game.theater.enemy_points()
|
||||
|
||||
def best_airbases_for(
|
||||
@staticmethod
|
||||
def squadron_rank_for_task(squadron: Squadron, task: FlightType) -> int:
|
||||
return aircraft_for_task(task).index(squadron.aircraft)
|
||||
|
||||
def compatible_squadrons_at_airbase(
|
||||
self, airbase: ControlPoint, request: AircraftProcurementRequest
|
||||
) -> Iterator[Squadron]:
|
||||
compatible: list[Squadron] = []
|
||||
for squadron in airbase.squadrons:
|
||||
if not squadron.can_auto_assign(request.task_capability):
|
||||
continue
|
||||
if not squadron.can_provide_pilots(request.number):
|
||||
continue
|
||||
|
||||
distance_to_target = meters(request.near.distance_to(airbase))
|
||||
if distance_to_target > squadron.aircraft.max_mission_range:
|
||||
continue
|
||||
compatible.append(squadron)
|
||||
yield from sorted(
|
||||
compatible,
|
||||
key=lambda s: self.squadron_rank_for_task(s, request.task_capability),
|
||||
)
|
||||
|
||||
def best_squadrons_for(
|
||||
self, request: AircraftProcurementRequest
|
||||
) -> Iterator[ControlPoint]:
|
||||
) -> Iterator[Squadron]:
|
||||
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
|
||||
threatened = []
|
||||
for cp in distance_cache.operational_airfields:
|
||||
@ -295,8 +256,10 @@ class ProcurementAi:
|
||||
continue
|
||||
if self.threat_zones.threatened(cp.position):
|
||||
threatened.append(cp)
|
||||
yield cp
|
||||
yield from threatened
|
||||
continue
|
||||
yield from self.compatible_squadrons_at_airbase(cp, request)
|
||||
for threatened_base in threatened:
|
||||
yield from self.compatible_squadrons_at_airbase(threatened_base, request)
|
||||
|
||||
def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
|
||||
worst_supply = math.inf
|
||||
|
||||
180
game/purchaseadapter.py
Normal file
180
game/purchaseadapter.py
Normal file
@ -0,0 +1,180 @@
|
||||
from abc import abstractmethod
|
||||
from typing import TypeVar, Generic
|
||||
|
||||
from game import Game
|
||||
from game.coalition import Coalition
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.squadrons import Squadron
|
||||
from game.theater import ControlPoint
|
||||
|
||||
ItemType = TypeVar("ItemType")
|
||||
|
||||
|
||||
class TransactionError(RuntimeError):
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class PurchaseAdapter(Generic[ItemType]):
|
||||
def __init__(self, coalition: Coalition) -> None:
|
||||
self.coalition = coalition
|
||||
|
||||
def buy(self, item: ItemType, quantity: int) -> None:
|
||||
for _ in range(quantity):
|
||||
if self.has_pending_sales(item):
|
||||
self.do_cancel_sale(item)
|
||||
elif self.can_buy(item):
|
||||
self.do_purchase(item)
|
||||
else:
|
||||
raise TransactionError(f"Cannot buy more {item}")
|
||||
self.coalition.adjust_budget(-self.price_of(item))
|
||||
|
||||
def sell(self, item: ItemType, quantity: int) -> None:
|
||||
for _ in range(quantity):
|
||||
if self.has_pending_orders(item):
|
||||
self.do_cancel_purchase(item)
|
||||
elif self.can_sell(item):
|
||||
self.do_sale(item)
|
||||
else:
|
||||
raise TransactionError(f"Cannot sell more {item}")
|
||||
self.coalition.adjust_budget(self.price_of(item))
|
||||
|
||||
def has_pending_orders(self, item: ItemType) -> bool:
|
||||
return self.pending_delivery_quantity(item) > 0
|
||||
|
||||
def has_pending_sales(self, item: ItemType) -> bool:
|
||||
return self.pending_delivery_quantity(item) < 0
|
||||
|
||||
@abstractmethod
|
||||
def current_quantity_of(self, item: ItemType) -> int:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def pending_delivery_quantity(self, item: ItemType) -> int:
|
||||
...
|
||||
|
||||
def expected_quantity_next_turn(self, item: ItemType) -> int:
|
||||
return self.current_quantity_of(item) + self.pending_delivery_quantity(item)
|
||||
|
||||
def can_buy(self, item: ItemType) -> bool:
|
||||
return self.coalition.budget >= self.price_of(item)
|
||||
|
||||
def can_sell_or_cancel(self, item: ItemType) -> bool:
|
||||
return self.can_sell(item) or self.has_pending_orders(item)
|
||||
|
||||
@abstractmethod
|
||||
def can_sell(self, item: ItemType) -> bool:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def do_purchase(self, item: ItemType) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def do_cancel_purchase(self, item: ItemType) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def do_sale(self, item: ItemType) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def do_cancel_sale(self, item: ItemType) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def price_of(self, item: ItemType) -> int:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def name_of(self, item: ItemType, multiline: bool = False) -> str:
|
||||
...
|
||||
|
||||
|
||||
class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]):
|
||||
def __init__(
|
||||
self, control_point: ControlPoint, coalition: Coalition, game: Game
|
||||
) -> None:
|
||||
super().__init__(coalition)
|
||||
self.control_point = control_point
|
||||
self.game = game
|
||||
|
||||
def pending_delivery_quantity(self, item: Squadron) -> int:
|
||||
return item.pending_deliveries
|
||||
|
||||
def current_quantity_of(self, item: Squadron) -> int:
|
||||
return item.owned_aircraft
|
||||
|
||||
def can_buy(self, item: Squadron) -> bool:
|
||||
return (
|
||||
super().can_buy(item)
|
||||
and self.control_point.unclaimed_parking(self.game) > 0
|
||||
)
|
||||
|
||||
def can_sell(self, item: Squadron) -> bool:
|
||||
return item.untasked_aircraft > 0
|
||||
|
||||
def do_purchase(self, item: Squadron) -> None:
|
||||
item.pending_deliveries += 1
|
||||
|
||||
def do_cancel_purchase(self, item: Squadron) -> None:
|
||||
item.pending_deliveries -= 1
|
||||
|
||||
def do_sale(self, item: Squadron) -> None:
|
||||
item.untasked_aircraft -= 1
|
||||
item.pending_deliveries -= 1
|
||||
|
||||
def do_cancel_sale(self, item: Squadron) -> None:
|
||||
item.untasked_aircraft += 1
|
||||
item.pending_deliveries += 1
|
||||
|
||||
def price_of(self, item: Squadron) -> int:
|
||||
return item.aircraft.price
|
||||
|
||||
def name_of(self, item: Squadron, multiline: bool = False) -> str:
|
||||
if multiline:
|
||||
separator = "<br />"
|
||||
else:
|
||||
separator = " "
|
||||
return separator.join([item.aircraft.name, str(item)])
|
||||
|
||||
|
||||
class GroundUnitPurchaseAdapter(PurchaseAdapter[GroundUnitType]):
|
||||
def __init__(
|
||||
self, control_point: ControlPoint, coalition: Coalition, game: Game
|
||||
) -> None:
|
||||
super().__init__(coalition)
|
||||
self.control_point = control_point
|
||||
self.game = game
|
||||
|
||||
def pending_delivery_quantity(self, item: GroundUnitType) -> int:
|
||||
return self.control_point.ground_unit_orders.pending_orders(item)
|
||||
|
||||
def current_quantity_of(self, item: GroundUnitType) -> int:
|
||||
return self.control_point.base.total_units_of_type(item)
|
||||
|
||||
def can_buy(self, item: GroundUnitType) -> bool:
|
||||
return super().can_buy(item) and self.control_point.has_ground_unit_source(
|
||||
self.game
|
||||
)
|
||||
|
||||
def can_sell(self, item: GroundUnitType) -> bool:
|
||||
return False
|
||||
|
||||
def do_purchase(self, item: GroundUnitType) -> None:
|
||||
self.control_point.ground_unit_orders.order({item: 1})
|
||||
|
||||
def do_cancel_purchase(self, item: GroundUnitType) -> None:
|
||||
self.control_point.ground_unit_orders.sell({item: 1})
|
||||
|
||||
def do_sale(self, item: GroundUnitType) -> None:
|
||||
raise TransactionError("Sale of ground units not allowed")
|
||||
|
||||
def do_cancel_sale(self, item: GroundUnitType) -> None:
|
||||
raise TransactionError("Sale of ground units not allowed")
|
||||
|
||||
def price_of(self, item: GroundUnitType) -> int:
|
||||
return item.price
|
||||
|
||||
def name_of(self, item: GroundUnitType, multiline: bool = False) -> str:
|
||||
return f"{item}"
|
||||
@ -4,7 +4,8 @@ from dataclasses import dataclass
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen import FlightData, AirSupport
|
||||
from gen.aircraft import FlightData
|
||||
from gen.airsupport import AirSupport
|
||||
|
||||
|
||||
class RadioChannelAllocator:
|
||||
|
||||
@ -16,78 +16,81 @@ class AutoAtoBehavior(Enum):
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
version: Optional[str] = None
|
||||
|
||||
# Difficulty settings
|
||||
# AI Difficulty
|
||||
player_skill: str = "Good"
|
||||
enemy_skill: str = "Average"
|
||||
ai_pilot_levelling: bool = True
|
||||
enemy_vehicle_skill: str = "Average"
|
||||
map_coalition_visibility: ForcedOptions.Views = ForcedOptions.Views.All
|
||||
labels: str = "Full"
|
||||
only_player_takeoff: bool = True # Legacy parameter do not use
|
||||
night_disabled: bool = False
|
||||
external_views_allowed: bool = True
|
||||
supercarrier: bool = False
|
||||
generate_marks: bool = True
|
||||
manpads: bool = True
|
||||
version: Optional[str] = None
|
||||
player_income_multiplier: float = 1.0
|
||||
enemy_income_multiplier: float = 1.0
|
||||
invulnerable_player_pilots: bool = True
|
||||
# Mission Difficulty
|
||||
night_disabled: bool = False
|
||||
manpads: bool = True
|
||||
# Mission Restrictions
|
||||
labels: str = "Full"
|
||||
map_coalition_visibility: ForcedOptions.Views = ForcedOptions.Views.All
|
||||
external_views_allowed: bool = True
|
||||
battle_damage_assessment: Optional[bool] = None
|
||||
|
||||
# Campaign management
|
||||
# General
|
||||
restrict_weapons_by_date: bool = False
|
||||
disable_legacy_aewc: bool = True
|
||||
disable_legacy_tanker: bool = True
|
||||
# Pilots and Squadrons
|
||||
ai_pilot_levelling: bool = True
|
||||
#: Feature flag for squadron limits.
|
||||
enable_squadron_pilot_limits: bool = False
|
||||
|
||||
#: The maximum number of pilots a squadron can have at one time. Changing this after
|
||||
#: the campaign has started will have no immediate effect; pilots already in the
|
||||
#: squadron will not be removed if the limit is lowered and pilots will not be
|
||||
#: immediately created if the limit is raised.
|
||||
squadron_pilot_limit: int = 12
|
||||
|
||||
#: The number of pilots a squadron can replace per turn.
|
||||
squadron_replenishment_rate: int = 4
|
||||
|
||||
default_start_type: str = "Cold"
|
||||
|
||||
# Mission specific
|
||||
desired_player_mission_duration: timedelta = timedelta(minutes=60)
|
||||
|
||||
# Campaign management
|
||||
# HQ Automation
|
||||
automate_runway_repair: bool = False
|
||||
automate_front_line_reinforcements: bool = False
|
||||
automate_aircraft_reinforcements: bool = False
|
||||
automate_front_line_stance: bool = True
|
||||
restrict_weapons_by_date: bool = False
|
||||
disable_legacy_aewc: bool = True
|
||||
disable_legacy_tanker: bool = True
|
||||
generate_dark_kneeboard: bool = False
|
||||
invulnerable_player_pilots: bool = True
|
||||
auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default
|
||||
auto_ato_player_missions_asap: bool = True
|
||||
automate_front_line_stance: bool = True
|
||||
reserves_procurement_target: int = 10
|
||||
|
||||
# Performance oriented
|
||||
perf_red_alert_state: bool = True
|
||||
# Mission Generator
|
||||
# Gameplay
|
||||
supercarrier: bool = False
|
||||
generate_marks: bool = True
|
||||
generate_dark_kneeboard: bool = False
|
||||
never_delay_player_flights: bool = False
|
||||
default_start_type: str = "Cold"
|
||||
# Mission specific
|
||||
desired_player_mission_duration: timedelta = timedelta(minutes=60)
|
||||
# Performance
|
||||
perf_smoke_gen: bool = True
|
||||
perf_smoke_spacing = 1600
|
||||
perf_red_alert_state: bool = True
|
||||
perf_artillery: bool = True
|
||||
perf_moving_units: bool = True
|
||||
perf_infantry: bool = True
|
||||
perf_destroyed_units: bool = True
|
||||
reserves_procurement_target: int = 10
|
||||
|
||||
# Performance culling
|
||||
perf_culling: bool = False
|
||||
perf_culling_distance: int = 100
|
||||
perf_do_not_cull_carrier = True
|
||||
|
||||
# LUA Plugins system
|
||||
plugins: Dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
# Cheating
|
||||
show_red_ato: bool = False
|
||||
enable_frontline_cheats: bool = False
|
||||
enable_base_capture_cheat: bool = False
|
||||
|
||||
never_delay_player_flights: bool = False
|
||||
# LUA Plugins system
|
||||
plugins: Dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
only_player_takeoff: bool = True # Legacy parameter do not use
|
||||
|
||||
@staticmethod
|
||||
def plugin_settings_key(identifier: str) -> str:
|
||||
|
||||
@ -1,503 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import unique, Enum
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Tuple,
|
||||
TYPE_CHECKING,
|
||||
Optional,
|
||||
Iterator,
|
||||
Sequence,
|
||||
Any,
|
||||
)
|
||||
|
||||
import yaml
|
||||
from faker import Faker
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.settings import AutoAtoBehavior, Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.coalition import Coalition
|
||||
from gen.flights.flight import FlightType
|
||||
from game.theater import ControlPoint
|
||||
|
||||
|
||||
@dataclass
|
||||
class PilotRecord:
|
||||
missions_flown: int = field(default=0)
|
||||
|
||||
|
||||
@unique
|
||||
class PilotStatus(Enum):
|
||||
Active = "Active"
|
||||
OnLeave = "On leave"
|
||||
Dead = "Dead"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pilot:
|
||||
name: str
|
||||
player: bool = field(default=False)
|
||||
status: PilotStatus = field(default=PilotStatus.Active)
|
||||
record: PilotRecord = field(default_factory=PilotRecord)
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
return self.status is not PilotStatus.Dead
|
||||
|
||||
@property
|
||||
def on_leave(self) -> bool:
|
||||
return self.status is PilotStatus.OnLeave
|
||||
|
||||
def send_on_leave(self) -> None:
|
||||
if self.status is not PilotStatus.Active:
|
||||
raise RuntimeError("Only active pilots may be sent on leave")
|
||||
self.status = PilotStatus.OnLeave
|
||||
|
||||
def return_from_leave(self) -> None:
|
||||
if self.status is not PilotStatus.OnLeave:
|
||||
raise RuntimeError("Only pilots on leave may be returned from leave")
|
||||
self.status = PilotStatus.Active
|
||||
|
||||
def kill(self) -> None:
|
||||
self.status = PilotStatus.Dead
|
||||
|
||||
@classmethod
|
||||
def random(cls, faker: Faker) -> Pilot:
|
||||
return Pilot(faker.name())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperatingBases:
|
||||
shore: bool
|
||||
carrier: bool
|
||||
lha: bool
|
||||
|
||||
@classmethod
|
||||
def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases:
|
||||
if aircraft.dcs_unit_type.helicopter:
|
||||
# Helicopters operate from anywhere by default.
|
||||
return OperatingBases(shore=True, carrier=True, lha=True)
|
||||
if aircraft.lha_capable:
|
||||
# Marine aircraft operate from LHAs and the shore by default.
|
||||
return OperatingBases(shore=True, carrier=False, lha=True)
|
||||
if aircraft.carrier_capable:
|
||||
# Carrier aircraft operate from carriers by default.
|
||||
return OperatingBases(shore=False, carrier=True, lha=False)
|
||||
# And the rest are only capable of shore operation.
|
||||
return OperatingBases(shore=True, carrier=False, lha=False)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases:
|
||||
return dataclasses.replace(
|
||||
OperatingBases.default_for_aircraft(aircraft), **data
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Squadron:
|
||||
name: str
|
||||
nickname: Optional[str]
|
||||
country: str
|
||||
role: str
|
||||
aircraft: AircraftType
|
||||
livery: Optional[str]
|
||||
mission_types: tuple[FlightType, ...]
|
||||
operating_bases: OperatingBases
|
||||
|
||||
#: The pool of pilots that have not yet been assigned to the squadron. This only
|
||||
#: happens when a preset squadron defines more preset pilots than the squadron limit
|
||||
#: allows. This pool will be consumed before random pilots are generated.
|
||||
pilot_pool: list[Pilot]
|
||||
|
||||
current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False)
|
||||
available_pilots: list[Pilot] = field(
|
||||
default_factory=list, init=False, hash=False, compare=False
|
||||
)
|
||||
|
||||
auto_assignable_mission_types: set[FlightType] = field(
|
||||
init=False, hash=False, compare=False
|
||||
)
|
||||
|
||||
coalition: Coalition = field(hash=False, compare=False)
|
||||
settings: Settings = field(hash=False, compare=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.auto_assignable_mission_types = set(self.mission_types)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.nickname is None:
|
||||
return self.name
|
||||
return f'{self.name} "{self.nickname}"'
|
||||
|
||||
@property
|
||||
def player(self) -> bool:
|
||||
return self.coalition.player
|
||||
|
||||
@property
|
||||
def pilot_limits_enabled(self) -> bool:
|
||||
return self.settings.enable_squadron_pilot_limits
|
||||
|
||||
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
|
||||
if self.pilot_limits_enabled:
|
||||
return None
|
||||
self._recruit_pilots(1)
|
||||
return self.available_pilots.pop()
|
||||
|
||||
def claim_available_pilot(self) -> Optional[Pilot]:
|
||||
if not self.available_pilots:
|
||||
return self.claim_new_pilot_if_allowed()
|
||||
|
||||
# For opfor, so player/AI option is irrelevant.
|
||||
if not self.player:
|
||||
return self.available_pilots.pop()
|
||||
|
||||
preference = self.settings.auto_ato_behavior
|
||||
|
||||
# No preference, so the first pilot is fine.
|
||||
if preference is AutoAtoBehavior.Default:
|
||||
return self.available_pilots.pop()
|
||||
|
||||
prefer_players = preference is AutoAtoBehavior.Prefer
|
||||
for pilot in self.available_pilots:
|
||||
if pilot.player == prefer_players:
|
||||
self.available_pilots.remove(pilot)
|
||||
return pilot
|
||||
|
||||
# No pilot was found that matched the user's preference.
|
||||
#
|
||||
# If they chose to *never* assign players and only players remain in the pool,
|
||||
# we cannot fill the slot with the available pilots.
|
||||
#
|
||||
# If they only *prefer* players and we're out of players, just return an AI
|
||||
# pilot.
|
||||
if not prefer_players:
|
||||
return self.claim_new_pilot_if_allowed()
|
||||
return self.available_pilots.pop()
|
||||
|
||||
def claim_pilot(self, pilot: Pilot) -> None:
|
||||
if pilot not in self.available_pilots:
|
||||
raise ValueError(
|
||||
f"Cannot assign {pilot} to {self} because they are not available"
|
||||
)
|
||||
self.available_pilots.remove(pilot)
|
||||
|
||||
def return_pilot(self, pilot: Pilot) -> None:
|
||||
self.available_pilots.append(pilot)
|
||||
|
||||
def return_pilots(self, pilots: Sequence[Pilot]) -> None:
|
||||
# Return in reverse so that returning two pilots and then getting two more
|
||||
# results in the same ordering. This happens commonly when resetting rosters in
|
||||
# the UI, when we clear the roster because the UI is updating, then end up
|
||||
# repopulating the same size flight from the same squadron.
|
||||
self.available_pilots.extend(reversed(pilots))
|
||||
|
||||
def _recruit_pilots(self, count: int) -> None:
|
||||
new_pilots = self.pilot_pool[:count]
|
||||
self.pilot_pool = self.pilot_pool[count:]
|
||||
count -= len(new_pilots)
|
||||
new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)])
|
||||
self.current_roster.extend(new_pilots)
|
||||
self.available_pilots.extend(new_pilots)
|
||||
|
||||
def populate_for_turn_0(self) -> None:
|
||||
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
|
||||
raise ValueError("Squadrons can only be created with active pilots.")
|
||||
self._recruit_pilots(self.settings.squadron_pilot_limit)
|
||||
|
||||
def replenish_lost_pilots(self) -> None:
|
||||
if not self.pilot_limits_enabled:
|
||||
return
|
||||
|
||||
replenish_count = min(
|
||||
self.settings.squadron_replenishment_rate,
|
||||
self._number_of_unfilled_pilot_slots,
|
||||
)
|
||||
if replenish_count > 0:
|
||||
self._recruit_pilots(replenish_count)
|
||||
|
||||
def return_all_pilots(self) -> None:
|
||||
self.available_pilots = list(self.active_pilots)
|
||||
|
||||
@staticmethod
|
||||
def send_on_leave(pilot: Pilot) -> None:
|
||||
pilot.send_on_leave()
|
||||
|
||||
def return_from_leave(self, pilot: Pilot) -> None:
|
||||
if not self.has_unfilled_pilot_slots:
|
||||
raise RuntimeError(
|
||||
f"Cannot return {pilot} from leave because {self} is full"
|
||||
)
|
||||
pilot.return_from_leave()
|
||||
|
||||
@property
|
||||
def faker(self) -> Faker:
|
||||
return self.coalition.faker
|
||||
|
||||
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
|
||||
return [p for p in self.current_roster if p.status == status]
|
||||
|
||||
def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]:
|
||||
return [p for p in self.current_roster if p.status != status]
|
||||
|
||||
@property
|
||||
def active_pilots(self) -> list[Pilot]:
|
||||
return self._pilots_with_status(PilotStatus.Active)
|
||||
|
||||
@property
|
||||
def pilots_on_leave(self) -> list[Pilot]:
|
||||
return self._pilots_with_status(PilotStatus.OnLeave)
|
||||
|
||||
@property
|
||||
def number_of_pilots_including_inactive(self) -> int:
|
||||
return len(self.current_roster)
|
||||
|
||||
@property
|
||||
def _number_of_unfilled_pilot_slots(self) -> int:
|
||||
return self.settings.squadron_pilot_limit - len(self.active_pilots)
|
||||
|
||||
@property
|
||||
def number_of_available_pilots(self) -> int:
|
||||
return len(self.available_pilots)
|
||||
|
||||
def can_provide_pilots(self, count: int) -> bool:
|
||||
return not self.pilot_limits_enabled or self.number_of_available_pilots >= count
|
||||
|
||||
@property
|
||||
def has_available_pilots(self) -> bool:
|
||||
return not self.pilot_limits_enabled or bool(self.available_pilots)
|
||||
|
||||
@property
|
||||
def has_unfilled_pilot_slots(self) -> bool:
|
||||
return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0
|
||||
|
||||
def can_auto_assign(self, task: FlightType) -> bool:
|
||||
return task in self.auto_assignable_mission_types
|
||||
|
||||
def operates_from(self, control_point: ControlPoint) -> bool:
|
||||
if control_point.is_carrier:
|
||||
return self.operating_bases.carrier
|
||||
elif control_point.is_lha:
|
||||
return self.operating_bases.lha
|
||||
else:
|
||||
return self.operating_bases.shore
|
||||
|
||||
def pilot_at_index(self, index: int) -> Pilot:
|
||||
return self.current_roster[index]
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Path, game: Game, coalition: Coalition) -> Squadron:
|
||||
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
with path.open(encoding="utf8") as squadron_file:
|
||||
data = yaml.safe_load(squadron_file)
|
||||
|
||||
name = data["aircraft"]
|
||||
try:
|
||||
unit_type = AircraftType.named(name)
|
||||
except KeyError as ex:
|
||||
raise KeyError(f"Could not find any aircraft named {name}") from ex
|
||||
|
||||
pilots = [Pilot(n, player=False) for n in data.get("pilots", [])]
|
||||
pilots.extend([Pilot(n, player=True) for n in data.get("players", [])])
|
||||
|
||||
mission_types = [FlightType.from_name(n) for n in data["mission_types"]]
|
||||
tasks = tasks_for_aircraft(unit_type)
|
||||
for mission_type in list(mission_types):
|
||||
if mission_type not in tasks:
|
||||
logging.error(
|
||||
f"Squadron has mission type {mission_type} but {unit_type} is not "
|
||||
f"capable of that task: {path}"
|
||||
)
|
||||
mission_types.remove(mission_type)
|
||||
|
||||
return Squadron(
|
||||
name=data["name"],
|
||||
nickname=data.get("nickname"),
|
||||
country=data["country"],
|
||||
role=data["role"],
|
||||
aircraft=unit_type,
|
||||
livery=data.get("livery"),
|
||||
mission_types=tuple(mission_types),
|
||||
operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),
|
||||
pilot_pool=pilots,
|
||||
coalition=coalition,
|
||||
settings=game.settings,
|
||||
)
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# TODO: Remove save compat.
|
||||
if "auto_assignable_mission_types" not in state:
|
||||
state["auto_assignable_mission_types"] = set(state["mission_types"])
|
||||
self.__dict__.update(state)
|
||||
|
||||
|
||||
class SquadronLoader:
|
||||
def __init__(self, game: Game, coalition: Coalition) -> None:
|
||||
self.game = game
|
||||
self.coalition = coalition
|
||||
|
||||
@staticmethod
|
||||
def squadron_directories() -> Iterator[Path]:
|
||||
from game import persistency
|
||||
|
||||
yield Path(persistency.base_path()) / "Liberation/Squadrons"
|
||||
yield Path("resources/squadrons")
|
||||
|
||||
def load(self) -> dict[AircraftType, list[Squadron]]:
|
||||
squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
|
||||
country = self.coalition.country_name
|
||||
faction = self.coalition.faction
|
||||
any_country = country.startswith("Combined Joint Task Forces ")
|
||||
for directory in self.squadron_directories():
|
||||
for path, squadron in self.load_squadrons_from(directory):
|
||||
if not any_country and squadron.country != country:
|
||||
logging.debug(
|
||||
"Not using squadron for non-matching country (is "
|
||||
f"{squadron.country}, need {country}: {path}"
|
||||
)
|
||||
continue
|
||||
if squadron.aircraft not in faction.aircrafts:
|
||||
logging.debug(
|
||||
f"Not using squadron because {faction.name} cannot use "
|
||||
f"{squadron.aircraft}: {path}"
|
||||
)
|
||||
continue
|
||||
logging.debug(
|
||||
f"Found {squadron.name} {squadron.aircraft} {squadron.role} "
|
||||
f"compatible with {faction.name}"
|
||||
)
|
||||
squadrons[squadron.aircraft].append(squadron)
|
||||
# Convert away from defaultdict because defaultdict doesn't unpickle so we don't
|
||||
# want it in the save state.
|
||||
return dict(squadrons)
|
||||
|
||||
def load_squadrons_from(self, directory: Path) -> Iterator[Tuple[Path, Squadron]]:
|
||||
logging.debug(f"Looking for factions in {directory}")
|
||||
# First directory level is the aircraft type so that historical squadrons that
|
||||
# have flown multiple airframes can be defined as many times as needed. The main
|
||||
# load() method is responsible for filtering out squadrons that aren't
|
||||
# compatible with the faction.
|
||||
for squadron_path in directory.glob("*/*.yaml"):
|
||||
try:
|
||||
yield squadron_path, Squadron.from_yaml(
|
||||
squadron_path, self.game, self.coalition
|
||||
)
|
||||
except Exception as ex:
|
||||
raise RuntimeError(
|
||||
f"Failed to load squadron defined by {squadron_path}"
|
||||
) from ex
|
||||
|
||||
|
||||
class AirWing:
|
||||
def __init__(self, game: Game, coalition: Coalition) -> None:
|
||||
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
|
||||
|
||||
self.game = game
|
||||
self.squadrons = SquadronLoader(game, coalition).load()
|
||||
|
||||
count = itertools.count(1)
|
||||
for aircraft in coalition.faction.aircrafts:
|
||||
if aircraft in self.squadrons:
|
||||
continue
|
||||
self.squadrons[aircraft] = [
|
||||
Squadron(
|
||||
name=f"Squadron {next(count):03}",
|
||||
nickname=self.random_nickname(),
|
||||
country=coalition.country_name,
|
||||
role="Flying Squadron",
|
||||
aircraft=aircraft,
|
||||
livery=None,
|
||||
mission_types=tuple(tasks_for_aircraft(aircraft)),
|
||||
operating_bases=OperatingBases.default_for_aircraft(aircraft),
|
||||
pilot_pool=[],
|
||||
coalition=coalition,
|
||||
settings=game.settings,
|
||||
)
|
||||
]
|
||||
|
||||
def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]:
|
||||
return self.squadrons[aircraft]
|
||||
|
||||
def can_auto_plan(self, task: FlightType) -> bool:
|
||||
try:
|
||||
next(self.auto_assignable_for_task(task))
|
||||
return True
|
||||
except StopIteration:
|
||||
return False
|
||||
|
||||
def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]:
|
||||
for squadron in self.iter_squadrons():
|
||||
if squadron.can_auto_assign(task):
|
||||
yield squadron
|
||||
|
||||
def auto_assignable_for_task_with_type(
|
||||
self, aircraft: AircraftType, task: FlightType
|
||||
) -> Iterator[Squadron]:
|
||||
for squadron in self.squadrons_for(aircraft):
|
||||
if squadron.can_auto_assign(task) and squadron.has_available_pilots:
|
||||
yield squadron
|
||||
|
||||
def squadron_for(self, aircraft: AircraftType) -> Squadron:
|
||||
return self.squadrons_for(aircraft)[0]
|
||||
|
||||
def iter_squadrons(self) -> Iterator[Squadron]:
|
||||
return itertools.chain.from_iterable(self.squadrons.values())
|
||||
|
||||
def squadron_at_index(self, index: int) -> Squadron:
|
||||
return list(self.iter_squadrons())[index]
|
||||
|
||||
def populate_for_turn_0(self) -> None:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.populate_for_turn_0()
|
||||
|
||||
def replenish(self) -> None:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.replenish_lost_pilots()
|
||||
|
||||
def reset(self) -> None:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.return_all_pilots()
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return sum(len(s) for s in self.squadrons.values())
|
||||
|
||||
@staticmethod
|
||||
def _make_random_nickname() -> str:
|
||||
from gen.naming import ANIMALS
|
||||
|
||||
animal = random.choice(ANIMALS)
|
||||
adjective = random.choice(
|
||||
(
|
||||
None,
|
||||
"Red",
|
||||
"Blue",
|
||||
"Green",
|
||||
"Golden",
|
||||
"Black",
|
||||
"Fighting",
|
||||
"Flying",
|
||||
)
|
||||
)
|
||||
if adjective is None:
|
||||
return animal.title()
|
||||
return f"{adjective} {animal}".title()
|
||||
|
||||
def random_nickname(self) -> str:
|
||||
while True:
|
||||
nickname = self._make_random_nickname()
|
||||
for squadron in self.iter_squadrons():
|
||||
if squadron.nickname == nickname:
|
||||
break
|
||||
else:
|
||||
return nickname
|
||||
3
game/squadrons/__init__.py
Normal file
3
game/squadrons/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .airwing import AirWing
|
||||
from .pilot import Pilot
|
||||
from .squadron import Squadron
|
||||
89
game/squadrons/airwing.py
Normal file
89
game/squadrons/airwing.py
Normal file
@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from typing import Sequence, Iterator, TYPE_CHECKING
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from .squadron import Squadron
|
||||
from ..theater import ControlPoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
class AirWing:
|
||||
def __init__(self, game: Game) -> None:
|
||||
self.game = game
|
||||
self.squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
|
||||
|
||||
def add_squadron(self, squadron: Squadron) -> None:
|
||||
squadron.location.squadrons.append(squadron)
|
||||
self.squadrons[squadron.aircraft].append(squadron)
|
||||
|
||||
def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]:
|
||||
return self.squadrons[aircraft]
|
||||
|
||||
def can_auto_plan(self, task: FlightType) -> bool:
|
||||
try:
|
||||
next(self.auto_assignable_for_task(task))
|
||||
return True
|
||||
except StopIteration:
|
||||
return False
|
||||
|
||||
@property
|
||||
def available_aircraft_types(self) -> Iterator[AircraftType]:
|
||||
for aircraft, squadrons in self.squadrons.items():
|
||||
for squadron in squadrons:
|
||||
if squadron.untasked_aircraft:
|
||||
yield aircraft
|
||||
break
|
||||
|
||||
def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]:
|
||||
for squadron in self.iter_squadrons():
|
||||
if squadron.can_auto_assign(task):
|
||||
yield squadron
|
||||
|
||||
def auto_assignable_for_task_at(
|
||||
self, task: FlightType, base: ControlPoint
|
||||
) -> Iterator[Squadron]:
|
||||
for squadron in self.iter_squadrons():
|
||||
if squadron.can_auto_assign(task) and squadron.location == base:
|
||||
yield squadron
|
||||
|
||||
def auto_assignable_for_task_with_type(
|
||||
self, aircraft: AircraftType, task: FlightType, base: ControlPoint
|
||||
) -> Iterator[Squadron]:
|
||||
for squadron in self.squadrons_for(aircraft):
|
||||
if (
|
||||
squadron.location == base
|
||||
and squadron.can_auto_assign(task)
|
||||
and squadron.has_available_pilots
|
||||
):
|
||||
yield squadron
|
||||
|
||||
def squadron_for(self, aircraft: AircraftType) -> Squadron:
|
||||
return self.squadrons_for(aircraft)[0]
|
||||
|
||||
def iter_squadrons(self) -> Iterator[Squadron]:
|
||||
return itertools.chain.from_iterable(self.squadrons.values())
|
||||
|
||||
def squadron_at_index(self, index: int) -> Squadron:
|
||||
return list(self.iter_squadrons())[index]
|
||||
|
||||
def populate_for_turn_0(self) -> None:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.populate_for_turn_0()
|
||||
|
||||
def replenish(self) -> None:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.replenish_lost_pilots()
|
||||
|
||||
def reset(self) -> None:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.return_all_pilots_and_aircraft()
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return sum(len(s) for s in self.squadrons.values())
|
||||
33
game/squadrons/operatingbases.py
Normal file
33
game/squadrons/operatingbases.py
Normal file
@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperatingBases:
|
||||
shore: bool
|
||||
carrier: bool
|
||||
lha: bool
|
||||
|
||||
@classmethod
|
||||
def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases:
|
||||
if aircraft.dcs_unit_type.helicopter:
|
||||
# Helicopters operate from anywhere by default.
|
||||
return OperatingBases(shore=True, carrier=True, lha=True)
|
||||
if aircraft.lha_capable:
|
||||
# Marine aircraft operate from LHAs and the shore by default.
|
||||
return OperatingBases(shore=True, carrier=False, lha=True)
|
||||
if aircraft.carrier_capable:
|
||||
# Carrier aircraft operate from carriers by default.
|
||||
return OperatingBases(shore=False, carrier=True, lha=False)
|
||||
# And the rest are only capable of shore operation.
|
||||
return OperatingBases(shore=True, carrier=False, lha=False)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases:
|
||||
return dataclasses.replace(
|
||||
OperatingBases.default_for_aircraft(aircraft), **data
|
||||
)
|
||||
51
game/squadrons/pilot.py
Normal file
51
game/squadrons/pilot.py
Normal file
@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import unique, Enum
|
||||
|
||||
from faker import Faker
|
||||
|
||||
|
||||
@dataclass
|
||||
class PilotRecord:
|
||||
missions_flown: int = field(default=0)
|
||||
|
||||
|
||||
@unique
|
||||
class PilotStatus(Enum):
|
||||
Active = "Active"
|
||||
OnLeave = "On leave"
|
||||
Dead = "Dead"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pilot:
|
||||
name: str
|
||||
player: bool = field(default=False)
|
||||
status: PilotStatus = field(default=PilotStatus.Active)
|
||||
record: PilotRecord = field(default_factory=PilotRecord)
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
return self.status is not PilotStatus.Dead
|
||||
|
||||
@property
|
||||
def on_leave(self) -> bool:
|
||||
return self.status is PilotStatus.OnLeave
|
||||
|
||||
def send_on_leave(self) -> None:
|
||||
if self.status is not PilotStatus.Active:
|
||||
raise RuntimeError("Only active pilots may be sent on leave")
|
||||
self.status = PilotStatus.OnLeave
|
||||
|
||||
def return_from_leave(self) -> None:
|
||||
if self.status is not PilotStatus.OnLeave:
|
||||
raise RuntimeError("Only pilots on leave may be returned from leave")
|
||||
self.status = PilotStatus.Active
|
||||
|
||||
def kill(self) -> None:
|
||||
self.status = PilotStatus.Dead
|
||||
|
||||
@classmethod
|
||||
def random(cls, faker: Faker) -> Pilot:
|
||||
return Pilot(faker.name())
|
||||
301
game/squadrons/squadron.py
Normal file
301
game/squadrons/squadron.py
Normal file
@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Optional,
|
||||
Sequence,
|
||||
)
|
||||
|
||||
from faker import Faker
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.settings import AutoAtoBehavior, Settings
|
||||
from game.squadrons.operatingbases import OperatingBases
|
||||
from game.squadrons.pilot import Pilot, PilotStatus
|
||||
from game.squadrons.squadrondef import SquadronDef
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.coalition import Coalition
|
||||
from gen.flights.flight import FlightType
|
||||
from game.theater import ControlPoint
|
||||
|
||||
|
||||
@dataclass
|
||||
class Squadron:
|
||||
name: str
|
||||
nickname: Optional[str]
|
||||
country: str
|
||||
role: str
|
||||
aircraft: AircraftType
|
||||
livery: Optional[str]
|
||||
mission_types: tuple[FlightType, ...]
|
||||
operating_bases: OperatingBases
|
||||
|
||||
#: The pool of pilots that have not yet been assigned to the squadron. This only
|
||||
#: happens when a preset squadron defines more preset pilots than the squadron limit
|
||||
#: allows. This pool will be consumed before random pilots are generated.
|
||||
pilot_pool: list[Pilot]
|
||||
|
||||
current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False)
|
||||
available_pilots: list[Pilot] = field(
|
||||
default_factory=list, init=False, hash=False, compare=False
|
||||
)
|
||||
|
||||
auto_assignable_mission_types: set[FlightType] = field(
|
||||
init=False, hash=False, compare=False
|
||||
)
|
||||
|
||||
coalition: Coalition = field(hash=False, compare=False)
|
||||
settings: Settings = field(hash=False, compare=False)
|
||||
|
||||
location: ControlPoint
|
||||
|
||||
owned_aircraft: int = field(init=False, hash=False, compare=False, default=0)
|
||||
untasked_aircraft: int = field(init=False, hash=False, compare=False, default=0)
|
||||
pending_deliveries: int = field(init=False, hash=False, compare=False, default=0)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.auto_assignable_mission_types = set(self.mission_types)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.nickname is None:
|
||||
return self.name
|
||||
return f'{self.name} "{self.nickname}"'
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(
|
||||
(
|
||||
self.name,
|
||||
self.nickname,
|
||||
self.country,
|
||||
self.role,
|
||||
self.aircraft,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def player(self) -> bool:
|
||||
return self.coalition.player
|
||||
|
||||
def assign_to_base(self, base: ControlPoint) -> None:
|
||||
self.location.squadrons.remove(self)
|
||||
self.location = base
|
||||
self.location.squadrons.append(self)
|
||||
logging.debug(f"Assigned {self} to {base}")
|
||||
|
||||
@property
|
||||
def pilot_limits_enabled(self) -> bool:
|
||||
return self.settings.enable_squadron_pilot_limits
|
||||
|
||||
def set_allowed_mission_types(self, mission_types: Iterable[FlightType]) -> None:
|
||||
self.mission_types = tuple(mission_types)
|
||||
self.auto_assignable_mission_types.intersection_update(self.mission_types)
|
||||
|
||||
def set_auto_assignable_mission_types(
|
||||
self, mission_types: Iterable[FlightType]
|
||||
) -> None:
|
||||
self.auto_assignable_mission_types = set(self.mission_types).intersection(
|
||||
mission_types
|
||||
)
|
||||
|
||||
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
|
||||
if self.pilot_limits_enabled:
|
||||
return None
|
||||
self._recruit_pilots(1)
|
||||
return self.available_pilots.pop()
|
||||
|
||||
def claim_available_pilot(self) -> Optional[Pilot]:
|
||||
if not self.available_pilots:
|
||||
return self.claim_new_pilot_if_allowed()
|
||||
|
||||
# For opfor, so player/AI option is irrelevant.
|
||||
if not self.player:
|
||||
return self.available_pilots.pop()
|
||||
|
||||
preference = self.settings.auto_ato_behavior
|
||||
|
||||
# No preference, so the first pilot is fine.
|
||||
if preference is AutoAtoBehavior.Default:
|
||||
return self.available_pilots.pop()
|
||||
|
||||
prefer_players = preference is AutoAtoBehavior.Prefer
|
||||
for pilot in self.available_pilots:
|
||||
if pilot.player == prefer_players:
|
||||
self.available_pilots.remove(pilot)
|
||||
return pilot
|
||||
|
||||
# No pilot was found that matched the user's preference.
|
||||
#
|
||||
# If they chose to *never* assign players and only players remain in the pool,
|
||||
# we cannot fill the slot with the available pilots.
|
||||
#
|
||||
# If they only *prefer* players and we're out of players, just return an AI
|
||||
# pilot.
|
||||
if not prefer_players:
|
||||
return self.claim_new_pilot_if_allowed()
|
||||
return self.available_pilots.pop()
|
||||
|
||||
def claim_pilot(self, pilot: Pilot) -> None:
|
||||
if pilot not in self.available_pilots:
|
||||
raise ValueError(
|
||||
f"Cannot assign {pilot} to {self} because they are not available"
|
||||
)
|
||||
self.available_pilots.remove(pilot)
|
||||
|
||||
def return_pilot(self, pilot: Pilot) -> None:
|
||||
self.available_pilots.append(pilot)
|
||||
|
||||
def return_pilots(self, pilots: Sequence[Pilot]) -> None:
|
||||
# Return in reverse so that returning two pilots and then getting two more
|
||||
# results in the same ordering. This happens commonly when resetting rosters in
|
||||
# the UI, when we clear the roster because the UI is updating, then end up
|
||||
# repopulating the same size flight from the same squadron.
|
||||
self.available_pilots.extend(reversed(pilots))
|
||||
|
||||
def _recruit_pilots(self, count: int) -> None:
|
||||
new_pilots = self.pilot_pool[:count]
|
||||
self.pilot_pool = self.pilot_pool[count:]
|
||||
count -= len(new_pilots)
|
||||
new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)])
|
||||
self.current_roster.extend(new_pilots)
|
||||
self.available_pilots.extend(new_pilots)
|
||||
|
||||
def populate_for_turn_0(self) -> None:
|
||||
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
|
||||
raise ValueError("Squadrons can only be created with active pilots.")
|
||||
self._recruit_pilots(self.settings.squadron_pilot_limit)
|
||||
|
||||
def replenish_lost_pilots(self) -> None:
|
||||
if not self.pilot_limits_enabled:
|
||||
return
|
||||
|
||||
replenish_count = min(
|
||||
self.settings.squadron_replenishment_rate,
|
||||
self._number_of_unfilled_pilot_slots,
|
||||
)
|
||||
if replenish_count > 0:
|
||||
self._recruit_pilots(replenish_count)
|
||||
|
||||
def return_all_pilots_and_aircraft(self) -> None:
|
||||
self.available_pilots = list(self.active_pilots)
|
||||
self.untasked_aircraft = self.owned_aircraft
|
||||
|
||||
@staticmethod
|
||||
def send_on_leave(pilot: Pilot) -> None:
|
||||
pilot.send_on_leave()
|
||||
|
||||
def return_from_leave(self, pilot: Pilot) -> None:
|
||||
if not self.has_unfilled_pilot_slots:
|
||||
raise RuntimeError(
|
||||
f"Cannot return {pilot} from leave because {self} is full"
|
||||
)
|
||||
pilot.return_from_leave()
|
||||
|
||||
@property
|
||||
def faker(self) -> Faker:
|
||||
return self.coalition.faker
|
||||
|
||||
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
|
||||
return [p for p in self.current_roster if p.status == status]
|
||||
|
||||
def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]:
|
||||
return [p for p in self.current_roster if p.status != status]
|
||||
|
||||
@property
|
||||
def max_size(self) -> int:
|
||||
return self.settings.squadron_pilot_limit
|
||||
|
||||
@property
|
||||
def active_pilots(self) -> list[Pilot]:
|
||||
return self._pilots_with_status(PilotStatus.Active)
|
||||
|
||||
@property
|
||||
def pilots_on_leave(self) -> list[Pilot]:
|
||||
return self._pilots_with_status(PilotStatus.OnLeave)
|
||||
|
||||
@property
|
||||
def number_of_pilots_including_inactive(self) -> int:
|
||||
return len(self.current_roster)
|
||||
|
||||
@property
|
||||
def _number_of_unfilled_pilot_slots(self) -> int:
|
||||
return self.max_size - len(self.active_pilots)
|
||||
|
||||
@property
|
||||
def number_of_available_pilots(self) -> int:
|
||||
return len(self.available_pilots)
|
||||
|
||||
def can_provide_pilots(self, count: int) -> bool:
|
||||
return not self.pilot_limits_enabled or self.number_of_available_pilots >= count
|
||||
|
||||
@property
|
||||
def has_available_pilots(self) -> bool:
|
||||
return not self.pilot_limits_enabled or bool(self.available_pilots)
|
||||
|
||||
@property
|
||||
def has_unfilled_pilot_slots(self) -> bool:
|
||||
return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0
|
||||
|
||||
def can_auto_assign(self, task: FlightType) -> bool:
|
||||
return task in self.auto_assignable_mission_types
|
||||
|
||||
def operates_from(self, control_point: ControlPoint) -> bool:
|
||||
if control_point.is_carrier:
|
||||
return self.operating_bases.carrier
|
||||
elif control_point.is_lha:
|
||||
return self.operating_bases.lha
|
||||
else:
|
||||
return self.operating_bases.shore
|
||||
|
||||
def pilot_at_index(self, index: int) -> Pilot:
|
||||
return self.current_roster[index]
|
||||
|
||||
def claim_inventory(self, count: int) -> None:
|
||||
if self.untasked_aircraft < count:
|
||||
raise ValueError(
|
||||
f"Cannot remove {count} from {self.name}. Only have "
|
||||
f"{self.untasked_aircraft}."
|
||||
)
|
||||
self.untasked_aircraft -= count
|
||||
|
||||
def can_fulfill_flight(self, count: int) -> bool:
|
||||
return self.can_provide_pilots(count) and self.untasked_aircraft >= count
|
||||
|
||||
def refund_orders(self) -> None:
|
||||
self.coalition.adjust_budget(self.aircraft.price * self.pending_deliveries)
|
||||
self.pending_deliveries = 0
|
||||
|
||||
def deliver_orders(self) -> None:
|
||||
self.owned_aircraft += self.pending_deliveries
|
||||
self.pending_deliveries = 0
|
||||
|
||||
@property
|
||||
def max_fulfillable_aircraft(self) -> int:
|
||||
return max(self.number_of_available_pilots, self.untasked_aircraft)
|
||||
|
||||
@classmethod
|
||||
def create_from(
|
||||
cls,
|
||||
squadron_def: SquadronDef,
|
||||
base: ControlPoint,
|
||||
coalition: Coalition,
|
||||
game: Game,
|
||||
) -> Squadron:
|
||||
return Squadron(
|
||||
squadron_def.name,
|
||||
squadron_def.nickname,
|
||||
squadron_def.country,
|
||||
squadron_def.role,
|
||||
squadron_def.aircraft,
|
||||
squadron_def.livery,
|
||||
squadron_def.mission_types,
|
||||
squadron_def.operating_bases,
|
||||
squadron_def.pilot_pool,
|
||||
coalition,
|
||||
game.settings,
|
||||
base,
|
||||
)
|
||||
99
game/squadrons/squadrondef.py
Normal file
99
game/squadrons/squadrondef.py
Normal file
@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Optional,
|
||||
)
|
||||
|
||||
import yaml
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.squadrons.operatingbases import OperatingBases
|
||||
from game.squadrons.pilot import Pilot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen.flights.flight import FlightType
|
||||
from game.theater import ControlPoint
|
||||
|
||||
|
||||
@dataclass
|
||||
class SquadronDef:
|
||||
name: str
|
||||
nickname: Optional[str]
|
||||
country: str
|
||||
role: str
|
||||
aircraft: AircraftType
|
||||
livery: Optional[str]
|
||||
mission_types: tuple[FlightType, ...]
|
||||
operating_bases: OperatingBases
|
||||
pilot_pool: list[Pilot]
|
||||
|
||||
auto_assignable_mission_types: set[FlightType] = field(
|
||||
init=False, hash=False, compare=False
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.auto_assignable_mission_types = set(self.mission_types)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.nickname is None:
|
||||
return self.name
|
||||
return f'{self.name} "{self.nickname}"'
|
||||
|
||||
def set_allowed_mission_types(self, mission_types: Iterable[FlightType]) -> None:
|
||||
self.mission_types = tuple(mission_types)
|
||||
self.auto_assignable_mission_types.intersection_update(self.mission_types)
|
||||
|
||||
def can_auto_assign(self, task: FlightType) -> bool:
|
||||
return task in self.auto_assignable_mission_types
|
||||
|
||||
def operates_from(self, control_point: ControlPoint) -> bool:
|
||||
if control_point.is_carrier:
|
||||
return self.operating_bases.carrier
|
||||
elif control_point.is_lha:
|
||||
return self.operating_bases.lha
|
||||
else:
|
||||
return self.operating_bases.shore
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Path) -> SquadronDef:
|
||||
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
with path.open(encoding="utf8") as squadron_file:
|
||||
data = yaml.safe_load(squadron_file)
|
||||
|
||||
name = data["aircraft"]
|
||||
try:
|
||||
unit_type = AircraftType.named(name)
|
||||
except KeyError as ex:
|
||||
raise KeyError(f"Could not find any aircraft named {name}") from ex
|
||||
|
||||
pilots = [Pilot(n, player=False) for n in data.get("pilots", [])]
|
||||
pilots.extend([Pilot(n, player=True) for n in data.get("players", [])])
|
||||
|
||||
mission_types = [FlightType.from_name(n) for n in data["mission_types"]]
|
||||
tasks = tasks_for_aircraft(unit_type)
|
||||
for mission_type in list(mission_types):
|
||||
if mission_type not in tasks:
|
||||
logging.error(
|
||||
f"Squadron has mission type {mission_type} but {unit_type} is not "
|
||||
f"capable of that task: {path}"
|
||||
)
|
||||
mission_types.remove(mission_type)
|
||||
|
||||
return SquadronDef(
|
||||
name=data["name"],
|
||||
nickname=data.get("nickname"),
|
||||
country=data["country"],
|
||||
role=data["role"],
|
||||
aircraft=unit_type,
|
||||
livery=data.get("livery"),
|
||||
mission_types=tuple(mission_types),
|
||||
operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),
|
||||
pilot_pool=pilots,
|
||||
)
|
||||
68
game/squadrons/squadrondefloader.py
Normal file
68
game/squadrons/squadrondefloader.py
Normal file
@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Tuple, TYPE_CHECKING
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from .squadrondef import SquadronDef
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class SquadronDefLoader:
|
||||
def __init__(self, game: Game, coalition: Coalition) -> None:
|
||||
self.game = game
|
||||
self.coalition = coalition
|
||||
|
||||
@staticmethod
|
||||
def squadron_directories() -> Iterator[Path]:
|
||||
from game import persistency
|
||||
|
||||
yield Path(persistency.base_path()) / "Liberation/Squadrons"
|
||||
yield Path("resources/squadrons")
|
||||
|
||||
def load(self) -> dict[AircraftType, list[SquadronDef]]:
|
||||
squadrons: dict[AircraftType, list[SquadronDef]] = defaultdict(list)
|
||||
country = self.coalition.country_name
|
||||
faction = self.coalition.faction
|
||||
any_country = country.startswith("Combined Joint Task Forces ")
|
||||
for directory in self.squadron_directories():
|
||||
for path, squadron_def in self.load_squadrons_from(directory):
|
||||
if not any_country and squadron_def.country != country:
|
||||
logging.debug(
|
||||
"Not using squadron for non-matching country (is "
|
||||
f"{squadron_def.country}, need {country}: {path}"
|
||||
)
|
||||
continue
|
||||
if squadron_def.aircraft not in faction.aircrafts:
|
||||
logging.debug(
|
||||
f"Not using squadron because {faction.name} cannot use "
|
||||
f"{squadron_def.aircraft}: {path}"
|
||||
)
|
||||
continue
|
||||
logging.debug(
|
||||
f"Found {squadron_def.name} {squadron_def.aircraft} "
|
||||
f"{squadron_def.role} compatible with {faction.name}"
|
||||
)
|
||||
|
||||
squadrons[squadron_def.aircraft].append(squadron_def)
|
||||
return squadrons
|
||||
|
||||
@staticmethod
|
||||
def load_squadrons_from(directory: Path) -> Iterator[Tuple[Path, SquadronDef]]:
|
||||
logging.debug(f"Looking for factions in {directory}")
|
||||
# First directory level is the aircraft type so that historical squadrons that
|
||||
# have flown multiple airframes can be defined as many times as needed. The main
|
||||
# load() method is responsible for filtering out squadrons that aren't
|
||||
# compatible with the faction.
|
||||
for squadron_path in directory.glob("*/*.yaml"):
|
||||
try:
|
||||
yield squadron_path, SquadronDef.from_yaml(squadron_path)
|
||||
except Exception as ex:
|
||||
raise RuntimeError(
|
||||
f"Failed to load squadron defined by {squadron_path}"
|
||||
) from ex
|
||||
@ -1,10 +1,4 @@
|
||||
import itertools
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.dcs.unittype import UnitType
|
||||
|
||||
BASE_MAX_STRENGTH = 1.0
|
||||
BASE_MIN_STRENGTH = 0.0
|
||||
@ -12,14 +6,9 @@ BASE_MIN_STRENGTH = 0.0
|
||||
|
||||
class Base:
|
||||
def __init__(self) -> None:
|
||||
self.aircraft: dict[AircraftType, int] = {}
|
||||
self.armor: dict[GroundUnitType, int] = {}
|
||||
self.strength = 1.0
|
||||
|
||||
@property
|
||||
def total_aircraft(self) -> int:
|
||||
return sum(self.aircraft.values())
|
||||
|
||||
@property
|
||||
def total_armor(self) -> int:
|
||||
return sum(self.armor.values())
|
||||
@ -31,49 +20,24 @@ class Base:
|
||||
total += unit_type.price * count
|
||||
return total
|
||||
|
||||
def total_units_of_type(self, unit_type: UnitType[Any]) -> int:
|
||||
return sum(
|
||||
[
|
||||
c
|
||||
for t, c in itertools.chain(self.aircraft.items(), self.armor.items())
|
||||
if t == unit_type
|
||||
]
|
||||
)
|
||||
def total_units_of_type(self, unit_type: GroundUnitType) -> int:
|
||||
return sum([c for t, c in self.armor.items() if t == unit_type])
|
||||
|
||||
def commission_units(self, units: dict[Any, int]) -> None:
|
||||
def commission_units(self, units: dict[GroundUnitType, int]) -> None:
|
||||
for unit_type, unit_count in units.items():
|
||||
if unit_count <= 0:
|
||||
continue
|
||||
self.armor[unit_type] = self.armor.get(unit_type, 0) + unit_count
|
||||
|
||||
target_dict: dict[Any, int]
|
||||
if isinstance(unit_type, AircraftType):
|
||||
target_dict = self.aircraft
|
||||
elif isinstance(unit_type, GroundUnitType):
|
||||
target_dict = self.armor
|
||||
else:
|
||||
logging.error(f"Unexpected unit type of {unit_type}")
|
||||
return
|
||||
|
||||
target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count
|
||||
|
||||
def commit_losses(self, units_lost: dict[Any, int]) -> None:
|
||||
def commit_losses(self, units_lost: dict[GroundUnitType, int]) -> None:
|
||||
for unit_type, count in units_lost.items():
|
||||
target_dict: dict[Any, int]
|
||||
if unit_type in self.aircraft:
|
||||
target_dict = self.aircraft
|
||||
elif unit_type in self.armor:
|
||||
target_dict = self.armor
|
||||
else:
|
||||
print("Base didn't find event type {}".format(unit_type))
|
||||
if unit_type not in self.armor:
|
||||
print("Base didn't find unit type {}".format(unit_type))
|
||||
continue
|
||||
|
||||
if unit_type not in target_dict:
|
||||
print("Base didn't find event type {}".format(unit_type))
|
||||
continue
|
||||
|
||||
target_dict[unit_type] = max(target_dict[unit_type] - count, 0)
|
||||
if target_dict[unit_type] == 0:
|
||||
del target_dict[unit_type]
|
||||
self.armor[unit_type] = max(self.armor[unit_type] - count, 0)
|
||||
if self.armor[unit_type] == 0:
|
||||
del self.armor[unit_type]
|
||||
|
||||
def affect_strength(self, amount: float) -> None:
|
||||
self.strength += amount
|
||||
|
||||
@ -1,27 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from dcs import Mission
|
||||
from dcs.countries import (
|
||||
CombinedJointTaskForcesBlue,
|
||||
CombinedJointTaskForcesRed,
|
||||
)
|
||||
from dcs.country import Country
|
||||
from dcs.mapping import Point
|
||||
from dcs.planes import F_15C
|
||||
from dcs.ships import (
|
||||
HandyWind,
|
||||
Stennis,
|
||||
USS_Arleigh_Burke_IIa,
|
||||
LHA_Tarawa,
|
||||
)
|
||||
from dcs.statics import Fortification, Warehouse
|
||||
from dcs.terrain import (
|
||||
caucasus,
|
||||
nevada,
|
||||
@ -31,497 +15,23 @@ from dcs.terrain import (
|
||||
thechannel,
|
||||
marianaislands,
|
||||
)
|
||||
from dcs.terrain.terrain import Airport, Terrain
|
||||
from dcs.unitgroup import (
|
||||
ShipGroup,
|
||||
StaticGroup,
|
||||
VehicleGroup,
|
||||
PlaneGroup,
|
||||
)
|
||||
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
|
||||
from dcs.terrain.terrain import Terrain
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely import geometry, ops
|
||||
|
||||
from .controlpoint import (
|
||||
Airfield,
|
||||
Carrier,
|
||||
ControlPoint,
|
||||
Fob,
|
||||
Lha,
|
||||
MissionTarget,
|
||||
OffMapSpawn,
|
||||
)
|
||||
from .frontline import FrontLine
|
||||
from .landmap import Landmap, load_landmap, poly_contains
|
||||
from .latlon import LatLon
|
||||
from .projections import TransverseMercator
|
||||
from .seasonalconditions import SeasonalConditions
|
||||
from ..point_with_heading import PointWithHeading
|
||||
from ..positioned import Positioned
|
||||
from ..profiling import logged_duration
|
||||
from ..scenery_group import SceneryGroup
|
||||
from ..utils import Distance, Heading, meters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import TheaterGroundObject
|
||||
|
||||
SIZE_TINY = 150
|
||||
SIZE_SMALL = 600
|
||||
SIZE_REGULAR = 1000
|
||||
SIZE_BIG = 2000
|
||||
SIZE_LARGE = 3000
|
||||
|
||||
IMPORTANCE_LOW = 1
|
||||
IMPORTANCE_MEDIUM = 1.2
|
||||
IMPORTANCE_HIGH = 1.4
|
||||
|
||||
|
||||
class MizCampaignLoader:
|
||||
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
|
||||
RED_COUNTRY = CombinedJointTaskForcesRed()
|
||||
|
||||
OFF_MAP_UNIT_TYPE = F_15C.id
|
||||
|
||||
CV_UNIT_TYPE = Stennis.id
|
||||
LHA_UNIT_TYPE = LHA_Tarawa.id
|
||||
FRONT_LINE_UNIT_TYPE = Armor.M_113.id
|
||||
SHIPPING_LANE_UNIT_TYPE = HandyWind.id
|
||||
|
||||
FOB_UNIT_TYPE = Unarmed.SKP_11.id
|
||||
FARP_HELIPAD = "Invisible FARP"
|
||||
|
||||
OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id
|
||||
SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id
|
||||
MISSILE_SITE_UNIT_TYPE = MissilesSS.Scud_B.id
|
||||
COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.Hy_launcher.id
|
||||
|
||||
# Multiple options for air defenses so campaign designers can more accurately see
|
||||
# the coverage of their IADS for the expected type.
|
||||
LONG_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.Patriot_ln.id,
|
||||
AirDefence.S_300PS_5P85C_ln.id,
|
||||
AirDefence.S_300PS_5P85D_ln.id,
|
||||
}
|
||||
|
||||
MEDIUM_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.Hawk_ln.id,
|
||||
AirDefence.S_75M_Volhov.id,
|
||||
AirDefence._5p73_s_125_ln.id,
|
||||
}
|
||||
|
||||
SHORT_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.M1097_Avenger.id,
|
||||
AirDefence.Rapier_fsa_launcher.id,
|
||||
AirDefence._2S6_Tunguska.id,
|
||||
AirDefence.Strela_1_9P31.id,
|
||||
}
|
||||
|
||||
AAA_UNIT_TYPES = {
|
||||
AirDefence.Flak18.id,
|
||||
AirDefence.Vulcan.id,
|
||||
AirDefence.ZSU_23_4_Shilka.id,
|
||||
}
|
||||
|
||||
EWR_UNIT_TYPE = AirDefence._1L13_EWR.id
|
||||
|
||||
ARMOR_GROUP_UNIT_TYPE = Armor.M_1_Abrams.id
|
||||
|
||||
FACTORY_UNIT_TYPE = Fortification.Workshop_A.id
|
||||
|
||||
AMMUNITION_DEPOT_UNIT_TYPE = Warehouse._Ammunition_depot.id
|
||||
|
||||
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
|
||||
|
||||
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
|
||||
self.theater = theater
|
||||
self.mission = Mission()
|
||||
with logged_duration("Loading miz"):
|
||||
self.mission.load_file(str(miz))
|
||||
self.control_point_id = itertools.count(1000)
|
||||
|
||||
# If there are no red carriers there usually aren't red units. Make sure
|
||||
# both countries are initialized so we don't have to deal with None.
|
||||
if self.mission.country(self.BLUE_COUNTRY.name) is None:
|
||||
self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY)
|
||||
if self.mission.country(self.RED_COUNTRY.name) is None:
|
||||
self.mission.coalition["red"].add_country(self.RED_COUNTRY)
|
||||
|
||||
@staticmethod
|
||||
def control_point_from_airport(airport: Airport) -> ControlPoint:
|
||||
|
||||
# The wiki says this is a legacy property and to just use regular.
|
||||
size = SIZE_REGULAR
|
||||
|
||||
# The importance is taken from the periodicity of the airport's
|
||||
# warehouse divided by 10. 30 is the default, and out of range (valid
|
||||
# values are between 1.0 and 1.4). If it is used, pick the default
|
||||
# importance.
|
||||
if airport.periodicity == 30:
|
||||
importance = IMPORTANCE_MEDIUM
|
||||
else:
|
||||
importance = airport.periodicity / 10
|
||||
|
||||
cp = Airfield(airport, size, importance)
|
||||
cp.captured = airport.is_blue()
|
||||
|
||||
# Use the unlimited aircraft option to determine if an airfield should
|
||||
# be owned by the player when the campaign is "inverted".
|
||||
cp.captured_invert = airport.unlimited_aircrafts
|
||||
|
||||
return cp
|
||||
|
||||
def country(self, blue: bool) -> Country:
|
||||
country = self.mission.country(
|
||||
self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name
|
||||
)
|
||||
# Should be guaranteed because we initialized them.
|
||||
assert country
|
||||
return country
|
||||
|
||||
@property
|
||||
def blue(self) -> Country:
|
||||
return self.country(blue=True)
|
||||
|
||||
@property
|
||||
def red(self) -> Country:
|
||||
return self.country(blue=False)
|
||||
|
||||
def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]:
|
||||
for group in self.country(blue).plane_group:
|
||||
if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def carriers(self, blue: bool) -> Iterator[ShipGroup]:
|
||||
for group in self.country(blue).ship_group:
|
||||
if group.units[0].type == self.CV_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def lhas(self, blue: bool) -> Iterator[ShipGroup]:
|
||||
for group in self.country(blue).ship_group:
|
||||
if group.units[0].type == self.LHA_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def fobs(self, blue: bool) -> Iterator[VehicleGroup]:
|
||||
for group in self.country(blue).vehicle_group:
|
||||
if group.units[0].type == self.FOB_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ships(self) -> Iterator[ShipGroup]:
|
||||
for group in self.red.ship_group:
|
||||
if group.units[0].type == self.SHIP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def offshore_strike_targets(self) -> Iterator[StaticGroup]:
|
||||
for group in self.red.static_group:
|
||||
if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def missile_sites(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def coastal_defenses(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def long_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def medium_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def short_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def aaa(self) -> Iterator[VehicleGroup]:
|
||||
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
|
||||
if group.units[0].type in self.AAA_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ewrs(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.EWR_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def armor_groups(self) -> Iterator[VehicleGroup]:
|
||||
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
|
||||
if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def helipads(self) -> Iterator[StaticGroup]:
|
||||
for group in self.blue.static_group:
|
||||
if group.units[0].type == self.FARP_HELIPAD:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def factories(self) -> Iterator[StaticGroup]:
|
||||
for group in self.blue.static_group:
|
||||
if group.units[0].type in self.FACTORY_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ammunition_depots(self) -> Iterator[StaticGroup]:
|
||||
for group in itertools.chain(self.blue.static_group, self.red.static_group):
|
||||
if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def strike_targets(self) -> Iterator[StaticGroup]:
|
||||
for group in itertools.chain(self.blue.static_group, self.red.static_group):
|
||||
if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def scenery(self) -> List[SceneryGroup]:
|
||||
return SceneryGroup.from_trigger_zones(self.mission.triggers._zones)
|
||||
|
||||
@cached_property
|
||||
def control_points(self) -> Dict[int, ControlPoint]:
|
||||
control_points = {}
|
||||
for airport in self.mission.terrain.airport_list():
|
||||
if airport.is_blue() or airport.is_red():
|
||||
control_point = self.control_point_from_airport(airport)
|
||||
control_points[control_point.id] = control_point
|
||||
|
||||
for blue in (False, True):
|
||||
for group in self.off_map_spawns(blue):
|
||||
control_point = OffMapSpawn(
|
||||
next(self.control_point_id), str(group.name), group.position
|
||||
)
|
||||
control_point.captured = blue
|
||||
control_point.captured_invert = group.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for ship in self.carriers(blue):
|
||||
# TODO: Name the carrier.
|
||||
control_point = Carrier(
|
||||
"carrier", ship.position, next(self.control_point_id)
|
||||
)
|
||||
control_point.captured = blue
|
||||
control_point.captured_invert = ship.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for ship in self.lhas(blue):
|
||||
# TODO: Name the LHA.db
|
||||
control_point = Lha("lha", ship.position, next(self.control_point_id))
|
||||
control_point.captured = blue
|
||||
control_point.captured_invert = ship.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for fob in self.fobs(blue):
|
||||
control_point = Fob(
|
||||
str(fob.name), fob.position, next(self.control_point_id)
|
||||
)
|
||||
control_point.captured = blue
|
||||
control_point.captured_invert = fob.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
|
||||
return control_points
|
||||
|
||||
@property
|
||||
def front_line_path_groups(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.country(blue=True).vehicle_group:
|
||||
if group.units[0].type == self.FRONT_LINE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def shipping_lane_groups(self) -> Iterator[ShipGroup]:
|
||||
for group in self.country(blue=True).ship_group:
|
||||
if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def add_supply_routes(self) -> None:
|
||||
for group in self.front_line_path_groups:
|
||||
# The unit will have its first waypoint at the source CP and the final
|
||||
# waypoint at the destination CP. Each waypoint defines the path of the
|
||||
# cargo ship.
|
||||
waypoints = [p.position for p in group.points]
|
||||
origin = self.theater.closest_control_point(waypoints[0])
|
||||
if origin is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the first waypoint of {group.name}"
|
||||
)
|
||||
destination = self.theater.closest_control_point(waypoints[-1])
|
||||
if destination is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the final waypoint of {group.name}"
|
||||
)
|
||||
|
||||
self.control_points[origin.id].create_convoy_route(destination, waypoints)
|
||||
self.control_points[destination.id].create_convoy_route(
|
||||
origin, list(reversed(waypoints))
|
||||
)
|
||||
|
||||
def add_shipping_lanes(self) -> None:
|
||||
for group in self.shipping_lane_groups:
|
||||
# The unit will have its first waypoint at the source CP and the final
|
||||
# waypoint at the destination CP. Each waypoint defines the path of the
|
||||
# cargo ship.
|
||||
waypoints = [p.position for p in group.points]
|
||||
origin = self.theater.closest_control_point(waypoints[0])
|
||||
if origin is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the first waypoint of {group.name}"
|
||||
)
|
||||
destination = self.theater.closest_control_point(waypoints[-1])
|
||||
if destination is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the final waypoint of {group.name}"
|
||||
)
|
||||
|
||||
self.control_points[origin.id].create_shipping_lane(destination, waypoints)
|
||||
self.control_points[destination.id].create_shipping_lane(
|
||||
origin, list(reversed(waypoints))
|
||||
)
|
||||
|
||||
def objective_info(
|
||||
self, near: Positioned, allow_naval: bool = False
|
||||
) -> Tuple[ControlPoint, Distance]:
|
||||
closest = self.theater.closest_control_point(near.position, allow_naval)
|
||||
distance = meters(closest.position.distance_to_point(near.position))
|
||||
return closest, distance
|
||||
|
||||
def add_preset_locations(self) -> None:
|
||||
for static in self.offshore_strike_targets:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.offshore_strike_locations.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for ship in self.ships:
|
||||
closest, distance = self.objective_info(ship, allow_naval=True)
|
||||
closest.preset_locations.ships.append(
|
||||
PointWithHeading.from_point(
|
||||
ship.position, Heading.from_degrees(ship.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.missile_sites:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.missile_sites.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.coastal_defenses:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.coastal_defenses.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.long_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.long_range_sams.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.medium_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.medium_range_sams.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.short_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.short_range_sams.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.aaa:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.aaa.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.ewrs:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.ewrs.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.armor_groups:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.armor_groups.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.helipads:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.helipads.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.factories:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.factories.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.ammunition_depots:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.ammunition_depots.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.strike_targets:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.strike_locations.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for scenery_group in self.scenery:
|
||||
closest, distance = self.objective_info(scenery_group)
|
||||
closest.preset_locations.scenery.append(scenery_group)
|
||||
|
||||
def populate_theater(self) -> None:
|
||||
for control_point in self.control_points.values():
|
||||
self.theater.add_controlpoint(control_point)
|
||||
self.add_preset_locations()
|
||||
self.add_supply_routes()
|
||||
self.add_shipping_lanes()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReferencePoint:
|
||||
world_coordinates: Point
|
||||
@ -730,29 +240,11 @@ class ConflictTheater:
|
||||
return i
|
||||
raise KeyError(f"Cannot find ControlPoint with ID {id}")
|
||||
|
||||
@staticmethod
|
||||
def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater:
|
||||
theaters = {
|
||||
"Caucasus": CaucasusTheater,
|
||||
"Nevada": NevadaTheater,
|
||||
"Persian Gulf": PersianGulfTheater,
|
||||
"Normandy": NormandyTheater,
|
||||
"The Channel": TheChannelTheater,
|
||||
"Syria": SyriaTheater,
|
||||
"MarianaIslands": MarianaIslandsTheater,
|
||||
}
|
||||
theater = theaters[data["theater"]]
|
||||
t = theater()
|
||||
|
||||
miz = data.get("miz", None)
|
||||
if miz is None:
|
||||
raise RuntimeError(
|
||||
"Old format (non-miz) campaigns are no longer supported."
|
||||
)
|
||||
|
||||
with logged_duration("Importing miz data"):
|
||||
MizCampaignLoader(directory / miz, t).populate_theater()
|
||||
return t
|
||||
def control_point_named(self, name: str) -> ControlPoint:
|
||||
for cp in self.controlpoints:
|
||||
if cp.name == name:
|
||||
return cp
|
||||
raise KeyError(f"Cannot find ControlPoint named {name}")
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
@ -779,7 +271,7 @@ class CaucasusTheater(ConflictTheater):
|
||||
ReferencePoint(caucasus.Batumi.position, Point(1307, 1205)),
|
||||
)
|
||||
|
||||
landmap = load_landmap("resources\\caulandmap.p")
|
||||
landmap = load_landmap(Path("resources/caulandmap.p"))
|
||||
daytime_map = {
|
||||
"dawn": (6, 9),
|
||||
"day": (9, 18),
|
||||
@ -807,7 +299,7 @@ class PersianGulfTheater(ConflictTheater):
|
||||
ReferencePoint(persiangulf.Jiroft.position, Point(1692, 1343)),
|
||||
ReferencePoint(persiangulf.Liwa_AFB.position, Point(358, 3238)),
|
||||
)
|
||||
landmap = load_landmap("resources\\gulflandmap.p")
|
||||
landmap = load_landmap(Path("resources/gulflandmap.p"))
|
||||
daytime_map = {
|
||||
"dawn": (6, 8),
|
||||
"day": (8, 16),
|
||||
@ -835,7 +327,7 @@ class NevadaTheater(ConflictTheater):
|
||||
ReferencePoint(nevada.Mina_Airport_3Q0.position, Point(252, 295)),
|
||||
ReferencePoint(nevada.Laughlin_Airport.position, Point(844, 909)),
|
||||
)
|
||||
landmap = load_landmap("resources\\nevlandmap.p")
|
||||
landmap = load_landmap(Path("resources/nevlandmap.p"))
|
||||
daytime_map = {
|
||||
"dawn": (4, 6),
|
||||
"day": (6, 17),
|
||||
@ -863,7 +355,7 @@ class NormandyTheater(ConflictTheater):
|
||||
ReferencePoint(normandy.Needs_Oar_Point.position, Point(515, 329)),
|
||||
ReferencePoint(normandy.Evreux.position, Point(2029, 1709)),
|
||||
)
|
||||
landmap = load_landmap("resources\\normandylandmap.p")
|
||||
landmap = load_landmap(Path("resources/normandylandmap.p"))
|
||||
daytime_map = {
|
||||
"dawn": (6, 8),
|
||||
"day": (10, 17),
|
||||
@ -891,7 +383,7 @@ class TheChannelTheater(ConflictTheater):
|
||||
ReferencePoint(thechannel.Abbeville_Drucat.position, Point(2005, 2390)),
|
||||
ReferencePoint(thechannel.Detling.position, Point(706, 382)),
|
||||
)
|
||||
landmap = load_landmap("resources\\channellandmap.p")
|
||||
landmap = load_landmap(Path("resources/channellandmap.p"))
|
||||
daytime_map = {
|
||||
"dawn": (6, 8),
|
||||
"day": (10, 17),
|
||||
@ -919,7 +411,7 @@ class SyriaTheater(ConflictTheater):
|
||||
ReferencePoint(syria.Eyn_Shemer.position, Point(564, 1289)),
|
||||
ReferencePoint(syria.Tabqa.position, Point(1329, 491)),
|
||||
)
|
||||
landmap = load_landmap("resources\\syrialandmap.p")
|
||||
landmap = load_landmap(Path("resources/syrialandmap.p"))
|
||||
daytime_map = {
|
||||
"dawn": (6, 8),
|
||||
"day": (8, 16),
|
||||
@ -944,7 +436,7 @@ class MarianaIslandsTheater(ConflictTheater):
|
||||
terrain = marianaislands.MarianaIslands()
|
||||
overview_image = "marianaislands.gif"
|
||||
|
||||
landmap = load_landmap("resources\\marianaislandslandmap.p")
|
||||
landmap = load_landmap(Path("resources/marianaislandslandmap.p"))
|
||||
daytime_map = {
|
||||
"dawn": (6, 8),
|
||||
"day": (8, 16),
|
||||
|
||||
@ -55,6 +55,7 @@ from ..weather import Conditions
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from gen.flights.flight import FlightType
|
||||
from game.squadrons.squadron import Squadron
|
||||
from ..transfers import PendingTransfers
|
||||
|
||||
FREE_FRONTLINE_UNIT_SUPPLY: int = 15
|
||||
@ -294,8 +295,6 @@ class ControlPoint(MissionTarget, ABC):
|
||||
name: str,
|
||||
position: Point,
|
||||
at: db.StartingPosition,
|
||||
size: int,
|
||||
importance: float,
|
||||
has_frontline: bool = True,
|
||||
cptype: ControlPointType = ControlPointType.AIRBASE,
|
||||
) -> None:
|
||||
@ -308,9 +307,6 @@ class ControlPoint(MissionTarget, ABC):
|
||||
self.preset_locations = PresetLocations()
|
||||
self.helipads: List[PointWithHeading] = []
|
||||
|
||||
# TODO: Should be Airbase specific.
|
||||
self.size = size
|
||||
self.importance = importance
|
||||
self.captured = False
|
||||
self.captured_invert = False
|
||||
# TODO: Should be Airbase specific.
|
||||
@ -322,12 +318,14 @@ class ControlPoint(MissionTarget, ABC):
|
||||
self.cptype = cptype
|
||||
# TODO: Should be Airbase specific.
|
||||
self.stances: Dict[int, CombatStance] = {}
|
||||
from ..unitdelivery import PendingUnitDeliveries
|
||||
from ..groundunitorders import GroundUnitOrders
|
||||
|
||||
self.pending_unit_deliveries = PendingUnitDeliveries(self)
|
||||
self.ground_unit_orders = GroundUnitOrders(self)
|
||||
|
||||
self.target_position: Optional[Point] = None
|
||||
|
||||
self.squadrons: list[Squadron] = []
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__}: {self.name}>"
|
||||
|
||||
@ -588,25 +586,14 @@ class ControlPoint(MissionTarget, ABC):
|
||||
return airbase
|
||||
return None
|
||||
|
||||
def _retreat_air_units(
|
||||
self, game: Game, airframe: AircraftType, count: int
|
||||
) -> None:
|
||||
while count:
|
||||
logging.debug(f"Retreating {count} {airframe} from {self.name}")
|
||||
destination = self.aircraft_retreat_destination(game, airframe)
|
||||
if destination is None:
|
||||
self.capture_aircraft(game, airframe, count)
|
||||
return
|
||||
parking = destination.unclaimed_parking(game)
|
||||
transfer_amount = min([parking, count])
|
||||
destination.base.commission_units({airframe: transfer_amount})
|
||||
count -= transfer_amount
|
||||
@staticmethod
|
||||
def _retreat_squadron(squadron: Squadron) -> None:
|
||||
logging.error("Air unit retreat not currently implemented")
|
||||
|
||||
def retreat_air_units(self, game: Game) -> None:
|
||||
# TODO: Capture in order of price to retain maximum value?
|
||||
while self.base.aircraft:
|
||||
airframe, count = self.base.aircraft.popitem()
|
||||
self._retreat_air_units(game, airframe, count)
|
||||
for squadron in self.squadrons:
|
||||
self._retreat_squadron(squadron)
|
||||
|
||||
def depopulate_uncapturable_tgos(self) -> None:
|
||||
for tgo in self.connected_objectives:
|
||||
@ -615,7 +602,10 @@ class ControlPoint(MissionTarget, ABC):
|
||||
|
||||
# TODO: Should be Airbase specific.
|
||||
def capture(self, game: Game, for_player: bool) -> None:
|
||||
self.pending_unit_deliveries.refund_all(game.coalition_for(for_player))
|
||||
coalition = game.coalition_for(for_player)
|
||||
self.ground_unit_orders.refund_all(coalition)
|
||||
for squadron in self.squadrons:
|
||||
squadron.refund_orders()
|
||||
self.retreat_ground_units(game)
|
||||
self.retreat_air_units(game)
|
||||
self.depopulate_uncapturable_tgos()
|
||||
@ -631,19 +621,6 @@ class ControlPoint(MissionTarget, ABC):
|
||||
def can_operate(self, aircraft: AircraftType) -> bool:
|
||||
...
|
||||
|
||||
def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]:
|
||||
ato = game.coalition_for(self.captured).ato
|
||||
transferring: defaultdict[AircraftType, int] = defaultdict(int)
|
||||
for package in ato.packages:
|
||||
for flight in package.flights:
|
||||
if flight.departure == flight.arrival:
|
||||
continue
|
||||
if flight.departure == self:
|
||||
transferring[flight.unit_type] -= flight.count
|
||||
elif flight.arrival == self:
|
||||
transferring[flight.unit_type] += flight.count
|
||||
return transferring
|
||||
|
||||
def unclaimed_parking(self, game: Game) -> int:
|
||||
return self.total_aircraft_parking - self.allocated_aircraft(game).total
|
||||
|
||||
@ -673,7 +650,9 @@ class ControlPoint(MissionTarget, ABC):
|
||||
self.runway_status.begin_repair()
|
||||
|
||||
def process_turn(self, game: Game) -> None:
|
||||
self.pending_unit_deliveries.process(game)
|
||||
self.ground_unit_orders.process(game)
|
||||
for squadron in self.squadrons:
|
||||
squadron.deliver_orders()
|
||||
|
||||
runway_status = self.runway_status
|
||||
if runway_status is not None:
|
||||
@ -695,21 +674,22 @@ class ControlPoint(MissionTarget, ABC):
|
||||
u.position.x = u.position.x + delta.x
|
||||
u.position.y = u.position.y + delta.y
|
||||
|
||||
def allocated_aircraft(self, game: Game) -> AircraftAllocations:
|
||||
on_order = {}
|
||||
for unit_bought, count in self.pending_unit_deliveries.units.items():
|
||||
if isinstance(unit_bought, AircraftType):
|
||||
on_order[unit_bought] = count
|
||||
def allocated_aircraft(self, _game: Game) -> AircraftAllocations:
|
||||
present: dict[AircraftType, int] = defaultdict(int)
|
||||
on_order: dict[AircraftType, int] = defaultdict(int)
|
||||
for squadron in self.squadrons:
|
||||
present[squadron.aircraft] += squadron.owned_aircraft
|
||||
# TODO: Only if this is the squadron destination, not location.
|
||||
on_order[squadron.aircraft] += squadron.pending_deliveries
|
||||
|
||||
return AircraftAllocations(
|
||||
self.base.aircraft, on_order, self.aircraft_transferring(game)
|
||||
)
|
||||
# TODO: Implement squadron transfers.
|
||||
return AircraftAllocations(present, on_order, transferring={})
|
||||
|
||||
def allocated_ground_units(
|
||||
self, transfers: PendingTransfers
|
||||
) -> GroundUnitAllocations:
|
||||
on_order = {}
|
||||
for unit_bought, count in self.pending_unit_deliveries.units.items():
|
||||
for unit_bought, count in self.ground_unit_orders.units.items():
|
||||
if isinstance(unit_bought, GroundUnitType):
|
||||
on_order[unit_bought] = count
|
||||
|
||||
@ -815,16 +795,12 @@ class ControlPoint(MissionTarget, ABC):
|
||||
|
||||
|
||||
class Airfield(ControlPoint):
|
||||
def __init__(
|
||||
self, airport: Airport, size: int, importance: float, has_frontline: bool = True
|
||||
) -> None:
|
||||
def __init__(self, airport: Airport, has_frontline: bool = True) -> None:
|
||||
super().__init__(
|
||||
airport.id,
|
||||
airport.name,
|
||||
airport.position,
|
||||
airport,
|
||||
size,
|
||||
importance,
|
||||
has_frontline,
|
||||
cptype=ControlPointType.AIRBASE,
|
||||
)
|
||||
@ -990,15 +966,11 @@ class NavalControlPoint(ControlPoint, ABC):
|
||||
|
||||
class Carrier(NavalControlPoint):
|
||||
def __init__(self, name: str, at: Point, cp_id: int):
|
||||
import game.theater.conflicttheater
|
||||
|
||||
super().__init__(
|
||||
cp_id,
|
||||
name,
|
||||
at,
|
||||
at,
|
||||
game.theater.conflicttheater.SIZE_SMALL,
|
||||
1,
|
||||
has_frontline=False,
|
||||
cptype=ControlPointType.AIRCRAFT_CARRIER_GROUP,
|
||||
)
|
||||
@ -1034,15 +1006,11 @@ class Carrier(NavalControlPoint):
|
||||
|
||||
class Lha(NavalControlPoint):
|
||||
def __init__(self, name: str, at: Point, cp_id: int):
|
||||
import game.theater.conflicttheater
|
||||
|
||||
super().__init__(
|
||||
cp_id,
|
||||
name,
|
||||
at,
|
||||
at,
|
||||
game.theater.conflicttheater.SIZE_SMALL,
|
||||
1,
|
||||
has_frontline=False,
|
||||
cptype=ControlPointType.LHA_GROUP,
|
||||
)
|
||||
@ -1071,15 +1039,11 @@ class OffMapSpawn(ControlPoint):
|
||||
return True
|
||||
|
||||
def __init__(self, cp_id: int, name: str, position: Point):
|
||||
from . import IMPORTANCE_MEDIUM, SIZE_REGULAR
|
||||
|
||||
super().__init__(
|
||||
cp_id,
|
||||
name,
|
||||
position,
|
||||
at=position,
|
||||
size=SIZE_REGULAR,
|
||||
importance=IMPORTANCE_MEDIUM,
|
||||
has_frontline=False,
|
||||
cptype=ControlPointType.OFF_MAP,
|
||||
)
|
||||
@ -1128,15 +1092,11 @@ class OffMapSpawn(ControlPoint):
|
||||
|
||||
class Fob(ControlPoint):
|
||||
def __init__(self, name: str, at: Point, cp_id: int):
|
||||
import game.theater.conflicttheater
|
||||
|
||||
super().__init__(
|
||||
cp_id,
|
||||
name,
|
||||
at,
|
||||
at,
|
||||
game.theater.conflicttheater.SIZE_SMALL,
|
||||
1,
|
||||
has_frontline=True,
|
||||
cptype=ControlPointType.FOB,
|
||||
)
|
||||
|
||||
@ -3,6 +3,7 @@ import pickle
|
||||
from functools import cached_property
|
||||
from typing import Optional, Tuple, Union
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from shapely import geometry
|
||||
from shapely.geometry import MultiPolygon, Polygon
|
||||
@ -27,7 +28,7 @@ class Landmap:
|
||||
return self.inclusion_zones - self.exclusion_zones - self.sea_zones
|
||||
|
||||
|
||||
def load_landmap(filename: str) -> Optional[Landmap]:
|
||||
def load_landmap(filename: Path) -> Optional[Landmap]:
|
||||
try:
|
||||
with open(filename, "rb") as f:
|
||||
return pickle.load(f)
|
||||
|
||||
@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions(
|
||||
Season.Summer: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=10,
|
||||
cloudy=30,
|
||||
clear_skies=60,
|
||||
cloudy=35,
|
||||
clear_skies=55,
|
||||
),
|
||||
Season.Fall: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
|
||||
@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions(
|
||||
Season.Summer: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=5,
|
||||
cloudy=25,
|
||||
clear_skies=70,
|
||||
cloudy=30,
|
||||
clear_skies=65,
|
||||
),
|
||||
Season.Fall: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
|
||||
@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions(
|
||||
Season.Summer: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=10,
|
||||
cloudy=30,
|
||||
clear_skies=60,
|
||||
cloudy=35,
|
||||
clear_skies=55,
|
||||
),
|
||||
Season.Fall: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
|
||||
@ -12,26 +12,26 @@ CONDITIONS = SeasonalConditions(
|
||||
# Winter there is some rain in PG (Dubai)
|
||||
thunderstorm=1,
|
||||
raining=15,
|
||||
cloudy=35,
|
||||
clear_skies=50,
|
||||
cloudy=40,
|
||||
clear_skies=45,
|
||||
),
|
||||
Season.Spring: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=2,
|
||||
cloudy=18,
|
||||
clear_skies=80,
|
||||
cloudy=28,
|
||||
clear_skies=70,
|
||||
),
|
||||
Season.Summer: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=1,
|
||||
cloudy=8,
|
||||
clear_skies=90,
|
||||
cloudy=18,
|
||||
clear_skies=80,
|
||||
),
|
||||
Season.Fall: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=2,
|
||||
cloudy=18,
|
||||
clear_skies=80,
|
||||
cloudy=28,
|
||||
clear_skies=70,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@ -11,8 +11,8 @@ CONDITIONS = SeasonalConditions(
|
||||
Season.Winter: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=25,
|
||||
cloudy=25,
|
||||
clear_skies=50,
|
||||
cloudy=35,
|
||||
clear_skies=40,
|
||||
),
|
||||
Season.Spring: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
@ -22,15 +22,15 @@ CONDITIONS = SeasonalConditions(
|
||||
),
|
||||
Season.Summer: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=3,
|
||||
cloudy=20,
|
||||
clear_skies=77,
|
||||
raining=5,
|
||||
cloudy=30,
|
||||
clear_skies=65,
|
||||
),
|
||||
Season.Fall: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=10,
|
||||
cloudy=30,
|
||||
clear_skies=60,
|
||||
raining=15,
|
||||
cloudy=35,
|
||||
clear_skies=50,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions(
|
||||
Season.Summer: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=10,
|
||||
cloudy=30,
|
||||
clear_skies=60,
|
||||
cloudy=35,
|
||||
clear_skies=55,
|
||||
),
|
||||
Season.Fall: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
|
||||
@ -30,7 +30,7 @@ from game.theater.theatergroundobject import (
|
||||
)
|
||||
from game.utils import Heading
|
||||
from game.version import VERSION
|
||||
from gen import namegen
|
||||
from gen.naming import namegen
|
||||
from gen.coastal.coastal_group_generator import generate_coastal_group
|
||||
from gen.defenses.armor_group_generator import generate_armor_group
|
||||
from gen.fleet.ship_group_generator import (
|
||||
@ -49,22 +49,12 @@ from . import (
|
||||
Fob,
|
||||
OffMapSpawn,
|
||||
)
|
||||
from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig
|
||||
from ..profiling import logged_duration
|
||||
from ..settings import Settings
|
||||
|
||||
GroundObjectTemplates = Dict[str, Dict[str, Any]]
|
||||
|
||||
UNIT_VARIETY = 6
|
||||
UNIT_AMOUNT_FACTOR = 16
|
||||
UNIT_COUNT_IMPORTANCE_LOG = 1.3
|
||||
|
||||
COUNT_BY_TASK = {
|
||||
PinpointStrike: 12,
|
||||
CAP: 8,
|
||||
CAS: 4,
|
||||
AirDefence: 1,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeneratorSettings:
|
||||
@ -96,6 +86,7 @@ class GameGenerator:
|
||||
player: Faction,
|
||||
enemy: Faction,
|
||||
theater: ConflictTheater,
|
||||
air_wing_config: CampaignAirWingConfig,
|
||||
settings: Settings,
|
||||
generator_settings: GeneratorSettings,
|
||||
mod_settings: ModSettings,
|
||||
@ -103,6 +94,7 @@ class GameGenerator:
|
||||
self.player = player
|
||||
self.enemy = enemy
|
||||
self.theater = theater
|
||||
self.air_wing_config = air_wing_config
|
||||
self.settings = settings
|
||||
self.generator_settings = generator_settings
|
||||
self.mod_settings = mod_settings
|
||||
@ -116,6 +108,7 @@ class GameGenerator:
|
||||
player_faction=self.player.apply_mod_settings(self.mod_settings),
|
||||
enemy_faction=self.enemy.apply_mod_settings(self.mod_settings),
|
||||
theater=self.theater,
|
||||
air_wing_config=self.air_wing_config,
|
||||
start_date=self.generator_settings.start_date,
|
||||
settings=self.settings,
|
||||
player_budget=self.generator_settings.player_budget,
|
||||
|
||||
@ -51,7 +51,6 @@ from dcs.mapping import Point
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.procurement import AircraftProcurementRequest
|
||||
from game.squadrons import Squadron
|
||||
from game.theater import ControlPoint, MissionTarget
|
||||
from game.theater.transitnetwork import (
|
||||
TransitConnection,
|
||||
@ -67,7 +66,7 @@ from gen.naming import namegen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.inventory import ControlPointAircraftInventory
|
||||
from game.squadrons import Squadron
|
||||
|
||||
|
||||
class Transport:
|
||||
@ -315,29 +314,20 @@ class AirliftPlanner:
|
||||
if cp.captured != self.for_player:
|
||||
continue
|
||||
|
||||
inventory = self.game.aircraft_inventory.for_control_point(cp)
|
||||
for unit_type, available in inventory.all_aircraft:
|
||||
squadrons = air_wing.auto_assignable_for_task_with_type(
|
||||
unit_type, FlightType.TRANSPORT
|
||||
)
|
||||
for squadron in squadrons:
|
||||
if self.compatible_with_mission(unit_type, cp):
|
||||
while (
|
||||
available
|
||||
and squadron.has_available_pilots
|
||||
and self.transfer.transport is None
|
||||
):
|
||||
flight_size = self.create_airlift_flight(
|
||||
squadron, inventory
|
||||
)
|
||||
available -= flight_size
|
||||
squadrons = air_wing.auto_assignable_for_task_at(FlightType.TRANSPORT, cp)
|
||||
for squadron in squadrons:
|
||||
if self.compatible_with_mission(squadron.aircraft, cp):
|
||||
while (
|
||||
squadron.untasked_aircraft
|
||||
and squadron.has_available_pilots
|
||||
and self.transfer.transport is None
|
||||
):
|
||||
self.create_airlift_flight(squadron)
|
||||
if self.package.flights:
|
||||
self.game.ato_for(self.for_player).add_package(self.package)
|
||||
|
||||
def create_airlift_flight(
|
||||
self, squadron: Squadron, inventory: ControlPointAircraftInventory
|
||||
) -> int:
|
||||
available_aircraft = inventory.available(squadron.aircraft)
|
||||
def create_airlift_flight(self, squadron: Squadron) -> int:
|
||||
available_aircraft = squadron.untasked_aircraft
|
||||
capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2
|
||||
required = math.ceil(self.transfer.size / capacity_each)
|
||||
flight_size = min(
|
||||
@ -348,8 +338,8 @@ class AirliftPlanner:
|
||||
# TODO: Use number_of_available_pilots directly once feature flag is gone.
|
||||
# The number of currently available pilots is not relevant when pilot limits
|
||||
# are disabled.
|
||||
if not squadron.can_provide_pilots(flight_size):
|
||||
flight_size = squadron.number_of_available_pilots
|
||||
if not squadron.can_fulfill_flight(flight_size):
|
||||
flight_size = squadron.max_fulfillable_aircraft
|
||||
capacity = flight_size * capacity_each
|
||||
|
||||
if capacity < self.transfer.size:
|
||||
@ -359,16 +349,15 @@ class AirliftPlanner:
|
||||
else:
|
||||
transfer = self.transfer
|
||||
|
||||
player = inventory.control_point.captured
|
||||
flight = Flight(
|
||||
self.package,
|
||||
self.game.country_for(player),
|
||||
self.game.country_for(squadron.player),
|
||||
squadron,
|
||||
flight_size,
|
||||
FlightType.TRANSPORT,
|
||||
self.game.settings.default_start_type,
|
||||
departure=inventory.control_point,
|
||||
arrival=inventory.control_point,
|
||||
departure=squadron.location,
|
||||
arrival=squadron.location,
|
||||
divert=None,
|
||||
cargo=transfer,
|
||||
)
|
||||
@ -381,7 +370,6 @@ class AirliftPlanner:
|
||||
self.package, self.game.coalition_for(self.for_player), self.game.theater
|
||||
)
|
||||
planner.populate_flight_plan(flight)
|
||||
self.game.aircraft_inventory.claim_for_flight(flight)
|
||||
return flight_size
|
||||
|
||||
|
||||
@ -652,8 +640,7 @@ class PendingTransfers:
|
||||
flight.package.remove_flight(flight)
|
||||
if not flight.package.flights:
|
||||
self.game.ato_for(self.player).remove_package(flight.package)
|
||||
self.game.aircraft_inventory.return_from_flight(flight)
|
||||
flight.clear_roster()
|
||||
flight.return_pilots_and_aircraft()
|
||||
|
||||
@cancel_transport.register
|
||||
def _cancel_transport_convoy(
|
||||
@ -722,26 +709,59 @@ class PendingTransfers:
|
||||
):
|
||||
self.order_airlift_assets_at(control_point)
|
||||
|
||||
@staticmethod
|
||||
def desired_airlift_capacity(control_point: ControlPoint) -> int:
|
||||
return 4 if control_point.has_factory else 0
|
||||
def desired_airlift_capacity(self, control_point: ControlPoint) -> int:
|
||||
|
||||
def current_airlift_capacity(self, control_point: ControlPoint) -> int:
|
||||
inventory = self.game.aircraft_inventory.for_control_point(control_point)
|
||||
squadrons = self.game.air_wing_for(
|
||||
control_point.captured
|
||||
).auto_assignable_for_task(FlightType.TRANSPORT)
|
||||
unit_types = {s.aircraft for s in squadrons}
|
||||
if control_point.has_factory:
|
||||
is_major_hub = control_point.total_aircraft_parking > 0
|
||||
# Check if there is a CP which is only reachable via Airlift
|
||||
transit_network = self.network_for(control_point)
|
||||
for cp in self.game.theater.control_points_for(self.player):
|
||||
# check if the CP has no factory, is reachable from the current
|
||||
# position and can only be reached with airlift connections
|
||||
if (
|
||||
cp.can_deploy_ground_units
|
||||
and not cp.has_factory
|
||||
and transit_network.has_link(control_point, cp)
|
||||
and not any(
|
||||
link_type
|
||||
for link, link_type in transit_network.nodes[cp].items()
|
||||
if not link_type == TransitConnection.Airlift
|
||||
)
|
||||
):
|
||||
return 4
|
||||
|
||||
if (
|
||||
is_major_hub
|
||||
and cp.has_factory
|
||||
and cp.total_aircraft_parking > control_point.total_aircraft_parking
|
||||
):
|
||||
is_major_hub = False
|
||||
|
||||
if is_major_hub:
|
||||
# If the current CP is a major hub keep always 2 planes on reserve
|
||||
return 2
|
||||
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def current_airlift_capacity(control_point: ControlPoint) -> int:
|
||||
return sum(
|
||||
count
|
||||
for unit_type, count in inventory.all_aircraft
|
||||
if unit_type in unit_types
|
||||
s.owned_aircraft
|
||||
for s in control_point.squadrons
|
||||
if s.can_auto_assign(FlightType.TRANSPORT)
|
||||
)
|
||||
|
||||
def order_airlift_assets_at(self, control_point: ControlPoint) -> None:
|
||||
gap = self.desired_airlift_capacity(
|
||||
control_point
|
||||
) - self.current_airlift_capacity(control_point)
|
||||
unclaimed_parking = control_point.unclaimed_parking(self.game)
|
||||
# Buy a maximum of unclaimed_parking only to prevent that aircraft procurement
|
||||
# take place at another base
|
||||
gap = min(
|
||||
[
|
||||
self.desired_airlift_capacity(control_point)
|
||||
- self.current_airlift_capacity(control_point),
|
||||
unclaimed_parking,
|
||||
]
|
||||
)
|
||||
|
||||
if gap <= 0:
|
||||
return
|
||||
@ -751,6 +771,10 @@ class PendingTransfers:
|
||||
# aesthetic.
|
||||
gap += 1
|
||||
|
||||
if gap > unclaimed_parking:
|
||||
# Prevent to buy more aircraft than possible
|
||||
return
|
||||
|
||||
self.game.coalition_for(self.player).add_procurement_request(
|
||||
AircraftProcurementRequest(control_point, FlightType.TRANSPORT, gap)
|
||||
)
|
||||
|
||||
@ -62,6 +62,8 @@ class Distance:
|
||||
def __mul__(self, other: Union[float, int]) -> Distance:
|
||||
return meters(self.meters * other)
|
||||
|
||||
__rmul__ = __mul__
|
||||
|
||||
def __truediv__(self, other: Union[float, int]) -> Distance:
|
||||
return meters(self.meters / other)
|
||||
|
||||
@ -147,6 +149,8 @@ class Speed:
|
||||
def __mul__(self, other: Union[float, int]) -> Speed:
|
||||
return kph(self.kph * other)
|
||||
|
||||
__rmul__ = __mul__
|
||||
|
||||
def __truediv__(self, other: Union[float, int]) -> Speed:
|
||||
return kph(self.kph / other)
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ def _build_version_string() -> str:
|
||||
]
|
||||
build_number_path = Path("resources/buildnumber")
|
||||
if build_number_path.exists():
|
||||
with build_number_path.open("r") as build_number_file:
|
||||
with build_number_path.open("r", encoding="utf-8") as build_number_file:
|
||||
components.append(build_number_file.readline())
|
||||
|
||||
if not Path("resources/final").exists():
|
||||
@ -114,4 +114,8 @@ VERSION = _build_version_string()
|
||||
#: Version 8.1
|
||||
#: * You can now add "Invisible FARP" static to FOB to add helicopter slots
|
||||
#:
|
||||
CAMPAIGN_FORMAT_VERSION = (8, 1)
|
||||
#: Version 9.0
|
||||
#: * Campaign files now define the initial squadron layouts. See TODO.
|
||||
#: * CV and LHA control points now get their names from the group name in the campaign
|
||||
#: miz.
|
||||
CAMPAIGN_FORMAT_VERSION = (9, 0)
|
||||
|
||||
@ -10,7 +10,6 @@ from typing import Optional, TYPE_CHECKING, Any
|
||||
from dcs.cloud_presets import Clouds as PydcsClouds
|
||||
from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind
|
||||
|
||||
from game.savecompat import has_save_compat_for
|
||||
from game.settings import Settings
|
||||
from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg
|
||||
|
||||
@ -36,13 +35,6 @@ class AtmosphericConditions:
|
||||
#: Temperature at sea level in Celcius.
|
||||
temperature_celsius: float
|
||||
|
||||
@has_save_compat_for(5)
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
if "qnh" not in state:
|
||||
state["qnh"] = inches_hg(state["qnh_inches_mercury"])
|
||||
del state["qnh_inches_mercury"]
|
||||
self.__dict__.update(state)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindConditions:
|
||||
|
||||
@ -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
|
||||
@ -69,7 +69,6 @@ from game.data.weapons import Pylon, WeaponType as WeaponTypeEnum
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.factions.faction import Faction
|
||||
from game.settings import Settings
|
||||
from game.squadrons import Pilot
|
||||
from game.theater.controlpoint import (
|
||||
Airfield,
|
||||
ControlPoint,
|
||||
@ -109,6 +108,7 @@ from .naming import namegen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.squadrons import Pilot, Squadron
|
||||
|
||||
WARM_START_HELI_ALT = meters(500)
|
||||
WARM_START_ALTITUDE = meters(3000)
|
||||
@ -644,8 +644,7 @@ class AircraftConflictGenerator:
|
||||
def spawn_unused_aircraft(
|
||||
self, player_country: Country, enemy_country: Country
|
||||
) -> None:
|
||||
inventories = self.game.aircraft_inventory.inventories
|
||||
for control_point, inventory in inventories.items():
|
||||
for control_point in self.game.theater.controlpoints:
|
||||
if not isinstance(control_point, Airfield):
|
||||
continue
|
||||
|
||||
@ -655,11 +654,9 @@ class AircraftConflictGenerator:
|
||||
else:
|
||||
country = enemy_country
|
||||
|
||||
for aircraft, available in inventory.all_aircraft:
|
||||
for squadron in control_point.squadrons:
|
||||
try:
|
||||
self._spawn_unused_at(
|
||||
control_point, country, faction, aircraft, available
|
||||
)
|
||||
self._spawn_unused_at(control_point, country, faction, squadron)
|
||||
except NoParkingSlotError:
|
||||
# If we run out of parking, stop spawning aircraft.
|
||||
return
|
||||
@ -669,17 +666,16 @@ class AircraftConflictGenerator:
|
||||
control_point: Airfield,
|
||||
country: Country,
|
||||
faction: Faction,
|
||||
aircraft: AircraftType,
|
||||
number: int,
|
||||
squadron: Squadron,
|
||||
) -> None:
|
||||
for _ in range(number):
|
||||
for _ in range(squadron.untasked_aircraft):
|
||||
# Creating a flight even those this isn't a fragged mission lets us
|
||||
# reuse the existing debriefing code.
|
||||
# TODO: Special flight type?
|
||||
flight = Flight(
|
||||
Package(control_point),
|
||||
faction.country,
|
||||
self.game.air_wing_for(control_point.captured).squadron_for(aircraft),
|
||||
squadron,
|
||||
1,
|
||||
FlightType.BARCAP,
|
||||
"Cold",
|
||||
@ -691,16 +687,13 @@ class AircraftConflictGenerator:
|
||||
group = self._generate_at_airport(
|
||||
name=namegen.next_aircraft_name(country, control_point.id, flight),
|
||||
side=country,
|
||||
unit_type=aircraft.dcs_unit_type,
|
||||
unit_type=squadron.aircraft.dcs_unit_type,
|
||||
count=1,
|
||||
start_type="Cold",
|
||||
airport=control_point.airport,
|
||||
)
|
||||
|
||||
if aircraft in faction.liveries_overrides:
|
||||
livery = random.choice(faction.liveries_overrides[aircraft])
|
||||
for unit in group.units:
|
||||
unit.livery_id = livery
|
||||
self._setup_livery(flight, group)
|
||||
|
||||
group.uncontrolled = True
|
||||
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.
|
||||
if isinstance(flight_plan, RefuelingFlightPlan):
|
||||
orbit = OrbitAction(
|
||||
altitude=waypoint.alt,
|
||||
pattern=OrbitAction.OrbitPattern.RaceTrack,
|
||||
speed=int(flight_plan.patrol_speed.kph),
|
||||
)
|
||||
else:
|
||||
orbit = OrbitAction(
|
||||
altitude=waypoint.alt, pattern=OrbitAction.OrbitPattern.RaceTrack
|
||||
)
|
||||
orbit = OrbitAction(
|
||||
altitude=waypoint.alt,
|
||||
pattern=OrbitAction.OrbitPattern.RaceTrack,
|
||||
speed=int(flight_plan.patrol_speed.kph),
|
||||
)
|
||||
|
||||
racetrack = ControlledTask(orbit)
|
||||
self.set_waypoint_tot(waypoint, flight_plan.patrol_start_time)
|
||||
|
||||
@ -5,7 +5,8 @@ from datetime import timedelta
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen import RadioFrequency, TacanChannel
|
||||
from gen.radios import RadioFrequency
|
||||
from gen.tacan import TacanChannel
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@ -16,8 +16,7 @@ from dcs.task import (
|
||||
from dcs.unittype import UnitType
|
||||
|
||||
from game.utils import Heading
|
||||
from . import AirSupport
|
||||
from .airsupport import TankerInfo, AwacsInfo
|
||||
from .airsupport import AirSupport, TankerInfo, AwacsInfo
|
||||
from .callsigns import callsign_for_support_unit
|
||||
from .conflictgen import Conflict
|
||||
from .flights.ai_flight_planner_db import AEWC_CAPABLE
|
||||
|
||||
@ -129,7 +129,6 @@ CAP_CAPABLE = [
|
||||
F_14B,
|
||||
F_14A_135_GR,
|
||||
Su_33,
|
||||
Su_34,
|
||||
J_11A,
|
||||
Su_30,
|
||||
Su_27,
|
||||
|
||||
@ -2,20 +2,19 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
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.point import MovingPoint, PointAction
|
||||
from dcs.unit import Unit
|
||||
|
||||
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.utils import Distance, meters
|
||||
from gen.flights.loadouts import Loadout
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.squadrons import Pilot, Squadron
|
||||
from game.transfers import TransferOrder
|
||||
from gen.ato import Package
|
||||
from gen.flights.flightplan import FlightPlan
|
||||
@ -50,6 +49,8 @@ class FlightType(Enum):
|
||||
strike-like missions will need more specialized control.
|
||||
* ai_flight_planner.py: Use the new mission type in propose_missions so the AI will
|
||||
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"
|
||||
@ -80,6 +81,30 @@ class FlightType(Enum):
|
||||
return entry
|
||||
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):
|
||||
"""Enumeration of waypoint types.
|
||||
@ -141,8 +166,8 @@ class FlightWaypoint:
|
||||
waypoint_type: The waypoint type.
|
||||
x: X coordinate of the waypoint.
|
||||
y: Y coordinate of the waypoint.
|
||||
alt: Altitude of the waypoint. By default this is AGL, but it can be
|
||||
changed to MSL by setting alt_type to "RADIO".
|
||||
alt: Altitude of the waypoint. By default this is MSL, but it can be
|
||||
changed to AGL by setting alt_type to "RADIO"
|
||||
"""
|
||||
self.waypoint_type = waypoint_type
|
||||
self.x = x
|
||||
@ -169,12 +194,6 @@ class FlightWaypoint:
|
||||
self.tot: 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
|
||||
def position(self) -> Point:
|
||||
return Point(self.x, self.y)
|
||||
@ -273,6 +292,7 @@ class Flight:
|
||||
self.package = package
|
||||
self.country = country
|
||||
self.squadron = squadron
|
||||
self.squadron.claim_inventory(count)
|
||||
if roster is None:
|
||||
self.roster = FlightRoster(self.squadron, initial_size=count)
|
||||
else:
|
||||
@ -321,6 +341,7 @@ class Flight:
|
||||
return self.flight_plan.waypoints[1:]
|
||||
|
||||
def resize(self, new_size: int) -> None:
|
||||
self.squadron.claim_inventory(new_size - self.count)
|
||||
self.roster.resize(new_size)
|
||||
|
||||
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
|
||||
@ -330,8 +351,9 @@ class Flight:
|
||||
def missing_pilots(self) -> int:
|
||||
return self.roster.missing_pilots
|
||||
|
||||
def clear_roster(self) -> None:
|
||||
def return_pilots_and_aircraft(self) -> None:
|
||||
self.roster.clear()
|
||||
self.squadron.claim_inventory(-self.count)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if self.custom_name:
|
||||
|
||||
@ -411,6 +411,9 @@ class PatrollingFlightPlan(FlightPlan):
|
||||
#: Maximum time to remain on station.
|
||||
patrol_duration: timedelta
|
||||
|
||||
#: Racetrack speed TAS.
|
||||
patrol_speed: Speed
|
||||
|
||||
#: 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
|
||||
#: this mission within this range of the flight's current position (or the
|
||||
@ -779,9 +782,6 @@ class RefuelingFlightPlan(PatrollingFlightPlan):
|
||||
divert: Optional[FlightWaypoint]
|
||||
bullseye: FlightWaypoint
|
||||
|
||||
#: Racetrack speed.
|
||||
patrol_speed: Speed
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.takeoff
|
||||
yield from self.nav_to
|
||||
@ -1115,7 +1115,7 @@ class FlightPlanBuilder:
|
||||
if isinstance(location, FrontLine):
|
||||
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
|
||||
randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
|
||||
@ -1124,6 +1124,11 @@ class FlightPlanBuilder:
|
||||
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)
|
||||
start, end = builder.race_track(start_pos, end_pos, patrol_alt)
|
||||
|
||||
@ -1131,6 +1136,7 @@ class FlightPlanBuilder:
|
||||
package=self.package,
|
||||
flight=flight,
|
||||
patrol_duration=self.doctrine.cap_duration,
|
||||
patrol_speed=patrol_speed,
|
||||
engagement_distance=self.doctrine.cap_engagement_range,
|
||||
takeoff=builder.takeoff(flight.departure),
|
||||
nav_to=builder.nav_path(
|
||||
@ -1238,7 +1244,7 @@ class FlightPlanBuilder:
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def racetrack_for_objective(
|
||||
def cap_racetrack_for_objective(
|
||||
self, location: MissionTarget, barcap: bool
|
||||
) -> Tuple[Point, Point]:
|
||||
closest_cache = ObjectiveDistanceCache.get_closest_airfields(location)
|
||||
@ -1270,6 +1276,7 @@ class FlightPlanBuilder:
|
||||
- self.doctrine.cap_engagement_range
|
||||
- nautical_miles(5)
|
||||
)
|
||||
max_track_length = self.doctrine.cap_max_track_length
|
||||
else:
|
||||
# Other race tracks (TARCAPs, currently) just try to keep some
|
||||
# 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
|
||||
|
||||
# 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(
|
||||
self.doctrine.cap_min_distance_from_cp, distance_to_no_fly
|
||||
)
|
||||
@ -1294,11 +1306,12 @@ class FlightPlanBuilder:
|
||||
heading.degrees,
|
||||
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_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
|
||||
|
||||
def aewc_orbit(self, location: MissionTarget) -> Point:
|
||||
@ -1321,33 +1334,6 @@ class FlightPlanBuilder:
|
||||
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:
|
||||
"""Generate a CAP flight plan for the given front line.
|
||||
|
||||
@ -1362,16 +1348,14 @@ class FlightPlanBuilder:
|
||||
self.doctrine.min_patrol_altitude,
|
||||
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
|
||||
builder = WaypointBuilder(flight, self.coalition)
|
||||
|
||||
if isinstance(location, FrontLine):
|
||||
orbit0p, orbit1p = self.racetrack_for_frontline(
|
||||
flight.departure.position, location
|
||||
)
|
||||
else:
|
||||
orbit0p, orbit1p = self.racetrack_for_objective(location, barcap=False)
|
||||
orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False)
|
||||
|
||||
start, end = builder.race_track(orbit0p, orbit1p, patrol_alt)
|
||||
return TarCapFlightPlan(
|
||||
@ -1383,6 +1367,7 @@ class FlightPlanBuilder:
|
||||
# requests an escort the CAP flight will remain on station for the
|
||||
# duration of the escorted mission, or until it is winchester/bingo.
|
||||
patrol_duration=self.doctrine.cap_duration,
|
||||
patrol_speed=patrol_speed,
|
||||
engagement_distance=self.doctrine.cap_engagement_range,
|
||||
takeoff=builder.takeoff(flight.departure),
|
||||
nav_to=builder.nav_path(flight.departure.position, orbit0p, patrol_alt),
|
||||
@ -1546,16 +1531,33 @@ class FlightPlanBuilder:
|
||||
|
||||
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(
|
||||
package=self.package,
|
||||
flight=flight,
|
||||
patrol_duration=self.doctrine.cas_duration,
|
||||
patrol_speed=patrol_speed,
|
||||
takeoff=builder.takeoff(flight.departure),
|
||||
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(
|
||||
egress, flight.arrival.position, self.doctrine.ingress_altitude
|
||||
egress,
|
||||
flight.arrival.position,
|
||||
ingress_egress_altitude,
|
||||
use_agl_ingress_egress,
|
||||
),
|
||||
patrol_start=builder.ingress(
|
||||
FlightWaypointType.INGRESS_CAS, ingress, location
|
||||
@ -1608,6 +1610,7 @@ class FlightPlanBuilder:
|
||||
else:
|
||||
altitude = feet(21000)
|
||||
|
||||
# TODO: Could use flight.unit_type.preferred_patrol_speed(altitude) instead.
|
||||
if tanker_type.patrol_speed is not None:
|
||||
speed = tanker_type.patrol_speed
|
||||
else:
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
@ -55,7 +56,7 @@ class WaypointBuilder:
|
||||
|
||||
@property
|
||||
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:
|
||||
"""Create takeoff waypoint for the given arrival airfield or carrier.
|
||||
@ -167,6 +168,8 @@ class WaypointBuilder:
|
||||
position.y,
|
||||
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
|
||||
)
|
||||
if self.is_helo:
|
||||
waypoint.alt_type = "RADIO"
|
||||
waypoint.pretty_name = "Hold"
|
||||
waypoint.description = "Wait until push time"
|
||||
waypoint.name = "HOLD"
|
||||
@ -210,7 +213,7 @@ class WaypointBuilder:
|
||||
ingress_type,
|
||||
position.x,
|
||||
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:
|
||||
waypoint.alt_type = "RADIO"
|
||||
@ -225,7 +228,7 @@ class WaypointBuilder:
|
||||
FlightWaypointType.EGRESS,
|
||||
position.x,
|
||||
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:
|
||||
waypoint.alt_type = "RADIO"
|
||||
@ -309,7 +312,7 @@ class WaypointBuilder:
|
||||
FlightWaypointType.CAS,
|
||||
position.x,
|
||||
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.description = "Provide CAS"
|
||||
@ -445,7 +448,7 @@ class WaypointBuilder:
|
||||
FlightWaypointType.TARGET_GROUP_LOC,
|
||||
target.position.x,
|
||||
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:
|
||||
waypoint.alt_type = "RADIO"
|
||||
|
||||
@ -43,8 +43,14 @@ class ForcedOptionsGenerator:
|
||||
if blue.unrestricted_satnav or red.unrestricted_satnav:
|
||||
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:
|
||||
self._set_options_view()
|
||||
self._set_external_views()
|
||||
self._set_labels()
|
||||
self._set_unrestricted_satnav()
|
||||
self._set_battle_damage_assessment()
|
||||
|
||||
@ -65,7 +65,8 @@ class KneeboardPageWriter:
|
||||
else:
|
||||
self.foreground_fill = (15, 15, 15)
|
||||
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
|
||||
# we start generating more complicated flight plans, or start including
|
||||
# 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
|
||||
)
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.page_margin = page_margin
|
||||
self.x = page_margin
|
||||
self.y = page_margin
|
||||
self.line_spacing = line_spacing
|
||||
@ -97,12 +99,21 @@ class KneeboardPageWriter:
|
||||
text: str,
|
||||
font: Optional[ImageFont.FreeTypeFont] = None,
|
||||
fill: Optional[Tuple[int, int, int]] = None,
|
||||
wrap: bool = False,
|
||||
) -> None:
|
||||
if font is None:
|
||||
font = self.content_font
|
||||
if fill is None:
|
||||
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)
|
||||
width, height = self.draw.textsize(text, font=font)
|
||||
self.y += height + self.line_spacing
|
||||
@ -146,6 +157,24 @@ class KneeboardPageWriter:
|
||||
output = combo
|
||||
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:
|
||||
"""Base class for all kneeboard pages."""
|
||||
@ -631,7 +660,7 @@ class NotesPage(KneeboardPage):
|
||||
def write(self, path: Path) -> None:
|
||||
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
|
||||
writer.title(f"Notes")
|
||||
writer.text(self.notes)
|
||||
writer.text(self.notes, wrap=True)
|
||||
writer.write(path)
|
||||
|
||||
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
from typing import List, Any
|
||||
from typing import List, Any, TYPE_CHECKING
|
||||
|
||||
from dcs.country import Country
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.unittype import UnitType
|
||||
from gen.flights.flight import Flight
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen.flights.flight import Flight
|
||||
|
||||
ALPHA_MILITARY = [
|
||||
"Alpha",
|
||||
|
||||
115
gen/radios.py
115
gen/radios.py
@ -2,7 +2,7 @@
|
||||
import itertools
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterator, List, Set
|
||||
from typing import Dict, FrozenSet, Iterator, List, Reversible, Set, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -45,14 +45,8 @@ def kHz(num: int) -> RadioFrequency:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Radio:
|
||||
"""A radio.
|
||||
|
||||
Defines the minimum (inclusive) and maximum (exclusive) range of the radio.
|
||||
"""
|
||||
|
||||
#: The name of the radio.
|
||||
name: str
|
||||
class RadioRange:
|
||||
"""Defines the minimum (inclusive) and maximum (exclusive) range of the radio."""
|
||||
|
||||
#: The minimum (inclusive) frequency tunable by this radio.
|
||||
minimum: RadioFrequency
|
||||
@ -63,19 +57,51 @@ class Radio:
|
||||
#: The spacing between adjacent frequencies.
|
||||
step: RadioFrequency
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
#: Specific frequencies to exclude. (e.g. Guard channels)
|
||||
excludes: FrozenSet[RadioFrequency] = frozenset()
|
||||
|
||||
def range(self) -> Iterator[RadioFrequency]:
|
||||
"""Returns an iterator over the usable frequencies of this radio."""
|
||||
return (
|
||||
RadioFrequency(x)
|
||||
for x in range(self.minimum.hertz, self.maximum.hertz, self.step.hertz)
|
||||
if RadioFrequency(x) not in self.excludes
|
||||
)
|
||||
|
||||
@property
|
||||
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):
|
||||
@ -88,53 +114,58 @@ class ChannelInUseError(RuntimeError):
|
||||
# TODO: Figure out appropriate steps for each radio. These are just guesses.
|
||||
#: List of all known radios used by aircraft in the game.
|
||||
RADIOS: List[Radio] = [
|
||||
Radio("AN/ARC-164", 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) FM", MHz(30), MHz(76), step=MHz(1)),
|
||||
# The AN/ARC-210 can also use [30, 88) and [108, 118), but the current
|
||||
# implementation can't implement the gap and the radio can't transmit on the
|
||||
# latter. There's still plenty of channels between 118 MHz and 400 MHz, so
|
||||
# not worth worrying about.
|
||||
Radio("AN/ARC-210", MHz(118), MHz(400), step=MHz(1)),
|
||||
Radio("AN/ARC-222", MHz(116), MHz(174), step=MHz(1)),
|
||||
Radio("SCR-522", MHz(100), MHz(156), step=MHz(1)),
|
||||
Radio("A.R.I. 1063", MHz(100), MHz(156), step=MHz(1)),
|
||||
Radio("BC-1206", kHz(200), kHz(400), step=kHz(10)),
|
||||
Radio("AN/ARC-164", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)),
|
||||
Radio("AN/ARC-186(V) AM", (RadioRange(MHz(116), MHz(152), step=MHz(1)),)),
|
||||
Radio("AN/ARC-186(V) FM", (RadioRange(MHz(30), MHz(76), step=MHz(1)),)),
|
||||
Radio(
|
||||
"AN/ARC-210",
|
||||
(
|
||||
RadioRange(MHz(225), MHz(400), MHz(1), frozenset((MHz(243),))),
|
||||
RadioRange(MHz(136), MHz(155), MHz(1)),
|
||||
RadioRange(MHz(156), MHz(174), MHz(1)),
|
||||
RadioRange(MHz(118), MHz(136), MHz(1)),
|
||||
RadioRange(MHz(30), MHz(88), MHz(1)),
|
||||
),
|
||||
),
|
||||
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
|
||||
# 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
|
||||
# can model gaps later if needed.
|
||||
Radio("TRT ERA 7000 V/UHF", MHz(118), MHz(150), step=MHz(1)),
|
||||
Radio("TRT ERA 7200 UHF", MHz(225), MHz(400), step=MHz(1)),
|
||||
Radio("TRT ERA 7000 V/UHF", (RadioRange(MHz(118), MHz(150), step=MHz(1)),)),
|
||||
Radio("TRT ERA 7200 UHF", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)),
|
||||
# Tomcat radios
|
||||
# # 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
|
||||
# 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
|
||||
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.
|
||||
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
|
||||
# 4 preset channels (A/B/C/D)
|
||||
Radio("SCR522", MHz(100), MHz(156), step=kHz(25)),
|
||||
Radio("R&S M3AR VHF", MHz(120), MHz(174), step=MHz(1)),
|
||||
Radio("R&S M3AR UHF", MHz(225), MHz(400), step=MHz(1)),
|
||||
Radio("SCR522", (RadioRange(MHz(100), MHz(156), step=kHz(25)),)),
|
||||
Radio("R&S M3AR VHF", (RadioRange(MHz(120), MHz(174), step=MHz(1)),)),
|
||||
Radio("R&S M3AR UHF", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)),
|
||||
# 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
|
||||
Radio("RSIU-4V", MHz(100), MHz(150), step=MHz(1)),
|
||||
Radio("RSIU-4V", (RadioRange(MHz(100), MHz(150), step=MHz(1)),)),
|
||||
# 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
|
||||
# Note: Also capable of 100MHz-150MHz, but we can't model gaps.
|
||||
Radio("R-800L1", MHz(220), MHz(400), step=kHz(25)),
|
||||
Radio("R-828", MHz(20), MHz(60), step=kHz(25)),
|
||||
Radio("R-800L1", (RadioRange(MHz(220), MHz(400), step=kHz(25)),)),
|
||||
Radio("R-828", (RadioRange(MHz(20), MHz(60), step=kHz(25)),)),
|
||||
# UH-1H
|
||||
Radio("AN/ARC-51BX", MHz(225), MHz(400), step=kHz(50)),
|
||||
Radio("AN/ARC-131", MHz(30), MHz(76), step=kHz(50)),
|
||||
Radio("AN/ARC-134", MHz(116), MHz(150), step=kHz(25)),
|
||||
Radio("R&S Series 6000", MHz(100), MHz(156), step=kHz(25)),
|
||||
Radio("AN/ARC-51BX", (RadioRange(MHz(225), MHz(400), step=kHz(50)),)),
|
||||
Radio("AN/ARC-131", (RadioRange(MHz(30), MHz(76), step=kHz(50)),)),
|
||||
Radio("AN/ARC-134", (RadioRange(MHz(116), MHz(150), 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
|
||||
# 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:
|
||||
self.allocated_channels: Set[RadioFrequency] = set()
|
||||
|
||||
@ -82,8 +82,10 @@ class FlakGenerator(AirDefenseGroupGenerator):
|
||||
)
|
||||
|
||||
# Some Opel Blitz trucks
|
||||
index = 0
|
||||
for i in range(int(max(1, 2))):
|
||||
for j in range(int(max(1, 2))):
|
||||
index += 1
|
||||
self.add_unit(
|
||||
Unarmed.Blitz_36_6700A,
|
||||
"BLITZ#" + str(index),
|
||||
|
||||
@ -16,7 +16,11 @@ class EwrGenerator(VehicleGroupGenerator[EwrGroundObject]):
|
||||
|
||||
def generate(self) -> None:
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ from qt_ui import (
|
||||
)
|
||||
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
|
||||
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.preferences.QLiberationFirstStartWindow import (
|
||||
QLiberationFirstStartWindow,
|
||||
@ -64,7 +64,8 @@ def run_ui(game: Optional[Game]) -> None:
|
||||
# init the theme and load the stylesheet based on the theme index
|
||||
liberation_theme.init()
|
||||
with open(
|
||||
"./resources/stylesheets/" + liberation_theme.get_theme_css_file()
|
||||
"./resources/stylesheets/" + liberation_theme.get_theme_css_file(),
|
||||
encoding="utf-8",
|
||||
) as stylesheet:
|
||||
logging.info("Loading stylesheet: %s", liberation_theme.get_theme_css_file())
|
||||
app.setStyleSheet(stylesheet.read())
|
||||
@ -231,11 +232,13 @@ def create_game(
|
||||
# for loadouts) without saving the generated campaign and reloading it the normal
|
||||
# way.
|
||||
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(
|
||||
FACTIONS[blue],
|
||||
FACTIONS[red],
|
||||
campaign.load_theater(),
|
||||
theater,
|
||||
campaign.load_air_wing_config(theater),
|
||||
Settings(
|
||||
supercarrier=supercarrier,
|
||||
automate_runway_repair=auto_procurement,
|
||||
|
||||
@ -13,7 +13,7 @@ from PySide2.QtCore import (
|
||||
from PySide2.QtGui import QIcon
|
||||
|
||||
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.transfers import TransferOrder, PendingTransfers
|
||||
from gen.ato import AirTaskingOrder, Package
|
||||
@ -165,8 +165,7 @@ class PackageModel(QAbstractListModel):
|
||||
self.beginRemoveRows(QModelIndex(), index, index)
|
||||
if flight.cargo is not None:
|
||||
flight.cargo.transport = None
|
||||
self.game_model.game.aircraft_inventory.return_from_flight(flight)
|
||||
flight.clear_roster()
|
||||
flight.return_pilots_and_aircraft()
|
||||
self.package.remove_flight(flight)
|
||||
self.endRemoveRows()
|
||||
self.update_tot()
|
||||
@ -258,8 +257,7 @@ class AtoModel(QAbstractListModel):
|
||||
self.beginRemoveRows(QModelIndex(), index, index)
|
||||
self.ato.remove_package(package)
|
||||
for flight in package.flights:
|
||||
self.game.aircraft_inventory.return_from_flight(flight)
|
||||
flight.clear_roster()
|
||||
flight.return_pilots_and_aircraft()
|
||||
if flight.cargo is not None:
|
||||
flight.cargo.transport = None
|
||||
self.endRemoveRows()
|
||||
|
||||
@ -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))
|
||||
@ -1,10 +1,10 @@
|
||||
from PySide2.QtGui import QStandardItem, QStandardItemModel
|
||||
|
||||
from game import Game
|
||||
from game.theater import ControlPointType
|
||||
from game.theater import ControlPointType, BuildingGroundObject
|
||||
from game.utils import Distance
|
||||
from gen import BuildingGroundObject, Conflict, FlightWaypointType
|
||||
from gen.flights.flight import FlightWaypoint
|
||||
from gen.conflictgen import Conflict
|
||||
from gen.flights.flight import FlightWaypoint, FlightWaypointType
|
||||
from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import Optional, Callable
|
||||
from typing import Optional, Callable, Iterable
|
||||
|
||||
from PySide2.QtCore import (
|
||||
QItemSelectionModel,
|
||||
@ -24,11 +24,13 @@ from PySide2.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QStackedLayout,
|
||||
QTabWidget,
|
||||
QComboBox,
|
||||
)
|
||||
|
||||
from game import Game
|
||||
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 qt_ui.models import AirWingModel, SquadronModel
|
||||
from qt_ui.uiconstants import AIRCRAFT_ICONS
|
||||
@ -96,8 +98,33 @@ class AllowedMissionTypeControls(QVBoxLayout):
|
||||
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):
|
||||
def __init__(self, squadron: Squadron) -> None:
|
||||
def __init__(self, squadron: Squadron, theater: ConflictTheater) -> None:
|
||||
super().__init__()
|
||||
self.setCheckable(True)
|
||||
self.squadron = squadron
|
||||
@ -119,6 +146,13 @@ class SquadronConfigurationBox(QGroupBox):
|
||||
self.nickname_edit.textChanged.connect(self.on_nickname_changed)
|
||||
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:
|
||||
player_label = QLabel(
|
||||
"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:
|
||||
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:
|
||||
self.setTitle(f"{self.squadron.name} - {self.squadron.aircraft}")
|
||||
|
||||
@ -158,16 +198,18 @@ class SquadronConfigurationBox(QGroupBox):
|
||||
self.squadron.pilot_pool = [
|
||||
Pilot(n, player=True) for n in player_names
|
||||
] + 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
|
||||
|
||||
|
||||
class SquadronConfigurationLayout(QVBoxLayout):
|
||||
def __init__(self, squadrons: list[Squadron]) -> None:
|
||||
def __init__(self, squadrons: list[Squadron], theater: ConflictTheater) -> None:
|
||||
super().__init__()
|
||||
self.squadron_configs = []
|
||||
for squadron in squadrons:
|
||||
squadron_config = SquadronConfigurationBox(squadron)
|
||||
squadron_config = SquadronConfigurationBox(squadron, theater)
|
||||
self.squadron_configs.append(squadron_config)
|
||||
self.addWidget(squadron_config)
|
||||
|
||||
@ -180,12 +222,12 @@ class SquadronConfigurationLayout(QVBoxLayout):
|
||||
|
||||
|
||||
class AircraftSquadronsPage(QWidget):
|
||||
def __init__(self, squadrons: list[Squadron]) -> None:
|
||||
def __init__(self, squadrons: list[Squadron], theater: ConflictTheater) -> None:
|
||||
super().__init__()
|
||||
layout = QVBoxLayout()
|
||||
self.setLayout(layout)
|
||||
|
||||
self.squadrons_config = SquadronConfigurationLayout(squadrons)
|
||||
self.squadrons_config = SquadronConfigurationLayout(squadrons, theater)
|
||||
|
||||
scrolling_widget = QWidget()
|
||||
scrolling_widget.setLayout(self.squadrons_config)
|
||||
@ -203,12 +245,12 @@ class AircraftSquadronsPage(QWidget):
|
||||
|
||||
|
||||
class AircraftSquadronsPanel(QStackedLayout):
|
||||
def __init__(self, air_wing: AirWing) -> None:
|
||||
def __init__(self, air_wing: AirWing, theater: ConflictTheater) -> None:
|
||||
super().__init__()
|
||||
self.air_wing = air_wing
|
||||
self.squadrons_pages: dict[AircraftType, AircraftSquadronsPage] = {}
|
||||
for aircraft, squadrons in self.air_wing.squadrons.items():
|
||||
page = AircraftSquadronsPage(squadrons)
|
||||
page = AircraftSquadronsPage(squadrons, theater)
|
||||
self.addWidget(page)
|
||||
self.squadrons_pages[aircraft] = page
|
||||
|
||||
@ -260,7 +302,7 @@ class AircraftTypeList(QListView):
|
||||
|
||||
|
||||
class AirWingConfigurationTab(QWidget):
|
||||
def __init__(self, air_wing: AirWing) -> None:
|
||||
def __init__(self, air_wing: AirWing, theater: ConflictTheater) -> None:
|
||||
super().__init__()
|
||||
|
||||
layout = QHBoxLayout()
|
||||
@ -270,7 +312,7 @@ class AirWingConfigurationTab(QWidget):
|
||||
type_list.page_index_changed.connect(self.on_aircraft_changed)
|
||||
layout.addWidget(type_list)
|
||||
|
||||
self.squadrons_panel = AircraftSquadronsPanel(air_wing)
|
||||
self.squadrons_panel = AircraftSquadronsPanel(air_wing, theater)
|
||||
layout.addLayout(self.squadrons_panel)
|
||||
|
||||
def apply(self) -> None:
|
||||
@ -315,7 +357,7 @@ class AirWingConfigurationDialog(QDialog):
|
||||
|
||||
self.tabs = []
|
||||
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"
|
||||
tab_widget.addTab(coalition_tab, name)
|
||||
self.tabs.append(coalition_tab)
|
||||
|
||||
@ -16,7 +16,6 @@ from PySide2.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from game.inventory import ControlPointAircraftInventory
|
||||
from game.squadrons import Squadron
|
||||
from gen.flights.flight import Flight
|
||||
from qt_ui.delegates import TwoColumnRowDelegate
|
||||
@ -34,13 +33,17 @@ class SquadronDelegate(TwoColumnRowDelegate):
|
||||
return index.data(AirWingModel.SquadronRole)
|
||||
|
||||
def text_for(self, index: QModelIndex, row: int, column: int) -> str:
|
||||
squadron = self.squadron(index)
|
||||
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):
|
||||
squadron = self.air_wing_model.data(index, AirWingModel.SquadronRole)
|
||||
return squadron.aircraft.name
|
||||
elif (row, column) == (1, 0):
|
||||
return self.squadron(index).nickname or ""
|
||||
return squadron.location.name
|
||||
elif (row, column) == (1, 1):
|
||||
squadron = self.squadron(index)
|
||||
active = len(squadron.active_pilots)
|
||||
@ -123,19 +126,13 @@ class AircraftInventoryData:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def each_from_inventory(
|
||||
cls, inventory: ControlPointAircraftInventory
|
||||
def each_untasked_from_squadron(
|
||||
cls, squadron: Squadron
|
||||
) -> Iterator[AircraftInventoryData]:
|
||||
for unit_type, num_units in inventory.all_aircraft:
|
||||
for _ in range(0, num_units):
|
||||
yield AircraftInventoryData(
|
||||
inventory.control_point.name,
|
||||
unit_type.name,
|
||||
"Idle",
|
||||
"N/A",
|
||||
"N/A",
|
||||
"N/A",
|
||||
)
|
||||
for _ in range(0, squadron.untasked_aircraft):
|
||||
yield AircraftInventoryData(
|
||||
squadron.name, squadron.aircraft.name, "Idle", "N/A", "N/A", "N/A"
|
||||
)
|
||||
|
||||
|
||||
class AirInventoryView(QWidget):
|
||||
@ -184,9 +181,8 @@ class AirInventoryView(QWidget):
|
||||
|
||||
def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]:
|
||||
game = self.game_model.game
|
||||
for control_point, inventory in game.aircraft_inventory.inventories.items():
|
||||
if control_point.captured:
|
||||
yield from AircraftInventoryData.each_from_inventory(inventory)
|
||||
for squadron in game.blue.air_wing.iter_squadrons():
|
||||
yield from AircraftInventoryData.each_untasked_from_squadron(squadron)
|
||||
|
||||
def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]:
|
||||
yield from self.iter_unallocated_aircraft()
|
||||
|
||||
@ -228,7 +228,7 @@ class QWaitingForMissionResultWindow(QDialog):
|
||||
)
|
||||
print(file)
|
||||
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["mission_ended"] = True
|
||||
debriefing = Debriefing(json_data, self.game, self.unit_map)
|
||||
|
||||
@ -14,7 +14,6 @@ from PySide2.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QPushButton,
|
||||
QHBoxLayout,
|
||||
QGridLayout,
|
||||
QLabel,
|
||||
QCheckBox,
|
||||
)
|
||||
|
||||
@ -24,7 +24,7 @@ from qt_ui.uiconstants import EVENT_ICONS
|
||||
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
|
||||
from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog
|
||||
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):
|
||||
@ -108,7 +108,7 @@ class QBaseMenu2(QDialog):
|
||||
capture_button.clicked.connect(self.cheat_capture)
|
||||
|
||||
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.setProperty("style", "budget-label")
|
||||
@ -190,7 +190,7 @@ class QBaseMenu2(QDialog):
|
||||
self.repair_button.setDisabled(True)
|
||||
|
||||
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
|
||||
ground_unit_limit = self.cp.frontline_unit_count_limit
|
||||
deployable_unit_info = ""
|
||||
@ -258,5 +258,5 @@ class QBaseMenu2(QDialog):
|
||||
|
||||
def update_budget(self, game: Game) -> None:
|
||||
self.budget_display.setText(
|
||||
QRecruitBehaviour.BUDGET_FORMAT.format(game.blue.budget)
|
||||
UnitTransactionFrame.BUDGET_FORMAT.format(game.blue.budget)
|
||||
)
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import TypeVar, Generic
|
||||
|
||||
from PySide2.QtCore import Qt
|
||||
from PySide2.QtWidgets import (
|
||||
QGroupBox,
|
||||
@ -11,15 +14,15 @@ from PySide2.QtWidgets import (
|
||||
QSpacerItem,
|
||||
QGridLayout,
|
||||
QApplication,
|
||||
QFrame,
|
||||
QMessageBox,
|
||||
)
|
||||
|
||||
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.windows.GameUpdateSignal import GameUpdateSignal
|
||||
from qt_ui.windows.QUnitInfoWindow import QUnitInfoWindow
|
||||
from enum import Enum
|
||||
from game.purchaseadapter import PurchaseAdapter, TransactionError
|
||||
|
||||
|
||||
class RecruitType(Enum):
|
||||
@ -27,21 +30,28 @@ class RecruitType(Enum):
|
||||
SELL = 1
|
||||
|
||||
|
||||
class PurchaseGroup(QGroupBox):
|
||||
def __init__(self, unit_type: UnitType, recruiter: QRecruitBehaviour) -> None:
|
||||
TransactionItemType = TypeVar("TransactionItemType")
|
||||
|
||||
|
||||
class PurchaseGroup(QGroupBox, Generic[TransactionItemType]):
|
||||
def __init__(
|
||||
self,
|
||||
item: TransactionItemType,
|
||||
recruiter: UnitTransactionFrame[TransactionItemType],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.unit_type = unit_type
|
||||
self.item = item
|
||||
self.recruiter = recruiter
|
||||
|
||||
self.setProperty("style", "buy-box")
|
||||
self.setMaximumHeight(36)
|
||||
self.setMaximumHeight(72)
|
||||
self.setMinimumHeight(36)
|
||||
layout = QHBoxLayout()
|
||||
self.setLayout(layout)
|
||||
|
||||
self.sell_button = QPushButton("-")
|
||||
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.setMaximumSize(16, 16)
|
||||
self.sell_button.setSizePolicy(
|
||||
@ -49,7 +59,7 @@ class PurchaseGroup(QGroupBox):
|
||||
)
|
||||
|
||||
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()
|
||||
@ -59,12 +69,12 @@ class PurchaseGroup(QGroupBox):
|
||||
|
||||
self.buy_button = QPushButton("+")
|
||||
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.setMaximumSize(16, 16)
|
||||
|
||||
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))
|
||||
|
||||
@ -76,30 +86,53 @@ class PurchaseGroup(QGroupBox):
|
||||
|
||||
@property
|
||||
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:
|
||||
self.buy_button.setEnabled(self.recruiter.enable_purchase(self.unit_type))
|
||||
self.sell_button.setEnabled(self.recruiter.enable_sale(self.unit_type))
|
||||
self.buy_button.setEnabled(self.recruiter.enable_purchase(self.item))
|
||||
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>")
|
||||
|
||||
|
||||
class QRecruitBehaviour:
|
||||
game_model: GameModel
|
||||
cp: ControlPoint
|
||||
purchase_groups: dict[UnitType, PurchaseGroup]
|
||||
existing_units_labels = None
|
||||
maximum_units = -1
|
||||
class UnitTransactionFrame(QFrame, Generic[TransactionItemType]):
|
||||
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.purchase_groups = {}
|
||||
self.purchase_groups: dict[
|
||||
TransactionItemType, PurchaseGroup[TransactionItemType]
|
||||
] = {}
|
||||
self.update_available_budget()
|
||||
|
||||
@property
|
||||
def pending_deliveries(self) -> PendingUnitDeliveries:
|
||||
return self.cp.pending_unit_deliveries
|
||||
def current_quantity_of(self, item: TransactionItemType) -> int:
|
||||
return self.purchase_adapter.current_quantity_of(item)
|
||||
|
||||
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
|
||||
def budget(self) -> float:
|
||||
@ -111,20 +144,20 @@ class QRecruitBehaviour:
|
||||
|
||||
def add_purchase_row(
|
||||
self,
|
||||
unit_type: UnitType,
|
||||
item: TransactionItemType,
|
||||
layout: QGridLayout,
|
||||
row: int,
|
||||
) -> None:
|
||||
exist = QGroupBox()
|
||||
exist.setProperty("style", "buy-box")
|
||||
exist.setMaximumHeight(36)
|
||||
exist.setMaximumHeight(72)
|
||||
exist.setMinimumHeight(36)
|
||||
existLayout = QHBoxLayout()
|
||||
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(
|
||||
QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
)
|
||||
@ -132,17 +165,17 @@ class QRecruitBehaviour:
|
||||
existing_units = QLabel(str(existing_units))
|
||||
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))
|
||||
|
||||
purchase_group = PurchaseGroup(unit_type, self)
|
||||
self.purchase_groups[unit_type] = purchase_group
|
||||
purchase_group = PurchaseGroup(item, self)
|
||||
self.purchase_groups[item] = purchase_group
|
||||
|
||||
info = QGroupBox()
|
||||
info.setProperty("style", "buy-box")
|
||||
info.setMaximumHeight(36)
|
||||
info.setMaximumHeight(72)
|
||||
info.setMinimumHeight(36)
|
||||
infolayout = QHBoxLayout()
|
||||
info.setLayout(infolayout)
|
||||
@ -151,7 +184,7 @@ class QRecruitBehaviour:
|
||||
unitInfo.setProperty("style", "btn-info")
|
||||
unitInfo.setMinimumSize(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))
|
||||
|
||||
existLayout.addWidget(unitName)
|
||||
@ -173,7 +206,9 @@ class QRecruitBehaviour:
|
||||
def update_available_budget(self) -> None:
|
||||
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
|
||||
# Shift = 10 times
|
||||
# CTRL = 5 Times
|
||||
@ -185,50 +220,59 @@ class QRecruitBehaviour:
|
||||
else:
|
||||
amount = 1
|
||||
|
||||
for i in range(amount):
|
||||
if recruit_type == RecruitType.SELL:
|
||||
if not self.sell(unit_type):
|
||||
return
|
||||
elif recruit_type == RecruitType.BUY:
|
||||
if not self.buy(unit_type):
|
||||
return
|
||||
if recruit_type == RecruitType.SELL:
|
||||
self.sell(item, amount)
|
||||
elif recruit_type == RecruitType.BUY:
|
||||
self.buy(item, amount)
|
||||
|
||||
def buy(self, unit_type: UnitType) -> bool:
|
||||
|
||||
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
|
||||
def post_transaction_update(self) -> None:
|
||||
self.update_purchase_controls()
|
||||
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
|
||||
|
||||
def sell(self, unit_type: UnitType) -> bool:
|
||||
if self.pending_deliveries.available_next_turn(unit_type) > 0:
|
||||
self.budget += unit_type.price
|
||||
self.pending_deliveries.sell({unit_type: 1})
|
||||
self.update_purchase_controls()
|
||||
self.update_available_budget()
|
||||
def sell(self, item: TransactionItemType, quantity: int) -> bool:
|
||||
try:
|
||||
self.purchase_adapter.sell(item, quantity)
|
||||
except TransactionError as ex:
|
||||
logging.exception(f"Sale of {self.display_name_of(item)} failed")
|
||||
QMessageBox.warning(self, "Sale failed", str(ex), QMessageBox.Ok)
|
||||
return False
|
||||
self.post_transaction_update()
|
||||
return True
|
||||
|
||||
def update_purchase_controls(self) -> None:
|
||||
for group in self.purchase_groups.values():
|
||||
group.update_state()
|
||||
|
||||
def enable_purchase(self, unit_type: UnitType) -> bool:
|
||||
return self.budget >= unit_type.price
|
||||
def enable_purchase(self, item: TransactionItemType) -> bool:
|
||||
return self.purchase_adapter.can_buy(item)
|
||||
|
||||
def enable_sale(self, unit_type: UnitType) -> bool:
|
||||
return True
|
||||
def enable_sale(self, item: TransactionItemType) -> bool:
|
||||
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:
|
||||
self.info_window = QUnitInfoWindow(self.game_model.game, unit_type)
|
||||
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
|
||||
@ -1,38 +1,38 @@
|
||||
import logging
|
||||
from typing import Set
|
||||
|
||||
from PySide2.QtCore import Qt
|
||||
from PySide2.QtWidgets import (
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from dcs.helicopters import helicopter_map
|
||||
|
||||
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.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:
|
||||
QFrame.__init__(self)
|
||||
super().__init__(
|
||||
game_model,
|
||||
AircraftPurchaseAdapter(
|
||||
cp, game_model.game.coalition_for(cp.captured), game_model.game
|
||||
),
|
||||
)
|
||||
self.cp = cp
|
||||
self.game_model = game_model
|
||||
self.purchase_groups = {}
|
||||
self.bought_amount_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.existing_units_labels = {}
|
||||
|
||||
@ -45,24 +45,14 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
|
||||
row = 0
|
||||
|
||||
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(
|
||||
unit_types,
|
||||
key=lambda u: u.name,
|
||||
)
|
||||
for row, unit_type in enumerate(sorted_units):
|
||||
self.add_purchase_row(unit_type, task_box_layout, row)
|
||||
for squadron in cp.squadrons:
|
||||
unit_types.add(squadron.aircraft)
|
||||
|
||||
sorted_squadrons = sorted(cp.squadrons, key=lambda s: (s.aircraft.name, s.name))
|
||||
for row, squadron in enumerate(sorted_squadrons):
|
||||
self.add_purchase_row(squadron, task_box_layout, row)
|
||||
|
||||
stretch = QVBoxLayout()
|
||||
stretch.addStretch()
|
||||
task_box_layout.addLayout(stretch, row, 0)
|
||||
@ -77,63 +67,18 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
|
||||
main_layout.addWidget(scroll)
|
||||
self.setLayout(main_layout)
|
||||
|
||||
def enable_purchase(self, unit_type: AircraftType) -> bool:
|
||||
if not super().enable_purchase(unit_type):
|
||||
return False
|
||||
if not self.cp.can_operate(unit_type):
|
||||
return False
|
||||
return True
|
||||
def sell_tooltip(self, is_enabled: bool) -> str:
|
||||
if is_enabled:
|
||||
return "Sell unit. Use Shift or Ctrl key to sell multiple units at once."
|
||||
else:
|
||||
return (
|
||||
"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:
|
||||
if not self.cp.can_operate(unit_type):
|
||||
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)
|
||||
def post_transaction_update(self) -> None:
|
||||
super().post_transaction_update()
|
||||
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):
|
||||
|
||||
@ -1,30 +1,27 @@
|
||||
from PySide2.QtCore import Qt
|
||||
from PySide2.QtWidgets import (
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PySide2.QtWidgets import QGridLayout, QScrollArea, QVBoxLayout, QWidget
|
||||
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.theater import ControlPoint
|
||||
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):
|
||||
QFrame.__init__(self)
|
||||
super().__init__(
|
||||
game_model,
|
||||
GroundUnitPurchaseAdapter(
|
||||
cp, game_model.game.coalition_for(cp.captured), game_model.game
|
||||
),
|
||||
)
|
||||
self.cp = cp
|
||||
self.game_model = game_model
|
||||
self.purchase_groups = {}
|
||||
self.bought_amount_labels = {}
|
||||
self.existing_units_labels = {}
|
||||
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
main_layout = QVBoxLayout()
|
||||
|
||||
scroll_content = QWidget()
|
||||
@ -50,11 +47,3 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour):
|
||||
scroll.setWidget(scroll_content)
|
||||
main_layout.addWidget(scroll)
|
||||
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
|
||||
|
||||
@ -26,7 +26,7 @@ class QIntelInfo(QFrame):
|
||||
intel_layout = QVBoxLayout()
|
||||
|
||||
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:
|
||||
task_type = unit_type.dcs_unit_type.task_default.name
|
||||
units_by_task[task_type][unit_type.name] += count
|
||||
|
||||
@ -77,14 +77,15 @@ class AircraftIntelLayout(IntelTableLayout):
|
||||
|
||||
total = 0
|
||||
for control_point in game.theater.control_points_for(player):
|
||||
base = control_point.base
|
||||
total += base.total_aircraft
|
||||
if not base.total_aircraft:
|
||||
allocation = control_point.allocated_aircraft(game)
|
||||
base_total = allocation.total_present
|
||||
total += base_total
|
||||
if not base_total:
|
||||
continue
|
||||
|
||||
self.add_header(f"{control_point.name} ({base.total_aircraft})")
|
||||
for airframe in sorted(base.aircraft, key=lambda k: k.name):
|
||||
count = base.aircraft[airframe]
|
||||
self.add_header(f"{control_point.name} ({base_total})")
|
||||
for airframe in sorted(allocation.present, key=lambda k: k.name):
|
||||
count = allocation.present[airframe]
|
||||
if not count:
|
||||
continue
|
||||
self.add_row(f" {airframe.name}", count)
|
||||
|
||||
@ -177,7 +177,6 @@ class QPackageDialog(QDialog):
|
||||
|
||||
def add_flight(self, flight: Flight) -> None:
|
||||
"""Adds the new flight to the package."""
|
||||
self.game.aircraft_inventory.claim_for_flight(flight)
|
||||
self.package_model.add_flight(flight)
|
||||
planner = FlightPlanBuilder(
|
||||
self.package_model.package, self.game.blue, self.game.theater
|
||||
@ -251,8 +250,7 @@ class QNewPackageDialog(QPackageDialog):
|
||||
def on_cancel(self) -> None:
|
||||
super().on_cancel()
|
||||
for flight in self.package_model.package.flights:
|
||||
self.game.aircraft_inventory.return_from_flight(flight)
|
||||
flight.clear_roster()
|
||||
flight.return_pilots_and_aircraft()
|
||||
|
||||
|
||||
class QEditPackageDialog(QPackageDialog):
|
||||
|
||||
@ -14,7 +14,7 @@ from PySide2.QtWidgets import (
|
||||
from dcs.unittype import FlyingType
|
||||
|
||||
from game import Game
|
||||
from game.squadrons import Squadron
|
||||
from game.squadrons.squadron import Squadron
|
||||
from game.theater import ControlPoint, OffMapSpawn
|
||||
from gen.ato import Package
|
||||
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.QArrivalAirfieldSelector import QArrivalAirfieldSelector
|
||||
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.settings.QFlightSlotEditor import FlightRosterEditor
|
||||
|
||||
@ -34,6 +33,7 @@ class QFlightCreator(QDialog):
|
||||
|
||||
def __init__(self, game: Game, package: Package, parent=None) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self.setMinimumWidth(400)
|
||||
|
||||
self.game = game
|
||||
self.package = package
|
||||
@ -51,7 +51,7 @@ class QFlightCreator(QDialog):
|
||||
layout.addLayout(QLabeledWidget("Task:", self.task_selector))
|
||||
|
||||
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.aircraft_selector.setCurrentIndex(0)
|
||||
@ -66,22 +66,6 @@ class QFlightCreator(QDialog):
|
||||
self.squadron_selector.setCurrentIndex(0)
|
||||
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(
|
||||
[cp for cp in game.theater.controlpoints if cp.captured],
|
||||
self.aircraft_selector.currentData(),
|
||||
@ -90,7 +74,7 @@ class QFlightCreator(QDialog):
|
||||
layout.addLayout(QLabeledWidget("Divert:", self.divert))
|
||||
|
||||
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))
|
||||
|
||||
squadron = self.squadron_selector.currentData()
|
||||
@ -144,8 +128,6 @@ class QFlightCreator(QDialog):
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
self.on_departure_changed(self.departure.currentIndex())
|
||||
|
||||
def reject(self) -> None:
|
||||
super().reject()
|
||||
# Clear the roster to return pilots to the pool.
|
||||
@ -161,25 +143,19 @@ class QFlightCreator(QDialog):
|
||||
def verify_form(self) -> Optional[str]:
|
||||
aircraft: Optional[Type[FlyingType]] = self.aircraft_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()
|
||||
size: int = self.flight_size_spinner.value()
|
||||
if aircraft is None:
|
||||
return "You must select an aircraft type."
|
||||
if squadron is None:
|
||||
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:
|
||||
return f"{divert.name} is not owned by your coalition."
|
||||
available = origin.base.aircraft.get(aircraft, 0)
|
||||
available = squadron.untasked_aircraft
|
||||
if not available:
|
||||
return f"{origin.name} has no {aircraft.id} available."
|
||||
return f"{squadron} has no aircraft 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:
|
||||
return f"Flight must have at least one aircraft."
|
||||
if self.custom_name_text and "|" in self.custom_name_text:
|
||||
@ -194,14 +170,9 @@ class QFlightCreator(QDialog):
|
||||
|
||||
task = self.task_selector.currentData()
|
||||
squadron = self.squadron_selector.currentData()
|
||||
origin = self.departure.currentData()
|
||||
arrival = self.arrival.currentData()
|
||||
divert = self.divert.currentData()
|
||||
roster = self.roster_editor.roster
|
||||
|
||||
if arrival is None:
|
||||
arrival = origin
|
||||
|
||||
flight = Flight(
|
||||
self.package,
|
||||
self.country,
|
||||
@ -211,8 +182,8 @@ class QFlightCreator(QDialog):
|
||||
roster.max_size,
|
||||
task,
|
||||
self.start_type.currentText(),
|
||||
origin,
|
||||
arrival,
|
||||
squadron.location,
|
||||
squadron.location,
|
||||
divert,
|
||||
custom_name=self.custom_name_text,
|
||||
roster=roster,
|
||||
@ -228,11 +199,9 @@ class QFlightCreator(QDialog):
|
||||
self.task_selector.currentData(), new_aircraft
|
||||
)
|
||||
self.departure.change_aircraft(new_aircraft)
|
||||
self.arrival.change_aircraft(new_aircraft)
|
||||
self.divert.change_aircraft(new_aircraft)
|
||||
|
||||
def on_departure_changed(self, index: int) -> None:
|
||||
departure = self.departure.itemData(index)
|
||||
def on_departure_changed(self, departure: ControlPoint) -> None:
|
||||
if isinstance(departure, OffMapSpawn):
|
||||
previous_type = self.start_type.currentText()
|
||||
if previous_type != "In Flight":
|
||||
@ -248,12 +217,12 @@ class QFlightCreator(QDialog):
|
||||
def on_task_changed(self, index: int) -> None:
|
||||
task = self.task_selector.itemData(index)
|
||||
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())
|
||||
|
||||
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
|
||||
# up repopulating from the same squadron we'll get the same pilots back.
|
||||
self.roster_editor.replace(None)
|
||||
@ -261,6 +230,7 @@ class QFlightCreator(QDialog):
|
||||
self.roster_editor.replace(
|
||||
FlightRoster(squadron, self.flight_size_spinner.value())
|
||||
)
|
||||
self.on_departure_changed(squadron.location)
|
||||
|
||||
def update_max_size(self, available: int) -> None:
|
||||
aircraft = self.aircraft_selector.currentData()
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"""Combo box for selecting squadrons."""
|
||||
from typing import Type, Optional
|
||||
from typing import Optional
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ class SquadronSelector(QComboBox):
|
||||
self,
|
||||
air_wing: AirWing,
|
||||
task: Optional[FlightType],
|
||||
aircraft: Optional[Type[FlyingType]],
|
||||
aircraft: Optional[AircraftType],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.air_wing = air_wing
|
||||
@ -24,8 +24,15 @@ class SquadronSelector(QComboBox):
|
||||
self.setSizeAdjustPolicy(self.AdjustToContents)
|
||||
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(
|
||||
self, task: Optional[FlightType], aircraft: Optional[Type[FlyingType]]
|
||||
self, task: Optional[FlightType], aircraft: Optional[AircraftType]
|
||||
) -> None:
|
||||
current_squadron = self.currentData()
|
||||
self.blockSignals(True)
|
||||
@ -41,12 +48,12 @@ class SquadronSelector(QComboBox):
|
||||
return
|
||||
|
||||
for squadron in self.air_wing.squadrons_for(aircraft):
|
||||
if task in squadron.mission_types:
|
||||
self.addItem(f"{squadron}", squadron)
|
||||
if task in squadron.mission_types and squadron.untasked_aircraft:
|
||||
self.addItem(f"{squadron.location}: {squadron}", squadron)
|
||||
|
||||
if self.count() == 0:
|
||||
self.addItem("No capable aircraft available", None)
|
||||
return
|
||||
|
||||
if current_squadron is not None:
|
||||
self.setCurrentText(f"{current_squadron}")
|
||||
self.setCurrentText(f"{current_squadron.location}: {current_squadron}")
|
||||
|
||||
@ -14,7 +14,7 @@ from PySide2.QtWidgets import (
|
||||
)
|
||||
|
||||
from game import Game
|
||||
from game.squadrons import Pilot
|
||||
from game.squadrons.pilot import Pilot
|
||||
from gen.flights.flight import Flight, FlightRoster
|
||||
from qt_ui.models import PackageModel
|
||||
|
||||
@ -195,8 +195,7 @@ class QFlightSlotEditor(QGroupBox):
|
||||
self.package_model = package_model
|
||||
self.flight = flight
|
||||
self.game = game
|
||||
self.inventory = self.game.aircraft_inventory.for_control_point(flight.from_cp)
|
||||
available = self.inventory.available(self.flight.unit_type)
|
||||
available = self.flight.squadron.untasked_aircraft
|
||||
max_count = self.flight.count + available
|
||||
if max_count > 4:
|
||||
max_count = 4
|
||||
@ -225,21 +224,18 @@ class QFlightSlotEditor(QGroupBox):
|
||||
def _changed_aircraft_count(self):
|
||||
old_count = self.flight.count
|
||||
new_count = int(self.aircraft_count_spinner.value())
|
||||
self.game.aircraft_inventory.return_from_flight(self.flight)
|
||||
self.flight.resize(new_count)
|
||||
try:
|
||||
self.game.aircraft_inventory.claim_for_flight(self.flight)
|
||||
self.flight.resize(new_count)
|
||||
except ValueError:
|
||||
# The UI should have prevented this, but if we ran out of aircraft
|
||||
# then roll back the inventory change.
|
||||
difference = new_count - self.flight.count
|
||||
available = self.inventory.available(self.flight.unit_type)
|
||||
available = self.flight.squadron.untasked_aircraft
|
||||
logging.error(
|
||||
f"Could not add {difference} additional aircraft to "
|
||||
f"{self.flight} because {self.flight.departure} has only "
|
||||
f"{available} {self.flight.unit_type} remaining"
|
||||
)
|
||||
self.game.aircraft_inventory.claim_for_flight(self.flight)
|
||||
self.flight.resize(old_count)
|
||||
return
|
||||
self.roster_editor.resize(new_count)
|
||||
|
||||
@ -1,116 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Union, Tuple
|
||||
from typing import Optional
|
||||
|
||||
import packaging.version
|
||||
from PySide2 import QtGui
|
||||
from PySide2.QtCore import QItemSelectionModel, QModelIndex, Qt
|
||||
from PySide2.QtGui import QStandardItem, QStandardItemModel
|
||||
from PySide2.QtWidgets import QAbstractItemView, QListView
|
||||
|
||||
import qt_ui.uiconstants as CONST
|
||||
from game.theater import ConflictTheater
|
||||
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)
|
||||
from game.campaignloader.campaign import Campaign
|
||||
|
||||
|
||||
class QCampaignItem(QStandardItem):
|
||||
@ -140,7 +38,7 @@ class QCampaignList(QListView):
|
||||
self.setup_content(show_incompatible)
|
||||
|
||||
@property
|
||||
def selected_campaign(self) -> Campaign:
|
||||
def selected_campaign(self) -> Optional[Campaign]:
|
||||
return self.currentIndex().data(QCampaignList.CampaignRole)
|
||||
|
||||
def setup_content(self, show_incompatible: bool) -> None:
|
||||
|
||||
@ -10,17 +10,14 @@ from PySide2.QtWidgets import QVBoxLayout, QTextEdit, QLabel, QCheckBox
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from game import db
|
||||
from game.campaignloader.campaign import Campaign
|
||||
from game.settings import Settings
|
||||
from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings
|
||||
from game.factions.faction import Faction
|
||||
from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar
|
||||
from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner
|
||||
from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog
|
||||
from qt_ui.windows.newgame.QCampaignList import (
|
||||
Campaign,
|
||||
QCampaignList,
|
||||
load_campaigns,
|
||||
)
|
||||
from qt_ui.windows.newgame.QCampaignList import QCampaignList
|
||||
|
||||
jinja_env = Environment(
|
||||
loader=FileSystemLoader("resources/ui/templates"),
|
||||
@ -41,7 +38,7 @@ class NewGameWizard(QtWidgets.QWizard):
|
||||
def __init__(self, parent=None):
|
||||
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.addPage(IntroPage())
|
||||
@ -116,10 +113,12 @@ class NewGameWizard(QtWidgets.QWizard):
|
||||
|
||||
blue_faction = self.faction_selection_page.selected_blue_faction
|
||||
red_faction = self.faction_selection_page.selected_red_faction
|
||||
theater = campaign.load_theater()
|
||||
generator = GameGenerator(
|
||||
blue_faction,
|
||||
red_faction,
|
||||
campaign.load_theater(),
|
||||
theater,
|
||||
campaign.load_air_wing_config(theater),
|
||||
settings,
|
||||
generator_settings,
|
||||
mod_settings,
|
||||
@ -369,6 +368,11 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
|
||||
)
|
||||
campaign = campaignList.selected_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.faction_selection.setDefaultFactions(campaign)
|
||||
self.performanceText.setText(
|
||||
|
||||
@ -230,7 +230,7 @@ class PilotSettingsBox(QGroupBox):
|
||||
)
|
||||
|
||||
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 = QCheckBox()
|
||||
@ -517,6 +517,18 @@ class QSettingsWindow(QDialog):
|
||||
self.ext_views.setChecked(self.game.settings.external_views_allowed)
|
||||
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:
|
||||
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(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.difficultyLayout.addWidget(self.missionRestrictionsSettings)
|
||||
|
||||
@ -909,6 +929,9 @@ class QSettingsWindow(QDialog):
|
||||
self.mapVisibiitySelection.currentData()
|
||||
)
|
||||
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.never_delay_player_flights = (
|
||||
self.never_delay_players.isChecked()
|
||||
|
||||
@ -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
|
||||
}
|
||||
Binary file not shown.
@ -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
|
||||
}
|
||||
Binary file not shown.
@ -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"
|
||||
}
|
||||
10
resources/campaigns/battle_of_abu_dhabi.yaml
Normal file
10
resources/campaigns/battle_of_abu_dhabi.yaml
Normal 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"
|
||||
@ -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.
151
resources/campaigns/black_sea.yaml
Normal file
151
resources/campaigns/black_sea.yaml
Normal 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
|
||||
@ -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.
@ -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>",
|
||||
"miz": "humble_helper.miz",
|
||||
"performance": 1,
|
||||
"version": "7.0"
|
||||
"version": "8.0"
|
||||
}
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user