Merge branch 'develop' into helipads

# Conflicts:
#	resources/campaigns/golan_heights_lite.miz
This commit is contained in:
Khopa
2021-05-30 17:29:55 +02:00
172 changed files with 3858 additions and 1434 deletions

View File

@@ -3,7 +3,6 @@ import dcs
DEFAULT_AVAILABLE_BUILDINGS = [
"fuel",
"ammo",
"comms",
"oil",
"ware",

View File

@@ -22,14 +22,46 @@ from dcs.ships import (
)
from dcs.vehicles import AirDefence
UNITS_WITH_RADAR = [
# Radars
TELARS = {
AirDefence.SAM_SA_19_Tunguska_Grison,
AirDefence.SAM_SA_11_Buk_Gadfly_Fire_Dome_TEL,
AirDefence.SAM_SA_8_Osa_Gecko_TEL,
AirDefence.SAM_SA_15_Tor_Gauntlet,
AirDefence.SAM_Roland_ADS,
}
TRACK_RADARS = {
AirDefence.SAM_SA_6_Kub_Straight_Flush_STR,
AirDefence.SAM_SA_3_S_125_Low_Blow_TR,
AirDefence.SAM_SA_10_S_300_Grumble_Flap_Lid_TR,
AirDefence.SAM_Hawk_TR__AN_MPQ_46,
AirDefence.SAM_Patriot_STR,
AirDefence.SAM_SA_2_S_75_Fan_Song_TR,
AirDefence.SAM_Rapier_Blindfire_TR,
AirDefence.HQ_7_Self_Propelled_STR,
}
LAUNCHER_TRACKER_PAIRS = {
AirDefence.SAM_SA_6_Kub_Gainful_TEL: AirDefence.SAM_SA_6_Kub_Straight_Flush_STR,
AirDefence.SAM_SA_3_S_125_Goa_LN: AirDefence.SAM_SA_3_S_125_Low_Blow_TR,
AirDefence.SAM_SA_10_S_300_Grumble_TEL_D: AirDefence.SAM_SA_10_S_300_Grumble_Flap_Lid_TR,
AirDefence.SAM_SA_10_S_300_Grumble_TEL_C: AirDefence.SAM_SA_10_S_300_Grumble_Flap_Lid_TR,
AirDefence.SAM_Hawk_LN_M192: AirDefence.SAM_Hawk_TR__AN_MPQ_46,
AirDefence.SAM_Patriot_LN: AirDefence.SAM_Patriot_STR,
AirDefence.SAM_SA_2_S_75_Guideline_LN: AirDefence.SAM_SA_2_S_75_Fan_Song_TR,
AirDefence.SAM_Rapier_LN: AirDefence.SAM_Rapier_Blindfire_TR,
AirDefence.HQ_7_Self_Propelled_LN: AirDefence.HQ_7_Self_Propelled_STR,
}
UNITS_WITH_RADAR = {
# Radars
AirDefence.SAM_SA_19_Tunguska_Grison,
AirDefence.SAM_SA_11_Buk_Gadfly_Fire_Dome_TEL,
AirDefence.SAM_SA_8_Osa_Gecko_TEL,
AirDefence.SAM_SA_15_Tor_Gauntlet,
AirDefence.SAM_SA_11_Buk_Gadfly_C2,
AirDefence.SAM_Patriot_CR__AMG_AN_MRC_137,
AirDefence.SAM_Patriot_ECS,
AirDefence.SPAAA_Gepard,
AirDefence.SPAAA_Vulcan_M163,
AirDefence.SAM_Roland_ADS,
AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish,
AirDefence.EWR_1L13,
AirDefence.SAM_SA_6_Kub_Straight_Flush_STR,
@@ -47,7 +79,11 @@ UNITS_WITH_RADAR = [
AirDefence.SAM_Roland_EWR,
AirDefence.SAM_SA_3_S_125_Low_Blow_TR,
AirDefence.SAM_SA_2_S_75_Fan_Song_TR,
AirDefence.SAM_Rapier_Blindfire_TR,
AirDefence.HQ_7_Self_Propelled_LN,
AirDefence.HQ_7_Self_Propelled_STR,
AirDefence.EWR_FuMG_401_Freya_LZ,
AirDefence.EWR_FuSe_65_Würzburg_Riese,
# Ships
CVN_70_Carl_Vinson,
FFG_Oliver_Hazzard_Perry,
@@ -69,4 +105,4 @@ UNITS_WITH_RADAR = [
Type_052B_Destroyer,
Type_054A_Frigate,
Type_052C_Destroyer,
]
}

View File

@@ -1459,6 +1459,13 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]:
return None
def flying_type_from_name(name: str) -> Optional[Type[FlyingType]]:
unit_type = plane_map.get(name)
if unit_type is not None:
return unit_type
return helicopter_map.get(name)
def unit_type_of(unit: Unit) -> UnitType:
if isinstance(unit, Vehicle):
return vehicle_map[unit.type]
@@ -1603,3 +1610,39 @@ F_16C_50.Liveries = DefaultLiveries
P_51D_30_NA.Liveries = DefaultLiveries
Ju_88A4.Liveries = DefaultLiveries
B_17G.Liveries = DefaultLiveries
# List of airframes that rely on their gun as a primary weapon. We confiscate bullets
# from most AI air-to-ground missions since they aren't smart enough to RTB when they're
# out of everything other than bullets (DCS does not have an all-but-gun winchester
# option) and we don't want to be attacking fully functional Tors with a Vulcan.
#
# These airframes are the exceptions. They probably should be using their gun regardless
# of the mission type.
GUN_RELIANT_AIRFRAMES: List[Type[FlyingType]] = [
AH_1W,
AH_64A,
AH_64D,
A_10A,
A_10C,
A_10C_2,
A_20G,
Bf_109K_4,
FW_190A8,
FW_190D9,
F_86F_Sabre,
Ju_88A4,
Ka_50,
MiG_15bis,
MiG_19P,
Mi_24V,
Mi_28N,
P_47D_30,
P_47D_30bl1,
P_47D_40,
P_51D,
P_51D_30_NA,
SpitfireLFMkIX,
SpitfireLFMkIXCW,
Su_25,
Su_25T,
]

View File

@@ -30,6 +30,7 @@ from game.unitmap import (
FrontLineUnit,
GroundObjectUnit,
UnitMap,
FlyingUnit,
)
from gen.flights.flight import Flight
@@ -41,24 +42,24 @@ DEBRIEFING_LOG_EXTENSION = "log"
@dataclass(frozen=True)
class AirLosses:
player: List[Flight]
enemy: List[Flight]
player: List[FlyingUnit]
enemy: List[FlyingUnit]
@property
def losses(self) -> Iterator[Flight]:
def losses(self) -> Iterator[FlyingUnit]:
return itertools.chain(self.player, self.enemy)
def by_type(self, player: bool) -> Dict[Type[FlyingType], int]:
losses_by_type: Dict[Type[FlyingType], int] = defaultdict(int)
losses = self.player if player else self.enemy
for loss in losses:
losses_by_type[loss.unit_type] += 1
losses_by_type[loss.flight.unit_type] += 1
return losses_by_type
def surviving_flight_members(self, flight: Flight) -> int:
losses = 0
for loss in self.losses:
if loss == flight:
if loss.flight == flight:
losses += 1
return flight.count - losses
@@ -239,14 +240,14 @@ class Debriefing:
player_losses = []
enemy_losses = []
for unit_name in self.state_data.killed_aircraft:
flight = self.unit_map.flight(unit_name)
if flight is None:
aircraft = self.unit_map.flight(unit_name)
if aircraft is None:
logging.error(f"Could not find Flight matching {unit_name}")
continue
if flight.departure.captured:
player_losses.append(flight)
if aircraft.flight.departure.captured:
player_losses.append(aircraft)
else:
enemy_losses.append(flight)
enemy_losses.append(aircraft)
return AirLosses(player_losses, enemy_losses)
def dead_ground_units(self) -> GroundLosses:

View File

@@ -120,11 +120,15 @@ class Event:
self.game.red_ato, debriefing.air_losses, for_player=False
)
@staticmethod
def commit_air_losses(debriefing: Debriefing) -> None:
def commit_air_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.air_losses.losses:
aircraft = loss.unit_type
cp = loss.departure
if (
not loss.pilot.player
or not self.game.settings.invulnerable_player_pilots
):
loss.pilot.kill()
aircraft = loss.flight.unit_type
cp = loss.flight.departure
available = cp.base.total_units_of_type(aircraft)
if available <= 0:
logging.error(
@@ -136,6 +140,23 @@ class Event:
logging.info(f"{aircraft} destroyed from {cp}")
cp.base.aircraft[aircraft] -= 1
@staticmethod
def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
for package in ato.packages:
for flight in package.flights:
for idx, pilot in enumerate(flight.pilots):
if pilot is None:
logging.error(
f"Cannot award experience to pilot #{idx} of {flight} "
"because no pilot is assigned"
)
continue
pilot.record.missions_flown += 1
def commit_pilot_experience(self) -> None:
self._commit_pilot_experience(self.game.blue_ato)
self._commit_pilot_experience(self.game.red_ato)
@staticmethod
def commit_front_line_losses(debriefing: Debriefing) -> None:
for loss in debriefing.front_line_losses:
@@ -249,6 +270,7 @@ class Event:
logging.info("Committing mission results")
self.commit_air_losses(debriefing)
self.commit_pilot_experience()
self.commit_front_line_losses(debriefing)
self.commit_convoy_losses(debriefing)
self.commit_airlift_losses(debriefing)

View File

@@ -27,6 +27,9 @@ from pydcs_extensions.mod_units import MODDED_VEHICLES, MODDED_AIRPLANES
@dataclass
class Faction:
#: List of locales to use when generating random names. If not set, Faker will
#: choose the default locale.
locales: Optional[List[str]]
# Country used by this faction
country: str = field(default="")
@@ -132,8 +135,7 @@ class Faction:
@classmethod
def from_json(cls: Type[Faction], json: Dict[str, Any]) -> Faction:
faction = Faction()
faction = Faction(locales=json.get("locales"))
faction.country = json.get("country", "/")
if faction.country not in [c.name for c in country_dict.values()]:

View File

@@ -4,12 +4,13 @@ import random
import sys
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Any, Dict, List
from typing import Any, Dict, List, Iterator
from dcs.action import Coalition
from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence
from faker import Faker
from game import db
from game.inventory import GlobalAircraftInventory
@@ -32,7 +33,8 @@ from .infos.information import Information
from .navmesh import NavMesh
from .procurement import AircraftProcurementRequest, ProcurementAi
from .profiling import logged_duration
from .settings import Settings
from .settings import Settings, AutoAtoBehavior
from .squadrons import Pilot, AirWing
from .theater import ConflictTheater
from .theater.bullseye import Bullseye
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
@@ -140,6 +142,12 @@ class Game:
self.sanitize_sides()
self.blue_faker = Faker(self.player_faction.locales)
self.red_faker = Faker(self.enemy_faction.locales)
self.blue_air_wing = AirWing(self, player=True)
self.red_air_wing = AirWing(self, player=False)
self.on_load()
def __getstate__(self) -> Dict[str, Any]:
@@ -150,6 +158,8 @@ class Game:
del state["red_threat_zone"]
del state["blue_navmesh"]
del state["red_navmesh"]
del state["blue_faker"]
del state["red_faker"]
return state
def __setstate__(self, state: Dict[str, Any]) -> None:
@@ -205,6 +215,21 @@ class Game:
return self.player_faction
return self.enemy_faction
def faker_for(self, player: bool) -> Faker:
if player:
return self.blue_faker
return self.red_faker
def air_wing_for(self, player: bool) -> AirWing:
if player:
return self.blue_air_wing
return self.red_air_wing
def country_for(self, player: bool) -> str:
if player:
return self.player_country
return self.enemy_country
def bullseye_for(self, player: bool) -> Bullseye:
if player:
return self.blue_bullseye
@@ -281,6 +306,8 @@ class Game:
ObjectiveDistanceCache.set_theater(self.theater)
self.compute_conflicts_position()
self.compute_threat_zones()
self.blue_faker = Faker(self.faction_for(player=True).locales)
self.red_faker = Faker(self.faction_for(player=False).locales)
def reset_ato(self) -> None:
self.blue_ato.clear()
@@ -325,8 +352,10 @@ class Game:
def pass_turn(self, no_action: bool = False) -> None:
logging.info("Pass turn")
self.finish_turn(no_action)
self.initialize_turn()
with logged_duration("Turn finalization"):
self.finish_turn(no_action)
with logged_duration("Turn initialization"):
self.initialize_turn()
# Autosave progress
persistency.autosave(self)
@@ -360,6 +389,8 @@ class Game:
# Update statistics
self.game_stats.update(self)
self.blue_air_wing.reset()
self.red_air_wing.reset()
self.aircraft_inventory.reset()
for cp in self.theater.controlpoints:
self.aircraft_inventory.set_from_control_point(cp)
@@ -370,18 +401,28 @@ class Game:
return self.process_win_loss(turn_state)
# Plan flights & combat for next turn
self.compute_conflicts_position()
self.compute_threat_zones()
self.compute_transit_networks()
with logged_duration("Computing conflict positions"):
self.compute_conflicts_position()
with logged_duration("Threat zone computation"):
self.compute_threat_zones()
with logged_duration("Transit network identification"):
self.compute_transit_networks()
self.ground_planners = {}
self.transfers.order_airlift_assets()
self.transfers.plan_transports()
self.blue_procurement_requests.clear()
self.red_procurement_requests.clear()
with logged_duration("Mission planning"):
blue_planner = CoalitionMissionPlanner(self, is_player=True)
blue_planner.plan_missions()
with logged_duration("Procurement of airlift assets"):
self.transfers.order_airlift_assets()
with logged_duration("Transport planning"):
self.transfers.plan_transports()
with logged_duration("Blue mission planning"):
if self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled:
blue_planner = CoalitionMissionPlanner(self, is_player=True)
blue_planner.plan_missions()
with logged_duration("Red mission planning"):
red_planner = CoalitionMissionPlanner(self, is_player=False)
red_planner.plan_missions()
@@ -408,7 +449,7 @@ class Game:
manage_front_line=self.settings.automate_front_line_reinforcements,
manage_aircraft=self.settings.automate_aircraft_reinforcements,
front_line_budget_share=ground_portion,
).spend_budget(self.budget, self.blue_procurement_requests)
).spend_budget(self.budget)
self.enemy_budget = ProcurementAi(
self,
@@ -418,7 +459,7 @@ class Game:
manage_front_line=True,
manage_aircraft=True,
front_line_budget_share=ground_portion,
).spend_budget(self.enemy_budget, self.red_procurement_requests)
).spend_budget(self.enemy_budget)
def message(self, text: str) -> None:
self.informations.append(Information(text, turn=self.turn))

