Merge branch 'develop' into helipads

# Conflicts:
#	game/theater/conflicttheater.py
#	gen/flights/flightplan.py
This commit is contained in:
Khopa 2021-06-06 15:46:30 +02:00
commit 73b1be36a2
103 changed files with 1992 additions and 3914 deletions

View File

@ -0,0 +1,28 @@
---
name: Campaign update submission
about: Submit an update to a campaign you maintain.
title: 'Update for <campaign name>'
labels: campaign-update-submission
assignees: ''
---
This form should only be used for submitted updated miz/json files for campaigns
distributed with Liberation. If you are _requesting_ an update to a campaign, see
https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance. If the
campaign has an owner, it will be updated before release. If it does not, you can
volunteer to own it.
If you are not the owner of the campaign listed on
https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance, please start
there.
Otherwise, delete everything above the line below and fill out the following form. Note:
GitHub does not accept .miz files. You can either rename the file to .miz.txt or add the
file to a .zip file.
---
* Campaign name:
* Files:
* Update summary (optional):

View File

@ -1,3 +1,11 @@
# 4.0.0
Saves from 3.x are not compatible with 4.0.
## Features/Improvements
## Fixes
# 3.0.0
Saves from 2.5 are not compatible with 3.0.
@ -9,9 +17,14 @@ Saves from 2.5 are not compatible with 3.0.
* **[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]** Added squadrons and pilots. See https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots for more information.
* **[Campaign]** Capturing a base now depopulates all of its attached objectives with units: air defenses, EWRs, ships, armor groups, etc. Buildings are captured.
* **[Campaign]** Ammunition Depots determine how many ground units can be deployed on the frontline by a control point.
* **[Campaign AI]** AI now considers Ju-88s for CAS, strike, and DEAD missions.
* **[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.
* **[Campaign AI]** Auto purchase of ground units will now maintain unit composition instead of buying randomly. The unit composition is predefined.
* **[Campaign AI]** Auto purchase will aim to purchase enough ground units to support the frontline, plus 30% reserve units.
* **[Campaign AI]** Auto purchase will now adjust its air/ground balance to favor whichever is under-funded.
* **[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.
@ -22,12 +35,15 @@ Saves from 2.5 are not compatible with 3.0.
* **[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]** Overhauled the map implementation. Now uses satellite imagery instead of low res map images. Display options have moved from the toolbar to panels in the 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.
* **[UI]** DCS loadouts are now selectable in the loadout setup menu.
* **[UI]** Added global aircraft inventory view under Air Wing dialog.
* **[UI]** Base menu now shows information about ground unit deployment limits.
* **[Modding]** Campaigns now choose locations for factories to spawn.
* **[Modding]** Campaigns now choose locations for ammunition depots to spawn.
* **[Modding]** Campaigns now use map structures as strike targets.
* **[Modding]** Campaigns may now set *any* objective type to be a required spawn rather than random chance.
* **[Modding]** Campaigns may now set *any* objective type to be a required spawn rather than random chance. Support for random objective generation was removed.
* **[Modding]** Campaigns may now place AAA objectives.
* **[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.
@ -41,8 +57,10 @@ Saves from 2.5 are not compatible with 3.0.
* **[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.
* **[Campaign]** EWR sites are now purchasable.
* **[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).
* **[Flight Planner]** Fixed some contexts where damaged runways would be used. Destroying a carrier will no longer break the game.
# 2.5.1

0
game/data/__init__.py Normal file
View File

View File

@ -11,7 +11,7 @@ DEFAULT_AVAILABLE_BUILDINGS = [
"derrick",
]
WW2_FREE = ["fuel", "ware", "fob"]
WW2_FREE = ["fuel", "ware"]
WW2_GERMANY_BUILDINGS = [
"fuel",
"ww2bunker",

View File

@ -1,7 +1,20 @@
from dataclasses import dataclass
from datetime import timedelta
from dcs.task import Reconnaissance
from game.utils import Distance, feet, nautical_miles
from game.data.groundunitclass import GroundUnitClass
@dataclass
class GroundUnitProcurementRatios:
ratios: dict[GroundUnitClass, float]
def for_unit_class(self, unit_class: GroundUnitClass) -> float:
try:
return self.ratios[unit_class] / sum(self.ratios.values())
except KeyError:
return 0.0
@dataclass(frozen=True)
@ -50,6 +63,8 @@ class Doctrine:
sweep_distance: Distance
ground_unit_procurement_ratios: GroundUnitProcurementRatios
MODERN_DOCTRINE = Doctrine(
cap=True,
@ -76,6 +91,17 @@ MODERN_DOCTRINE = Doctrine(
cap_engagement_range=nautical_miles(50),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(60),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
GroundUnitClass.Tank: 3,
GroundUnitClass.Atgm: 2,
GroundUnitClass.Apc: 2,
GroundUnitClass.Ifv: 3,
GroundUnitClass.Artillery: 1,
GroundUnitClass.Shorads: 2,
GroundUnitClass.Recon: 1,
}
),
)
COLDWAR_DOCTRINE = Doctrine(
@ -103,6 +129,17 @@ COLDWAR_DOCTRINE = Doctrine(
cap_engagement_range=nautical_miles(35),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(40),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
GroundUnitClass.Tank: 4,
GroundUnitClass.Atgm: 2,
GroundUnitClass.Apc: 3,
GroundUnitClass.Ifv: 2,
GroundUnitClass.Artillery: 1,
GroundUnitClass.Shorads: 2,
GroundUnitClass.Recon: 1,
}
),
)
WWII_DOCTRINE = Doctrine(
@ -130,4 +167,14 @@ WWII_DOCTRINE = Doctrine(
cap_engagement_range=nautical_miles(20),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(10),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
GroundUnitClass.Tank: 3,
GroundUnitClass.Atgm: 3,
GroundUnitClass.Apc: 3,
GroundUnitClass.Artillery: 1,
GroundUnitClass.Shorads: 3,
GroundUnitClass.Recon: 1,
}
),
)

View File

@ -0,0 +1,239 @@
from enum import unique, Enum
from typing import Type
from dcs.vehicles import AirDefence, Infantry, Unarmed, Artillery, Armor
from dcs.unittype import VehicleType
from pydcs_extensions.frenchpack import frenchpack
@unique
class GroundUnitClass(Enum):
Tank = (
"Tank",
(
Armor.MBT_T_55,
Armor.MBT_T_72B,
Armor.MBT_T_72B3,
Armor.MBT_T_80U,
Armor.MBT_T_90,
Armor.MBT_Leopard_2A4,
Armor.MBT_Leopard_2A4_Trs,
Armor.MBT_Leopard_2A5,
Armor.MBT_Leopard_2A6M,
Armor.MBT_Leopard_1A3,
Armor.MBT_Leclerc,
Armor.MBT_Challenger_II,
Armor.MBT_Chieftain_Mk_3,
Armor.MBT_M1A2_Abrams,
Armor.MBT_M60A3_Patton,
Armor.MBT_Merkava_IV,
Armor.ZTZ_96B,
# WW2
# Axis
Armor.Tk_PzIV_H,
Armor.SPG_Sturmpanzer_IV_Brummbar,
Armor.MT_Pz_Kpfw_V_Panther_Ausf_G,
Armor.HT_Pz_Kpfw_VI_Tiger_I,
Armor.HT_Pz_Kpfw_VI_Ausf__B_Tiger_II,
# Allies
Armor.Tk_M4_Sherman,
Armor.CT_Centaur_IV,
Armor.CT_Cromwell_IV,
Armor.HIT_Churchill_VII,
# Mods
frenchpack.DIM__TOYOTA_BLUE,
frenchpack.DIM__TOYOTA_GREEN,
frenchpack.DIM__TOYOTA_DESERT,
frenchpack.DIM__KAMIKAZE,
frenchpack.AMX_30B2,
frenchpack.Leclerc_Serie_XXI,
),
)
Atgm = (
"ATGM",
(
Armor.ATGM_HMMWV,
Armor.ATGM_VAB_Mephisto,
Armor.ATGM_Stryker,
Armor.IFV_BMP_2,
# WW2 (Tank Destroyers)
# Axxis
Armor.SPG_StuG_III_Ausf__G,
Armor.SPG_StuG_IV,
Armor.SPG_Jagdpanzer_IV,
Armor.SPG_Jagdpanther_G1,
Armor.SPG_Sd_Kfz_184_Elefant,
# Allies
Armor.SPG_M10_GMC,
Armor.MT_M4A4_Sherman_Firefly,
# Mods
frenchpack.VBAE_CRAB_MMP,
frenchpack.VAB_MEPHISTO,
frenchpack.TRM_2000_PAMELA,
),
)
Ifv = (
"IFV",
(
Armor.IFV_BMP_3,
Armor.IFV_BMP_2,
Armor.IFV_BMP_1,
Armor.IFV_Marder,
Armor.IFV_Warrior,
Armor.SPG_Stryker_MGS,
Armor.IFV_M2A2_Bradley,
Armor.IFV_BMD_1,
Armor.ZBD_04A,
# Mods
frenchpack.VBAE_CRAB,
frenchpack.VAB_T20_13,
),
)
Apc = (
"APC",
(
Armor.IFV_M1126_Stryker_ICV,
Armor.APC_M113,
Armor.APC_BTR_80,
Armor.IFV_BTR_82A,
Armor.APC_MTLB,
Armor.APC_AAV_7_Amphibious,
Armor.APC_TPz_Fuchs,
Armor.APC_BTR_RD,
# WW2
Armor.APC_M2A1_Halftrack,
Armor.APC_Sd_Kfz_251_Halftrack,
# Mods
frenchpack.VAB__50,
frenchpack.VBL__50,
frenchpack.VBL_AANF1,
),
)
Artillery = (
"Artillery",
(
Artillery.Grad_MRL_FDDM__FC,
Artillery.MLRS_9A52_Smerch_HE_300mm,
Artillery.SPH_2S1_Gvozdika_122mm,
Artillery.SPH_2S3_Akatsia_152mm,
Artillery.MLRS_BM_21_Grad_122mm,
Artillery.MLRS_9K57_Uragan_BM_27_220mm,
Artillery.SPH_M109_Paladin_155mm,
Artillery.MLRS_M270_227mm,
Artillery.SPM_2S9_Nona_120mm_M,
Artillery.SPH_Dana_vz77_152mm,
Artillery.SPH_T155_Firtina_155mm,
Artillery.PLZ_05,
Artillery.SPH_2S19_Msta_152mm,
Artillery.MLRS_9A52_Smerch_CM_300mm,
# WW2
Artillery.SPG_M12_GMC_155mm,
),
)
Logistics = (
"Logistics",
(
Unarmed.Carrier_M30_Cargo,
Unarmed.Truck_M818_6x6,
Unarmed.Truck_KAMAZ_43101,
Unarmed.Truck_Ural_375,
Unarmed.Truck_GAZ_66,
Unarmed.Truck_GAZ_3307,
Unarmed.Truck_GAZ_3308,
Unarmed.Truck_Ural_4320_31_Arm_d,
Unarmed.Truck_Ural_4320T,
Unarmed.Truck_Opel_Blitz,
Unarmed.LUV_Kubelwagen_82,
Unarmed.Carrier_Sd_Kfz_7_Tractor,
Unarmed.LUV_Kettenrad,
Unarmed.Car_Willys_Jeep,
Unarmed.LUV_Land_Rover_109,
Unarmed.Truck_Land_Rover_101_FC,
# Mods
frenchpack.VBL,
frenchpack.VAB,
),
)
Recon = (
"Recon",
(
Armor.Scout_HMMWV,
Armor.Scout_Cobra,
Armor.LT_PT_76,
Armor.IFV_LAV_25,
Armor.Scout_BRDM_2,
# WW2
Armor.LT_Mk_VII_Tetrarch,
Armor.IFV_Sd_Kfz_234_2_Puma,
Armor.Car_M8_Greyhound_Armored,
Armor.Car_Daimler_Armored,
# Mods
frenchpack.ERC_90,
frenchpack.AMX_10RCR,
frenchpack.AMX_10RCR_SEPAR,
),
)
Infantry = (
"Infantry",
(
Infantry.Insurgent_AK_74,
Infantry.Infantry_AK_74,
Infantry.Infantry_M1_Garand,
Infantry.Infantry_Mauser_98,
Infantry.Infantry_SMLE_No_4_Mk_1,
Infantry.Infantry_M4_Georgia,
Infantry.Infantry_AK_74_Rus,
Infantry.Paratrooper_AKS,
Infantry.Paratrooper_RPG_16,
Infantry.Infantry_M249,
Infantry.Infantry_M4,
Infantry.Infantry_RPG,
),
)
Shorads = (
"SHORADS",
(
AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375,
AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375,
AirDefence.SPAAA_ZSU_57_2,
AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish,
AirDefence.SAM_SA_8_Osa_Gecko_TEL,
AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL,
AirDefence.SAM_SA_13_Strela_10M3_Gopher_TEL,
AirDefence.SAM_SA_15_Tor_Gauntlet,
AirDefence.SAM_SA_19_Tunguska_Grison,
AirDefence.SPAAA_Gepard,
AirDefence.SPAAA_Vulcan_M163,
AirDefence.SAM_Linebacker___Bradley_M6,
AirDefence.SAM_Chaparral_M48,
AirDefence.SAM_Avenger__Stinger,
AirDefence.SAM_Roland_ADS,
AirDefence.HQ_7_Self_Propelled_LN,
AirDefence.AAA_8_8cm_Flak_18,
AirDefence.AAA_8_8cm_Flak_36,
AirDefence.AAA_8_8cm_Flak_37,
AirDefence.AAA_8_8cm_Flak_41,
AirDefence.AAA_Bofors_40mm,
AirDefence.AAA_S_60_57mm,
AirDefence.AAA_M1_37mm,
AirDefence.AAA_QF_3_7,
),
)
def __init__(
self, class_name: str, unit_list: tuple[Type[VehicleType], ...]
) -> None:
self.class_name = class_name
self.unit_list = unit_list
def __contains__(self, unit_type: Type[VehicleType]) -> bool:
return unit_type in self.unit_list

View File

@ -1251,6 +1251,7 @@ REWARDS = {
"ammo": 2,
"farp": 1,
# TODO: Should generate no cash once they generate units.
# https://github.com/dcs-liberation/dcs_liberation/issues/1036
"factory": 10,
"comms": 10,
"oil": 10,

View File

@ -24,7 +24,7 @@ from game import db
from game.theater import Airfield, ControlPoint
from game.transfers import CargoShip
from game.unitmap import (
AirliftUnit,
AirliftUnits,
Building,
ConvoyUnit,
FrontLineUnit,
@ -75,8 +75,8 @@ class GroundLosses:
player_cargo_ships: List[CargoShip] = field(default_factory=list)
enemy_cargo_ships: List[CargoShip] = field(default_factory=list)
player_airlifts: List[AirliftUnit] = field(default_factory=list)
enemy_airlifts: List[AirliftUnit] = field(default_factory=list)
player_airlifts: List[AirliftUnits] = field(default_factory=list)
enemy_airlifts: List[AirliftUnits] = field(default_factory=list)
player_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
@ -160,7 +160,7 @@ class Debriefing:
yield from self.ground_losses.enemy_cargo_ships
@property
def airlift_losses(self) -> Iterator[AirliftUnit]:
def airlift_losses(self) -> Iterator[AirliftUnits]:
yield from self.ground_losses.player_airlifts
yield from self.ground_losses.enemy_airlifts
@ -220,7 +220,8 @@ class Debriefing:
else:
losses = self.ground_losses.enemy_airlifts
for loss in losses:
losses_by_type[loss.unit_type] += 1
for unit_type in loss.cargo:
losses_by_type[unit_type] += 1
return losses_by_type
def building_losses_by_type(self, player: bool) -> Dict[str, int]:

View File

@ -144,7 +144,7 @@ class Event:
def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
for package in ato.packages:
for flight in package.flights:
for idx, pilot in enumerate(flight.pilots):
for idx, pilot in enumerate(flight.roster.pilots):
if pilot is None:
logging.error(
f"Cannot award experience to pilot #{idx} of {flight} "
@ -202,19 +202,17 @@ class Event:
@staticmethod
def commit_airlift_losses(debriefing: Debriefing) -> None:
for loss in debriefing.airlift_losses:
unit_type = loss.unit_type
transfer = loss.transfer
available = loss.transfer.units.get(unit_type, 0)
airlift_name = f"airlift from {transfer.origin} to {transfer.destination}"
if available <= 0:
logging.error(
f"Found killed {unit_type} in {airlift_name} but that airlift has "
"none available."
)
continue
logging.info(f"{unit_type} destroyed in {airlift_name}")
transfer.kill_unit(unit_type)
for unit_type in loss.cargo:
try:
transfer.kill_unit(unit_type)
logging.info(f"{unit_type} destroyed in {airlift_name}")
except KeyError:
logging.exception(
f"Found killed {unit_type} in {airlift_name} but that airlift "
"has none available."
)
@staticmethod
def commit_ground_object_losses(debriefing: Debriefing) -> None:

View File

@ -1,4 +1,5 @@
from __future__ import annotations
from game.data.groundunitclass import GroundUnitClass
import logging
from dataclasses import dataclass, field
@ -133,6 +134,16 @@ class Faction:
#: both will use it.
unrestricted_satnav: bool = False
def has_access_to_unittype(self, unitclass: GroundUnitClass) -> bool:
has_access = False
for vehicle in unitclass.unit_list:
if vehicle in self.frontline_units:
return True
if vehicle in self.artillery_units:
return True
return has_access
@classmethod
def from_json(cls: Type[Faction], json: Dict[str, Any]) -> Faction:
faction = Faction(locales=json.get("locales"))

View File

@ -113,8 +113,6 @@ class Game:
self.informations.append(Information("Game Start", "-" * 40, 0))
# Culling Zones are for areas around points of interest that contain things we may not wish to cull.
self.__culling_zones: List[Point] = []
# Culling Points are for individual theater ground objects that we don't wish to cull.
self.__culling_points: List[Point] = []
self.__destroyed_units: List[str] = []
self.savepath = ""
self.budget = player_budget
@ -124,8 +122,8 @@ class Game:
self.conditions = self.generate_conditions()
self.blue_transit_network = self.compute_transit_network_for(player=True)
self.red_transit_network = self.compute_transit_network_for(player=False)
self.blue_transit_network = TransitNetwork()
self.red_transit_network = TransitNetwork()
self.blue_procurement_requests: List[AircraftProcurementRequest] = []
self.red_procurement_requests: List[AircraftProcurementRequest] = []
@ -148,7 +146,7 @@ class Game:
self.blue_air_wing = AirWing(self, player=True)
self.red_air_wing = AirWing(self, player=False)
self.on_load()
self.on_load(game_still_initializing=True)
def __getstate__(self) -> Dict[str, Any]:
state = self.__dict__.copy()
@ -301,11 +299,12 @@ class Game:
else:
raise RuntimeError(f"{event} was passed when an Event type was expected")
def on_load(self) -> None:
def on_load(self, game_still_initializing: bool = False) -> None:
LuaPluginManager.load_settings(self.settings)
ObjectiveDistanceCache.set_theater(self.theater)
self.compute_conflicts_position()
self.compute_threat_zones()
if not game_still_initializing:
self.compute_threat_zones()
self.blue_faker = Faker(self.faction_for(player=True).locales)
self.red_faker = Faker(self.faction_for(player=False).locales)
@ -439,8 +438,8 @@ class Game:
# gets much more of the budget that turn. Otherwise budget (after
# repairs) is split evenly between air and ground. For the default
# starting budget of 2000 this gives 600 to ground forces and 1400 to
# aircraft.
ground_portion = 0.3 if self.turn == 0 else 0.5
# aircraft. After that the budget will be spend proportionally based on how much is already invested
self.budget = ProcurementAi(
self,
for_player=True,
@ -448,7 +447,6 @@ class Game:
manage_runways=self.settings.automate_runway_repair,
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.enemy_budget = ProcurementAi(
@ -458,7 +456,6 @@ class Game:
manage_runways=True,
manage_front_line=True,
manage_aircraft=True,
front_line_budget_share=ground_portion,
).spend_budget(self.enemy_budget)
def message(self, text: str) -> None:
@ -519,7 +516,6 @@ class Game:
:return: List of points of interests
"""
zones = []
points = []
# By default, use the existing frontline conflict position
for front_line in self.theater.conflicts():
@ -529,11 +525,6 @@ class Game:
zones.append(front_line.red_cp.position)
for cp in self.theater.controlpoints:
# Don't cull missile sites - their range is long enough to make them
# easily culled despite being a threat.
for tgo in cp.ground_objects:
if isinstance(tgo, MissileSiteGroundObject):
points.append(tgo.position)
# If do_not_cull_carrier is enabled, add carriers as culling point
if self.settings.perf_do_not_cull_carrier:
if cp.is_carrier or cp.is_lha:
@ -577,7 +568,6 @@ class Game:
zones.append(Point(0, 0))
self.__culling_zones = zones
self.__culling_points = points
def add_destroyed_units(self, data):
pos = Point(data["x"], data["z"])
@ -593,19 +583,12 @@ class Game:
:param pos: Position you are tryng to spawn stuff at
:return: True if units can not be added at given position
"""
if self.settings.perf_culling == False:
if not self.settings.perf_culling:
return False
else:
for z in self.__culling_zones:
if (
z.distance_to_point(pos)
< self.settings.perf_culling_distance * 1000
):
return False
for p in self.__culling_points:
if p.distance_to_point(pos) < 2500:
return False
return True
for z in self.__culling_zones:
if z.distance_to_point(pos) < self.settings.perf_culling_distance * 1000:
return False
return True
def get_culling_zones(self):
"""
@ -614,13 +597,6 @@ class Game:
"""
return self.__culling_zones
def get_culling_points(self):
"""
Check culling points
:return: List of culling points
"""
return self.__culling_points
# 1 = red, 2 = blue
def get_player_coalition_id(self):
return 2

View File

@ -8,17 +8,19 @@ from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple, Type
from dcs.unittype import FlyingType, VehicleType
from game import db
from game.data.groundunitclass import GroundUnitClass
from game.factions.faction import Faction
from game.theater import ControlPoint, MissionTarget
from game.utils import Distance
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD
if TYPE_CHECKING:
from game import Game
FRONTLINE_RESERVES_FACTOR = 1.3
@dataclass(frozen=True)
class AircraftProcurementRequest:
@ -43,10 +45,7 @@ class ProcurementAi:
manage_runways: bool,
manage_front_line: bool,
manage_aircraft: bool,
front_line_budget_share: float,
) -> None:
if front_line_budget_share > 1.0:
raise ValueError
self.game = game
self.is_player = for_player
@ -55,14 +54,34 @@ class ProcurementAi:
self.manage_runways = manage_runways
self.manage_front_line = manage_front_line
self.manage_aircraft = manage_aircraft
self.front_line_budget_share = front_line_budget_share
self.threat_zones = self.game.threat_zone_for(not self.is_player)
def calculate_ground_unit_budget_share(self) -> float:
armor_investment = 0
aircraft_investment = 0
for cp in self.owned_points:
cp_ground_units = cp.allocated_ground_units(self.game.transfers)
armor_investment += cp_ground_units.total_value
cp_aircraft = cp.allocated_aircraft(self.game)
aircraft_investment += cp_aircraft.total_value
total_investment = aircraft_investment + armor_investment
if total_investment == 0:
# Turn 0 or all units were destroyed. Either way, split 30/70.
return 0.3
# the more planes we have, the more ground units we want and vice versa
ground_unit_share = aircraft_investment / total_investment
if ground_unit_share > 1.0:
raise ValueError
return ground_unit_share
def spend_budget(self, budget: float) -> float:
if self.manage_runways:
budget = self.repair_runways(budget)
if self.manage_front_line:
armor_budget = math.ceil(budget * self.front_line_budget_share)
armor_budget = budget * self.calculate_ground_unit_budget_share()
budget -= armor_budget
budget += self.reinforce_front_line(armor_budget)
@ -114,28 +133,14 @@ class ProcurementAi:
)
return budget
def random_affordable_ground_unit(
self, budget: float, cp: ControlPoint
def affordable_ground_unit_of_class(
self, budget: float, unit_class: GroundUnitClass
) -> Optional[Type[VehicleType]]:
affordable_units = [
u
for u in self.faction.frontline_units + self.faction.artillery_units
if db.PRICES[u] <= budget
]
total_number_aa = (
cp.base.total_frontline_aa + cp.pending_frontline_aa_deliveries_count
faction_units = set(self.faction.frontline_units) | set(
self.faction.artillery_units
)
total_non_aa = (
cp.base.total_armor + cp.pending_deliveries_count - total_number_aa
)
max_aa = math.ceil(total_non_aa / 8)
# Limit the number of AA units the AI will buy
if not total_number_aa < max_aa:
for unit in [u for u in affordable_units if u in TYPE_SHORAD]:
affordable_units.remove(unit)
of_class = set(unit_class.unit_list) & faction_units
affordable_units = [u for u in of_class if db.PRICES[u] <= budget]
if not affordable_units:
return None
return random.choice(affordable_units)
@ -147,12 +152,12 @@ class ProcurementAi:
# TODO: Attempt to transfer from reserves.
while budget > 0:
candidates = self.front_line_candidates()
if not candidates:
cp = self.ground_reinforcement_candidate()
if cp is None:
break
cp = random.choice(candidates)
unit = self.random_affordable_ground_unit(budget, cp)
most_needed_type = self.most_needed_unit_class(cp)
unit = self.affordable_ground_unit_of_class(budget, most_needed_type)
if unit is None:
# Can't afford any more units.
break
@ -162,6 +167,31 @@ class ProcurementAi:
return budget
def most_needed_unit_class(self, cp: ControlPoint) -> GroundUnitClass:
worst_balanced: Optional[GroundUnitClass] = None
worst_fulfillment = math.inf
for unit_class in GroundUnitClass:
if not self.faction.has_access_to_unittype(unit_class):
continue
current_ratio = self.cost_ratio_of_ground_unit(cp, unit_class)
desired_ratio = (
self.faction.doctrine.ground_unit_procurement_ratios.for_unit_class(
unit_class
)
)
if not desired_ratio:
continue
if current_ratio >= desired_ratio:
continue
fulfillment = current_ratio / desired_ratio
if fulfillment < worst_fulfillment:
worst_fulfillment = fulfillment
worst_balanced = unit_class
if worst_balanced is None:
return GroundUnitClass.Tank
return worst_balanced
def _affordable_aircraft_for_task(
self,
task: FlightType,
@ -179,7 +209,7 @@ class ProcurementAi:
continue
for squadron in self.air_wing.squadrons_for(unit):
if task in squadron.mission_types:
if task in squadron.auto_assignable_mission_types:
break
else:
continue
@ -244,11 +274,9 @@ class ProcurementAi:
) -> Iterator[ControlPoint]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
threatened = []
for cp in distance_cache.airfields_within(request.range):
for cp in distance_cache.operational_airfields_within(request.range):
if not cp.is_friendly(self.is_player):
continue
if not cp.runway_is_operational():
continue
if cp.unclaimed_parking(self.game) < request.number:
continue
if self.threat_zones.threatened(cp.position):
@ -256,56 +284,69 @@ class ProcurementAi:
yield cp
yield from threatened
def front_line_candidates(self) -> List[ControlPoint]:
candidates = []
def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
worst_supply = math.inf
understaffed: Optional[ControlPoint] = None
# 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_active_frontline:
continue
if not cp.has_ground_unit_source(self.game):
# No source of ground units, so can't buy anything.
continue
if (
total_ground_units_allocated_to_this_control_point >= 50
or total_ground_units_allocated_to_this_control_point
>= cp.frontline_unit_count_limit
):
purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR
allocated = cp.allocated_ground_units(self.game.transfers)
if allocated.total >= purchase_target:
# Control point is already sufficiently defended.
continue
for connected in cp.connected_points:
if not connected.is_friendly(to_player=self.is_player):
candidates.append(cp)
if allocated.total < worst_supply:
worst_supply = allocated.total
understaffed = cp
if not candidates:
# Otherwise buy reserves, but don't exceed 10 reserve units per CP.
# These units do not exist in the world until the CP becomes
# connected to an active front line, at which point all these units
# will suddenly appear at the gates of the newly captured CP.
#
# To avoid sudden overwhelming numbers of units we avoid buying
# many.
#
# Also, do not bother buying units at bases that will never connect
# to a front line.
for cp in self.owned_points:
if not cp.can_recruit_ground_units(self.game):
continue
if self.total_ground_units_allocated_to(cp) >= 10:
continue
if cp.is_global:
continue
candidates.append(cp)
if understaffed is not None:
return understaffed
return candidates
# Otherwise buy reserves, but don't exceed the amount defined in the settings.
# These units do not exist in the world until the CP becomes
# connected to an active front line, at which point all these units
# will suddenly appear at the gates of the newly captured CP.
#
# To avoid sudden overwhelming numbers of units we avoid buying
# many.
#
# Also, do not bother buying units at bases that will never connect
# to a front line.
for cp in self.owned_points:
if cp.is_global:
continue
if not cp.can_recruit_ground_units(self.game):
continue
def total_ground_units_allocated_to(self, control_point: ControlPoint) -> int:
total = control_point.expected_ground_units_next_turn.total
for transfer in self.game.transfers:
if transfer.destination == control_point:
total += sum(transfer.units.values())
return total
allocated = cp.allocated_ground_units(self.game.transfers)
if allocated.total >= self.game.settings.reserves_procurement_target:
continue
if allocated.total < worst_supply:
worst_supply = allocated.total
understaffed = cp
return understaffed
def cost_ratio_of_ground_unit(
self, control_point: ControlPoint, unit_class: GroundUnitClass
) -> float:
allocations = control_point.allocated_ground_units(self.game.transfers)
class_cost = 0
total_cost = 0
for unit_type, count in allocations.all.items():
cost = db.PRICES[unit_type] * count
total_cost += cost
if unit_type in unit_class:
class_cost += cost
if not total_cost:
return 0
return class_cost / total_cost

View File

@ -43,11 +43,11 @@ class Settings:
automate_front_line_reinforcements: bool = False
automate_aircraft_reinforcements: bool = False
restrict_weapons_by_date: bool = False
disable_legacy_aewc: bool = False
disable_legacy_aewc: bool = True
generate_dark_kneeboard: bool = False
invulnerable_player_pilots: bool = True
auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default
auto_ato_player_missions_asap: bool = False
auto_ato_player_missions_asap: bool = True
# Performance oriented
perf_red_alert_state: bool = True
@ -57,6 +57,7 @@ class Settings:
perf_moving_units: bool = True
perf_infantry: bool = True
perf_destroyed_units: bool = True
reserves_procurement_target: int = 10
# Performance culling
perf_culling: bool = False

View File

@ -10,10 +10,8 @@ from pathlib import Path
from typing import (
Type,
Tuple,
List,
TYPE_CHECKING,
Optional,
Iterable,
Iterator,
Sequence,
)
@ -83,9 +81,12 @@ class Squadron:
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)
mission_types: tuple[FlightType, ...]
pilots: list[Pilot]
available_pilots: list[Pilot] = field(init=False, hash=False, compare=False)
auto_assignable_mission_types: set[FlightType] = 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
@ -95,6 +96,7 @@ class Squadron:
def __post_init__(self) -> None:
self.available_pilots = list(self.active_pilots)
self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str:
return f'{self.name} "{self.nickname}"'
@ -142,8 +144,12 @@ class Squadron:
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 return_pilots(self, pilots: Sequence[Pilot]) -> None:
# Return in reverse so that returning two pilots and then getting two more
# results in the same ordering. This happens commonly when resetting rosters in
# the UI, when we clear the roster because the UI is updating, then end up
# repopulating the same size flight from the same squadron.
self.available_pilots.extend(reversed(pilots))
def enlist_new_pilots(self, count: int) -> None:
new_pilots = [Pilot(self.faker.name()) for _ in range(count)]
@ -160,6 +166,9 @@ class Squadron:
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.pilots if p.status == status]
def _pilots_without_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)
@ -169,8 +178,12 @@ class Squadron:
return self._pilots_with_status(PilotStatus.OnLeave)
@property
def size(self) -> int:
return len(self.active_pilots) + len(self.pilots_on_leave)
def number_of_pilots_including_dead(self) -> int:
return len(self.pilots)
@property
def number_of_living_pilots(self) -> int:
return len(self._pilots_without_status(PilotStatus.Dead))
def pilot_at_index(self, index: int) -> Pilot:
return self.pilots[index]
@ -213,6 +226,12 @@ class Squadron:
player=player,
)
def __setstate__(self, state) -> None:
# TODO: Remove save compat.
if "auto_assignable_mission_types" not in state:
state["auto_assignable_mission_types"] = set(state["mission_types"])
self.__dict__.update(state)
class SquadronLoader:
def __init__(self, game: Game, player: bool) -> None:

View File

@ -10,7 +10,6 @@ from dcs.vehicles import AirDefence, Armor
from game import db
from game.db import PRICES
from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD
STRENGTH_AA_ASSEMBLE_MIN = 0.2
PLANES_SCRAMBLE_MIN_BASE = 2
@ -25,6 +24,7 @@ class Base:
def __init__(self):
self.aircraft: Dict[Type[FlyingType], int] = {}
self.armor: Dict[Type[VehicleType], int] = {}
# TODO: Appears unused.
self.aa: Dict[AirDefence, int] = {}
self.commision_points: Dict[Type, float] = {}
self.strength = 1
@ -47,10 +47,6 @@ class Base:
logging.exception(f"No price found for {unit_type.id}")
return total
@property
def total_frontline_aa(self) -> int:
return sum([v for k, v in self.armor.items() if k in TYPE_SHORAD])
@property
def total_aa(self) -> int:
return sum(self.aa.values())

View File

@ -1,7 +1,6 @@
from __future__ import annotations
import itertools
import logging
import math
from dataclasses import dataclass
from functools import cached_property
@ -40,10 +39,6 @@ from dcs.unitgroup import (
VehicleGroup,
)
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
from .latlon import LatLon
from ..helipad import Helipad
from ..scenery_group import SceneryGroup
from pyproj import CRS, Transformer
from shapely import geometry, ops
@ -58,10 +53,12 @@ from .controlpoint import (
)
from .frontline import FrontLine
from .landmap import Landmap, load_landmap, poly_contains
from .latlon import LatLon
from .projections import TransverseMercator
from ..point_with_heading import PointWithHeading
from ..profiling import logged_duration
from ..utils import Distance, meters, nautical_miles
from ..scenery_group import SceneryGroup
from ..utils import Distance, meters
SIZE_TINY = 150
SIZE_SMALL = 600
@ -88,42 +85,39 @@ class MizCampaignLoader:
FOB_UNIT_TYPE = Unarmed.Truck_SKP_11_Mobile_ATC.id
FARP_HELIPAD = "SINGLE_HELIPAD"
EWR_UNIT_TYPE = AirDefence.EWR_55G6.id
SAM_UNIT_TYPE = AirDefence.SAM_SA_10_S_300_Grumble_Big_Bird_SR.id
GARRISON_UNIT_TYPE = AirDefence.SAM_SA_19_Tunguska_Grison.id
OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id
SHIP_UNIT_TYPE = DDG_Arleigh_Burke_IIa.id
MISSILE_SITE_UNIT_TYPE = MissilesSS.SSM_SS_1C_Scud_B.id
COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.AShM_SS_N_2_Silkworm.id
# Multiple options for the required SAMs so campaign designers can more
# accurately see the coverage of their IADS for the expected type.
REQUIRED_LONG_RANGE_SAM_UNIT_TYPES = {
# Multiple options for air defenses so campaign designers can more accurately see
# the coverage of their IADS for the expected type.
LONG_RANGE_SAM_UNIT_TYPES = {
AirDefence.SAM_Patriot_LN.id,
AirDefence.SAM_SA_10_S_300_Grumble_TEL_C.id,
AirDefence.SAM_SA_10_S_300_Grumble_TEL_D.id,
}
REQUIRED_MEDIUM_RANGE_SAM_UNIT_TYPES = {
MEDIUM_RANGE_SAM_UNIT_TYPES = {
AirDefence.SAM_Hawk_LN_M192.id,
AirDefence.SAM_SA_2_S_75_Guideline_LN.id,
AirDefence.SAM_SA_3_S_125_Goa_LN.id,
}
REQUIRED_SHORT_RANGE_SAM_UNIT_TYPES = {
SHORT_RANGE_SAM_UNIT_TYPES = {
AirDefence.SAM_Avenger__Stinger.id,
AirDefence.SAM_Rapier_LN.id,
AirDefence.SAM_SA_19_Tunguska_Grison.id,
AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL.id,
}
REQUIRED_AAA_UNIT_TYPES = {
AAA_UNIT_TYPES = {
AirDefence.AAA_8_8cm_Flak_18.id,
AirDefence.SPAAA_Vulcan_M163.id,
AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish.id,
}
REQUIRED_EWR_UNIT_TYPE = AirDefence.EWR_1L13.id
EWR_UNIT_TYPE = AirDefence.EWR_1L13.id
ARMOR_GROUP_UNIT_TYPE = Armor.MBT_M1A2_Abrams.id
@ -131,9 +125,7 @@ class MizCampaignLoader:
AMMUNITION_DEPOT_UNIT_TYPE = Warehouse.Ammunition_depot.id
REQUIRED_STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
BASE_DEFENSE_RADIUS = nautical_miles(2)
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
self.theater = theater
@ -211,98 +203,56 @@ class MizCampaignLoader:
@property
def ships(self) -> Iterator[ShipGroup]:
for group in self.blue.ship_group:
if group.units[0].type == self.SHIP_UNIT_TYPE:
yield group
@property
def required_ships(self) -> Iterator[ShipGroup]:
for group in self.red.ship_group:
if group.units[0].type == self.SHIP_UNIT_TYPE:
yield group
@property
def ewrs(self) -> Iterator[VehicleGroup]:
for group in self.blue.vehicle_group:
if group.units[0].type == self.EWR_UNIT_TYPE:
yield group
@property
def sams(self) -> Iterator[VehicleGroup]:
for group in self.blue.vehicle_group:
if group.units[0].type == self.SAM_UNIT_TYPE:
yield group
@property
def garrisons(self) -> Iterator[VehicleGroup]:
for group in self.blue.vehicle_group:
if group.units[0].type == self.GARRISON_UNIT_TYPE:
yield group
@property
def offshore_strike_targets(self) -> Iterator[StaticGroup]:
for group in self.blue.static_group:
if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE:
yield group
@property
def required_offshore_strike_targets(self) -> Iterator[StaticGroup]:
for group in self.red.static_group:
if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE:
yield group
@property
def missile_sites(self) -> Iterator[VehicleGroup]:
for group in self.blue.vehicle_group:
if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE:
yield group
@property
def required_missile_sites(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE:
yield group
@property
def coastal_defenses(self) -> Iterator[VehicleGroup]:
for group in self.blue.vehicle_group:
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
yield group
@property
def required_coastal_defenses(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
yield group
@property
def required_long_range_sams(self) -> Iterator[VehicleGroup]:
def long_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.REQUIRED_LONG_RANGE_SAM_UNIT_TYPES:
if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES:
yield group
@property
def required_medium_range_sams(self) -> Iterator[VehicleGroup]:
def medium_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.REQUIRED_MEDIUM_RANGE_SAM_UNIT_TYPES:
if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES:
yield group
@property
def required_short_range_sams(self) -> Iterator[VehicleGroup]:
def short_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.REQUIRED_SHORT_RANGE_SAM_UNIT_TYPES:
if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES:
yield group
@property
def required_aaa(self) -> Iterator[VehicleGroup]:
def aaa(self) -> Iterator[VehicleGroup]:
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
if group.units[0].type in self.REQUIRED_AAA_UNIT_TYPES:
if group.units[0].type in self.AAA_UNIT_TYPES:
yield group
@property
def required_ewrs(self) -> Iterator[VehicleGroup]:
def ewrs(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.REQUIRED_EWR_UNIT_TYPE:
if group.units[0].type in self.EWR_UNIT_TYPE:
yield group
@property
@ -330,9 +280,9 @@ class MizCampaignLoader:
yield group
@property
def required_strike_targets(self) -> Iterator[StaticGroup]:
def strike_targets(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.REQUIRED_STRIKE_TARGET_UNIT_TYPE:
if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE:
yield group
@property
@ -441,112 +391,57 @@ class MizCampaignLoader:
return closest, distance
def add_preset_locations(self) -> None:
for group in self.garrisons:
closest, distance = self.objective_info(group)
if distance < self.BASE_DEFENSE_RADIUS:
closest.preset_locations.base_garrisons.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
else:
logging.warning(f"Found garrison unit too far from base: {group.name}")
for group in self.sams:
closest, distance = self.objective_info(group)
if distance < self.BASE_DEFENSE_RADIUS:
closest.preset_locations.base_air_defense.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
else:
closest.preset_locations.strike_locations.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.ewrs:
closest, distance = self.objective_info(group)
if distance < self.BASE_DEFENSE_RADIUS:
closest.preset_locations.base_ewrs.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
else:
closest.preset_locations.ewrs.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.offshore_strike_targets:
closest, distance = self.objective_info(group)
closest.preset_locations.offshore_strike_locations.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_offshore_strike_targets:
closest, distance = self.objective_info(group)
closest.preset_locations.required_offshore_strike_locations.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.ships:
closest, distance = self.objective_info(group)
closest.preset_locations.ships.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_ships:
closest, distance = self.objective_info(group)
closest.preset_locations.required_ships.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.missile_sites:
closest, distance = self.objective_info(group)
closest.preset_locations.missile_sites.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_missile_sites:
closest, distance = self.objective_info(group)
closest.preset_locations.required_missile_sites.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.coastal_defenses:
closest, distance = self.objective_info(group)
closest.preset_locations.coastal_defenses.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_coastal_defenses:
for group in self.long_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.required_coastal_defenses.append(
closest.preset_locations.long_range_sams.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_long_range_sams:
for group in self.medium_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.required_long_range_sams.append(
closest.preset_locations.medium_range_sams.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_medium_range_sams:
for group in self.short_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.required_medium_range_sams.append(
closest.preset_locations.short_range_sams.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_short_range_sams:
for group in self.aaa:
closest, distance = self.objective_info(group)
closest.preset_locations.required_short_range_sams.append(
closest.preset_locations.aaa.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_aaa:
for group in self.ewrs:
closest, distance = self.objective_info(group)
closest.preset_locations.required_aaa.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_ewrs:
closest, distance = self.objective_info(group)
closest.preset_locations.required_ewrs.append(
closest.preset_locations.ewrs.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
@ -574,9 +469,9 @@ class MizCampaignLoader:
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.required_strike_targets:
for group in self.strike_targets:
closest, distance = self.objective_info(group)
closest.preset_locations.required_strike_locations.append(
closest.preset_locations.strike_locations.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)

View File

@ -3,11 +3,11 @@ from __future__ import annotations
import heapq
import itertools
import logging
import random
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass, field
from enum import Enum
from functools import total_ordering
from enum import Enum, unique, auto, IntEnum
from functools import total_ordering, cached_property
from typing import (
Any,
Dict,
@ -33,24 +33,19 @@ from dcs.ships import (
)
from dcs.terrain.terrain import Airport, ParkingSlot
from dcs.unit import Unit
from dcs.unittype import FlyingType
from dcs.unittype import FlyingType, VehicleType
from game import db
from game.point_with_heading import PointWithHeading
from game.scenery_group import SceneryGroup
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD
from gen.ground_forces.combat_stance import CombatStance
from gen.runways import RunwayAssigner, RunwayData
from .base import Base
from .missiontarget import MissionTarget
from .theatergroundobject import (
BaseDefenseGroundObject,
EwrGroundObject,
GenericCarrierGroundObject,
SamGroundObject,
TheaterGroundObject,
VehicleGroupGroundObject,
)
from ..db import PRICES
from ..helipad import Helipad
@ -60,6 +55,7 @@ from ..weather import Conditions
if TYPE_CHECKING:
from game import Game
from gen.flights.flight import FlightType
from ..transfers import PendingTransfers
FREE_FRONTLINE_UNIT_SUPPLY: int = 15
AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION: int = 12
@ -79,149 +75,133 @@ class ControlPointType(Enum):
OFF_MAP = 6
class LocationType(Enum):
BaseAirDefense = "base air defense"
Coastal = "coastal defense"
Ewr = "EWR"
BaseEwr = "Base EWR"
Garrison = "garrison"
MissileSite = "missile site"
OffshoreStrikeTarget = "offshore strike target"
Sam = "SAM"
Ship = "ship"
Shorad = "SHORAD"
StrikeTarget = "strike target"
@dataclass
class PresetLocations:
"""Defines the preset locations loaded from the campaign mission file."""
#: Locations used for spawning ground defenses for bases.
base_garrisons: List[PointWithHeading] = field(default_factory=list)
#: Locations used for spawning air defenses for bases. Used by SAMs, AAA,
#: and SHORADs.
base_air_defense: List[PointWithHeading] = field(default_factory=list)
#: Locations used by EWRs.
ewrs: List[PointWithHeading] = field(default_factory=list)
#: Locations used by Base EWRs.
base_ewrs: List[PointWithHeading] = field(default_factory=list)
#: Locations used by non-carrier ships. Carriers and LHAs are not random.
#: Locations used by non-carrier ships that will be spawned unless the faction has
#: no navy or the player has disabled ship generation for the owning side.
ships: List[PointWithHeading] = field(default_factory=list)
#: Locations used by non-carrier ships that will be spawned unless the faction has
#: no navy or the player has disable ship generation for the original owning side.
required_ships: List[PointWithHeading] = field(default_factory=list)
#: Locations used by coastal defenses.
#: Locations used by coastal defenses that are generated if the faction is capable.
coastal_defenses: List[PointWithHeading] = field(default_factory=list)
#: Locations used by coastal defenses that are always generated if the faction is
#: capable.
required_coastal_defenses: List[PointWithHeading] = field(default_factory=list)
#: Locations used by ground based strike objectives.
strike_locations: List[PointWithHeading] = field(default_factory=list)
#: Locations used by ground based strike objectives that will always be spawned.
required_strike_locations: List[PointWithHeading] = field(default_factory=list)
#: Locations used by offshore strike objectives.
offshore_strike_locations: List[PointWithHeading] = field(default_factory=list)
#: Locations used by offshore strike objectives that will always be spawned.
required_offshore_strike_locations: List[PointWithHeading] = field(
default_factory=list
)
#: Locations used by missile sites like scuds and V-2s.
#: Locations used by missile sites like scuds and V-2s that are generated if the
#: faction is capable.
missile_sites: List[PointWithHeading] = field(default_factory=list)
#: Locations used by missile sites like scuds and V-2s that are always generated if
#: the faction is capable.
required_missile_sites: List[PointWithHeading] = field(default_factory=list)
#: Locations of long range SAMs.
long_range_sams: List[PointWithHeading] = field(default_factory=list)
#: Locations of long range SAMs which should always be spawned.
required_long_range_sams: List[PointWithHeading] = field(default_factory=list)
#: Locations of medium range SAMs.
medium_range_sams: List[PointWithHeading] = field(default_factory=list)
#: Locations of medium range SAMs which should always be spawned.
required_medium_range_sams: List[PointWithHeading] = field(default_factory=list)
#: Locations of short range SAMs.
short_range_sams: List[PointWithHeading] = field(default_factory=list)
#: Locations of short range SAMs which should always be spawned.
required_short_range_sams: List[PointWithHeading] = field(default_factory=list)
#: Locations of AAA groups.
aaa: List[PointWithHeading] = field(default_factory=list)
#: Locations of AAA groups which should always be spawned.
required_aaa: List[PointWithHeading] = field(default_factory=list)
#: Locations of EWRs which should always be spawned.
required_ewrs: List[PointWithHeading] = field(default_factory=list)
#: Locations of EWRs.
ewrs: List[PointWithHeading] = field(default_factory=list)
#: Locations of map scenery to create zones for.
scenery: List[SceneryGroup] = field(default_factory=list)
#: Locations of factories for producing ground units. These will always be spawned.
#: Locations of factories for producing ground units.
factories: List[PointWithHeading] = field(default_factory=list)
#: Locations of ammo depots for controlling number of units on the front line at a control point.
#: 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.
#: Locations of stationary armor groups.
armor_groups: List[PointWithHeading] = field(default_factory=list)
@staticmethod
def _random_from(points: List[PointWithHeading]) -> Optional[PointWithHeading]:
"""Finds, removes, and returns a random position from the given list."""
if not points:
return None
point = random.choice(points)
points.remove(point)
return point
def random_for(self, location_type: LocationType) -> Optional[PointWithHeading]:
"""Returns a position suitable for the given location type.
The location, if found, will be claimed by the caller and not available
to subsequent calls.
"""
if location_type == LocationType.BaseAirDefense:
return self._random_from(self.base_air_defense)
if location_type == LocationType.Coastal:
return self._random_from(self.coastal_defenses)
if location_type == LocationType.Ewr:
return self._random_from(self.ewrs)
if location_type == LocationType.BaseEwr:
return self._random_from(self.base_ewrs)
if location_type == LocationType.Garrison:
return self._random_from(self.base_garrisons)
if location_type == LocationType.MissileSite:
return self._random_from(self.missile_sites)
if location_type == LocationType.OffshoreStrikeTarget:
return self._random_from(self.offshore_strike_locations)
if location_type == LocationType.Sam:
return self._random_from(self.strike_locations)
if location_type == LocationType.Ship:
return self._random_from(self.ships)
if location_type == LocationType.Shorad:
return self._random_from(self.base_garrisons)
if location_type == LocationType.StrikeTarget:
return self._random_from(self.strike_locations)
logging.error(f"Unknown location type: {location_type}")
return None
@dataclass(frozen=True)
class PendingOccupancy:
present: int
ordered: int
transferring: int
class AircraftAllocations:
present: dict[Type[FlyingType], int]
ordered: dict[Type[FlyingType], int]
transferring: dict[Type[FlyingType], int]
@property
def total_value(self) -> int:
total: int = 0
for unit_type, count in self.present.items():
total += PRICES[unit_type] * count
for unit_type, count in self.ordered.items():
total += PRICES[unit_type] * count
for unit_type, count in self.transferring.items():
total += PRICES[unit_type] * count
return total
@property
def total(self) -> int:
return self.present + self.ordered + self.transferring
return self.total_present + self.total_ordered + self.total_transferring
@property
def total_present(self) -> int:
return sum(self.present.values())
@property
def total_ordered(self) -> int:
return sum(self.ordered.values())
@property
def total_transferring(self) -> int:
return sum(self.transferring.values())
@dataclass(frozen=True)
class GroundUnitAllocations:
present: dict[Type[VehicleType], int]
ordered: dict[Type[VehicleType], int]
transferring: dict[Type[VehicleType], int]
@property
def all(self) -> dict[Type[VehicleType], int]:
combined: dict[Type[VehicleType], int] = defaultdict(int)
for unit_type, count in itertools.chain(
self.present.items(), self.ordered.items(), self.transferring.items()
):
combined[unit_type] += count
return dict(combined)
@property
def total_value(self) -> int:
total: int = 0
for unit_type, count in self.present.items():
total += PRICES[unit_type] * count
for unit_type, count in self.ordered.items():
total += PRICES[unit_type] * count
for unit_type, count in self.transferring.items():
total += PRICES[unit_type] * count
return total
@cached_property
def total(self) -> int:
return self.total_present + self.total_ordered + self.total_transferring
@cached_property
def total_present(self) -> int:
return sum(self.present.values())
@cached_property
def total_ordered(self) -> int:
return sum(self.ordered.values())
@cached_property
def total_transferring(self) -> int:
return sum(self.transferring.values())
@dataclass
@ -285,6 +265,13 @@ class GroundUnitDestination:
return self.total_value < other.total_value
@unique
class ControlPointStatus(IntEnum):
Functional = auto()
Damaged = auto()
Destroyed = auto()
class ControlPoint(MissionTarget, ABC):
position = None # type: Point
@ -315,7 +302,6 @@ class ControlPoint(MissionTarget, ABC):
self.full_name = name
self.at = at
self.connected_objectives: List[TheaterGroundObject] = []
self.base_defenses: List[BaseDefenseGroundObject] = []
self.preset_locations = PresetLocations()
self.helipads: List[Helipad] = []
@ -344,7 +330,7 @@ class ControlPoint(MissionTarget, ABC):
@property
def ground_objects(self) -> List[TheaterGroundObject]:
return list(itertools.chain(self.connected_objectives, self.base_defenses))
return list(self.connected_objectives)
@property
@abstractmethod
@ -553,24 +539,6 @@ class ControlPoint(MissionTarget, ABC):
def is_friendly_to(self, control_point: ControlPoint) -> bool:
return control_point.is_friendly(self.captured)
# TODO: Should be Airbase specific.
def clear_base_defenses(self) -> None:
for base_defense in self.base_defenses:
p = PointWithHeading.from_point(base_defense.position, base_defense.heading)
if isinstance(base_defense, EwrGroundObject):
self.preset_locations.base_ewrs.append(p)
elif isinstance(base_defense, SamGroundObject):
self.preset_locations.base_air_defense.append(p)
elif isinstance(base_defense, VehicleGroupGroundObject):
self.preset_locations.base_garrisons.append(p)
else:
logging.error(
"Could not determine preset location type for "
f"{base_defense}. Assuming garrison type."
)
self.preset_locations.base_garrisons.append(p)
self.base_defenses = []
def capture_equipment(self, game: Game) -> None:
total = self.base.total_armor_value
self.base.armor.clear()
@ -625,7 +593,7 @@ class ControlPoint(MissionTarget, ABC):
max_retreat_distance = nautical_miles(200)
# Skip the first airbase because that's the airbase we're retreating
# from.
airfields = list(closest.airfields_within(max_retreat_distance))[1:]
airfields = list(closest.operational_airfields_within(max_retreat_distance))[1:]
for airbase in airfields:
if not airbase.can_operate(airframe):
continue
@ -655,11 +623,17 @@ class ControlPoint(MissionTarget, ABC):
airframe, count = self.base.aircraft.popitem()
self._retreat_air_units(game, airframe, count)
def depopulate_uncapturable_tgos(self) -> None:
for tgo in self.connected_objectives:
if not tgo.capturable:
tgo.clear()
# TODO: Should be Airbase specific.
def capture(self, game: Game, for_player: bool) -> None:
self.pending_unit_deliveries.refund_all(game)
self.retreat_ground_units(game)
self.retreat_air_units(game)
self.depopulate_uncapturable_tgos()
if for_player:
self.captured = True
@ -668,46 +642,29 @@ class ControlPoint(MissionTarget, ABC):
self.base.set_strength_to_minimum()
self.clear_base_defenses()
from .start_generator import BaseDefenseGenerator
BaseDefenseGenerator(game, self).generate()
@abstractmethod
def can_operate(self, aircraft: Type[FlyingType]) -> bool:
...
def aircraft_transferring(self, game: Game) -> int:
def aircraft_transferring(self, game: Game) -> dict[Type[FlyingType], int]:
if self.captured:
ato = game.blue_ato
else:
ato = game.red_ato
total = 0
transferring: defaultdict[Type[FlyingType], int] = defaultdict(int)
for package in ato.packages:
for flight in package.flights:
if flight.departure == flight.arrival:
continue
if flight.departure == self:
total -= flight.count
transferring[flight.unit_type] -= flight.count
elif flight.arrival == self:
total += flight.count
return total
def expected_aircraft_next_turn(self, game: Game) -> PendingOccupancy:
on_order = 0
for unit_bought in self.pending_unit_deliveries.units:
if issubclass(unit_bought, FlyingType):
on_order += self.pending_unit_deliveries.units[unit_bought]
return PendingOccupancy(
self.base.total_aircraft, on_order, self.aircraft_transferring(game)
)
transferring[flight.unit_type] += flight.count
return transferring
def unclaimed_parking(self, game: Game) -> int:
return (
self.total_aircraft_parking - self.expected_aircraft_next_turn(game).total
)
return self.total_aircraft_parking - self.allocated_aircraft(game).total
@abstractmethod
def active_runway(
@ -757,47 +714,34 @@ class ControlPoint(MissionTarget, ABC):
u.position.x = u.position.x + delta.x
u.position.y = u.position.y + delta.y
@property
def pending_frontline_aa_deliveries_count(self):
"""
Get number of pending frontline aa units
"""
if self.pending_unit_deliveries:
return sum(
[
v
for k, v in self.pending_unit_deliveries.units.items()
if k in TYPE_SHORAD
]
)
else:
return 0
@property
def pending_deliveries_count(self):
"""
Get number of pending units
"""
if self.pending_unit_deliveries:
return sum([v for k, v in self.pending_unit_deliveries.units.items()])
else:
return 0
@property
def expected_ground_units_next_turn(self) -> PendingOccupancy:
on_order = 0
for unit_bought in self.pending_unit_deliveries.units:
def allocated_aircraft(self, game: Game) -> AircraftAllocations:
on_order = {}
for unit_bought, count in self.pending_unit_deliveries.units.items():
if issubclass(unit_bought, FlyingType):
continue
if unit_bought in TYPE_SHORAD:
continue
on_order += self.pending_unit_deliveries.units[unit_bought]
on_order[unit_bought] = count
return PendingOccupancy(
self.base.total_armor,
return AircraftAllocations(
self.base.aircraft, on_order, self.aircraft_transferring(game)
)
def allocated_ground_units(
self, transfers: PendingTransfers
) -> GroundUnitAllocations:
on_order = {}
for unit_bought, count in self.pending_unit_deliveries.units.items():
if issubclass(unit_bought, VehicleType):
on_order[unit_bought] = count
transferring: dict[Type[VehicleType], int] = defaultdict(int)
for transfer in transfers:
if transfer.destination == self:
for unit_type, count in transfer.units.items():
transferring[unit_type] += count
return GroundUnitAllocations(
self.base.armor,
on_order,
# Ground unit transfers not yet implemented.
transferring=0,
transferring,
)
@property
@ -816,18 +760,27 @@ class ControlPoint(MissionTarget, ABC):
@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
+ self.active_ammo_depots_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION
)
@property
def active_ammo_depots_count(self) -> int:
"""Return the number of available ammo depots"""
return len(
[
obj
for obj in self.connected_objectives
if obj.category == "ammo" and not obj.is_dead
]
)
@property
def total_ammo_depots_count(self) -> int:
"""Return the number of ammo depots, including dead ones"""
return len([obj for obj in self.connected_objectives if obj.category == "ammo"])
@property
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
return []
@ -837,6 +790,11 @@ class ControlPoint(MissionTarget, ABC):
def category(self) -> str:
...
@property
@abstractmethod
def status(self) -> ControlPointStatus:
...
class Airfield(ControlPoint):
def __init__(
@ -921,6 +879,15 @@ class Airfield(ControlPoint):
def category(self) -> str:
return "airfield"
@property
def status(self) -> ControlPointStatus:
runway_staus = self.runway_status
if runway_staus.needs_repair:
return ControlPointStatus.Destroyed
elif runway_staus.damaged:
return ControlPointStatus.Damaged
return ControlPointStatus.Functional
class NavalControlPoint(ControlPoint, ABC):
@property
@ -945,20 +912,24 @@ class NavalControlPoint(ControlPoint, ABC):
def heading(self) -> int:
return 0 # TODO compute heading
def find_main_tgo(self) -> TheaterGroundObject:
for g in self.ground_objects:
if g.dcs_identifier in ["CARRIER", "LHA"]:
return g
raise RuntimeError(f"Found no carrier/LHA group for {self.name}")
def runway_is_operational(self) -> bool:
# Necessary because it's possible for the carrier itself to have sunk
# while its escorts are still alive.
for g in self.ground_objects:
if g.dcs_identifier in ["CARRIER", "LHA"]:
for group in g.groups:
for u in group.units:
if db.unit_type_from_name(u.type) in [
CVN_74_John_C__Stennis,
LHA_1_Tarawa,
CV_1143_5_Admiral_Kuznetsov,
Type_071_Amphibious_Transport_Dock,
]:
return True
for group in self.find_main_tgo().groups:
for u in group.units:
if db.unit_type_from_name(u.type) in [
CVN_74_John_C__Stennis,
LHA_1_Tarawa,
CV_1143_5_Admiral_Kuznetsov,
Type_071_Amphibious_Transport_Dock,
]:
return True
return False
def active_runway(
@ -984,6 +955,14 @@ class NavalControlPoint(ControlPoint, ABC):
def can_deploy_ground_units(self) -> bool:
return False
@property
def status(self) -> ControlPointStatus:
if not self.runway_is_operational():
return ControlPointStatus.Destroyed
if self.find_main_tgo().dead_units:
return ControlPointStatus.Damaged
return ControlPointStatus.Functional
class Carrier(NavalControlPoint):
def __init__(self, name: str, at: Point, cp_id: int):
@ -1113,6 +1092,10 @@ class OffMapSpawn(ControlPoint):
def category(self) -> str:
return "offmap"
@property
def status(self) -> ControlPointStatus:
return ControlPointStatus.Functional
class Fob(ControlPoint):
def __init__(self, name: str, at: Point, cp_id: int):
@ -1176,3 +1159,7 @@ class Fob(ControlPoint):
@property
def category(self) -> str:
return "fob"
@property
def status(self) -> ControlPointStatus:
return ControlPointStatus.Functional

View File

@ -1,12 +1,11 @@
from __future__ import annotations
from game.scenery_group import SceneryGroup
import logging
import pickle
import random
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional, Set
from typing import Any, Dict, Iterable, List, Set
from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike
@ -14,7 +13,8 @@ from dcs.vehicles import AirDefence
from game import Game, db
from game.factions.faction import Faction
from game.theater import Carrier, Lha, LocationType, PointWithHeading
from game.scenery_group import SceneryGroup
from game.theater import Carrier, Lha, PointWithHeading
from game.theater.theatergroundobject import (
BuildingGroundObject,
CarrierGroundObject,
@ -39,8 +39,8 @@ from gen.fleet.ship_group_generator import (
)
from gen.missiles.missiles_group_generator import generate_missile_group
from gen.sam.airdefensegroupgenerator import AirDefenseRange
from gen.sam.sam_group_generator import generate_anti_air_group
from gen.sam.ewr_group_generator import generate_ewr_group
from gen.sam.sam_group_generator import generate_anti_air_group
from . import (
ConflictTheater,
ControlPoint,
@ -145,24 +145,6 @@ class GameGenerator:
cp.captured = True
class LocationFinder:
def __init__(self, control_point: ControlPoint) -> None:
self.control_point = control_point
def location_for(self, location_type: LocationType) -> Optional[PointWithHeading]:
position = self.control_point.preset_locations.random_for(location_type)
if position is not None:
logging.warning(
f"Campaign relies on random generation of %s at %s. Support for random "
"objectives will be removed soon.",
location_type.value,
self.control_point,
)
return position
return None
class ControlPointGroundObjectGenerator:
def __init__(
self,
@ -173,7 +155,6 @@ class ControlPointGroundObjectGenerator:
self.game = game
self.generator_settings = generator_settings
self.control_point = control_point
self.location_finder = LocationFinder(control_point)
@property
def faction_name(self) -> str:
@ -203,19 +184,9 @@ class ControlPointGroundObjectGenerator:
if not self.control_point.captured and skip_enemy_navy:
return
self.generate_required_ships()
for _ in range(self.faction.navy_group_count):
self.generate_ship()
def generate_required_ships(self) -> None:
for position in self.control_point.preset_locations.required_ships:
for position in self.control_point.preset_locations.ships:
self.generate_ship_at(position)
def generate_ship(self) -> None:
point = self.location_finder.location_for(LocationType.Ship)
if point is not None:
self.generate_ship_at(point)
def generate_ship_at(self, position: PointWithHeading) -> None:
group_id = self.game.next_group_id()
@ -289,159 +260,6 @@ class LhaGroundObjectGenerator(ControlPointGroundObjectGenerator):
return True
class BaseDefenseGenerator:
def __init__(self, game: Game, control_point: ControlPoint) -> None:
self.game = game
self.control_point = control_point
self.location_finder = LocationFinder(control_point)
@property
def faction_name(self) -> str:
if self.control_point.captured:
return self.game.player_name
else:
return self.game.enemy_name
@property
def faction(self) -> Faction:
return db.FACTIONS[self.faction_name]
def generate(self) -> None:
self.generate_ewr()
self.generate_garrison()
self.generate_base_defenses()
def generate_ewr(self) -> None:
position = self.location_finder.location_for(LocationType.BaseEwr)
if position is None:
return
group_id = self.game.next_group_id()
g = EwrGroundObject(
namegen.random_objective_name(),
group_id,
position,
self.control_point,
True,
)
group = generate_ewr_group(self.game, g, self.faction)
if group is None:
logging.error(f"Could not generate EWR at {self.control_point}")
return
g.groups = [group]
self.control_point.base_defenses.append(g)
def generate_base_defenses(self) -> None:
# First group has a 1/2 chance of being a SAM, 1/6 chance of SHORAD,
# and a 1/6 chance of a garrison.
#
# Further groups have a 1/3 chance of being SHORAD and 2/3 chance of
# being a garrison.
for i in range(random.randint(2, 5)):
if i == 0 and random.randint(0, 1) == 0:
self.generate_sam()
elif random.randint(0, 2) == 1:
self.generate_shorad()
else:
self.generate_garrison()
def generate_garrison(self) -> None:
position = self.location_finder.location_for(LocationType.Garrison)
if position is None:
return
group_id = self.game.next_group_id()
g = VehicleGroupGroundObject(
namegen.random_objective_name(),
group_id,
position,
self.control_point,
for_airbase=True,
)
group = generate_armor_group(self.faction_name, self.game, g)
if group is None:
logging.error(f"Could not generate garrison at {self.control_point}")
return
g.groups.append(group)
self.control_point.base_defenses.append(g)
def generate_sam(self) -> None:
position = self.location_finder.location_for(LocationType.BaseAirDefense)
if position is None:
return
group_id = self.game.next_group_id()
g = SamGroundObject(
namegen.random_objective_name(),
group_id,
position,
self.control_point,
for_airbase=True,
)
groups = generate_anti_air_group(self.game, g, self.faction)
if not groups:
logging.error(f"Could not generate SAM at {self.control_point}")
return
g.groups = groups
self.control_point.base_defenses.append(g)
def generate_shorad(self) -> None:
position = self.location_finder.location_for(LocationType.BaseAirDefense)
if position is None:
return
group_id = self.game.next_group_id()
g = SamGroundObject(
namegen.random_objective_name(),
group_id,
position,
self.control_point,
for_airbase=True,
)
groups = generate_anti_air_group(
self.game,
g,
self.faction,
ranges=[{AirDefenseRange.Short, AirDefenseRange.AAA}],
)
if not groups:
logging.error(f"Could not generate SHORAD group at {self.control_point}")
return
g.groups = groups
self.control_point.base_defenses.append(g)
class FobDefenseGenerator(BaseDefenseGenerator):
def generate(self) -> None:
self.generate_garrison()
self.generate_fob_defenses()
def generate_fob_defenses(self):
# First group has a 1/2 chance of being a SHORAD,
# and a 1/2 chance of a garrison.
#
# Further groups have a 1/3 chance of being SHORAD and 2/3 chance of
# being a garrison.
for i in range(random.randint(2, 5)):
if i == 0 and random.randint(0, 1) == 0:
self.generate_shorad()
elif i == 0 and random.randint(0, 1) == 0:
self.generate_garrison()
elif random.randint(0, 2) == 1:
self.generate_shorad()
else:
self.generate_garrison()
class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
def __init__(
self,
@ -457,16 +275,14 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
if not super().generate():
return False
BaseDefenseGenerator(self.game, self.control_point).generate()
self.generate_ground_points()
return True
def generate_ground_points(self) -> None:
"""Generate ground objects and AA sites for the control point."""
self.generate_armor_groups()
skip_sams = self.generate_required_aa()
skip_ewrs = self.generate_required_ewr()
self.generate_aa()
self.generate_ewrs()
self.generate_scenery_sites()
self.generate_strike_targets()
self.generate_offshore_strike_targets()
@ -475,35 +291,9 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
if self.faction.missiles:
self.generate_missile_sites()
self.generate_required_missile_sites()
if self.faction.coastal_defenses:
self.generate_coastal_sites()
self.generate_required_coastal_sites()
if self.control_point.is_global:
return
# Always generate at least one AA point.
self.generate_aa_site()
# And between 2 and 7 other objectives.
amount = random.randrange(2, 7)
for i in range(amount):
# 1 in 4 additional objectives are AA.
if random.randint(0, 3) == 0:
if skip_sams > 0:
skip_sams -= 1
else:
self.generate_aa_site()
# 1 in 4 additional objectives are EWR.
elif random.randint(0, 3) == 0:
if skip_ewrs > 0:
skip_ewrs -= 1
else:
self.generate_ewr_site()
else:
self.generate_ground_point()
def generate_armor_groups(self) -> None:
for position in self.control_point.preset_locations.armor_groups:
@ -517,7 +307,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
group_id,
position,
self.control_point,
for_airbase=False,
)
group = generate_armor_group(self.faction_name, self.game, g)
@ -531,14 +320,9 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
g.groups = [group]
self.control_point.connected_objectives.append(g)
def generate_required_aa(self) -> int:
"""Generates the AA sites that are required by the campaign.
Returns:
The number of AA sites that were generated.
"""
def generate_aa(self) -> None:
presets = self.control_point.preset_locations
for position in presets.required_long_range_sams:
for position in presets.long_range_sams:
self.generate_aa_at(
position,
ranges=[
@ -548,7 +332,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
{AirDefenseRange.AAA},
],
)
for position in presets.required_medium_range_sams:
for position in presets.medium_range_sams:
self.generate_aa_at(
position,
ranges=[
@ -557,52 +341,21 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
{AirDefenseRange.AAA},
],
)
for position in presets.required_short_range_sams:
for position in presets.short_range_sams:
self.generate_aa_at(
position,
ranges=[{AirDefenseRange.Short}, {AirDefenseRange.AAA}],
)
for position in presets.required_aaa:
for position in presets.aaa:
self.generate_aa_at(
position,
ranges=[{AirDefenseRange.AAA}],
)
return (
len(presets.required_long_range_sams)
+ len(presets.required_medium_range_sams)
+ len(presets.required_short_range_sams)
+ len(presets.required_aaa)
)
def generate_required_ewr(self) -> int:
"""Generates the EWR sites that are required by the campaign.
Returns:
The number of EWR sites that were generated.
"""
def generate_ewrs(self) -> None:
presets = self.control_point.preset_locations
for position in presets.required_ewrs:
for position in presets.ewrs:
self.generate_ewr_at(position)
return len(presets.required_ewrs)
def generate_ground_point(self) -> None:
try:
category = random.choice(self.faction.building_set)
except IndexError:
logging.exception("Faction has no buildings defined")
return
if category == "oil":
location_type = LocationType.OffshoreStrikeTarget
else:
location_type = LocationType.StrikeTarget
# Pick from preset locations
point = self.location_finder.location_for(location_type)
if point is None:
return
self.generate_strike_target_at(category, point)
def generate_strike_target_at(self, category: str, position: Point) -> None:
@ -635,7 +388,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
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:
self.generate_factory_at(position)
@ -653,19 +405,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
self.control_point.connected_objectives.append(g)
def generate_aa_site(self) -> None:
position = self.location_finder.location_for(LocationType.Sam)
if position is None:
return
self.generate_aa_at(
position,
ranges=[
# Prefer to use proper SAMs, but fall back to SHORADs if needed.
{AirDefenseRange.Long, AirDefenseRange.Medium},
{AirDefenseRange.Short},
],
)
def generate_aa_at(
self, position: Point, ranges: Iterable[Set[AirDefenseRange]]
) -> None:
@ -676,7 +415,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
group_id,
position,
self.control_point,
for_airbase=False,
)
groups = generate_anti_air_group(self.game, g, self.faction, ranges)
if not groups:
@ -689,12 +427,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
g.groups = groups
self.control_point.connected_objectives.append(g)
def generate_ewr_site(self) -> None:
position = self.location_finder.location_for(LocationType.Ewr)
if position is None:
return
self.generate_ewr_at(position)
def generate_ewr_at(self, position: PointWithHeading) -> None:
group_id = self.game.next_group_id()
@ -703,7 +435,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
group_id,
position,
self.control_point,
for_airbase=False,
)
group = generate_ewr_group(self.game, g, self.faction)
if group is None:
@ -750,18 +481,9 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
return
def generate_required_missile_sites(self) -> None:
for position in self.control_point.preset_locations.required_missile_sites:
self.generate_missile_site_at(position)
def generate_missile_sites(self) -> None:
for i in range(self.faction.missiles_group_count):
self.generate_missile_site()
def generate_missile_site(self) -> None:
position = self.location_finder.location_for(LocationType.MissileSite)
if position is not None:
return self.generate_missile_site_at(position)
for position in self.control_point.preset_locations.missile_sites:
self.generate_missile_site_at(position)
def generate_missile_site_at(self, position: PointWithHeading) -> None:
group_id = self.game.next_group_id()
@ -776,17 +498,8 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
self.control_point.connected_objectives.append(g)
return
def generate_required_coastal_sites(self) -> None:
for position in self.control_point.preset_locations.required_coastal_defenses:
self.generate_coastal_site_at(position)
def generate_coastal_sites(self) -> None:
for i in range(self.faction.coastal_group_count):
self.generate_coastal_site()
def generate_coastal_site(self) -> None:
position = self.location_finder.location_for(LocationType.Coastal)
if position is not None:
for position in self.control_point.preset_locations.coastal_defenses:
self.generate_coastal_site_at(position)
def generate_coastal_site_at(self, position: PointWithHeading) -> None:
@ -807,46 +520,39 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
return
def generate_strike_targets(self) -> None:
"""Generates the strike targets that are required by the campaign."""
building_set = list(set(self.faction.building_set) - {"oil"})
if not building_set:
logging.error("Faction has no buildings defined")
return
for position in self.control_point.preset_locations.required_strike_locations:
for position in self.control_point.preset_locations.strike_locations:
category = random.choice(building_set)
self.generate_strike_target_at(category, position)
def generate_offshore_strike_targets(self) -> None:
"""Generates the offshore strike targets that are required by the campaign."""
if "oil" not in self.faction.building_set:
logging.error("Faction does not support offshore strike targets")
return
for (
position
) in self.control_point.preset_locations.required_offshore_strike_locations:
for position in self.control_point.preset_locations.offshore_strike_locations:
self.generate_strike_target_at("oil", position)
class FobGroundObjectGenerator(AirbaseGroundObjectGenerator):
def generate(self) -> bool:
self.generate_fob()
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_aa()
self.generate_ewrs()
self.generate_scenery_sites()
self.generate_strike_targets()
self.generate_offshore_strike_targets()
if self.faction.missiles:
self.generate_missile_sites()
self.generate_required_missile_sites()
if self.faction.coastal_defenses:
self.generate_coastal_sites()
self.generate_required_coastal_sites()
return True
@ -873,7 +579,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator):
unit["heading"],
self.control_point,
unit["type"],
airbase_group=True,
is_fob_structure=True,
)
self.control_point.connected_objectives.append(g)

View File

@ -12,7 +12,6 @@ from dcs.unittype import VehicleType
from .. import db
from ..data.radar_db import (
UNITS_WITH_RADAR,
TRACK_RADARS,
TELARS,
LAUNCHER_TRACKER_PAIRS,
@ -58,7 +57,6 @@ class TheaterGroundObject(MissionTarget):
heading: int,
control_point: ControlPoint,
dcs_identifier: str,
airbase_group: bool,
sea_object: bool,
) -> None:
super().__init__(name, position)
@ -67,7 +65,6 @@ class TheaterGroundObject(MissionTarget):
self.heading = heading
self.control_point = control_point
self.dcs_identifier = dcs_identifier
self.airbase_group = airbase_group
self.sea_object = sea_object
self.groups: List[Group] = []
@ -193,6 +190,21 @@ class TheaterGroundObject(MissionTarget):
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
return self.units
@property
def mark_locations(self) -> Iterator[Point]:
yield self.position
def clear(self) -> None:
self.groups = []
@property
def capturable(self) -> bool:
raise NotImplementedError
@property
def purchasable(self) -> bool:
raise NotImplementedError
class BuildingGroundObject(TheaterGroundObject):
def __init__(
@ -205,7 +217,7 @@ class BuildingGroundObject(TheaterGroundObject):
heading: int,
control_point: ControlPoint,
dcs_identifier: str,
airbase_group=False,
is_fob_structure=False,
) -> None:
super().__init__(
name=name,
@ -215,9 +227,9 @@ class BuildingGroundObject(TheaterGroundObject):
heading=heading,
control_point=control_point,
dcs_identifier=dcs_identifier,
airbase_group=airbase_group,
sea_object=False,
)
self.is_fob_structure = is_fob_structure
self.object_id = object_id
# Other TGOs track deadness based on the number of alive units, but
# buildings don't have groups assigned to the TGO.
@ -250,6 +262,23 @@ class BuildingGroundObject(TheaterGroundObject):
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
return list(self.iter_building_group())
@property
def mark_locations(self) -> Iterator[Point]:
for building in self.iter_building_group():
yield building.position
@property
def is_control_point(self) -> bool:
return self.is_fob_structure
@property
def capturable(self) -> bool:
return True
@property
def purchasable(self) -> bool:
return False
class SceneryGroundObject(BuildingGroundObject):
def __init__(
@ -272,7 +301,7 @@ class SceneryGroundObject(BuildingGroundObject):
heading=0,
control_point=control_point,
dcs_identifier=dcs_identifier,
airbase_group=False,
is_fob_structure=False,
)
self.zone = zone
try:
@ -305,7 +334,7 @@ class FactoryGroundObject(BuildingGroundObject):
heading=heading,
control_point=control_point,
dcs_identifier="Workshop A",
airbase_group=False,
is_fob_structure=False,
)
@ -321,6 +350,14 @@ class NavalGroundObject(TheaterGroundObject):
def might_have_aa(self) -> bool:
return True
@property
def capturable(self) -> bool:
return False
@property
def purchasable(self) -> bool:
return False
class GenericCarrierGroundObject(NavalGroundObject):
@property
@ -339,7 +376,6 @@ class CarrierGroundObject(GenericCarrierGroundObject):
heading=0,
control_point=control_point,
dcs_identifier="CARRIER",
airbase_group=True,
sea_object=True,
)
@ -361,7 +397,6 @@ class LhaGroundObject(GenericCarrierGroundObject):
heading=0,
control_point=control_point,
dcs_identifier="LHA",
airbase_group=True,
sea_object=True,
)
@ -384,10 +419,17 @@ class MissileSiteGroundObject(TheaterGroundObject):
heading=0,
control_point=control_point,
dcs_identifier="AA",
airbase_group=False,
sea_object=False,
)
@property
def capturable(self) -> bool:
return False
@property
def purchasable(self) -> bool:
return False
class CoastalSiteGroundObject(TheaterGroundObject):
def __init__(
@ -406,26 +448,28 @@ class CoastalSiteGroundObject(TheaterGroundObject):
heading=heading,
control_point=control_point,
dcs_identifier="AA",
airbase_group=False,
sea_object=False,
)
@property
def capturable(self) -> bool:
return False
class BaseDefenseGroundObject(TheaterGroundObject):
"""Base type for all base defenses."""
@property
def purchasable(self) -> bool:
return False
# TODO: Differentiate types.
# This type gets used both for AA sites (SAM, AAA, or SHORAD). These should each
# be split into their own types.
class SamGroundObject(BaseDefenseGroundObject):
class SamGroundObject(TheaterGroundObject):
def __init__(
self,
name: str,
group_id: int,
position: Point,
control_point: ControlPoint,
for_airbase: bool,
) -> None:
super().__init__(
name=name,
@ -435,7 +479,6 @@ class SamGroundObject(BaseDefenseGroundObject):
heading=0,
control_point=control_point,
dcs_identifier="AA",
airbase_group=for_airbase,
sea_object=False,
)
# Set by the SAM unit generator if the generated group is compatible
@ -495,15 +538,22 @@ class SamGroundObject(BaseDefenseGroundObject):
else:
return max(max_tel_range, max_telar_range, max_non_radar)
@property
def capturable(self) -> bool:
return False
class VehicleGroupGroundObject(BaseDefenseGroundObject):
@property
def purchasable(self) -> bool:
return True
class VehicleGroupGroundObject(TheaterGroundObject):
def __init__(
self,
name: str,
group_id: int,
position: Point,
control_point: ControlPoint,
for_airbase: bool,
) -> None:
super().__init__(
name=name,
@ -513,19 +563,25 @@ class VehicleGroupGroundObject(BaseDefenseGroundObject):
heading=0,
control_point=control_point,
dcs_identifier="AA",
airbase_group=for_airbase,
sea_object=False,
)
@property
def capturable(self) -> bool:
return False
class EwrGroundObject(BaseDefenseGroundObject):
@property
def purchasable(self) -> bool:
return True
class EwrGroundObject(TheaterGroundObject):
def __init__(
self,
name: str,
group_id: int,
position: Point,
control_point: ControlPoint,
for_airbase: bool,
) -> None:
super().__init__(
name=name,
@ -535,7 +591,6 @@ class EwrGroundObject(BaseDefenseGroundObject):
heading=0,
control_point=control_point,
dcs_identifier="EWR",
airbase_group=for_airbase,
sea_object=False,
)
@ -555,6 +610,14 @@ class EwrGroundObject(BaseDefenseGroundObject):
def might_have_aa(self) -> bool:
return True
@property
def capturable(self) -> bool:
return False
@property
def purchasable(self) -> bool:
return True
class ShipGroundObject(NavalGroundObject):
def __init__(
@ -568,7 +631,6 @@ class ShipGroundObject(NavalGroundObject):
heading=0,
control_point=control_point,
dcs_identifier="AA",
airbase_group=False,
sea_object=True,
)

View File

@ -40,6 +40,10 @@ class ThreatZones:
)
return DcsPoint(boundary.x, boundary.y)
def distance_to_threat(self, point: DcsPoint) -> Distance:
boundary = self.closest_boundary(point)
return meters(boundary.distance_to_point(point))
@singledispatchmethod
def threatened(self, position) -> bool:
raise NotImplementedError
@ -124,7 +128,7 @@ class ThreatZones:
cls, location: ControlPoint, max_distance: Distance
) -> Optional[ControlPoint]:
airfields = ObjectiveDistanceCache.get_closest_airfields(location)
for airfield in airfields.airfields_within(max_distance):
for airfield in airfields.all_airfields_within(max_distance):
if airfield.captured != location.captured:
return airfield
return None

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import math
from collections import defaultdict
from dataclasses import dataclass, field
from functools import singledispatchmethod
@ -89,10 +90,9 @@ class TransferOrder:
self.units.clear()
def kill_unit(self, unit_type: Type[VehicleType]) -> None:
if unit_type in self.units:
self.units[unit_type] -= 1
return
raise KeyError
if unit_type not in self.units or not self.units[unit_type]:
raise KeyError(f"{self.destination} has no {unit_type} remaining")
self.units[unit_type] -= 1
@property
def size(self) -> int:
@ -238,7 +238,7 @@ class AirliftPlanner:
for s in self.game.air_wing_for(self.for_player).squadrons_for(
unit_type
)
if FlightType.TRANSPORT in s.mission_types
if FlightType.TRANSPORT in s.auto_assignable_mission_types
]
if not squadrons:
continue
@ -254,11 +254,13 @@ class AirliftPlanner:
self, squadron: Squadron, inventory: ControlPointAircraftInventory
) -> int:
available = inventory.available(squadron.aircraft)
# 4 is the max flight size in DCS.
flight_size = min(self.transfer.size, available, 4)
capacity_each = 1 if squadron.aircraft.helicopter else 2
required = math.ceil(self.transfer.size / capacity_each)
flight_size = min(required, available, squadron.aircraft.group_size_max)
capacity = flight_size * capacity_each
if flight_size < self.transfer.size:
transfer = self.game.transfers.split_transfer(self.transfer, flight_size)
if capacity < self.transfer.size:
transfer = self.game.transfers.split_transfer(self.transfer, capacity)
else:
transfer = self.transfer
@ -530,33 +532,35 @@ class PendingTransfers:
return new_transfer
@singledispatchmethod
def cancel_transport(self, transfer: TransferOrder, transport) -> None:
def cancel_transport(self, transport, transfer: TransferOrder) -> None:
pass
@cancel_transport.register
def _cancel_transport_air(
self, _transfer: TransferOrder, transport: Airlift
self, transport: Airlift, _transfer: TransferOrder
) -> None:
flight = transport.flight
flight.package.remove_flight(flight)
if not flight.package.flights:
self.game.ato_for(transport.player_owned).remove_package(flight.package)
self.game.aircraft_inventory.return_from_flight(flight)
flight.clear_roster()
@cancel_transport.register
def _cancel_transport_convoy(
self, transfer: TransferOrder, transport: Convoy
self, transport: Convoy, transfer: TransferOrder
) -> None:
self.convoys.remove(transport, transfer)
@cancel_transport.register
def _cancel_transport_cargo_ship(
self, transfer: TransferOrder, transport: CargoShip
self, transport: CargoShip, transfer: TransferOrder
) -> None:
self.cargo_ships.remove(transport, transfer)
def cancel_transfer(self, transfer: TransferOrder) -> None:
if transfer.transport is not None:
self.cancel_transport(transfer, transfer.transport)
self.cancel_transport(transfer.transport, transfer)
self.pending_transfers.remove(transfer)
transfer.origin.base.commision_units(transfer.units)

View File

@ -1,4 +1,6 @@
"""Maps generated units back to their Liberation types."""
import itertools
import math
from dataclasses import dataclass
from typing import Dict, Optional, Type
@ -40,8 +42,8 @@ class ConvoyUnit:
@dataclass(frozen=True)
class AirliftUnit:
unit_type: Type[VehicleType]
class AirliftUnits:
cargo: tuple[Type[VehicleType], ...]
transfer: TransferOrder
@ -59,10 +61,10 @@ class UnitMap:
self.buildings: Dict[str, Building] = {}
self.convoys: Dict[str, ConvoyUnit] = {}
self.cargo_ships: Dict[str, CargoShip] = {}
self.airlifts: Dict[str, AirliftUnit] = {}
self.airlifts: Dict[str, AirliftUnits] = {}
def add_aircraft(self, group: FlyingGroup, flight: Flight) -> None:
for pilot, unit in zip(flight.pilots, group.units):
for pilot, unit in zip(flight.roster.pilots, group.units):
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(unit.name)
@ -177,15 +179,26 @@ class UnitMap:
return self.cargo_ships.get(name, None)
def add_airlift_units(self, group: FlyingGroup, transfer: TransferOrder) -> None:
for transport, cargo_type in zip(group.units, transfer.iter_units()):
capacity_each = math.ceil(transfer.size / len(group.units))
for idx, transport in enumerate(group.units):
# Slice the units in groups based on the capacity of each unit. Cargo is
# assigned arbitrarily to units in the order of the group. The last unit in
# the group will receive a partial load if there is not enough cargo to fill
# every transport.
base_idx = idx * capacity_each
cargo = tuple(
itertools.islice(
transfer.iter_units(), base_idx, base_idx + capacity_each
)
)
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(transport.name)
if name in self.airlifts:
raise RuntimeError(f"Duplicate airlift unit: {name}")
self.airlifts[name] = AirliftUnit(cargo_type, transfer)
self.airlifts[name] = AirliftUnits(cargo, transfer)
def airlift_unit(self, name: str) -> Optional[AirliftUnit]:
def airlift_unit(self, name: str) -> Optional[AirliftUnits]:
return self.airlifts.get(name, None)
def add_building(self, ground_object: BuildingGroundObject, group: Group) -> None:

View File

@ -2,7 +2,7 @@ from pathlib import Path
def _build_version_string() -> str:
components = ["3.0"]
components = ["4.0"]
build_number_path = Path("resources/buildnumber")
if build_number_path.exists():
with build_number_path.open("r") as build_number_file:
@ -75,10 +75,16 @@ VERSION = _build_version_string()
#: * SPAAA_ZSU_23_4_Shilka_Gun_Dish,
#:
#: 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)
#: * 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.
#:
#: Version 6.0
#: * Random objective generation no is longer supported. Fixed objective locations were
#: added in 4.1.
CAMPAIGN_FORMAT_VERSION = (6, 0)

View File

@ -803,7 +803,7 @@ class AircraftConflictGenerator:
self._setup_payload(flight, group)
self._setup_livery(flight, group)
for unit, pilot in zip(group.units, flight.pilots):
for unit, pilot in zip(group.units, flight.roster.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.

View File

@ -162,7 +162,7 @@ class AircraftAllocator:
self, flight: ProposedFlight, task: FlightType
) -> Optional[Tuple[ControlPoint, Squadron]]:
types = aircraft_for_task(task)
airfields_in_range = self.closest_airfields.airfields_within(
airfields_in_range = self.closest_airfields.operational_airfields_within(
flight.max_distance
)
@ -180,7 +180,7 @@ class AircraftAllocator:
# 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:
if task not in squadron.auto_assignable_mission_types:
continue
if len(squadron.available_pilots) >= flight.num_aircraft:
inventory.remove_aircraft(aircraft, flight.num_aircraft)
@ -258,7 +258,9 @@ class PackageBuilder:
self, aircraft: Type[FlyingType], arrival: ControlPoint
) -> Optional[ControlPoint]:
divert_limit = nautical_miles(150)
for airfield in self.closest_airfields.airfields_within(divert_limit):
for airfield in self.closest_airfields.operational_airfields_within(
divert_limit
):
if airfield.captured != self.is_player:
continue
if airfield == arrival:
@ -433,7 +435,7 @@ class ObjectiveFinder:
is_building = isinstance(ground_object, BuildingGroundObject)
is_fob = isinstance(enemy_cp, Fob)
if is_building and is_fob and ground_object.airbase_group:
if is_building and is_fob and ground_object.is_control_point:
# This is the FOB structure itself. Can't be repaired or
# targeted by the player, so shouldn't be targetable by the
# AI.
@ -467,8 +469,10 @@ class ObjectiveFinder:
# Off-map spawn locations don't need protection.
continue
airfields_in_proximity = self.closest_airfields_to(cp)
airfields_in_threat_range = airfields_in_proximity.airfields_within(
self.AIRFIELD_THREAT_RANGE
airfields_in_threat_range = (
airfields_in_proximity.operational_airfields_within(
self.AIRFIELD_THREAT_RANGE
)
)
for airfield in airfields_in_threat_range:
if not airfield.is_friendly(self.is_player):
@ -502,31 +506,23 @@ class ObjectiveFinder:
c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player)
)
def farthest_friendly_control_point(self) -> Optional[ControlPoint]:
"""
Iterates over all friendly control points and find the one farthest away from the frontline
BUT! prefer Cvs. Everybody likes CVs!
"""
from_frontline = 0
cp = None
first_friendly_cp = None
def farthest_friendly_control_point(self) -> ControlPoint:
"""Finds the friendly control point that is farthest from any threats."""
threat_zones = self.game.threat_zone_for(not self.is_player)
for c in self.game.theater.controlpoints:
if c.is_friendly(self.is_player):
if first_friendly_cp is None:
first_friendly_cp = c
if c.is_carrier:
return c
if c.has_active_frontline:
if c.distance_to(self.front_lines().__next__()) > from_frontline:
from_frontline = c.distance_to(self.front_lines().__next__())
cp = c
farthest = None
max_distance = meters(0)
for cp in self.friendly_control_points():
if isinstance(cp, OffMapSpawn):
continue
distance = threat_zones.distance_to_threat(cp.position)
if distance > max_distance:
farthest = cp
max_distance = distance
# If no frontlines on the map, return the first friendly cp
if cp is None:
return first_friendly_cp
else:
return cp
if farthest is None:
raise RuntimeError("Found no friendly control points. You probably lost.")
return farthest
def enemy_control_points(self) -> Iterator[ControlPoint]:
"""Iterates over all enemy control points."""
@ -608,7 +604,7 @@ class CoalitionMissionPlanner:
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
and mission_type in squadron.auto_assignable_mission_types
):
return True
return False
@ -624,15 +620,13 @@ class CoalitionMissionPlanner:
eliminated this turn.
"""
# Find farthest, friendly CP for AEWC
cp = self.objective_finder.farthest_friendly_control_point()
if cp is not None:
yield ProposedMission(
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 farthest, friendly CP for AEWC.
yield ProposedMission(
self.objective_finder.farthest_friendly_control_point(),
[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.
for cp in self.objective_finder.vulnerable_control_points():
@ -1012,7 +1006,7 @@ class CoalitionMissionPlanner:
interval = (latest - earliest) // count
for time in range(earliest, latest, interval):
error = random.randint(-margin, margin)
yield timedelta(minutes=max(0, time + error))
yield timedelta(seconds=max(0, time + error))
dca_types = {
FlightType.BARCAP,
@ -1026,11 +1020,11 @@ class CoalitionMissionPlanner:
start_time = start_time_generator(
count=len(non_dca_packages),
earliest=5,
earliest=5 * 60,
latest=int(
self.game.settings.desired_player_mission_duration.total_seconds() / 60
self.game.settings.desired_player_mission_duration.total_seconds()
),
margin=5,
margin=5 * 60,
)
for package in self.ato.packages:
tot = TotEstimator(package).earliest_tot()

View File

@ -31,17 +31,35 @@ class ClosestAirfields:
if c.runway_is_operational() or c.has_helipads
)
def airfields_within(self, distance: Distance) -> Iterator[ControlPoint]:
def _airfields_within(
self, distance: Distance, operational: bool
) -> Iterator[ControlPoint]:
airfields = (
self.operational_airfields if operational else self.closest_airfields
)
for cp in airfields:
if cp.distance_to(self.target) < distance.meters:
yield cp
else:
break
def operational_airfields_within(
self, distance: Distance
) -> Iterator[ControlPoint]:
"""Iterates over all airfields within the given range of the target.
Note that this iterates over *all* airfields, not just friendly
airfields.
"""
for cp in self.closest_airfields:
if cp.distance_to(self.target) < distance.meters:
yield cp
else:
break
return self._airfields_within(distance, operational=True)
def all_airfields_within(self, distance: Distance) -> Iterator[ControlPoint]:
"""Iterates over all airfields within the given range of the target.
Note that this iterates over *all* airfields, not just friendly
airfields.
"""
return self._airfields_within(distance, operational=False)
class ObjectiveDistanceCache:

View File

@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
from typing import List, Optional, TYPE_CHECKING, Type, Union
@ -202,6 +203,49 @@ class FlightWaypoint:
return waypoint
class FlightRoster:
def __init__(self, squadron: Squadron, initial_size: int = 0) -> None:
self.squadron = squadron
self.pilots: list[Optional[Pilot]] = []
self.resize(initial_size)
@property
def max_size(self) -> int:
return len(self.pilots)
@property
def player_count(self) -> int:
return len([p for p in self.pilots if p is not None and p.player])
@property
def missing_pilots(self) -> int:
return len([p for p in self.pilots if p is None])
def resize(self, new_size: int) -> None:
if self.max_size > 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.max_size)
]
)
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
def clear(self) -> None:
self.squadron.return_pilots([p for p in self.pilots if p is not None])
class Flight:
def __init__(
self,
@ -216,11 +260,15 @@ class Flight:
divert: Optional[ControlPoint],
custom_name: Optional[str] = None,
cargo: Optional[TransferOrder] = None,
roster: Optional[FlightRoster] = None,
) -> None:
self.package = package
self.country = country
self.squadron = squadron
self.pilots = [squadron.claim_available_pilot() for _ in range(count)]
if roster is None:
self.roster = FlightRoster(self.squadron, initial_size=count)
else:
self.roster = roster
self.departure = departure
self.arrival = arrival
self.divert = divert
@ -246,11 +294,11 @@ class Flight:
@property
def count(self) -> int:
return len(self.pilots)
return self.roster.max_size
@property
def client_count(self) -> int:
return len([p for p in self.pilots if p is not None and p.player])
return self.roster.player_count
@property
def unit_type(self) -> Type[FlyingType]:
@ -265,32 +313,17 @@ class Flight:
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)
]
)
self.roster.resize(new_size)
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
self.roster.set_pilot(index, pilot)
@property
def missing_pilots(self) -> int:
return len([p for p in self.pilots if p is None])
return self.roster.missing_pilots
def clear_roster(self) -> None:
self.squadron.return_pilots([p for p in self.pilots if p is not None])
self.roster.clear()
def __repr__(self):
name = db.unit_type_name(self.unit_type)

View File

@ -1057,7 +1057,7 @@ class FlightPlanBuilder:
"""
location = self.package.target
start = self.aewc_orbit(location)
orbit_location = self.aewc_orbit(location)
# As high as possible to maximize detection and on-station time.
if flight.unit_type == E_2C:
@ -1072,22 +1072,22 @@ class FlightPlanBuilder:
patrol_alt = feet(25000)
builder = WaypointBuilder(flight, self.game, self.is_player)
start = builder.orbit(start, patrol_alt)
orbit_location = builder.orbit(orbit_location, patrol_alt)
return AwacsFlightPlan(
package=self.package,
flight=flight,
takeoff=builder.takeoff(flight.departure),
nav_to=builder.nav_path(
flight.departure.position, start.position, patrol_alt
flight.departure.position, orbit_location.position, patrol_alt
),
nav_from=builder.nav_path(
start.position, flight.arrival.position, patrol_alt
orbit_location.position, flight.arrival.position, patrol_alt
),
land=builder.land(flight.arrival),
divert=builder.divert(flight.divert),
bullseye=builder.bullseye(),
hold=start,
hold=orbit_location,
hold_duration=timedelta(hours=4),
)
@ -1339,20 +1339,24 @@ class FlightPlanBuilder:
return start, end
def aewc_orbit(self, location: MissionTarget) -> Point:
# in threat zone
closest_boundary = self.threat_zones.closest_boundary(location.position)
heading_to_threat_boundary = location.position.heading_between_point(
closest_boundary
)
distance_to_threat = meters(
location.position.distance_to_point(closest_boundary)
)
orbit_heading = heading_to_threat_boundary
# Station 100nm outside the threat zone.
threat_buffer = nautical_miles(100)
if self.threat_zones.threatened(location.position):
# Borderpoint
closest_boundary = self.threat_zones.closest_boundary(location.position)
# Heading + Distance to border point
heading = location.position.heading_between_point(closest_boundary)
distance = location.position.distance_to_point(closest_boundary)
return location.position.point_from_heading(heading, distance)
# this Part is fine. No threat zone, just use our point
orbit_distance = distance_to_threat + threat_buffer
else:
return location.position
orbit_distance = distance_to_threat - threat_buffer
return location.position.point_from_heading(
orbit_heading, orbit_distance.meters
)
def racetrack_for_frontline(
self, origin: Point, front_line: FrontLine
@ -1807,7 +1811,7 @@ class FlightPlanBuilder:
# We'll always have a package, but if this is being planned via the UI
# it could be the first flight in the package.
if not self.package.flights:
raise RuntimeError(
raise PlanningError(
"Cannot determine source airfield for package with no flights"
)
@ -1819,5 +1823,4 @@ class FlightPlanBuilder:
for flight in self.package.flights:
if flight.departure == airfield:
return airfield
raise RuntimeError("Could not find any airfield assigned to this package")
raise PlanningError("Could not find any airfield assigned to this package")

View File

@ -1,3 +1,4 @@
import logging
import random
from enum import Enum
from typing import Dict, List
@ -5,7 +6,8 @@ from typing import Dict, List
from dcs.unittype import VehicleType
from game.theater import ControlPoint
from gen.ground_forces.ai_ground_planner_db import *
from game.data.groundunitclass import GroundUnitClass
from gen.ground_forces.combat_stance import CombatStance
MAX_COMBAT_GROUP_PER_CP = 10
@ -91,37 +93,35 @@ class GroundPlanner:
group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE]
# Create combat groups and assign them randomly to each enemy CP
for key in self.cp.base.armor.keys():
role = None
collection = None
if key in TYPE_TANKS:
for unit_type in self.cp.base.armor:
if unit_type in GroundUnitClass.Tank:
collection = self.tank_groups
role = CombatGroupRole.TANK
elif key in TYPE_APC:
elif unit_type in GroundUnitClass.Apc:
collection = self.apc_group
role = CombatGroupRole.APC
elif key in TYPE_ARTILLERY:
elif unit_type in GroundUnitClass.Artillery:
collection = self.art_group
role = CombatGroupRole.ARTILLERY
elif key in TYPE_IFV:
elif unit_type in GroundUnitClass.Ifv:
collection = self.ifv_group
role = CombatGroupRole.IFV
elif key in TYPE_LOGI:
elif unit_type in GroundUnitClass.Logistics:
collection = self.logi_groups
role = CombatGroupRole.LOGI
elif key in TYPE_ATGM:
elif unit_type in GroundUnitClass.Atgm:
collection = self.atgm_group
role = CombatGroupRole.ATGM
elif key in TYPE_SHORAD:
elif unit_type in GroundUnitClass.Shorads:
collection = self.shorad_groups
role = CombatGroupRole.SHORAD
else:
print("Warning unit type not handled by ground generator")
print(key)
logging.warning(
f"Unused front line vehicle at base {unit_type}: unknown unit class"
)
continue
available = self.cp.base.armor[key]
available = self.cp.base.armor[unit_type]
if available > remaining_available_frontline_units:
available = remaining_available_frontline_units
@ -151,7 +151,7 @@ class GroundPlanner:
group.assigned_enemy_cp = "__reserve__"
for i in range(n):
group.units.append(key)
group.units.append(unit_type)
collection.append(group)
if remaining_available_frontline_units == 0:
@ -161,7 +161,7 @@ class GroundPlanner:
print("Ground Planner : ")
print(self.cp.name)
print("------------------")
for key in self.units_per_cp.keys():
print("For : #" + str(key))
for group in self.units_per_cp[key]:
for unit_type in self.units_per_cp.keys():
print("For : #" + str(unit_type))
for group in self.units_per_cp[unit_type]:
print(str(group))

View File

@ -1,189 +0,0 @@
from dcs.vehicles import AirDefence, Infantry, Unarmed, Artillery, Armor
from pydcs_extensions.frenchpack import frenchpack
TYPE_TANKS = [
Armor.MBT_T_55,
Armor.MBT_T_72B,
Armor.MBT_T_72B3,
Armor.MBT_T_80U,
Armor.MBT_T_90,
Armor.MBT_Leopard_2A4,
Armor.MBT_Leopard_2A4_Trs,
Armor.MBT_Leopard_2A5,
Armor.MBT_Leopard_2A6M,
Armor.MBT_Leopard_1A3,
Armor.MBT_Leclerc,
Armor.MBT_Challenger_II,
Armor.MBT_Chieftain_Mk_3,
Armor.MBT_M1A2_Abrams,
Armor.MBT_M60A3_Patton,
Armor.MBT_Merkava_IV,
Armor.ZTZ_96B,
Armor.LT_PT_76,
# WW2
Armor.MT_Pz_Kpfw_V_Panther_Ausf_G,
Armor.Tk_PzIV_H,
Armor.HT_Pz_Kpfw_VI_Tiger_I,
Armor.HT_Pz_Kpfw_VI_Ausf__B_Tiger_II,
Armor.Tk_M4_Sherman,
Armor.MT_M4A4_Sherman_Firefly,
Armor.SPG_StuG_IV,
Armor.CT_Centaur_IV,
Armor.CT_Cromwell_IV,
Armor.HIT_Churchill_VII,
Armor.LT_Mk_VII_Tetrarch,
Armor.SPG_Sturmpanzer_IV_Brummbar,
# Mods
frenchpack.DIM__TOYOTA_BLUE,
frenchpack.DIM__TOYOTA_GREEN,
frenchpack.DIM__TOYOTA_DESERT,
frenchpack.DIM__KAMIKAZE,
frenchpack.AMX_10RCR,
frenchpack.AMX_10RCR_SEPAR,
frenchpack.AMX_30B2,
frenchpack.Leclerc_Serie_XXI,
]
TYPE_ATGM = [
Armor.ATGM_HMMWV,
Armor.ATGM_VAB_Mephisto,
Armor.ATGM_Stryker,
Armor.IFV_BMP_2,
# WW2 (Tank Destroyers)
Unarmed.Carrier_M30_Cargo,
Armor.SPG_Jagdpanzer_IV,
Armor.SPG_Jagdpanther_G1,
Armor.SPG_M10_GMC,
# Mods
frenchpack.VBAE_CRAB_MMP,
frenchpack.VAB_MEPHISTO,
frenchpack.TRM_2000_PAMELA,
]
TYPE_IFV = [
Armor.IFV_BMP_3,
Armor.IFV_BMP_2,
Armor.IFV_BMP_1,
Armor.IFV_Marder,
Armor.IFV_Warrior,
Armor.IFV_LAV_25,
Armor.SPG_Stryker_MGS,
Armor.IFV_Sd_Kfz_234_2_Puma,
Armor.IFV_M2A2_Bradley,
Armor.IFV_BMD_1,
Armor.ZBD_04A,
# WW2
Armor.IFV_Sd_Kfz_234_2_Puma,
Armor.Car_M8_Greyhound_Armored,
Armor.Car_Daimler_Armored,
# Mods
frenchpack.ERC_90,
frenchpack.VBAE_CRAB,
frenchpack.VAB_T20_13,
]
TYPE_APC = [
Armor.Scout_HMMWV,
Armor.IFV_M1126_Stryker_ICV,
Armor.APC_M113,
Armor.APC_BTR_80,
Armor.IFV_BTR_82A,
Armor.APC_MTLB,
Armor.APC_M2A1_Halftrack,
Armor.Scout_Cobra,
Armor.APC_Sd_Kfz_251_Halftrack,
Armor.APC_AAV_7_Amphibious,
Armor.APC_TPz_Fuchs,
Armor.Scout_BRDM_2,
Armor.APC_BTR_RD,
Artillery.Grad_MRL_FDDM__FC,
# WW2
Armor.APC_M2A1_Halftrack,
Armor.APC_Sd_Kfz_251_Halftrack,
# Mods
frenchpack.VAB__50,
frenchpack.VBL__50,
frenchpack.VBL_AANF1,
]
TYPE_ARTILLERY = [
Artillery.MLRS_9A52_Smerch_HE_300mm,
Artillery.SPH_2S1_Gvozdika_122mm,
Artillery.SPH_2S3_Akatsia_152mm,
Artillery.MLRS_BM_21_Grad_122mm,
Artillery.MLRS_9K57_Uragan_BM_27_220mm,
Artillery.SPH_M109_Paladin_155mm,
Artillery.MLRS_M270_227mm,
Artillery.SPM_2S9_Nona_120mm_M,
Artillery.SPH_Dana_vz77_152mm,
Artillery.SPH_T155_Firtina_155mm,
Artillery.PLZ_05,
Artillery.SPH_2S19_Msta_152mm,
Artillery.MLRS_9A52_Smerch_CM_300mm,
# WW2
Artillery.SPG_M12_GMC_155mm,
]
TYPE_LOGI = [
Unarmed.Truck_M818_6x6,
Unarmed.Truck_KAMAZ_43101,
Unarmed.Truck_Ural_375,
Unarmed.Truck_GAZ_66,
Unarmed.Truck_GAZ_3307,
Unarmed.Truck_GAZ_3308,
Unarmed.Truck_Ural_4320_31_Arm_d,
Unarmed.Truck_Ural_4320T,
Unarmed.Truck_Opel_Blitz,
Unarmed.LUV_Kubelwagen_82,
Unarmed.Carrier_Sd_Kfz_7_Tractor,
Unarmed.LUV_Kettenrad,
Unarmed.Car_Willys_Jeep,
Unarmed.LUV_Land_Rover_109,
Unarmed.Truck_Land_Rover_101_FC,
# Mods
frenchpack.VBL,
frenchpack.VAB,
]
TYPE_INFANTRY = [
Infantry.Insurgent_AK_74,
Infantry.Infantry_AK_74,
Infantry.Infantry_M1_Garand,
Infantry.Infantry_Mauser_98,
Infantry.Infantry_SMLE_No_4_Mk_1,
Infantry.Infantry_M4_Georgia,
Infantry.Infantry_AK_74_Rus,
Infantry.Paratrooper_AKS,
Infantry.Paratrooper_RPG_16,
Infantry.Infantry_M249,
Infantry.Infantry_M4,
Infantry.Infantry_RPG,
]
TYPE_SHORAD = [
AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375,
AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375,
AirDefence.SPAAA_ZSU_57_2,
AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish,
AirDefence.SAM_SA_8_Osa_Gecko_TEL,
AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL,
AirDefence.SAM_SA_13_Strela_10M3_Gopher_TEL,
AirDefence.SAM_SA_15_Tor_Gauntlet,
AirDefence.SAM_SA_19_Tunguska_Grison,
AirDefence.SPAAA_Gepard,
AirDefence.SPAAA_Vulcan_M163,
AirDefence.SAM_Linebacker___Bradley_M6,
AirDefence.SAM_Chaparral_M48,
AirDefence.SAM_Avenger__Stinger,
AirDefence.SAM_Roland_ADS,
AirDefence.HQ_7_Self_Propelled_LN,
AirDefence.AAA_8_8cm_Flak_18,
AirDefence.AAA_8_8cm_Flak_36,
AirDefence.AAA_8_8cm_Flak_37,
AirDefence.AAA_8_8cm_Flak_41,
AirDefence.AAA_Bofors_40mm,
AirDefence.AAA_S_60_57mm,
AirDefence.AAA_M1_37mm,
AirDefence.AAA_QF_3_7,
]

View File

@ -15,7 +15,7 @@ from dcs import Mission, Point, unitgroup
from dcs.action import SceneryDestructionZone
from dcs.country import Country
from dcs.point import StaticPoint
from dcs.statics import Fortification, fortification_map, warehouse_map, Warehouse
from dcs.statics import Fortification, fortification_map, warehouse_map
from dcs.task import (
ActivateBeaconCommand,
ActivateICLSCommand,
@ -24,7 +24,7 @@ from dcs.task import (
FireAtPoint,
)
from dcs.triggers import TriggerStart, TriggerZone
from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad, Static
from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad
from dcs.unitgroup import Group, ShipGroup, StaticGroup, VehicleGroup
from dcs.unittype import StaticType, UnitType
from dcs.vehicles import vehicle_map
@ -76,8 +76,12 @@ class GenericGroundObjectGenerator:
self.m = mission
self.unit_map = unit_map
@property
def culled(self) -> bool:
return self.game.position_culled(self.ground_object.position)
def generate(self) -> None:
if self.game.position_culled(self.ground_object.position):
if self.culled:
return
for group in self.ground_object.groups:
@ -130,6 +134,12 @@ class GenericGroundObjectGenerator:
class MissileSiteGenerator(GenericGroundObjectGenerator):
@property
def culled(self) -> bool:
# Don't cull missile sites - their range is long enough to make them easily
# culled despite being a threat.
return False
def generate(self) -> None:
super(MissileSiteGenerator, self).generate()
# Note : Only the SCUD missiles group can fire (V1 site cannot fire in game right now)

View File

@ -312,10 +312,6 @@ class NameGenerator:
db.unit_type_name(unit_type),
)
@staticmethod
def next_basedefense_name():
return "basedefense_aa|0|0|"
@classmethod
def next_awacs_name(cls, country: Country):
cls.number += 1
@ -352,7 +348,7 @@ class NameGenerator:
for _ in range(10):
alpha = random.choice(ALPHA_MILITARY).upper()
number = str(random.randint(0, 100))
number = 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)

View File

@ -21,6 +21,8 @@ class AirDefenseGroupGenerator(GroupGenerator, ABC):
This is the base for all SAM group generators
"""
price: int
def __init__(self, game: Game, ground_object: SamGroundObject) -> None:
ground_object.skynet_capable = True
super().__init__(game, ground_object)

View File

@ -17,8 +17,8 @@ from gen.sam.ewrs import (
SnowDriftGenerator,
StraightFlushGenerator,
TallRackGenerator,
EwrGenerator,
)
from gen.sam.group_generator import GroupGenerator
EWR_MAP = {
"BoxSpringGenerator": BoxSpringGenerator,
@ -36,7 +36,7 @@ EWR_MAP = {
def get_faction_possible_ewrs_generator(
faction: Faction,
) -> List[Type[GroupGenerator]]:
) -> List[Type[EwrGenerator]]:
"""
Return the list of possible EWR generators for the given faction
:param faction: Faction name to search units for

View File

@ -5,9 +5,16 @@ from gen.sam.group_generator import GroupGenerator
class EwrGenerator(GroupGenerator):
@property
def unit_type(self) -> VehicleType:
raise NotImplementedError
unit_type: VehicleType
@classmethod
def name(cls) -> str:
return cls.unit_type.name
@staticmethod
def price() -> int:
# TODO: Differentiate sites.
return 20
def generate(self) -> None:
self.add_unit(

View File

@ -10,14 +10,12 @@ from dcs.condition import (
FlagIsFalse,
FlagIsTrue,
)
from dcs.unitgroup import FlyingGroup
from dcs.mission import Mission
from dcs.task import Option
from dcs.translation import String
from dcs.triggers import (
Event,
TriggerOnce,
TriggerZone,
TriggerCondition,
)
from dcs.unit import Skill
@ -25,7 +23,6 @@ from dcs.unit import Skill
from game.theater import Airfield
from game.theater.controlpoint import Fob
if TYPE_CHECKING:
from game.game import Game
@ -115,19 +112,22 @@ class TriggersGenerator:
mark_trigger.add_condition(TimeAfter(1))
v = 10
for cp in self.game.theater.controlpoints:
added = []
seen = set()
for ground_object in cp.ground_objects:
if ground_object.obj_name not in added:
if ground_object.obj_name in seen:
continue
seen.add(ground_object.obj_name)
for location in ground_object.mark_locations:
zone = self.mission.triggers.add_triggerzone(
ground_object.position, radius=10, hidden=True, name="MARK"
location, radius=10, hidden=True, name="MARK"
)
if cp.captured:
name = ground_object.obj_name + " [ALLY]"
else:
name = ground_object.obj_name + " [ENEMY]"
mark_trigger.add_action(MarkToAll(v, zone.id, String(name)))
v = v + 1
added.append(ground_object.obj_name)
v += 1
self.mission.triggerrules.triggers.append(mark_trigger)
def _generate_capture_triggers(

View File

@ -1,128 +0,0 @@
"""Visibility options for the game map."""
from dataclasses import dataclass, field
from typing import Iterator, Optional, Union
@dataclass
class DisplayRule:
name: str
_value: bool
debug_only: bool = field(default=False)
@property
def menu_text(self) -> str:
return self.name
@property
def value(self) -> bool:
return self._value
@value.setter
def value(self, value: bool) -> None:
from qt_ui.widgets.map.QLiberationMap import QLiberationMap
self._value = value
if QLiberationMap.instance is not None:
QLiberationMap.instance.reload_scene()
QLiberationMap.instance.update()
def __bool__(self) -> bool:
return self.value
class DisplayGroup:
def __init__(self, name: Optional[str], debug_only: bool = False) -> None:
self.name = name
self.debug_only = debug_only
def __iter__(self) -> Iterator[DisplayRule]:
# Python 3.6 enforces that __dict__ is order preserving by default.
for value in self.__dict__.values():
if isinstance(value, DisplayRule):
yield value
class FlightPathOptions(DisplayGroup):
def __init__(self) -> None:
super().__init__("Flight Paths")
self.hide = DisplayRule("Hide Flight Paths", False)
self.only_selected = DisplayRule("Show Selected Flight Path", False)
self.all = DisplayRule("Show All Flight Paths", True)
class ThreatZoneOptions(DisplayGroup):
def __init__(self, coalition_name: str) -> None:
super().__init__(f"{coalition_name} Threat Zones")
self.none = DisplayRule(f"Hide {coalition_name.lower()} threat zones", True)
self.all = DisplayRule(
f"Show full {coalition_name.lower()} threat zones", False
)
self.aircraft = DisplayRule(
f"Show {coalition_name.lower()} aircraft threat tones", False
)
self.air_defenses = DisplayRule(
f"Show {coalition_name.lower()} air defenses threat zones", False
)
class NavMeshOptions(DisplayGroup):
def __init__(self) -> None:
super().__init__("Navmeshes", debug_only=True)
self.hide = DisplayRule("DEBUG Hide Navmeshes", True)
self.blue_navmesh = DisplayRule("DEBUG Show blue navmesh", False)
self.red_navmesh = DisplayRule("DEBUG Show red navmesh", False)
class PathDebugFactionOptions(DisplayGroup):
def __init__(self) -> None:
super().__init__("Faction for path debugging", debug_only=True)
self.blue = DisplayRule("Debug blue paths", True)
self.red = DisplayRule("Debug red paths", False)
class PathDebugOptions(DisplayGroup):
def __init__(self) -> None:
super().__init__("Shortest paths", debug_only=True)
self.hide = DisplayRule("DEBUG Hide paths", True)
self.shortest_path = DisplayRule("DEBUG Show shortest path", False)
self.barcap = DisplayRule("DEBUG Show BARCAP plan", False)
self.cas = DisplayRule("DEBUG Show CAS plan", False)
self.sweep = DisplayRule("DEBUG Show fighter sweep plan", False)
self.strike = DisplayRule("DEBUG Show strike plan", False)
self.tarcap = DisplayRule("DEBUG Show TARCAP plan", False)
class DisplayOptions:
ground_objects = DisplayRule("Ground Objects", True)
control_points = DisplayRule("Control Points", True)
lines = DisplayRule("Lines", True)
sam_ranges = DisplayRule("Ally SAM Threat Range", False)
enemy_sam_ranges = DisplayRule("Enemy SAM Threat Range", True)
detection_range = DisplayRule("SAM Detection Range", False)
map_poly = DisplayRule("Map Polygon Debug Mode", False)
waypoint_info = DisplayRule("Waypoint Information", True)
culling = DisplayRule("Display Culling Zones", False)
actual_frontline_pos = DisplayRule("Display Actual Frontline Location", False)
patrol_engagement_range = DisplayRule(
"Display selected patrol engagement range", True
)
flight_paths = FlightPathOptions()
blue_threat_zones = ThreatZoneOptions("Blue")
red_threat_zones = ThreatZoneOptions("Red")
navmeshes = NavMeshOptions()
path_debug_faction = PathDebugFactionOptions()
path_debug = PathDebugOptions()
@classmethod
def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]:
debug = False # Set to True to enable debug options.
# Python 3.6 enforces that __dict__ is order preserving by default.
for value in cls.__dict__.values():
if isinstance(value, DisplayRule):
if value.debug_only and not debug:
continue
yield value
elif isinstance(value, DisplayGroup):
if value.debug_only and not debug:
continue
yield value

View File

@ -57,7 +57,7 @@ def inject_custom_payloads(user_path: Path) -> None:
PayloadDirectories.set_preferred(user_path / "MissionEditor" / "UnitPayloads")
def run_ui(game: Optional[Game], new_map: bool) -> None:
def run_ui(game: Optional[Game]) -> None:
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens
app = QApplication(sys.argv)
@ -111,7 +111,7 @@ def run_ui(game: Optional[Game], new_map: bool) -> None:
GameUpdateSignal()
# Start window
window = QLiberationWindow(game, new_map)
window = QLiberationWindow(game)
window.showMaximized()
splash.finish(window)
qt_execution_code = app.exec_()
@ -139,16 +139,8 @@ def parse_args() -> argparse.Namespace:
help="Emits a warning for weapons without date or fallback information.",
)
parser.add_argument(
"--new-map",
action="store_true",
default=True,
help="Use the new map. Functional but missing many display options.",
)
parser.add_argument(
"--old-map", dest="new_map", action="store_false", help="Use the old map."
)
parser.add_argument("--new-map", help="Deprecated. Does nothing.")
parser.add_argument("--old-map", help="Deprecated. Does nothing.")
new_game = subparsers.add_parser("new-game")
@ -267,7 +259,7 @@ def main():
args.cheats,
)
run_ui(game, args.new_map)
run_ui(game)
if __name__ == "__main__":

View File

@ -18,7 +18,7 @@ from game.squadrons import Squadron, Pilot
from game.theater.missiontarget import MissionTarget
from game.transfers import TransferOrder
from gen.ato import AirTaskingOrder, Package
from gen.flights.flight import Flight
from gen.flights.flight import Flight, FlightType
from gen.flights.traveltime import TotEstimator
from qt_ui.uiconstants import AIRCRAFT_ICONS
@ -424,7 +424,7 @@ class SquadronModel(QAbstractListModel):
self.squadron = squadron
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return self.squadron.size
return self.squadron.number_of_pilots_including_dead
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
if not index.isValid():
@ -467,6 +467,15 @@ class SquadronModel(QAbstractListModel):
pilot.send_on_leave()
self.endResetModel()
def is_auto_assignable(self, task: FlightType) -> bool:
return task in self.squadron.auto_assignable_mission_types
def set_auto_assignable(self, task: FlightType, auto_assignable: bool) -> None:
if auto_assignable:
self.squadron.auto_assignable_mission_types.add(task)
else:
self.squadron.auto_assignable_mission_types.remove(task)
class GameModel:
"""A model for the Game object.

View File

@ -1,7 +1,7 @@
import os
from typing import Dict
from PySide2.QtGui import QColor, QFont, QPixmap
from PySide2.QtGui import QPixmap
from game.theater.theatergroundobject import NAME_BY_CATEGORY
from .liberation_theme import get_theme_icons
@ -16,51 +16,6 @@ URLS: Dict[str, str] = {
LABELS_OPTIONS = ["Full", "Abbreviated", "Dot Only", "Off"]
SKILL_OPTIONS = ["Average", "Good", "High", "Excellent"]
FONT_SIZE = 8
FONT_NAME = "Arial"
# FONT = QFont("Arial", 12, weight=5, italic=True)
FONT_PRIMARY = QFont(FONT_NAME, FONT_SIZE, weight=5, italic=False)
FONT_PRIMARY_I = QFont(FONT_NAME, FONT_SIZE, weight=5, italic=True)
FONT_PRIMARY_B = QFont(FONT_NAME, FONT_SIZE, weight=75, italic=False)
FONT_MAP = QFont(FONT_NAME, 10, weight=75, italic=False)
COLORS: Dict[str, QColor] = {
"white": QColor(255, 255, 255),
"white_transparent": QColor(255, 255, 255, 35),
"light_red": QColor(231, 92, 83, 90),
"red": QColor(200, 80, 80),
"dark_red": QColor(140, 20, 20),
"red_transparent": QColor(227, 32, 0, 20),
"transparent": QColor(255, 255, 255, 0),
"light_blue": QColor(105, 182, 240, 90),
"blue": QColor(0, 132, 255),
"dark_blue": QColor(45, 62, 80),
"sea_blue": QColor(52, 68, 85),
"sea_blue_transparent": QColor(52, 68, 85, 150),
"blue_transparent": QColor(0, 132, 255, 20),
"purple": QColor(187, 137, 255),
"yellow": QColor(238, 225, 123),
"bright_red": QColor(150, 80, 80),
"super_red": QColor(227, 32, 0),
"green": QColor(128, 186, 128),
"light_green": QColor(223, 255, 173),
"light_green_transparent": QColor(180, 255, 140, 50),
"bright_green": QColor(64, 200, 64),
"black": QColor(0, 0, 0),
"black_transparent": QColor(0, 0, 0, 5),
"orange": QColor(254, 125, 10),
"night_overlay": QColor(12, 20, 69),
"dawn_dust_overlay": QColor(46, 38, 85),
"grey": QColor(150, 150, 150),
"grey_transparent": QColor(150, 150, 150, 150),
"dark_grey": QColor(75, 75, 75),
"dark_grey_transparent": QColor(75, 75, 75, 150),
"dark_dark_grey": QColor(48, 48, 48),
"dark_dark_grey_transparent": QColor(48, 48, 48, 150),
}
CP_SIZE = 12
AIRCRAFT_BANNERS: Dict[str, QPixmap] = {}
AIRCRAFT_ICONS: Dict[str, QPixmap] = {}
VEHICLE_BANNERS: Dict[str, QPixmap] = {}
@ -138,17 +93,6 @@ def load_icons():
"./resources/ui/misc/" + get_theme_icons() + "/ordnance_icon.png"
)
ICONS["target"] = QPixmap("./resources/ui/ground_assets/target.png")
ICONS["cleared"] = QPixmap("./resources/ui/ground_assets/cleared.png")
for category in NAME_BY_CATEGORY.keys():
ICONS[category] = QPixmap("./resources/ui/ground_assets/" + category + ".png")
ICONS[category + "_blue"] = QPixmap(
"./resources/ui/ground_assets/" + category + "_blue.png"
)
ICONS["destroyed"] = QPixmap("./resources/ui/ground_assets/destroyed.png")
ICONS["nothreat"] = QPixmap("./resources/ui/ground_assets/nothreat.png")
ICONS["nothreat_blue"] = QPixmap("./resources/ui/ground_assets/nothreat_blue.png")
ICONS["Generator"] = QPixmap(
"./resources/ui/misc/" + get_theme_icons() + "/generator.png"
)

View File

@ -1,8 +1,6 @@
from PySide2 import QtCore, QtGui, QtWidgets
from PySide2 import QtCore, QtGui
from PySide2.QtWidgets import QCalendarWidget
from qt_ui.uiconstants import COLORS
class QLiberationCalendar(QCalendarWidget):
def __init__(self, parent=None):
@ -29,7 +27,7 @@ class QLiberationCalendar(QCalendarWidget):
painter.save()
painter.fillRect(rect, QtGui.QColor("#D3D3D3"))
painter.setPen(QtCore.Qt.NoPen)
painter.setBrush(QtGui.QColor(COLORS["sea_blue"]))
painter.setBrush(QtGui.QColor(52, 68, 85))
r = QtCore.QRect(
QtCore.QPoint(), min(rect.width(), rect.height()) * QtCore.QSize(1, 1)
)

View File

@ -25,8 +25,6 @@ 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
from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow
from qt_ui.windows.stats.QStatsWindow import QStatsWindow
class QTopPanel(QFrame):
@ -74,26 +72,12 @@ class QTopPanel(QFrame):
self.transfers.setProperty("style", "btn-primary")
self.transfers.clicked.connect(self.open_transfers)
self.settings = QPushButton("Settings")
self.settings.setDisabled(True)
self.settings.setIcon(CONST.ICONS["Settings"])
self.settings.setProperty("style", "btn-primary")
self.settings.clicked.connect(self.openSettings)
self.statistics = QPushButton("Statistics")
self.statistics.setDisabled(True)
self.statistics.setIcon(CONST.ICONS["Statistics"])
self.statistics.setProperty("style", "btn-primary")
self.statistics.clicked.connect(self.openStatisticsWindow)
self.intel_box = QIntelBox(self.game)
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)
self.buttonBox.setLayout(self.buttonBoxLayout)
self.proceedBox = QGroupBox("Proceed")
@ -123,8 +107,6 @@ class QTopPanel(QFrame):
self.air_wing.setEnabled(True)
self.transfers.setEnabled(True)
self.settings.setEnabled(True)
self.statistics.setEnabled(True)
self.conditionsWidget.setCurrentTurn(game.turn, game.conditions)
self.intel_box.set_game(game)
@ -146,14 +128,6 @@ class QTopPanel(QFrame):
self.dialog = PendingTransfersDialog(self.game_model)
self.dialog.show()
def openSettings(self):
self.dialog = QSettingsWindow(self.game)
self.dialog.show()
def openStatisticsWindow(self):
self.dialog = QStatsWindow(self.game)
self.dialog.show()
def passTurn(self):
with logged_duration("Skipping turn"):
self.game.pass_turn(no_action=True)

View File

@ -1,117 +0,0 @@
"""Common base for objects drawn on the game map."""
from typing import Optional
from PySide2.QtCore import Qt
from PySide2.QtGui import QPen
from PySide2.QtWidgets import (
QAction,
QGraphicsLineItem,
QGraphicsSceneContextMenuEvent,
QGraphicsSceneHoverEvent,
QGraphicsSceneMouseEvent,
QMenu,
)
import qt_ui.uiconstants as const
from game.theater import FrontLine
from qt_ui.dialogs import Dialog
from qt_ui.models import GameModel
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.mission.QPackageDialog import QNewPackageDialog
class QFrontLine(QGraphicsLineItem):
"""Base class for objects drawn on the game map.
Game map objects have an on_click behavior that triggers on left click, and
change the mouse cursor on hover.
"""
def __init__(
self,
x1: float,
y1: float,
x2: float,
y2: float,
mission_target: FrontLine,
game_model: GameModel,
) -> None:
super().__init__(x1, y1, x2, y2)
self.mission_target = mission_target
self.game_model = game_model
self.new_package_dialog: Optional[QNewPackageDialog] = None
self.setAcceptHoverEvents(True)
pen = QPen(brush=const.COLORS["bright_red"])
pen.setColor(const.COLORS["orange"])
pen.setWidth(8)
self.setPen(pen)
def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent):
self.setCursor(Qt.PointingHandCursor)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent):
if event.button() == Qt.LeftButton:
self.on_click()
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None:
menu = QMenu("Menu")
object_details_action = QAction(self.object_dialog_text)
object_details_action.triggered.connect(self.on_click)
menu.addAction(object_details_action)
new_package_action = QAction(f"New package")
new_package_action.triggered.connect(self.open_new_package_dialog)
menu.addAction(new_package_action)
if self.game_model.game.settings.enable_frontline_cheats:
cheat_forward = QAction(f"CHEAT: Advance Frontline")
cheat_forward.triggered.connect(self.cheat_forward)
menu.addAction(cheat_forward)
cheat_backward = QAction(f"CHEAT: Retreat Frontline")
cheat_backward.triggered.connect(self.cheat_backward)
menu.addAction(cheat_backward)
menu.exec_(event.screenPos())
@property
def object_dialog_text(self) -> str:
"""Text to for the object's dialog in the context menu.
Right clicking a map object will open a context menu and the first item
will open the details dialog for this object. This menu action has the
same behavior as the on_click event.
Return:
The text that should be displayed for the menu item.
"""
return "Details"
def on_click(self) -> None:
"""The action to take when this map object is left-clicked.
Typically this should open a details view of the object.
"""
raise NotImplementedError
def open_new_package_dialog(self) -> None:
"""Opens the dialog for planning a new mission package."""
Dialog.open_new_package_dialog(self.mission_target)
def cheat_forward(self) -> None:
self.mission_target.blue_cp.base.affect_strength(0.1)
self.mission_target.red_cp.base.affect_strength(-0.1)
# Clear the ATO to replan missions affected by the front line.
self.game_model.game.reset_ato()
self.game_model.game.initialize_turn()
GameUpdateSignal.get_instance().updateGame(self.game_model.game)
def cheat_backward(self) -> None:
self.mission_target.blue_cp.base.affect_strength(-0.1)
self.mission_target.red_cp.base.affect_strength(0.1)
# Clear the ATO to replan missions affected by the front line.
self.game_model.game.reset_ato()
self.game_model.game.initialize_turn()
GameUpdateSignal.get_instance().updateGame(self.game_model.game)

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +0,0 @@
from PySide2.QtWidgets import QGraphicsScene, QGraphicsSceneMouseEvent
import qt_ui.uiconstants as CONST
class QLiberationScene(QGraphicsScene):
def __init__(self, parent):
super().__init__(parent)
item = self.addText(
'Go to "File/New Game" to setup a new campaign or go to "File/Open" to load an existing save game.',
CONST.FONT_PRIMARY,
)
item.setDefaultTextColor(CONST.COLORS["white"])
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent):
super(QLiberationScene, self).mouseMoveEvent(event)
self.parent().sceneMouseMovedEvent(event)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent):
super(QLiberationScene, self).mousePressEvent(event)
self.parent().sceneMousePressEvent(event)

View File

@ -1,125 +0,0 @@
from typing import Optional
from PySide2.QtGui import QColor, QPainter
from PySide2.QtWidgets import QAction, QMenu
import qt_ui.uiconstants as const
from game.theater import ControlPoint, NavalControlPoint
from qt_ui.models import GameModel
from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2
from .QMapObject import QMapObject
from ...displayoptions import DisplayOptions
from ...windows.GameUpdateSignal import GameUpdateSignal
class QMapControlPoint(QMapObject):
def __init__(
self,
parent,
x: float,
y: float,
w: float,
h: float,
control_point: ControlPoint,
game_model: GameModel,
) -> None:
super().__init__(x, y, w, h, mission_target=control_point)
self.game_model = game_model
self.control_point = control_point
self.parent = parent
self.setZValue(1)
self.setToolTip(self.control_point.name)
self.base_details_dialog: Optional[QBaseMenu2] = None
self.capture_action = QAction(f"CHEAT: Capture {self.control_point.name}")
self.capture_action.triggered.connect(self.cheat_capture)
self.move_action = QAction("Move")
self.move_action.triggered.connect(self.move)
self.cancel_move_action = QAction("Cancel Move")
self.cancel_move_action.triggered.connect(self.cancel_move)
def paint(self, painter, option, widget=None) -> None:
if DisplayOptions.control_points:
painter.save()
painter.setRenderHint(QPainter.Antialiasing)
painter.setBrush(self.brush_color)
painter.setPen(self.pen_color)
if not self.control_point.runway_is_operational():
painter.setBrush(const.COLORS["black"])
painter.setPen(self.brush_color)
r = option.rect
painter.drawEllipse(r.x(), r.y(), r.width(), r.height())
# TODO: Draw sunk carriers differently.
# Either don't draw them at all, or perhaps use a sunk ship icon.
painter.restore()
@property
def brush_color(self) -> QColor:
if self.control_point.captured:
return const.COLORS["blue"]
else:
return const.COLORS["super_red"]
@property
def pen_color(self) -> QColor:
return const.COLORS["white"]
@property
def object_dialog_text(self) -> str:
if self.control_point.captured:
return "Open base menu"
else:
return "Open intel menu"
def on_click(self) -> None:
self.base_details_dialog = QBaseMenu2(
self.window(), self.control_point, self.game_model
)
self.base_details_dialog.show()
def add_context_menu_actions(self, menu: QMenu) -> None:
if self.control_point.moveable and self.control_point.captured:
menu.addAction(self.move_action)
if self.control_point.target_position is not None:
menu.addAction(self.cancel_move_action)
if self.control_point.is_fleet:
return
if self.control_point.captured:
return
for connected in self.control_point.connected_points:
if (
connected.captured
and self.game_model.game.settings.enable_base_capture_cheat
):
menu.addAction(self.capture_action)
break
def cheat_capture(self) -> None:
self.control_point.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)
def move(self):
self.parent.setSelectedUnit(self)
def cancel_move(self):
self.control_point.target_position = None
GameUpdateSignal.get_instance().updateGame(self.game_model.game)
def open_new_package_dialog(self) -> None:
"""Extends the default packagedialog to redirect to base menu for red air base."""
is_navy = isinstance(self.control_point, NavalControlPoint)
if self.control_point.captured or is_navy:
super().open_new_package_dialog()
return
self.on_click()

View File

@ -1,167 +0,0 @@
from typing import List, Optional
from PySide2.QtCore import QRect
from PySide2.QtGui import QBrush
from PySide2.QtWidgets import QGraphicsItem
import qt_ui.uiconstants as const
from game import Game
from game.data.building_data import FORTIFICATION_BUILDINGS
from game.db import REWARDS
from game.theater import ControlPoint, TheaterGroundObject
from game.theater.theatergroundobject import (
MissileSiteGroundObject,
CoastalSiteGroundObject,
)
from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu
from .QMapObject import QMapObject
from ...displayoptions import DisplayOptions
class QMapGroundObject(QMapObject):
def __init__(
self,
parent,
x: float,
y: float,
w: float,
h: float,
control_point: ControlPoint,
ground_object: TheaterGroundObject,
game: Game,
buildings: Optional[List[TheaterGroundObject]] = None,
) -> None:
super().__init__(x, y, w, h, mission_target=ground_object)
self.ground_object = ground_object
self.control_point = control_point
self.parent = parent
self.game = game
self.setZValue(2)
self.buildings = buildings if buildings is not None else []
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, False)
self.ground_object_dialog: Optional[QGroundObjectMenu] = None
self.setToolTip(self.tooltip)
@property
def tooltip(self) -> str:
lines = [
f"[{self.ground_object.obj_name}]",
f"${self.production_per_turn} per turn",
]
if self.ground_object.groups:
units = {}
for g in self.ground_object.groups:
for u in g.units:
if u.type in units:
units[u.type] = units[u.type] + 1
else:
units[u.type] = 1
for unit in units.keys():
lines.append(f"{unit} x {units[unit]}")
else:
for building in self.buildings:
if not building.is_dead:
lines.append(f"{building.dcs_identifier}")
return "\n".join(lines)
@property
def production_per_turn(self) -> int:
production = 0
for building in self.buildings:
if building.is_dead:
continue
if building.category in REWARDS.keys():
production += REWARDS[building.category]
return production
def paint(self, painter, option, widget=None) -> None:
player_icons = "_blue"
enemy_icons = ""
if DisplayOptions.ground_objects:
painter.save()
cat = self.ground_object.category
rect = QRect(
option.rect.x() + 2,
option.rect.y(),
option.rect.width() - 2,
option.rect.height(),
)
is_dead = self.ground_object.is_dead
for building in self.buildings:
if not building.is_dead:
is_dead = False
break
if cat == "aa":
has_threat = False
for group in self.ground_object.groups:
if self.ground_object.threat_range(group).distance_in_meters > 0:
has_threat = True
if not is_dead and not self.control_point.captured:
if cat == "aa" and not has_threat:
painter.drawPixmap(rect, const.ICONS["nothreat" + enemy_icons])
else:
painter.drawPixmap(rect, const.ICONS[cat + enemy_icons])
elif not is_dead:
if cat == "aa" and not has_threat:
painter.drawPixmap(rect, const.ICONS["nothreat" + player_icons])
else:
painter.drawPixmap(rect, const.ICONS[cat + player_icons])
else:
painter.drawPixmap(rect, const.ICONS["destroyed"])
self.draw_health_gauge(painter, option)
painter.restore()
def draw_health_gauge(self, painter, option) -> None:
units_alive = 0
units_dead = 0
if len(self.ground_object.groups) == 0:
for building in self.buildings:
if building.dcs_identifier in FORTIFICATION_BUILDINGS:
continue
if building.is_dead:
units_dead += 1
else:
units_alive += 1
for g in self.ground_object.groups:
units_alive += len(g.units)
if hasattr(g, "units_losts"):
units_dead += len(g.units_losts)
if units_dead + units_alive > 0:
ratio = float(units_alive) / (float(units_dead) + float(units_alive))
bar_height = ratio * option.rect.height()
painter.fillRect(
option.rect.x(),
option.rect.y(),
2,
option.rect.height(),
QBrush(const.COLORS["dark_red"]),
)
painter.fillRect(
option.rect.x(),
option.rect.y(),
2,
bar_height,
QBrush(const.COLORS["green"]),
)
def on_click(self) -> None:
self.ground_object_dialog = QGroundObjectMenu(
self.window(),
self.ground_object,
self.buildings,
self.control_point,
self.game,
)
self.ground_object_dialog.show()

View File

@ -1,84 +0,0 @@
"""Common base for objects drawn on the game map."""
from typing import Optional
from PySide2.QtCore import Qt
from PySide2.QtWidgets import (
QAction,
QGraphicsRectItem,
QGraphicsSceneContextMenuEvent,
QGraphicsSceneHoverEvent,
QGraphicsSceneMouseEvent,
QMenu,
)
from qt_ui.dialogs import Dialog
from qt_ui.windows.mission.QPackageDialog import QNewPackageDialog
from game.theater.missiontarget import MissionTarget
class QMapObject(QGraphicsRectItem):
"""Base class for objects drawn on the game map.
Game map objects have an on_click behavior that triggers on left click, and
change the mouse cursor on hover.
"""
def __init__(
self, x: float, y: float, w: float, h: float, mission_target: MissionTarget
) -> None:
super().__init__(x, y, w, h)
self.mission_target = mission_target
self.new_package_dialog: Optional[QNewPackageDialog] = None
self.setAcceptHoverEvents(True)
def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent):
self.setCursor(Qt.PointingHandCursor)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent):
if event.button() == Qt.LeftButton:
self.on_click()
def add_context_menu_actions(self, menu: QMenu) -> None:
pass
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None:
menu = QMenu("Menu", self.parent)
object_details_action = QAction(self.object_dialog_text)
object_details_action.triggered.connect(self.on_click)
menu.addAction(object_details_action)
# Not all locations have valid objectives. Off-map spawns, for example,
# have no mission types.
if list(self.mission_target.mission_types(for_player=True)):
new_package_action = QAction(f"New package")
new_package_action.triggered.connect(self.open_new_package_dialog)
menu.addAction(new_package_action)
self.add_context_menu_actions(menu)
menu.exec_(event.screenPos())
@property
def object_dialog_text(self) -> str:
"""Text to for the object's dialog in the context menu.
Right clicking a map object will open a context menu and the first item
will open the details dialog for this object. This menu action has the
same behavior as the on_click event.
Return:
The text that should be displayed for the menu item.
"""
return "Details"
def on_click(self) -> None:
"""The action to take when this map object is left-clicked.
Typically this should open a details view of the object.
"""
raise NotImplementedError
def open_new_package_dialog(self) -> None:
"""Opens the dialog for planning a new mission package."""
Dialog.open_new_package_dialog(self.mission_target)

View File

@ -1,70 +0,0 @@
from typing import List, Optional
from PySide2.QtCore import Qt
from PySide2.QtGui import QColor, QPen
from PySide2.QtWidgets import (
QGraphicsItem,
QGraphicsLineItem,
)
from game.theater import ControlPoint
from game.transfers import CargoShip
from qt_ui.uiconstants import COLORS
class ShippingLaneSegment(QGraphicsLineItem):
def __init__(
self,
x0: float,
y0: float,
x1: float,
y1: float,
control_point_a: ControlPoint,
control_point_b: ControlPoint,
ships: List[CargoShip],
parent: Optional[QGraphicsItem] = None,
) -> None:
super().__init__(x0, y0, x1, y1, parent)
self.control_point_a = control_point_a
self.control_point_b = control_point_b
self.ships = ships
self.setPen(self.make_pen())
self.setToolTip(self.make_tooltip())
self.setAcceptHoverEvents(True)
@property
def has_ships(self) -> bool:
return bool(self.ships)
def make_tooltip(self) -> str:
if not self.has_ships:
return "No ships present in this shipping lane."
ships = []
for ship in self.ships:
units = "units" if ship.size > 1 else "unit"
ships.append(
f"{ship.size} {units} transferring from {ship.origin} to "
f"{ship.destination}."
)
return "\n".join(ships)
@property
def line_color(self) -> QColor:
if self.control_point_a.captured:
return COLORS["dark_blue"]
else:
return COLORS["dark_red"]
@property
def line_style(self) -> Qt.PenStyle:
if self.has_ships:
return Qt.PenStyle.SolidLine
return Qt.PenStyle.DotLine
def make_pen(self) -> QPen:
pen = QPen(brush=self.line_color)
pen.setColor(self.line_color)
pen.setStyle(self.line_style)
pen.setWidth(2)
return pen

View File

@ -1,76 +0,0 @@
from functools import cached_property
from typing import List, Optional
from PySide2.QtCore import Qt
from PySide2.QtGui import QColor, QPen
from PySide2.QtWidgets import (
QGraphicsItem,
QGraphicsLineItem,
)
from game.theater import ControlPoint
from game.transfers import Convoy
from qt_ui.uiconstants import COLORS
class SupplyRouteSegment(QGraphicsLineItem):
def __init__(
self,
x0: float,
y0: float,
x1: float,
y1: float,
control_point_a: ControlPoint,
control_point_b: ControlPoint,
convoys: List[Convoy],
parent: Optional[QGraphicsItem] = None,
) -> None:
super().__init__(x0, y0, x1, y1, parent)
self.control_point_a = control_point_a
self.control_point_b = control_point_b
self.convoys = convoys
self.setPen(self.make_pen())
self.setToolTip(self.make_tooltip())
self.setAcceptHoverEvents(True)
@property
def has_convoys(self) -> bool:
return bool(self.convoys)
def make_tooltip(self) -> str:
if not self.has_convoys:
return "No convoys present on this supply route."
convoys = []
for convoy in self.convoys:
units = "units" if convoy.size > 1 else "unit"
convoys.append(
f"{convoy.size} {units} transferring from {convoy.origin} to "
f"{convoy.destination}"
)
return "\n".join(convoys)
@property
def line_color(self) -> QColor:
if self.control_point_a.front_is_active(self.control_point_b):
return COLORS["red"]
elif self.control_point_a.captured:
return COLORS["dark_blue"]
else:
return COLORS["dark_red"]
@property
def line_style(self) -> Qt.PenStyle:
if (
self.control_point_a.front_is_active(self.control_point_b)
or self.has_convoys
):
return Qt.PenStyle.SolidLine
return Qt.PenStyle.DotLine
def make_pen(self) -> QPen:
pen = QPen(brush=self.line_color)
pen.setColor(self.line_color)
pen.setStyle(self.line_style)
pen.setWidth(6)
return pen

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import logging
from datetime import timedelta
from typing import List, Optional, Tuple, Union
from typing import List, Optional, Tuple, Union, Iterator
from PySide2.QtCore import Property, QObject, Signal, Slot
from dcs import Point
@ -20,6 +20,7 @@ from game.theater import (
TheaterGroundObject,
FrontLine,
LatLon,
ControlPointStatus,
)
from game.threatzones import ThreatZones
from game.transfers import MultiGroupTransport, TransportMap
@ -36,6 +37,8 @@ from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu
LeafletLatLon = list[float]
LeafletPoly = list[LeafletLatLon]
MAX_SHIP_DISTANCE = nautical_miles(80)
# **EVERY PROPERTY NEEDS A NOTIFY SIGNAL**
#
# https://bugreports.qt.io/browse/PYSIDE-1426
@ -60,6 +63,16 @@ def shapely_poly_to_leaflet_points(
return [theater.point_to_ll(Point(x, y)).as_list() for x, y in poly.exterior.coords]
def shapely_to_leaflet_polys(
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]
class ControlPointJs(QObject):
nameChanged = Signal()
blueChanged = Signal()
@ -67,6 +80,7 @@ class ControlPointJs(QObject):
mobileChanged = Signal()
destinationChanged = Signal(list)
categoryChanged = Signal()
statusChanged = Signal()
def __init__(
self,
@ -92,6 +106,17 @@ class ControlPointJs(QObject):
def category(self) -> str:
return self.control_point.category
@Property(str, notify=statusChanged)
def status(self) -> str:
status = self.control_point.status
if status is ControlPointStatus.Functional:
return "alive"
elif status is ControlPointStatus.Damaged:
return "damaged"
elif status is ControlPointStatus.Destroyed:
return "destroyed"
raise ValueError(f"Unhandled ControlPointStatus: {status.name}")
@Property(list, notify=positionChanged)
def position(self) -> LeafletLatLon:
ll = self.theater.point_to_ll(self.control_point.position)
@ -109,8 +134,6 @@ class ControlPointJs(QObject):
return self.theater.point_to_ll(self.control_point.target_position).as_list()
def destination_in_range(self, destination: Point) -> bool:
from qt_ui.widgets.map.QLiberationMap import MAX_SHIP_DISTANCE
move_distance = meters(
destination.distance_to_point(self.control_point.position)
)
@ -122,8 +145,6 @@ class ControlPointJs(QObject):
@Slot(list, result=str)
def setDestination(self, destination: LeafletLatLon) -> str:
from qt_ui.widgets.map.QLiberationMap import MAX_SHIP_DISTANCE
if not self.control_point.moveable:
return f"{self.control_point} is not mobile"
if not self.control_point.captured:
@ -581,23 +602,13 @@ class ThreatZonesJs(QObject):
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),
shapely_to_leaflet_polys(zones.all, theater),
shapely_to_leaflet_polys(zones.airbases, theater),
shapely_to_leaflet_polys(zones.air_defenses, theater),
shapely_to_leaflet_polys(zones.radar_sam_threats, theater),
)
@classmethod
@ -658,6 +669,70 @@ class NavMeshJs(QObject):
)
class MapZonesJs(QObject):
inclusionZonesChanged = Signal()
exclusionZonesChanged = Signal()
seaZonesChanged = Signal()
def __init__(
self,
inclusion_zones: list[LeafletPoly],
exclusion_zones: list[LeafletPoly],
sea_zones: list[LeafletPoly],
) -> None:
super().__init__()
self._inclusion_zones = inclusion_zones
self._exclusion_zones = exclusion_zones
self._sea_zones = sea_zones
@Property(list, notify=inclusionZonesChanged)
def inclusionZones(self) -> list[LeafletPoly]:
return self._inclusion_zones
@Property(list, notify=exclusionZonesChanged)
def exclusionZones(self) -> list[LeafletPoly]:
return self._exclusion_zones
@Property(list, notify=seaZonesChanged)
def seaZones(self) -> list[LeafletPoly]:
return self._sea_zones
@classmethod
def from_game(cls, game: Game) -> MapZonesJs:
zones = game.theater.landmap
return MapZonesJs(
shapely_to_leaflet_polys(zones.inclusion_zones, game.theater),
shapely_to_leaflet_polys(zones.exclusion_zones, game.theater),
shapely_to_leaflet_polys(zones.sea_zones, game.theater),
)
class UnculledZone(QObject):
positionChanged = Signal()
radiusChanged = Signal()
def __init__(self, position: LeafletLatLon, radius: float) -> None:
super().__init__()
self._position = position
self._radius = radius
@Property(list, notify=positionChanged)
def position(self) -> LeafletLatLon:
return self._position
@Property(float, notify=radiusChanged)
def radius(self) -> float:
return self._radius
@classmethod
def each_from_game(cls, game: Game) -> Iterator[UnculledZone]:
for zone in game.get_culling_zones():
ll = game.theater.point_to_ll(zone)
yield UnculledZone(
[ll.latitude, ll.longitude], game.settings.perf_culling_distance * 1000
)
class MapModel(QObject):
cleared = Signal()
@ -669,6 +744,8 @@ class MapModel(QObject):
frontLinesChanged = Signal()
threatZonesChanged = Signal()
navmeshesChanged = Signal()
mapZonesChanged = Signal()
unculledZonesChanged = Signal()
def __init__(self, game_model: GameModel) -> None:
super().__init__()
@ -683,6 +760,8 @@ class MapModel(QObject):
ThreatZonesJs.empty(), ThreatZonesJs.empty()
)
self._navmeshes = NavMeshJs([], [])
self._map_zones = MapZonesJs([], [], [])
self._unculled_zones = []
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)
@ -704,6 +783,8 @@ class MapModel(QObject):
ThreatZonesJs.empty(), ThreatZonesJs.empty()
)
self._navmeshes = NavMeshJs([], [])
self._map_zones = MapZonesJs([], [], [])
self._unculled_zones = []
self.cleared.emit()
def set_package_selection(self, index: int) -> None:
@ -749,6 +830,8 @@ class MapModel(QObject):
self.reset_front_lines()
self.reset_threat_zones()
self.reset_navmeshes()
self.reset_map_zones()
self.reset_unculled_zones()
def on_game_load(self, game: Optional[Game]) -> None:
if game is not None:
@ -895,6 +978,22 @@ class MapModel(QObject):
def navmeshes(self) -> NavMeshJs:
return self._navmeshes
def reset_map_zones(self) -> None:
self._map_zones = MapZonesJs.from_game(self.game)
self.mapZonesChanged.emit()
@Property(MapZonesJs, notify=mapZonesChanged)
def mapZones(self) -> NavMeshJs:
return self._map_zones
def reset_unculled_zones(self) -> None:
self._unculled_zones = list(UnculledZone.each_from_game(self.game))
self.unculledZonesChanged.emit()
@Property(list, notify=unculledZonesChanged)
def unculledZones(self) -> list[UnculledZone]:
return self._unculled_zones
@property
def game(self) -> Game:
if self.game_model.game is None:

View File

@ -1,4 +1,7 @@
from typing import Optional
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Type, Iterator
from PySide2.QtCore import (
QItemSelectionModel,
@ -8,13 +11,21 @@ from PySide2.QtCore import (
)
from PySide2.QtWidgets import (
QAbstractItemView,
QCheckBox,
QDialog,
QListView,
QVBoxLayout,
QTabWidget,
QTableWidget,
QTableWidgetItem,
QWidget,
)
from dcs.unittype import FlyingType
from game import db
from game.inventory import ControlPointAircraftInventory
from game.squadrons import Squadron
from gen.flights.flight import Flight
from qt_ui.delegates import TwoColumnRowDelegate
from qt_ui.models import GameModel, AirWingModel, SquadronModel
from qt_ui.windows.SquadronDialog import SquadronDialog
@ -41,9 +52,10 @@ class SquadronDelegate(TwoColumnRowDelegate):
return self.squadron(index).nickname
elif (row, column) == (1, 1):
squadron = self.squadron(index)
alive = squadron.number_of_living_pilots
active = len(squadron.active_pilots)
available = len(squadron.available_pilots)
return f"{squadron.size} pilots, {active} active, {available} unassigned"
return f"{alive} pilots, {active} active, {available} unassigned"
return ""
@ -75,6 +87,138 @@ class SquadronList(QListView):
self.dialog.show()
@dataclass(frozen=True)
class AircraftInventoryData:
location: str
unit_type: str
task: str
target: str
pilot: str
player: str
@classmethod
def headers(cls) -> list[str]:
return ["Base", "Type", "Flight Type", "Target", "Pilot", "Player"]
@property
def columns(self) -> Iterator[str]:
yield self.location
yield self.unit_type
yield self.task
yield self.target
yield self.pilot
yield self.player
@classmethod
def from_flight(cls, flight: Flight) -> Iterator[AircraftInventoryData]:
unit_type_name = cls.format_unit_type(flight.unit_type, flight.country)
num_units = flight.count
flight_type = flight.flight_type.value
target = flight.package.target.name
for idx in range(0, num_units):
pilot = flight.roster.pilots[idx]
if pilot is None:
pilot_name = "Unassigned"
player = ""
else:
pilot_name = pilot.name
player = "Player" if pilot.player else "AI"
yield AircraftInventoryData(
flight.departure.name,
unit_type_name,
flight_type,
target,
pilot_name,
player,
)
@classmethod
def each_from_inventory(
cls, inventory: ControlPointAircraftInventory, country: str
) -> Iterator[AircraftInventoryData]:
for unit_type, num_units in inventory.all_aircraft:
unit_type_name = cls.format_unit_type(unit_type, country)
for _ in range(0, num_units):
yield AircraftInventoryData(
inventory.control_point.name,
unit_type_name,
"Idle",
"N/A",
"N/A",
"N/A",
)
@staticmethod
def format_unit_type(aircraft: Type[FlyingType], country: str) -> str:
return db.unit_get_expanded_info(country, aircraft, "name")
class AirInventoryView(QWidget):
def __init__(self, game_model: GameModel) -> None:
super().__init__()
self.game_model = game_model
self.country = self.game_model.game.country_for(player=True)
layout = QVBoxLayout()
self.setLayout(layout)
self.only_unallocated_cb = QCheckBox("Unallocated Only?")
self.only_unallocated_cb.toggled.connect(self.update_table)
layout.addWidget(self.only_unallocated_cb)
self.table = QTableWidget()
layout.addWidget(self.table)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.verticalHeader().setVisible(False)
self.update_table(False)
def update_table(self, only_unallocated: bool) -> None:
self.table.setSortingEnabled(False)
self.table.clear()
inventory_rows = list(self.get_data(only_unallocated))
self.table.setRowCount(len(inventory_rows))
headers = AircraftInventoryData.headers()
self.table.setColumnCount(len(headers))
self.table.setHorizontalHeaderLabels(headers)
for row, data in enumerate(inventory_rows):
for column, value in enumerate(data.columns):
self.table.setItem(row, column, QTableWidgetItem(value))
self.table.resizeColumnsToContents()
self.table.setSortingEnabled(True)
def iter_allocated_aircraft(self) -> Iterator[AircraftInventoryData]:
for package in self.game_model.game.blue_ato.packages:
for flight in package.flights:
yield from AircraftInventoryData.from_flight(flight)
def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]:
game = self.game_model.game
for control_point, inventory in game.aircraft_inventory.inventories.items():
if control_point.captured:
yield from AircraftInventoryData.each_from_inventory(
inventory, game.country_for(player=True)
)
def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]:
yield from self.iter_unallocated_aircraft()
if not only_unallocated:
yield from self.iter_allocated_aircraft()
class AirWingTabs(QTabWidget):
def __init__(self, game_model: GameModel) -> None:
super().__init__()
self.addTab(SquadronList(game_model.blue_air_wing_model), "Squadrons")
self.addTab(AirInventoryView(game_model), "Inventory")
class AirWingDialog(QDialog):
"""Dialog window showing the player's air wing."""
@ -89,4 +233,4 @@ class AirWingDialog(QDialog):
layout = QVBoxLayout()
self.setLayout(layout)
layout.addWidget(SquadronList(self.air_wing_model))
layout.addWidget(AirWingTabs(game_model))

View File

@ -22,12 +22,11 @@ from game import Game, VERSION, persistency
from game.debriefing import Debriefing
from qt_ui import liberation_install
from qt_ui.dialogs import Dialog
from qt_ui.displayoptions import DisplayGroup, DisplayOptions, DisplayRule
from qt_ui.models import GameModel
from qt_ui.uiconstants import URLS
from qt_ui.widgets.QTopPanel import QTopPanel
from qt_ui.widgets.ato import QAirTaskingOrderPanel
from qt_ui.widgets.map.QLiberationMap import LeafletMap, QLiberationMap, LiberationMap
from qt_ui.widgets.map.QLiberationMap import QLiberationMap
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.QDebriefingWindow import QDebriefingWindow
from qt_ui.windows.infos.QInfoPanel import QInfoPanel
@ -35,10 +34,12 @@ from qt_ui.windows.newgame.QNewGameWizard import NewGameWizard
from qt_ui.windows.preferences.QLiberationPreferencesWindow import (
QLiberationPreferencesWindow,
)
from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow
from qt_ui.windows.stats.QStatsWindow import QStatsWindow
class QLiberationWindow(QMainWindow):
def __init__(self, game: Optional[Game], new_map: bool) -> None:
def __init__(self, game: Optional[Game]) -> None:
super(QLiberationWindow, self).__init__()
self.game = game
@ -46,7 +47,7 @@ class QLiberationWindow(QMainWindow):
Dialog.set_game(self.game_model)
self.ato_panel = QAirTaskingOrderPanel(self.game_model)
self.info_panel = QInfoPanel(self.game)
self.liberation_map: LiberationMap = self.create_map(new_map)
self.liberation_map = QLiberationMap(self.game_model, self)
self.setGeometry(300, 100, 270, 100)
self.setWindowTitle(f"DCS Liberation - v{VERSION}")
@ -148,6 +149,14 @@ class QLiberationWindow(QMainWindow):
)
)
self.openSettingsAction = QAction("Settings", self)
self.openSettingsAction.setIcon(CONST.ICONS["Settings"])
self.openSettingsAction.triggered.connect(self.showSettingsDialog)
self.openStatsAction = QAction("Stats", self)
self.openStatsAction.setIcon(CONST.ICONS["Statistics"])
self.openStatsAction.triggered.connect(self.showStatsDialog)
def initToolbar(self):
self.tool_bar = self.addToolBar("File")
self.tool_bar.addAction(self.newGameAction)
@ -158,7 +167,9 @@ class QLiberationWindow(QMainWindow):
self.links_bar.addAction(self.openDiscordAction)
self.links_bar.addAction(self.openGithubAction)
self.display_bar = self.addToolBar("Display")
self.actions_bar = self.addToolBar("Actions")
self.actions_bar.addAction(self.openSettingsAction)
self.actions_bar.addAction(self.openStatsAction)
def initMenuBar(self):
self.menu = self.menuBar()
@ -174,30 +185,6 @@ class QLiberationWindow(QMainWindow):
file_menu.addSeparator()
file_menu.addAction("E&xit", self.close)
displayMenu = self.menu.addMenu("&Display")
last_was_group = False
for item in DisplayOptions.menu_items():
if isinstance(item, DisplayRule):
if last_was_group:
displayMenu.addSeparator()
self.display_bar.addSeparator()
action = self.make_display_rule_action(item)
displayMenu.addAction(action)
if action.icon():
self.display_bar.addAction(action)
last_was_group = False
elif isinstance(item, DisplayGroup):
displayMenu.addSeparator()
self.display_bar.addSeparator()
group = QActionGroup(displayMenu)
for display_rule in item:
action = self.make_display_rule_action(display_rule, group)
displayMenu.addAction(action)
if action.icon():
self.display_bar.addAction(action)
last_was_group = True
help_menu = self.menu.addMenu("&Help")
help_menu.addAction(self.openDiscordAction)
help_menu.addAction(self.openGithubAction)
@ -284,11 +271,6 @@ class QLiberationWindow(QMainWindow):
self.game = game
GameUpdateSignal.get_instance().game_loaded.emit(self.game)
def create_map(self, new_map: bool) -> LiberationMap:
if new_map:
return LeafletMap(self.game_model, self)
return QLiberationMap(self.game_model)
def setGame(self, game: Optional[Game]):
try:
self.game = game
@ -337,6 +319,14 @@ class QLiberationWindow(QMainWindow):
self.subwindow = QLiberationPreferencesWindow()
self.subwindow.show()
def showSettingsDialog(self) -> None:
self.dialog = QSettingsWindow(self.game)
self.dialog.show()
def showStatsDialog(self):
self.dialog = QStatsWindow(self.game)
self.dialog.show()
def onDebriefing(self, debrief: Debriefing):
logging.info("On Debriefing")
self.debriefing = QDebriefingWindow(debrief)

View File

@ -133,10 +133,10 @@ class QWaitingForMissionResultWindow(QDialog):
self.setLayout(self.layout)
@staticmethod
def add_update_row(description: str, count: Sized, layout: QGridLayout) -> None:
def add_update_row(description: str, count: int, layout: QGridLayout) -> None:
row = layout.rowCount()
layout.addWidget(QLabel(f"<b>{description}</b>"), row, 0)
layout.addWidget(QLabel(f"{len(count)}"), row, 1)
layout.addWidget(QLabel(f"{count}"), row, 1)
def updateLayout(self, debriefing: Debriefing) -> None:
updateBox = QGroupBox("Mission status")
@ -145,34 +145,36 @@ class QWaitingForMissionResultWindow(QDialog):
self.debriefing = debriefing
self.add_update_row(
"Aircraft destroyed", list(debriefing.air_losses.losses), update_layout
"Aircraft destroyed", len(list(debriefing.air_losses.losses)), update_layout
)
self.add_update_row(
"Front line units destroyed",
list(debriefing.front_line_losses),
len(list(debriefing.front_line_losses)),
update_layout,
)
self.add_update_row(
"Convoy units destroyed", list(debriefing.convoy_losses), update_layout
"Convoy units destroyed", len(list(debriefing.convoy_losses)), update_layout
)
self.add_update_row(
"Shipping cargo destroyed",
list(debriefing.cargo_ship_losses),
len(list(debriefing.cargo_ship_losses)),
update_layout,
)
self.add_update_row(
"Airlift cargo destroyed", list(debriefing.airlift_losses), update_layout
"Airlift cargo destroyed",
sum(len(loss.cargo) for loss in debriefing.airlift_losses),
update_layout,
)
self.add_update_row(
"Ground units lost at objective areas",
list(debriefing.ground_object_losses),
len(list(debriefing.ground_object_losses)),
update_layout,
)
self.add_update_row(
"Buildings destroyed", list(debriefing.building_losses), update_layout
"Buildings destroyed", len(list(debriefing.building_losses)), update_layout
)
self.add_update_row(
"Base capture events", debriefing.base_captures, update_layout
"Base capture events", len(debriefing.base_captures), update_layout
)
# Clear previous content of the window

View File

@ -1,4 +1,5 @@
import logging
from typing import Callable
from PySide2.QtCore import (
QItemSelectionModel,
@ -13,9 +14,13 @@ from PySide2.QtWidgets import (
QVBoxLayout,
QPushButton,
QHBoxLayout,
QGridLayout,
QLabel,
QCheckBox,
)
from game.squadrons import Pilot
from gen.flights.flight import FlightType
from qt_ui.delegates import TwoColumnRowDelegate
from qt_ui.models import SquadronModel
@ -61,6 +66,31 @@ class PilotList(QListView):
self.setSelectionBehavior(QAbstractItemView.SelectItems)
class AutoAssignedTaskControls(QVBoxLayout):
def __init__(self, squadron_model: SquadronModel) -> None:
super().__init__()
self.squadron_model = squadron_model
self.addWidget(QLabel("Auto-assignable mission types"))
def make_callback(toggled_task: FlightType) -> Callable[[bool], None]:
def callback(checked: bool) -> None:
self.on_toggled(toggled_task, checked)
return callback
for task in squadron_model.squadron.mission_types:
checkbox = QCheckBox(text=task.value)
checkbox.setChecked(squadron_model.is_auto_assignable(task))
checkbox.toggled.connect(make_callback(task))
self.addWidget(checkbox)
self.addStretch()
def on_toggled(self, task: FlightType, checked: bool) -> None:
self.squadron_model.set_auto_assignable(task, checked)
class SquadronDialog(QDialog):
"""Dialog window showing a squadron."""
@ -75,11 +105,17 @@ class SquadronDialog(QDialog):
layout = QVBoxLayout()
self.setLayout(layout)
columns = QHBoxLayout()
layout.addLayout(columns)
auto_assigned_tasks = AutoAssignedTaskControls(squadron_model)
columns.addLayout(auto_assigned_tasks)
self.pilot_list = PilotList(squadron_model)
self.pilot_list.selectionModel().selectionChanged.connect(
self.on_selection_changed
)
layout.addWidget(self.pilot_list)
columns.addWidget(self.pilot_list)
button_panel = QHBoxLayout()
button_panel.addStretch()

View File

@ -11,7 +11,12 @@ from PySide2.QtWidgets import (
)
from game import Game, db
from game.theater import ControlPoint, ControlPointType
from game.theater import (
ControlPoint,
ControlPointType,
FREE_FRONTLINE_UNIT_SUPPLY,
AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION,
)
from gen.flights.flight import FlightType
from qt_ui.dialogs import Dialog
from qt_ui.models import GameModel
@ -44,8 +49,8 @@ class QBaseMenu2(QDialog):
self.setWindowFlags(Qt.WindowStaysOnTopHint)
self.setMinimumSize(300, 200)
self.setMinimumWidth(800)
self.setMaximumWidth(800)
self.setMinimumWidth(1024)
self.setMaximumWidth(1024)
self.setModal(True)
self.setWindowTitle(self.cp.name)
@ -62,6 +67,7 @@ class QBaseMenu2(QDialog):
title.setAlignment(Qt.AlignLeft | Qt.AlignTop)
title.setProperty("style", "base-title")
self.intel_summary = QLabel()
self.intel_summary.setToolTip(self.generate_intel_tooltip())
self.update_intel_summary()
top_layout.addWidget(title)
top_layout.addWidget(self.intel_summary)
@ -195,16 +201,49 @@ class QBaseMenu2(QDialog):
def update_intel_summary(self) -> None:
aircraft = self.cp.base.total_aircraft
parking = self.cp.total_aircraft_parking
ground_unit_limit = self.cp.frontline_unit_count_limit
deployable_unit_info = ""
allocated = self.cp.allocated_ground_units(self.game_model.game.transfers)
unit_overage = max(
allocated.total_present - self.cp.frontline_unit_count_limit, 0
)
if self.cp.has_active_frontline:
deployable_unit_info = (
f" (Up to {ground_unit_limit} deployable, {unit_overage} reserve)"
)
self.intel_summary.setText(
"\n".join(
[
f"{aircraft}/{parking} aircraft",
f"{self.cp.base.total_armor} ground units",
f"{self.cp.base.total_armor} ground units" + deployable_unit_info,
f"{allocated.total_transferring} more ground units en route, {allocated.total_ordered} ordered",
str(self.cp.runway_status),
f"{self.cp.active_ammo_depots_count}/{self.cp.total_ammo_depots_count} ammo depots",
f"{'Factory can produce units' if self.cp.has_factory else 'Does not have a factory'}",
]
)
)
def generate_intel_tooltip(self) -> str:
tooltip = (
f"Deployable unit limit ({self.cp.frontline_unit_count_limit}) = {FREE_FRONTLINE_UNIT_SUPPLY} (base) + "
f" {AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION} (per connected ammo depot) * {self.cp.total_ammo_depots_count} "
f"(depots)"
)
if self.cp.has_active_frontline:
unit_overage = max(
self.cp.base.total_armor - self.cp.frontline_unit_count_limit, 0
)
tooltip += (
f"\n{unit_overage} units will be held in reserve and will not be deployed to "
f"connected frontlines for this turn"
)
return tooltip
def closeEvent(self, close_event: QCloseEvent):
GameUpdateSignal.get_instance().updateGame(self.game_model.game)

View File

@ -4,7 +4,6 @@ from game.theater import ControlPoint, OffMapSpawn, Fob
from qt_ui.models import GameModel
from qt_ui.windows.basemenu.DepartingConvoysMenu import DepartingConvoysMenu
from qt_ui.windows.basemenu.airfield.QAirfieldCommand import QAirfieldCommand
from qt_ui.windows.basemenu.base_defenses.QBaseDefensesHQ import QBaseDefensesHQ
from qt_ui.windows.basemenu.ground_forces.QGroundForcesHQ import QGroundForcesHQ
from qt_ui.windows.basemenu.intel.QIntelInfo import QIntelInfo
@ -14,9 +13,6 @@ class QBaseMenuTabs(QTabWidget):
super(QBaseMenuTabs, self).__init__()
if not cp.captured:
if not cp.is_carrier and not isinstance(cp, OffMapSpawn):
self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game)
self.addTab(self.base_defenses_hq, "Base Defenses")
self.intel = QIntelInfo(cp, game_model.game)
self.addTab(self.intel, "Intel")
@ -30,17 +26,9 @@ class QBaseMenuTabs(QTabWidget):
if cp.helipads:
self.airfield_command = QAirfieldCommand(cp, game_model)
self.addTab(self.airfield_command, "Heliport")
self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game)
self.addTab(self.base_defenses_hq, "Base Defenses")
else:
self.airfield_command = QAirfieldCommand(cp, game_model)
self.addTab(self.airfield_command, "Airfield Command")
if cp.is_carrier:
self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game)
self.addTab(self.base_defenses_hq, "Fleet")
elif not isinstance(cp, OffMapSpawn):
if not isinstance(cp, OffMapSpawn):
self.ground_forces_hq = QGroundForcesHQ(cp, game_model)
self.addTab(self.ground_forces_hq, "Ground Forces HQ")
self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game)
self.addTab(self.base_defenses_hq, "Base Defenses")

View File

@ -164,16 +164,16 @@ class QHangarStatus(QHBoxLayout):
self.setAlignment(Qt.AlignLeft)
def update_label(self) -> None:
next_turn = self.control_point.expected_aircraft_next_turn(self.game_model.game)
next_turn = self.control_point.allocated_aircraft(self.game_model.game)
max_amount = self.control_point.total_aircraft_parking
components = [f"{next_turn.present} present"]
if next_turn.ordered > 0:
components.append(f"{next_turn.ordered} purchased")
elif next_turn.ordered < 0:
components.append(f"{-next_turn.ordered} sold")
if next_turn.total_ordered > 0:
components.append(f"{next_turn.total_ordered} purchased")
elif next_turn.total_ordered < 0:
components.append(f"{-next_turn.total_ordered} sold")
transferring = next_turn.transferring
transferring = next_turn.total_transferring
if transferring > 0:
components.append(f"{transferring} transferring in")
if transferring < 0:

View File

@ -1,106 +0,0 @@
from PySide2.QtCore import Qt
from PySide2.QtWidgets import (
QGridLayout,
QGroupBox,
QLabel,
QPushButton,
QVBoxLayout,
)
from game.theater import ControlPoint, TheaterGroundObject
from qt_ui.dialogs import Dialog
from qt_ui.uiconstants import VEHICLES_ICONS
from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu
from game import db
from dcs import vehicles
class QBaseDefenseGroupInfo(QGroupBox):
def __init__(self, cp: ControlPoint, ground_object: TheaterGroundObject, game):
super(QBaseDefenseGroupInfo, self).__init__("Group : " + ground_object.obj_name)
self.ground_object = ground_object
self.cp = cp
self.game = game
self.buildings = game.theater.find_ground_objects_by_obj_name(
self.ground_object.obj_name
)
self.main_layout = QVBoxLayout()
self.unit_layout = QGridLayout()
self.init_ui()
def init_ui(self):
self.buildLayout()
self.main_layout.addLayout(self.unit_layout)
if not self.cp.captured and not self.ground_object.is_dead:
attack_button = QPushButton("Attack")
attack_button.setProperty("style", "btn-danger")
attack_button.setMaximumWidth(180)
attack_button.clicked.connect(self.onAttack)
self.main_layout.addWidget(attack_button, 0, Qt.AlignLeft)
if self.cp.captured:
manage_button = QPushButton("Manage")
manage_button.setProperty("style", "btn-success")
manage_button.setMaximumWidth(180)
manage_button.clicked.connect(self.onManage)
self.main_layout.addWidget(manage_button, 0, Qt.AlignLeft)
self.setLayout(self.main_layout)
def buildLayout(self):
unit_dict = {}
for i in range(self.unit_layout.rowCount()):
for j in range(self.unit_layout.columnCount()):
item = self.unit_layout.itemAtPosition(i, j)
if item is not None and item.widget() is not None:
item.widget().setParent(None)
print("Remove " + str(i) + ", " + str(j))
for g in self.ground_object.groups:
for u in g.units:
if u.type in unit_dict.keys():
unit_dict[u.type] = unit_dict[u.type] + 1
else:
unit_dict[u.type] = 1
i = 0
for k, v in unit_dict.items():
icon = QLabel()
if k in VEHICLES_ICONS.keys():
icon.setPixmap(VEHICLES_ICONS[k])
else:
icon.setText("<b>" + k[:8] + "</b>")
icon.setProperty("style", "icon-armor")
self.unit_layout.addWidget(icon, i, 0)
unit_display_name = k
unit_type = vehicles.vehicle_map.get(k)
if unit_type is not None:
unit_display_name = db.unit_get_expanded_info(
self.game.enemy_country, unit_type, "name"
)
self.unit_layout.addWidget(
QLabel(str(v) + " x " + "<strong>" + unit_display_name + "</strong>"),
i,
1,
)
i = i + 1
if len(unit_dict.items()) == 0:
self.unit_layout.addWidget(QLabel("/"), 0, 0)
self.setLayout(self.main_layout)
def onAttack(self):
Dialog.open_new_package_dialog(self.ground_object, parent=self.window())
def onManage(self):
self.edition_menu = QGroundObjectMenu(
self.window(), self.ground_object, self.buildings, self.cp, self.game
)
self.edition_menu.show()
self.edition_menu.changed.connect(self.onEdition)
def onEdition(self):
self.buildLayout()

View File

@ -1,19 +0,0 @@
from PySide2.QtWidgets import QFrame, QGridLayout
from game import Game
from game.theater import ControlPoint
from qt_ui.windows.basemenu.base_defenses.QBaseInformation import QBaseInformation
class QBaseDefensesHQ(QFrame):
def __init__(self, cp: ControlPoint, game: Game):
super(QBaseDefensesHQ, self).__init__()
self.cp = cp
self.game = game
self.init_ui()
def init_ui(self):
airport = self.game.theater.terrain.airport_by_id(self.cp.id)
layout = QGridLayout()
layout.addWidget(QBaseInformation(self.cp, airport, self.game))
self.setLayout(layout)

View File

@ -1,56 +0,0 @@
from PySide2.QtGui import Qt
from PySide2.QtWidgets import (
QFrame,
QGridLayout,
QScrollArea,
QVBoxLayout,
QWidget,
)
from game.theater import Airport, ControlPoint, Fob
from game.theater.theatergroundobject import BuildingGroundObject
from qt_ui.windows.basemenu.base_defenses.QBaseDefenseGroupInfo import (
QBaseDefenseGroupInfo,
)
class QBaseInformation(QFrame):
def __init__(self, cp: ControlPoint, airport: Airport, game):
super(QBaseInformation, self).__init__()
self.cp = cp
self.airport = airport
self.game = game
self.setMinimumWidth(500)
self.init_ui()
def init_ui(self):
self.mainLayout = QVBoxLayout()
scroll_content = QWidget()
task_box_layout = QGridLayout()
scroll_content.setLayout(task_box_layout)
for g in self.cp.ground_objects:
# Airbase groups are the objects that are hidden on the map because
# they're shown in the base menu.
if not g.airbase_group:
continue
# Of these, we need to ignore the FOB structure itself since that's
# not supposed to be targetable.
if isinstance(self.cp, Fob) and isinstance(g, BuildingGroundObject):
continue
group_info = QBaseDefenseGroupInfo(self.cp, g, self.game)
task_box_layout.addWidget(group_info)
scroll_content.setLayout(task_box_layout)
scroll = QScrollArea()
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
scroll.setWidgetResizable(True)
scroll.setWidget(scroll_content)
self.mainLayout.addWidget(scroll)
self.setLayout(self.mainLayout)

View File

@ -1,7 +1,10 @@
from PySide2.QtWidgets import QGroupBox, QLabel, QVBoxLayout
from collections import Callable
from PySide2.QtWidgets import QGroupBox, QLabel, QVBoxLayout, QPushButton
from game import Game
from game.theater import ControlPoint
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.basemenu.ground_forces.QGroundForcesStrategySelector import (
QGroundForcesStrategySelector,
)
@ -15,10 +18,44 @@ class QGroundForcesStrategy(QGroupBox):
self.init_ui()
def init_ui(self):
def make_cheat_callback(
enemy_point: ControlPoint, advance: bool
) -> Callable[[], None]:
def cheat() -> None:
self.cheat_alter_front_line(enemy_point, advance)
return cheat
layout = QVBoxLayout()
for enemy_cp in self.cp.connected_points:
if not enemy_cp.captured:
layout.addWidget(QLabel(enemy_cp.name))
layout.addWidget(QGroundForcesStrategySelector(self.cp, enemy_cp))
if self.game.settings.enable_frontline_cheats:
advance_button = QPushButton("CHEAT: Advance")
advance_button.setProperty("style", "btn-danger")
layout.addWidget(advance_button)
advance_button.clicked.connect(
make_cheat_callback(enemy_cp, advance=True)
)
retreat_button = QPushButton("CHEAT: Retreat")
retreat_button.setProperty("style", "btn-danger")
layout.addWidget(retreat_button)
retreat_button.clicked.connect(
make_cheat_callback(enemy_cp, advance=False)
)
layout.addStretch()
self.setLayout(layout)
def cheat_alter_front_line(self, enemy_point: ControlPoint, advance: bool) -> None:
amount = 0.2
if not advance:
amount *= -1
self.cp.base.affect_strength(amount)
enemy_point.base.affect_strength(-amount)
# Clear the ATO to replan missions affected by the front line.
self.game.reset_ato()
self.game.initialize_turn()
GameUpdateSignal.get_instance().updateGame(self.game)

View File

@ -21,8 +21,15 @@ from game import Game, db
from game.data.building_data import FORTIFICATION_BUILDINGS
from game.db import PRICES, PinpointStrike, REWARDS, unit_type_of
from game.theater import ControlPoint, TheaterGroundObject
from game.theater.theatergroundobject import NavalGroundObject
from game.theater.theatergroundobject import (
NavalGroundObject,
VehicleGroupGroundObject,
SamGroundObject,
EwrGroundObject,
BuildingGroundObject,
)
from gen.defenses.armor_group_generator import generate_armor_group_of_type_and_size
from gen.sam.ewr_group_generator import get_faction_possible_ewrs_generator
from gen.sam.sam_group_generator import get_faction_possible_sams_generator
from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.widgets.QBudgetBox import QBudgetBox
@ -32,9 +39,6 @@ from dcs import vehicles
class QGroundObjectMenu(QDialog):
changed = QtCore.Signal()
def __init__(
self,
parent,
@ -70,12 +74,12 @@ class QGroundObjectMenu(QDialog):
self.doLayout()
if self.ground_object.dcs_identifier == "AA":
self.mainLayout.addWidget(self.intelBox)
else:
if isinstance(self.ground_object, BuildingGroundObject):
self.mainLayout.addWidget(self.buildingBox)
if self.cp.captured:
self.mainLayout.addWidget(self.financesBox)
else:
self.mainLayout.addWidget(self.intelBox)
self.actionLayout = QHBoxLayout()
@ -87,12 +91,12 @@ class QGroundObjectMenu(QDialog):
self.buy_replace.clicked.connect(self.buy_group)
self.buy_replace.setProperty("style", "btn-success")
if not isinstance(self.ground_object, NavalGroundObject):
if self.ground_object.purchasable:
if self.total_value > 0:
self.actionLayout.addWidget(self.sell_all_button)
self.actionLayout.addWidget(self.buy_replace)
if self.cp.captured and self.ground_object.dcs_identifier == "AA":
if self.cp.captured and self.ground_object.purchasable:
self.mainLayout.addLayout(self.actionLayout)
self.setLayout(self.mainLayout)
@ -196,23 +200,21 @@ class QGroundObjectMenu(QDialog):
self.actionLayout.setParent(None)
self.doLayout()
if self.ground_object.dcs_identifier == "AA":
self.mainLayout.addWidget(self.intelBox)
else:
if isinstance(self.ground_object, BuildingGroundObject):
self.mainLayout.addWidget(self.buildingBox)
else:
self.mainLayout.addWidget(self.intelBox)
self.actionLayout = QHBoxLayout()
if self.total_value > 0:
self.actionLayout.addWidget(self.sell_all_button)
self.actionLayout.addWidget(self.buy_replace)
if self.cp.captured and self.ground_object.dcs_identifier == "AA":
if self.cp.captured and self.ground_object.purchasable:
self.mainLayout.addLayout(self.actionLayout)
except Exception as e:
print(e)
logging.exception(e)
self.update_total_value()
self.changed.emit()
def update_total_value(self):
total_value = 0
@ -244,7 +246,6 @@ class QGroundObjectMenu(QDialog):
logging.info("Repaired unit : " + str(unit.id) + " " + str(unit.type))
self.do_refresh_layout()
self.changed.emit()
def sell_all(self):
self.update_total_value()
@ -294,9 +295,6 @@ class QBuyGroupForGroundObjectDialog(QDialog):
self.buySamBox = QGroupBox("Buy SAM site :")
self.buyArmorBox = QGroupBox("Buy defensive position :")
self.init_ui()
def init_ui(self):
faction = self.game.player_faction
# Sams
@ -317,6 +315,30 @@ class QBuyGroupForGroundObjectDialog(QDialog):
self.buySamButton.clicked.connect(self.buySam)
# EWRs
buy_ewr_box = QGroupBox("Buy EWR:")
buy_ewr_layout = QGridLayout()
buy_ewr_box.setLayout(buy_ewr_layout)
buy_ewr_layout.addWidget(QLabel("Radar type:"), 0, 0, Qt.AlignLeft)
self.ewr_selector = QComboBox()
buy_ewr_layout.addWidget(self.ewr_selector, 0, 1, alignment=Qt.AlignRight)
ewr_types = get_faction_possible_ewrs_generator(faction)
for ewr_type in ewr_types:
self.ewr_selector.addItem(
f"{ewr_type.name()} [${ewr_type.price()}M]", ewr_type
)
self.ewr_selector.currentIndexChanged.connect(self.on_ewr_selection_changed)
self.buy_ewr_button = QPushButton("Buy")
self.buy_ewr_button.clicked.connect(self.buy_ewr)
buy_ewr_layout.addWidget(self.buy_ewr_button, 1, 1, alignment=Qt.AlignRight)
stretch = QVBoxLayout()
stretch.addStretch()
buy_ewr_layout.addLayout(stretch, 2, 0)
# Armored units
armored_units = db.find_unittype(
@ -354,16 +376,20 @@ class QBuyGroupForGroundObjectDialog(QDialog):
self.buyArmorBox.setLayout(self.buyArmorLayout)
self.mainLayout = QHBoxLayout()
self.mainLayout.addWidget(self.buySamBox)
if self.ground_object.airbase_group:
if isinstance(self.ground_object, SamGroundObject):
self.mainLayout.addWidget(self.buySamBox)
elif isinstance(self.ground_object, VehicleGroupGroundObject):
self.mainLayout.addWidget(self.buyArmorBox)
elif isinstance(self.ground_object, EwrGroundObject):
self.mainLayout.addWidget(buy_ewr_box)
self.setLayout(self.mainLayout)
try:
self.samComboChanged(0)
self.armorComboChanged(0)
self.on_ewr_selection_changed(0)
except:
pass
@ -376,6 +402,12 @@ class QBuyGroupForGroundObjectDialog(QDialog):
+ "M]"
)
def on_ewr_selection_changed(self, index):
ewr = self.ewr_selector.itemData(index)
self.buy_ewr_button.setText(
f"Buy [${ewr.price()}M][-${self.current_group_value}M]"
)
def armorComboChanged(self, index):
self.buyArmorButton.setText(
"Buy [$"
@ -441,6 +473,24 @@ class QBuyGroupForGroundObjectDialog(QDialog):
self.changed.emit()
self.close()
def buy_ewr(self):
ewr_generator = self.ewr_selector.itemData(self.ewr_selector.currentIndex())
price = ewr_generator.price() - self.current_group_value
if price > self.game.budget:
self.error_money()
return
else:
self.game.budget -= price
generator = ewr_generator(self.game, self.ground_object)
generator.generate()
self.ground_object.groups = [generator.vg]
GameUpdateSignal.get_instance().updateBudget(self.game)
self.changed.emit()
self.close()
def error_money(self):
msg = QMessageBox()
msg.setIcon(QMessageBox.Information)

View File

@ -221,6 +221,19 @@ class QNewPackageDialog(QPackageDialog):
)
self.ato_model = model
# In the *new* package dialog, a package has been created and may have aircraft
# assigned to it, but it is not a part of the ATO until the user saves it.
#
# Other actions (modifying settings, closing some other dialogs like the base
# menu) can cause a Game update which will forcibly close this window without
# either accepting or rejecting it, so we neither save the package nor release
# any allocated units.
#
# While it would be preferable to be able to update this dialog as needed in the
# event of game updates, the quick fix is to just not allow interaction with
# other UI elements until the new package has either been finalized or canceled.
self.setModal(True)
self.save_button = QPushButton("Save")
self.save_button.setProperty("style", "start-button")
self.save_button.clicked.connect(self.accept)

View File

@ -1,3 +1,5 @@
from datetime import timedelta
from PySide2.QtCore import QItemSelectionModel, QSize
from PySide2.QtGui import QStandardItemModel
from PySide2.QtWidgets import QAbstractItemView, QListView
@ -5,6 +7,7 @@ from PySide2.QtWidgets import QAbstractItemView, QListView
from qt_ui.models import GameModel
from qt_ui.windows.mission.QFlightItem import QFlightItem
from game.theater.controlpoint import ControlPoint
from gen.flights.traveltime import TotEstimator
class QPlannedFlightsView(QListView):
@ -25,8 +28,11 @@ class QPlannedFlightsView(QListView):
for flight in package.flights:
if flight.from_cp == self.cp:
item = QFlightItem(package.package, flight)
self.model.appendRow(item)
self.flight_items.append(item)
self.flight_items.sort(key=self.mission_start_for_flight)
for item in self.flight_items:
self.model.appendRow(item)
self.set_selected_flight(0)
def set_selected_flight(self, row):
@ -43,3 +49,7 @@ class QPlannedFlightsView(QListView):
def set_flight_planner(self) -> None:
self.clear_layout()
self.setup_content()
@staticmethod
def mission_start_for_flight(flight_item: QFlightItem) -> timedelta:
return TotEstimator(flight_item.package).mission_start_time(flight_item.flight)

View File

@ -10,6 +10,7 @@ from PySide2.QtWidgets import (
QPushButton,
QVBoxLayout,
QLineEdit,
QHBoxLayout,
)
from dcs.unittype import FlyingType
@ -17,7 +18,7 @@ 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
from gen.flights.flight import Flight, FlightRoster
from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.widgets.QFlightSizeSpinner import QFlightSizeSpinner
from qt_ui.widgets.QLabeledWidget import QLabeledWidget
@ -26,6 +27,7 @@ from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelect
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
from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import FlightRosterEditor
class QFlightCreator(QDialog):
@ -46,7 +48,7 @@ class QFlightCreator(QDialog):
self.task_selector = QFlightTypeComboBox(self.game.theater, package.target)
self.task_selector.setCurrentIndex(0)
self.task_selector.currentTextChanged.connect(self.on_task_changed)
self.task_selector.currentIndexChanged.connect(self.on_task_changed)
layout.addLayout(QLabeledWidget("Task:", self.task_selector))
self.aircraft_selector = QAircraftTypeSelector(
@ -93,13 +95,20 @@ class QFlightCreator(QDialog):
self.update_max_size(self.departure.available)
layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner))
self.client_slots_spinner = QFlightSizeSpinner(
min_size=0, max_size=self.flight_size_spinner.value(), default_size=0
)
self.flight_size_spinner.valueChanged.connect(
lambda v: self.client_slots_spinner.setMaximum(v)
)
layout.addLayout(QLabeledWidget("Client Slots:", self.client_slots_spinner))
squadron = self.squadron_selector.currentData()
if squadron is None:
roster = None
else:
roster = FlightRoster(
squadron, initial_size=self.flight_size_spinner.value()
)
self.roster_editor = FlightRosterEditor(roster)
self.flight_size_spinner.valueChanged.connect(self.resize_roster)
self.squadron_selector.currentIndexChanged.connect(self.on_squadron_changed)
roster_layout = QHBoxLayout()
layout.addLayout(roster_layout)
roster_layout.addWidget(QLabel("Assigned pilots:"))
roster_layout.addLayout(self.roster_editor)
# When an off-map spawn overrides the start type to in-flight, we save
# the selected type into this value. If a non-off-map spawn is selected
@ -142,6 +151,10 @@ class QFlightCreator(QDialog):
def set_custom_name_text(self, text: str):
self.custom_name_text = text
def resize_roster(self, new_size: int) -> None:
self.roster_editor.roster.resize(new_size)
self.roster_editor.resize(new_size)
def verify_form(self) -> Optional[str]:
aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData()
squadron: Optional[Squadron] = self.squadron_selector.currentData()
@ -181,7 +194,7 @@ class QFlightCreator(QDialog):
origin = self.departure.currentData()
arrival = self.arrival.currentData()
divert = self.divert.currentData()
size = self.flight_size_spinner.value()
roster = self.roster_editor.roster
if arrival is None:
arrival = origin
@ -190,22 +203,17 @@ class QFlightCreator(QDialog):
self.package,
self.country,
squadron,
size,
# A bit of a hack to work around the old API. Not actually relevant because
# the roster is passed explicitly. Needs a refactor.
roster.max_size,
task,
self.start_type.currentText(),
origin,
arrival,
divert,
custom_name=self.custom_name_text,
roster=roster,
)
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)
@ -234,14 +242,22 @@ class QFlightCreator(QDialog):
self.start_type.setCurrentText(self.restore_start_type)
self.restore_start_type = None
def on_task_changed(self) -> None:
def on_task_changed(self, index: int) -> None:
task = self.task_selector.itemData(index)
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()
task, self.game.aircraft_inventory.available_types_for_player
)
self.squadron_selector.update_items(task, self.aircraft_selector.currentData())
def on_squadron_changed(self, index: int) -> None:
squadron = self.squadron_selector.itemData(index)
# Clear the roster first so we return the pilots to the pool. This way if we end
# up repopulating from the same squadron we'll get the same pilots back.
self.roster_editor.replace(None)
if squadron is not None:
self.roster_editor.replace(
FlightRoster(squadron, self.flight_size_spinner.value())
)
def update_max_size(self, available: int) -> None:
self.flight_size_spinner.setMaximum(min(available, 4))

