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
commit 4eb78810c6
172 changed files with 3858 additions and 1434 deletions

View File

@ -11,10 +11,10 @@ jobs:
with:
submodules: true
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.8
python-version: 3.9
- name: Install environment
run: |

View File

@ -13,10 +13,10 @@ jobs:
with:
submodules: true
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.8
python-version: 3.9
- name: Install environment
run: |

View File

@ -8,11 +8,19 @@ Saves from 2.5 are not compatible with 3.0.
* **[Campaign]** Ground units can no longer be sold. To move units to a new location, transfer them.
* **[Campaign]** Ground units must now be recruited at a base with a factory and transferred to their destination. When buying units in the UI, the purchase will automatically be fulfilled at the closest factory, and a transfer will be created on the next turn.
* **[Campaign]** Non-control point FOBs will no longer spawn.
* **[Campaign AI]** Every 30 minutes the AI will plan a CAP, so players can customize their mission better.
* **[Campaign]** Added squadrons and pilots. See https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots for more information.
* **[Campaign AI]** AI now considers Ju-88s for CAS, strike, and DEAD missions.
* **[Campaign AI]** Fix purchase of aircraft by priority (the faction's list was being used as the priority list rather than the game's).
* **[Flight Planner]** AI strike flight plans now include the correct target actions for building groups.
* **[Campaign AI]** AI planned AEW&C missions will now be scheduled ASAP.
* **[Campaign AI]** AI now considers the range to the SAM's threat zone rather than the range to the SAM itself when determining target priorities.
* **[Flight Planner]** Desired mission length is now configurable (defaults to 60 minutes). A BARCAP will be planned every 30 minutes. Other packages will simply have their takeoffs spread out or compressed such that the last flight will take off around the mission end time.
* **[Flight Planner]** Flight plans now include bullseye waypoints.
* **[Flight Planner]** Differentiated SEAD and SEAD escort. SEAD is tasked with suppressing the package target, SEAD escort is tasked with protecting the package from all SAMs along its route.
* **[Flight Planner]** Planned airspeed increased to 0.85 mach for supersonic airframes and 85% of max speed for subsonic.
* **[Flight Planner]** Taxi time estimation for airfields increased from 5 minutes to 8 minutes.
* **[Flight Planner]** Reduce expected error margin for flight plans from 10% to 5%.
* **[Flight Planner]** SEAD flights are scheduled one minute ahead of the package's TOT so that they can suppress the site ahead of the strike.
* **[Flight Planner]** Automatic ATO generation for the player's coalition can now be disabled in the settings.
* **[Payloads]** AI flights for most air to ground mission types (CAS excluded) will have their guns emptied to prevent strafing fully armed and operational battle stations. Gun-reliant airframes like A-10s and warbirds will keep their bullets.
* **[Kneeboard]** ATC table overflow alleviated by wrapping long airfield names and splitting ATC frequency and channel into separate rows.
* **[UI]** Added new web based map UI. This is mostly functional but many of the old display options are a WIP. Revert to the old map with --old-map.
* **[UI]** Campaigns generated for an older or newer version of the game will now be marked as incompatible. They can still be played, but bugs may be present.
@ -24,13 +32,17 @@ Saves from 2.5 are not compatible with 3.0.
* **[Modding]** Can now install custom factions to <DCS saved games>/Liberation/Factions instead of the Liberation install directory.
* **[Performance Settings]** Added a settings to lower the number of smoke effects generated on frontlines. Lowered default settings for frontline smoke generators, so less smoke should be generated by default.
* **[Configuration]** Liberation preferences (DCS install and save game location) are now saved to `%LOCALAPPDATA%/DCSLiberation` to prevent needing to reconfigure each new install.
* **[Skynet]** Updated to 2.1.0.
## Fixes
* **[Campaign AI]** Fix purchase of aircraft by priority (the faction's list was being used as the priority list rather than the game's).
* **[Campaign AI]** Fixed bug causing AI to over-purchase cheap aircraft.
* **[Campaign AI]** Auto planner will no longer attempt to plan missions for which the faction has no compatible aircraft.
* **[Campaign AI]** Stop purchasing aircraft after the first unaffordable package to attempt to complete more packages rather than filling airfields with cheap escorts that will never be used.
* **[Campaign]** Fixed bug where offshore strike locations were being used to spawn ship objectives.
* **[Flight Planner]** AI strike flight plans now include the correct target actions for building groups.
* **[Flight Planner]** AI BAI/DEAD/SEAD flights now have tasks to attack all groups at the target location, not just the primary group (for multi-group SAM sites).
# 2.5.1

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)

View File