View File

@@ -103,7 +103,7 @@ class NavMesh:
# currently.
p = ShapelyPoint(point.x, point.y)
for navpoly in self.polys:
if navpoly.poly.contains(p):
if navpoly.poly.intersects(p):
return navpoly
return None

View File

@@ -50,6 +50,7 @@ class ProcurementAi:
self.game = game
self.is_player = for_player
self.air_wing = game.air_wing_for(for_player)
self.faction = faction
self.manage_runways = manage_runways
self.manage_front_line = manage_front_line
@@ -57,9 +58,7 @@ class ProcurementAi:
self.front_line_budget_share = front_line_budget_share
self.threat_zones = self.game.threat_zone_for(not self.is_player)
def spend_budget(
self, budget: float, aircraft_requests: List[AircraftProcurementRequest]
) -> float:
def spend_budget(self, budget: float) -> float:
if self.manage_runways:
budget = self.repair_runways(budget)
if self.manage_front_line:
@@ -163,23 +162,31 @@ class ProcurementAi:
return budget
def _affordable_aircraft_of_types(
def _affordable_aircraft_for_task(
self,
types: List[Type[FlyingType]],
task: FlightType,
airbase: ControlPoint,
number: int,
max_price: float,
) -> Optional[Type[FlyingType]]:
best_choice: Optional[Type[FlyingType]] = None
for unit in [u for u in types if u in self.faction.aircrafts]:
for unit in aircraft_for_task(task):
if unit not in self.faction.aircrafts:
continue
if db.PRICES[unit] * number > max_price:
continue
if not airbase.can_operate(unit):
continue
# Affordable and compatible. To keep some variety, skip with a 50/50
# chance. Might be a good idea to have the chance to skip based on
# the price compared to the rest of the choices.
for squadron in self.air_wing.squadrons_for(unit):
if task in squadron.mission_types:
break
else:
continue
# Affordable, compatible, and we have a squadron capable of the task. To
# keep some variety, skip with a 50/50 chance. Might be a good idea to have
# the chance to skip based on the price compared to the rest of the choices.
best_choice = unit
if random.choice([True, False]):
break
@@ -188,8 +195,8 @@ class ProcurementAi:
def affordable_aircraft_for(
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
) -> Optional[Type[FlyingType]]:
return self._affordable_aircraft_of_types(
aircraft_for_task(request.task_capability), airbase, request.number, budget
return self._affordable_aircraft_for_task(
request.task_capability, airbase, request.number, budget
)
def fulfill_aircraft_request(
@@ -255,10 +262,19 @@ class ProcurementAi:
# Prefer to buy front line units at active front lines that are not
# already overloaded.
for cp in self.owned_points:
total_ground_units_allocated_to_this_control_point = (
self.total_ground_units_allocated_to(cp)
)
if not cp.has_ground_unit_source(self.game):
continue
if self.total_ground_units_allocated_to(cp) >= 50:
if (
total_ground_units_allocated_to_this_control_point >= 50
or total_ground_units_allocated_to_this_control_point
>= cp.frontline_unit_count_limit
):
# Control point is already sufficiently defended.
continue
for connected in cp.connected_points:

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
import logging
import timeit
from collections import defaultdict
from contextlib import contextmanager
from datetime import timedelta
from typing import Iterator
@@ -11,3 +14,22 @@ def logged_duration(event: str) -> Iterator[None]:
yield
end = timeit.default_timer()
logging.debug("%s took %s", event, timedelta(seconds=end - start))
class MultiEventTracer:
def __init__(self) -> None:
self.events: dict[str, timedelta] = defaultdict(timedelta)
def __enter__(self) -> MultiEventTracer:
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
for event, duration in self.events.items():
logging.debug("%s took %s", event, duration)
@contextmanager
def trace(self, event: str) -> Iterator[None]:
start = timeit.default_timer()
yield
end = timeit.default_timer()
self.events[event] += timedelta(seconds=end - start)

View File

@@ -1,10 +1,19 @@
from dataclasses import dataclass, field
from datetime import timedelta
from enum import Enum, unique
from typing import Dict, Optional
from dcs.forcedoptions import ForcedOptions
@unique
class AutoAtoBehavior(Enum):
Disabled = "Disabled"
Never = "Never assign player pilots"
Default = "No preference"
Prefer = "Prefer player pilots"
@dataclass
class Settings:
@@ -27,7 +36,7 @@ class Settings:
default_start_type: str = "Cold"
# Mission specific
desired_player_mission_duration: timedelta = timedelta(minutes=90)
desired_player_mission_duration: timedelta = timedelta(minutes=60)
# Campaign management
automate_runway_repair: bool = False
@@ -36,6 +45,9 @@ class Settings:
restrict_weapons_by_date: bool = False
disable_legacy_aewc: bool = False
generate_dark_kneeboard: bool = False
invulnerable_player_pilots: bool = True
auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default
auto_ato_player_missions_asap: bool = False
# Performance oriented
perf_red_alert_state: bool = True

354
game/squadrons.py Normal file
View File

@@ -0,0 +1,354 @@
from __future__ import annotations
import itertools
import logging
import random
from collections import defaultdict
from dataclasses import dataclass, field
from enum import unique, Enum
from pathlib import Path
from typing import (
Type,
Tuple,
List,
TYPE_CHECKING,
Optional,
Iterable,
Iterator,
Sequence,
)
import yaml
from dcs.unittype import FlyingType
from faker import Faker
from game.db import flying_type_from_name
from game.settings import AutoAtoBehavior
if TYPE_CHECKING:
from game import Game
from gen.flights.flight import FlightType
@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
class Squadron:
name: str
nickname: str
country: str
role: str
aircraft: Type[FlyingType]
livery: Optional[str]
mission_types: Tuple[FlightType, ...]
pilots: List[Pilot]
available_pilots: List[Pilot] = field(init=False, hash=False, compare=False)
# We need a reference to the Game so that we can access the Faker without needing to
# persist it to the save game, or having to reconstruct it (it's not cheap) each
# time we create or load a squadron.
game: Game = field(hash=False, compare=False)
player: bool
def __post_init__(self) -> None:
self.available_pilots = list(self.active_pilots)
def __str__(self) -> str:
return f'{self.name} "{self.nickname}"'
def claim_available_pilot(self) -> Optional[Pilot]:
# No pilots available, so the preference is irrelevant. Create a new pilot and
# return it.
if not self.available_pilots:
self.enlist_new_pilots(1)
return self.available_pilots.pop()
# For opfor, so player/AI option is irrelevant.
if not self.player:
return self.available_pilots.pop()
preference = self.game.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. Recruit a new one.
#
# If they prefer players and we're out of players, just return an AI pilot.
if not prefer_players:
self.enlist_new_pilots(1)
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: Iterable[Pilot]) -> None:
self.available_pilots.extend(pilots)
def enlist_new_pilots(self, count: int) -> None:
new_pilots = [Pilot(self.faker.name()) for _ in range(count)]
self.pilots.extend(new_pilots)
self.available_pilots.extend(new_pilots)
def return_all_pilots(self) -> None:
self.available_pilots = list(self.active_pilots)
@property
def faker(self) -> Faker:
return self.game.faker_for(self.player)
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.pilots 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 size(self) -> int:
return len(self.active_pilots) + len(self.pilots_on_leave)
def pilot_at_index(self, index: int) -> Pilot:
return self.pilots[index]
@classmethod
def from_yaml(cls, path: Path, game: Game, player: bool) -> Squadron:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
from gen.flights.flight import FlightType
with path.open() as squadron_file:
data = yaml.safe_load(squadron_file)
unit_type = flying_type_from_name(data["aircraft"])
if unit_type is None:
raise KeyError(f"Could not find any aircraft with the ID {unit_type}")
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["nickname"],
country=data["country"],
role=data["role"],
aircraft=unit_type,
livery=data.get("livery"),
mission_types=tuple(mission_types),
pilots=pilots,
game=game,
player=player,
)
class SquadronLoader:
def __init__(self, game: Game, player: bool) -> None:
self.game = game
self.player = player
@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[Type[FlyingType], list[Squadron]]:
squadrons: dict[Type[FlyingType], list[Squadron]] = defaultdict(list)
country = self.game.country_for(self.player)
faction = self.game.faction_for(self.player)
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.player
)
except Exception as ex:
raise RuntimeError(
f"Failed to load squadron defined by {squadron_path}"
) from ex
class AirWing:
def __init__(self, game: Game, player: bool) -> None:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
self.game = game
self.player = player
self.squadrons = SquadronLoader(game, player).load()
count = itertools.count(1)
for aircraft in game.faction_for(player).aircrafts:
if aircraft in self.squadrons:
continue
self.squadrons[aircraft] = [
Squadron(
name=f"Squadron {next(count):03}",
nickname=self.random_nickname(),
country=game.country_for(player),
role="Flying Squadron",
aircraft=aircraft,
livery=None,
mission_types=tuple(tasks_for_aircraft(aircraft)),
pilots=[],
game=game,
player=player,
)
]
def squadrons_for(self, aircraft: Type[FlyingType]) -> Sequence[Squadron]:
return self.squadrons[aircraft]
def squadrons_for_task(self, task: FlightType) -> Iterator[Squadron]:
for squadron in self.iter_squadrons():
if task in squadron.mission_types:
yield squadron
def squadron_for(self, aircraft: Type[FlyingType]) -> 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 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