View File

@ -28,7 +28,11 @@ class SquadronSelector(QComboBox):
self, task: Optional[FlightType], aircraft: Optional[Type[FlyingType]]
) -> None:
current_squadron = self.currentData()
self.clear()
self.blockSignals(True)
try:
self.clear()
finally:
self.blockSignals(False)
if task is None:
self.addItem("No task selected", None)
return

View File

@ -13,7 +13,11 @@ class DcsLoadoutSelector(QComboBox):
for loadout in Loadout.iter_for(flight):
self.addItem(loadout.name, loadout)
self.model().sort(0)
self.setCurrentText(flight.loadout.name)
self.setDisabled(flight.loadout.is_custom)
if flight.loadout.is_custom:
self.setCurrentText(Loadout.default_for(flight).name)
else:
self.setCurrentText(flight.loadout.name)
class QFlightPayloadTab(QFrame):

View File

@ -15,16 +15,16 @@ from PySide2.QtWidgets import (
from game import Game
from game.squadrons import Pilot
from gen.flights.flight import Flight
from gen.flights.flight import Flight, FlightRoster
from qt_ui.models import PackageModel
class PilotSelector(QComboBox):
available_pilots_changed = Signal()
def __init__(self, flight: Flight, idx: int) -> None:
def __init__(self, roster: Optional[FlightRoster], idx: int) -> None:
super().__init__()
self.flight = flight
self.roster = roster
self.pilot_index = idx
self.rebuild()
@ -34,15 +34,15 @@ class PilotSelector(QComboBox):
def _do_rebuild(self) -> None:
self.clear()
if self.pilot_index >= self.flight.count:
if self.roster is None or self.pilot_index >= self.roster.max_size:
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]
choices = list(self.roster.squadron.available_pilots)
current_pilot = self.roster.pilots[self.pilot_index]
if current_pilot is not None:
choices.append(current_pilot)
# Put players first, otherwise alphabetically.
@ -70,19 +70,23 @@ class PilotSelector(QComboBox):
# 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]:
if pilot == self.roster.pilots[self.pilot_index]:
return
self.flight.set_pilot(self.pilot_index, pilot)
self.roster.set_pilot(self.pilot_index, pilot)
self.available_pilots_changed.emit()
def replace(self, new_roster: Optional[FlightRoster]) -> None:
self.roster = new_roster
self.rebuild()
class PilotControls(QHBoxLayout):
def __init__(self, flight: Flight, idx: int) -> None:
def __init__(self, roster: Optional[FlightRoster], idx: int) -> None:
super().__init__()
self.flight = flight
self.roster = roster
self.pilot_index = idx
self.selector = PilotSelector(flight, idx)
self.selector = PilotSelector(roster, idx)
self.selector.currentIndexChanged.connect(self.on_pilot_changed)
self.addWidget(self.selector)
@ -95,9 +99,9 @@ class PilotControls(QHBoxLayout):
@property
def pilot(self) -> Optional[Pilot]:
if self.pilot_index >= self.flight.count:
if self.roster is None or self.pilot_index >= self.roster.max_size:
return None
return self.flight.pilots[self.pilot_index]
return self.roster.pilots[self.pilot_index]
def on_player_toggled(self, checked: bool) -> None:
pilot = self.pilot
@ -130,12 +134,21 @@ class PilotControls(QHBoxLayout):
finally:
self.player_checkbox.blockSignals(False)
def replace(self, new_roster: Optional[FlightRoster]) -> None:
self.roster = new_roster
if self.roster is None or self.pilot_index >= self.roster.max_size:
self.disable_and_clear()
else:
self.enable_and_reset()
self.selector.replace(new_roster)
class FlightRosterEditor(QVBoxLayout):
MAX_PILOTS = 4
def __init__(self, flight: Flight) -> None:
def __init__(self, roster: Optional[FlightRoster]) -> None:
super().__init__()
self.roster = roster
self.pilot_controls = []
for pilot_idx in range(self.MAX_PILOTS):
@ -146,7 +159,7 @@ class FlightRosterEditor(QVBoxLayout):
return callback
controls = PilotControls(flight, pilot_idx)
controls = PilotControls(roster, pilot_idx)
controls.selector.available_pilots_changed.connect(
make_reset_callback(pilot_idx)
)
@ -167,6 +180,13 @@ class FlightRosterEditor(QVBoxLayout):
for controls in self.pilot_controls[new_size:]:
controls.disable_and_clear()
def replace(self, new_roster: Optional[FlightRoster]) -> None:
if self.roster is not None:
self.roster.clear()
self.roster = new_roster
for controls in self.pilot_controls:
controls.replace(new_roster)
class QFlightSlotEditor(QGroupBox):
def __init__(self, package_model: PackageModel, flight: Flight, game: Game):
@ -196,14 +216,16 @@ class QFlightSlotEditor(QGroupBox):
layout.addWidget(QLabel(str(self.flight.squadron)), 1, 1)
layout.addWidget(QLabel("Assigned pilots:"), 2, 0)
self.roster_editor = FlightRosterEditor(flight)
self.roster_editor = FlightRosterEditor(flight.roster)
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
new_count = int(self.aircraft_count_spinner.value())
self.game.aircraft_inventory.return_from_flight(self.flight)
self.flight.resize(new_count)
try:
self.game.aircraft_inventory.claim_for_flight(self.flight)
except ValueError:
@ -217,7 +239,6 @@ class QFlightSlotEditor(QGroupBox):
f"{available} {self.flight.unit_type} remaining"
)
self.game.aircraft_inventory.claim_for_flight(self.flight)
self.flight.resize(old_count)
return
self.flight.resize(new_count)
self.roster_editor.resize(new_count)

