Add campaign support for ferry-only bases.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3170.
This commit is contained in:
Dan Albert 2023-09-21 21:08:42 -07:00
parent e43874e553
commit 2344fc0b5c
12 changed files with 256 additions and 45 deletions

View File

@ -5,6 +5,7 @@ Saves from 8.x are not compatible with 9.0.0.
## Features/Improvements ## Features/Improvements
* **[Engine]** Support for DCS Open Beta 2.8.8.43489. * **[Engine]** Support for DCS Open Beta 2.8.8.43489.
* **[Campaign]** Added ferry only control points, which offer campaign designers a way to add squadrons that can be brought in after additional airfields are captured.
* **[Data]** Added support for the ARA Veinticinco de Mayo. * **[Data]** Added support for the ARA Veinticinco de Mayo.
* **[Data]** Changed display name of the AI-only F-15E Strike Eagle for clarity. * **[Data]** Changed display name of the AI-only F-15E Strike Eagle for clarity.
* **[Flight Planning]** Improved IP selection for targets that are near the center of a threat zone. * **[Flight Planning]** Improved IP selection for targets that are near the center of a threat zone.

View File

@ -17,6 +17,7 @@ from game.theater.iadsnetwork.iadsnetwork import IadsNetwork
from game.theater.theaterloader import TheaterLoader from game.theater.theaterloader import TheaterLoader
from game.version import CAMPAIGN_FORMAT_VERSION from game.version import CAMPAIGN_FORMAT_VERSION
from .campaignairwingconfig import CampaignAirWingConfig from .campaignairwingconfig import CampaignAirWingConfig
from .controlpointconfig import ControlPointConfig
from .factionrecommendation import FactionRecommendation from .factionrecommendation import FactionRecommendation
from .mizcampaignloader import MizCampaignLoader from .mizcampaignloader import MizCampaignLoader
@ -123,7 +124,15 @@ class Campaign:
) from ex ) from ex
with logged_duration("Importing miz data"): with logged_duration("Importing miz data"):
MizCampaignLoader(self.path.parent / miz, t).populate_theater() MizCampaignLoader(
self.path.parent / miz,
t,
dict(
ControlPointConfig.iter_from_data(
self.data.get("control_points", {})
)
),
).populate_theater()
# TODO: Move into MizCampaignLoader so this doesn't have unknown initialization # TODO: Move into MizCampaignLoader so this doesn't have unknown initialization
# in ConflictTheater. # in ConflictTheater.

View File

@ -0,0 +1,90 @@
from dcs import Point
from dcs.terrain import Airport
from game.campaignloader.controlpointconfig import ControlPointConfig
from game.theater import (
Airfield,
Carrier,
ConflictTheater,
ControlPoint,
Fob,
Lha,
OffMapSpawn,
)
class ControlPointBuilder:
def __init__(
self, theater: ConflictTheater, configs: dict[str | int, ControlPointConfig]
) -> None:
self.theater = theater
self.config = configs
def create_airfield(self, airport: Airport) -> Airfield:
cp = Airfield(airport, self.theater, starts_blue=airport.is_blue())
# Use the unlimited aircraft option to determine if an airfield should
# be owned by the player when the campaign is "inverted".
cp.captured_invert = airport.unlimited_aircrafts
self._apply_config(airport.id, cp)
return cp
def create_fob(
self,
name: str,
position: Point,
theater: ConflictTheater,
starts_blue: bool,
captured_invert: bool,
) -> Fob:
cp = Fob(name, position, theater, starts_blue)
cp.captured_invert = captured_invert
self._apply_config(name, cp)
return cp
def create_carrier(
self,
name: str,
position: Point,
theater: ConflictTheater,
starts_blue: bool,
captured_invert: bool,
) -> Carrier:
cp = Carrier(name, position, theater, starts_blue)
cp.captured_invert = captured_invert
self._apply_config(name, cp)
return cp
def create_lha(
self,
name: str,
position: Point,
theater: ConflictTheater,
starts_blue: bool,
captured_invert: bool,
) -> Lha:
cp = Lha(name, position, theater, starts_blue)
cp.captured_invert = captured_invert
self._apply_config(name, cp)
return cp
def create_off_map(
self,
name: str,
position: Point,
theater: ConflictTheater,
starts_blue: bool,
captured_invert: bool,
) -> OffMapSpawn:
cp = OffMapSpawn(name, position, theater, starts_blue)
cp.captured_invert = captured_invert
self._apply_config(name, cp)
return cp
def _apply_config(self, cp_id: str | int, control_point: ControlPoint) -> None:
config = self.config.get(cp_id)
if config is None:
return
control_point.ferry_only = config.ferry_only