@ -62,23 +62,25 @@ from dcs.task import (
OptRestrictJettison,
OrbitAction,
RunwayAttack,
SEAD,
StartCommand,
Targets,
Transport,
WeaponType,
TargetType,
)
from dcs.terrain.terrain import Airport, NoParkingSlotError
from dcs.triggers import Event, TriggerOnce, TriggerRule
from dcs.unit import Unit
from dcs.unit import Unit, Skill
from dcs.unitgroup import FlyingGroup, ShipGroup, StaticGroup
from dcs.unittype import FlyingType, UnitType
from game import db
from game.data.cap_capabilities_db import GUNFIGHTERS
from game.data.weapons import Pylon
from game.db import GUN_RELIANT_AIRFRAMES
from game.factions.faction import Faction
from game.settings import Settings
from game.squadrons import Pilot, Squadron
from game.theater.controlpoint import (
Airfield,
ControlPoint,
@ -725,6 +727,70 @@ class AircraftConflictGenerator:
return StartType.Cold
return StartType.Warm
def skill_level_for(
self, unit: FlyingUnit, pilot: Optional[Pilot], blue: bool
) -> Skill:
if blue:
base_skill = Skill(self.game.settings.player_skill)
else:
base_skill = Skill(self.game.settings.enemy_skill)
if pilot is None:
logging.error(f"Cannot determine skill level: {unit.name} has not pilot")
return base_skill
levels = [
Skill.Average,
Skill.Good,
Skill.High,
Skill.Excellent,
]
current_level = levels.index(base_skill)
missions_for_skill_increase = 4
increase = pilot.record.missions_flown // missions_for_skill_increase
new_level = min(current_level + increase, len(levels) - 1)
return levels[new_level]
def set_skill(self, unit: FlyingUnit, pilot: Optional[Pilot], blue: bool) -> None:
if pilot is None or not pilot.player:
unit.skill = self.skill_level_for(unit, pilot, blue)
return
if self.use_client:
unit.set_client()
else:
unit.set_player()
@staticmethod
def livery_from_db(flight: Flight) -> Optional[str]:
return db.PLANE_LIVERY_OVERRIDES.get(flight.unit_type)
def livery_from_faction(self, flight: Flight) -> Optional[str]:
faction = self.game.faction_for(player=flight.departure.captured)
if (choices := faction.liveries_overrides.get(flight.unit_type)) is not None:
return random.choice(choices)
return None
@staticmethod
def livery_from_squadron(flight: Flight) -> Optional[str]:
return flight.squadron.livery
def livery_for(self, flight: Flight) -> Optional[str]:
if (livery := self.livery_from_squadron(flight)) is not None:
return livery
if (livery := self.livery_from_faction(flight)) is not None:
return livery
if (livery := self.livery_from_db(flight)) is not None:
return livery
return None
def _setup_livery(self, flight: Flight, group: FlyingGroup) -> None:
livery = self.livery_for(flight)
if livery is None:
return
for unit in group.units:
unit.livery_id = livery
def _setup_group(
self,
group: FlyingGroup,
@ -735,34 +801,16 @@ class AircraftConflictGenerator:
unit_type = group.units[0].unit_type
self._setup_payload(flight, group)
self._setup_livery(flight, group)
if unit_type in db.PLANE_LIVERY_OVERRIDES:
for unit_instance in group.units:
unit_instance.livery_id = db.PLANE_LIVERY_OVERRIDES[unit_type]
# Override livery by faction file data
if flight.from_cp.captured:
faction = self.game.player_faction
else:
faction = self.game.enemy_faction
if unit_type in faction.liveries_overrides:
livery = random.choice(faction.liveries_overrides[unit_type])
for unit_instance in group.units:
unit_instance.livery_id = livery
for idx in range(0, min(len(group.units), flight.client_count)):
unit = group.units[idx]
if self.use_client:
unit.set_client()
else:
unit.set_player()
for unit, pilot in zip(group.units, flight.pilots):
player = pilot is not None and pilot.player
self.set_skill(unit, pilot, blue=flight.departure.captured)
# Do not generate player group with late activation.
if group.late_activation:
if player and group.late_activation:
group.late_activation = False
# Set up F-14 Client to have pre-stored alignement
# Set up F-14 Client to have pre-stored alignment
if unit_type is F_14B:
unit.set_property(F_14B.Properties.INSAlignmentStored.id, True)
@ -783,7 +831,7 @@ class AircraftConflictGenerator:
self.flights.append(
FlightData(
package=package,
country=faction.country,
country=self.game.faction_for(player=flight.departure.captured).country,
flight_type=flight.flight_type,
units=group.units,
size=len(group.units),
@ -1019,7 +1067,7 @@ class AircraftConflictGenerator:
flight = Flight(
Package(control_point),
faction.country,
aircraft,
self.game.air_wing_for(control_point.captured).squadron_for(aircraft),
1,
FlightType.BARCAP,
"Cold",
@ -1179,12 +1227,23 @@ class AircraftConflictGenerator:
raise RuntimeError(f"No reduced fuel case for type {unit_type}")
@staticmethod
def flight_always_keeps_gun(flight: Flight) -> bool:
# Never take bullets from players. They're smart enough to know when to use it
# and when to RTB.
if flight.client_count > 0:
return True
return flight.unit_type in GUN_RELIANT_AIRFRAMES
def configure_behavior(
self,
flight: Flight,
group: FlyingGroup,
react_on_threat: Optional[OptReactOnThreat.Values] = None,
roe: Optional[OptROE.Values] = None,
rtb_winchester: Optional[OptRTBOnOutOfAmmo.Values] = None,
restrict_jettison: Optional[bool] = None,
mission_uses_gun: bool = True,
) -> None:
group.points[0].tasks.clear()
if react_on_threat is not None:
@ -1196,6 +1255,17 @@ class AircraftConflictGenerator:
if rtb_winchester is not None:
group.points[0].tasks.append(OptRTBOnOutOfAmmo(rtb_winchester))
# Confiscate the bullets of AI missions that do not rely on the gun. There is no
# "all but gun" RTB winchester option, so air to ground missions with mixed
# weapon types will insist on using all of their bullets after running out of
# missiles and bombs. Take away their bullets so they don't strafe a Tor.
#
# Exceptions are made for player flights and for airframes where the gun is
# essential like the A-10 or warbirds.
if not mission_uses_gun and not self.flight_always_keeps_gun(flight):
for unit in group.units:
unit.gun = 0
group.points[0].tasks.append(OptRTBOnBingoFuel(True))
# Do not restrict afterburner.
# https://forums.eagle.ru/forum/english/digital-combat-simulator/dcs-world-2-5/bugs-and-problems-ai/ai-ad/7121294-ai-stuck-at-high-aoa-after-making-sharp-turn-if-afterburner-is-restricted
@ -1221,7 +1291,7 @@ class AircraftConflictGenerator:
else:
ammo_type = OptRTBOnOutOfAmmo.Values.Cannon
self.configure_behavior(group, rtb_winchester=ammo_type)
self.configure_behavior(flight, group, rtb_winchester=ammo_type)
def configure_sweep(
self,
@ -1238,7 +1308,7 @@ class AircraftConflictGenerator:
else:
ammo_type = OptRTBOnOutOfAmmo.Values.Cannon
self.configure_behavior(group, rtb_winchester=ammo_type)
self.configure_behavior(flight, group, rtb_winchester=ammo_type)
def configure_cas(
self,
@ -1250,6 +1320,7 @@ class AircraftConflictGenerator:
group.task = CAS.name
self._setup_group(group, package, flight, dynamic_runways)
self.configure_behavior(
flight,
group,
react_on_threat=OptReactOnThreat.Values.EvadeFire,
roe=OptROE.Values.OpenFire,
@ -1273,11 +1344,13 @@ class AircraftConflictGenerator:
group.task = CAS.name
self._setup_group(group, package, flight, dynamic_runways)
self.configure_behavior(
flight,
group,
react_on_threat=OptReactOnThreat.Values.EvadeFire,
roe=OptROE.Values.OpenFire,
rtb_winchester=OptRTBOnOutOfAmmo.Values.All,
restrict_jettison=True,
mission_uses_gun=False,
)
def configure_sead(
@ -1287,14 +1360,21 @@ class AircraftConflictGenerator:
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
) -> None:
group.task = SEAD.name
# CAS is able to perform all the same tasks as SEAD using a superset of the
# available aircraft, and F-14s are not able to be SEAD despite having TALDs.
# https://forums.eagle.ru/topic/272112-cannot-assign-f-14-to-sead/
group.task = CAS.name
self._setup_group(group, package, flight, dynamic_runways)
self.configure_behavior(
flight,
group,
react_on_threat=OptReactOnThreat.Values.EvadeFire,
roe=OptROE.Values.OpenFire,
# ASM includes ARMs and TALDs (among other things, but those are the useful
# weapons for SEAD).
rtb_winchester=OptRTBOnOutOfAmmo.Values.ASM,
restrict_jettison=True,
mission_uses_gun=False,
)
def configure_strike(
@ -1307,10 +1387,12 @@ class AircraftConflictGenerator:
group.task = GroundAttack.name
self._setup_group(group, package, flight, dynamic_runways)
self.configure_behavior(
flight,
group,
react_on_threat=OptReactOnThreat.Values.EvadeFire,
roe=OptROE.Values.OpenFire,
restrict_jettison=True,
mission_uses_gun=False,
)
def configure_anti_ship(
@ -1323,10 +1405,12 @@ class AircraftConflictGenerator:
group.task = AntishipStrike.name
self._setup_group(group, package, flight, dynamic_runways)
self.configure_behavior(
flight,
group,
react_on_threat=OptReactOnThreat.Values.EvadeFire,
roe=OptROE.Values.OpenFire,
restrict_jettison=True,
mission_uses_gun=False,
)
def configure_runway_attack(
@ -1339,10 +1423,12 @@ class AircraftConflictGenerator:
group.task = RunwayAttack.name
self._setup_group(group, package, flight, dynamic_runways)
self.configure_behavior(
flight,
group,
react_on_threat=OptReactOnThreat.Values.EvadeFire,
roe=OptROE.Values.OpenFire,
restrict_jettison=True,
mission_uses_gun=False,
)
def configure_oca_strike(
@ -1355,6 +1441,7 @@ class AircraftConflictGenerator:
group.task = CAS.name
self._setup_group(group, package, flight, dynamic_runways)
self.configure_behavior(
flight,
group,
react_on_threat=OptReactOnThreat.Values.EvadeFire,
roe=OptROE.Values.OpenFire,
@ -1380,6 +1467,7 @@ class AircraftConflictGenerator:
# Awacs task action
self.configure_behavior(
flight,
group,
react_on_threat=OptReactOnThreat.Values.EvadeFire,
roe=OptROE.Values.WeaponHold,
@ -1401,7 +1489,30 @@ class AircraftConflictGenerator:
group.task = CAP.name
self._setup_group(group, package, flight, dynamic_runways)
self.configure_behavior(
group, roe=OptROE.Values.OpenFire, restrict_jettison=True
flight, group, roe=OptROE.Values.OpenFire, restrict_jettison=True
)
def configure_sead_escort(
self,
group: FlyingGroup,
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
) -> None:
# CAS is able to perform all the same tasks as SEAD using a superset of the
# available aircraft, and F-14s are not able to be SEAD despite having TALDs.
# https://forums.eagle.ru/topic/272112-cannot-assign-f-14-to-sead/
group.task = CAS.name
self._setup_group(group, package, flight, dynamic_runways)
self.configure_behavior(
flight,
group,
roe=OptROE.Values.OpenFire,
# ASM includes ARMs and TALDs (among other things, but those are the useful
# weapons for SEAD).
rtb_winchester=OptRTBOnOutOfAmmo.Values.ASM,
restrict_jettison=True,
mission_uses_gun=False,
)
def configure_transport(
@ -1414,6 +1525,7 @@ class AircraftConflictGenerator:
group.task = Transport.name
self._setup_group(group, package, flight, dynamic_runways)
self.configure_behavior(
flight,
group,
react_on_threat=OptReactOnThreat.Values.EvadeFire,
roe=OptROE.Values.WeaponHold,
@ -1422,7 +1534,7 @@ class AircraftConflictGenerator:
def configure_unknown_task(self, group: FlyingGroup, flight: Flight) -> None:
logging.error(f"Unhandled flight type: {flight.flight_type}")
self.configure_behavior(group)
self.configure_behavior(flight, group)
def setup_flight_group(
self,
@ -1448,6 +1560,8 @@ class AircraftConflictGenerator:
self.configure_dead(group, package, flight, dynamic_runways)
elif flight_type == FlightType.SEAD:
self.configure_sead(group, package, flight, dynamic_runways)
elif flight_type == FlightType.SEAD_ESCORT:
self.configure_sead_escort(group, package, flight, dynamic_runways)
elif flight_type == FlightType.STRIKE:
self.configure_strike(group, package, flight, dynamic_runways)
elif flight_type == FlightType.ANTISHIP:
@ -1695,29 +1809,32 @@ class BaiIngressBuilder(PydcsWaypointBuilder):
waypoint = super().build()
# TODO: Add common "UnitGroupTarget" base type.
target_group = self.package.target
if isinstance(target_group, TheaterGroundObject):
group_name = target_group.group_name
elif isinstance(target_group, MultiGroupTransport):
group_name = target_group.name
group_names = []
target = self.package.target
if isinstance(target, TheaterGroundObject):
for group in target.groups:
group_names.append(group.name)
elif isinstance(target, MultiGroupTransport):
group_names.append(target.name)
else:
logging.error(
"Unexpected target type for BAI mission: %s",
target_group.__class__.__name__,
target.__class__.__name__,
)
return waypoint
group = self.mission.find_group(group_name)
if group is None:
logging.error("Could not find group for BAI mission %s", group_name)
return waypoint
for group_name in group_names:
group = self.mission.find_group(group_name)
if group is None:
logging.error("Could not find group for BAI mission %s", group_name)
continue
task = AttackGroup(group.id, weapon_type=WeaponType.Auto)
task.params["attackQtyLimit"] = False
task.params["directionEnabled"] = False
task.params["altitudeEnabled"] = False
task.params["groupAttack"] = True
waypoint.tasks.append(task)
task = AttackGroup(group.id, weapon_type=WeaponType.Auto)
task.params["attackQtyLimit"] = False
task.params["directionEnabled"] = False
task.params["altitudeEnabled"] = False
task.params["groupAttack"] = True
waypoint.tasks.append(task)
return waypoint
@ -1754,23 +1871,29 @@ class CasIngressBuilder(PydcsWaypointBuilder):
class DeadIngressBuilder(PydcsWaypointBuilder):
def build(self) -> MovingPoint:
waypoint = super().build()
target_group = self.package.target
if isinstance(target_group, TheaterGroundObject):
tgroup = self.mission.find_group(target_group.group_name)
if tgroup is not None:
task = AttackGroup(tgroup.id, weapon_type=WeaponType.Auto)
task.params["expend"] = "All"
task.params["attackQtyLimit"] = False
task.params["directionEnabled"] = False
task.params["altitudeEnabled"] = False
task.params["groupAttack"] = True
waypoint.tasks.append(task)
else:
logging.error(
f"Could not find group for DEAD mission {target_group.group_name}"
)
self.register_special_waypoints(self.waypoint.targets)
target = self.package.target
if not isinstance(target, TheaterGroundObject):
logging.error(
"Unexpected target type for DEAD mission: %s",
target.__class__.__name__,
)
return waypoint
for group in target.groups:
miz_group = self.mission.find_group(group.name)
if miz_group is None:
logging.error(f"Could not find group for DEAD mission {group.name}")
continue
task = AttackGroup(miz_group.id, weapon_type=WeaponType.Auto)
task.params["expend"] = "All"
task.params["attackQtyLimit"] = False
task.params["directionEnabled"] = False
task.params["altitudeEnabled"] = False
task.params["groupAttack"] = True
waypoint.tasks.append(task)
return waypoint
@ -1822,25 +1945,29 @@ class OcaRunwayIngressBuilder(PydcsWaypointBuilder):
class SeadIngressBuilder(PydcsWaypointBuilder):
def build(self) -> MovingPoint:
waypoint = super().build()
target_group = self.package.target
if isinstance(target_group, TheaterGroundObject):
tgroup = self.mission.find_group(target_group.group_name)
if tgroup is not None:
waypoint.add_task(
EngageTargetsInZone(
position=tgroup.position,
radius=int(nautical_miles(30).meters),
targets=[
Targets.All.GroundUnits.AirDefence,
],
)
)
else:
logging.error(
f"Could not find group for DEAD mission {target_group.group_name}"
)
self.register_special_waypoints(self.waypoint.targets)
target = self.package.target
if not isinstance(target, TheaterGroundObject):
logging.error(
"Unexpected target type for SEAD mission: %s",
target.__class__.__name__,
)
return waypoint
for group in target.groups:
miz_group = self.mission.find_group(group.name)
if miz_group is None:
logging.error(f"Could not find group for SEAD mission {group.name}")
continue
task = AttackGroup(miz_group.id, weapon_type=WeaponType.Guided)
task.params["expend"] = "All"
task.params["attackQtyLimit"] = False
task.params["directionEnabled"] = False
task.params["altitudeEnabled"] = False
task.params["groupAttack"] = True
waypoint.tasks.append(task)
return waypoint
@ -1903,7 +2030,10 @@ class SweepIngressBuilder(PydcsWaypointBuilder):
waypoint.tasks.append(
EngageTargets(
max_distance=int(nautical_miles(50).meters),
targets=[Targets.All.Air.Planes.Fighters],
targets=[
Targets.All.Air.Planes.Fighters,
Targets.All.Air.Planes.MultiroleFighters,
],
)
)
@ -1914,11 +2044,23 @@ class JoinPointBuilder(PydcsWaypointBuilder):
def build(self) -> MovingPoint:
waypoint = super().build()
if self.flight.flight_type == FlightType.ESCORT:
self.configure_escort_tasks(waypoint)
self.configure_escort_tasks(
waypoint,
[
Targets.All.Air.Planes.Fighters,
Targets.All.Air.Planes.MultiroleFighters,
],
)
elif self.flight.flight_type == FlightType.SEAD_ESCORT:
self.configure_escort_tasks(
waypoint, [Targets.All.GroundUnits.AirDefence.AAA.SAMRelated]
)
return waypoint
@staticmethod
def configure_escort_tasks(waypoint: MovingPoint) -> None:
def configure_escort_tasks(
waypoint: MovingPoint, target_types: List[Type[TargetType]]
) -> None:
# Ideally we would use the escort mission type and escort task to have
# the AI automatically but the AI only escorts AI flights while they are
# traveling between waypoints. When an AI flight performs an attack
@ -1944,16 +2086,13 @@ class JoinPointBuilder(PydcsWaypointBuilder):
# for the target area that is set to end on a flag flip that occurs when
# the strike aircraft finish their attack task.
#
# https://forums.eagle.ru/forum/english/digital-combat-simulator/dcs-world-2-5/bugs-and-problems-ai/ai-ad/250183-task-follow-and-escort-temporarily-aborted
# https://forums.eagle.ru/topic/251798-options-for-alternate-ai-escort-behavior
waypoint.add_task(
ControlledTask(
EngageTargets(
# TODO: From doctrine.
max_distance=int(nautical_miles(30).meters),
targets=[
Targets.All.Air.Planes.Fighters,
Targets.All.Air.Planes.MultiroleFighters,
],
targets=target_types,
)
)
)

View File

@ -620,9 +620,9 @@ AIRFIELD_DATA = {
tacan=TacanChannel(78, TacanBand.X),
tacan_callsign="BND",
vor=("BND", MHz(117, 200)),
atc=AtcData(MHz(4, 250), MHz(39, 401), MHz(118, 100), MHz(251, 0)),
atc=AtcData(MHz(4, 250), MHz(39, 400), MHz(118, 100), MHz(251, 0)),
ils={
"21": ("IBND", MHz(333, 800)),
"21": ("IBND", MHz(109, 900)),
},
),
"Jiroft": AirfieldData(

View File

@ -67,6 +67,10 @@ class Package:
waypoints: Optional[PackageWaypoints] = field(default=None)
@property
def has_players(self) -> bool:
return any(flight.client_count for flight in self.flights)
@property
def formation_speed(self) -> Optional[Speed]:
"""The speed of the package when in formation.

View File

@ -17,12 +17,17 @@ from typing import (
TYPE_CHECKING,
Tuple,
Type,
TypeVar,
Union,
)
from dcs.unittype import FlyingType
from game.factions.faction import Faction
from game.infos.information import Information
from game.procurement import AircraftProcurementRequest
from game.profiling import logged_duration, MultiEventTracer
from game.squadrons import AirWing, Squadron
from game.theater import (
Airfield,
ControlPoint,
@ -40,8 +45,7 @@ from game.theater.theatergroundobject import (
VehicleGroupGroundObject,
)
from game.transfers import CargoShip, Convoy
from game.utils import Distance, nautical_miles
from gen import Conflict
from game.utils import Distance, nautical_miles, meters
from gen.ato import Package
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import (
@ -109,6 +113,8 @@ class ProposedMission:
#: The proposed flights that are required for the mission.
flights: List[ProposedFlight]
asap: bool = field(default=False)
def __str__(self) -> str:
flights = ", ".join([str(f) for f in self.flights])
return f"{self.location.name}: {flights}"
@ -119,17 +125,19 @@ class AircraftAllocator:
def __init__(
self,
air_wing: AirWing,
closest_airfields: ClosestAirfields,
global_inventory: GlobalAircraftInventory,
is_player: bool,
) -> None:
self.air_wing = air_wing
self.closest_airfields = closest_airfields
self.global_inventory = global_inventory
self.is_player = is_player
def find_aircraft_for_flight(
def find_squadron_for_flight(
self, flight: ProposedFlight
) -> Optional[Tuple[ControlPoint, Type[FlyingType]]]:
) -> Optional[Tuple[ControlPoint, Squadron]]:
"""Finds aircraft suitable for the given mission.
Searches for aircraft capable of performing the given mission within the
@ -148,16 +156,18 @@ class AircraftAllocator:
on subsequent calls. If the found aircraft are not used, the caller is
responsible for returning them to the inventory.
"""
return self.find_aircraft_of_type(flight, aircraft_for_task(flight.task))
return self.find_aircraft_for_task(flight, flight.task)
def find_aircraft_of_type(
self,
flight: ProposedFlight,
types: List[Type[FlyingType]],
) -> Optional[Tuple[ControlPoint, Type[FlyingType]]]:
def find_aircraft_for_task(
self, flight: ProposedFlight, task: FlightType
) -> Optional[Tuple[ControlPoint, Squadron]]:
types = aircraft_for_task(task)
airfields_in_range = self.closest_airfields.airfields_within(
flight.max_distance
)
# Prefer using squadrons with pilots first
best_understaffed: Optional[Tuple[ControlPoint, Squadron]] = None
for airfield in airfields_in_range:
if not airfield.is_friendly(self.is_player):
continue
@ -165,11 +175,28 @@ class AircraftAllocator:
for aircraft in types:
if not airfield.can_operate(aircraft):
continue
if inventory.available(aircraft) >= flight.num_aircraft:
inventory.remove_aircraft(aircraft, flight.num_aircraft)
return airfield, aircraft
if inventory.available(aircraft) < flight.num_aircraft:
continue
# Valid location with enough aircraft available. Find a squadron to fit
# the role.
for squadron in self.air_wing.squadrons_for(aircraft):
if task not in squadron.mission_types:
continue
if len(squadron.available_pilots) >= flight.num_aircraft:
inventory.remove_aircraft(aircraft, flight.num_aircraft)
return airfield, squadron
return None
# A compatible squadron that doesn't have enough pilots. Remember it
# as a fallback in case we find no better choices.
if best_understaffed is None:
best_understaffed = airfield, squadron
if best_understaffed is not None:
airfield, squadron = best_understaffed
self.global_inventory.for_control_point(airfield).remove_aircraft(
squadron.aircraft, flight.num_aircraft
)
return best_understaffed
class PackageBuilder:
@ -180,16 +207,18 @@ class PackageBuilder:
location: MissionTarget,
closest_airfields: ClosestAirfields,
global_inventory: GlobalAircraftInventory,
air_wing: AirWing,
is_player: bool,
package_country: str,
start_type: str,
asap: bool,
) -> None:
self.closest_airfields = closest_airfields
self.is_player = is_player
self.package_country = package_country
self.package = Package(location)
self.package = Package(location, auto_asap=asap)
self.allocator = AircraftAllocator(
closest_airfields, global_inventory, is_player
air_wing, closest_airfields, global_inventory, is_player
)
self.global_inventory = global_inventory
self.start_type = start_type
@ -202,10 +231,10 @@ class PackageBuilder:
caller should return any previously planned flights to the inventory
using release_planned_aircraft.
"""
assignment = self.allocator.find_aircraft_for_flight(plan)
assignment = self.allocator.find_squadron_for_flight(plan)
if assignment is None:
return False
airfield, aircraft = assignment
airfield, squadron = assignment
if isinstance(airfield, OffMapSpawn):
start_type = "In Flight"
else:
@ -214,13 +243,13 @@ class PackageBuilder:
flight = Flight(
self.package,
self.package_country,
aircraft,
squadron,
plan.num_aircraft,
plan.task,
start_type,
departure=airfield,
arrival=airfield,
divert=self.find_divert_field(aircraft, airfield),
divert=self.find_divert_field(squadron.aircraft, airfield),
)
self.package.add_flight(flight)
return True
@ -250,9 +279,13 @@ class PackageBuilder:
flights = list(self.package.flights)
for flight in flights:
self.global_inventory.return_from_flight(flight)
flight.clear_roster()
self.package.remove_flight(flight)
MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget)
class ObjectiveFinder:
"""Identifies potential objectives for the mission planner."""
@ -264,41 +297,53 @@ class ObjectiveFinder:
self.game = game
self.is_player = is_player
def enemy_sams(self) -> Iterator[TheaterGroundObject]:
def enemy_air_defenses(self) -> Iterator[tuple[TheaterGroundObject, Distance]]:
"""Iterates over all enemy SAM sites."""
# Control points might have the same ground object several times, for
# some reason.
found_targets: Set[str] = set()
doctrine = self.game.faction_for(self.is_player).doctrine
threat_zones = self.game.threat_zone_for(not self.is_player)
for cp in self.enemy_control_points():
for ground_object in cp.ground_objects:
is_ewr = isinstance(ground_object, EwrGroundObject)
is_sam = isinstance(ground_object, SamGroundObject)
if not is_ewr and not is_sam:
continue
if ground_object.is_dead:
continue
if ground_object.name in found_targets:
if isinstance(ground_object, EwrGroundObject):
if threat_zones.threatened_by_air_defense(ground_object):
# This is a very weak heuristic for determining whether the EWR
# is close enough to be worth targeting before a SAM that is
# covering it. Ingress distance corresponds to the beginning of
# the attack range and is sufficient for most standoff weapons,
# so treating the ingress distance as the threat distance sorts
# these EWRs such that they will be attacked before SAMs that do
# not threaten the ingress point, but after those that do.
target_range = doctrine.ingress_egress_distance
else:
# But if the EWR isn't covered then we should only be worrying
# about its detection range.
target_range = ground_object.max_detection_range()
elif isinstance(ground_object, SamGroundObject):
target_range = ground_object.max_threat_range()
else:
continue
if not ground_object.has_radar:
continue
yield ground_object, target_range
# TODO: Yield in order of most threatening.
# Need to sort in order of how close their defensive range comes
# to friendly assets. To do that we need to add effective range
# information to the database.
yield ground_object
found_targets.add(ground_object.name)
def threatening_sams(self) -> Iterator[MissionTarget]:
def threatening_air_defenses(self) -> Iterator[TheaterGroundObject]:
"""Iterates over enemy SAMs in threat range of friendly control points.
SAM sites are sorted by their closest proximity to any friendly control
point (airfield or fleet).
"""
return self._targets_by_range(self.enemy_sams())
target_ranges: list[tuple[TheaterGroundObject, Distance]] = []
for target, threat_range in self.enemy_air_defenses():
ranges: list[Distance] = []
for cp in self.friendly_control_points():
ranges.append(meters(target.distance_to(cp)) - threat_range)
target_ranges.append((target, min(ranges)))
target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
for target, _range in target_ranges:
yield target
def enemy_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]:
"""Iterates over all enemy vehicle groups."""
@ -340,9 +385,9 @@ class ObjectiveFinder:
return self._targets_by_range(self.enemy_ships())
def _targets_by_range(
self, targets: Iterable[MissionTarget]
) -> Iterator[MissionTarget]:
target_ranges: List[Tuple[MissionTarget, int]] = []
self, targets: Iterable[MissionTargetType]
) -> Iterator[MissionTargetType]:
target_ranges: List[Tuple[MissionTargetType, int]] = []
for target in targets:
ranges: List[int] = []
for cp in self.friendly_control_points():
@ -551,15 +596,20 @@ class CoalitionMissionPlanner:
self.procurement_requests = self.game.procurement_requests_for(self.is_player)
self.faction = self.game.faction_for(self.is_player)
def faction_can_plan(self, mission_type: FlightType) -> bool:
"""Returns True if it is possible for the faction to plan this mission type.
def air_wing_can_plan(self, mission_type: FlightType) -> bool:
"""Returns True if it is possible for the air wing to plan this mission type.
Not all mission types can be fulfilled by all factions. Many factions do not
have AEW&C aircraft, so they will never be able to plan those missions.
Not all mission types can be fulfilled by all air wings. Many factions do not
have AEW&C aircraft, so they will never be able to plan those missions. It's
also possible for the player to exclude mission types from their squadron
designs.
"""
all_compatible = aircraft_for_task(mission_type)
for aircraft in self.faction.aircrafts:
if aircraft in all_compatible:
for squadron in self.game.air_wing_for(self.is_player).iter_squadrons():
if (
squadron.aircraft in all_compatible
and mission_type in squadron.mission_types
):
return True
return False
@ -578,7 +628,10 @@ class CoalitionMissionPlanner:
cp = self.objective_finder.farthest_friendly_control_point()
if cp is not None:
yield ProposedMission(
cp, [ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)]
cp,
[ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)],
# Supports all the early CAP flights, so should be in the air ASAP.
asap=True,
)
# Find friendly CPs within 100 nmi from an enemy airfield, plan CAP.
@ -630,17 +683,36 @@ class CoalitionMissionPlanner:
# or objects, plan DEAD.
# Find enemy SAM sites with ranges that extend to within 50 nmi of
# friendly CPs, front, lines, or objects, plan DEAD.
for sam in self.objective_finder.threatening_sams():
yield ProposedMission(
sam,
[
ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE),
# TODO: Max escort range.
for sam in self.objective_finder.threatening_air_defenses():
flights = [ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE)]
# Only include SEAD against SAMs that still have emitters. No need to
# suppress an EWR, and SEAD isn't useful against a SAM that no longer has a
# working track radar.
#
# For SAMs without track radars and EWRs, we still want a SEAD escort if
# needed.
#
# Note that there is a quirk here: we should potentially be included a SEAD
# escort *and* SEAD when the target is a radar SAM but the flight path is
# also threatened by SAMs. We don't want to include a SEAD escort if the
# package is *only* threatened by the target though. Could be improved, but
# needs a decent refactor to the escort planning to do so.
if sam.has_live_radar_sam:
flights.append(ProposedFlight(FlightType.SEAD, 2, self.MAX_SEAD_RANGE))
else:
flights.append(
ProposedFlight(
FlightType.ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.AirToAir
),
],
FlightType.SEAD_ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.Sead
)
)
# TODO: Max escort range.
flights.append(
ProposedFlight(
FlightType.ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.AirToAir
)
)
yield ProposedMission(sam, flights)
# These will only rarely get planned. When a convoy is travelling multiple legs,
# they're targetable after the first leg. The reason for this is that
@ -665,7 +737,7 @@ class CoalitionMissionPlanner:
FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
),
ProposedFlight(
FlightType.SEAD, 2, self.MAX_BAI_RANGE, EscortType.Sead
FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead
),
],
)
@ -680,7 +752,7 @@ class CoalitionMissionPlanner:
FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
),
ProposedFlight(
FlightType.SEAD, 2, self.MAX_BAI_RANGE, EscortType.Sead
FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead
),
],
)
@ -710,7 +782,7 @@ class CoalitionMissionPlanner:
FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
),
ProposedFlight(
FlightType.SEAD, 2, self.MAX_OCA_RANGE, EscortType.Sead
FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead
),
],
)
@ -732,7 +804,7 @@ class CoalitionMissionPlanner:
FlightType.ESCORT, 2, self.MAX_OCA_RANGE, EscortType.AirToAir
),
ProposedFlight(
FlightType.SEAD, 2, self.MAX_OCA_RANGE, EscortType.Sead
FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead
),
]
)
@ -749,20 +821,29 @@ class CoalitionMissionPlanner:
FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE, EscortType.AirToAir
),
ProposedFlight(
FlightType.SEAD, 2, self.MAX_STRIKE_RANGE, EscortType.Sead
FlightType.SEAD_ESCORT,
2,
self.MAX_STRIKE_RANGE,
EscortType.Sead,
),
],
)
def plan_missions(self) -> None:
"""Identifies and plans mission for the turn."""
for proposed_mission in self.propose_missions():
self.plan_mission(proposed_mission)
player = "Blue" if self.is_player else "Red"
with logged_duration(f"{player} mission identification and fulfillment"):
with MultiEventTracer() as tracer:
for proposed_mission in self.propose_missions():
self.plan_mission(proposed_mission, tracer)
for critical_mission in self.critical_missions():
self.plan_mission(critical_mission, reserves=True)
with logged_duration(f"{player} reserve mission planning"):
with MultiEventTracer() as tracer:
for critical_mission in self.critical_missions():
self.plan_mission(critical_mission, tracer, reserves=True)
self.stagger_missions()
with logged_duration(f"{player} mission scheduling"):
self.stagger_missions()
for cp in self.objective_finder.friendly_control_points():
inventory = self.game.aircraft_inventory.for_control_point(cp)
@ -817,27 +898,29 @@ class CoalitionMissionPlanner:
def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]:
threats = defaultdict(bool)
for flight in builder.package.flights:
if self.threat_zones.threatened_by_aircraft(flight):
if self.threat_zones.waypoints_threatened_by_aircraft(
flight.flight_plan.escorted_waypoints()
):
threats[EscortType.AirToAir] = True
if self.threat_zones.threatened_by_air_defense(flight):
if self.threat_zones.waypoints_threatened_by_radar_sam(
list(flight.flight_plan.escorted_waypoints())
):
threats[EscortType.Sead] = True
return threats
def plan_mission(self, mission: ProposedMission, reserves: bool = False) -> None:
def plan_mission(
self, mission: ProposedMission, tracer: MultiEventTracer, reserves: bool = False
) -> None:
"""Allocates aircraft for a proposed mission and adds it to the ATO."""
if self.is_player:
package_country = self.game.player_country
else:
package_country = self.game.enemy_country
builder = PackageBuilder(
mission.location,
self.objective_finder.closest_airfields_to(mission.location),
self.game.aircraft_inventory,
self.game.air_wing_for(self.is_player),
self.is_player,
package_country,
self.game.country_for(self.is_player),
self.game.settings.default_start_type,
mission.asap,
)
# Attempt to plan all the main elements of the mission first. Escorts
@ -846,17 +929,20 @@ class CoalitionMissionPlanner:
missing_types: Set[FlightType] = set()
escorts = []
for proposed_flight in mission.flights:
if not self.faction_can_plan(proposed_flight.task):
# This faction can never plan this mission type because they do not have
# compatible aircraft. Skip fulfillment so that we don't place the
# purchase request.
if not self.air_wing_can_plan(proposed_flight.task):
# This air wing can never plan this mission type because they do not
# have compatible aircraft or squadrons. Skip fulfillment so that we
# don't place the purchase request.
continue
if proposed_flight.escort_type is not None:
# Escorts are planned after the primary elements of the package.
# If the package does not need escorts they may be pruned.
escorts.append(proposed_flight)
continue
self.plan_flight(mission, proposed_flight, builder, missing_types, reserves)
with tracer.trace("Flight planning"):
self.plan_flight(
mission, proposed_flight, builder, missing_types, reserves
)
if missing_types:
self.scrub_mission_missing_aircraft(
@ -880,7 +966,8 @@ class CoalitionMissionPlanner:
self.game, builder.package, self.is_player
)
for flight in builder.package.flights:
flight_plan_builder.populate_flight_plan(flight)
with tracer.trace("Flight plan population"):
flight_plan_builder.populate_flight_plan(flight)
needed_escorts = self.check_needed_escorts(builder)
for escort in escorts:
@ -888,7 +975,8 @@ class CoalitionMissionPlanner:
# impossible.
assert escort.escort_type is not None
if needed_escorts[escort.escort_type]:
self.plan_flight(mission, escort, builder, missing_types, reserves)
with tracer.trace("Flight planning"):
self.plan_flight(mission, escort, builder, missing_types, reserves)
# Check again for unavailable aircraft. If the escort was required and
# none were found, scrub the mission.
@ -908,7 +996,13 @@ class CoalitionMissionPlanner:
# Add flight plans for escorts.
for flight in package.flights:
if not flight.flight_plan.waypoints:
flight_plan_builder.populate_flight_plan(flight)
with tracer.trace("Flight plan population"):
flight_plan_builder.populate_flight_plan(flight)
if package.has_players and self.game.settings.auto_ato_player_missions_asap:
package.auto_asap = True
package.set_tot_asap()
self.ato.add_package(package)
def stagger_missions(self) -> None:
@ -956,6 +1050,8 @@ class CoalitionMissionPlanner:
logging.error(f"Could not determine mission end time for {package}")
continue
previous_cap_end_time[package.target] = departure_time
elif package.auto_asap:
package.set_tot_asap()
else:
# But other packages should be spread out a bit. Note that take
# times are delayed, but all aircraft will become active at

View File

@ -230,7 +230,7 @@ CAS_CAPABLE = [
]
# Aircraft used for SEAD tasks. Must be capable of the SEAD DCS task.
# Aircraft used for SEAD and SEAD Escort tasks. Must be capable of the CAS DCS task.
SEAD_CAPABLE = [
JF_17,
F_16C_50,
@ -240,6 +240,8 @@ SEAD_CAPABLE = [
Su_25TM,
F_4E,
A_4E_C,
F_14B,
F_14A_135_GR,
AV8BNA,
Su_24M,
Su_17M4,
@ -394,7 +396,7 @@ AEWC_CAPABLE = [
def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]:
cap_missions = (FlightType.BARCAP, FlightType.TARCAP)
cap_missions = (FlightType.BARCAP, FlightType.TARCAP, FlightType.SWEEP)
if task in cap_missions:
return CAP_CAPABLE
elif task == FlightType.ANTISHIP:
@ -405,6 +407,8 @@ def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]:
return CAS_CAPABLE
elif task == FlightType.SEAD:
return SEAD_CAPABLE
elif task == FlightType.SEAD_ESCORT:
return SEAD_CAPABLE
elif task == FlightType.DEAD:
return DEAD_CAPABLE
elif task == FlightType.OCA_AIRCRAFT:
@ -422,3 +426,11 @@ def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]:
else:
logging.error(f"Unplannable flight type: {task}")
return []
def tasks_for_aircraft(aircraft: Type[FlyingType]) -> list[FlightType]:
tasks = []
for task in FlightType:
if aircraft in aircraft_for_task(task):
tasks.append(task)
return tasks

View File

@ -10,6 +10,7 @@ from dcs.unit import Unit
from dcs.unittype import FlyingType
from game import db
from game.squadrons import Pilot, Squadron
from game.theater.controlpoint import ControlPoint, MissionTarget
from game.utils import Distance, meters
from gen.flights.loadouts import Loadout
@ -28,6 +29,27 @@ class FlightType(Enum):
These values are persisted to the save game as well since they are a part of
each flight and thus a part of the ATO, so changing these values will break
save compat.
When adding new mission types to this list, you will also need to update:
* flightplan.py: Add waypoint population in generate_flight_plan. Add a new flight
plan type if necessary, though most are a subclass of StrikeFlightPlan.
* aircraft.py: Add a configuration method and call it in setup_flight_group. This is
responsible for configuring waypoint 0 actions like setting ROE, threat reaction,
and mission abort parameters (winchester, bingo, etc).
* Implementations of MissionTarget.mission_types: A mission type can only be planned
against compatible targets. The mission_types method of each target class defines
which missions may target it.
* ai_flight_planner_db.py: Add the new mission type to aircraft_for_task that
returns the list of compatible aircraft in order of preference.
You may also need to update:
* flight.py: Add a new waypoint type if necessary. Most mission types will need
these, as aircraft.py uses the ingress point type to specialize AI tasks, and non-
strike-like missions will need more specialized control.
* ai_flight_planner.py: Use the new mission type in propose_missions so the AI will
plan the new mission type.
"""
TARCAP = "TARCAP"
@ -45,12 +67,34 @@ class FlightType(Enum):
OCA_AIRCRAFT = "OCA/Aircraft"
AEWC = "AEW&C"
TRANSPORT = "Transport"
SEAD_ESCORT = "SEAD Escort"
def __str__(self) -> str:
return self.value
@classmethod
def from_name(cls, name: str) -> FlightType:
for entry in cls:
if name == entry.value:
return entry
raise KeyError(f"No FlightType with name {name}")
class FlightWaypointType(Enum):
"""Enumeration of waypoint types.
The value of the enum has no meaning but should remain stable to prevent breaking
save game compatibility.
When adding a new waypoint type, you will also need to update:
* waypointbuilder.py: Add a builder to simplify construction of the new waypoint
type unless the new waypoint type will be a parameter to an existing builder
method (such as how escort ingress waypoints work).
* aircraft.py: Associate AI actions with the new waypoint type by subclassing
PydcsWaypointBuilder and using it in PydcsWaypointBuilder.for_waypoint.
"""
TAKEOFF = 0 # Take off point
ASCEND_POINT = 1 # Ascension point after take off
PATROL = 2 # Patrol point
@ -65,7 +109,7 @@ class FlightWaypointType(Enum):
LANDING_POINT = 11 # Should land there
TARGET_POINT = 12 # A target building or static object, position
TARGET_GROUP_LOC = 13 # A target group approximate location
TARGET_SHIP = 14 # A target ship known location
TARGET_SHIP = 14 # Unused.
CUSTOM = 15 # User waypoint (no specific behaviour)
JOIN = 16
SPLIT = 17
@ -163,7 +207,7 @@ class Flight:
self,
package: Package,
country: str,
unit_type: Type[FlyingType],
squadron: Squadron,
count: int,
flight_type: FlightType,
start_type: str,
@ -175,8 +219,8 @@ class Flight:
) -> None:
self.package = package
self.country = country
self.unit_type = unit_type
self.count = count
self.squadron = squadron
self.pilots = [squadron.claim_available_pilot() for _ in range(count)]
self.departure = departure
self.arrival = arrival
self.divert = divert
@ -186,7 +230,6 @@ class Flight:
self.loadout = Loadout.default_for(self)
self.start_type = start_type
self.use_custom_loadout = False
self.client_count = 0
self.custom_name = custom_name
# Only used by transport missions.
@ -201,6 +244,18 @@ class Flight:
package=package, flight=self, custom_waypoints=[]
)
@property
def count(self) -> int:
return len(self.pilots)
@property
def client_count(self) -> int:
return len([p for p in self.pilots if p is not None and p.player])
@property
def unit_type(self) -> Type[FlyingType]:
return self.squadron.aircraft
@property
def from_cp(self) -> ControlPoint:
return self.departure
@ -209,6 +264,34 @@ class Flight:
def points(self) -> List[FlightWaypoint]:
return self.flight_plan.waypoints[1:]
def resize(self, new_size: int) -> None:
if self.count > new_size:
self.squadron.return_pilots(
p for p in self.pilots[new_size:] if p is not None
)
self.pilots = self.pilots[:new_size]
return
self.pilots.extend(
[
self.squadron.claim_available_pilot()
for _ in range(new_size - self.count)
]
)
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
if pilot is not None:
self.squadron.claim_pilot(pilot)
if (current_pilot := self.pilots[index]) is not None:
self.squadron.return_pilot(current_pilot)
self.pilots[index] = pilot
@property
def missing_pilots(self) -> int:
return len([p for p in self.pilots if p is None])
def clear_roster(self) -> None:
self.squadron.return_pilots([p for p in self.pilots if p is not None])
def __repr__(self):
name = db.unit_type_name(self.unit_type)
if self.custom_name:

View File

@ -10,18 +10,18 @@ from __future__ import annotations
import logging
import math
import random
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import timedelta
from functools import cached_property
from typing import Iterator, List, Optional, Set, TYPE_CHECKING, Tuple
from dcs.planes import E_3A, E_2C, A_50, KJ_2000
from dcs.mapping import Point
from dcs.planes import E_3A, E_2C, A_50, KJ_2000
from dcs.unit import Unit
from shapely.geometry import Point as ShapelyPoint
from game.data.doctrine import Doctrine
from game.squadrons import Pilot
from game.theater import (
Airfield,
ControlPoint,
@ -125,6 +125,10 @@ class FlightPlan:
"""
raise NotImplementedError
@property
def tot(self) -> timedelta:
return self.package.time_over_target + self.tot_offset
@cached_property
def bingo_fuel(self) -> int:
"""Bingo fuel value for the FlightPlan"""
@ -198,15 +202,28 @@ class FlightPlan:
def dismiss_escort_at(self) -> Optional[FlightWaypoint]:
return None
def escorted_waypoints(self) -> Iterator[FlightWaypoint]:
begin = self.request_escort_at()
end = self.dismiss_escort_at()
if begin is None or end is None:
return
escorting = False
for waypoint in self.waypoints:
if waypoint == begin:
escorting = True
if escorting:
yield waypoint
if waypoint == end:
return
def takeoff_time(self) -> Optional[timedelta]:
tot_waypoint = self.tot_waypoint
if tot_waypoint is None:
return None
time = self.tot_for_waypoint(tot_waypoint)
time = self.tot
if time is None:
return None
time += self.tot_offset
return time - self._travel_time_to_waypoint(tot_waypoint)
def startup_time(self) -> Optional[timedelta]:
@ -243,7 +260,7 @@ class FlightPlan:
if self.flight.from_cp.is_fleet:
return timedelta(minutes=2)
else:
return timedelta(minutes=5)
return timedelta(minutes=8)
@property
def mission_departure_time(self) -> timedelta:
@ -506,7 +523,7 @@ class TarCapFlightPlan(PatrollingFlightPlan):
start = self.package.escort_start_time
if start is not None:
return start + self.tot_offset
return super().patrol_start_time + self.tot_offset
return self.tot
@property
def patrol_end_time(self) -> timedelta:
@ -530,6 +547,7 @@ class StrikeFlightPlan(FormationFlightPlan):
land: FlightWaypoint
divert: Optional[FlightWaypoint]
bullseye: FlightWaypoint
lead_time: timedelta = field(default_factory=timedelta)
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield self.takeoff
@ -568,6 +586,13 @@ class StrikeFlightPlan(FormationFlightPlan):
def tot_waypoint(self) -> FlightWaypoint:
return self.targets[0]
@property
def tot_offset(self) -> timedelta:
try:
return -self.lead_time
except AttributeError:
return timedelta()
@property
def target_area_waypoint(self) -> FlightWaypoint:
return FlightWaypoint(
@ -600,10 +625,6 @@ class StrikeFlightPlan(FormationFlightPlan):
)
return total
@property
def mission_speed(self) -> Speed:
return GroundSpeed.for_flight(self.flight, self.ingress.alt)
@property
def join_time(self) -> timedelta:
travel_time = self.travel_time_between_waypoints(self.join, self.ingress)
@ -616,7 +637,7 @@ class StrikeFlightPlan(FormationFlightPlan):
@property
def ingress_time(self) -> timedelta:
tot = self.package.time_over_target
tot = self.tot
travel_time = self.travel_time_between_waypoints(
self.ingress, self.target_area_waypoint
)
@ -624,7 +645,7 @@ class StrikeFlightPlan(FormationFlightPlan):
@property
def egress_time(self) -> timedelta:
tot = self.package.time_over_target
tot = self.tot
travel_time = self.travel_time_between_waypoints(
self.target_area_waypoint, self.egress
)
@ -636,7 +657,7 @@ class StrikeFlightPlan(FormationFlightPlan):
elif waypoint == self.egress:
return self.egress_time
elif waypoint in self.targets:
return self.package.time_over_target
return self.tot
return super().tot_for_waypoint(waypoint)
@ -681,7 +702,7 @@ class SweepFlightPlan(LoiterFlightPlan):
@property
def sweep_end_time(self) -> timedelta:
return self.package.time_over_target + self.tot_offset
return self.tot
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]:
if waypoint == self.sweep_start:
@ -837,11 +858,7 @@ class FlightPlanBuilder:
self.game = game
self.package = package
self.is_player = is_player
if is_player:
faction = self.game.player_faction
else:
faction = self.game.enemy_faction
self.doctrine: Doctrine = faction.doctrine
self.doctrine: Doctrine = self.game.faction_for(self.is_player).doctrine
self.threat_zones = self.game.threat_zone_for(not self.is_player)
def populate_flight_plan(
@ -853,12 +870,12 @@ class FlightPlanBuilder:
"""Creates a default flight plan for the given mission."""
if flight not in self.package.flights:
raise RuntimeError("Flight must be a part of the package")
if self.package.waypoints is None:
self.regenerate_package_waypoints()
from game.navmesh import NavMeshError
try:
if self.package.waypoints is None:
self.regenerate_package_waypoints()
flight.flight_plan = self.generate_flight_plan(flight, custom_targets)
except NavMeshError as ex:
color = "blue" if self.is_player else "red"
@ -890,6 +907,8 @@ class FlightPlanBuilder:
return self.generate_runway_attack(flight)
elif task == FlightType.SEAD:
return self.generate_sead(flight, custom_targets)
elif task == FlightType.SEAD_ESCORT:
return self.generate_escort(flight)
elif task == FlightType.STRIKE:
return self.generate_strike(flight)
elif task == FlightType.SWEEP:
@ -1501,7 +1520,11 @@ class FlightPlanBuilder:
targets.append(StrikeTarget(location.name, target))
return self.strike_flightplan(
flight, location, FlightWaypointType.INGRESS_SEAD, targets
flight,
location,
FlightWaypointType.INGRESS_SEAD,
targets,
lead_time=timedelta(minutes=1),
)
def generate_escort(self, flight: Flight) -> StrikeFlightPlan:
@ -1679,6 +1702,7 @@ class FlightPlanBuilder:
location: MissionTarget,
ingress_type: FlightWaypointType,
targets: Optional[List[StrikeTarget]] = None,
lead_time: timedelta = timedelta(),
) -> StrikeFlightPlan:
assert self.package.waypoints is not None
builder = WaypointBuilder(flight, self.game, self.is_player, targets)
@ -1718,6 +1742,7 @@ class FlightPlanBuilder:
land=builder.land(flight.arrival),
divert=builder.divert(flight.divert),
bullseye=builder.bullseye(),
lead_time=lead_time,
)
def _retreating_rendezvous_point(self, attack_transition: Point) -> Point:

View File

@ -62,7 +62,7 @@ class Loadout:
# "tasks": List (as a dict) of task IDs the payload is used by.
# }
payloads = flight.unit_type.load_payloads()
for payload in payloads["payloads"].values():
for payload in payloads.values():
name = payload["name"]
pylons = payload["pylons"]
yield Loadout(
@ -90,22 +90,33 @@ class Loadout:
# etc.
loadout_names = {t: [f"Liberation {t.value}"] for t in FlightType}
legacy_names = {
FlightType.TARCAP: ("CAP HEAVY", "CAP"),
FlightType.BARCAP: ("CAP HEAVY", "CAP"),
FlightType.TARCAP: ("CAP HEAVY", "CAP", "Liberation BARCAP"),
FlightType.BARCAP: ("CAP HEAVY", "CAP", "Liberation TARCAP"),
FlightType.CAS: ("CAS MAVERICK F", "CAS"),
FlightType.INTERCEPTION: ("CAP HEAVY", "CAP"),
FlightType.STRIKE: ("STRIKE",),
FlightType.ANTISHIP: ("ANTISHIP",),
FlightType.SEAD: ("SEAD",),
FlightType.DEAD: ("SEAD",),
FlightType.ESCORT: ("CAP HEAVY", "CAP"),
FlightType.BAI: ("BAI", "CAS MAVERICK F", "CAS"),
FlightType.SWEEP: ("CAP HEAVY", "CAP"),
FlightType.OCA_RUNWAY: ("RUNWAY_ATTACK", "RUNWAY_STRIKE", "STRIKE"),
FlightType.OCA_AIRCRAFT: ("OCA", "CAS MAVERICK F", "CAS"),
FlightType.BAI: ("BAI",),
FlightType.OCA_RUNWAY: ("RUNWAY_ATTACK", "RUNWAY_STRIKE"),
FlightType.OCA_AIRCRAFT: ("OCA",),
}
for flight_type, names in legacy_names.items():
loadout_names[flight_type].extend(names)
# A SEAD escort typically does not need a different loadout than a regular
# SEAD flight, so fall back to SEAD if needed.
loadout_names[FlightType.SEAD_ESCORT].extend(loadout_names[FlightType.SEAD])
# Sweep and escort can fall back to TARCAP.
loadout_names[FlightType.ESCORT].extend(loadout_names[FlightType.TARCAP])
loadout_names[FlightType.SWEEP].extend(loadout_names[FlightType.TARCAP])
# Intercept can fall back to BARCAP.
loadout_names[FlightType.INTERCEPTION].extend(loadout_names[FlightType.BARCAP])
# OCA/Aircraft falls back to BAI, which falls back to CAS.
loadout_names[FlightType.BAI].extend(loadout_names[FlightType.CAS])
loadout_names[FlightType.OCA_AIRCRAFT].extend(loadout_names[FlightType.BAI])
# DEAD also falls back to BAI.
loadout_names[FlightType.DEAD].extend(loadout_names[FlightType.BAI])
# OCA/Runway falls back to Strike
loadout_names[FlightType.OCA_RUNWAY].extend(loadout_names[FlightType.STRIKE])
yield from loadout_names[flight.flight_type]
@classmethod

View File

@ -36,23 +36,23 @@ class GroundSpeed:
# DCS's max speed is in kph at 0 MSL.
max_speed = kph(flight.unit_type.max_speed)
if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL:
# Aircraft is supersonic. Limit to mach 0.8 to conserve fuel and
# Aircraft is supersonic. Limit to mach 0.85 to conserve fuel and
# account for heavily loaded jets.
return mach(0.8, altitude)
return mach(0.85, altitude)
# For subsonic aircraft, assume the aircraft can reasonably perform at
# 80% of its maximum, and that it can maintain the same mach at altitude
# as it can at sea level. This probably isn't great assumption, but
# might. be sufficient given the wiggle room. We can come up with
# another heuristic if needed.
cruise_mach = max_speed.mach() * 0.8
cruise_mach = max_speed.mach() * 0.85
return mach(cruise_mach, altitude)
class TravelTime:
@staticmethod
def between_points(a: Point, b: Point, speed: Speed) -> timedelta:
error_factor = 1.1
error_factor = 1.05
distance = meters(a.distance_to_point(b))
return timedelta(hours=distance.nautical_miles / speed.knots * error_factor)

View File

@ -423,7 +423,10 @@ class WaypointBuilder:
return self.sweep_start(start, altitude), self.sweep_end(end, altitude)
def escort(
self, ingress: Point, target: MissionTarget, egress: Point
self,
ingress: Point,
target: MissionTarget,
egress: Point,
) -> Tuple[FlightWaypoint, FlightWaypoint, FlightWaypoint]:
"""Creates the waypoints needed to escort the package.

View File

@ -80,6 +80,10 @@ class GroundPlanner:
def plan_groundwar(self):
ground_unit_limit = self.cp.frontline_unit_count_limit
remaining_available_frontline_units = ground_unit_limit
if hasattr(self.cp, "stance"):
group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[self.cp.stance]
else:
@ -118,6 +122,12 @@ class GroundPlanner:
continue
available = self.cp.base.armor[key]
if available > remaining_available_frontline_units:
available = remaining_available_frontline_units
remaining_available_frontline_units -= available
while available > 0:
if role == CombatGroupRole.SHORAD:
@ -144,6 +154,9 @@ class GroundPlanner:
group.units.append(key)
collection.append(group)
if remaining_available_frontline_units == 0:
break
print("------------------")
print("Ground Planner : ")
print(self.cp.name)

View File

@ -36,7 +36,7 @@ from dcs.unittype import FlyingType
from tabulate import tabulate
from game.data.alic import AlicCodes
from game.db import find_unittype, unit_type_from_name
from game.db import unit_type_from_name
from game.theater import ConflictTheater, TheaterGroundObject, LatLon
from game.theater.bullseye import Bullseye
from game.utils import meters
@ -298,9 +298,7 @@ class BriefingPage(KneeboardPage):
headers=["#", "Action", "Alt", "Dist", "GSPD", "Time", "Departure"],
)
writer.text(
f"Bullseye: {self.format_ll(self.bullseye.to_lat_lon(self.theater))}"
)
writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}")
writer.table(
[
@ -507,7 +505,7 @@ class SeadTaskPage(KneeboardPage):
ll = self.theater.point_to_ll(unit.position)
unit_type = unit_type_from_name(unit.type)
name = unit.name if unit_type is None else unit_type.name
return [name, self.alic_for(unit), self.format_ll(ll)]
return [name, self.alic_for(unit), ll.format_dms(include_decimal_seconds=True)]
class StrikeTaskPage(KneeboardPage):
@ -546,7 +544,11 @@ class StrikeTaskPage(KneeboardPage):
def target_info_row(self, target: NumberedWaypoint) -> List[str]:
ll = self.theater.point_to_ll(target.waypoint.position)
return [str(target.number), target.waypoint.pretty_name, self.format_ll(ll)]
return [
str(target.number),
target.waypoint.pretty_name,
ll.format_dms(include_decimal_seconds=True),
]
class KneeboardGenerator(MissionInfoGenerator):

View File

@ -39,7 +39,7 @@ ALPHA_MILITARY = [
"Zero",
]
ANIMALS = [
ANIMALS: tuple[str, ...] = (
"SHARK",
"TORTOISE",
"BAT",
@ -243,7 +243,7 @@ ANIMALS = [
"CANARY",
"WOODCHUCK",
"ANACONDA",
]
)
class NameGenerator:
@ -253,7 +253,7 @@ class NameGenerator:
convoy_number = 0
cargo_ship_number = 0
ANIMALS = ANIMALS
animals: list[str] = list(ANIMALS)
existing_alphas: List[str] = []
@classmethod
@ -262,7 +262,7 @@ class NameGenerator:
cls.infantry_number = 0
cls.convoy_number = 0
cls.cargo_ship_number = 0
cls.ANIMALS = ANIMALS
cls.animals = list(ANIMALS)
cls.existing_alphas = []
@classmethod
@ -345,30 +345,25 @@ class NameGenerator:
@classmethod
def random_objective_name(cls):
if len(cls.ANIMALS) == 0:
for i in range(10):
new_name_generated = True
alpha_mil_name = (
random.choice(ALPHA_MILITARY).upper()
+ "#"
+ str(random.randint(0, 100))
)
for existing_name in cls.existing_alphas:
if existing_name == alpha_mil_name:
new_name_generated = False
if new_name_generated:
cls.existing_alphas.append(alpha_mil_name)
return alpha_mil_name
# At this point, give up trying - something has gone wrong and we haven't been able to make a new name in 10 tries.
# We'll just make a longer name using the current unix epoch in nanoseconds. That should be unique... right?
last_chance_name = alpha_mil_name + str(time.time_ns())
cls.existing_alphas.append(last_chance_name)
return last_chance_name
else:
animal = random.choice(cls.ANIMALS)
cls.ANIMALS.remove(animal)
if cls.animals:
animal = random.choice(cls.animals)
cls.animals.remove(animal)
return animal
for _ in range(10):
alpha = random.choice(ALPHA_MILITARY).upper()
number = str(random.randint(0, 100))
alpha_mil_name = f"{alpha} #{number:02}"
if alpha_mil_name not in cls.existing_alphas:
cls.existing_alphas.append(alpha_mil_name)
return alpha_mil_name
# At this point, give up trying - something has gone wrong and we haven't been
# able to make a new name in 10 tries. We'll just make a longer name using the
# current unix epoch in nanoseconds. That should be unique... right?
last_chance_name = alpha_mil_name + str(time.time_ns())
cls.existing_alphas.append(last_chance_name)
return last_chance_name
namegen = NameGenerator

View File

@ -96,32 +96,15 @@ class TriggersGenerator:
"""
for coalition_name, coalition in self.mission.coalition.items():
if coalition_name == player_coalition:
skill_level = (
self.game.settings.player_skill,
self.game.settings.player_skill,
)
skill_level = Skill(self.game.settings.player_skill)
elif coalition_name == enemy_coalition:
skill_level = (
self.game.settings.enemy_skill,
self.game.settings.enemy_vehicle_skill,
)
skill_level = Skill(self.game.settings.enemy_vehicle_skill)
else:
continue
for country in coalition.countries.values():
flying_groups = (
country.plane_group + country.helicopter_group
) # type: FlyingGroup
for flying_group in flying_groups:
for plane_unit in flying_group.units:
if (
plane_unit.skill != Skill.Client
and plane_unit.skill != Skill.Player
):
plane_unit.skill = Skill(skill_level[0])
for vehicle_group in country.vehicle_group:
vehicle_group.set_skill(Skill(skill_level[1]))
vehicle_group.set_skill(skill_level)
def _gen_markers(self):
"""

View File

@ -5,6 +5,9 @@ namespace_packages = True
follow_imports=silent
ignore_missing_imports = True
[mypy-faker.*]
ignore_missing_imports = True
[mypy-PIL.*]
ignore_missing_imports = True

2
pydcs

@ -1 +1 @@
Subproject commit 4972988c978f2057e7aa06919c4de71ee9a06ea5
Subproject commit 53632aa7a8749c67eba371aaea95bfef73f43cdc

View File

@ -4,6 +4,8 @@ from dcs import task
from dcs.planes import PlaneType
from dcs.weapons_data import Weapons
from pydcs_extensions.weapon_injector import inject_weapons
class WeaponsA4EC:
AN_M57__2__TER_ = {
@ -432,6 +434,9 @@ class WeaponsA4EC:
}
inject_weapons(WeaponsA4EC)
class A_4E_C(PlaneType):
id = "A-4E-C"
flyable = True

View File

@ -4,12 +4,17 @@ from dcs import task
from dcs.planes import PlaneType
from dcs.weapons_data import Weapons
from pydcs_extensions.weapon_injector import inject_weapons
class F22AWeapons:
AIM_9XX = {"clsid": "{AIM-9XX}", "name": "AIM-9XX", "weight": 85}
AIM_120D = {"clsid": "{AIM-120D}", "name": "AIM-120D", "weight": 152}
inject_weapons(F22AWeapons)
class F_22A(PlaneType):
id = "F-22A"
flyable = True

View File

@ -4,6 +4,8 @@ from dcs import task
from dcs.planes import PlaneType
from dcs.weapons_data import Weapons
from pydcs_extensions.weapon_injector import inject_weapons
class HerculesWeapons:
GAU_23A_Chain_Gun__30mm_ = {
@ -679,6 +681,9 @@ class HerculesWeapons:
}
inject_weapons(HerculesWeapons)
class Hercules(PlaneType):
id = "Hercules"
flyable = True

View File

@ -4,6 +4,8 @@ from dcs import task
from dcs.planes import PlaneType
from dcs.weapons_data import Weapons
from pydcs_extensions.weapon_injector import inject_weapons
class MB_339PAN_Weapons:
ARF8M3_TP = {"clsid": "{ARF8M3_TP}", "name": "ARF8M3 TP", "weight": None}
@ -107,6 +109,9 @@ class MB_339PAN_Weapons:
}
inject_weapons(MB_339PAN_Weapons)
class MB_339PAN(PlaneType):
id = "MB-339PAN"
flyable = True

View File

@ -4,6 +4,8 @@ from dcs import task
from dcs.planes import PlaneType
from dcs.weapons_data import Weapons
from pydcs_extensions.weapon_injector import inject_weapons
class Su57Weapons:
Kh_59MK2 = {"clsid": "{KH_59MK2}", "name": "Kh-59MK2", "weight": None}
@ -18,6 +20,9 @@ class Su57Weapons:
}
inject_weapons(Su57Weapons)
class Su_57(PlaneType):
id = "Su-57"
flyable = True

View File

@ -0,0 +1,17 @@
from typing import List, Any
from dcs.weapons_data import Weapons, weapon_ids
def inject_weapons(weapon_class: Any) -> None:
"""
Inject custom weapons from mods into pydcs weapons databases via introspection
:param weapon_class: The custom weapons class containing dictionaries with weapon info
:return: None
"""
for key, value in weapon_class.__dict__.items():
if key.startswith("__"):
continue
if isinstance(value, dict) and value.get("clsid"):
setattr(Weapons, key, value)
weapon_ids[value["clsid"]] = value

View File

@ -1,13 +0,0 @@
from contextlib import contextmanager
from typing import ContextManager
from PySide2.QtGui import QPainter
@contextmanager
def painter_context(painter: QPainter) -> ContextManager[None]:
try:
painter.save()
yield
finally:
painter.restore()

122
qt_ui/delegates.py Normal file
View File

@ -0,0 +1,122 @@
from contextlib import contextmanager
from typing import ContextManager, Optional
from PySide2.QtCore import QModelIndex, Qt, QSize
from PySide2.QtGui import QPainter, QFont, QFontMetrics, QIcon
from PySide2.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, QStyle
@contextmanager
def painter_context(painter: QPainter) -> ContextManager[None]:
try:
painter.save()
yield
finally:
painter.restore()
class TwoColumnRowDelegate(QStyledItemDelegate):
HMARGIN = 4
VMARGIN = 4
def __init__(self, rows: int, columns: int, font_size: int = 12) -> None:
if columns not in (1, 2):
raise ValueError(f"Only one or two columns may be used, not {columns}")
super().__init__()
self.font_size = font_size
self.rows = rows
self.columns = columns
def get_font(self, option: QStyleOptionViewItem) -> QFont:
font = QFont(option.font)
font.setPointSize(self.font_size)
return font
def text_for(self, index: QModelIndex, row: int, column: int) -> str:
raise NotImplementedError
def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None:
# Draw the list item with all the default selection styling, but with an
# invalid index so text formatting is left to us.
super().paint(painter, option, QModelIndex())
rect = option.rect.adjusted(
self.HMARGIN, self.VMARGIN, -self.HMARGIN, -self.VMARGIN
)
with painter_context(painter):
painter.setFont(self.get_font(option))
icon: Optional[QIcon] = index.data(Qt.DecorationRole)
if icon is not None:
icon.paint(
painter,
rect,
Qt.AlignLeft | Qt.AlignVCenter,
self.icon_mode(option),
self.icon_state(option),
)
rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, 0, 0, 0)
row_height = rect.height() / self.rows
for row in range(self.rows):
y = row_height * row
location = rect.adjusted(0, y, 0, y)
painter.drawText(location, Qt.AlignLeft, self.text_for(index, row, 0))
if self.columns == 2:
painter.drawText(
location, Qt.AlignRight, self.text_for(index, row, 1)
)
@staticmethod
def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode:
if not (option.state & QStyle.State_Enabled):
return QIcon.Disabled
elif option.state & QStyle.State_Selected:
return QIcon.Selected
elif option.state & QStyle.State_Active:
return QIcon.Active
return QIcon.Normal
@staticmethod
def icon_state(option: QStyleOptionViewItem) -> QIcon.State:
return QIcon.On if option.state & QStyle.State_Open else QIcon.Off
@staticmethod
def icon_size(option: QStyleOptionViewItem) -> QSize:
icon_size: Optional[QSize] = option.decorationSize
if icon_size is None:
return QSize(0, 0)
else:
return icon_size
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
metrics = QFontMetrics(self.get_font(option))
widths = []
heights = []
icon_size = self.icon_size(option)
icon_width = 0
icon_height = 0
if icon_size.width():
icon_width = icon_size.width() + self.HMARGIN
if icon_size.height():
icon_height = icon_size.height() + self.VMARGIN
for row in range(self.rows):
width = 0
height = 0
for column in range(self.columns):
size = metrics.size(0, self.text_for(index, row, column))
width += size.width()
height = max(height, size.height())
widths.append(width)
heights.append(height)
return QSize(
icon_width + max(widths) + 2 * self.HMARGIN,
max(icon_height, sum(heights)) + 2 * self.VMARGIN,
)

View File

@ -6,10 +6,10 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
import dcs
from PySide2 import QtWidgets
from PySide2.QtGui import QPixmap
from PySide2.QtWidgets import QApplication, QSplashScreen
from dcs.payloads import PayloadDirectories
from dcs.weapons_data import weapon_ids
from game import Game, VERSION, persistency
@ -35,6 +35,27 @@ from qt_ui.windows.preferences.QLiberationFirstStartWindow import (
QLiberationFirstStartWindow,
)
THIS_DIR = Path(__file__).parent
def inject_custom_payloads(user_path: Path) -> None:
dev_payloads = THIS_DIR.parent / "resources/customized_payloads"
# The packaged release rearranges the file locations, so the release has the
# customized payloads in a different location.
release_payloads = THIS_DIR / "resources/customized_payloads"
if dev_payloads.exists():
payloads = dev_payloads
elif release_payloads.exists():
payloads = release_payloads
else:
raise RuntimeError(
f"Could not find customized payloads at {release_payloads} or "
f"{dev_payloads}. Aircraft will have no payloads."
)
# We configure these as fallbacks so that the user's payloads override ours.
PayloadDirectories.set_fallback(payloads)
PayloadDirectories.set_preferred(user_path / "MissionEditor" / "UnitPayloads")
def run_ui(game: Optional[Game], new_map: bool) -> None:
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens
@ -48,22 +69,6 @@ def run_ui(game: Optional[Game], new_map: bool) -> None:
logging.info("Loading stylesheet: %s", liberation_theme.get_theme_css_file())
app.setStyleSheet(stylesheet.read())
# Inject custom payload in pydcs framework
custom_payloads = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
"..\\resources\\customized_payloads",
)
if os.path.exists(custom_payloads):
dcs.unittype.FlyingType.payload_dirs.append(custom_payloads)
else:
# For release version the path is different.
custom_payloads = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
"resources\\customized_payloads",
)
if os.path.exists(custom_payloads):
dcs.unittype.FlyingType.payload_dirs.append(custom_payloads)
first_start = liberation_install.init()
if first_start:
window = QLiberationFirstStartWindow()
@ -76,6 +81,8 @@ def run_ui(game: Optional[Game], new_map: bool) -> None:
)
)
inject_custom_payloads(Path(persistency.base_path()))
# Splash screen setup
pixmap = QPixmap("./resources/ui/splash_screen.png")
splash = QSplashScreen(pixmap)
@ -189,6 +196,15 @@ def create_game(
"Cannot generate campaign without configuring DCS Liberation. Start the UI "
"for the first run configuration."
)
# This needs to run before the pydcs payload cache is created, which happens
# extremely early. It's not a problem that we inject these paths twice because we'll
# get the same answers each time.
#
# Without this, it is not possible to use next turn (or anything that needs to check
# for loadouts) without saving the generated campaign and reloading it the normal
# way.
inject_custom_payloads(Path(persistency.base_path()))
campaign = Campaign.from_json(campaign_path)
generator = GameGenerator(
blue,

View File

@ -14,6 +14,7 @@ from PySide2.QtGui import QIcon
from game import db
from game.game import Game
from game.squadrons import Squadron, Pilot
from game.theater.missiontarget import MissionTarget
from game.transfers import TransferOrder
from gen.ato import AirTaskingOrder, Package
@ -166,6 +167,7 @@ class PackageModel(QAbstractListModel):
if flight.cargo is not None:
flight.cargo.transport = None
self.game_model.game.aircraft_inventory.return_from_flight(flight)
flight.clear_roster()
self.package.remove_flight(flight)
self.endRemoveRows()
self.update_tot()
@ -258,6 +260,7 @@ class AtoModel(QAbstractListModel):
self.ato.remove_package(package)
for flight in package.flights:
self.game.aircraft_inventory.return_from_flight(flight)
flight.clear_roster()
if flight.cargo is not None:
flight.cargo.transport = None
self.endRemoveRows()
@ -366,6 +369,105 @@ class TransferModel(QAbstractListModel):
return self.game_model.game.transfers.transfer_at_index(index.row())
class AirWingModel(QAbstractListModel):
"""The model for an air wing."""
SquadronRole = Qt.UserRole
def __init__(self, game_model: GameModel, player: bool) -> None:
super().__init__()
self.game_model = game_model
self.player = player
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return self.game_model.game.air_wing_for(self.player).size
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
if not index.isValid():
return None
squadron = self.squadron_at_index(index)
if role == Qt.DisplayRole:
return self.text_for_squadron(squadron)
if role == Qt.DecorationRole:
return self.icon_for_squadron(squadron)
elif role == AirWingModel.SquadronRole:
return squadron
return None
@staticmethod
def text_for_squadron(squadron: Squadron) -> str:
"""Returns the text that should be displayed for the squadron."""
return str(squadron)
@staticmethod
def icon_for_squadron(squadron: Squadron) -> Optional[QIcon]:
"""Returns the icon that should be displayed for the squadron."""
name = db.unit_type_name(squadron.aircraft)
if name in AIRCRAFT_ICONS:
return QIcon(AIRCRAFT_ICONS[name])
return None
def squadron_at_index(self, index: QModelIndex) -> Squadron:
"""Returns the squadron located at the given index."""
return self.game_model.game.air_wing_for(self.player).squadron_at_index(
index.row()
)
class SquadronModel(QAbstractListModel):
"""The model for a squadron."""
PilotRole = Qt.UserRole
def __init__(self, squadron: Squadron) -> None:
super().__init__()
self.squadron = squadron
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return self.squadron.size
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
if not index.isValid():
return None
pilot = self.pilot_at_index(index)
if role == Qt.DisplayRole:
return self.text_for_pilot(pilot)
if role == Qt.DecorationRole:
return self.icon_for_pilot(pilot)
elif role == SquadronModel.PilotRole:
return pilot
return None
@staticmethod
def text_for_pilot(pilot: Pilot) -> str:
"""Returns the text that should be displayed for the pilot."""
return pilot.name
@staticmethod
def icon_for_pilot(_pilot: Pilot) -> Optional[QIcon]:
"""Returns the icon that should be displayed for the pilot."""
return None
def pilot_at_index(self, index: QModelIndex) -> Pilot:
"""Returns the pilot located at the given index."""
return self.squadron.pilot_at_index(index.row())
def toggle_ai_state(self, index: QModelIndex) -> None:
pilot = self.pilot_at_index(index)
self.beginResetModel()
pilot.player = not pilot.player
self.endResetModel()
def toggle_leave_state(self, index: QModelIndex) -> None:
pilot = self.pilot_at_index(index)
self.beginResetModel()
if pilot.on_leave:
pilot.return_from_leave()
else:
pilot.send_on_leave()
self.endResetModel()
class GameModel:
"""A model for the Game object.
@ -376,6 +478,7 @@ class GameModel:
def __init__(self, game: Optional[Game]) -> None:
self.game: Optional[Game] = game
self.transfer_model = TransferModel(self)
self.blue_air_wing_model = AirWingModel(self, player=True)
if self.game is None:
self.ato_model = AtoModel(self, AirTaskingOrder())
self.red_ato_model = AtoModel(self, AirTaskingOrder())

View File

@ -21,6 +21,7 @@ from qt_ui.widgets.QConditionsWidget import QConditionsWidget
from qt_ui.widgets.QFactionsInfos import QFactionsInfos
from qt_ui.widgets.QIntelBox import QIntelBox
from qt_ui.widgets.clientslots import MaxPlayerCount
from qt_ui.windows.AirWingDialog import AirWingDialog
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.PendingTransfersDialog import PendingTransfersDialog
from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow
@ -63,6 +64,11 @@ class QTopPanel(QFrame):
self.factionsInfos = QFactionsInfos(self.game)
self.air_wing = QPushButton("Air Wing")
self.air_wing.setDisabled(True)
self.air_wing.setProperty("style", "btn-primary")
self.air_wing.clicked.connect(self.open_air_wing)
self.transfers = QPushButton("Transfers")
self.transfers.setDisabled(True)
self.transfers.setProperty("style", "btn-primary")
@ -84,6 +90,7 @@ class QTopPanel(QFrame):
self.buttonBox = QGroupBox("Misc")
self.buttonBoxLayout = QHBoxLayout()
self.buttonBoxLayout.addWidget(self.air_wing)
self.buttonBoxLayout.addWidget(self.transfers)
self.buttonBoxLayout.addWidget(self.settings)
self.buttonBoxLayout.addWidget(self.statistics)
@ -114,6 +121,7 @@ class QTopPanel(QFrame):
if game is None:
return
self.air_wing.setEnabled(True)
self.transfers.setEnabled(True)
self.settings.setEnabled(True)
self.statistics.setEnabled(True)
@ -130,6 +138,10 @@ class QTopPanel(QFrame):
else:
self.proceedButton.setEnabled(True)
def open_air_wing(self):
self.dialog = AirWingDialog(self.game_model, self.window())
self.dialog.show()
def open_transfers(self):
self.dialog = PendingTransfersDialog(self.game_model)
self.dialog.show()
@ -176,17 +188,18 @@ class QTopPanel(QFrame):
def confirm_no_client_launch(self) -> bool:
result = QMessageBox.question(
self,
"Continue without client slots?",
"Continue without player pilots?",
(
"No client slots have been created for players. Continuing will "
"allow the AI to perform the mission, but players will be unable "
"to participate.<br />"
"No player pilots have been assigned to flights. Continuing will allow "
"the AI to perform the mission, but players will be unable to "
"participate.<br />"
"<br />"
"To add client slots for players, select a package from the "
"Packages panel on the left of the main window, and then a flight "
"from the Flights panel below the Packages panel. The edit button "
"below the Flights panel will allow you to edit the number of "
"client slots in the flight. Each client slot allows one player.<br />"
"To assign player pilots to a flight, select a package from the "
"Packages panel on the left of the main window, and then a flight from "
"the Flights panel below the Packages panel. The edit button below the "
"Flights panel will allow you to assign specific pilots to the flight. "
"If you have no player pilots available, the checkbox next to the "
"name will convert them to a player.<br />"
"<br />Click 'Yes' to continue with an AI only mission"
"<br />Click 'No' if you'd like to make more changes."
),
@ -232,11 +245,44 @@ class QTopPanel(QFrame):
return True
return False
def check_no_missing_pilots(self) -> bool:
missing_pilots = []
for package in self.game.blue_ato.packages:
for flight in package.flights:
if flight.missing_pilots > 0:
missing_pilots.append((package, flight))
if not missing_pilots:
return False
formatted = "<br />".join(
[f"{p.primary_task} {p.target}: {f}" for p, f in missing_pilots]
)
mbox = QMessageBox(
QMessageBox.Critical,
"Flights are missing pilots",
(
"The following flights are missing one or more pilots:<br />"
"<br />"
f"{formatted}<br />"
"<br />"
"You must either assign pilots to those flights or cancel those "
"missions."
),
parent=self,
)
mbox.setEscapeButton(mbox.addButton(QMessageBox.Close))
mbox.exec_()
return True
def launch_mission(self):
"""Finishes planning and waits for mission completion."""
if not self.ato_has_clients() and not self.confirm_no_client_launch():
return
if self.check_no_missing_pilots():
return
negative_starts = self.negative_start_packages()
if negative_starts:
if not self.confirm_negative_start_time(negative_starts):

View File

@ -10,10 +10,6 @@ from PySide2.QtCore import (
)
from PySide2.QtGui import (
QContextMenuEvent,
QFont,
QFontMetrics,
QIcon,
QPainter,
)
from PySide2.QtWidgets import (
QAbstractItemView,
@ -25,9 +21,6 @@ from PySide2.QtWidgets import (
QMenu,
QPushButton,
QSplitter,
QStyle,
QStyleOptionViewItem,
QStyledItemDelegate,
QVBoxLayout,
)
@ -35,111 +28,42 @@ from gen.ato import Package
from gen.flights.flight import Flight
from gen.flights.traveltime import TotEstimator
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from ..delegate_helpers import painter_context
from ..delegates import TwoColumnRowDelegate
from ..models import AtoModel, GameModel, NullListModel, PackageModel
class FlightDelegate(QStyledItemDelegate):
FONT_SIZE = 10
HMARGIN = 4
VMARGIN = 4
class FlightDelegate(TwoColumnRowDelegate):
def __init__(self, package: Package) -> None:
super().__init__()
super().__init__(rows=2, columns=2, font_size=10)
self.package = package
def get_font(self, option: QStyleOptionViewItem) -> QFont:
font = QFont(option.font)
font.setPointSize(self.FONT_SIZE)
return font
@staticmethod
def flight(index: QModelIndex) -> Flight:
return index.data(PackageModel.FlightRole)
def first_row_text(self, index: QModelIndex) -> str:
def text_for(self, index: QModelIndex, row: int, column: int) -> str:
flight = self.flight(index)
estimator = TotEstimator(self.package)
delay = estimator.mission_start_time(flight)
return f"{flight} in {delay}"
def second_row_text(self, index: QModelIndex) -> str:
flight = self.flight(index)
origin = flight.from_cp.name
if flight.arrival != flight.departure:
return f"From {origin} to {flight.arrival.name}"
return f"From {origin}"
def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None:
# Draw the list item with all the default selection styling, but with an
# invalid index so text formatting is left to us.
super().paint(painter, option, QModelIndex())
rect = option.rect.adjusted(
self.HMARGIN, self.VMARGIN, -self.HMARGIN, -self.VMARGIN
)
with painter_context(painter):
painter.setFont(self.get_font(option))
icon: Optional[QIcon] = index.data(Qt.DecorationRole)
if icon is not None:
icon.paint(
painter,
rect,
Qt.AlignLeft | Qt.AlignVCenter,
self.icon_mode(option),
self.icon_state(option),
)
rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, 0, 0, 0)
painter.drawText(rect, Qt.AlignLeft, self.first_row_text(index))
line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2)
painter.drawText(line2, Qt.AlignLeft, self.second_row_text(index))
if (row, column) == (0, 0):
estimator = TotEstimator(self.package)
delay = estimator.mission_start_time(flight)
return f"{flight} in {delay}"
elif (row, column) == (0, 1):
clients = self.num_clients(index)
if clients:
painter.drawText(rect, Qt.AlignRight, f"Player Slots: {clients}")
return f"Player Slots: {clients}" if clients else ""
elif (row, column) == (1, 0):
origin = flight.from_cp.name
if flight.arrival != flight.departure:
return f"From {origin} to {flight.arrival.name}"
return f"From {origin}"
elif (row, column) == (1, 1):
missing_pilots = flight.missing_pilots
return f"Missing pilots: {flight.missing_pilots}" if missing_pilots else ""
return ""
def num_clients(self, index: QModelIndex) -> int:
flight = self.flight(index)
return flight.client_count
@staticmethod
def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode:
if not (option.state & QStyle.State_Enabled):
return QIcon.Disabled
elif option.state & QStyle.State_Selected:
return QIcon.Selected
elif option.state & QStyle.State_Active:
return QIcon.Active
return QIcon.Normal
@staticmethod
def icon_state(option: QStyleOptionViewItem) -> QIcon.State:
return QIcon.On if option.state & QStyle.State_Open else QIcon.Off
@staticmethod
def icon_size(option: QStyleOptionViewItem) -> QSize:
icon_size: Optional[QSize] = option.decorationSize
if icon_size is None:
return QSize(0, 0)
else:
return icon_size
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
left = self.icon_size(option).width() + self.HMARGIN
metrics = QFontMetrics(self.get_font(option))
first = metrics.size(0, self.first_row_text(index))
second = metrics.size(0, self.second_row_text(index))
text_width = max(first.width(), second.width())
return QSize(
left + text_width + 2 * self.HMARGIN,
first.height() + second.height() + 2 * self.VMARGIN,
)
class QFlightList(QListView):
"""List view for displaying the flights of a package."""
@ -310,62 +234,35 @@ class QFlightPanel(QGroupBox):
self.flight_list.delete_flight(index)
class PackageDelegate(QStyledItemDelegate):
FONT_SIZE = 12
HMARGIN = 4
VMARGIN = 4
def get_font(self, option: QStyleOptionViewItem) -> QFont:
font = QFont(option.font)
font.setPointSize(self.FONT_SIZE)
return font
class PackageDelegate(TwoColumnRowDelegate):
def __init__(self) -> None:
super().__init__(rows=2, columns=2)
@staticmethod
def package(index: QModelIndex) -> Package:
return index.data(AtoModel.PackageRole)
def left_text(self, index: QModelIndex) -> str:
def text_for(self, index: QModelIndex, row: int, column: int) -> str:
package = self.package(index)
return f"{package.package_description} {package.target.name}"
def right_text(self, index: QModelIndex) -> str:
package = self.package(index)
return f"TOT T+{package.time_over_target}"
def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None:
# Draw the list item with all the default selection styling, but with an
# invalid index so text formatting is left to us.
super().paint(painter, option, QModelIndex())
rect = option.rect.adjusted(
self.HMARGIN, self.VMARGIN, -self.HMARGIN, -self.VMARGIN
)
with painter_context(painter):
painter.setFont(self.get_font(option))
painter.drawText(rect, Qt.AlignLeft, self.left_text(index))
line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2)
painter.drawText(line2, Qt.AlignLeft, self.right_text(index))
if (row, column) == (0, 0):
return f"{package.package_description} {package.target.name}"
elif (row, column) == (0, 1):
clients = self.num_clients(index)
if clients:
painter.drawText(rect, Qt.AlignRight, f"Player Slots: {clients}")
return f"Player Slots: {clients}" if clients else ""
elif (row, column) == (1, 0):
return f"TOT T+{package.time_over_target}"
elif (row, column) == (1, 1):
unassigned_pilots = self.missing_pilots(index)
return f"Missing pilots: {unassigned_pilots}" if unassigned_pilots else ""
return ""
def num_clients(self, index: QModelIndex) -> int:
package = self.package(index)
return sum(f.client_count for f in package.flights)
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
metrics = QFontMetrics(self.get_font(option))
left = metrics.size(0, self.left_text(index))
right = metrics.size(0, self.right_text(index))
return QSize(
max(left.width(), right.width()) + 2 * self.HMARGIN,
left.height() + right.height() + 2 * self.VMARGIN,
)
def missing_pilots(self, index: QModelIndex) -> int:
package = self.package(index)
return sum(f.missing_pilots for f in package.flights)
class QPackageList(QListView):
@ -376,7 +273,7 @@ class QPackageList(QListView):
self.ato_model = model
self.setModel(model)
self.setItemDelegate(PackageDelegate())
self.setIconSize(QSize(91, 24))
self.setIconSize(QSize(0, 0))
self.setSelectionBehavior(QAbstractItemView.SelectItems)
self.model().rowsInserted.connect(self.on_new_packages)
self.doubleClicked.connect(self.on_double_click)

View File

@ -2,15 +2,12 @@
from typing import Iterable, Type
from PySide2.QtWidgets import QComboBox
from dcs.unittype import FlyingType
from game import db
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.flight import FlightType
import gen.flights.ai_flight_planner_db
from game import Game, db
class QAircraftTypeSelector(QComboBox):
"""Combo box for selecting among the given aircraft types."""
@ -19,77 +16,24 @@ class QAircraftTypeSelector(QComboBox):
self,
aircraft_types: Iterable[Type[FlyingType]],
country: str,
mission_type: str,
mission_type: FlightType,
) -> None:
super().__init__()
self.model().sort(0)
self.setSizeAdjustPolicy(self.AdjustToContents)
self.country = country
self.updateItems(mission_type, aircraft_types)
self.update_items(mission_type, aircraft_types)
def updateItems(self, mission_type: str, aircraft_types):
def update_items(self, mission_type: FlightType, aircraft_types):
current_aircraft = self.currentData()
self.clear()
for aircraft in aircraft_types:
if mission_type in [
FlightType.BARCAP,
FlightType.ESCORT,
FlightType.INTERCEPTION,
FlightType.SWEEP,
FlightType.TARCAP,
]:
if aircraft in gen.flights.ai_flight_planner_db.CAP_CAPABLE:
self.addItem(
f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}",
userData=aircraft,
)
elif mission_type in [
FlightType.CAS,
FlightType.BAI,
FlightType.OCA_AIRCRAFT,
]:
if aircraft in gen.flights.ai_flight_planner_db.CAS_CAPABLE:
self.addItem(
f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}",
userData=aircraft,
)
elif mission_type in [FlightType.SEAD]:
if aircraft in gen.flights.ai_flight_planner_db.SEAD_CAPABLE:
self.addItem(
f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}",
userData=aircraft,
)
elif mission_type in [FlightType.DEAD]:
if aircraft in gen.flights.ai_flight_planner_db.DEAD_CAPABLE:
self.addItem(
f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}",
userData=aircraft,
)
elif mission_type in [FlightType.STRIKE]:
if aircraft in gen.flights.ai_flight_planner_db.STRIKE_CAPABLE:
self.addItem(
f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}",
userData=aircraft,
)
elif mission_type in [FlightType.ANTISHIP]:
if aircraft in gen.flights.ai_flight_planner_db.ANTISHIP_CAPABLE:
self.addItem(
f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}",
userData=aircraft,
)
elif mission_type in [FlightType.OCA_RUNWAY]:
if aircraft in gen.flights.ai_flight_planner_db.RUNWAY_ATTACK_CAPABLE:
self.addItem(
f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}",
userData=aircraft,
)
elif mission_type in [FlightType.AEWC]:
if aircraft in gen.flights.ai_flight_planner_db.AEWC_CAPABLE:
self.addItem(
f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}",
userData=aircraft,
)
if aircraft in aircraft_for_task(mission_type):
self.addItem(
f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}",
userData=aircraft,
)
current_aircraft_index = self.findData(current_aircraft)
if current_aircraft_index != -1:
self.setCurrentIndex(current_aircraft_index)

View File

@ -1,87 +0,0 @@
from PySide2.QtGui import QStandardItem, QStandardItemModel
from game import Game
from game.data.radar_db import UNITS_WITH_RADAR
from gen import db
from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox
class SEADTargetInfo:
def __init__(self):
self.name = ""
self.location = None
self.radars = []
self.threat_range = 0
self.detection_range = 0
class QSEADTargetSelectionComboBox(QFilteredComboBox):
def __init__(self, game: Game, parent=None):
super(QSEADTargetSelectionComboBox, self).__init__(parent)
self.game = game
self.find_possible_sead_targets()
def get_selected_target(self) -> SEADTargetInfo:
n = self.currentText()
for target in self.targets:
if target.name == n:
return target
def find_possible_sead_targets(self):
self.targets = []
i = 0
model = QStandardItemModel()
def add_model_item(i, model, target):
item = QStandardItem(target.name)
model.setItem(i, 0, item)
self.targets.append(target)
return i + 1
for cp in self.game.theater.controlpoints:
if cp.captured:
continue
for g in cp.ground_objects:
radars = []
detection_range = 0
threat_range = 0
if g.dcs_identifier == "AA":
for group in g.groups:
for u in group.units:
utype = db.unit_type_from_name(u.type)
if utype in UNITS_WITH_RADAR:
if (
hasattr(utype, "detection_range")
and utype.detection_range > 1000
):
if utype.detection_range > detection_range:
detection_range = utype.detection_range
radars.append(u)
if hasattr(utype, "threat_range"):
if utype.threat_range > threat_range:
threat_range = utype.threat_range
if len(radars) > 0:
tgt_info = SEADTargetInfo()
tgt_info.name = (
g.obj_name
+ " ["
+ ",".join(
[db.unit_type_from_name(u.type).id for u in radars]
)
+ " ]"
)
if len(tgt_info.name) > 25:
tgt_info.name = (
g.obj_name + " [" + str(len(radars)) + " units]"
)
tgt_info.radars = radars
tgt_info.location = g
tgt_info.threat_range = threat_range
tgt_info.detection_range = detection_range
i = add_model_item(i, model, tgt_info)
self.setModel(model)

View File

@ -40,7 +40,6 @@ from PySide2.QtWidgets import (
)
from dcs import Point
from dcs.mapping import point_from_heading
from dcs.planes import F_16C_50
from dcs.unitgroup import Group
from shapely.geometry import (
LineString,
@ -167,11 +166,6 @@ class LeafletMap(QWebEngineView, LiberationMap):
)
self.setPage(self.page)
self.loadFinished.connect(self.load_finished)
def load_finished(self) -> None:
self.page.runJavaScript(Path("resources/ui/map/map.js").read_text())
def set_game(self, game: Optional[Game]) -> None:
if game is None:
self.map_model.clear()
@ -567,10 +561,17 @@ class QLiberationMap(QGraphicsView, LiberationMap):
origin = self.game.theater.enemy_points()[0]
package = Package(target)
for squadron_list in self.game.air_wing_for(player=True).squadrons.values():
squadron = squadron_list[0]
break
else:
logging.error("Player has no squadrons?")
return
flight = Flight(
package,
self.game.player_country if player else self.game.enemy_country,
F_16C_50,
self.game.country_for(player),
squadron,
2,
task,
start_type="Warm",

View File

@ -2,16 +2,17 @@ from __future__ import annotations
import logging
from datetime import timedelta
from typing import List, Optional, Tuple
from typing import List, Optional, Tuple, Union
from PySide2.QtCore import Property, QObject, Signal, Slot
from dcs import Point
from dcs.unit import Unit
from dcs.vehicles import vehicle_map
from shapely.geometry import LineString, Point as ShapelyPoint, Polygon
from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPolygon
from game import Game, db
from game.factions.faction import Faction
from game.navmesh import NavMesh
from game.profiling import logged_duration
from game.theater import (
ConflictTheater,
@ -20,6 +21,7 @@ from game.theater import (
FrontLine,
LatLon,
)
from game.threatzones import ThreatZones
from game.transfers import MultiGroupTransport, TransportMap
from game.utils import meters, nautical_miles
from gen.ato import AirTaskingOrder
@ -31,7 +33,8 @@ from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2
from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu
LeafletLatLon = List[float]
LeafletLatLon = list[float]
LeafletPoly = list[LeafletLatLon]
# **EVERY PROPERTY NEEDS A NOTIFY SIGNAL**
#
@ -51,9 +54,9 @@ LeafletLatLon = List[float]
def shapely_poly_to_leaflet_points(
poly: Polygon, theater: ConflictTheater
) -> Optional[List[LeafletLatLon]]:
) -> LeafletPoly:
if poly.is_empty:
return None
return []
return [theater.point_to_ll(Point(x, y)).as_list() for x, y in poly.exterior.coords]
@ -63,6 +66,7 @@ class ControlPointJs(QObject):
positionChanged = Signal()
mobileChanged = Signal()
destinationChanged = Signal(list)
categoryChanged = Signal()
def __init__(
self,
@ -84,6 +88,10 @@ class ControlPointJs(QObject):
def blue(self) -> bool:
return self.control_point.captured
@Property(str, notify=categoryChanged)
def category(self) -> str:
return self.control_point.category
@Property(list, notify=positionChanged)
def position(self) -> LeafletLatLon:
ll = self.theater.point_to_ll(self.control_point.position)
@ -373,7 +381,9 @@ class WaypointJs(QObject):
altitudeReferenceChanged = Signal()
nameChanged = Signal()
timingChanged = Signal()
isTargetPointChanged = Signal()
isTakeoffChanged = Signal()
isLandingChanged = Signal()
isDivertChanged = Signal()
isBullseyeChanged = Signal()
@ -432,10 +442,18 @@ class WaypointJs(QObject):
return ""
return f"{prefix} T+{timedelta(seconds=int(time.total_seconds()))}"
@Property(bool, notify=isTargetPointChanged)
def isTargetPoint(self) -> bool:
return self.waypoint.waypoint_type is FlightWaypointType.TARGET_POINT
@Property(bool, notify=isTakeoffChanged)
def isTakeoff(self) -> bool:
return self.waypoint.waypoint_type is FlightWaypointType.TAKEOFF
@Property(bool, notify=isLandingChanged)
def isLanding(self) -> bool:
return self.waypoint.waypoint_type is FlightWaypointType.LANDING_POINT
@Property(bool, notify=isDivertChanged)
def isDivert(self) -> bool:
return self.waypoint.waypoint_type is FlightWaypointType.DIVERT
@ -512,7 +530,7 @@ class FlightJs(QObject):
return self._selected
@Property(list, notify=commitBoundaryChanged)
def commitBoundary(self) -> Optional[List[LeafletLatLon]]:
def commitBoundary(self) -> LeafletPoly:
if not isinstance(self.flight.flight_plan, PatrollingFlightPlan):
return []
start = self.flight.flight_plan.patrol_start
@ -528,6 +546,118 @@ class FlightJs(QObject):
return shapely_poly_to_leaflet_points(bubble, self.theater)
class ThreatZonesJs(QObject):
fullChanged = Signal()
aircraftChanged = Signal()
airDefensesChanged = Signal()
radarSamsChanged = Signal()
def __init__(
self,
full: list[LeafletPoly],
aircraft: list[LeafletPoly],
air_defenses: list[LeafletPoly],
radar_sams: list[LeafletPoly],
) -> None:
super().__init__()
self._full = full
self._aircraft = aircraft
self._air_defenses = air_defenses
self._radar_sams = radar_sams
@Property(list, notify=fullChanged)
def full(self) -> list[LeafletPoly]:
return self._full
@Property(list, notify=aircraftChanged)
def aircraft(self) -> list[LeafletPoly]:
return self._aircraft
@Property(list, notify=airDefensesChanged)
def airDefenses(self) -> list[LeafletPoly]:
return self._air_defenses
@Property(list, notify=radarSamsChanged)
def radarSams(self) -> list[LeafletPoly]:
return self._radar_sams
@staticmethod
def polys_to_leaflet(
poly: Union[Polygon, MultiPolygon], theater: ConflictTheater
) -> list[LeafletPoly]:
if isinstance(poly, MultiPolygon):
polys = poly.geoms
else:
polys = [poly]
return [shapely_poly_to_leaflet_points(poly, theater) for poly in polys]
@classmethod
def from_zones(cls, zones: ThreatZones, theater: ConflictTheater) -> ThreatZonesJs:
return ThreatZonesJs(
cls.polys_to_leaflet(zones.all, theater),
cls.polys_to_leaflet(zones.airbases, theater),
cls.polys_to_leaflet(zones.air_defenses, theater),
cls.polys_to_leaflet(zones.radar_sam_threats, theater),
)
@classmethod
def empty(cls) -> ThreatZonesJs:
return ThreatZonesJs([], [], [], [])
class ThreatZoneContainerJs(QObject):
blueChanged = Signal()
redChanged = Signal()
def __init__(self, blue: ThreatZonesJs, red: ThreatZonesJs) -> None:
super().__init__()
self._blue = blue
self._red = red
@Property(ThreatZonesJs, notify=blueChanged)
def blue(self) -> ThreatZonesJs:
return self._blue
@Property(ThreatZonesJs, notify=redChanged)
def red(self) -> ThreatZonesJs:
return self._red
class NavMeshJs(QObject):
blueChanged = Signal()
redChanged = Signal()
def __init__(self, blue: list[LeafletPoly], red: list[LeafletPoly]) -> None:
super().__init__()
self._blue = blue
self._red = red
# TODO: Boundary markers.
# TODO: Numbering.
# TODO: Localization debugging.
@Property(list, notify=blueChanged)
def blue(self) -> list[LeafletPoly]:
return self._blue
@Property(list, notify=redChanged)
def red(self) -> list[LeafletPoly]:
return self._red
@staticmethod
def to_polys(navmesh: NavMesh, theater: ConflictTheater) -> list[LeafletPoly]:
polys = []
for poly in navmesh.polys:
polys.append(shapely_poly_to_leaflet_points(poly.poly, theater))
return polys
@classmethod
def from_game(cls, game: Game) -> NavMeshJs:
return NavMeshJs(
cls.to_polys(game.blue_navmesh, game.theater),
cls.to_polys(game.red_navmesh, game.theater),
)
class MapModel(QObject):
cleared = Signal()
@ -537,6 +667,8 @@ class MapModel(QObject):
supplyRoutesChanged = Signal()
flightsChanged = Signal()
frontLinesChanged = Signal()
threatZonesChanged = Signal()
navmeshesChanged = Signal()
def __init__(self, game_model: GameModel) -> None:
super().__init__()
@ -547,6 +679,10 @@ class MapModel(QObject):
self._supply_routes = []
self._flights = []
self._front_lines = []
self._threat_zones = ThreatZoneContainerJs(
ThreatZonesJs.empty(), ThreatZonesJs.empty()
)
self._navmeshes = NavMeshJs([], [])
self._selected_flight_index: Optional[Tuple[int, int]] = None
GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load)
GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos)
@ -564,6 +700,10 @@ class MapModel(QObject):
self._ground_objects = []
self._flights = []
self._front_lines = []
self._threat_zones = ThreatZoneContainerJs(
ThreatZonesJs.empty(), ThreatZonesJs.empty()
)
self._navmeshes = NavMeshJs([], [])
self.cleared.emit()
def set_package_selection(self, index: int) -> None:
@ -607,6 +747,8 @@ class MapModel(QObject):
self.reset_routes()
self.reset_atos()
self.reset_front_lines()
self.reset_threat_zones()
self.reset_navmeshes()
def on_game_load(self, game: Optional[Game]) -> None:
if game is not None:
@ -730,6 +872,29 @@ class MapModel(QObject):
def frontLines(self) -> List[FrontLineJs]:
return self._front_lines
def reset_threat_zones(self) -> None:
self._threat_zones = ThreatZoneContainerJs(
ThreatZonesJs.from_zones(
self.game.threat_zone_for(player=True), self.game.theater
),
ThreatZonesJs.from_zones(
self.game.threat_zone_for(player=False), self.game.theater
),
)
self.threatZonesChanged.emit()
@Property(ThreatZoneContainerJs, notify=threatZonesChanged)
def threatZones(self) -> ThreatZoneContainerJs:
return self._threat_zones
def reset_navmeshes(self) -> None:
self._navmeshes = NavMeshJs.from_game(self.game)
self.navmeshesChanged.emit()
@Property(NavMeshJs, notify=navmeshesChanged)
def navmeshes(self) -> NavMeshJs:
return self._navmeshes
@property
def game(self) -> Game:
if self.game_model.game is None:

View File

@ -1,33 +0,0 @@
from PySide2.QtGui import QStandardItemModel, QStandardItem
from PySide2.QtWidgets import QGroupBox, QVBoxLayout, QListView, QAbstractItemView
from qt_ui.widgets.combos.QSEADTargetSelectionComboBox import SEADTargetInfo
class QSeadTargetInfoView(QGroupBox):
"""
UI Component to display info about a sead target
"""
def __init__(self, sead_target_infos: SEADTargetInfo):
if sead_target_infos is None:
sead_target_infos = SEADTargetInfo()
super(QSeadTargetInfoView, self).__init__("Target : " + sead_target_infos.name)
self.sead_target_infos = sead_target_infos
self.radar_list = QListView()
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(0)
layout.addWidget(self.radar_list)
self.setLayout(layout)
def setTarget(self, target: SEADTargetInfo):
self.setTitle(target.name)
self.sead_target_infos = target
radar_list_model = QStandardItemModel()
self.radar_list.setSelectionMode(QAbstractItemView.NoSelection)
for r in self.sead_target_infos.radars:
radar_list_model.appendRow(QStandardItem(r.type))
self.radar_list.setModel(radar_list_model)

View File

@ -0,0 +1,92 @@
from typing import Optional
from PySide2.QtCore import (
QItemSelectionModel,
QModelIndex,
Qt,
QSize,
)
from PySide2.QtWidgets import (
QAbstractItemView,
QDialog,
QListView,
QVBoxLayout,
)
from game import db
from game.squadrons import Squadron
from qt_ui.delegates import TwoColumnRowDelegate
from qt_ui.models import GameModel, AirWingModel, SquadronModel
from qt_ui.windows.SquadronDialog import SquadronDialog
class SquadronDelegate(TwoColumnRowDelegate):
def __init__(self, air_wing_model: AirWingModel) -> None:
super().__init__(rows=2, columns=2, font_size=12)
self.air_wing_model = air_wing_model
@staticmethod
def squadron(index: QModelIndex) -> Squadron:
return index.data(AirWingModel.SquadronRole)
def text_for(self, index: QModelIndex, row: int, column: int) -> str:
if (row, column) == (0, 0):
return self.air_wing_model.data(index, Qt.DisplayRole)
elif (row, column) == (0, 1):
squadron = self.air_wing_model.data(index, AirWingModel.SquadronRole)
return db.unit_get_expanded_info(
squadron.country, squadron.aircraft, "name"
)
elif (row, column) == (1, 0):
return self.squadron(index).nickname
elif (row, column) == (1, 1):
squadron = self.squadron(index)
active = len(squadron.active_pilots)
available = len(squadron.available_pilots)
return f"{squadron.size} pilots, {active} active, {available} unassigned"
return ""
class SquadronList(QListView):
"""List view for displaying the air wing's squadrons."""
def __init__(self, air_wing_model: AirWingModel) -> None:
super().__init__()
self.air_wing_model = air_wing_model
self.dialog: Optional[SquadronDialog] = None
self.setIconSize(QSize(91, 24))
self.setItemDelegate(SquadronDelegate(self.air_wing_model))
self.setModel(self.air_wing_model)
self.selectionModel().setCurrentIndex(
self.air_wing_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select
)
# self.setIconSize(QSize(91, 24))
self.setSelectionBehavior(QAbstractItemView.SelectItems)
self.doubleClicked.connect(self.on_double_click)
def on_double_click(self, index: QModelIndex) -> None:
if not index.isValid():
return
self.dialog = SquadronDialog(
SquadronModel(self.air_wing_model.squadron_at_index(index)), self
)
self.dialog.show()
class AirWingDialog(QDialog):
"""Dialog window showing the player's air wing."""
def __init__(self, game_model: GameModel, parent) -> None:
super().__init__(parent)
self.air_wing_model = game_model.blue_air_wing_model
self.setMinimumSize(1000, 440)
self.setWindowTitle(f"Air Wing")
# TODO: self.setWindowIcon()
layout = QVBoxLayout()
self.setLayout(layout)
layout.addWidget(SquadronList(self.air_wing_model))

View File

@ -1,13 +1,10 @@
from typing import Optional
from PySide2.QtCore import (
QItemSelection,
QItemSelectionModel,
QModelIndex,
QSize,
Qt,
)
from PySide2.QtGui import QContextMenuEvent, QFont, QFontMetrics, QIcon, QPainter
from PySide2.QtGui import QContextMenuEvent
from PySide2.QtWidgets import (
QAbstractItemView,
QAction,
@ -16,102 +13,29 @@ from PySide2.QtWidgets import (
QListView,
QMenu,
QPushButton,
QStyle,
QStyleOptionViewItem,
QStyledItemDelegate,
QVBoxLayout,
)
from game.transfers import TransferOrder
from qt_ui.delegate_helpers import painter_context
from qt_ui.delegates import TwoColumnRowDelegate
from qt_ui.models import GameModel, TransferModel
class TransferDelegate(QStyledItemDelegate):
FONT_SIZE = 10
HMARGIN = 4
VMARGIN = 4
class TransferDelegate(TwoColumnRowDelegate):
def __init__(self, transfer_model: TransferModel) -> None:
super().__init__()
super().__init__(rows=2, columns=1, font_size=12)
self.transfer_model = transfer_model
def get_font(self, option: QStyleOptionViewItem) -> QFont:
font = QFont(option.font)
font.setPointSize(self.FONT_SIZE)
return font
@staticmethod
def transfer(index: QModelIndex) -> TransferOrder:
return index.data(TransferModel.TransferRole)
def first_row_text(self, index: QModelIndex) -> str:
return self.transfer_model.data(index, Qt.DisplayRole)
def second_row_text(self, index: QModelIndex) -> str:
return self.transfer(index).description
def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None:
# Draw the list item with all the default selection styling, but with an
# invalid index so text formatting is left to us.
super().paint(painter, option, QModelIndex())
rect = option.rect.adjusted(
self.HMARGIN, self.VMARGIN, -self.HMARGIN, -self.VMARGIN
)
with painter_context(painter):
painter.setFont(self.get_font(option))
icon: Optional[QIcon] = index.data(Qt.DecorationRole)
if icon is not None:
icon.paint(
painter,
rect,
Qt.AlignLeft | Qt.AlignVCenter,
self.icon_mode(option),
self.icon_state(option),
)
rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, 0, 0, 0)
painter.drawText(rect, Qt.AlignLeft, self.first_row_text(index))
line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2)
painter.drawText(line2, Qt.AlignLeft, self.second_row_text(index))
@staticmethod
def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode:
if not (option.state & QStyle.State_Enabled):
return QIcon.Disabled
elif option.state & QStyle.State_Selected:
return QIcon.Selected
elif option.state & QStyle.State_Active:
return QIcon.Active
return QIcon.Normal
@staticmethod
def icon_state(option: QStyleOptionViewItem) -> QIcon.State:
return QIcon.On if option.state & QStyle.State_Open else QIcon.Off
@staticmethod
def icon_size(option: QStyleOptionViewItem) -> QSize:
icon_size: Optional[QSize] = option.decorationSize
if icon_size is None:
return QSize(0, 0)
else:
return icon_size
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
left = self.icon_size(option).width() + self.HMARGIN
metrics = QFontMetrics(self.get_font(option))
first = metrics.size(0, self.first_row_text(index))
second = metrics.size(0, self.second_row_text(index))
text_width = max(first.width(), second.width())
return QSize(
left + text_width + 2 * self.HMARGIN,
first.height() + second.height() + 2 * self.VMARGIN,
)
def text_for(self, index: QModelIndex, row: int, column: int) -> str:
if row == 0:
return self.transfer_model.data(index, Qt.DisplayRole)
elif row == 1:
return self.transfer(index).description
return ""
class PendingTransfersList(QListView):

View File

@ -0,0 +1,155 @@
import logging
from PySide2.QtCore import (
QItemSelectionModel,
QModelIndex,
Qt,
QItemSelection,
)
from PySide2.QtWidgets import (
QAbstractItemView,
QDialog,
QListView,
QVBoxLayout,
QPushButton,
QHBoxLayout,
)
from game.squadrons import Pilot
from qt_ui.delegates import TwoColumnRowDelegate
from qt_ui.models import SquadronModel
class PilotDelegate(TwoColumnRowDelegate):
def __init__(self, squadron_model: SquadronModel) -> None:
super().__init__(rows=2, columns=2, font_size=12)
self.squadron_model = squadron_model
@staticmethod
def pilot(index: QModelIndex) -> Pilot:
return index.data(SquadronModel.PilotRole)
def text_for(self, index: QModelIndex, row: int, column: int) -> str:
pilot = self.pilot(index)
if (row, column) == (0, 0):
return self.squadron_model.data(index, Qt.DisplayRole)
elif (row, column) == (0, 1):
flown = pilot.record.missions_flown
missions = "missions" if flown != 1 else "mission"
return f"{flown} {missions} flown"
elif (row, column) == (1, 0):
return "Player" if pilot.player else "AI"
elif (row, column) == (1, 1):
return pilot.status.value
return ""
class PilotList(QListView):
"""List view for displaying a squadron's pilots."""
def __init__(self, squadron_model: SquadronModel) -> None:
super().__init__()
self.squadron_model = squadron_model
self.setItemDelegate(PilotDelegate(self.squadron_model))
self.setModel(self.squadron_model)
self.selectionModel().setCurrentIndex(
self.squadron_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select
)
# self.setIconSize(QSize(91, 24))
self.setSelectionBehavior(QAbstractItemView.SelectItems)
class SquadronDialog(QDialog):
"""Dialog window showing a squadron."""
def __init__(self, squadron_model: SquadronModel, parent) -> None:
super().__init__(parent)
self.squadron_model = squadron_model
self.setMinimumSize(1000, 440)
self.setWindowTitle(str(squadron_model.squadron))
# TODO: self.setWindowIcon()
layout = QVBoxLayout()
self.setLayout(layout)
self.pilot_list = PilotList(squadron_model)
self.pilot_list.selectionModel().selectionChanged.connect(
self.on_selection_changed
)
layout.addWidget(self.pilot_list)
button_panel = QHBoxLayout()
button_panel.addStretch()
layout.addLayout(button_panel)
self.toggle_ai_button = QPushButton()
self.reset_ai_toggle_state(self.pilot_list.currentIndex())
self.toggle_ai_button.setProperty("style", "start-button")
self.toggle_ai_button.clicked.connect(self.toggle_ai)
button_panel.addWidget(self.toggle_ai_button, alignment=Qt.AlignRight)
self.toggle_leave_button = QPushButton()
self.reset_leave_toggle_state(self.pilot_list.currentIndex())
self.toggle_leave_button.setProperty("style", "start-button")
self.toggle_leave_button.clicked.connect(self.toggle_leave)
button_panel.addWidget(self.toggle_leave_button, alignment=Qt.AlignRight)
def check_disabled_button_states(
self, button: QPushButton, index: QModelIndex
) -> bool:
if not index.isValid():
button.setText("No pilot selected")
button.setDisabled(True)
return True
pilot = self.squadron_model.pilot_at_index(index)
if not pilot.alive:
button.setText("Pilot is dead")
button.setDisabled(True)
return True
return False
def toggle_ai(self) -> None:
index = self.pilot_list.currentIndex()
if not index.isValid():
logging.error("Cannot toggle player/AI: no pilot is selected")
return
self.squadron_model.toggle_ai_state(index)
def reset_ai_toggle_state(self, index: QModelIndex) -> None:
if self.check_disabled_button_states(self.toggle_ai_button, index):
return
if not self.squadron_model.squadron.aircraft.flyable:
self.toggle_ai_button.setText("Not flyable")
self.toggle_ai_button.setDisabled(True)
return
self.toggle_ai_button.setEnabled(True)
pilot = self.squadron_model.pilot_at_index(index)
self.toggle_ai_button.setText(
"Convert to AI" if pilot.player else "Convert to player"
)
def toggle_leave(self) -> None:
index = self.pilot_list.currentIndex()
if not index.isValid():
logging.error("Cannot toggle on leave state: no pilot is selected")
return
self.squadron_model.toggle_leave_state(index)
def reset_leave_toggle_state(self, index: QModelIndex) -> None:
if self.check_disabled_button_states(self.toggle_leave_button, index):
return
pilot = self.squadron_model.pilot_at_index(index)
self.toggle_leave_button.setEnabled(True)
self.toggle_leave_button.setText(
"Return from leave" if pilot.on_leave else "Send on leave"
)
def on_selection_changed(
self, selected: QItemSelection, _deselected: QItemSelection
) -> None:
index = selected.indexes()[0]
self.reset_ai_toggle_state(index)
self.reset_leave_toggle_state(index)

View File

@ -4,7 +4,7 @@ import logging
from collections import defaultdict
from typing import Callable, Dict, Type
from PySide2.QtCore import Qt
from PySide2.QtCore import Qt, Signal
from PySide2.QtWidgets import (
QComboBox,
QDialog,
@ -153,6 +153,8 @@ class TransferControls(QGroupBox):
class ScrollingUnitTransferGrid(QFrame):
transfer_quantity_changed = Signal()
def __init__(self, cp: ControlPoint, game_model: GameModel) -> None:
super().__init__()
self.cp = cp
@ -229,6 +231,7 @@ class ScrollingUnitTransferGrid(QFrame):
origin_inventory -= 1
controls.set_quantity(self.transfers[unit_type])
origin_inventory_label.setText(str(origin_inventory))
self.transfer_quantity_changed.emit()
def decrease(controls: TransferControls):
nonlocal origin_inventory
@ -240,6 +243,7 @@ class ScrollingUnitTransferGrid(QFrame):
origin_inventory += 1
controls.set_quantity(self.transfers[unit_type])
origin_inventory_label.setText(str(origin_inventory))
self.transfer_quantity_changed.emit()
transfer_controls = TransferControls("->", increase, "<-", decrease)
@ -276,11 +280,15 @@ class NewUnitTransferDialog(QDialog):
layout.addLayout(self.dest_panel)
self.transfer_panel = ScrollingUnitTransferGrid(origin, game_model)
self.transfer_panel.transfer_quantity_changed.connect(
self.on_transfer_quantity_changed
)
layout.addWidget(self.transfer_panel)
self.submit_button = QPushButton("Create Transfer Order", parent=self)
self.submit_button.clicked.connect(self.on_submit)
self.submit_button.setProperty("style", "start-button")
self.submit_button.setDisabled(True)
layout.addWidget(self.submit_button)
def on_submit(self) -> None:
@ -303,3 +311,7 @@ class NewUnitTransferDialog(QDialog):
)
self.game_model.transfer_model.new_transfer(transfer)
self.close()
def on_transfer_quantity_changed(self) -> None:
has_transfer_items = any(self.transfer_panel.transfers.values())
self.submit_button.setDisabled(not has_transfer_items)

View File

@ -95,6 +95,12 @@ class QBaseMenu2(QDialog):
bottom_row.addWidget(transfer_button)
transfer_button.clicked.connect(self.open_transfer_dialog)
if self.cheat_capturable:
capture_button = QPushButton("CHEAT: Capture")
capture_button.setProperty("style", "btn-danger")
bottom_row.addWidget(capture_button)
capture_button.clicked.connect(self.cheat_capture)
self.budget_display = QLabel(
QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.budget)
)
@ -104,6 +110,26 @@ class QBaseMenu2(QDialog):
GameUpdateSignal.get_instance().budgetupdated.connect(self.update_budget)
self.setLayout(main_layout)
@property
def cheat_capturable(self) -> bool:
if not self.game_model.game.settings.enable_base_capture_cheat:
return False
if self.cp.captured:
return False
for connected in self.cp.connected_points:
if connected.captured:
return True
return False
def cheat_capture(self) -> None:
self.cp.capture(self.game_model.game, for_player=True)
# Reinitialized ground planners and the like. The ATO needs to be reset because
# missions planned against the flipped base are no longer valid.
self.game_model.game.reset_ato()
self.game_model.game.initialize_turn()
GameUpdateSignal.get_instance().updateGame(self.game_model.game)
@property
def has_transfer_destinations(self) -> bool:
return self.game_model.game.transit_network_for(

View File

@ -1,6 +1,7 @@
import itertools
from PySide2.QtWidgets import (
QCheckBox,
QDialog,
QFrame,
QGridLayout,
@ -42,9 +43,9 @@ class ScrollingFrame(QFrame):
class EconomyIntelTab(ScrollingFrame):
def __init__(self, game: Game) -> None:
def __init__(self, game: Game, player: bool) -> None:
super().__init__()
self.addLayout(FinancesLayout(game, player=False))
self.addLayout(FinancesLayout(game, player=player))
class IntelTableLayout(QGridLayout):
@ -93,9 +94,9 @@ class AircraftIntelLayout(IntelTableLayout):
class AircraftIntelTab(ScrollingFrame):
def __init__(self, game: Game) -> None:
def __init__(self, game: Game, player: bool) -> None:
super().__init__()
self.addLayout(AircraftIntelLayout(game, player=False))
self.addLayout(AircraftIntelLayout(game, player=player))
class ArmyIntelLayout(IntelTableLayout):
@ -120,18 +121,18 @@ class ArmyIntelLayout(IntelTableLayout):
class ArmyIntelTab(ScrollingFrame):
def __init__(self, game: Game) -> None:
def __init__(self, game: Game, player: bool) -> None:
super().__init__()
self.addLayout(ArmyIntelLayout(game, player=False))
self.addLayout(ArmyIntelLayout(game, player=player))
class IntelTabs(QTabWidget):
def __init__(self, game: Game):
def __init__(self, game: Game, player: bool):
super().__init__()
self.addTab(EconomyIntelTab(game), "Economy")
self.addTab(AircraftIntelTab(game), "Air forces")
self.addTab(ArmyIntelTab(game), "Ground forces")
self.addTab(EconomyIntelTab(game, player), "Economy")
self.addTab(AircraftIntelTab(game, player), "Air forces")
self.addTab(ArmyIntelTab(game, player), "Ground forces")
class IntelWindow(QDialog):
@ -139,12 +140,42 @@ class IntelWindow(QDialog):
super().__init__()
self.game = game
self.player = True
self.setModal(True)
self.setWindowTitle("Intelligence")
self.setWindowIcon(ICONS["Statistics"])
self.setMinimumSize(600, 500)
self.selected_intel_tab = 0
layout = QVBoxLayout()
self.setLayout(layout)
self.refresh_layout()
layout.addWidget(IntelTabs(game), stretch=1)
def on_faction_changed(self) -> None:
self.player = not self.player
self.refresh_layout()
def refresh_layout(self) -> None:
# Clear the existing layout
if self.layout():
idx = 0
while child := self.layout().itemAt(idx):
self.layout().removeItem(child)
# Add the new layout
own_faction = QCheckBox("Enemy Info")
own_faction.setChecked(not self.player)
own_faction.stateChanged.connect(self.on_faction_changed)
intel_tabs = IntelTabs(self.game, self.player)
intel_tabs.currentChanged.connect(self.on_tab_changed)
if self.selected_intel_tab:
intel_tabs.setCurrentIndex(self.selected_intel_tab)
self.layout().addWidget(own_faction)
self.layout().addWidget(intel_tabs, stretch=1)
def on_tab_changed(self, idx: int) -> None:
self.selected_intel_tab = idx

View File

@ -215,7 +215,9 @@ class QNewPackageDialog(QPackageDialog):
self, game_model: GameModel, model: AtoModel, target: MissionTarget, parent=None
) -> None:
super().__init__(
game_model, PackageModel(Package(target), game_model), parent=parent
game_model,
PackageModel(Package(target, auto_asap=True), game_model),
parent=parent,
)
self.ato_model = model
@ -237,6 +239,7 @@ class QNewPackageDialog(QPackageDialog):
super().on_cancel()
for flight in self.package_model.package.flights:
self.game.aircraft_inventory.return_from_flight(flight)
flight.clear_roster()
class QEditPackageDialog(QPackageDialog):

View File

@ -1,4 +1,5 @@
from typing import Optional
import logging
from typing import Optional, Type
from PySide2.QtCore import Qt, Signal
from PySide2.QtWidgets import (
@ -10,9 +11,10 @@ from PySide2.QtWidgets import (
QVBoxLayout,
QLineEdit,
)
from dcs.planes import PlaneType
from dcs.unittype import FlyingType
from game import Game
from game.squadrons import Squadron
from game.theater import ControlPoint, OffMapSpawn
from gen.ato import Package
from gen.flights.flight import Flight
@ -23,6 +25,7 @@ from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector
from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelector
from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox
from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector
from qt_ui.windows.mission.flight.SquadronSelector import SquadronSelector
class QFlightCreator(QDialog):
@ -55,6 +58,14 @@ class QFlightCreator(QDialog):
self.aircraft_selector.currentIndexChanged.connect(self.on_aircraft_changed)
layout.addLayout(QLabeledWidget("Aircraft:", self.aircraft_selector))
self.squadron_selector = SquadronSelector(
self.game.air_wing_for(player=True),
self.task_selector.currentData(),
self.aircraft_selector.currentData(),
)
self.squadron_selector.setCurrentIndex(0)
layout.addLayout(QLabeledWidget("Squadron:", self.squadron_selector))
self.departure = QOriginAirfieldSelector(
self.game.aircraft_inventory,
[cp for cp in game.theater.controlpoints if cp.captured],
@ -132,13 +143,16 @@ class QFlightCreator(QDialog):
self.custom_name_text = text
def verify_form(self) -> Optional[str]:
aircraft: PlaneType = self.aircraft_selector.currentData()
origin: ControlPoint = self.departure.currentData()
arrival: ControlPoint = self.arrival.currentData()
divert: ControlPoint = self.divert.currentData()
aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData()
squadron: Optional[Squadron] = self.squadron_selector.currentData()
origin: Optional[ControlPoint] = self.departure.currentData()
arrival: Optional[ControlPoint] = self.arrival.currentData()
divert: Optional[ControlPoint] = self.divert.currentData()
size: int = self.flight_size_spinner.value()
if aircraft is None:
return "You must select an aircraft type."
if squadron is None:
return "You must select a squadron."
if not origin.captured:
return f"{origin.name} is not owned by your coalition."
if arrival is not None and not arrival.captured:
@ -163,7 +177,7 @@ class QFlightCreator(QDialog):
return
task = self.task_selector.currentData()
aircraft = self.aircraft_selector.currentData()
squadron = self.squadron_selector.currentData()
origin = self.departure.currentData()
arrival = self.arrival.currentData()
divert = self.divert.currentData()
@ -175,7 +189,7 @@ class QFlightCreator(QDialog):
flight = Flight(
self.package,
self.country,
aircraft,
squadron,
size,
task,
self.start_type.currentText(),
@ -184,7 +198,14 @@ class QFlightCreator(QDialog):
divert,
custom_name=self.custom_name_text,
)
flight.client_count = self.client_slots_spinner.value()
for pilot, idx in zip(flight.pilots, range(self.client_slots_spinner.value())):
if pilot is None:
logging.error(
f"Cannot create client slot because {flight} has no pilot for "
f"aircraft {idx}"
)
continue
pilot.player = True
# noinspection PyUnresolvedReferences
self.created.emit(flight)
@ -192,6 +213,9 @@ class QFlightCreator(QDialog):
def on_aircraft_changed(self, index: int) -> None:
new_aircraft = self.aircraft_selector.itemData(index)
self.squadron_selector.update_items(
self.task_selector.currentData(), new_aircraft
)
self.departure.change_aircraft(new_aircraft)
self.arrival.change_aircraft(new_aircraft)
self.divert.change_aircraft(new_aircraft)
@ -211,10 +235,13 @@ class QFlightCreator(QDialog):
self.restore_start_type = None
def on_task_changed(self) -> None:
self.aircraft_selector.updateItems(
self.aircraft_selector.update_items(
self.task_selector.currentData(),
self.game.aircraft_inventory.available_types_for_player,
)
self.squadron_selector.update_items(
self.task_selector.currentData(), self.aircraft_selector.currentData()
)
def update_max_size(self, available: int) -> None:
self.flight_size_spinner.setMaximum(min(available, 4))

View File

@ -19,6 +19,7 @@ class QFlightPlanner(QTabWidget):
)
self.payload_tab = QFlightPayloadTab(flight, game)
self.waypoint_tab = QFlightWaypointTab(game, package_model.package, flight)
self.waypoint_tab.loadout_changed.connect(self.payload_tab.reload_from_flight)
self.addTab(self.general_settings_tab, "General Flight settings")
self.addTab(self.payload_tab, "Payload")
self.addTab(self.waypoint_tab, "Waypoints")

View File

@ -0,0 +1,48 @@
"""Combo box for selecting squadrons."""
from typing import Type, Optional
from PySide2.QtWidgets import QComboBox
from dcs.unittype import FlyingType
from game.squadrons import AirWing
from gen.flights.flight import FlightType
class SquadronSelector(QComboBox):
"""Combo box for selecting squadrons compatible with the given requirements."""
def __init__(
self,
air_wing: AirWing,
task: Optional[FlightType],
aircraft: Optional[Type[FlyingType]],
) -> None:
super().__init__()
self.air_wing = air_wing
self.model().sort(0)
self.setSizeAdjustPolicy(self.AdjustToContents)
self.update_items(task, aircraft)
def update_items(
self, task: Optional[FlightType], aircraft: Optional[Type[FlyingType]]
) -> None:
current_squadron = self.currentData()
self.clear()
if task is None:
self.addItem("No task selected", None)
return
if aircraft is None:
self.addItem("No aircraft selected", None)
return
for squadron in self.air_wing.squadrons_for(aircraft):
if task in squadron.mission_types:
self.addItem(f"{squadron}", squadron)
if self.count() == 0:
self.addItem("No capable aircraft available", None)
return
if current_squadron is not None:
self.setCurrentText(f"{current_squadron}")

View File

@ -40,6 +40,9 @@ class QFlightPayloadTab(QFrame):
self.setLayout(layout)
def reload_from_flight(self) -> None:
self.loadout_selector.setCurrentText(self.flight.loadout.name)
def on_new_loadout(self, index: int) -> None:
self.flight.loadout = self.loadout_selector.itemData(index)
self.payload_editor.reset_pylons()
@ -49,5 +52,5 @@ class QFlightPayloadTab(QFrame):
if use_custom:
self.flight.loadout = self.flight.loadout.derive_custom("Custom")
else:
self.flight.loadout = Loadout.default_for(self.flight)
self.flight.loadout = self.loadout_selector.currentData()
self.payload_editor.reset_pylons()

View File

@ -1,17 +1,174 @@
import logging
from typing import Optional, Callable
from PySide2.QtCore import Signal
from PySide2.QtWidgets import QLabel, QGroupBox, QSpinBox, QGridLayout
from PySide2.QtCore import Signal, QModelIndex
from PySide2.QtWidgets import (
QLabel,
QGroupBox,
QSpinBox,
QGridLayout,
QComboBox,
QHBoxLayout,
QCheckBox,
QVBoxLayout,
)
from game import Game
from game.squadrons import Pilot
from gen.flights.flight import Flight
from qt_ui.models import PackageModel
class PilotSelector(QComboBox):
available_pilots_changed = Signal()
def __init__(self, flight: Flight, idx: int) -> None:
super().__init__()
self.flight = flight
self.pilot_index = idx
self.rebuild()
@staticmethod
def text_for(pilot: Pilot) -> str:
return pilot.name
def _do_rebuild(self) -> None:
self.clear()
if self.pilot_index >= self.flight.count:
self.addItem("No aircraft", None)
self.setDisabled(True)
return
self.setEnabled(True)
self.addItem("Unassigned", None)
choices = list(self.flight.squadron.available_pilots)
current_pilot = self.flight.pilots[self.pilot_index]
if current_pilot is not None:
choices.append(current_pilot)
# Put players first, otherwise alphabetically.
for pilot in sorted(choices, key=lambda p: (not p.player, p.name)):
self.addItem(self.text_for(pilot), pilot)
if current_pilot is None:
self.setCurrentText("Unassigned")
else:
self.setCurrentText(self.text_for(current_pilot))
self.currentIndexChanged.connect(self.replace_pilot)
def rebuild(self) -> None:
# The contents of the selector depend on the selection of the other selectors
# for the flight, so changing the selection of one causes each selector to
# rebuild. A rebuild causes a selection change, so if we don't block signals
# during a rebuild we'll never stop rebuilding.
self.blockSignals(True)
try:
self._do_rebuild()
finally:
self.blockSignals(False)
def replace_pilot(self, index: QModelIndex) -> None:
if self.itemText(index) == "No aircraft":
# The roster resize is handled separately, so we have no pilots to remove.
return
pilot = self.itemData(index)
if pilot == self.flight.pilots[self.pilot_index]:
return
self.flight.set_pilot(self.pilot_index, pilot)
self.available_pilots_changed.emit()
class PilotControls(QHBoxLayout):
def __init__(self, flight: Flight, idx: int) -> None:
super().__init__()
self.flight = flight
self.pilot_index = idx
self.selector = PilotSelector(flight, idx)
self.selector.currentIndexChanged.connect(self.on_pilot_changed)
self.addWidget(self.selector)
self.player_checkbox = QCheckBox()
self.player_checkbox.setToolTip("Checked if this pilot is a player.")
self.on_pilot_changed(self.selector.currentIndex())
self.addWidget(self.player_checkbox)
self.player_checkbox.toggled.connect(self.on_player_toggled)
@property
def pilot(self) -> Optional[Pilot]:
if self.pilot_index >= self.flight.count:
return None
return self.flight.pilots[self.pilot_index]
def on_player_toggled(self, checked: bool) -> None:
pilot = self.pilot
if pilot is None:
logging.error("Cannot toggle state of a pilot when none is selected")
return
pilot.player = checked
def on_pilot_changed(self, index: int) -> None:
pilot = self.selector.itemData(index)
self.player_checkbox.blockSignals(True)
try:
self.player_checkbox.setChecked(pilot is not None and pilot.player)
finally:
self.player_checkbox.blockSignals(False)
def update_available_pilots(self) -> None:
self.selector.rebuild()
def enable_and_reset(self) -> None:
self.selector.rebuild()
self.on_pilot_changed(self.selector.currentIndex())
def disable_and_clear(self) -> None:
self.selector.rebuild()
self.player_checkbox.blockSignals(True)
try:
self.player_checkbox.setEnabled(False)
self.player_checkbox.setChecked(False)
finally:
self.player_checkbox.blockSignals(False)
class FlightRosterEditor(QVBoxLayout):
MAX_PILOTS = 4
def __init__(self, flight: Flight) -> None:
super().__init__()
self.pilot_controls = []
for pilot_idx in range(self.MAX_PILOTS):
def make_reset_callback(source_idx: int) -> Callable[[int], None]:
def callback() -> None:
self.update_available_pilots(source_idx)
return callback
controls = PilotControls(flight, pilot_idx)
controls.selector.available_pilots_changed.connect(
make_reset_callback(pilot_idx)
)
self.pilot_controls.append(controls)
self.addLayout(controls)
def update_available_pilots(self, source_idx: int) -> None:
for idx, controls in enumerate(self.pilot_controls):
# No need to reset the source of the reset, it was just manually selected.
if idx != source_idx:
controls.update_available_pilots()
def resize(self, new_size: int) -> None:
if new_size > self.MAX_PILOTS:
raise ValueError("A flight may not have more than four pilots.")
for controls in self.pilot_controls[:new_size]:
controls.enable_and_reset()
for controls in self.pilot_controls[new_size:]:
controls.disable_and_clear()
class QFlightSlotEditor(QGroupBox):
changed = Signal()
def __init__(self, package_model: PackageModel, flight: Flight, game: Game):
super().__init__("Slots")
self.package_model = package_model
@ -32,52 +189,35 @@ class QFlightSlotEditor(QGroupBox):
self.aircraft_count_spinner.setValue(flight.count)
self.aircraft_count_spinner.valueChanged.connect(self._changed_aircraft_count)
self.client_count = QLabel("Client slots count:")
self.client_count_spinner = QSpinBox()
self.client_count_spinner.setMinimum(0)
self.client_count_spinner.setMaximum(max_count)
self.client_count_spinner.setValue(flight.client_count)
self.client_count_spinner.valueChanged.connect(self._changed_client_count)
if not self.flight.unit_type.flyable:
self.client_count_spinner.setValue(0)
self.client_count_spinner.setEnabled(False)
layout.addWidget(self.aircraft_count, 0, 0)
layout.addWidget(self.aircraft_count_spinner, 0, 1)
layout.addWidget(self.client_count, 1, 0)
layout.addWidget(self.client_count_spinner, 1, 1)
layout.addWidget(QLabel("Squadron:"), 1, 0)
layout.addWidget(QLabel(str(self.flight.squadron)), 1, 1)
layout.addWidget(QLabel("Assigned pilots:"), 2, 0)
self.roster_editor = FlightRosterEditor(flight)
layout.addLayout(self.roster_editor, 2, 1)
self.setLayout(layout)
def _changed_aircraft_count(self):
self.game.aircraft_inventory.return_from_flight(self.flight)
old_count = self.flight.count
self.flight.count = int(self.aircraft_count_spinner.value())
new_count = int(self.aircraft_count_spinner.value())
try:
self.game.aircraft_inventory.claim_for_flight(self.flight)
except ValueError:
# The UI should have prevented this, but if we ran out of aircraft
# then roll back the inventory change.
difference = self.flight.count - old_count
difference = new_count - self.flight.count
available = self.inventory.available(self.flight.unit_type)
logging.error(
f"Could not add {difference} additional aircraft to "
f"{self.flight} because {self.flight.from_cp} has only "
f"{self.flight} because {self.flight.departure} has only "
f"{available} {self.flight.unit_type} remaining"
)
self.flight.count = old_count
self.game.aircraft_inventory.claim_for_flight(self.flight)
self.changed.emit()
return
def _changed_client_count(self):
self.flight.client_count = int(self.client_count_spinner.value())
self._cap_client_count()
self.package_model.update_tot()
self.changed.emit()
def _cap_client_count(self):
if self.flight.client_count > self.flight.count:
self.flight.client_count = self.flight.count
self.client_count_spinner.setValue(self.flight.client_count)
self.flight.resize(new_count)
self.roster_editor.resize(new_count)

View File

@ -21,9 +21,6 @@ class QFlightWaypointList(QTableView):
header = self.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
if len(self.flight.points) > 0:
self.selectedPoint = self.flight.points[0]
self.update_list()
self.selectionModel().setCurrentIndex(
@ -31,6 +28,9 @@ class QFlightWaypointList(QTableView):
)
def update_list(self):
# We need to keep just the row and rebuild the index later because the
# QModelIndex will not be valid after the model is cleared.
current_index = self.currentIndex().row()
self.model.clear()
self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"])
@ -39,7 +39,7 @@ class QFlightWaypointList(QTableView):
for row, waypoint in enumerate(waypoints):
self.add_waypoint_row(row, self.flight, waypoint)
self.selectionModel().setCurrentIndex(
self.indexAt(QPoint(1, 1)), QItemSelectionModel.Select
self.model.index(current_index, 0), QItemSelectionModel.Select
)
self.resizeColumnsToContents()
total_column_width = self.verticalHeader().width() + self.lineWidth()

View File

@ -20,6 +20,7 @@ from gen.flights.flightplan import (
PlanningError,
StrikeFlightPlan,
)
from gen.flights.loadouts import Loadout
from qt_ui.windows.mission.flight.waypoints.QFlightWaypointList import (
QFlightWaypointList,
)
@ -29,6 +30,8 @@ from qt_ui.windows.mission.flight.waypoints.QPredefinedWaypointSelectionWindow i
class QFlightWaypointTab(QFrame):
loadout_changed = Signal()
def __init__(self, game: Game, package: Package, flight: Flight):
super(QFlightWaypointTab, self).__init__()
self.game = game
@ -161,6 +164,9 @@ class QFlightWaypointTab(QFrame):
QMessageBox.critical(
self, "Could not recreate flight", str(ex), QMessageBox.Ok
)
if not self.flight.loadout.is_custom:
self.flight.loadout = Loadout.default_for(self.flight)
self.loadout_changed.emit()
self.flight_waypoint_list.update_list()
self.on_change()

View File

@ -32,7 +32,7 @@ jinja_env = Environment(
)
DEFAULT_BUDGET = 2000
DEFAULT_MISSION_LENGTH: timedelta = timedelta(minutes=90)
DEFAULT_MISSION_LENGTH: timedelta = timedelta(minutes=60)
class NewGameWizard(QtWidgets.QWizard):

View File

@ -23,7 +23,7 @@ from dcs.forcedoptions import ForcedOptions
import qt_ui.uiconstants as CONST
from game.game import Game
from game.infos.information import Information
from game.settings import Settings
from game.settings import Settings, AutoAtoBehavior
from qt_ui.widgets.QLabeledWidget import QLabeledWidget
from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
@ -75,6 +75,110 @@ class CheatSettingsBox(QGroupBox):
return self.base_capture_cheat_checkbox.isChecked()
class AutoAtoBehaviorSelector(QComboBox):
def __init__(self, default: AutoAtoBehavior) -> None:
super().__init__()
for behavior in AutoAtoBehavior:
self.addItem(behavior.value, behavior)
self.setCurrentText(default.value)
class HqAutomationSettingsBox(QGroupBox):
def __init__(self, game: Game) -> None:
super().__init__("HQ Automation")
self.game = game
layout = QGridLayout()
self.setLayout(layout)
runway_repair = QCheckBox()
runway_repair.setChecked(self.game.settings.automate_runway_repair)
runway_repair.toggled.connect(self.set_runway_automation)
layout.addWidget(QLabel("Automate runway repairs"), 0, 0)
layout.addWidget(runway_repair, 0, 1, Qt.AlignRight)
front_line = QCheckBox()
front_line.setChecked(self.game.settings.automate_front_line_reinforcements)
front_line.toggled.connect(self.set_front_line_automation)
layout.addWidget(QLabel("Automate front-line purchases"), 1, 0)
layout.addWidget(front_line, 1, 1, Qt.AlignRight)
self.automate_aircraft_reinforcements = QCheckBox()
self.automate_aircraft_reinforcements.setChecked(
self.game.settings.automate_aircraft_reinforcements
)
self.automate_aircraft_reinforcements.toggled.connect(
self.set_aircraft_automation
)
layout.addWidget(QLabel("Automate aircraft purchases"), 2, 0)
layout.addWidget(self.automate_aircraft_reinforcements, 2, 1, Qt.AlignRight)
self.auto_ato_behavior = AutoAtoBehaviorSelector(
self.game.settings.auto_ato_behavior
)
self.auto_ato_behavior.currentIndexChanged.connect(self.set_auto_ato_behavior)
layout.addWidget(
QLabel(
"Automatic package planning behavior<br>"
"<strong>Aircraft auto-purchase is directed by the auto-planner,<br />"
"so disabling auto-planning disables auto-purchase.</strong>"
),
3,
0,
)
layout.addWidget(self.auto_ato_behavior, 3, 1)
self.auto_ato_player_missions_asap = QCheckBox()
self.auto_ato_player_missions_asap.setChecked(
self.game.settings.auto_ato_player_missions_asap
)
self.auto_ato_player_missions_asap.toggled.connect(
self.set_auto_ato_player_missions_asap
)
layout.addWidget(
QLabel("Automatically generated packages with players are scheduled ASAP"),
4,
0,
)
layout.addWidget(self.auto_ato_player_missions_asap, 4, 1, Qt.AlignRight)
def set_runway_automation(self, value: bool) -> None:
self.game.settings.automate_runway_repair = value
def set_front_line_automation(self, value: bool) -> None:
self.game.settings.automate_front_line_reinforcements = value
def set_aircraft_automation(self, value: bool) -> None:
self.game.settings.automate_aircraft_reinforcements = value
def set_auto_ato_behavior(self, index: int) -> None:
behavior = self.auto_ato_behavior.itemData(index)
self.game.settings.auto_ato_behavior = behavior
if behavior in (AutoAtoBehavior.Disabled, AutoAtoBehavior.Never):
self.auto_ato_player_missions_asap.setChecked(False)
self.auto_ato_player_missions_asap.setEnabled(False)
if behavior is AutoAtoBehavior.Disabled:
self.automate_aircraft_reinforcements.setChecked(False)
self.automate_aircraft_reinforcements.setEnabled(False)
else:
self.auto_ato_player_missions_asap.setEnabled(True)
self.auto_ato_player_missions_asap.setChecked(
self.game.settings.auto_ato_player_missions_asap
)
self.automate_aircraft_reinforcements.setEnabled(True)
self.automate_aircraft_reinforcements.setChecked(
self.game.settings.automate_aircraft_reinforcements
)
def set_auto_ato_player_missions_asap(self, value: bool) -> None:
self.game.settings.auto_ato_player_missions_asap = value
START_TYPE_TOOLTIP = "Selects the start type used for AI aircraft."
@ -92,7 +196,7 @@ class StartTypeComboBox(QComboBox):
class QSettingsWindow(QDialog):
def __init__(self, game: Game):
super(QSettingsWindow, self).__init__()
super().__init__()
self.game = game
self.pluginsPage = None
@ -285,6 +389,23 @@ class QSettingsWindow(QDialog):
self.ext_views.setChecked(self.game.settings.external_views_allowed)
self.ext_views.toggled.connect(self.applySettings)
def set_invulnerable_player_pilots(checked: bool) -> None:
self.game.settings.invulnerable_player_pilots = checked
invulnerable_player_pilots_label = QLabel(
"Player pilots cannot be killed<br />"
"<strong>Aircraft are vulnerable, but the player's pilot will be<br />"
"returned to the squadron at the end of the mission</strong>"
)
invulnerable_player_pilots_checkbox = QCheckBox()
invulnerable_player_pilots_checkbox.setChecked(
self.game.settings.invulnerable_player_pilots
)
invulnerable_player_pilots_checkbox.toggled.connect(
set_invulnerable_player_pilots
)
self.aiDifficultyLayout.addWidget(QLabel("Player coalition skill"), 0, 0)
self.aiDifficultyLayout.addWidget(
self.playerCoalitionSkill, 0, 1, Qt.AlignRight
@ -295,6 +416,10 @@ class QSettingsWindow(QDialog):
self.aiDifficultyLayout.addWidget(self.enemyAASkill, 2, 1, Qt.AlignRight)
self.aiDifficultyLayout.addLayout(self.player_income, 3, 0)
self.aiDifficultyLayout.addLayout(self.enemy_income, 4, 0)
self.aiDifficultyLayout.addWidget(invulnerable_player_pilots_label, 5, 0)
self.aiDifficultyLayout.addWidget(
invulnerable_player_pilots_checkbox, 5, 1, Qt.AlignRight
)
self.aiDifficultySettings.setLayout(self.aiDifficultyLayout)
self.difficultyLayout.addWidget(self.aiDifficultySettings)
@ -367,41 +492,7 @@ class QSettingsWindow(QDialog):
general_layout.addWidget(old_awac_label, 1, 0)
general_layout.addWidget(old_awac, 1, 1, Qt.AlignRight)
automation = QGroupBox("HQ Automation")
campaign_layout.addWidget(automation)
automation_layout = QGridLayout()
automation.setLayout(automation_layout)
def set_runway_automation(value: bool) -> None:
self.game.settings.automate_runway_repair = value
def set_front_line_automation(value: bool) -> None:
self.game.settings.automate_front_line_reinforcements = value
def set_aircraft_automation(value: bool) -> None:
self.game.settings.automate_aircraft_reinforcements = value
runway_repair = QCheckBox()
runway_repair.setChecked(self.game.settings.automate_runway_repair)
runway_repair.toggled.connect(set_runway_automation)
automation_layout.addWidget(QLabel("Automate runway repairs"), 0, 0)
automation_layout.addWidget(runway_repair, 0, 1, Qt.AlignRight)
front_line = QCheckBox()
front_line.setChecked(self.game.settings.automate_front_line_reinforcements)
front_line.toggled.connect(set_front_line_automation)
automation_layout.addWidget(QLabel("Automate front-line purchases"), 1, 0)
automation_layout.addWidget(front_line, 1, 1, Qt.AlignRight)
aircraft = QCheckBox()
aircraft.setChecked(self.game.settings.automate_aircraft_reinforcements)
aircraft.toggled.connect(set_aircraft_automation)
automation_layout.addWidget(QLabel("Automate aircraft purchases"), 2, 0)
automation_layout.addWidget(aircraft, 2, 1, Qt.AlignRight)
campaign_layout.addWidget(HqAutomationSettingsBox(self.game))
def initGeneratorLayout(self):
self.generatorPage = QWidget()

View File

@ -5,6 +5,7 @@ certifi==2020.12.5
cfgv==3.2.0
click==7.1.2
distlib==0.3.1
Faker==8.2.1
filelock==3.0.12
future==0.18.2
identify==1.5.13
@ -23,6 +24,7 @@ pyinstaller-hooks-contrib==2021.1
pyparsing==2.4.7
pyproj==3.0.1
PySide2==5.15.2
python-dateutil==2.8.1
pywin32-ctypes==0.2.0
PyYAML==5.4.1
regex==2020.11.13
@ -30,6 +32,7 @@ Shapely==1.7.1
shiboken2==5.15.2
six==1.15.0
tabulate==0.8.7
text-unidecode==1.3
toml==0.10.2
typed-ast==1.4.2
typing-extensions==3.7.4.3

View File

@ -7,5 +7,5 @@
"description": "<p>You have managed to establish a foothold at Khasab. Continue pushing south.</p>",
"miz": "battle_of_abu_dhabi.miz",
"performance": 2,
"version": "4.1"
"version": "5.0"
}

View File

@ -7,5 +7,5 @@
"description": "<p>In this scenario, you start in Israel and the conflict is focused around the golan heights, an historically disputed territory.<br/><br/>This scenario is designed to be performance friendly.</p>",
"miz": "golan_heights_lite.miz",
"performance": 1,
"version": 3
"version": "5.0"
}

View File

@ -5,7 +5,7 @@
"recommended_player_faction": "USA 2005",
"recommended_enemy_faction": "Insurgents (Hard)",
"description": "<p>In this scenario, you start from Jordan, and have to fight your way through eastern Syria.</p>",
"version": 4,
"version": "5.0",
"miz": "inherent_resolve.miz",
"performance": 1
"performance": 2
}

View File

@ -1,102 +0,0 @@
{
"name": "Caucasus - North Caucasus",
"theater": "Caucasus",
"authors": "Khopa",
"description": "<p>In this scenario you will have to fight in the moutain of Caucasus</p><p><strong>Note:</strong> Running CAS in the moutains can be a bit difficult.</p>",
"player_points": [
{
"type": "airbase",
"id": "Kutaisi",
"size": 600,
"importance": 1
},
{
"type": "airbase",
"id": "Vaziani",
"size": 600,
"importance": 1
},
{
"type": "carrier",
"id": 1001,
"x": -285810.6875,
"y": 496399.1875,
"captured_invert": true
},
{
"type": "lha",
"id": 1002,
"x": -326050.6875,
"y": 519452.1875,
"captured_invert": true
}
],
"enemy_points": [
{
"type": "airbase",
"id": "Beslan",
"size": 1000,
"importance": 1
},
{
"type": "airbase",
"id": "Nalchik",
"size": 1000,
"importance": 1.1
},
{
"type": "airbase",
"id": "Mozdok",
"size": 2000,
"importance": 1.1
},
{
"type": "airbase",
"id": "Mineralnye Vody",
"size": 2000,
"importance": 1.3
},
{
"type": "airbase",
"id": "Maykop-Khanskaya",
"size": 3000,
"importance": 1.4,
"captured_invert": true
}
],
"links": [
[
"Kutaisi",
"Vaziani"
],
[
"Beslan",
"Vaziani"
],
[
"Beslan",
"Mozdok"
],
[
"Beslan",
"Nalchik"
],
[
"Mozdok",
"Nalchik"
],
[
"Mineralnye Vody",
"Nalchik"
],
[
"Mineralnye Vody",
"Mozdok"
],
[
"Maykop-Khanskaya",
"Mineralnye Vody"
]
],
"performance": 1
}

View File

@ -98,102 +98,6 @@ local unitPayloads = {
},
},
[3] = {
["name"] = "SEAD",
["pylons"] = {
[1] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 10,
},
[2] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 1,
},
[3] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}",
["num"] = 9,
},
[4] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}",
["num"] = 2,
},
[5] = {
["CLSID"] = "{F14-300gal}",
["num"] = 8,
},
[6] = {
["CLSID"] = "{F14-300gal}",
["num"] = 3,
},
[7] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 7,
},
[8] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 4,
},
[9] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 6,
},
[10] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 5,
},
},
["tasks"] = {
[1] = 10,
},
},
[4] = {
["name"] = "DEAD",
["pylons"] = {
[1] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 10,
},
[2] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 1,
},
[3] = {
["CLSID"] = "{F14-LANTIRN-TP}",
["num"] = 9,
},
[4] = {
["CLSID"] = "{PHXBRU3242_2*LAU10 LS}",
["num"] = 2,
},
[5] = {
["CLSID"] = "{F14-300gal}",
["num"] = 8,
},
[6] = {
["CLSID"] = "{F14-300gal}",
["num"] = 3,
},
[7] = {
["CLSID"] = "{BRU-32 GBU-12}",
["num"] = 7,
},
[8] = {
["CLSID"] = "{BRU-32 GBU-12}",
["num"] = 4,
},
[9] = {
["CLSID"] = "{BRU-32 GBU-12}",
["num"] = 6,
},
[10] = {
["CLSID"] = "{BRU-32 GBU-12}",
["num"] = 5,
},
},
["tasks"] = {
[1] = 10,
},
},
[5] = {
["name"] = "STRIKE",
["pylons"] = {
[1] = {
@ -241,7 +145,7 @@ local unitPayloads = {
[1] = 10,
},
},
[6] = {
[4] = {
["name"] = "BAI",
["pylons"] = {
[1] = {
@ -289,7 +193,7 @@ local unitPayloads = {
[1] = 10,
},
},
[7] = {
[5] = {
["name"] = "ANTISHIP",
["pylons"] = {
[1] = {
@ -337,6 +241,103 @@ local unitPayloads = {
[1] = 10,
},
},
[6] = {
["name"] = "Liberation DEAD",
["pylons"] = {
[1] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 10,
},
[2] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 1,
},
[3] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}",
["num"] = 2,
},
[4] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}",
["num"] = 9,
},
[5] = {
["CLSID"] = "{F14-300gal}",
["num"] = 8,
},
[6] = {
["CLSID"] = "{F14-300gal}",
["num"] = 3,
},
[7] = {
["CLSID"] = "{MAK79_MK82 4}",
["num"] = 7,
},
[8] = {
["CLSID"] = "{MAK79_MK82 3R}",
["num"] = 6,
},
[9] = {
["CLSID"] = "{MAK79_MK82 3L}",
["num"] = 5,
},
[10] = {
["CLSID"] = "{MAK79_MK82 4}",
["num"] = 4,
},
},
["tasks"] = {
[1] = 31,
},
},
[7] = {
["displayName"] = "Liberation SEAD",
["name"] = "Liberation SEAD",
["pylons"] = {
[1] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 10,
},
[2] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 1,
},
[3] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}",
["num"] = 2,
},
[4] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}",
["num"] = 9,
},
[5] = {
["CLSID"] = "{F14-300gal}",
["num"] = 8,
},
[6] = {
["CLSID"] = "{F14-300gal}",
["num"] = 3,
},
[7] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 7,
},
[8] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 6,
},
[9] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 5,
},
[10] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 4,
},
},
["tasks"] = {
[1] = 31,
},
},
},
["unitType"] = "F-14A-135-GR",
}