View File

@ -8,7 +8,7 @@ from typing import Any, Dict, List, Union, Tuple
import packaging.version
from PySide2 import QtGui
from PySide2.QtCore import QItemSelectionModel
from PySide2.QtCore import QItemSelectionModel, QModelIndex, Qt
from PySide2.QtGui import QStandardItem, QStandardItemModel
from PySide2.QtWidgets import QAbstractItemView, QListView
@ -116,6 +116,7 @@ def load_campaigns() -> List[Campaign]:
class QCampaignItem(QStandardItem):
def __init__(self, campaign: Campaign) -> None:
super(QCampaignItem, self).__init__()
self.setData(campaign, QCampaignList.CampaignRole)
self.setIcon(QtGui.QIcon(CONST.ICONS[campaign.icon_name]))
self.setEditable(False)
if campaign.is_compatible:
@ -126,31 +127,33 @@ class QCampaignItem(QStandardItem):
class QCampaignList(QListView):
def __init__(self, campaigns: List[Campaign]) -> None:
CampaignRole = Qt.UserRole
def __init__(self, campaigns: list[Campaign], show_incompatible: bool) -> None:
super(QCampaignList, self).__init__()
self.model = QStandardItemModel(self)
self.setModel(self.model)
self.campaign_model = QStandardItemModel(self)
self.setModel(self.campaign_model)
self.setMinimumWidth(250)
self.setMinimumHeight(350)
self.campaigns = []
self.campaigns = campaigns
self.setSelectionBehavior(QAbstractItemView.SelectItems)
self.setup_content(campaigns)
self.setup_content(show_incompatible)
def setup_content(self, campaigns: List[Campaign]) -> None:
for campaign in campaigns:
self.campaigns.append(campaign)
item = QCampaignItem(campaign)
self.model.appendRow(item)
self.setSelectedCampaign(0)
self.repaint()
@property
def selected_campaign(self) -> Campaign:
return self.currentIndex().data(QCampaignList.CampaignRole)
def setSelectedCampaign(self, row):
self.selectionModel().clearSelection()
index = self.model.index(row, 0)
if not index.isValid():
index = self.model.index(0, 0)
self.selectionModel().setCurrentIndex(index, QItemSelectionModel.Select)
self.repaint()
def setup_content(self, show_incompatible: bool) -> None:
self.selectionModel().blockSignals(True)
try:
self.campaign_model.clear()
for campaign in self.campaigns:
if show_incompatible or campaign.is_compatible:
item = QCampaignItem(campaign)
self.campaign_model.appendRow(item)
finally:
self.selectionModel().blockSignals(False)
def clear_layout(self):
self.model.removeRows(0, self.model.rowCount())
self.selectionModel().setCurrentIndex(
self.campaign_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select
)