View File

@ -0,0 +1,21 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class ControlPointConfig:
ferry_only: bool
@staticmethod
def from_data(data: dict[str, Any]) -> ControlPointConfig:
return ControlPointConfig(ferry_only=data.get("ferry_only", False))
@staticmethod
def iter_from_data(
data: dict[str | int, Any]
) -> Iterator[tuple[str | int, ControlPointConfig]]:
for name_or_id, cp_data in data.items():
yield name_or_id, ControlPointConfig.from_data(cp_data)

View File

@ -12,20 +12,14 @@ from dcs.country import Country
from dcs.planes import F_15C from dcs.planes import F_15C
from dcs.ships import HandyWind, LHA_Tarawa, Stennis, USS_Arleigh_Burke_IIa from dcs.ships import HandyWind, LHA_Tarawa, Stennis, USS_Arleigh_Burke_IIa
from dcs.statics import Fortification, Warehouse from dcs.statics import Fortification, Warehouse
from dcs.terrain import Airport
from dcs.unitgroup import PlaneGroup, ShipGroup, StaticGroup, VehicleGroup from dcs.unitgroup import PlaneGroup, ShipGroup, StaticGroup, VehicleGroup
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
from game.campaignloader.controlpointbuilder import ControlPointBuilder
from game.campaignloader.controlpointconfig import ControlPointConfig
from game.profiling import logged_duration from game.profiling import logged_duration
from game.scenery_group import SceneryGroup from game.scenery_group import SceneryGroup
from game.theater.controlpoint import ( from game.theater.controlpoint import ControlPoint
Airfield,
Carrier,
ControlPoint,
Fob,
Lha,
OffMapSpawn,
)
from game.theater.presetlocation import PresetLocation from game.theater.presetlocation import PresetLocation
if TYPE_CHECKING: if TYPE_CHECKING:
@ -92,8 +86,14 @@ class MizCampaignLoader:
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
def __init__(self, miz: Path, theater: ConflictTheater) -> None: def __init__(
self,
miz: Path,
theater: ConflictTheater,
control_point_configs: dict[str | int, ControlPointConfig],
) -> None:
self.theater = theater self.theater = theater
self.control_point_builder = ControlPointBuilder(theater, control_point_configs)
self.mission = Mission() self.mission = Mission()
with logged_duration("Loading miz"): with logged_duration("Loading miz"):
self.mission.load_file(str(miz)) self.mission.load_file(str(miz))
@ -105,15 +105,6 @@ class MizCampaignLoader:
if self.mission.country(self.RED_COUNTRY.name) is None: if self.mission.country(self.RED_COUNTRY.name) is None:
self.mission.coalition["red"].add_country(self.RED_COUNTRY) self.mission.coalition["red"].add_country(self.RED_COUNTRY)
def control_point_from_airport(self, airport: Airport) -> ControlPoint:
cp = Airfield(airport, self.theater, starts_blue=airport.is_blue())
# Use the unlimited aircraft option to determine if an airfield should
# be owned by the player when the campaign is "inverted".
cp.captured_invert = airport.unlimited_aircrafts
return cp
def country(self, blue: bool) -> Country: def country(self, blue: bool) -> Country:
country = self.mission.country( country = self.mission.country(
self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name
@ -240,36 +231,49 @@ class MizCampaignLoader:
@cached_property @cached_property
def control_points(self) -> dict[UUID, ControlPoint]: def control_points(self) -> dict[UUID, ControlPoint]:
control_points = {} control_points: dict[UUID, ControlPoint] = {}
control_point: ControlPoint
for airport in self.mission.terrain.airport_list(): for airport in self.mission.terrain.airport_list():
if airport.is_blue() or airport.is_red(): if airport.is_blue() or airport.is_red():
control_point = self.control_point_from_airport(airport) control_point = self.control_point_builder.create_airfield(airport)
control_points[control_point.id] = control_point control_points[control_point.id] = control_point
for blue in (False, True): for blue in (False, True):
for group in self.off_map_spawns(blue): for group in self.off_map_spawns(blue):
control_point = OffMapSpawn( control_point = self.control_point_builder.create_off_map(
str(group.name), group.position, self.theater, starts_blue=blue str(group.name),
group.position,
self.theater,
starts_blue=blue,
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):
control_point = Carrier( control_point = self.control_point_builder.create_carrier(
ship.name, ship.position, self.theater, starts_blue=blue ship.name,
ship.position,
self.theater,
starts_blue=blue,
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):
control_point = Lha( control_point = self.control_point_builder.create_lha(
ship.name, ship.position, self.theater, starts_blue=blue ship.name,
ship.position,
self.theater,
starts_blue=blue,
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 fob in self.fobs(blue): for fob in self.fobs(blue):
control_point = Fob( control_point = self.control_point_builder.create_fob(
str(fob.name), fob.position, self.theater, starts_blue=blue str(fob.name),
fob.position,
self.theater,
starts_blue=blue,
captured_invert=fob.late_activation,
) )
control_point.captured_invert = fob.late_activation
control_points[control_point.id] = control_point control_points[control_point.id] = control_point
return control_points return control_points

View File

@ -53,6 +53,8 @@ class AirWing:
for control_point in airfield_cache.operational_airfields: for control_point in airfield_cache.operational_airfields:
if control_point.captured != self.player: if control_point.captured != self.player:
continue continue
if control_point.ferry_only:
continue
capable_at_base = [] capable_at_base = []
for squadron in control_point.squadrons: for squadron in control_point.squadrons:
if squadron.can_auto_assign_mission(location, task, size, this_turn): if squadron.can_auto_assign_mission(location, task, size, this_turn):
@ -91,6 +93,8 @@ class AirWing:
best_aircraft_for_task = AircraftType.priority_list_for_task(task) best_aircraft_for_task = AircraftType.priority_list_for_task(task)
for aircraft, squadrons in self.squadrons.items(): for aircraft, squadrons in self.squadrons.items():
for squadron in squadrons: for squadron in squadrons:
if squadron.location.ferry_only:
continue
if squadron.untasked_aircraft and squadron.capable_of(task): if squadron.untasked_aircraft and squadron.capable_of(task):
aircrafts.append(aircraft) aircrafts.append(aircraft)
if aircraft not in best_aircraft_for_task: if aircraft not in best_aircraft_for_task:

View File

@ -356,6 +356,7 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC):
self.ground_unit_orders = GroundUnitOrders(self) self.ground_unit_orders = GroundUnitOrders(self)
self.target_position: Optional[Point] = None self.target_position: Optional[Point] = None
self.ferry_only = False
# Initialized late because ControlPoints are constructed before the game is. # Initialized late because ControlPoints are constructed before the game is.
self._front_line_db: Database[FrontLine] | None = None self._front_line_db: Database[FrontLine] | None = None

View File

@ -185,4 +185,7 @@ VERSION = _build_version_string()
#: #:
#: Version 10.10 #: Version 10.10
#: * Support for Sinai. #: * Support for Sinai.
#:
#: Version 10.11
#: * Support for ferry-only bases.
CAMPAIGN_FORMAT_VERSION = (10, 10) CAMPAIGN_FORMAT_VERSION = (10, 10)

View File

@ -1,3 +1,5 @@
import textwrap
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QCloseEvent, QPixmap from PySide6.QtGui import QCloseEvent, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -60,13 +62,32 @@ class QBaseMenu2(QDialog):
pixmap = QPixmap(self.get_base_image()) pixmap = QPixmap(self.get_base_image())
header.setPixmap(pixmap) header.setPixmap(pixmap)
description_layout = QVBoxLayout()
top_layout.addLayout(description_layout)
title = QLabel("<b>" + self.cp.name + "</b>") title = QLabel("<b>" + self.cp.name + "</b>")
title.setAlignment(Qt.AlignLeft | Qt.AlignTop) title.setAlignment(Qt.AlignLeft | Qt.AlignTop)
title.setProperty("style", "base-title") title.setProperty("style", "base-title")
description_layout.addWidget(title)
if self.cp.ferry_only:
description_layout.addWidget(
QLabel(
"<br />".join(
textwrap.wrap(
"This base only supports ferry missions. Transfer the "
"squadrons to a different base to use them.",
width=80,
)
)
)
)
description_layout.addStretch()
self.intel_summary = QLabel() self.intel_summary = QLabel()
self.intel_summary.setToolTip(self.generate_intel_tooltip()) self.intel_summary.setToolTip(self.generate_intel_tooltip())
self.update_intel_summary() self.update_intel_summary()
top_layout.addWidget(title)
top_layout.addWidget(self.intel_summary) top_layout.addWidget(self.intel_summary)
top_layout.setAlignment(Qt.AlignTop) top_layout.setAlignment(Qt.AlignTop)

View File

@ -48,8 +48,13 @@ class SquadronSelector(QComboBox):
return return
for squadron in self.air_wing.squadrons_for(aircraft): for squadron in self.air_wing.squadrons_for(aircraft):
if squadron.capable_of(task) and squadron.untasked_aircraft: if not squadron.capable_of(task):
self.addItem(f"{squadron.location}: {squadron}", squadron) continue
if not squadron.untasked_aircraft:
continue
if squadron.location.ferry_only:
continue
self.addItem(f"{squadron.location}: {squadron}", squadron)
if self.count() == 0: if self.count() == 0:
self.addItem("No capable aircraft available", None) self.addItem("No capable aircraft available", None)

View File

@ -4,11 +4,33 @@ theater: Caucasus
authors: Starfire authors: Starfire
recommended_player_faction: USA 2005 recommended_player_faction: USA 2005
recommended_enemy_faction: Russia 2010 recommended_enemy_faction: Russia 2010
description: <p>United Nations Observer Mission in Georgia (UNOMIG) observers stationed in Georgia to monitor the ceasefire between Georgia and Abkhazia have been cut off from friendly forces by Russian troops backing the separatist state. The UNOMIG HQ at Sukhumi has been taken, and a small contingent of observers and troops at the Zugdidi Sector HQ will have to make a run for the coast, supported by offshore US naval aircraft. The contingent is aware that their best shot at survival is to swiftly retake Sukhumi before Russian forces have a chance to dig in, so that friendly ground forces can land and reinforce them.</p><p><strong>Note:</strong> Ground unit purchase will not be available past Turn 0 until Sukhumi is retaken, so it is imperative you reach Sukhumi with at least one surviving ground unit to capture it. Two Hueys are available at Zugdidi for some close air support. The player can either play the first leg of the scenario as an evacuation with a couple of light vehicles (e.g. Humvees) set on breakthrough (modifying waypoints in the mission editor so they are not charging head-on into enemy ground forces is suggested), or purchase heavier ground units if they wish to experience a more traditional frontline ground war. Once Sukhumi has been captured, squadrons based in Incirlik Turkey can be ferried in via the "From Incirlik" off-map spawn point.</p> description:
<p>United Nations Observer Mission in Georgia (UNOMIG) observers stationed in
Georgia to monitor the ceasefire between Georgia and Abkhazia have been cut
off from friendly forces by Russian troops backing the separatist state. The
UNOMIG HQ at Sukhumi has been taken, and a small contingent of observers and
troops at the Zugdidi Sector HQ will have to make a run for the coast,
supported by offshore US naval aircraft. The contingent is aware that their
best shot at survival is to swiftly retake Sukhumi before Russian forces have
a chance to dig in, so that friendly ground forces can land and reinforce
them.</p><p><strong>Note:</strong> Ground unit purchase will not be available
past Turn 0 until Sukhumi is retaken, so it is imperative you reach Sukhumi
with at least one surviving ground unit to capture it. Two Hueys are available
at Zugdidi for some close air support. The player can either play the first
leg of the scenario as an evacuation with a couple of light vehicles (e.g.
Humvees) set on breakthrough (modifying waypoints in the mission editor so
they are not charging head-on into enemy ground forces is suggested), or
purchase heavier ground units if they wish to experience a more traditional
frontline ground war. Once Sukhumi has been captured, squadrons based in
Incirlik Turkey can be ferried in via the "From Incirlik" off-map spawn
point.</p>
miz: operation_vectrons_claw.miz miz: operation_vectrons_claw.miz
performance: 1 performance: 1
recommended_start_date: 2008-08-08 recommended_start_date: 2008-08-08
version: "10.9" version: "10.11"
control_points:
From Incirlik:
ferry_only: true
squadrons: squadrons:
Blue CV-1: Blue CV-1:
- primary: BARCAP - primary: BARCAP
@ -90,7 +112,7 @@ squadrons:
aircraft: aircraft:
- KC-135 Stratotanker - KC-135 Stratotanker
size: 1 size: 1
#FARPs #FARPs
UNOMIG Sector HQ: UNOMIG Sector HQ:
- primary: Transport - primary: Transport
secondary: any secondary: any
@ -103,7 +125,7 @@ squadrons:
aircraft: aircraft:
- Mi-24P Hind-F - Mi-24P Hind-F
size: 8 size: 8
#Sukhumi-Babushara #Sukhumi-Babushara
20: 20:
- primary: TARCAP - primary: TARCAP
secondary: air-to-air secondary: air-to-air
@ -115,7 +137,7 @@ squadrons:
aircraft: aircraft:
- Su-25T Frogfoot - Su-25T Frogfoot
size: 12 size: 12
#Sochi-Adler #Sochi-Adler
18: 18:
- primary: Escort - primary: Escort
secondary: air-to-air secondary: air-to-air
@ -132,7 +154,7 @@ squadrons:
aircraft: aircraft:
- Su-34 Fullback - Su-34 Fullback
size: 20 size: 20
#Anapa-Vityazevo #Anapa-Vityazevo
12: 12:
- primary: Strike - primary: Strike
secondary: air-to-ground secondary: air-to-ground
@ -145,8 +167,8 @@ squadrons:
aircraft: aircraft:
- SU-33 Flanker-D - SU-33 Flanker-D
size: 18 size: 18
#I am aware there is no Russian LHA. This is just for campaign inversion. #I am aware there is no Russian LHA. This is just for campaign inversion.
Red LHA: Red LHA:
- primary: BAI - primary: BAI
secondary: air-to-ground secondary: air-to-ground
size: 20 size: 20

View File

@ -0,0 +1,30 @@
from game.campaignloader.controlpointconfig import ControlPointConfig
def test_from_empty_data() -> None:
config = ControlPointConfig.from_data({})
assert not config.ferry_only
def test_from_data() -> None:
config = ControlPointConfig.from_data(
{
"ferry_only": True,
}
)
assert config.ferry_only
def iter_from_data() -> None:
data = dict(
ControlPointConfig.iter_from_data(
{
0: {},
"named": {"ferry_only": True},
}
)
)
assert data == {
0: ControlPointConfig(ferry_only=False),
"named": ControlPointConfig(ferry_only=True),
}