View File

@ -155,57 +155,6 @@ local unitPayloads = {
},
},
[4] = {
["name"] = "SEAD",
["pylons"] = {
[1] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 10,
},
[2] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}",
["num"] = 9,
},
[3] = {
["CLSID"] = "{F14-300gal}",
["num"] = 8,
},
[4] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 7,
},
[5] = {
["CLSID"] = "{BRU-32 GBU-12}",
["num"] = 6,
},
[6] = {
["CLSID"] = "{BRU-32 GBU-12}",
["num"] = 5,
},
[7] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 4,
},
[8] = {
["CLSID"] = "{F14-300gal}",
["num"] = 3,
},
[9] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}",
["num"] = 2,
},
[10] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 1,
},
},
["tasks"] = {
[1] = 10,
[2] = 11,
[3] = 18,
[4] = 19,
},
},
[5] = {
["name"] = "ANTISHIP",
["pylons"] = {
[1] = {
@ -256,6 +205,103 @@ local unitPayloads = {
[4] = 19,
},
},
[5] = {
["name"] = "Liberation DEAD",
["pylons"] = {
[1] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 10,
},
[2] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 1,
},
[3] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}",
["num"] = 2,
},
[4] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}",
["num"] = 9,
},
[5] = {
["CLSID"] = "{F14-300gal}",
["num"] = 8,
},
[6] = {
["CLSID"] = "{F14-300gal}",
["num"] = 3,
},
[7] = {
["CLSID"] = "{MAK79_MK82 4}",
["num"] = 7,
},
[8] = {
["CLSID"] = "{MAK79_MK82 3R}",
["num"] = 6,
},
[9] = {
["CLSID"] = "{MAK79_MK82 3L}",
["num"] = 5,
},
[10] = {
["CLSID"] = "{MAK79_MK82 4}",
["num"] = 4,
},
},
["tasks"] = {
[1] = 31,
},
},
[6] = {
["displayName"] = "Liberation SEAD",
["name"] = "Liberation SEAD",
["pylons"] = {
[1] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 10,
},
[2] = {
["CLSID"] = "{LAU-138 wtip - AIM-9M}",
["num"] = 1,
},
[3] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}",
["num"] = 2,
},
[4] = {
["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}",
["num"] = 9,
},
[5] = {
["CLSID"] = "{F14-300gal}",
["num"] = 8,
},
[6] = {
["CLSID"] = "{F14-300gal}",
["num"] = 3,
},
[7] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 7,
},
[8] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 6,
},
[9] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 5,
},
[10] = {
["CLSID"] = "{BRU3242_ADM141}",
["num"] = 4,
},
},
["tasks"] = {
[1] = 31,
},
},
},
["unitType"] = "F-14B",
}

