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

@@ -1 +1,2 @@
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
import yaml
from game.campaignloader.mizcampaignloader import MizCampaignLoader
from game.profiling import logged_duration
from game.theater import (
ConflictTheater,
@@ -23,6 +22,8 @@ from game.theater import (
MarianaIslandsTheater,
)
from game.version import CAMPAIGN_FORMAT_VERSION
from .campaignairwingconfig import CampaignAirWingConfig
from .mizcampaignloader import MizCampaignLoader
PERF_FRIENDLY = 0
@@ -103,6 +104,14 @@ class Campaign:
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.

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_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)
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):
# TODO: Name the LHA.db
control_point = Lha("lha", ship.position, next(self.control_point_id))
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

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