View File

@ -6,7 +6,7 @@ from typing import List
from PySide2 import QtGui, QtWidgets
from PySide2.QtCore import QItemSelectionModel, QPoint, Qt, QDate
from PySide2.QtWidgets import QVBoxLayout, QTextEdit, QLabel
from PySide2.QtWidgets import QVBoxLayout, QTextEdit, QLabel, QCheckBox
from jinja2 import Environment, FileSystemLoader, select_autoescape
from game import db
@ -319,7 +319,16 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
)
# List of campaigns
campaignList = QCampaignList(campaigns)
show_incompatible_campaigns_checkbox = QCheckBox(
text="Show incompatible campaigns"
)
show_incompatible_campaigns_checkbox.setChecked(False)
campaignList = QCampaignList(
campaigns, show_incompatible_campaigns_checkbox.isChecked()
)
show_incompatible_campaigns_checkbox.toggled.connect(
lambda checked: campaignList.setup_content(show_incompatible=checked)
)
self.registerField("selectedCampaign", campaignList)
# Faction description
@ -380,8 +389,7 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
template_perf = jinja_env.get_template(
"campaign_performance_template_EN.j2"
)
index = campaignList.selectionModel().currentIndex().row()
campaign = campaignList.campaigns[index]
campaign = campaignList.selected_campaign
self.setField("selectedCampaign", campaign)
self.campaignMapDescription.setText(template.render({"campaign": campaign}))
self.faction_selection.setDefaultFactions(campaign)
@ -396,9 +404,12 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
campaignList.selectionModel().selectionChanged.connect(on_campaign_selected)
on_campaign_selected()
# Docs Link
docsText = QtWidgets.QLabel(
'<a href="https://github.com/dcs-liberation/dcs_liberation/wiki/Custom-Campaigns"><span style="color:#FFFFFF;">How to create your own theater</span></a>'
"<p>Want more campaigns? You can "
'<a href="https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance"><span style="color:#FFFFFF;">offer to help</span></a>, '
'<a href="https://github.com/dcs-liberation/dcs_liberation/wiki/Community-campaigns"><span style="color:#FFFFFF;">play a community campaign</span></a>, '
'or <a href="https://github.com/dcs-liberation/dcs_liberation/wiki/Custom-Campaigns"><span style="color:#FFFFFF;">create your own</span></a>.'
"</p>"
)
docsText.setAlignment(Qt.AlignCenter)
docsText.setOpenExternalLinks(True)
@ -418,7 +429,8 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
layout = QtWidgets.QGridLayout()
layout.setColumnMinimumWidth(0, 20)
layout.addWidget(campaignList, 0, 0, 5, 1)
layout.addWidget(docsText, 5, 0, 1, 1)
layout.addWidget(show_incompatible_campaigns_checkbox, 5, 0, 1, 1)
layout.addWidget(docsText, 6, 0, 1, 1)
layout.addWidget(self.campaignMapDescription, 0, 1, 1, 1)
layout.addWidget(self.performanceText, 1, 1, 1, 1)
layout.addWidget(mapSettingsGroup, 2, 1, 1, 1)