View File

@ -2,71 +2,88 @@ local unitPayloads = {
["name"] = "F-15E",
["payloads"] = {
[1] = {
["name"] = "CAS",
["displayName"] = "Liberation CAS",
["name"] = "Liberation CAS",
["pylons"] = {
[1] = {
["CLSID"] = "{C8E06185-7CD6-4C90-959F-044679E90751}",
["num"] = 1,
["CLSID"] = "{444BA8AE-82A7-4345-842E-76154EFCCA46}",
["num"] = 18,
},
[2] = {
["CLSID"] = "{444BA8AE-82A7-4345-842E-76154EFCCA46}",
["num"] = 2,
},
[3] = {
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 1,
},
[4] = {
["CLSID"] = "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}",
["num"] = 3,
},
[3] = {
["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}",
["num"] = 4,
},
[4] = {
["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}",
["num"] = 6,
},
[5] = {
["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}",
["num"] = 7,
["CLSID"] = "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}",
["num"] = 17,
},
[6] = {
["CLSID"] = "{GBU-38}",
["num"] = 9,
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 19,
},
[7] = {
["CLSID"] = "{E1F29B21-F291-4589-9FD8-3272EEC69506}",
["num"] = 10,
},
[8] = {
["CLSID"] = "{GBU-38}",
["CLSID"] = "{CBU_105}",
["num"] = 11,
},
[9] = {
["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}",
["num"] = 13,
["CLSID"] = "{CBU_105}",
["num"] = 9,
},
[10] = {
["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}",
["num"] = 14,
["CLSID"] = "{CBU_105}",
["num"] = 8,
},
[11] = {
["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}",
["num"] = 16,
["CLSID"] = "{CBU_105}",
["num"] = 7,
},
[12] = {
["CLSID"] = "{C8E06185-7CD6-4C90-959F-044679E90751}",
["num"] = 19,
["CLSID"] = "{CBU_105}",
["num"] = 12,
},
[13] = {
["CLSID"] = "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}",
["num"] = 17,
["CLSID"] = "{CBU_105}",
["num"] = 13,
},
[14] = {
["CLSID"] = "{444BA8AE-82A7-4345-842E-76154EFCCA46}",
["num"] = 18,
["CLSID"] = "{Mk82AIR}",
["num"] = 6,
},
[15] = {
["CLSID"] = "{444BA8AE-82A7-4345-842E-76154EFCCA46}",
["num"] = 2,
["CLSID"] = "{Mk82AIR}",
["num"] = 5,
},
[16] = {
["CLSID"] = "{Mk82AIR}",
["num"] = 4,
},
[17] = {
["CLSID"] = "{Mk82AIR}",
["num"] = 14,
},
[18] = {
["CLSID"] = "{Mk82AIR}",
["num"] = 15,
},
[19] = {
["CLSID"] = "{Mk82AIR}",
["num"] = 16,
},
},
["tasks"] = {
[1] = 32,
[1] = 31,
},
},
[2] = {

View File

@ -5,7 +5,7 @@ local unitPayloads = {
["name"] = "CAS",
["pylons"] = {
[1] = {
["CLSID"] = "{8A0BE8AE-58D4-4572-9263-3144C0D06364}",
["CLSID"] = "<CLEAN>",
["num"] = 5,
},
[2] = {
@ -80,7 +80,7 @@ local unitPayloads = {
["num"] = 11,
},
[8] = {
["CLSID"] = "{8A0BE8AE-58D4-4572-9263-3144C0D06364}",
["CLSID"] = "<CLEAN>",
["num"] = 5,
},
},
@ -91,7 +91,7 @@ local unitPayloads = {
["name"] = "CAP",
["pylons"] = {
[1] = {
["CLSID"] = "{8A0BE8AE-58D4-4572-9263-3144C0D06364}",
["CLSID"] = "<CLEAN>",
["num"] = 5,
},
[2] = {
@ -166,7 +166,7 @@ local unitPayloads = {
["num"] = 1,
},
[9] = {
["CLSID"] = "{8A0BE8AE-58D4-4572-9263-3144C0D06364}",
["CLSID"] = "<CLEAN>",
["num"] = 5,
},
[10] = {
@ -197,7 +197,7 @@ local unitPayloads = {
["num"] = 3,
},
[5] = {
["CLSID"] = "{8A0BE8AE-58D4-4572-9263-3144C0D06364}",
["CLSID"] = "<CLEAN>",
["num"] = 5,
},
[6] = {
@ -220,6 +220,55 @@ local unitPayloads = {
["tasks"] = {
},
},
[6] = {
["displayName"] = "Liberation DEAD",
["name"] = "Liberation DEAD",
["pylons"] = {
[1] = {
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 1,
},
[2] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 2,
},
[3] = {
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 9,
},
[4] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 8,
},
[5] = {
["CLSID"] = "{5335D97A-35A5-4643-9D9B-026C75961E52}",
["num"] = 7,
},
[6] = {
["CLSID"] = "{F376DBEE-4CAE-41BA-ADD9-B2910AC95DEC}",
["num"] = 6,
},
[7] = {
["CLSID"] = "{5335D97A-35A5-4643-9D9B-026C75961E52}",
["num"] = 3,
},
[8] = {
["CLSID"] = "{F376DBEE-4CAE-41BA-ADD9-B2910AC95DEC}",
["num"] = 4,
},
[9] = {
["CLSID"] = "{A111396E-D3E8-4b9c-8AC9-2432489304D5}",
["num"] = 11,
},
[10] = {
["CLSID"] = "<CLEAN>",
["num"] = 5,
},
},
["tasks"] = {
[1] = 31,
},
},
},
["unitType"] = "F-16C_50",
}

View File

@ -2,39 +2,39 @@ local unitPayloads = {
["name"] = "FA-18C_hornet",
["payloads"] = {
[1] = {
["name"] = "CAS MAVERICK F",
["name"] = "Liberation BARCAP",
["pylons"] = {
[1] = {
["CLSID"] = "LAU_117_AGM_65F",
["num"] = 7,
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 9,
},
[2] = {
["CLSID"] = "LAU_117_AGM_65F",
["num"] = 8,
},
[3] = {
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 6,
},
[4] = {
["CLSID"] = "{AN_ASQ_228}",
["num"] = 4,
},
[5] = {
["CLSID"] = "LAU_117_AGM_65F",
["num"] = 3,
},
[6] = {
["CLSID"] = "LAU_117_AGM_65F",
["num"] = 2,
},
[7] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 1,
},
[3] = {
["CLSID"] = "{FPU_8A_FUEL_TANK}",
["num"] = 3,
},
[4] = {
["CLSID"] = "{FPU_8A_FUEL_TANK}",
["num"] = 7,
},
[5] = {
["CLSID"] = "LAU-115_2*LAU-127_AIM-120C",
["num"] = 8,
},
[6] = {
["CLSID"] = "LAU-115_2*LAU-127_AIM-120C",
["num"] = 2,
},
[7] = {
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 6,
},
[8] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 9,
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 4,
},
},
["tasks"] = {
@ -42,90 +42,50 @@ local unitPayloads = {
},
},
[2] = {
["name"] = "CAS MAVERICK E",
["name"] = "Liberation CAS",
["pylons"] = {
[1] = {
["CLSID"] = "{F16A4DE0-116C-4A71-97F0-2CF85B0313EC}",
["num"] = 7,
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 9,
},
[2] = {
["CLSID"] = "{F16A4DE0-116C-4A71-97F0-2CF85B0313EC}",
["num"] = 8,
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 1,
},
[3] = {
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 6,
["CLSID"] = "LAU_117_AGM_65F",
["num"] = 2,
},
[4] = {
["CLSID"] = "LAU_117_AGM_65F",
["num"] = 8,
},
[5] = {
["CLSID"] = "LAU_117_AGM_65F",
["num"] = 7,
},
[6] = {
["CLSID"] = "LAU_117_AGM_65F",
["num"] = 3,
},
[7] = {
["CLSID"] = "{AN_ASQ_228}",
["num"] = 4,
},
[5] = {
["CLSID"] = "{F16A4DE0-116C-4A71-97F0-2CF85B0313EC}",
["num"] = 3,
},
[6] = {
["CLSID"] = "{F16A4DE0-116C-4A71-97F0-2CF85B0313EC}",
["num"] = 2,
},
[7] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 1,
},
[8] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 9,
},
},
["tasks"] = {
[1] = 11,
},
},
[3] = {
["name"] = "CAP HEAVY",
["pylons"] = {
[1] = {
["CLSID"] = "LAU-115_2*LAU-127_AIM-120C",
["num"] = 7,
},
[2] = {
["CLSID"] = "LAU-115_2*LAU-127_AIM-120C",
["num"] = 8,
},
[3] = {
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 6,
},
[4] = {
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 4,
},
[5] = {
[9] = {
["CLSID"] = "{FPU_8A_FUEL_TANK}",
["num"] = 5,
},
[6] = {
["CLSID"] = "LAU-115_2*LAU-127_AIM-120C",
["num"] = 3,
},
[7] = {
["CLSID"] = "LAU-115_2*LAU-127_AIM-120C",
["num"] = 2,
},
[8] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 1,
},
[9] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 9,
},
},
["tasks"] = {
[1] = 11,
[1] = 31,
},
},
[4] = {
[3] = {
["name"] = "STRIKE",
["pylons"] = {
[1] = {
@ -165,7 +125,7 @@ local unitPayloads = {
[1] = 11,
},
},
[5] = {
[4] = {
["name"] = "ANTISHIP",
["pylons"] = {
[1] = {
@ -205,16 +165,17 @@ local unitPayloads = {
[1] = 11,
},
},
[6] = {
["name"] = "SEAD",
[5] = {
["displayName"] = "Liberation SEAD",
["name"] = "Liberation SEAD",
["pylons"] = {
[1] = {
["CLSID"] = "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}",
["num"] = 7,
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 9,
},
[2] = {
["CLSID"] = "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}",
["num"] = 8,
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 1,
},
[3] = {
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
@ -226,30 +187,26 @@ local unitPayloads = {
},
[5] = {
["CLSID"] = "{FPU_8A_FUEL_TANK}",
["num"] = 5,
["num"] = 3,
},
[6] = {
["CLSID"] = "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}",
["num"] = 3,
["CLSID"] = "{FPU_8A_FUEL_TANK}",
["num"] = 7,
},
[7] = {
["CLSID"] = "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}",
["num"] = 2,
},
[8] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 1,
},
[9] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 9,
["CLSID"] = "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}",
["num"] = 8,
},
},
["tasks"] = {
[1] = 11,
[1] = 29,
},
},
[7] = {
[6] = {
["name"] = "RUNWAY_ATTACK",
["pylons"] = {
[1] = {
@ -293,6 +250,46 @@ local unitPayloads = {
[1] = 34,
},
},
[7] = {
["name"] = "Liberation DEAD",
["pylons"] = {
[1] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 9,
},
[2] = {
["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}",
["num"] = 1,
},
[3] = {
["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}",
["num"] = 6,
},
[4] = {
["CLSID"] = "{FPU_8A_FUEL_TANK}",
["num"] = 7,
},
[5] = {
["CLSID"] = "{FPU_8A_FUEL_TANK}",
["num"] = 3,
},
[6] = {
["CLSID"] = "{BRU55_2*AGM-154A}",
["num"] = 8,
},
[7] = {
["CLSID"] = "{BRU55_2*AGM-154A}",
["num"] = 2,
},
[8] = {
["CLSID"] = "{AN_ASQ_228}",
["num"] = 4,
},
},
["tasks"] = {
[1] = 11,
},
},
},
["tasks"] = {
},

View File

@ -3,6 +3,7 @@
"name": "Canada 2005",
"authors": "Khopa",
"description": "<p>Canada in the 2000s, an F/A-18C Hornet focused faction.</p>",
"locales": ["en_US", "fr_CA"],
"aircrafts": [
"FA_18C_hornet",
"UH_1H",

View File

@ -3,6 +3,7 @@
"name": "Canada 2005 (With C-130)",
"authors": "Khopa, SpaceEnthusiast",
"description": "<p>Canada in the 2000s, an F/A-18C Hornet focused faction.</p>",
"locales": ["en_US", "fr_CA"],
"aircrafts": [
"FA_18C_hornet",
"UH_1H",

View File

@ -3,6 +3,7 @@
"name": "China 2010",
"authors": "Khopa",
"description": "<p>China in the late 2000s, early 2010s.</p>",
"locales": ["zh_CN"],
"aircrafts": [
"MiG_21Bis",
"Su_30",

View File

@ -3,6 +3,7 @@
"name": "France 1985 (Frenchpack)",
"authors": "Colonel Panic",
"description": "<p>1980s French equipment using FrenchPack.</p>",
"locales": ["fr_FR"],
"doctrine": "coldwar",
"aircrafts": [
"M_2000C",

View File

@ -3,6 +3,7 @@
"name": "France 1995",
"authors": "Khopa",
"description": "<p>France in the late 90s before Rafale introduction. A Mirage-2000 centric faction choice.</p>",
"locales": ["fr_FR"],
"aircrafts": [
"M_2000C",
"Mirage_2000_5",

View File

@ -3,6 +3,7 @@
"name": "France 2005 (Frenchpack)",
"authors": "HerrTom",
"description": "<p>French equipment using the Frenchpack, but without the Rafale mod.</p>",
"locales": ["fr_FR"],
"aircrafts": [
"M_2000C",
"Mirage_2000_5",

View File

@ -3,6 +3,7 @@
"name": "German Democratic Republic 1985",
"authors": "Colonel Panic",
"description": "<p>The German Democratic Republic in 1985.</p>",
"locales": ["de_DE"],
"doctrine": "coldwar",
"aircrafts": [
"MiG_21Bis",

View File

@ -3,6 +3,7 @@
"name": "Germany 1940",
"authors": "Khopa",
"description": "<p>Germany 1940, Early german faction for Battle of France, or Battle of England.</p>",
"locales": ["de_DE"],
"aircrafts": [
"FW_190A8",
"FW_190D9",

View File

@ -3,6 +3,7 @@
"name": "Germany 1942",
"authors": "Khopa",
"description": "<p>Germany 1942, is a faction that does not use the late war german units such as the Tiger tank, so it's a bit easier to perform CAS against them.</p>",
"locales": ["de_DE"],
"aircrafts": [
"FW_190A8",
"FW_190D9",

Some files were not shown because too many files have changed in this diff Show More