@@ -22,7 +22,7 @@ from dcs.ships import (
DDG_Arleigh_Burke_IIa,
LHA_1_Tarawa,
)
from dcs.statics import Fortification
from dcs.statics import Fortification, Warehouse
from dcs.terrain import (
caucasus,
nevada,
@@ -129,6 +129,8 @@ class MizCampaignLoader:
FACTORY_UNIT_TYPE = Fortification.Workshop_A.id
AMMUNITION_DEPOT_UNIT_TYPE = Warehouse.Ammunition_depot.id
REQUIRED_STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
BASE_DEFENSE_RADIUS = nautical_miles(2)
@@ -321,6 +323,12 @@ class MizCampaignLoader:
if group.units[0].type in self.FACTORY_UNIT_TYPE:
yield group
@property
def ammunition_depots(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE:
yield group
@property
def required_strike_targets(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
@@ -560,6 +568,12 @@ class MizCampaignLoader:
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.ammunition_depots:
closest, distance = self.objective_info(group)
closest.preset_locations.ammunition_depots.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_strike_targets:
closest, distance = self.objective_info(group)
closest.preset_locations.required_strike_locations.append(

View File

@@ -8,7 +8,20 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from functools import total_ordering
from typing import Any, Dict, Iterator, List, Optional, Set, TYPE_CHECKING, Type, Union
from typing import (
Any,
Dict,
Iterator,
List,
Optional,
Set,
TYPE_CHECKING,
Type,
Union,
Sequence,
Iterable,
Tuple,
)
from dcs import helicopters
from dcs.mapping import Point
@@ -48,6 +61,9 @@ if TYPE_CHECKING:
from game import Game
from gen.flights.flight import FlightType
FREE_FRONTLINE_UNIT_SUPPLY: int = 15
AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION: int = 12
class ControlPointType(Enum):
#: An airbase with slots for everything.
@@ -150,6 +166,9 @@ class PresetLocations:
#: Locations of factories for producing ground units. These will always be spawned.
factories: List[PointWithHeading] = field(default_factory=list)
#: Locations of ammo depots for controlling number of units on the front line at a control point.
ammunition_depots: List[PointWithHeading] = field(default_factory=list)
#: Locations of stationary armor groups. These will always be spawned.
armor_groups: List[PointWithHeading] = field(default_factory=list)
@@ -308,8 +327,8 @@ class ControlPoint(MissionTarget, ABC):
# TODO: Should be Airbase specific.
self.has_frontline = has_frontline
self.connected_points: List[ControlPoint] = []
self.convoy_routes: Dict[ControlPoint, List[Point]] = {}
self.shipping_lanes: Dict[ControlPoint, List[Point]] = {}
self.convoy_routes: Dict[ControlPoint, Tuple[Point, ...]] = {}
self.shipping_lanes: Dict[ControlPoint, Tuple[Point, ...]] = {}
self.base: Base = Base()
self.cptype = cptype
# TODO: Should be Airbase specific.
@@ -467,24 +486,21 @@ class ControlPoint(MissionTarget, ABC):
"""
...
# TODO: Should be Airbase specific.
def connect(self, to: ControlPoint) -> None:
self.connected_points.append(to)
self.stances[to.id] = CombatStance.DEFENSIVE
def convoy_origin_for(self, destination: ControlPoint) -> Point:
return self.convoy_route_to(destination)[0]
def convoy_route_to(self, destination: ControlPoint) -> List[Point]:
def convoy_route_to(self, destination: ControlPoint) -> Sequence[Point]:
return self.convoy_routes[destination]
def create_convoy_route(self, to: ControlPoint, waypoints: List[Point]) -> None:
def create_convoy_route(self, to: ControlPoint, waypoints: Iterable[Point]) -> None:
self.connected_points.append(to)
self.stances[to.id] = CombatStance.DEFENSIVE
self.convoy_routes[to] = waypoints
self.convoy_routes[to] = tuple(waypoints)
def create_shipping_lane(self, to: ControlPoint, waypoints: List[Point]) -> None:
self.shipping_lanes[to] = waypoints
def create_shipping_lane(
self, to: ControlPoint, waypoints: Iterable[Point]
) -> None:
self.shipping_lanes[to] = tuple(waypoints)
@abstractmethod
def runway_is_operational(self) -> bool:
@@ -788,15 +804,6 @@ class ControlPoint(MissionTarget, ABC):
def income_per_turn(self) -> int:
return 0
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
if self.is_friendly(for_player):
yield from [
FlightType.AEWC,
]
yield from super().mission_types(for_player)
@property
def has_active_frontline(self) -> bool:
return any(not c.is_friendly(self.captured) for c in self.connected_points)
@@ -807,10 +814,29 @@ class ControlPoint(MissionTarget, ABC):
return self.captured != other.captured
@property
def frontline_unit_count_limit(self) -> int:
tally_connected_ammo_depots = 0
for cp_objective in self.connected_objectives:
if cp_objective.category == "ammo" and not cp_objective.is_dead:
tally_connected_ammo_depots += 1
return (
FREE_FRONTLINE_UNIT_SUPPLY
+ tally_connected_ammo_depots * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION
)
@property
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
return []
@property
@abstractmethod
def category(self) -> str:
...
class Airfield(ControlPoint):
def __init__(
@@ -840,18 +866,21 @@ class Airfield(ControlPoint):
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
if self.is_friendly(for_player):
yield from [
# TODO: FlightType.INTERCEPTION
# TODO: FlightType.LOGISTICS
]
else:
if not self.is_friendly(for_player):
yield from [
FlightType.OCA_AIRCRAFT,
FlightType.OCA_RUNWAY,
]
yield from super().mission_types(for_player)
if self.is_friendly(for_player):
yield from [
FlightType.AEWC,
# TODO: FlightType.INTERCEPTION
# TODO: FlightType.LOGISTICS
]
@property
def total_aircraft_parking(self) -> int:
return len(self.airport.parking_slots)
@@ -888,6 +917,10 @@ class Airfield(ControlPoint):
def income_per_turn(self) -> int:
return 20
@property
def category(self) -> str:
return "airfield"
class NavalControlPoint(ControlPoint, ABC):
@property
@@ -967,6 +1000,13 @@ class Carrier(NavalControlPoint):
cptype=ControlPointType.AIRCRAFT_CARRIER_GROUP,
)
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
yield from super().mission_types(for_player)
if self.is_friendly(for_player):
yield FlightType.AEWC
def capture(self, game: Game, for_player: bool) -> None:
raise RuntimeError("Carriers cannot be captured")
@@ -981,6 +1021,10 @@ class Carrier(NavalControlPoint):
def total_aircraft_parking(self) -> int:
return 90
@property
def category(self) -> str:
return "cv"
class Lha(NavalControlPoint):
def __init__(self, name: str, at: Point, cp_id: int):
@@ -1011,6 +1055,10 @@ class Lha(NavalControlPoint):
def total_aircraft_parking(self) -> int:
return 20
@property
def category(self) -> str:
return "lha"
class OffMapSpawn(ControlPoint):
def runway_is_operational(self) -> bool:
@@ -1061,6 +1109,10 @@ class OffMapSpawn(ControlPoint):
def can_deploy_ground_units(self) -> bool:
return False
@property
def category(self) -> str:
return "offmap"
class Fob(ControlPoint):
def __init__(self, name: str, at: Point, cp_id: int):
@@ -1094,18 +1146,10 @@ class Fob(ControlPoint):
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
if self.is_friendly(for_player):
yield from [
FlightType.BARCAP,
# TODO: FlightType.LOGISTICS
]
else:
yield from [
FlightType.STRIKE,
FlightType.SWEEP,
FlightType.ESCORT,
FlightType.SEAD,
]
if not self.is_friendly(for_player):
yield FlightType.STRIKE
yield from super().mission_types(for_player)
@property
def total_aircraft_parking(self) -> int:
@@ -1128,3 +1172,7 @@ class Fob(ControlPoint):
@property
def income_per_turn(self) -> int:
return 10
@property
def category(self) -> str:
return "fob"

View File

@@ -52,7 +52,7 @@ class FrontLine(MissionTarget):
self.blue_cp = blue_point
self.red_cp = red_point
try:
route = blue_point.convoy_route_to(red_point)
route = list(blue_point.convoy_route_to(red_point))
except KeyError:
# Some campaigns are air only and the mission generator currently relies on
# *some* "front line" being drawn between these two. In this case there will

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import List
from typing import List, Tuple
@dataclass(frozen=True)
@@ -9,3 +9,26 @@ class LatLon:
def as_list(self) -> List[float]:
return [self.latitude, self.longitude]
@staticmethod
def _components(dimension: float) -> Tuple[int, int, float]:
degrees = int(dimension)
minutes = int(dimension * 60 % 60)
seconds = dimension * 3600 % 60
return degrees, minutes, seconds
def _format_component(
self, dimension: float, hemispheres: Tuple[str, str], seconds_precision: int
) -> str:
hemisphere = hemispheres[0] if dimension >= 0 else hemispheres[1]
degrees, minutes, seconds = self._components(dimension)
return f"{degrees}°{minutes:02}'{seconds:02.{seconds_precision}f}\"{hemisphere}"
def format_dms(self, include_decimal_seconds: bool = False) -> str:
precision = 2 if include_decimal_seconds else 0
return " ".join(
[
self._format_component(self.latitude, ("N", "S"), precision),
self._format_component(self.longitude, ("E", "W"), precision),
]
)

View File

@@ -37,7 +37,7 @@ class MissionTarget:
yield from [
FlightType.ESCORT,
FlightType.TARCAP,
FlightType.SEAD,
FlightType.SEAD_ESCORT,
FlightType.SWEEP,
# TODO: FlightType.ELINT,
# TODO: FlightType.EWAR,

View File

@@ -471,6 +471,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
self.generate_strike_targets()
self.generate_offshore_strike_targets()
self.generate_factories()
self.generate_ammunition_depots()
if self.faction.missiles:
self.generate_missile_sites()
@@ -629,6 +630,10 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
self.control_point.connected_objectives.append(g)
def generate_ammunition_depots(self) -> None:
for position in self.control_point.preset_locations.ammunition_depots:
self.generate_strike_target_at(category="ammo", position=position)
def generate_factories(self) -> None:
"""Generates the factories that are required by the campaign."""
for position in self.control_point.preset_locations.factories:
@@ -828,6 +833,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator):
FobDefenseGenerator(self.game, self.control_point).generate()
self.generate_armor_groups()
self.generate_factories()
self.generate_ammunition_depots()
self.generate_required_aa()
self.generate_required_ewr()
self.generate_scenery_sites()

View File

@@ -8,9 +8,15 @@ from dcs.mapping import Point
from dcs.triggers import TriggerZone
from dcs.unit import Unit
from dcs.unitgroup import Group
from dcs.unittype import VehicleType
from .. import db
from ..data.radar_db import UNITS_WITH_RADAR
from ..data.radar_db import (
UNITS_WITH_RADAR,
TRACK_RADARS,
TELARS,
LAUNCHER_TRACKER_PAIRS,
)
from ..utils import Distance, meters
if TYPE_CHECKING:
@@ -137,12 +143,11 @@ class TheaterGroundObject(MissionTarget):
return False
@property
def has_radar(self) -> bool:
"""Returns True if the ground object contains a unit with radar."""
def has_live_radar_sam(self) -> bool:
"""Returns True if the ground object contains a unit with working radar SAM."""
for group in self.groups:
for unit in group.units:
if db.unit_type_from_name(unit.type) in UNITS_WITH_RADAR:
return True
if self.threat_range(group, radar_only=True):
return True
return False
def _max_range_of_type(self, group: Group, range_type: str) -> Distance:
@@ -163,17 +168,16 @@ class TheaterGroundObject(MissionTarget):
max_range = max(max_range, meters(unit_range))
return max_range
def max_detection_range(self) -> Distance:
return max(self.detection_range(g) for g in self.groups)
def detection_range(self, group: Group) -> Distance:
return self._max_range_of_type(group, "detection_range")
def threat_range(self, group: Group) -> Distance:
if not self.detection_range(group):
# For simple SAMs like shilkas, the unit has both a threat and
# detection range. For complex sites like SA-2s, the launcher has a
# threat range and the search/track radars have detection ranges. If
# the site has no detection range it has no radars and can't fire,
# so it's not actually a threat even if it still has launchers.
return meters(0)
def max_threat_range(self) -> Distance:
return max(self.threat_range(g) for g in self.groups)
def threat_range(self, group: Group, radar_only: bool = False) -> Distance:
return self._max_range_of_type(group, "threat_range")
@property
@@ -452,12 +456,45 @@ class SamGroundObject(BaseDefenseGroundObject):
if not self.is_friendly(for_player):
yield FlightType.DEAD
yield FlightType.SEAD
yield from super().mission_types(for_player)
@property
def might_have_aa(self) -> bool:
return True
def threat_range(self, group: Group, radar_only: bool = False) -> Distance:
max_non_radar = meters(0)
live_trs = set()
max_telar_range = meters(0)
launchers = set()
for unit in group.units:
unit_type = db.unit_type_from_name(unit.type)
if unit_type is None or not issubclass(unit_type, VehicleType):
continue
if unit_type in TRACK_RADARS:
live_trs.add(unit_type)
elif unit_type in TELARS:
max_telar_range = max(
max_telar_range, meters(getattr(unit_type, "threat_range", 0))
)
elif unit_type in LAUNCHER_TRACKER_PAIRS:
launchers.add(unit_type)
else:
max_non_radar = max(
max_non_radar, meters(getattr(unit_type, "threat_range", 0))
)
max_tel_range = meters(0)
for launcher in launchers:
if LAUNCHER_TRACKER_PAIRS[launcher] in live_trs:
max_tel_range = max(
max_tel_range, meters(getattr(launcher, "threat_range"))
)
if radar_only:
return max(max_tel_range, max_telar_range)
else:
return max(max_tel_range, max_telar_range, max_non_radar)
class VehicleGroupGroundObject(BaseDefenseGroundObject):
def __init__(

View File

@@ -88,9 +88,27 @@ class TransitNetwork:
TransitConnection.Airlift: a.position.distance_to_point(b.position),
}[self.link_type(a, b)]
def has_path_between(
self,
origin: ControlPoint,
destination: ControlPoint,
seen: Optional[set[ControlPoint]] = None,
) -> bool:
if seen is None:
seen = set()
seen.add(origin)
for connection in self.connections_from(origin):
if connection in seen:
continue
if connection == destination:
return True
if self.has_path_between(connection, destination, seen):
return True
return False
def shortest_path_between(
self, origin: ControlPoint, destination: ControlPoint
) -> List[ControlPoint]:
) -> list[ControlPoint]:
return self.shortest_path_with_cost(origin, destination)[0]
def shortest_path_with_cost(
@@ -127,7 +145,7 @@ class TransitNetwork:
path: List[ControlPoint] = []
while current != origin:
path.append(current)
previous = came_from[current]
previous = came_from.get(current)
if previous is None:
raise NoPathError(origin, destination)
current = previous

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from functools import singledispatchmethod
from typing import Optional, TYPE_CHECKING, Union
from typing import Optional, TYPE_CHECKING, Union, Iterable
from dcs.mapping import Point as DcsPoint
from shapely.geometry import (
@@ -13,11 +13,10 @@ from shapely.geometry import (
from shapely.geometry.base import BaseGeometry
from shapely.ops import nearest_points, unary_union
from game.theater import ControlPoint
from game.theater import ControlPoint, MissionTarget
from game.utils import Distance, meters, nautical_miles
from gen import Conflict
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import Flight
from gen.flights.flight import Flight, FlightWaypoint
if TYPE_CHECKING:
from game import Game
@@ -27,9 +26,12 @@ ThreatPoly = Union[MultiPolygon, Polygon]
class ThreatZones:
def __init__(self, airbases: ThreatPoly, air_defenses: ThreatPoly) -> None:
def __init__(
self, airbases: ThreatPoly, air_defenses: ThreatPoly, radar_sam_threats
) -> None:
self.airbases = airbases
self.air_defenses = air_defenses
self.radar_sam_threats = radar_sam_threats
self.all = unary_union([airbases, air_defenses])
def closest_boundary(self, point: DcsPoint) -> DcsPoint:
@@ -69,6 +71,13 @@ class ThreatZones:
LineString((self.dcs_to_shapely_point(p.position) for p in flight.points))
)
def waypoints_threatened_by_aircraft(
self, waypoints: Iterable[FlightWaypoint]
) -> bool:
return self.threatened_by_aircraft(
LineString((self.dcs_to_shapely_point(p.position) for p in waypoints))
)
@singledispatchmethod
def threatened_by_air_defense(self, target) -> bool:
raise NotImplementedError
@@ -83,6 +92,33 @@ class ThreatZones:
LineString((self.dcs_to_shapely_point(p.position) for p in flight.points))
)
@threatened_by_air_defense.register
def _threatened_by_air_defense_mission_target(self, target: MissionTarget) -> bool:
return self.threatened_by_air_defense(
self.dcs_to_shapely_point(target.position)
)
@singledispatchmethod
def threatened_by_radar_sam(self, target) -> bool:
raise NotImplementedError
@threatened_by_radar_sam.register
def _threatened_by_radar_sam_geom(self, position: BaseGeometry) -> bool:
return self.radar_sam_threats.intersects(position)
@threatened_by_radar_sam.register
def _threatened_by_radar_sam_flight(self, flight: Flight) -> bool:
return self.threatened_by_radar_sam(
LineString((self.dcs_to_shapely_point(p.position) for p in flight.points))
)
def waypoints_threatened_by_radar_sam(
self, waypoints: Iterable[FlightWaypoint]
) -> bool:
return self.threatened_by_radar_sam(
LineString((self.dcs_to_shapely_point(p.position) for p in waypoints))
)
@classmethod
def closest_enemy_airbase(
cls, location: ControlPoint, max_distance: Distance
@@ -134,6 +170,7 @@ class ThreatZones:
"""
air_threats = []
air_defenses = []
radar_sam_threats = []
for control_point in game.theater.controlpoints:
if control_point.captured != player:
continue
@@ -151,9 +188,16 @@ class ThreatZones:
point = ShapelyPoint(tgo.position.x, tgo.position.y)
threat_zone = point.buffer(threat_range.meters)
air_defenses.append(threat_zone)
radar_threat_range = tgo.threat_range(group, radar_only=True)
if radar_threat_range > nautical_miles(3):
point = ShapelyPoint(tgo.position.x, tgo.position.y)
threat_zone = point.buffer(threat_range.meters)
radar_sam_threats.append(threat_zone)
return cls(
airbases=unary_union(air_threats), air_defenses=unary_union(air_defenses)
airbases=unary_union(air_threats),
air_defenses=unary_union(air_defenses),
radar_sam_threats=unary_union(radar_sam_threats),
)
@staticmethod

View File

@@ -4,12 +4,23 @@ import logging
from collections import defaultdict
from dataclasses import dataclass, field
from functools import singledispatchmethod
from typing import Dict, Generic, Iterator, List, Optional, TYPE_CHECKING, Type, TypeVar
from typing import (
Dict,
Generic,
Iterator,
List,
Optional,
TYPE_CHECKING,
Type,
TypeVar,
Sequence,
)
from dcs.mapping import Point
from dcs.unittype import FlyingType, VehicleType
from game.procurement import AircraftProcurementRequest
from game.squadrons import Squadron
from game.theater import ControlPoint, MissionTarget
from game.theater.transitnetwork import (
TransitConnection,
@@ -222,17 +233,27 @@ class AirliftPlanner:
inventory = self.game.aircraft_inventory.for_control_point(cp)
for unit_type, available in inventory.all_aircraft:
squadrons = [
s
for s in self.game.air_wing_for(self.for_player).squadrons_for(
unit_type
)
if FlightType.TRANSPORT in s.mission_types
]
if not squadrons:
continue
squadron = squadrons[0]
if self.compatible_with_mission(unit_type, cp):
while available and self.transfer.transport is None:
flight_size = self.create_airlift_flight(unit_type, inventory)
flight_size = self.create_airlift_flight(squadron, inventory)
available -= flight_size
if self.package.flights:
self.game.ato_for(self.for_player).add_package(self.package)
def create_airlift_flight(
self, unit_type: Type[FlyingType], inventory: ControlPointAircraftInventory
self, squadron: Squadron, inventory: ControlPointAircraftInventory
) -> int:
available = inventory.available(unit_type)
available = inventory.available(squadron.aircraft)
# 4 is the max flight size in DCS.
flight_size = min(self.transfer.size, available, 4)
@@ -241,10 +262,11 @@ class AirliftPlanner:
else:
transfer = self.transfer
player = inventory.control_point.captured
flight = Flight(
self.package,
self.game.player_country,
unit_type,
self.game.country_for(player),
squadron,
flight_size,
FlightType.TRANSPORT,
self.game.settings.default_start_type,
@@ -363,7 +385,7 @@ class CargoShip(MultiGroupTransport):
yield from super().mission_types(for_player)
@property
def route(self) -> List[Point]:
def route(self) -> Sequence[Point]:
return self.origin.shipping_lanes[self.destination]
def description(self) -> str:
@@ -518,6 +540,7 @@ class PendingTransfers:
flight = transport.flight
flight.package.remove_flight(flight)
self.game.aircraft_inventory.return_from_flight(flight)
flight.clear_roster()
@cancel_transport.register
def _cancel_transport_convoy(
@@ -562,10 +585,14 @@ class PendingTransfers:
def current_airlift_capacity(self, control_point: ControlPoint) -> int:
inventory = self.game.aircraft_inventory.for_control_point(control_point)
squadrons = self.game.air_wing_for(control_point.captured).squadrons_for_task(
FlightType.TRANSPORT
)
unit_types = {s.aircraft for s in squadrons}.intersection(TRANSPORT_CAPABLE)
return sum(
count
for unit_type, count in inventory.all_aircraft
if unit_type in TRANSPORT_CAPABLE
if unit_type in unit_types
)
def order_airlift_assets_at(self, control_point: ControlPoint) -> None:

View File

@@ -139,7 +139,9 @@ class PendingUnitDeliveries:
) -> Optional[ControlPoint]:
sources = []
for control_point in game.theater.control_points_for(self.destination.captured):
if control_point.can_recruit_ground_units(game):
if control_point.can_recruit_ground_units(
game
) and network.has_path_between(self.destination, control_point):
sources.append(control_point)
if not sources:

View File

@@ -7,12 +7,19 @@ from dcs.unitgroup import FlyingGroup, Group, VehicleGroup
from dcs.unittype import VehicleType
from game import db
from game.squadrons import Pilot
from game.theater import Airfield, ControlPoint, TheaterGroundObject
from game.theater.theatergroundobject import BuildingGroundObject, SceneryGroundObject
from game.transfers import CargoShip, Convoy, TransferOrder
from gen.flights.flight import Flight
@dataclass(frozen=True)
class FlyingUnit:
flight: Flight
pilot: Pilot
@dataclass(frozen=True)
class FrontLineUnit:
unit_type: Type[VehicleType]
@@ -45,7 +52,7 @@ class Building:
class UnitMap:
def __init__(self) -> None:
self.aircraft: Dict[str, Flight] = {}
self.aircraft: Dict[str, FlyingUnit] = {}
self.airfields: Dict[str, Airfield] = {}
self.front_line_units: Dict[str, FrontLineUnit] = {}
self.ground_object_units: Dict[str, GroundObjectUnit] = {}
@@ -55,17 +62,19 @@ class UnitMap:
self.airlifts: Dict[str, AirliftUnit] = {}
def add_aircraft(self, group: FlyingGroup, flight: Flight) -> None:
for unit in group.units:
for pilot, unit in zip(flight.pilots, group.units):
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(unit.name)
if name in self.aircraft:
raise RuntimeError(f"Duplicate unit name: {name}")
self.aircraft[name] = flight
if pilot is None:
raise ValueError(f"{name} has no pilot assigned")
self.aircraft[name] = FlyingUnit(flight, pilot)
if flight.cargo is not None:
self.add_airlift_units(group, flight.cargo)
def flight(self, unit_name: str) -> Optional[Flight]:
def flight(self, unit_name: str) -> Optional[FlyingUnit]:
return self.aircraft.get(unit_name, None)
def add_airfield(self, airfield: Airfield) -> None:

View File

@@ -73,4 +73,12 @@ VERSION = _build_version_string()
#: * AAA_8_8cm_Flak_18,
#: * SPAAA_Vulcan_M163,
#: * SPAAA_ZSU_23_4_Shilka_Gun_Dish,
CAMPAIGN_FORMAT_VERSION = (4, 2)
#:
#: Version 5.0
#: * Ammunition Depots objective locations are now predetermined using the "Ammunition Depot"
#: Warehouse object, and through trigger zone based scenery objects.
#: * The number of alive Ammunition Depot objective buildings connected to a control point
#: directly influences how many ground units can be supported on the front line.
#: * The number of supported ground units at any control point is artificially capped at 50,
#: even if the number of alive Ammunition Depot objectives can support more.
CAMPAIGN_FORMAT_VERSION = (5, 0)