View File

@ -473,20 +473,22 @@ class QSettingsWindow(QDialog):
general_layout.addWidget(restrict_weapons, 0, 1, Qt.AlignRight)
def set_old_awec(value: bool) -> None:
self.game.settings.disable_legacy_aewc = value
self.game.settings.disable_legacy_aewc = not value
old_awac = QCheckBox()
old_awac.setChecked(self.game.settings.disable_legacy_aewc)
old_awac.setChecked(not self.game.settings.disable_legacy_aewc)
old_awac.toggled.connect(set_old_awec)
old_awec_info = (
"If checked, the invulnerable friendly AEW&C aircraft that begins "
"the mission in the air will not be spawned. AEW&C missions must "
"be planned in the ATO and will take time to arrive on-station."
"If checked, an invulnerable friendly AEW&C aircraft that begins the "
"mission on station will be be spawned. This behavior will be removed in a "
"future release."
)
old_awac.setToolTip(old_awec_info)
old_awac_label = QLabel("Disable invulnerable, always-available AEW&C (WIP)")
old_awac_label = QLabel(
"Spawn invulnerable, always-available AEW&C aircraft (deprecated)"
)
old_awac_label.setToolTip(old_awec_info)
general_layout.addWidget(old_awac_label, 1, 0)

View File

