Change squadrons to operate out of a single base.

https://github.com/dcs-liberation/dcs_liberation/issues/1145

Currently this is fixed at the start of the campaign. The squadron
locations are defined by the campaign file.

Follow up work:

* Track aircraft ownership per-squadron rather than per-airbase.
* UI for relocating squadrons.
* Ferry missions for squadrons that are relocating.
* Auto-relocation (probably only for retreat handling).

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1138
This commit is contained in:
Dan Albert 2021-08-07 21:51:18 -07:00
parent 51e056a765
commit 90ad1f4a61
51 changed files with 1043 additions and 652 deletions

View File

@ -4,10 +4,10 @@ Saves from 4.x are not compatible with 5.0.
## Features/Improvements ## Features/Improvements
* **[Campaign]** Weather! Theaters now experience weather that is more realistic for the region and its current season. For example, Persian Gulf will have very hot, sunny summers and Marianas will experience lots of rain during fall. These changes affect pressure, temperature, clouds and precipitation. Additionally, temperature will drop during the night, by an amount that is somewhat realistic for the region. * **[Campaign]** Weather! Theaters now experience weather that is more realistic for the region and its current season. For example, Persian Gulf will have very hot, sunny summers and Marianas will experience lots of rain during fall. These changes affect pressure, temperature, clouds and precipitation. Additionally, temperature will drop during the night, by an amount that is somewhat realistic for the region.
* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. * **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated.
* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts. * **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts.
* **[Campaign]** (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 AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions.
* **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI.
* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points.
@ -15,9 +15,10 @@ Saves from 4.x are not compatible with 5.0.
* **[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]** Transport aircraft will now be bought only if necessary at control points which can produce ground units and are capable to operate transport aircraft.
* **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron. * **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron.
* **[Mission Generation]** EWRs are now also headed towards the center of the conflict * **[Mission Generation]** EWRs are now also headed towards the center of the conflict
* **[Modding]** Campaigns now specify the squadrons that are present in the campaign, their roles, and their starting bases. Players can customize this at game start but the campaign will choose the defaults.
* **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet). * **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet).
* **[Kneeboard]** QNH (pressure MSL) and temperature have been added to the kneeboard. * **[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 or rename squadrons. * **[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 * **[UI]** Sell Button for aircraft will be disabled if there are no units available to be sold or all are already assigned to a mission
## Fixes ## Fixes

View File

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

View File

@ -10,7 +10,6 @@ from typing import Tuple, Dict, Any
from packaging.version import Version from packaging.version import Version
import yaml import yaml
from game.campaignloader.mizcampaignloader import MizCampaignLoader
from game.profiling import logged_duration from game.profiling import logged_duration
from game.theater import ( from game.theater import (
ConflictTheater, ConflictTheater,
@ -23,6 +22,8 @@ from game.theater import (
MarianaIslandsTheater, MarianaIslandsTheater,
) )
from game.version import CAMPAIGN_FORMAT_VERSION from game.version import CAMPAIGN_FORMAT_VERSION
from .campaignairwingconfig import CampaignAirWingConfig
from .mizcampaignloader import MizCampaignLoader
PERF_FRIENDLY = 0 PERF_FRIENDLY = 0
@ -103,6 +104,14 @@ class Campaign:
MizCampaignLoader(self.path.parent / miz, t).populate_theater() MizCampaignLoader(self.path.parent / miz, t).populate_theater()
return t 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 @property
def is_out_of_date(self) -> bool: def is_out_of_date(self) -> bool:
"""Returns True if this campaign is not up to date with the latest format. """Returns True if this campaign is not up to date with the latest format.

View File

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

View File

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

View File

@ -255,16 +255,16 @@ class MizCampaignLoader:
control_point.captured_invert = group.late_activation control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point control_points[control_point.id] = control_point
for ship in self.carriers(blue): for ship in self.carriers(blue):
# TODO: Name the carrier.
control_point = Carrier( control_point = Carrier(
"carrier", ship.position, next(self.control_point_id) ship.name, ship.position, next(self.control_point_id)
) )
control_point.captured = blue control_point.captured = blue
control_point.captured_invert = ship.late_activation control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point control_points[control_point.id] = control_point
for ship in self.lhas(blue): for ship in self.lhas(blue):
# TODO: Name the LHA.db control_point = Lha(
control_point = Lha("lha", ship.position, next(self.control_point_id)) ship.name, ship.position, next(self.control_point_id)
)
control_point.captured = blue control_point.captured = blue
control_point.captured_invert = ship.late_activation control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point control_points[control_point.id] = control_point

View File

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

View File

@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, Any, Optional
from dcs import Point from dcs import Point
from faker import Faker from faker import Faker
from game.campaignloader import CampaignAirWingConfig
from game.campaignloader.defaultsquadronassigner import DefaultSquadronAssigner
from game.commander import TheaterCommander from game.commander import TheaterCommander
from game.commander.missionscheduler import MissionScheduler from game.commander.missionscheduler import MissionScheduler
from game.income import Income from game.income import Income
@ -12,7 +14,7 @@ from game.inventory import GlobalAircraftInventory
from game.navmesh import NavMesh from game.navmesh import NavMesh
from game.orderedset import OrderedSet from game.orderedset import OrderedSet
from game.profiling import logged_duration, MultiEventTracer from game.profiling import logged_duration, MultiEventTracer
from game.savecompat import has_save_compat_for from game.squadrons import AirWing
from game.threatzones import ThreatZones from game.threatzones import ThreatZones
from game.transfers import PendingTransfers from game.transfers import PendingTransfers
@ -21,10 +23,9 @@ if TYPE_CHECKING:
from game.data.doctrine import Doctrine from game.data.doctrine import Doctrine
from game.factions.faction import Faction from game.factions.faction import Faction
from game.procurement import AircraftProcurementRequest, ProcurementAi from game.procurement import AircraftProcurementRequest, ProcurementAi
from game.squadrons import AirWing
from game.theater.bullseye import Bullseye from game.theater.bullseye import Bullseye
from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
from gen import AirTaskingOrder from gen.ato import AirTaskingOrder
class Coalition: class Coalition:
@ -40,7 +41,7 @@ class Coalition:
self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet() self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet()
self.bullseye = Bullseye(Point(0, 0)) self.bullseye = Bullseye(Point(0, 0))
self.faker = Faker(self.faction.locales) self.faker = Faker(self.faction.locales)
self.air_wing = AirWing(game, self) self.air_wing = AirWing(game)
self.transfers = PendingTransfers(game, player) self.transfers = PendingTransfers(game, player)
# Late initialized because the two coalitions in the game are mutually # Late initialized because the two coalitions in the game are mutually
@ -100,14 +101,7 @@ class Coalition:
del state["faker"] del state["faker"]
return state return state
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None: def __setstate__(self, state: dict[str, Any]) -> None:
# Begin save compat
old_procurement_requests = state["procurement_requests"]
if isinstance(old_procurement_requests, list):
state["procurement_requests"] = OrderedSet(old_procurement_requests)
# End save compat
self.__dict__.update(state) self.__dict__.update(state)
# Regenerate any state that was not persisted. # Regenerate any state that was not persisted.
self.on_load() self.on_load()
@ -120,6 +114,11 @@ class Coalition:
raise RuntimeError("Double-initialization of Coalition.opponent") raise RuntimeError("Double-initialization of Coalition.opponent")
self._opponent = opponent self._opponent = opponent
def configure_default_air_wing(
self, air_wing_config: CampaignAirWingConfig
) -> None:
DefaultSquadronAssigner(air_wing_config, self.game, self).assign()
def adjust_budget(self, amount: float) -> None: def adjust_budget(self, amount: float) -> None:
self.budget += amount self.budget += amount

View File

@ -2,7 +2,8 @@ from typing import Optional, Tuple
from game.commander.missionproposals import ProposedFlight from game.commander.missionproposals import ProposedFlight
from game.inventory import GlobalAircraftInventory from game.inventory import GlobalAircraftInventory
from game.squadrons import AirWing, Squadron from game.squadrons.squadron import Squadron
from game.squadrons.airwing import AirWing
from game.theater import ControlPoint, MissionTarget from game.theater import ControlPoint, MissionTarget
from game.utils import meters from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.ai_flight_planner_db import aircraft_for_task
@ -67,7 +68,7 @@ class AircraftAllocator:
# Valid location with enough aircraft available. Find a squadron to fit # Valid location with enough aircraft available. Find a squadron to fit
# the role. # the role.
squadrons = self.air_wing.auto_assignable_for_task_with_type( squadrons = self.air_wing.auto_assignable_for_task_with_type(
aircraft, task aircraft, task, airfield
) )
for squadron in squadrons: for squadron in squadrons:
if squadron.operates_from(airfield) and squadron.can_provide_pilots( if squadron.operates_from(airfield) and squadron.can_provide_pilots(

View File

@ -3,10 +3,10 @@ from typing import Optional
from game.commander.missionproposals import ProposedFlight from game.commander.missionproposals import ProposedFlight
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.inventory import GlobalAircraftInventory from game.inventory import GlobalAircraftInventory
from game.squadrons import AirWing from game.squadrons.airwing import AirWing
from game.theater import MissionTarget, OffMapSpawn, ControlPoint from game.theater import MissionTarget, OffMapSpawn, ControlPoint
from game.utils import nautical_miles from game.utils import nautical_miles
from gen import Package from gen.ato import Package
from game.commander.aircraftallocator import AircraftAllocator from game.commander.aircraftallocator import AircraftAllocator
from gen.flights.closestairfields import ClosestAirfields from gen.flights.closestairfields import ClosestAirfields
from gen.flights.flight import Flight from gen.flights.flight import Flight

View File

@ -13,8 +13,8 @@ from game.settings import Settings
from game.squadrons import AirWing from game.squadrons import AirWing
from game.theater import ConflictTheater from game.theater import ConflictTheater
from game.threatzones import ThreatZones from game.threatzones import ThreatZones
from gen import AirTaskingOrder, Package
from game.commander.packagebuilder import PackageBuilder from game.commander.packagebuilder import PackageBuilder
from gen.ato import AirTaskingOrder, Package
from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
from gen.flights.flightplan import FlightPlanBuilder from gen.flights.flightplan import FlightPlanBuilder

View File

@ -11,12 +11,11 @@ from game.commander.missionproposals import ProposedFlight, EscortType, Proposed
from game.commander.packagefulfiller import PackageFulfiller from game.commander.packagefulfiller import PackageFulfiller
from game.commander.tasks.theatercommandertask import TheaterCommanderTask from game.commander.tasks.theatercommandertask import TheaterCommanderTask
from game.commander.theaterstate import TheaterState from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.settings import AutoAtoBehavior from game.settings import AutoAtoBehavior
from game.theater import MissionTarget from game.theater import MissionTarget
from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
from game.utils import Distance, meters from game.utils import Distance, meters
from gen import Package from gen.ato import Package
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
if TYPE_CHECKING: if TYPE_CHECKING:

View File

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

View File

@ -41,8 +41,8 @@ from game.utils import (
if TYPE_CHECKING: if TYPE_CHECKING:
from gen.aircraft import FlightData from gen.aircraft import FlightData
from gen import AirSupport, RadioFrequency, RadioRegistry from gen.airsupport import AirSupport
from gen.radios import Radio from gen.radios import Radio, RadioFrequency, RadioRegistry
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@ -11,7 +11,7 @@ from game.debriefing import AirLosses, Debriefing
from game.infos.information import Information from game.infos.information import Information
from game.operation.operation import Operation from game.operation.operation import Operation
from game.theater import ControlPoint from game.theater import ControlPoint
from gen import AirTaskingOrder from gen.ato import AirTaskingOrder
from gen.ground_forces.combat_stance import CombatStance from gen.ground_forces.combat_stance import CombatStance
from ..dcs.groundunittype import GroundUnitType from ..dcs.groundunittype import GroundUnitType
from ..unitmap import UnitMap from ..unitmap import UnitMap

View File

@ -1,10 +1,12 @@
from __future__ import annotations
import itertools import itertools
import logging import logging
import math import math
from collections import Iterator from collections import Iterator
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from enum import Enum from enum import Enum
from typing import Any, List, Type, Union, cast from typing import Any, List, Type, Union, cast, TYPE_CHECKING
from dcs.mapping import Point from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike from dcs.task import CAP, CAS, PinpointStrike
@ -21,6 +23,7 @@ from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
from gen.ground_forces.ai_ground_planner import GroundPlanner from gen.ground_forces.ai_ground_planner import GroundPlanner
from . import persistency from . import persistency
from .campaignloader import CampaignAirWingConfig
from .coalition import Coalition from .coalition import Coalition
from .debriefing import Debriefing from .debriefing import Debriefing
from .event.event import Event from .event.event import Event
@ -28,10 +31,8 @@ from .event.frontlineattack import FrontlineAttackEvent
from .factions.faction import Faction from .factions.faction import Faction
from .infos.information import Information from .infos.information import Information
from .navmesh import NavMesh from .navmesh import NavMesh
from .procurement import AircraftProcurementRequest
from .profiling import logged_duration from .profiling import logged_duration
from .settings import Settings from .settings import Settings
from .squadrons import AirWing
from .theater import ConflictTheater, ControlPoint from .theater import ConflictTheater, ControlPoint
from .theater.bullseye import Bullseye from .theater.bullseye import Bullseye
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
@ -39,6 +40,9 @@ from .threatzones import ThreatZones
from .unitmap import UnitMap from .unitmap import UnitMap
from .weather import Conditions, TimeOfDay from .weather import Conditions, TimeOfDay
if TYPE_CHECKING:
from .squadrons import AirWing
COMMISION_UNIT_VARIETY = 4 COMMISION_UNIT_VARIETY = 4
COMMISION_LIMITS_SCALE = 1.5 COMMISION_LIMITS_SCALE = 1.5
COMMISION_LIMITS_FACTORS = { COMMISION_LIMITS_FACTORS = {
@ -85,6 +89,7 @@ class Game:
player_faction: Faction, player_faction: Faction,
enemy_faction: Faction, enemy_faction: Faction,
theater: ConflictTheater, theater: ConflictTheater,
air_wing_config: CampaignAirWingConfig,
start_date: datetime, start_date: datetime,
settings: Settings, settings: Settings,
player_budget: float, player_budget: float,
@ -119,6 +124,9 @@ class Game:
self.blue.set_opponent(self.red) self.blue.set_opponent(self.red)
self.red.set_opponent(self.blue) self.red.set_opponent(self.blue)
self.blue.configure_default_air_wing(air_wing_config)
self.red.configure_default_air_wing(air_wing_config)
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints) self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
self.on_load(game_still_initializing=True) self.on_load(game_still_initializing=True)

View File

@ -5,10 +5,10 @@ from collections import defaultdict, Iterator, Iterable
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from gen.flights.flight import Flight
if TYPE_CHECKING: if TYPE_CHECKING:
from game.theater import ControlPoint from game.theater import ControlPoint
from gen.flights.flight import Flight
class ControlPointAircraftInventory: class ControlPointAircraftInventory:

View File

@ -16,16 +16,18 @@ from dcs.triggers import TriggerStart
from game.plugins import LuaPluginManager from game.plugins import LuaPluginManager
from game.theater.theatergroundobject import TheaterGroundObject from game.theater.theatergroundobject import TheaterGroundObject
from gen import Conflict, FlightType, VisualGenerator, AirSupport
from gen.aircraft import AircraftConflictGenerator, FlightData from gen.aircraft import AircraftConflictGenerator, FlightData
from gen.airfields import AIRFIELD_DATA from gen.airfields import AIRFIELD_DATA
from gen.airsupport import AirSupport
from gen.airsupportgen import AirSupportConflictGenerator from gen.airsupportgen import AirSupportConflictGenerator
from gen.armor import GroundConflictGenerator from gen.armor import GroundConflictGenerator
from gen.beacons import load_beacons_for_terrain from gen.beacons import load_beacons_for_terrain
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
from gen.cargoshipgen import CargoShipGenerator from gen.cargoshipgen import CargoShipGenerator
from gen.conflictgen import Conflict
from gen.convoygen import ConvoyGenerator from gen.convoygen import ConvoyGenerator
from gen.environmentgen import EnvironmentGenerator from gen.environmentgen import EnvironmentGenerator
from gen.flights.flight import FlightType
from gen.forcedoptionsgen import ForcedOptionsGenerator from gen.forcedoptionsgen import ForcedOptionsGenerator
from gen.groundobjectsgen import GroundObjectsGenerator from gen.groundobjectsgen import GroundObjectsGenerator
from gen.kneeboard import KneeboardGenerator from gen.kneeboard import KneeboardGenerator
@ -34,6 +36,7 @@ from gen.naming import namegen
from gen.radios import RadioFrequency, RadioRegistry from gen.radios import RadioFrequency, RadioRegistry
from gen.tacan import TacanRegistry from gen.tacan import TacanRegistry
from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator
from gen.visualgen import VisualGenerator
from .. import db from .. import db
from ..theater import Airfield, FrontLine from ..theater import Airfield, FrontLine
from ..unitmap import UnitMap from ..unitmap import UnitMap

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import math import math
import random import random
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
from game import db from game import db
@ -10,6 +10,7 @@ from game.data.groundunitclass import GroundUnitClass
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction from game.factions.faction import Faction
from game.squadrons import Squadron
from game.theater import ControlPoint, MissionTarget from game.theater import ControlPoint, MissionTarget
from game.utils import meters from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.ai_flight_planner_db import aircraft_for_task
@ -209,32 +210,38 @@ class ProcurementAi:
return GroundUnitClass.Tank return GroundUnitClass.Tank
return worst_balanced return worst_balanced
@staticmethod
def _compatible_squadron_at(
aircraft: AircraftType, airbase: ControlPoint, task: FlightType, count: int
) -> Optional[Squadron]:
for squadron in airbase.squadrons:
if squadron.aircraft != aircraft:
continue
if not squadron.can_auto_assign(task):
continue
if not squadron.can_provide_pilots(count):
continue
return squadron
return None
def affordable_aircraft_for( def affordable_aircraft_for(
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
) -> Optional[AircraftType]: ) -> Optional[AircraftType]:
best_choice: Optional[AircraftType] = None best_choice: Optional[AircraftType] = None
for unit in aircraft_for_task(request.task_capability): for unit in aircraft_for_task(request.task_capability):
if unit not in self.faction.aircrafts:
continue
if unit.price * request.number > budget: if unit.price * request.number > budget:
continue continue
if not airbase.can_operate(unit):
squadron = self._compatible_squadron_at(
unit, airbase, request.task_capability, request.number
)
if squadron is None:
continue continue
distance_to_target = meters(request.near.distance_to(airbase)) distance_to_target = meters(request.near.distance_to(airbase))
if distance_to_target > unit.max_mission_range: if distance_to_target > unit.max_mission_range:
continue 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 # 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 # 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. # the chance to skip based on the price compared to the rest of the choices.

View File

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

View File

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

View File

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

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

@ -0,0 +1,74 @@
from __future__ import annotations
import itertools
from collections import defaultdict
from typing import Sequence, Iterator, TYPE_CHECKING
from game.dcs.aircrafttype import AircraftType
from gen.flights.flight import FlightType
from .squadron import Squadron
from ..theater import ControlPoint
if TYPE_CHECKING:
from game import Game
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
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, 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()
@property
def size(self) -> int:
return sum(len(s) for s in self.squadrons.values())

View File

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

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

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

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

@ -0,0 +1,262 @@
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
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
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(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 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]
@classmethod
def create_from(
cls,
squadron_def: SquadronDef,
base: ControlPoint,
coalition: Coalition,
game: Game,
) -> Squadron:
return Squadron(
squadron_def.name,
squadron_def.nickname,
squadron_def.country,
squadron_def.role,
squadron_def.aircraft,
squadron_def.livery,
squadron_def.mission_types,
squadron_def.operating_bases,
squadron_def.pilot_pool,
coalition,
game.settings,
base,
)

View File

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

View File

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

View File

@ -241,6 +241,12 @@ class ConflictTheater:
return i return i
raise KeyError(f"Cannot find ControlPoint with ID {id}") raise KeyError(f"Cannot find ControlPoint with ID {id}")
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 @property
def seasonal_conditions(self) -> SeasonalConditions: def seasonal_conditions(self) -> SeasonalConditions:
raise NotImplementedError raise NotImplementedError

View File

@ -54,6 +54,7 @@ from ..weather import Conditions
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
from game.squadrons.squadron import Squadron
from ..transfers import PendingTransfers from ..transfers import PendingTransfers
FREE_FRONTLINE_UNIT_SUPPLY: int = 15 FREE_FRONTLINE_UNIT_SUPPLY: int = 15
@ -322,6 +323,8 @@ class ControlPoint(MissionTarget, ABC):
self.target_position: Optional[Point] = None self.target_position: Optional[Point] = None
self.squadrons: list[Squadron] = []
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<{self.__class__}: {self.name}>" return f"<{self.__class__}: {self.name}>"

View File

@ -30,7 +30,7 @@ from game.theater.theatergroundobject import (
) )
from game.utils import Heading from game.utils import Heading
from game.version import VERSION from game.version import VERSION
from gen import namegen from gen.naming import namegen
from gen.coastal.coastal_group_generator import generate_coastal_group from gen.coastal.coastal_group_generator import generate_coastal_group
from gen.defenses.armor_group_generator import generate_armor_group from gen.defenses.armor_group_generator import generate_armor_group
from gen.fleet.ship_group_generator import ( from gen.fleet.ship_group_generator import (
@ -49,6 +49,7 @@ from . import (
Fob, Fob,
OffMapSpawn, OffMapSpawn,
) )
from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig
from ..profiling import logged_duration from ..profiling import logged_duration
from ..settings import Settings from ..settings import Settings
@ -85,6 +86,7 @@ class GameGenerator:
player: Faction, player: Faction,
enemy: Faction, enemy: Faction,
theater: ConflictTheater, theater: ConflictTheater,
air_wing_config: CampaignAirWingConfig,
settings: Settings, settings: Settings,
generator_settings: GeneratorSettings, generator_settings: GeneratorSettings,
mod_settings: ModSettings, mod_settings: ModSettings,
@ -92,6 +94,7 @@ class GameGenerator:
self.player = player self.player = player
self.enemy = enemy self.enemy = enemy
self.theater = theater self.theater = theater
self.air_wing_config = air_wing_config
self.settings = settings self.settings = settings
self.generator_settings = generator_settings self.generator_settings = generator_settings
self.mod_settings = mod_settings self.mod_settings = mod_settings
@ -105,6 +108,7 @@ class GameGenerator:
player_faction=self.player.apply_mod_settings(self.mod_settings), player_faction=self.player.apply_mod_settings(self.mod_settings),
enemy_faction=self.enemy.apply_mod_settings(self.mod_settings), enemy_faction=self.enemy.apply_mod_settings(self.mod_settings),
theater=self.theater, theater=self.theater,
air_wing_config=self.air_wing_config,
start_date=self.generator_settings.start_date, start_date=self.generator_settings.start_date,
settings=self.settings, settings=self.settings,
player_budget=self.generator_settings.player_budget, player_budget=self.generator_settings.player_budget,

View File

@ -51,7 +51,6 @@ from dcs.mapping import Point
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.procurement import AircraftProcurementRequest from game.procurement import AircraftProcurementRequest
from game.squadrons import Squadron
from game.theater import ControlPoint, MissionTarget from game.theater import ControlPoint, MissionTarget
from game.theater.transitnetwork import ( from game.theater.transitnetwork import (
TransitConnection, TransitConnection,
@ -68,6 +67,7 @@ from gen.naming import namegen
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
from game.inventory import ControlPointAircraftInventory from game.inventory import ControlPointAircraftInventory
from game.squadrons import Squadron
class Transport: class Transport:
@ -318,7 +318,7 @@ class AirliftPlanner:
inventory = self.game.aircraft_inventory.for_control_point(cp) inventory = self.game.aircraft_inventory.for_control_point(cp)
for unit_type, available in inventory.all_aircraft: for unit_type, available in inventory.all_aircraft:
squadrons = air_wing.auto_assignable_for_task_with_type( squadrons = air_wing.auto_assignable_for_task_with_type(
unit_type, FlightType.TRANSPORT unit_type, FlightType.TRANSPORT, cp
) )
for squadron in squadrons: for squadron in squadrons:
if self.compatible_with_mission(unit_type, cp): if self.compatible_with_mission(unit_type, cp):

View File

@ -110,4 +110,9 @@ VERSION = _build_version_string()
#: Version 8.0 #: Version 8.0
#: * DCS 2.7.4.9632 changed scenery target IDs. Any mission using map buildings as #: * DCS 2.7.4.9632 changed scenery target IDs. Any mission using map buildings as
#: strike targets must check and potentially recreate all those objectives. #: strike targets must check and potentially recreate all those objectives.
CAMPAIGN_FORMAT_VERSION = (8, 0) #:
#: Version 9.0
#: * Campaign files now define the initial squadron layouts. See TODO.
#: * CV and LHA control points now get their names from the group name in the campaign
#: miz.
CAMPAIGN_FORMAT_VERSION = (9, 0)

View File

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

View File

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

View File

@ -69,7 +69,6 @@ from game.data.weapons import Pylon, WeaponType as WeaponTypeEnum
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.factions.faction import Faction from game.factions.faction import Faction
from game.settings import Settings from game.settings import Settings
from game.squadrons import Pilot
from game.theater.controlpoint import ( from game.theater.controlpoint import (
Airfield, Airfield,
ControlPoint, ControlPoint,
@ -109,6 +108,7 @@ from .naming import namegen
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
from game.squadrons import Pilot
WARM_START_HELI_ALT = meters(500) WARM_START_HELI_ALT = meters(500)
WARM_START_ALTITUDE = meters(3000) WARM_START_ALTITUDE = meters(3000)

View File

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

View File

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

View File

@ -2,20 +2,19 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from enum import Enum from enum import Enum
from typing import List, Optional, TYPE_CHECKING, Union, Sequence, Any from typing import List, Optional, TYPE_CHECKING, Union, Sequence
from dcs.mapping import Point from dcs.mapping import Point
from dcs.point import MovingPoint, PointAction from dcs.point import MovingPoint, PointAction
from dcs.unit import Unit from dcs.unit import Unit
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.savecompat import has_save_compat_for
from game.squadrons import Pilot, Squadron
from game.theater.controlpoint import ControlPoint, MissionTarget from game.theater.controlpoint import ControlPoint, MissionTarget
from game.utils import Distance, meters from game.utils import Distance, meters
from gen.flights.loadouts import Loadout from gen.flights.loadouts import Loadout
if TYPE_CHECKING: if TYPE_CHECKING:
from game.squadrons import Pilot, Squadron
from game.transfers import TransferOrder from game.transfers import TransferOrder
from gen.ato import Package from gen.ato import Package
from gen.flights.flightplan import FlightPlan from gen.flights.flightplan import FlightPlan
@ -50,6 +49,8 @@ class FlightType(Enum):
strike-like missions will need more specialized control. strike-like missions will need more specialized control.
* ai_flight_planner.py: Use the new mission type in propose_missions so the AI will * ai_flight_planner.py: Use the new mission type in propose_missions so the AI will
plan the new mission type. plan the new mission type.
* FlightType.is_air_to_air and FlightType.is_air_to_ground: If the new mission type
fits either of these categories, update those methods accordingly.
""" """
TARCAP = "TARCAP" TARCAP = "TARCAP"
@ -80,6 +81,30 @@ class FlightType(Enum):
return entry return entry
raise KeyError(f"No FlightType with name {name}") raise KeyError(f"No FlightType with name {name}")
@property
def is_air_to_air(self) -> bool:
return self in {
FlightType.TARCAP,
FlightType.BARCAP,
FlightType.INTERCEPTION,
FlightType.ESCORT,
FlightType.SWEEP,
}
@property
def is_air_to_ground(self) -> bool:
return self in {
FlightType.CAS,
FlightType.STRIKE,
FlightType.ANTISHIP,
FlightType.SEAD,
FlightType.DEAD,
FlightType.BAI,
FlightType.OCA_RUNWAY,
FlightType.OCA_AIRCRAFT,
FlightType.SEAD_ESCORT,
}
class FlightWaypointType(Enum): class FlightWaypointType(Enum):
"""Enumeration of waypoint types. """Enumeration of waypoint types.
@ -169,12 +194,6 @@ class FlightWaypoint:
self.tot: Optional[timedelta] = None self.tot: Optional[timedelta] = None
self.departure_time: Optional[timedelta] = None self.departure_time: Optional[timedelta] = None
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
if "min_fuel" not in state:
state["min_fuel"] = None
self.__dict__.update(state)
@property @property
def position(self) -> Point: def position(self) -> Point:
return Point(self.x, self.y) return Point(self.x, self.y)

View File

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

View File

@ -233,10 +233,12 @@ def create_game(
# way. # way.
inject_custom_payloads(Path(persistency.base_path())) inject_custom_payloads(Path(persistency.base_path()))
campaign = Campaign.from_file(campaign_path) campaign = Campaign.from_file(campaign_path)
theater = campaign.load_theater()
generator = GameGenerator( generator = GameGenerator(
FACTIONS[blue], FACTIONS[blue],
FACTIONS[red], FACTIONS[red],
campaign.load_theater(), theater,
campaign.load_air_wing_config(theater),
Settings( Settings(
supercarrier=supercarrier, supercarrier=supercarrier,
automate_runway_repair=auto_procurement, automate_runway_repair=auto_procurement,

View File

@ -13,7 +13,7 @@ from PySide2.QtCore import (
from PySide2.QtGui import QIcon from PySide2.QtGui import QIcon
from game.game import Game from game.game import Game
from game.squadrons import Squadron, Pilot from game.squadrons.squadron import Pilot, Squadron
from game.theater.missiontarget import MissionTarget from game.theater.missiontarget import MissionTarget
from game.transfers import TransferOrder, PendingTransfers from game.transfers import TransferOrder, PendingTransfers
from gen.ato import AirTaskingOrder, Package from gen.ato import AirTaskingOrder, Package

View File

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

View File

@ -28,7 +28,7 @@ from PySide2.QtWidgets import (
from game import Game from game import Game
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.squadrons import Squadron, AirWing, Pilot from game.squadrons import AirWing, Pilot, Squadron
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
from qt_ui.models import AirWingModel, SquadronModel from qt_ui.models import AirWingModel, SquadronModel
from qt_ui.uiconstants import AIRCRAFT_ICONS from qt_ui.uiconstants import AIRCRAFT_ICONS

View File

@ -34,13 +34,17 @@ class SquadronDelegate(TwoColumnRowDelegate):
return index.data(AirWingModel.SquadronRole) return index.data(AirWingModel.SquadronRole)
def text_for(self, index: QModelIndex, row: int, column: int) -> str: def text_for(self, index: QModelIndex, row: int, column: int) -> str:
squadron = self.squadron(index)
if (row, column) == (0, 0): if (row, column) == (0, 0):
return self.squadron(index).name if squadron.nickname:
nickname = f' "{squadron.nickname}"'
else:
nickname = ""
return f"{squadron.name}{nickname}"
elif (row, column) == (0, 1): elif (row, column) == (0, 1):
squadron = self.air_wing_model.data(index, AirWingModel.SquadronRole)
return squadron.aircraft.name return squadron.aircraft.name
elif (row, column) == (1, 0): elif (row, column) == (1, 0):
return self.squadron(index).nickname or "" return squadron.location.name
elif (row, column) == (1, 1): elif (row, column) == (1, 1):
squadron = self.squadron(index) squadron = self.squadron(index)
active = len(squadron.active_pilots) active = len(squadron.active_pilots)

View File

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

View File

@ -45,22 +45,10 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
row = 0 row = 0
unit_types: Set[AircraftType] = set() unit_types: Set[AircraftType] = set()
for unit_type in self.game_model.game.blue.faction.aircrafts: for squadron in cp.squadrons:
if self.cp.is_carrier and not unit_type.carrier_capable: unit_types.add(squadron.aircraft)
continue
if self.cp.is_lha and not unit_type.lha_capable:
continue
if (
self.cp.cptype in [ControlPointType.FOB, ControlPointType.FARP]
and unit_type not in helicopter_map.values()
):
continue
unit_types.add(unit_type)
sorted_units = sorted( sorted_units = sorted(unit_types, key=lambda u: u.name)
unit_types,
key=lambda u: u.name,
)
for row, unit_type in enumerate(sorted_units): for row, unit_type in enumerate(sorted_units):
self.add_purchase_row(unit_type, task_box_layout, row) self.add_purchase_row(unit_type, task_box_layout, row)
stretch = QVBoxLayout() stretch = QVBoxLayout()

View File

@ -14,7 +14,7 @@ from PySide2.QtWidgets import (
from dcs.unittype import FlyingType from dcs.unittype import FlyingType
from game import Game from game import Game
from game.squadrons import Squadron from game.squadrons.squadron import Squadron
from game.theater import ControlPoint, OffMapSpawn from game.theater import ControlPoint, OffMapSpawn
from gen.ato import Package from gen.ato import Package
from gen.flights.flight import Flight, FlightRoster from gen.flights.flight import Flight, FlightRoster

View File

@ -4,7 +4,7 @@ from typing import Type, Optional
from PySide2.QtWidgets import QComboBox from PySide2.QtWidgets import QComboBox
from dcs.unittype import FlyingType from dcs.unittype import FlyingType
from game.squadrons import AirWing from game.squadrons.airwing import AirWing
from gen.flights.flight import FlightType from gen.flights.flight import FlightType

View File

@ -14,7 +14,7 @@ from PySide2.QtWidgets import (
) )
from game import Game from game import Game
from game.squadrons import Pilot from game.squadrons.pilot import Pilot
from gen.flights.flight import Flight, FlightRoster from gen.flights.flight import Flight, FlightRoster
from qt_ui.models import PackageModel from qt_ui.models import PackageModel

View File

@ -113,10 +113,12 @@ class NewGameWizard(QtWidgets.QWizard):
blue_faction = self.faction_selection_page.selected_blue_faction blue_faction = self.faction_selection_page.selected_blue_faction
red_faction = self.faction_selection_page.selected_red_faction red_faction = self.faction_selection_page.selected_red_faction
theater = campaign.load_theater()
generator = GameGenerator( generator = GameGenerator(
blue_faction, blue_faction,
red_faction, red_faction,
campaign.load_theater(), theater,
campaign.load_air_wing_config(theater),
settings, settings,
generator_settings, generator_settings,
mod_settings, mod_settings,