@ -0,0 +1,11 @@
{
"name": "Persian Gulf - Battle for the UAE",
"theater": "Persian Gulf",
"authors": "Mustang25",
"recommended_player_faction": "Bluefor Modern",
"recommended_enemy_faction": "Iran 2015",
"description": "<p>Following the Battle of Abu Dhabi, Iran's invasion of the UAE has been halted approximately 20 miles Northeast of Liwa Airbase by coalition forces.</p><p>After weeks of stalemate, coalition forces have consolidated their position and are ready to launch their counterattack to push Iranian forces off the peninsula.</p>",
"version": "6.0",
"miz": "Battle_for_the_UAE_v3.0.2.miz",
"performance": 2
}

Binary file not shown.

View File

@ -0,0 +1,11 @@
{
"name": "Syria - Operation Mole Cricket 2010",
"theater": "Syria",
"authors": "Mustang25",
"recommended_player_faction": "Bluefor Modern",
"recommended_enemy_faction": "Syria 2011",
"description": "<p>In a scenario reminescent of the First Lebanon War, hostile Syrian-backed forces have flooded into the Bekaa Valley.</p><p>The objective of this operation is twofold: drive the enemy out of the Bekaa Valley and push past the Golan Heights into Syrian territory to capture Tiyas Airbase.</p>",
"version": "6.0",
"miz": "Operation_Mole_Cricket_2010_v3.0.2.miz",
"performance": 2
}

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": "5.0"
"version": "6.0"
}

View File

@ -4,5 +4,6 @@
"authors": "Colonel Panic",
"description": "<p>A medium sized theater with bases along the coast of the Black Sea.</p>",
"miz": "black_sea.miz",
"performance": 2
"performance": 2,
"version": "6.0"
}

Binary file not shown.

View File

@ -2,8 +2,10 @@
"name": "Nevada - Exercise Vegas Nerve",
"theater": "Nevada",
"authors": "Starfire",
"description": "<p>A Red Flag Exercise scenario for the NTTR comprising 4 control points.</p>",
"version": 3,
"recommended_player_faction": "Bluefor Modern",
"recommended_enemy_faction": "Redfor (China) 2010",
"description": "<p>This is an asymmetrical Red Flag Exercise scenario for the NTTR comprising 4 control points. You start off in control of the two Tonopah airports, and will push south to capture Groom Lake and Nellis AFBs. Taking down Nellis AFB's IADS and striking their resource sites ASAP once Groom Lake has been captured is recommended to offset their resource advantage.</p>",
"version": "6.0",
"miz": "exercise_vegas_nerve.miz",
"performance": 0
}

View File

@ -1,5 +1,5 @@
{
"name": "Syria - Battle for Golan Heights - Lite",
"name": "Syria - Battle for Golan Heights",
"theater": "Syria",
"authors": "Khopa",
"recommended_player_faction": "Israel 2000",
@ -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": "5.0"
"version": "6.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": "5.0",
"version": "6.0",
"miz": "inherent_resolve.miz",
"performance": 2
}

View File

@ -5,7 +5,7 @@
"recommended_player_faction": "Bluefor Modern",
"recommended_enemy_faction": "Turkey 2005",
"description": "<p>This is a semi-fictional what-if scenario for Operation Peace Spring, during which Turkish forces that crossed into Syria on an offensive against Kurdish militias were emboldened by early successes to continue pushing further southward. Attempts to broker a ceasefire have failed. Members of Operation Inherent Resolve have gathered at Ramat David Airbase in Israel to launch a counter-offensive. Campaign inversion is available if you wish to play as Turkey.</p>",
"version": 3,
"version": "6.0",
"miz": "operation_peace_spring.miz",
"performance": 1
}

View File

@ -0,0 +1,11 @@
{
"name": "Caucasus - Operation Vectron's Claw",
"theater": "Caucasus",
"authors": "Starfire",
"recommended_player_faction": "USA 2005",
"recommended_enemy_faction": "Russia 1990",
"description": "<p>United Nations Observer Mission in Georgia (UNOMIG) observers stationed in Georgia to monitor the ceasefire between Georgia and Abkhazia have been cut off from friendly forces by Russian troops backing the separatist state. The UNOMIG HQ at Sukhumi has been taken, and a small contingent of observers and troops at the Zugdidi Sector HQ will have to make a run for the coast, supported by offshore US naval aircraft. The contingent is aware that their best shot at survival is to swiftly retake Sukhumi before Russian forces have a chance to dig in, so that friendly ground forces can land and reinforce them.<br/></p><p><strong>Note:</strong> Ground unit purchase will not be available past Turn 0 until Sukhumi is retaken, so it is imperative you reach Sukhumi with at least one surviving ground unit to capture it. The player can either play the first leg of the scenario as an evacuation with a couple of light vehicles (e.g. Humvees) set on breakthrough (modifying waypoints in the mission editor so they are not charging head-on into enemy ground forces is suggested), or purchase heavier ground units if they wish to experience a more traditional ground war.</p>",
"version": "6.0",
"miz": "operation_vectrons_claw.miz",
"performance": 1
}

Binary file not shown.

View File

@ -7,5 +7,5 @@
"description": "<p>A small theater in Russia, progress from Mozdok to Maykop.</p><p>This scenario is pretty simple, it is ideal if you want to run a short campaign. If your PC is not powerful, this is also the less performance heavy scenario.</p>",
"miz": "russia_small.miz",
"performance": 0,
"version": 3
"version": "6.0"
}

Binary file not shown.

View File

@ -0,0 +1,11 @@
{
"name": "Syria - Full Map",
"theater": "Syria",
"authors": "Plob",
"recommended_player_faction": "Bluefor Modern",
"recommended_enemy_faction": "Syria 2011",
"description": "<p>Syria Full map, designed for groups of 4-12 players.</p>",
"miz": "syria_full_map.miz",
"performance": 3,
"version": "6.0"
}

Binary file not shown.

View File

@ -1,8 +0,0 @@
{
"name": "Syria - Full Map",
"theater": "Syria",
"authors": "Hawkmoon",
"description": "<p>Full map of Syria</p><p><strong>Note :&nbsp;</strong></p><p>For a better early game experience, it is suggested to give the AI an high amount of starting money.</p>",
"miz": "syria_full_map_remastered.miz",
"performance": 3
}

View File

@ -0,0 +1,61 @@
{
"country": "Jordan",
"name": "Jordan 2010",
"authors": "Starfire",
"description": "<p>Royal Jordanian Armed Forces early 21st century</p>",
"aircrafts": [
"F_5E_3",
"C_101CC",
"SA342M",
"SA342L"
],
"frontline_units": [
"MBT_Challenger_II",
"MBT_M60A3_Patton",
"IFV_Marder",
"IFV_BMP_2",
"APC_M113",
"APC_M1043_HMMWV_Armament",
"ATGM_M1045_HMMWV_TOW"
],
"artillery_units": [
"MLRS_M270",
"SPH_M109_Paladin"
],
"logistics_units": [
"Transport_M818"
],
"infantry_units": [
"Infantry_M4",
"Soldier_M249",
"SAM_SA_18_Igla_S_MANPADS"
],
"air_defenses": [
"SA8Generator",
"SA13Generator",
"VulcanGenerator",
"ZU23Generator",
"HawkGenerator"
],
"ewrs": [
"HawkEwrGenerator"
],
"aircraft_carrier": [
],
"helicopter_carrier": [
],
"destroyers": [
],
"cruisers": [
],
"requirements": {
},
"carrier_names": [
],
"helicopter_carrier_names": [
],
"navy_generators": [
],
"has_jtac": false,
"doctrine": "coldwar"
}

View File

@ -498,7 +498,6 @@ QHeaderView::section {
background: #4B5B74;
padding: 4px;
border-style: none;
border-bottom: 1px solid #1D2731;
}
QHeaderView::section:horizontal
@ -515,11 +514,6 @@ QHeaderView::section:vertical
background: #4B5B74;
}
QTableWidget {
gridline-color: red;
background: #4B5B74;
}
QTableView QTableCornerButton::section {
background: #4B5B74;
}

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="158" height="118" viewBox="21 36 158 118"><path d="M25,50 l150,0 0,100 -150,0 z" stroke-width="4" stroke="black" fill="rgb(128,224,255)" fill-opacity="1" ></path><path d="m 75,85 50,30 m -50,0 50,-30" stroke-width="4" stroke="black" fill="none" ></path><path d="M85,48 85,40 115,40 115,48 100,46 Z" stroke-width="4" stroke="black" fill="black" ></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="158" height="118" viewBox="21 36 158 118"><path d="M25,50 l150,0 0,100 -150,0 z" stroke-width="4" stroke="black" fill="rgb(0,107,140)" fill-opacity="1" ></path><path d="m 75,85 50,30 m -50,0 50,-30" stroke-width="4" stroke="black" fill="none" ></path><path d="M85,48 85,40 115,40 115,48 100,46 Z" stroke-width="4" stroke="black" fill="black" ></path></svg>

Before

Width:  |  Height:  |  Size: 438 B

After

Width:  |  Height:  |  Size: 436 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="158" height="148" viewBox="21 36 158 148"><path d="M25,50 l150,0 0,100 -150,0 z" stroke-width="4" stroke="black" fill="rgb(128,224,255)" fill-opacity="1" ></path><path d="m 75,85 50,30 m -50,0 50,-30" stroke-width="4" stroke="black" fill="none" ></path><path d="M85,48 85,40 115,40 115,48 100,46 Z" stroke-width="4" stroke="black" fill="black" ></path><path d="M25,155 l150,0 0,25 -150,0 z" stroke-width="4" stroke="black" fill="rgb(255,255,0)" ></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="158" height="148" viewBox="21 36 158 148"><path d="M25,50 l150,0 0,100 -150,0 z" stroke-width="4" stroke="black" fill="rgb(0,107,140)" fill-opacity="1" ></path><path d="m 75,85 50,30 m -50,0 50,-30" stroke-width="4" stroke="black" fill="none" ></path><path d="M85,48 85,40 115,40 115,48 100,46 Z" stroke-width="4" stroke="black" fill="black" ></path><path d="M25,155 l150,0 0,25 -150,0 z" stroke-width="4" stroke="black" fill="rgb(255,255,0)" ></path></svg>

Before

Width:  |  Height:  |  Size: 539 B

After

Width:  |  Height:  |  Size: 537 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="158" height="148" viewBox="21 36 158 148"><path d="M25,50 l150,0 0,100 -150,0 z" stroke-width="4" stroke="black" fill="rgb(128,224,255)" fill-opacity="1" ></path><path d="m 75,85 50,30 m -50,0 50,-30" stroke-width="4" stroke="black" fill="none" ></path><path d="M85,48 85,40 115,40 115,48 100,46 Z" stroke-width="4" stroke="black" fill="black" ></path><path d="M25,155 l150,0 0,25 -150,0 z" stroke-width="4" stroke="black" fill="rgb(255,0,0)" ></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="158" height="148" viewBox="21 36 158 148"><path d="M25,50 l150,0 0,100 -150,0 z" stroke-width="4" stroke="black" fill="rgb(0,107,140)" fill-opacity="1" ></path><path d="m 75,85 50,30 m -50,0 50,-30" stroke-width="4" stroke="black" fill="none" ></path><path d="M85,48 85,40 115,40 115,48 100,46 Z" stroke-width="4" stroke="black" fill="black" ></path><path d="M25,155 l150,0 0,25 -150,0 z" stroke-width="4" stroke="black" fill="rgb(255,0,0)" ></path></svg>

Before

Width:  |  Height:  |  Size: 537 B

After

Width:  |  Height:  |  Size: 535 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="152" height="162" viewBox="24 14 152 162"><path d="M 100,28 L172,100 100,172 28,100 100,28 Z" stroke-width="4" stroke="black" fill="rgb(255,128,128)" fill-opacity="1" ></path><path d="m 75,85 50,30 m -50,0 50,-30" stroke-width="4" stroke="black" fill="none" ></path><path d="M85,40 85,18 115,18 115,40 100,24 Z" stroke-width="4" stroke="black" fill="black" ></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="152" height="162" viewBox="24 14 152 162"><path d="M 100,28 L172,100 100,172 28,100 100,28 Z" stroke-width="4" stroke="black" fill="rgb(200,0,0)" fill-opacity="1" ></path><path d="m 75,85 50,30 m -50,0 50,-30" stroke-width="4" stroke="black" fill="none" ></path><path d="M85,40 85,18 115,18 115,40 100,24 Z" stroke-width="4" stroke="black" fill="black" ></path></svg>

Before

Width:  |  Height:  |  Size: 451 B

After

Width:  |  Height:  |  Size: 447 B

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