diff --git a/changelog.md b/changelog.md index 0f60b72f..c0ecb509 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,7 @@ -# 2.1.3 +# 2.1.4 + +## Fixes : +* **[UI]** Fixed an issue that prevented generating the mission (take off button no working) on old savegames. ## Features/Improvements : * **[Units/Factions]** Added A-10C_2 to USA 2005 and Bluefor modern factions diff --git a/game/data/cap_capabilities_db.py b/game/data/cap_capabilities_db.py index eb367238..438a0f80 100644 --- a/game/data/cap_capabilities_db.py +++ b/game/data/cap_capabilities_db.py @@ -1,4 +1,22 @@ -from dcs.planes import * +from dcs.planes import ( + Bf_109K_4, + C_101CC, + FW_190A8, + FW_190D9, + F_5E_3, + F_86F_Sabre, + I_16, + L_39ZA, + MiG_15bis, + MiG_19P, + MiG_21Bis, + P_47D_30, + P_51D, + P_51D_30_NA, + SpitfireLFMkIX, + SpitfireLFMkIXCW, +) + from pydcs_extensions.a4ec.a4ec import A_4E_C """ diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 866ae897..d81c5484 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,95 +1,101 @@ +from dataclasses import dataclass + from game.utils import nm_to_meter, feet_to_meter -MODERN_DOCTRINE = { - "GENERATORS": { - "CAS": True, - "CAP": True, - "SEAD": True, - "STRIKE": True, - "ANTISHIP": True, - }, +@dataclass(frozen=True) +class Doctrine: + cas: bool + cap: bool + sead: bool + strike: bool + antiship: bool - "STRIKE_MAX_RANGE": 1500000, - "SEAD_MAX_RANGE": 1500000, + strike_max_range: int + sead_max_range: int - "CAP_EVERY_X_MINUTES": 20, - "CAS_EVERY_X_MINUTES": 30, - "SEAD_EVERY_X_MINUTES": 40, - "STRIKE_EVERY_X_MINUTES": 40, + rendezvous_altitude: int + join_distance: int + split_distance: int + ingress_egress_distance: int + ingress_altitude: int + egress_altitude: int - "INGRESS_EGRESS_DISTANCE": nm_to_meter(45), - "INGRESS_ALT": feet_to_meter(20000), - "EGRESS_ALT": feet_to_meter(20000), - "PATROL_ALT_RANGE": (feet_to_meter(15000), feet_to_meter(33000)), - "PATTERN_ALTITUDE": feet_to_meter(5000), + min_patrol_altitude: int + max_patrol_altitude: int + pattern_altitude: int - "CAP_PATTERN_LENGTH": (nm_to_meter(15), nm_to_meter(40)), - "FRONTLINE_CAP_DISTANCE_FROM_FRONTLINE": (nm_to_meter(6), nm_to_meter(15)), - "CAP_DISTANCE_FROM_CP": (nm_to_meter(10), nm_to_meter(40)), + cap_min_track_length: int + cap_max_track_length: int + cap_min_distance_from_cp: int + cap_max_distance_from_cp: int - "MAX_NUMBER_OF_INTERCEPTION_GROUP": 3, -} -COLDWAR_DOCTRINE = { +MODERN_DOCTRINE = Doctrine( + cap=True, + cas=True, + sead=True, + strike=True, + antiship=True, + strike_max_range=1500000, + sead_max_range=1500000, + rendezvous_altitude=feet_to_meter(25000), + join_distance=nm_to_meter(20), + split_distance=nm_to_meter(20), + ingress_egress_distance=nm_to_meter(45), + ingress_altitude=feet_to_meter(20000), + egress_altitude=feet_to_meter(20000), + min_patrol_altitude=feet_to_meter(15000), + max_patrol_altitude=feet_to_meter(33000), + pattern_altitude=feet_to_meter(5000), + cap_min_track_length=nm_to_meter(15), + cap_max_track_length=nm_to_meter(40), + cap_min_distance_from_cp=nm_to_meter(10), + cap_max_distance_from_cp=nm_to_meter(40), +) - "GENERATORS": { - "CAS": True, - "CAP": True, - "SEAD": True, - "STRIKE": True, - "ANTISHIP": True, - }, +COLDWAR_DOCTRINE = Doctrine( + cap=True, + cas=True, + sead=True, + strike=True, + antiship=True, + strike_max_range=1500000, + sead_max_range=1500000, + rendezvous_altitude=feet_to_meter(22000), + join_distance=nm_to_meter(10), + split_distance=nm_to_meter(10), + ingress_egress_distance=nm_to_meter(30), + ingress_altitude=feet_to_meter(18000), + egress_altitude=feet_to_meter(18000), + min_patrol_altitude=feet_to_meter(10000), + max_patrol_altitude=feet_to_meter(24000), + pattern_altitude=feet_to_meter(5000), + cap_min_track_length=nm_to_meter(12), + cap_max_track_length=nm_to_meter(24), + cap_min_distance_from_cp=nm_to_meter(8), + cap_max_distance_from_cp=nm_to_meter(25), +) - "STRIKE_MAX_RANGE": 1500000, - "SEAD_MAX_RANGE": 1500000, - - "CAP_EVERY_X_MINUTES": 20, - "CAS_EVERY_X_MINUTES": 30, - "SEAD_EVERY_X_MINUTES": 40, - "STRIKE_EVERY_X_MINUTES": 40, - - "INGRESS_EGRESS_DISTANCE": nm_to_meter(30), - "INGRESS_ALT": feet_to_meter(18000), - "EGRESS_ALT": feet_to_meter(18000), - "PATROL_ALT_RANGE": (feet_to_meter(10000), feet_to_meter(24000)), - "PATTERN_ALTITUDE": feet_to_meter(5000), - - "CAP_PATTERN_LENGTH": (nm_to_meter(12), nm_to_meter(24)), - "FRONTLINE_CAP_DISTANCE_FROM_FRONTLINE": (nm_to_meter(2), nm_to_meter(8)), - "CAP_DISTANCE_FROM_CP": (nm_to_meter(8), nm_to_meter(25)), - - "MAX_NUMBER_OF_INTERCEPTION_GROUP": 3, -} - -WWII_DOCTRINE = { - - "GENERATORS": { - "CAS": True, - "CAP": True, - "SEAD": False, - "STRIKE": True, - "ANTISHIP": True, - }, - - "STRIKE_MAX_RANGE": 1500000, - "SEAD_MAX_RANGE": 1500000, - - "CAP_EVERY_X_MINUTES": 20, - "CAS_EVERY_X_MINUTES": 30, - "SEAD_EVERY_X_MINUTES": 40, - "STRIKE_EVERY_X_MINUTES": 40, - - "INGRESS_EGRESS_DISTANCE": nm_to_meter(7), - "INGRESS_ALT": feet_to_meter(8000), - "EGRESS_ALT": feet_to_meter(8000), - "PATROL_ALT_RANGE": (feet_to_meter(4000), feet_to_meter(15000)), - "PATTERN_ALTITUDE": feet_to_meter(5000), - - "CAP_PATTERN_LENGTH": (nm_to_meter(8), nm_to_meter(18)), - "FRONTLINE_CAP_DISTANCE_FROM_FRONTLINE": (nm_to_meter(1), nm_to_meter(6)), - "CAP_DISTANCE_FROM_CP": (nm_to_meter(0), nm_to_meter(5)), - - "MAX_NUMBER_OF_INTERCEPTION_GROUP": 3, - -} +WWII_DOCTRINE = Doctrine( + cap=True, + cas=True, + sead=False, + strike=True, + antiship=True, + strike_max_range=1500000, + sead_max_range=1500000, + join_distance=nm_to_meter(5), + split_distance=nm_to_meter(5), + rendezvous_altitude=feet_to_meter(10000), + ingress_egress_distance=nm_to_meter(7), + ingress_altitude=feet_to_meter(8000), + egress_altitude=feet_to_meter(8000), + min_patrol_altitude=feet_to_meter(4000), + max_patrol_altitude=feet_to_meter(15000), + pattern_altitude=feet_to_meter(5000), + cap_min_track_length=nm_to_meter(8), + cap_max_track_length=nm_to_meter(18), + cap_min_distance_from_cp=nm_to_meter(0), + cap_max_distance_from_cp=nm_to_meter(5), +) diff --git a/game/data/radar_db.py b/game/data/radar_db.py index c3c9e25a..4e90d56c 100644 --- a/game/data/radar_db.py +++ b/game/data/radar_db.py @@ -1,5 +1,26 @@ +from dcs.ships import ( + CGN_1144_2_Pyotr_Velikiy, + CG_1164_Moskva, + CVN_70_Carl_Vinson, + CVN_71_Theodore_Roosevelt, + CVN_72_Abraham_Lincoln, + CVN_73_George_Washington, + CVN_74_John_C__Stennis, + CV_1143_5_Admiral_Kuznetsov, + CV_1143_5_Admiral_Kuznetsov_2017, + FFG_11540_Neustrashimy, + FFL_1124_4_Grisha, + FF_1135M_Rezky, + FSG_1241_1MP_Molniya, + LHA_1_Tarawa, + Oliver_Hazzard_Perry_class, + Ticonderoga_class, + Type_052B_Destroyer, + Type_052C_Destroyer, + Type_054A_Frigate, + USS_Arleigh_Burke_IIa, +) from dcs.vehicles import AirDefence -from dcs.ships import * UNITS_WITH_RADAR = [ diff --git a/game/db.py b/game/db.py index f2b94b54..97c3c4fc 100644 --- a/game/db.py +++ b/game/db.py @@ -1,34 +1,177 @@ -import typing -import enum from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple, Type, Union -from dcs.countries import get_by_id, country_dict -from dcs.vehicles import * -from dcs.ships import * -from dcs.planes import * -from dcs.helicopters import * - -from dcs.task import * -from dcs.unit import * -from dcs.unittype import * -from dcs.unitgroup import * +from dcs.countries import country_dict +from dcs.helicopters import ( + AH_1W, + AH_64A, + AH_64D, + HelicopterType, + Ka_50, + Mi_24V, + Mi_28N, + Mi_8MT, + OH_58D, + SA342L, + SA342M, + SA342Minigun, + SA342Mistral, + UH_1H, + UH_60A, + helicopter_map, +) +from dcs.mapping import Point +# mypy can't resolve these if they're wildcard imports for some reason. +from dcs.planes import ( + AJS37, + AV8BNA, + A_10A, + A_10C, + A_10C_2, + A_20G, + A_50, + An_26B, + An_30M, + B_17G, + B_1B, + B_52H, + Bf_109K_4, + C_101CC, + C_130, + E_3A, + FA_18C_hornet, + FW_190A8, + FW_190D9, + F_14B, + F_15C, + F_15E, + F_16A, + F_16C_50, + F_4E, + F_5E_3, + F_86F_Sabre, + F_A_18C, + IL_76MD, + IL_78M, + JF_17, + J_11A, + Ju_88A4, + KC130, + KC_135, + KJ_2000, + L_39C, + L_39ZA, + MQ_9_Reaper, + M_2000C, + MiG_15bis, + MiG_19P, + MiG_21Bis, + MiG_23MLD, + MiG_25PD, + MiG_27K, + MiG_29A, + MiG_29G, + MiG_29S, + MiG_31, + Mirage_2000_5, + P_47D_30, + P_47D_30bl1, + P_47D_40, + P_51D, + P_51D_30_NA, + PlaneType, + RQ_1A_Predator, + S_3B_Tanker, + SpitfireLFMkIX, + SpitfireLFMkIXCW, + Su_17M4, + Su_24M, + Su_24MR, + Su_25, + Su_25T, + Su_25TM, + Su_27, + Su_30, + Su_33, + Su_34, + Tornado_GR4, + Tornado_IDS, + WingLoong_I, + Yak_40, + plane_map, +) +from dcs.ships import ( + Armed_speedboat, + Bulk_cargo_ship_Yakushev, + CVN_71_Theodore_Roosevelt, + CVN_72_Abraham_Lincoln, + CVN_73_George_Washington, + CVN_74_John_C__Stennis, + CV_1143_5_Admiral_Kuznetsov, + CV_1143_5_Admiral_Kuznetsov_2017, + Dry_cargo_ship_Ivanov, + LHA_1_Tarawa, + Tanker_Elnya_160, + ship_map, +) +from dcs.task import ( + AWACS, + AntishipStrike, + CAP, + CAS, + CargoTransportation, + Embarking, + Escort, + GroundAttack, + Intercept, + MainTask, + Nothing, + PinpointStrike, + Reconnaissance, + Refueling, + SEAD, + Task, + Transport, +) +from dcs.terrain.terrain import Airport +from dcs.unit import Ship, Unit, Vehicle +from dcs.unitgroup import ShipGroup, StaticGroup +from dcs.unittype import FlyingType, ShipType, UnitType, VehicleType +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Carriage, + Infantry, + Unarmed, + vehicle_map, +) +import pydcs_extensions.frenchpack.frenchpack as frenchpack from game.factions.australia_2005 import Australia_2005 from game.factions.bluefor_coldwar import BLUEFOR_COLDWAR from game.factions.bluefor_coldwar_a4 import BLUEFOR_COLDWAR_A4 from game.factions.bluefor_coldwar_mods import BLUEFOR_COLDWAR_MODS +from game.factions.bluefor_modern import BLUEFOR_MODERN from game.factions.canada_2005 import Canada_2005 from game.factions.china_2010 import China_2010 from game.factions.france_1995 import France_1995 from game.factions.france_2005 import France_2005 from game.factions.france_modded import France_2005_Modded +from game.factions.germany_1944 import Germany_1944 from game.factions.germany_1944_easy import Germany_1944_Easy from game.factions.germany_1990 import Germany_1990 +from game.factions.india_2010 import India_2010 from game.factions.insurgent import Insurgent from game.factions.insurgent_modded import Insurgent_modded from game.factions.iran_2015 import Iran_2015 from game.factions.israel_1948 import Israel_1948 -from game.factions.israel_1973 import Israel_1973, Israel_1973_NO_WW2_UNITS, Israel_1982 +from game.factions.israel_1973 import ( + Israel_1973, + Israel_1973_NO_WW2_UNITS, + Israel_1982, +) from game.factions.israel_2000 import Israel_2000 from game.factions.italy_1990 import Italy_1990 from game.factions.italy_1990_mb339 import Italy_1990_MB339 @@ -37,35 +180,41 @@ from game.factions.libya_2011 import Libya_2011 from game.factions.netherlands_1990 import Netherlands_1990 from game.factions.north_korea_2000 import NorthKorea_2000 from game.factions.pakistan_2015 import Pakistan_2015 -from game.factions.private_miltary_companies import PMC_WESTERN_B, PMC_RUSSIAN, PMC_WESTERN_A -from game.factions.russia_1975 import Russia_1975 -from game.factions.germany_1944 import Germany_1944 -from game.factions.india_2010 import India_2010 +from game.factions.private_miltary_companies import ( + PMC_RUSSIAN, + PMC_WESTERN_A, + PMC_WESTERN_B, +) from game.factions.russia_1955 import Russia_1955 from game.factions.russia_1965 import Russia_1965 +from game.factions.russia_1975 import Russia_1975 from game.factions.russia_1990 import Russia_1990 from game.factions.russia_2010 import Russia_2010 from game.factions.spain_1990 import Spain_1990 from game.factions.sweden_1990 import Sweden_1990 -from game.factions.syria import Syria_2011, Syria_1967, Syria_1967_WW2_Weapons, Syria_1973, Arab_Armies_1948, Syria_1982 +from game.factions.syria import ( + Arab_Armies_1948, + Syria_1967, + Syria_1967_WW2_Weapons, + Syria_1973, + Syria_1982, + Syria_2011, +) from game.factions.turkey_2005 import Turkey_2005 from game.factions.uae_2005 import UAE_2005 from game.factions.uk_1944 import UK_1944 from game.factions.uk_1990 import UnitedKingdom_1990 from game.factions.ukraine_2010 import Ukraine_2010 from game.factions.us_aggressors import US_Aggressors -from game.factions.usa_1944 import USA_1944, ALLIES_1944 +from game.factions.usa_1944 import ALLIES_1944, USA_1944 from game.factions.usa_1955 import USA_1955 from game.factions.usa_1960 import USA_1960 from game.factions.usa_1965 import USA_1965 from game.factions.usa_1990 import USA_1990 from game.factions.usa_2005 import USA_2005 -from game.factions.bluefor_modern import BLUEFOR_MODERN - # PATCH pydcs data with MODS from pydcs_extensions.a4ec.a4ec import A_4E_C from pydcs_extensions.mb339.mb339 import MB_339PAN -import pydcs_extensions.frenchpack.frenchpack as frenchpack from pydcs_extensions.rafale.rafale import Rafale_A_S, Rafale_M plane_map["A-4E-C"] = A_4E_C @@ -793,13 +942,13 @@ SAM_CONVERT = { """ Units that will always be spawned in the air """ -TAKEOFF_BAN = [ +TAKEOFF_BAN: List[Type[FlyingType]] = [ ] """ Units that will be always spawned in the air if launched from the carrier """ -CARRIER_TAKEOFF_BAN = [ +CARRIER_TAKEOFF_BAN: List[Type[FlyingType]] = [ Su_33, # Kuznecow is bugged in a way that only 2 aircraft could be spawned ] @@ -807,7 +956,7 @@ CARRIER_TAKEOFF_BAN = [ Units separated by country. country : DCS Country name """ -FACTIONS = { +FACTIONS: Dict[str, Dict[str, Any]] = { "Bluefor Modern": BLUEFOR_MODERN, "Bluefor Cold War 1970s": BLUEFOR_COLDWAR, @@ -934,10 +1083,11 @@ COMMON_OVERRIDE = { PinpointStrike: "STRIKE", SEAD: "SEAD", AntishipStrike: "ANTISHIP", - GroundAttack: "STRIKE" + GroundAttack: "STRIKE", + Escort: "CAP", } -PLANE_PAYLOAD_OVERRIDES = { +PLANE_PAYLOAD_OVERRIDES: Dict[Type[PlaneType], Dict[Type[Task], str]] = { FA_18C_hornet: { CAP: "CAP HEAVY", @@ -946,7 +1096,8 @@ PLANE_PAYLOAD_OVERRIDES = { PinpointStrike: "STRIKE", SEAD: "SEAD", AntishipStrike: "ANTISHIP", - GroundAttack: "STRIKE" + GroundAttack: "STRIKE", + Escort: "CAP HEAVY", }, F_A_18C: { CAP: "CAP HEAVY", @@ -955,7 +1106,8 @@ PLANE_PAYLOAD_OVERRIDES = { PinpointStrike: "STRIKE", SEAD: "SEAD", AntishipStrike: "ANTISHIP", - GroundAttack: "STRIKE" + GroundAttack: "STRIKE", + Escort: "CAP HEAVY", }, A_10A: COMMON_OVERRIDE, A_10C: COMMON_OVERRIDE, @@ -1134,17 +1286,17 @@ LHA_CAPABLE = [ ---------- END OF CONFIGURATION SECTION """ -UnitsDict = typing.Dict[UnitType, int] -PlaneDict = typing.Dict[FlyingType, int] -HeliDict = typing.Dict[HelicopterType, int] -ArmorDict = typing.Dict[VehicleType, int] -ShipDict = typing.Dict[ShipType, int] -AirDefenseDict = typing.Dict[AirDefence, int] +UnitsDict = Dict[UnitType, int] +PlaneDict = Dict[FlyingType, int] +HeliDict = Dict[HelicopterType, int] +ArmorDict = Dict[VehicleType, int] +ShipDict = Dict[ShipType, int] +AirDefenseDict = Dict[AirDefence, int] -AssignedUnitsDict = typing.Dict[typing.Type[UnitType], typing.Tuple[int, int]] -TaskForceDict = typing.Dict[typing.Type[Task], AssignedUnitsDict] +AssignedUnitsDict = Dict[Type[UnitType], Tuple[int, int]] +TaskForceDict = Dict[Type[MainTask], AssignedUnitsDict] -StartingPosition = typing.Optional[typing.Union[ShipGroup, StaticGroup, Airport, Point]] +StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point] def upgrade_to_supercarrier(unit, name: str): @@ -1162,7 +1314,7 @@ def upgrade_to_supercarrier(unit, name: str): else: return unit -def unit_task(unit: UnitType) -> Task: +def unit_task(unit: UnitType) -> Optional[Task]: for task, units in UNIT_BY_TASK.items(): if unit in units: return task @@ -1173,10 +1325,10 @@ def unit_task(unit: UnitType) -> Task: print(unit.name + " cause issue") return None -def find_unittype(for_task: Task, country_name: str) -> typing.List[UnitType]: +def find_unittype(for_task: Task, country_name: str) -> List[UnitType]: return [x for x in UNIT_BY_TASK[for_task] if x in FACTIONS[country_name]["units"]] -def find_infantry(country_name: str) -> typing.List[UnitType]: +def find_infantry(country_name: str) -> List[UnitType]: inf = [ Infantry.Paratrooper_AKS, Infantry.Paratrooper_AKS, Infantry.Paratrooper_AKS, Infantry.Paratrooper_AKS, Infantry.Paratrooper_AKS, Infantry.Soldier_RPG, @@ -1199,7 +1351,7 @@ def unit_type_name(unit_type) -> str: def unit_type_name_2(unit_type) -> str: return unit_type.name and unit_type.name or unit_type.id -def unit_type_from_name(name: str) -> UnitType: +def unit_type_from_name(name: str) -> Optional[UnitType]: if name in vehicle_map: return vehicle_map[name] elif name in plane_map: @@ -1232,7 +1384,7 @@ def task_name(task) -> str: return task.name -def choose_units(for_task: Task, factor: float, count: int, country: str) -> typing.Collection[UnitType]: +def choose_units(for_task: Task, factor: float, count: int, country: str) -> List[UnitType]: suitable_unittypes = find_unittype(for_task, country) suitable_unittypes = [x for x in suitable_unittypes if x not in helicopter_map.values()] suitable_unittypes.sort(key=lambda x: PRICES[x]) @@ -1258,7 +1410,7 @@ def unitdict_merge(a: UnitsDict, b: UnitsDict) -> UnitsDict: def unitdict_split(unit_dict: UnitsDict, count: int): - buffer_dict = {} + buffer_dict: Dict[UnitType, int] = {} for unit_type, unit_count in unit_dict.items(): for _ in range(unit_count): unitdict_append(buffer_dict, unit_type, 1) @@ -1281,7 +1433,7 @@ def unitdict_restrict_count(unit_dict: UnitsDict, total_count: int) -> UnitsDict return {} -def assigned_units_split(fd: AssignedUnitsDict) -> typing.Tuple[PlaneDict, PlaneDict]: +def assigned_units_split(fd: AssignedUnitsDict) -> Tuple[PlaneDict, PlaneDict]: return {k: v1 for k, (v1, v2) in fd.items()}, {k: v2 for k, (v1, v2) in fd.items()}, @@ -1290,7 +1442,7 @@ def assigned_units_from(d: PlaneDict) -> AssignedUnitsDict: def assignedunits_split_to_count(dict: AssignedUnitsDict, count: int): - buffer_dict = {} + buffer_dict: Dict[Type[UnitType], Tuple[int, int]] = {} for unit_type, (unit_count, client_count) in dict.items(): for _ in range(unit_count): new_count, new_client_count = buffer_dict.get(unit_type, (0, 0)) diff --git a/userdata/debriefing.py b/game/debriefing.py similarity index 100% rename from userdata/debriefing.py rename to game/debriefing.py diff --git a/game/event/event.py b/game/event/event.py index 8146deb3..0af3852c 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -1,24 +1,24 @@ -import typing +from __future__ import annotations + import logging +import math +from typing import Dict, List, Optional, Type, TYPE_CHECKING -from dcs.action import Coalition -from dcs.unittype import UnitType -from dcs.task import * -from dcs.vehicles import AirDefence +from dcs.mapping import Point +from dcs.task import Task from dcs.unittype import UnitType -from game import * +from game import db, persistency +from game.debriefing import Debriefing from game.infos.information import Information -from theater import * +from game.operation.operation import Operation from gen.environmentgen import EnvironmentSettings -from gen.conflictgen import Conflict -from game.db import assigned_units_from, unitdict_from +from gen.ground_forces.combat_stance import CombatStance +from theater import ControlPoint from theater.start_generator import generate_airbase_defense_group -from userdata.debriefing import Debriefing -from userdata import persistency - -import game.db as db +if TYPE_CHECKING: + from ..game import Game DIFFICULTY_LOG_BASE = 1.1 EVENT_DEPARTURE_MAX_DISTANCE = 340000 @@ -28,6 +28,7 @@ MINOR_DEFEAT_INFLUENCE = 0.1 DEFEAT_INFLUENCE = 0.3 STRONG_DEFEAT_INFLUENCE = 0.5 + class Event: silent = False informational = False @@ -37,7 +38,6 @@ class Event: game = None # type: Game location = None # type: Point from_cp = None # type: ControlPoint - departure_cp = None # type: ControlPoint to_cp = None # type: ControlPoint operation = None # type: Operation @@ -47,7 +47,7 @@ class Event: def __init__(self, game, from_cp: ControlPoint, target_cp: ControlPoint, location: Point, attacker_name: str, defender_name: str): self.game = game - self.departure_cp = None + self.departure_cp: Optional[ControlPoint] = None self.from_cp = from_cp self.to_cp = target_cp self.location = location @@ -59,14 +59,14 @@ class Event: return self.attacker_name == self.game.player_name @property - def enemy_cp(self) -> ControlPoint: + def enemy_cp(self) -> Optional[ControlPoint]: if self.attacker_name == self.game.player_name: return self.to_cp else: return self.departure_cp @property - def tasks(self) -> typing.Collection[typing.Type[Task]]: + def tasks(self) -> List[Type[Task]]: return [] @property @@ -91,18 +91,6 @@ class Event: def is_successfull(self, debriefing: Debriefing) -> bool: return self.operation.is_successfull(debriefing) - def player_attacking(self, cp: ControlPoint, flights: db.TaskForceDict): - if self.is_player_attacking: - self.departure_cp = cp - else: - self.to_cp = cp - - def player_defending(self, cp: ControlPoint, flights: db.TaskForceDict): - if self.is_player_attacking: - self.departure_cp = cp - else: - self.to_cp = cp - def generate(self): self.operation.is_awacs_enabled = self.is_awacs_enabled self.operation.ca_slots = self.ca_slots @@ -253,7 +241,7 @@ class Event: for enemy_cp in enemy_cps: print("Compute frontline progression for : " + cp.name + " to " + enemy_cp.name) - delta = 0 + delta = 0.0 player_won = True ally_casualties = killed_unit_count_by_cp[cp.id] enemy_casualties = killed_unit_count_by_cp[enemy_cp.id] @@ -376,7 +364,6 @@ class Event: class UnitsDeliveryEvent(Event): informational = True - units = None # type: typing.Dict[UnitType, int] def __init__(self, attacker_name: str, defender_name: str, from_cp: ControlPoint, to_cp: ControlPoint, game): super(UnitsDeliveryEvent, self).__init__(game=game, @@ -386,12 +373,12 @@ class UnitsDeliveryEvent(Event): attacker_name=attacker_name, defender_name=defender_name) - self.units = {} + self.units: Dict[UnitType, int] = {} def __str__(self): return "Pending delivery to {}".format(self.to_cp) - def deliver(self, units: typing.Dict[UnitType, int]): + def deliver(self, units: Dict[UnitType, int]): for k, v in units.items(): self.units[k] = self.units.get(k, 0) + v diff --git a/game/event/frontlineattack.py b/game/event/frontlineattack.py index e548440f..0046526d 100644 --- a/game/event/frontlineattack.py +++ b/game/event/frontlineattack.py @@ -1,12 +1,17 @@ -from game.event import * +from typing import List, Type + +from dcs.task import CAP, CAS, Task + +from game import db from game.operation.frontlineattack import FrontlineAttackOperation -from userdata.debriefing import Debriefing +from .event import Event +from ..debriefing import Debriefing class FrontlineAttackEvent(Event): @property - def tasks(self) -> typing.Collection[typing.Type[Task]]: + def tasks(self) -> List[Type[Task]]: if self.is_player_attacking: return [CAS, CAP] else: @@ -34,6 +39,7 @@ class FrontlineAttackEvent(Event): self.to_cp.base.affect_strength(-0.1) def player_attacking(self, flights: db.TaskForceDict): + assert self.departure_cp is not None op = FrontlineAttackOperation(game=self.game, attacker_name=self.attacker_name, defender_name=self.defender_name, @@ -41,13 +47,3 @@ class FrontlineAttackEvent(Event): departure_cp=self.departure_cp, to_cp=self.to_cp) self.operation = op - - def player_defending(self, flights: db.TaskForceDict): - op = FrontlineAttackOperation(game=self.game, - attacker_name=self.attacker_name, - defender_name=self.defender_name, - from_cp=self.from_cp, - departure_cp=self.departure_cp, - to_cp=self.to_cp) - self.operation = op - diff --git a/game/factions/australia_2005.py b/game/factions/australia_2005.py index df4972e6..c9cfb320 100644 --- a/game/factions/australia_2005.py +++ b/game/factions/australia_2005.py @@ -1,7 +1,27 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_1W, + UH_1H, +) +from dcs.planes import ( + C_130, + E_3A, + FA_18C_hornet, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Ticonderoga_class, + USS_Arleigh_Burke_IIa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Australia_2005 = { "country": "Australia", diff --git a/game/factions/bluefor_coldwar.py b/game/factions/bluefor_coldwar.py index 5db15d73..c241bbae 100644 --- a/game/factions/bluefor_coldwar.py +++ b/game/factions/bluefor_coldwar.py @@ -1,7 +1,30 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + SA342L, + SA342M, + UH_1H, +) +from dcs.planes import ( + AJS37, + A_10A, + C_130, + E_3A, + F_14B, + F_4E, + F_5E_3, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) BLUEFOR_COLDWAR = { "country": "Combined Joint Task Forces Blue", diff --git a/game/factions/bluefor_coldwar_a4.py b/game/factions/bluefor_coldwar_a4.py index 74983134..ce6cf016 100644 --- a/game/factions/bluefor_coldwar_a4.py +++ b/game/factions/bluefor_coldwar_a4.py @@ -1,7 +1,31 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + SA342L, + SA342M, + UH_1H, +) +from dcs.planes import ( + AJS37, + A_10A, + C_130, + E_3A, + F_14B, + F_4E, + F_5E_3, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Ticonderoga_class, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) from pydcs_extensions.a4ec.a4ec import A_4E_C diff --git a/game/factions/bluefor_coldwar_mods.py b/game/factions/bluefor_coldwar_mods.py index aece4e46..a395fc48 100644 --- a/game/factions/bluefor_coldwar_mods.py +++ b/game/factions/bluefor_coldwar_mods.py @@ -1,7 +1,31 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + SA342L, + SA342M, + UH_1H, +) +from dcs.planes import ( + AJS37, + A_10A, + C_130, + E_3A, + F_14B, + F_4E, + F_5E_3, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Ticonderoga_class, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) from pydcs_extensions.a4ec.a4ec import A_4E_C from pydcs_extensions.mb339.mb339 import MB_339PAN diff --git a/game/factions/bluefor_modern.py b/game/factions/bluefor_modern.py index 8db0ad89..9f97827b 100644 --- a/game/factions/bluefor_modern.py +++ b/game/factions/bluefor_modern.py @@ -1,7 +1,45 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_64D, + Ka_50, + SA342L, + SA342M, + UH_1H, +) +from dcs.planes import ( + AJS37, + AV8BNA, + A_10A, + A_10C, + A_10C_2, + C_130, + E_3A, + FA_18C_hornet, + F_14B, + F_15C, + F_16C_50, + F_5E_3, + JF_17, + KC130, + KC_135, + M_2000C, + Su_25T, + Su_27, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Oliver_Hazzard_Perry_class, + Ticonderoga_class, + USS_Arleigh_Burke_IIa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) BLUEFOR_MODERN = { "country": "Combined Joint Task Forces Blue", diff --git a/game/factions/canada_2005.py b/game/factions/canada_2005.py index ea4497ca..7a664709 100644 --- a/game/factions/canada_2005.py +++ b/game/factions/canada_2005.py @@ -1,7 +1,26 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + UH_1H, +) +from dcs.planes import ( + C_130, + E_3A, + FA_18C_hornet, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Ticonderoga_class, + USS_Arleigh_Burke_IIa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Canada_2005 = { "country": "Canada", diff --git a/game/factions/china_2010.py b/game/factions/china_2010.py index 0d98d1b9..577fb33d 100644 --- a/game/factions/china_2010.py +++ b/game/factions/china_2010.py @@ -1,7 +1,38 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + Mi_28N, + Mi_8MT, +) +from dcs.planes import ( + An_26B, + An_30M, + IL_76MD, + IL_78M, + JF_17, + J_11A, + KJ_2000, + MiG_21Bis, + Su_30, + Su_33, + WingLoong_I, + Yak_40, +) +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + CV_1143_5_Admiral_Kuznetsov, + Dry_cargo_ship_Ivanov, + Tanker_Elnya_160, + Type_052B_Destroyer, + Type_052C_Destroyer, + Type_054A_Frigate, + Type_071_Amphibious_Transport_Dock, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) China_2010 = { "country": "China", diff --git a/game/factions/france_1995.py b/game/factions/france_1995.py index acf56495..a14f24e5 100644 --- a/game/factions/france_1995.py +++ b/game/factions/france_1995.py @@ -1,7 +1,28 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + SA342L, + SA342M, + SA342Mistral, +) +from dcs.planes import ( + C_130, + E_3A, + KC130, + KC_135, + M_2000C, + Mirage_2000_5, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) France_1995 = { "country": "France", diff --git a/game/factions/france_2005.py b/game/factions/france_2005.py index b2f4b87a..28c00ae4 100644 --- a/game/factions/france_2005.py +++ b/game/factions/france_2005.py @@ -1,7 +1,31 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + SA342L, + SA342M, + SA342Mistral, +) +from dcs.planes import ( + C_130, + E_3A, + FA_18C_hornet, + KC130, + KC_135, + M_2000C, + Mirage_2000_5, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Oliver_Hazzard_Perry_class, + Ticonderoga_class, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) France_2005 = { "country": "France", diff --git a/game/factions/france_modded.py b/game/factions/france_modded.py index ad0f7de5..8283d090 100644 --- a/game/factions/france_modded.py +++ b/game/factions/france_modded.py @@ -1,10 +1,33 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + SA342L, + SA342M, + SA342Mistral, +) +from dcs.planes import ( + C_130, + E_3A, + KC130, + KC_135, + M_2000C, + Mirage_2000_5, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Oliver_Hazzard_Perry_class, + Ticonderoga_class, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) import pydcs_extensions.frenchpack.frenchpack as frenchpack -from pydcs_extensions.rafale.rafale import Rafale_M, Rafale_A_S +from pydcs_extensions.rafale.rafale import Rafale_A_S, Rafale_M France_2005_Modded = { "country": "France", diff --git a/game/factions/germany_1944.py b/game/factions/germany_1944.py index 9123b350..c63f88f4 100644 --- a/game/factions/germany_1944.py +++ b/game/factions/germany_1944.py @@ -1,5 +1,16 @@ -from dcs.planes import * -from dcs.vehicles import * +from dcs.planes import ( + Bf_109K_4, + FW_190A8, + FW_190D9, + Ju_88A4, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) from game.data.building_data import WW2_GERMANY_BUILDINGS from game.data.doctrine import WWII_DOCTRINE diff --git a/game/factions/germany_1944_easy.py b/game/factions/germany_1944_easy.py index b79d45f0..8be93457 100644 --- a/game/factions/germany_1944_easy.py +++ b/game/factions/germany_1944_easy.py @@ -1,5 +1,16 @@ -from dcs.planes import * -from dcs.vehicles import * +from dcs.planes import ( + Bf_109K_4, + FW_190A8, + FW_190D9, + Ju_88A4, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) from game.data.building_data import WW2_GERMANY_BUILDINGS from game.data.doctrine import WWII_DOCTRINE diff --git a/game/factions/germany_1990.py b/game/factions/germany_1990.py index ae1f0668..54432ef4 100644 --- a/game/factions/germany_1990.py +++ b/game/factions/germany_1990.py @@ -1,7 +1,28 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + SA342L, + SA342M, + UH_1H, +) +from dcs.planes import ( + C_130, + E_3A, + F_4E, + KC130, + KC_135, + MiG_29G, + Tornado_IDS, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Germany_1990 = { "country": "Germany", diff --git a/game/factions/india_2010.py b/game/factions/india_2010.py index 2dc756ec..756faa23 100644 --- a/game/factions/india_2010.py +++ b/game/factions/india_2010.py @@ -1,7 +1,32 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_64A, + Mi_8MT, +) +from dcs.planes import ( + C_130, + E_3A, + KC130, + KC_135, + M_2000C, + MiG_21Bis, + MiG_27K, + MiG_29S, + Mirage_2000_5, + Su_30, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + CV_1143_5_Admiral_Kuznetsov, + FSG_1241_1MP_Molniya, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) India_2010 = { "country": "India", diff --git a/game/factions/insurgent.py b/game/factions/insurgent.py index d94603c6..66ae3459 100644 --- a/game/factions/insurgent.py +++ b/game/factions/insurgent.py @@ -1,7 +1,14 @@ -from dcs.vehicles import * -from dcs.ships import * -from dcs.planes import * -from dcs.helicopters import * +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + Dry_cargo_ship_Ivanov, + Tanker_Elnya_160, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Insurgent = { "country": "Insurgents", diff --git a/game/factions/insurgent_modded.py b/game/factions/insurgent_modded.py index 39e1e174..b19b4344 100644 --- a/game/factions/insurgent_modded.py +++ b/game/factions/insurgent_modded.py @@ -1,8 +1,21 @@ -from dcs.ships import * -from dcs.vehicles import * +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + Dry_cargo_ship_Ivanov, + Tanker_Elnya_160, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) -from pydcs_extensions.frenchpack.frenchpack import DIM__TOYOTA_BLUE, DIM__TOYOTA_DESERT, DIM__TOYOTA_GREEN, \ - DIM__KAMIKAZE +from pydcs_extensions.frenchpack.frenchpack import ( + DIM__KAMIKAZE, + DIM__TOYOTA_BLUE, + DIM__TOYOTA_DESERT, + DIM__TOYOTA_GREEN, +) Insurgent_modded = { "country": "Insurgents", diff --git a/game/factions/iran_2015.py b/game/factions/iran_2015.py index 56751a2c..53f20dd6 100644 --- a/game/factions/iran_2015.py +++ b/game/factions/iran_2015.py @@ -1,7 +1,35 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + Mi_24V, + Mi_28N, +) +from dcs.planes import ( + A_50, + An_26B, + An_30M, + F_14B, + F_4E, + F_5E_3, + IL_76MD, + IL_78M, + MiG_21Bis, + MiG_29A, + Su_17M4, + Su_24M, + Su_25, + Yak_40, +) +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + CV_1143_5_Admiral_Kuznetsov, + Dry_cargo_ship_Ivanov, + Tanker_Elnya_160, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Iran_2015 = { "country": "Iran", diff --git a/game/factions/israel_1948.py b/game/factions/israel_1948.py index ffa57fcb..bc3b615c 100644 --- a/game/factions/israel_1948.py +++ b/game/factions/israel_1948.py @@ -1,6 +1,20 @@ -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.planes import ( + B_17G, + Bf_109K_4, + P_51D, + P_51D_30_NA, + SpitfireLFMkIX, + SpitfireLFMkIXCW, +) +from dcs.ships import ( + Armed_speedboat, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Israel_1948 = { "country": "Israel", diff --git a/game/factions/israel_1973.py b/game/factions/israel_1973.py index 00624ec9..3bfa5d15 100644 --- a/game/factions/israel_1973.py +++ b/game/factions/israel_1973.py @@ -1,7 +1,26 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_1W, + UH_1H, +) +from dcs.planes import ( + C_130, + E_3A, + F_15C, + F_16A, + F_16C_50, + F_4E, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) from pydcs_extensions.a4ec.a4ec import A_4E_C diff --git a/game/factions/israel_2000.py b/game/factions/israel_2000.py index 6c0db9c0..d87460f9 100644 --- a/game/factions/israel_2000.py +++ b/game/factions/israel_2000.py @@ -1,7 +1,29 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_1W, + AH_64D, +) +from dcs.planes import ( + C_130, + E_3A, + F_15C, + F_15E, + F_16C_50, + F_4E, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) Israel_2000 = { "country": "Israel", diff --git a/game/factions/italy_1990.py b/game/factions/italy_1990.py index 2c2175a8..267cd611 100644 --- a/game/factions/italy_1990.py +++ b/game/factions/italy_1990.py @@ -1,7 +1,28 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_1W, + UH_1H, +) +from dcs.planes import ( + AV8BNA, + C_130, + E_3A, + KC_135, + S_3B_Tanker, + Tornado_IDS, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Oliver_Hazzard_Perry_class, + Ticonderoga_class, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Italy_1990 = { "country": "Italy", diff --git a/game/factions/italy_1990_mb339.py b/game/factions/italy_1990_mb339.py index 92307749..9d594817 100644 --- a/game/factions/italy_1990_mb339.py +++ b/game/factions/italy_1990_mb339.py @@ -1,7 +1,28 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_1W, + UH_1H, +) +from dcs.planes import ( + AV8BNA, + C_130, + E_3A, + KC_135, + S_3B_Tanker, + Tornado_IDS, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Oliver_Hazzard_Perry_class, + Ticonderoga_class, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) from pydcs_extensions.mb339.mb339 import MB_339PAN diff --git a/game/factions/japan_2005.py b/game/factions/japan_2005.py index c9657348..d33800ff 100644 --- a/game/factions/japan_2005.py +++ b/game/factions/japan_2005.py @@ -1,7 +1,24 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_1W, + AH_64D, +) +from dcs.planes import ( + C_130, + E_3A, + F_15C, + F_16C_50, + F_4E, + KC130, + KC_135, +) +from dcs.ships import LHA_1_Tarawa, Ticonderoga_class, USS_Arleigh_Burke_IIa +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) Japan_2005 = { "country": "Japan", diff --git a/game/factions/libya_2011.py b/game/factions/libya_2011.py index 688b4877..4de5b42c 100644 --- a/game/factions/libya_2011.py +++ b/game/factions/libya_2011.py @@ -1,6 +1,25 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.vehicles import * +from dcs.helicopters import ( + Mi_24V, +) +from dcs.planes import ( + A_50, + An_26B, + An_30M, + IL_76MD, + IL_78M, + MiG_21Bis, + MiG_23MLD, + Su_17M4, + Su_24M, + Yak_40, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) Libya_2011 = { "country": "Libya", diff --git a/game/factions/netherlands_1990.py b/game/factions/netherlands_1990.py index b32fe7d0..48c916bd 100644 --- a/game/factions/netherlands_1990.py +++ b/game/factions/netherlands_1990.py @@ -1,7 +1,25 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_64A, +) +from dcs.planes import ( + C_130, + E_3A, + F_16C_50, + F_5E_3, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Netherlands_1990 = { "country": "The Netherlands", diff --git a/game/factions/north_korea_2000.py b/game/factions/north_korea_2000.py index dd588f92..15d1f587 100644 --- a/game/factions/north_korea_2000.py +++ b/game/factions/north_korea_2000.py @@ -1,7 +1,33 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + Mi_24V, + Mi_8MT, +) +from dcs.planes import ( + A_50, + An_26B, + An_30M, + IL_76MD, + IL_78M, + MiG_15bis, + MiG_19P, + MiG_21Bis, + MiG_23MLD, + MiG_29A, + Su_25, + Yak_40, +) +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + CV_1143_5_Admiral_Kuznetsov, + Dry_cargo_ship_Ivanov, + Tanker_Elnya_160, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) NorthKorea_2000 = { "country": "North Korea", diff --git a/game/factions/pakistan_2015.py b/game/factions/pakistan_2015.py index 67bfa2aa..8d7c6190 100644 --- a/game/factions/pakistan_2015.py +++ b/game/factions/pakistan_2015.py @@ -1,7 +1,25 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_1W, + UH_1H, +) +from dcs.planes import ( + E_3A, + F_16C_50, + IL_78M, + JF_17, + MiG_19P, + MiG_21Bis, + WingLoong_I, +) +from dcs.ships import ( + Armed_speedboat, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Pakistan_2015 = { "country": "Pakistan", diff --git a/game/factions/private_miltary_companies.py b/game/factions/private_miltary_companies.py index 4f2860ca..23fbcbdd 100644 --- a/game/factions/private_miltary_companies.py +++ b/game/factions/private_miltary_companies.py @@ -1,7 +1,25 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + Ka_50, + Mi_24V, + Mi_8MT, + OH_58D, + SA342M, + UH_1H, +) +from dcs.planes import ( + C_101CC, + L_39C, + L_39ZA, +) +from dcs.ships import ( + Armed_speedboat, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) from pydcs_extensions.mb339.mb339 import MB_339PAN diff --git a/game/factions/russia_1955.py b/game/factions/russia_1955.py index dcaeec68..5730bd9d 100644 --- a/game/factions/russia_1955.py +++ b/game/factions/russia_1955.py @@ -1,6 +1,11 @@ -from dcs.planes import MiG_15bis, IL_76MD, IL_78M, An_26B, An_30M, Yak_40 -from dcs.ships import CV_1143_5_Admiral_Kuznetsov, Bulk_cargo_ship_Yakushev, Dry_cargo_ship_Ivanov, Tanker_Elnya_160 -from dcs.vehicles import AirDefence, Armor, Unarmed, Infantry, Artillery +from dcs.planes import An_26B, An_30M, IL_76MD, IL_78M, MiG_15bis, Yak_40 +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + CV_1143_5_Admiral_Kuznetsov, + Dry_cargo_ship_Ivanov, + Tanker_Elnya_160, +) +from dcs.vehicles import AirDefence, Armor, Artillery, Infantry, Unarmed Russia_1955 = { "country": "Russia", diff --git a/game/factions/russia_1965.py b/game/factions/russia_1965.py index bc5762c6..9d88d251 100644 --- a/game/factions/russia_1965.py +++ b/game/factions/russia_1965.py @@ -1,7 +1,22 @@ from dcs.helicopters import Mi_8MT -from dcs.planes import MiG_15bis, MiG_19P, MiG_21Bis, IL_76MD, IL_78M, An_26B, An_30M, Yak_40, A_50 -from dcs.ships import CV_1143_5_Admiral_Kuznetsov, Bulk_cargo_ship_Yakushev, Dry_cargo_ship_Ivanov, Tanker_Elnya_160 -from dcs.vehicles import AirDefence, Armor, Unarmed, Infantry, Artillery +from dcs.planes import ( + A_50, + An_26B, + An_30M, + IL_76MD, + IL_78M, + MiG_15bis, + MiG_19P, + MiG_21Bis, + Yak_40, +) +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + CV_1143_5_Admiral_Kuznetsov, + Dry_cargo_ship_Ivanov, + Tanker_Elnya_160, +) +from dcs.vehicles import AirDefence, Armor, Artillery, Infantry, Unarmed Russia_1965 = { "country": "Russia", diff --git a/game/factions/russia_1975.py b/game/factions/russia_1975.py index db6a50ae..b8a75437 100644 --- a/game/factions/russia_1975.py +++ b/game/factions/russia_1975.py @@ -1,8 +1,31 @@ -from dcs.helicopters import Mi_8MT, Mi_24V -from dcs.planes import MiG_21Bis, MiG_23MLD, MiG_25PD, MiG_29A, Su_17M4, Su_24M, Su_25, IL_76MD, IL_78M, An_26B, An_30M, \ - Yak_40, A_50 -from dcs.ships import * -from dcs.vehicles import AirDefence, Armor, Unarmed, Infantry, Artillery +from dcs.helicopters import ( + Mi_24V, + Mi_8MT, +) +from dcs.planes import ( + A_50, + An_26B, + An_30M, + IL_76MD, + IL_78M, + MiG_21Bis, + MiG_23MLD, + MiG_25PD, + MiG_29A, + Su_17M4, + Su_24M, + Su_25, + Yak_40, +) +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + CGN_1144_2_Pyotr_Velikiy, + CV_1143_5_Admiral_Kuznetsov, + Dry_cargo_ship_Ivanov, + FF_1135M_Rezky, + Tanker_Elnya_160, +) +from dcs.vehicles import AirDefence, Armor, Artillery, Infantry, Unarmed Russia_1975 = { "country": "Russia", diff --git a/game/factions/russia_1990.py b/game/factions/russia_1990.py index ee2758a5..747024a8 100644 --- a/game/factions/russia_1990.py +++ b/game/factions/russia_1990.py @@ -1,7 +1,39 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + Ka_50, + Mi_24V, + Mi_8MT, +) +from dcs.planes import ( + A_50, + An_26B, + An_30M, + IL_76MD, + IL_78M, + MiG_23MLD, + MiG_25PD, + MiG_29A, + MiG_29S, + MiG_31, + Su_24M, + Su_25, + Su_27, + Yak_40, +) +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + CV_1143_5_Admiral_Kuznetsov, + Dry_cargo_ship_Ivanov, + FF_1135M_Rezky, + FSG_1241_1MP_Molniya, + Tanker_Elnya_160, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) Russia_1990 = { "country": "Russia", diff --git a/game/factions/russia_2010.py b/game/factions/russia_2010.py index cc45f062..13adefb6 100644 --- a/game/factions/russia_2010.py +++ b/game/factions/russia_2010.py @@ -1,7 +1,42 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + Ka_50, + Mi_24V, + Mi_28N, + Mi_8MT, +) +from dcs.planes import ( + A_50, + An_26B, + An_30M, + IL_76MD, + IL_78M, + L_39ZA, + MiG_29S, + MiG_31, + Su_24M, + Su_25, + Su_25T, + Su_27, + Su_30, + Su_33, + Su_34, + Yak_40, +) +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + CV_1143_5_Admiral_Kuznetsov, + Dry_cargo_ship_Ivanov, + FF_1135M_Rezky, + FSG_1241_1MP_Molniya, + Tanker_Elnya_160, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) Russia_2010 = { "country": "Russia", diff --git a/game/factions/spain_1990.py b/game/factions/spain_1990.py index f016cb8a..246484a4 100644 --- a/game/factions/spain_1990.py +++ b/game/factions/spain_1990.py @@ -1,6 +1,26 @@ -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.planes import ( + AV8BNA, + C_101CC, + C_130, + E_3A, + FA_18C_hornet, + F_5E_3, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Oliver_Hazzard_Perry_class, + Ticonderoga_class, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Spain_1990 = { "country": "Spain", diff --git a/game/factions/sweden_1990.py b/game/factions/sweden_1990.py index ebc754f9..058f1478 100644 --- a/game/factions/sweden_1990.py +++ b/game/factions/sweden_1990.py @@ -1,7 +1,21 @@ -from dcs.vehicles import * -from dcs.ships import * -from dcs.planes import * -from dcs.helicopters import * +from dcs.helicopters import ( + UH_1H, +) +from dcs.planes import ( + AJS37, +) +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + CV_1143_5_Admiral_Kuznetsov, + Dry_cargo_ship_Ivanov, + Tanker_Elnya_160, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Sweden_1990 = { "country": "Sweden", diff --git a/game/factions/syria.py b/game/factions/syria.py index de082320..f7017e86 100644 --- a/game/factions/syria.py +++ b/game/factions/syria.py @@ -1,6 +1,35 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.vehicles import * +from dcs.helicopters import ( + Mi_24V, + Mi_8MT, + SA342L, + SA342M, +) +from dcs.planes import ( + A_50, + An_26B, + An_30M, + IL_76MD, + IL_78M, + L_39ZA, + MiG_15bis, + MiG_19P, + MiG_21Bis, + MiG_23MLD, + MiG_25PD, + MiG_29S, + SpitfireLFMkIX, + SpitfireLFMkIXCW, + Su_17M4, + Su_24M, + Yak_40, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) Syria_2011 = { "country": "Syria", diff --git a/game/factions/turkey_2005.py b/game/factions/turkey_2005.py index 02b0500d..be68334c 100644 --- a/game/factions/turkey_2005.py +++ b/game/factions/turkey_2005.py @@ -1,7 +1,26 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_1W, + UH_1H, +) +from dcs.planes import ( + C_130, + E_3A, + F_16C_50, + F_4E, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Turkey_2005 = { "country": "Turkey", diff --git a/game/factions/uae_2005.py b/game/factions/uae_2005.py index cc412f06..d6240332 100644 --- a/game/factions/uae_2005.py +++ b/game/factions/uae_2005.py @@ -1,7 +1,27 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_64D, +) +from dcs.planes import ( + C_130, + E_3A, + F_16C_50, + KC130, + KC_135, + M_2000C, + Mirage_2000_5, + WingLoong_I, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) UAE_2005 = { "country": "United Arab Emirates", diff --git a/game/factions/uk_1944.py b/game/factions/uk_1944.py index 7798d714..2620fc86 100644 --- a/game/factions/uk_1944.py +++ b/game/factions/uk_1944.py @@ -1,6 +1,19 @@ -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.planes import ( + A_20G, + B_17G, + P_47D_30, + P_51D, + P_51D_30_NA, + SpitfireLFMkIX, + SpitfireLFMkIXCW, +) +from dcs.ships import LCVP__Higgins_boat, LST_Mk_II, LS_Samuel_Chase +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) from game.data.building_data import WW2_ALLIES_BUILDINGS from game.data.doctrine import WWII_DOCTRINE diff --git a/game/factions/uk_1990.py b/game/factions/uk_1990.py index f2bcc57a..4855059d 100644 --- a/game/factions/uk_1990.py +++ b/game/factions/uk_1990.py @@ -1,7 +1,29 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_64A, + SA342M, +) +from dcs.planes import ( + AV8BNA, + C_130, + E_3A, + F_4E, + KC130, + KC_135, + Tornado_GR4, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Oliver_Hazzard_Perry_class, + Ticonderoga_class, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) UnitedKingdom_1990 = { "country": "UK", diff --git a/game/factions/ukraine_2010.py b/game/factions/ukraine_2010.py index cd5149b6..de030137 100644 --- a/game/factions/ukraine_2010.py +++ b/game/factions/ukraine_2010.py @@ -1,7 +1,33 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + Mi_24V, + Mi_8MT, +) +from dcs.planes import ( + A_50, + An_26B, + An_30M, + IL_76MD, + IL_78M, + L_39ZA, + MiG_29S, + Su_24M, + Su_25, + Su_25T, + Su_27, + Yak_40, +) +from dcs.ships import ( + Bulk_cargo_ship_Yakushev, + CV_1143_5_Admiral_Kuznetsov, + Dry_cargo_ship_Ivanov, + Tanker_Elnya_160, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) Ukraine_2010 = { "country": "Ukraine", diff --git a/game/factions/us_aggressors.py b/game/factions/us_aggressors.py index ab4e6ffd..650c09cd 100644 --- a/game/factions/us_aggressors.py +++ b/game/factions/us_aggressors.py @@ -1,7 +1,36 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_64D, + Ka_50, + SA342L, + SA342M, + UH_1H, +) +from dcs.planes import ( + C_130, + E_3A, + FA_18C_hornet, + F_15C, + F_16C_50, + F_5E_3, + KC130, + KC_135, + Su_27, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Oliver_Hazzard_Perry_class, + Ticonderoga_class, + USS_Arleigh_Burke_IIa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) US_Aggressors = { "country": "USAF Aggressors", diff --git a/game/factions/usa_1944.py b/game/factions/usa_1944.py index 29e07372..7b99bd42 100644 --- a/game/factions/usa_1944.py +++ b/game/factions/usa_1944.py @@ -1,6 +1,20 @@ -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.planes import ( + A_20G, + B_17G, + P_47D_30, + P_51D, + P_51D_30_NA, + SpitfireLFMkIX, + SpitfireLFMkIXCW, +) +from dcs.ships import LCVP__Higgins_boat, LST_Mk_II, LS_Samuel_Chase +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) from game.data.building_data import WW2_ALLIES_BUILDINGS from game.data.doctrine import WWII_DOCTRINE diff --git a/game/factions/usa_1955.py b/game/factions/usa_1955.py index efa0af47..1943544b 100644 --- a/game/factions/usa_1955.py +++ b/game/factions/usa_1955.py @@ -1,7 +1,22 @@ -from dcs.vehicles import * -from dcs.ships import * -from dcs.planes import * -from dcs.helicopters import * +from dcs.planes import ( + C_130, + E_3A, + F_86F_Sabre, + KC130, + KC_135, + P_51D, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) USA_1955 = { "country": "USA", diff --git a/game/factions/usa_1960.py b/game/factions/usa_1960.py index e2b64bd1..ee162d04 100644 --- a/game/factions/usa_1960.py +++ b/game/factions/usa_1960.py @@ -1,7 +1,25 @@ -from dcs.vehicles import * -from dcs.ships import * -from dcs.planes import * -from dcs.helicopters import * +from dcs.helicopters import ( + UH_1H, +) +from dcs.planes import ( + C_130, + E_3A, + F_86F_Sabre, + KC130, + KC_135, + P_51D, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) USA_1960 = { "country": "USA", diff --git a/game/factions/usa_1965.py b/game/factions/usa_1965.py index 59dc651a..cba61391 100644 --- a/game/factions/usa_1965.py +++ b/game/factions/usa_1965.py @@ -1,7 +1,26 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + UH_1H, +) +from dcs.planes import ( + B_52H, + C_130, + E_3A, + F_4E, + F_5E_3, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) USA_1965 = { "country": "USA", diff --git a/game/factions/usa_1990.py b/game/factions/usa_1990.py index df2b37df..4014237a 100644 --- a/game/factions/usa_1990.py +++ b/game/factions/usa_1990.py @@ -1,7 +1,34 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_64A, + UH_1H, +) +from dcs.planes import ( + AV8BNA, + A_10A, + C_130, + E_3A, + FA_18C_hornet, + F_14B, + F_15C, + F_15E, + F_16C_50, + KC130, + KC_135, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Oliver_Hazzard_Perry_class, + Ticonderoga_class, + USS_Arleigh_Burke_IIa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Infantry, + Unarmed, +) USA_1990 = { "country": "USA", diff --git a/game/factions/usa_2005.py b/game/factions/usa_2005.py index d6b63a58..5b9e03ca 100644 --- a/game/factions/usa_2005.py +++ b/game/factions/usa_2005.py @@ -1,7 +1,36 @@ -from dcs.helicopters import * -from dcs.planes import * -from dcs.ships import * -from dcs.vehicles import * +from dcs.helicopters import ( + AH_64D, + UH_1H, +) +from dcs.planes import ( + AV8BNA, + A_10C, + A_10C_2, + C_130, + E_3A, + FA_18C_hornet, + F_14B, + F_15C, + F_15E, + F_16C_50, + KC130, + KC_135, + MQ_9_Reaper, +) +from dcs.ships import ( + Armed_speedboat, + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + Ticonderoga_class, + USS_Arleigh_Burke_IIa, +) +from dcs.vehicles import ( + AirDefence, + Armor, + Artillery, + Infantry, + Unarmed, +) USA_2005 = { "country": "USA", diff --git a/game/game.py b/game/game.py index 385261b8..57fabfd6 100644 --- a/game/game.py +++ b/game/game.py @@ -1,10 +1,32 @@ +import logging +import math +import random +import sys from datetime import datetime, timedelta +from typing import Any, Dict, List -from game.db import REWARDS, PLAYER_BUDGET_BASE, sys +from dcs.action import Coalition +from dcs.mapping import Point +from dcs.task import CAP, CAS, PinpointStrike, Task +from dcs.unittype import UnitType +from dcs.vehicles import AirDefence + +from game import db +from game.db import PLAYER_BUDGET_BASE, REWARDS +from game.inventory import GlobalAircraftInventory from game.models.game_stats import GameStats -from gen.flights.ai_flight_planner import FlightPlanner +from gen.ato import AirTaskingOrder +from gen.conflictgen import Conflict +from gen.flights.ai_flight_planner import CoalitionMissionPlanner +from gen.flights.closestairfields import ObjectiveDistanceCache from gen.ground_forces.ai_ground_planner import GroundPlanner -from .event import * +from theater import ConflictTheater, ControlPoint +from theater.conflicttheater import IMPORTANCE_HIGH, IMPORTANCE_LOW +from . import persistency +from .debriefing import Debriefing +from .event.event import Event, UnitsDeliveryEvent +from .event.frontlineattack import FrontlineAttackEvent +from .infos.information import Information from .settings import Settings COMMISION_UNIT_VARIETY = 4 @@ -45,20 +67,11 @@ PLAYER_BUDGET_IMPORTANCE_LOG = 2 class Game: - settings = None # type: Settings - budget = PLAYER_BUDGET_INITIAL - events = None # type: typing.List[Event] - pending_transfers = None # type: typing.Dict[] - ignored_cps = None # type: typing.Collection[ControlPoint] - turn = 0 - game_stats: GameStats = None - - current_unit_id = 0 - current_group_id = 0 - - def __init__(self, player_name: str, enemy_name: str, theater: ConflictTheater, start_date: datetime, settings): + def __init__(self, player_name: str, enemy_name: str, + theater: ConflictTheater, start_date: datetime, + settings: Settings): self.settings = settings - self.events = [] + self.events: List[Event] = [] self.theater = theater self.player_name = player_name self.player_country = db.FACTIONS[player_name]["country"] @@ -68,17 +81,25 @@ class Game: self.date = datetime(start_date.year, start_date.month, start_date.day) self.game_stats = GameStats() self.game_stats.update(self) - self.planners = {} - self.ground_planners = {} + self.ground_planners: Dict[int, GroundPlanner] = {} self.informations = [] self.informations.append(Information("Game Start", "-" * 40, 0)) self.__culling_points = self.compute_conflicts_position() - self.__frontlineData = [] - self.__destroyed_units = [] - self.jtacs = [] + self.__destroyed_units: List[str] = [] self.savepath = "" + self.budget = PLAYER_BUDGET_INITIAL + self.current_unit_id = 0 + self.current_group_id = 0 + + self.blue_ato = AirTaskingOrder() + self.red_ato = AirTaskingOrder() + + self.aircraft_inventory = GlobalAircraftInventory( + self.theater.controlpoints + ) self.sanitize_sides() + self.on_load() def sanitize_sides(self): @@ -95,11 +116,11 @@ class Game: self.enemy_country = "Russia" @property - def player_faction(self): + def player_faction(self) -> Dict[str, Any]: return db.FACTIONS[self.player_name] @property - def enemy_faction(self): + def enemy_faction(self) -> Dict[str, Any]: return db.FACTIONS[self.enemy_name] def _roll(self, prob, mult): @@ -116,7 +137,7 @@ class Game: for player_cp, enemy_cp in self.theater.conflicts(True): self._generate_player_event(FrontlineAttackEvent, player_cp, enemy_cp) - def commision_unit_types(self, cp: ControlPoint, for_task: Task) -> typing.Collection[UnitType]: + def commision_unit_types(self, cp: ControlPoint, for_task: Task) -> List[UnitType]: importance_factor = (cp.importance - IMPORTANCE_LOW) / (IMPORTANCE_HIGH - IMPORTANCE_LOW) if for_task == AirDefence and not self.settings.sams: @@ -190,12 +211,14 @@ class Game: def is_player_attack(self, event): if isinstance(event, Event): - return event.attacker_name == self.player_name + return event and event.attacker_name and event.attacker_name == self.player_name else: - return event.name == self.player_name + return event and event.name and event.name == self.player_name - def pass_turn(self, no_action=False, ignored_cps: typing.Collection[ControlPoint] = None): + def on_load(self) -> None: + ObjectiveDistanceCache.set_theater(self.theater) + def pass_turn(self, no_action=False): logging.info("Pass turn") self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0)) self.turn = self.turn + 1 @@ -219,26 +242,24 @@ class Game: if not cp.is_carrier and not cp.is_lha: cp.base.affect_strength(-PLAYER_BASE_STRENGTH_RECOVERY) - self.ignored_cps = [] - if ignored_cps: - self.ignored_cps = ignored_cps - - self.events = [] # type: typing.List[Event] + self.events = [] self._generate_events() # Update statistics self.game_stats.update(self) + self.aircraft_inventory.reset() + for cp in self.theater.controlpoints: + self.aircraft_inventory.set_from_control_point(cp) + # Plan flights & combat for next turn self.__culling_points = self.compute_conflicts_position() - self.planners = {} self.ground_planners = {} + self.blue_ato.clear() + self.red_ato.clear() + CoalitionMissionPlanner(self, is_player=True).plan_missions() + CoalitionMissionPlanner(self, is_player=False).plan_missions() for cp in self.theater.controlpoints: - if cp.has_runway(): - planner = FlightPlanner(cp, self) - planner.plan_flights() - self.planners[cp.id] = planner - if cp.has_frontline: gplanner = GroundPlanner(cp, self) gplanner.plan_groundwar() @@ -408,10 +429,10 @@ class Game: return 1 def get_player_coalition(self): - return dcs.action.Coalition.Blue + return Coalition.Blue def get_enemy_coalition(self): - return dcs.action.Coalition.Red + return Coalition.Red def get_player_color(self): return "blue" diff --git a/game/inventory.py b/game/inventory.py new file mode 100644 index 00000000..5ef68b04 --- /dev/null +++ b/game/inventory.py @@ -0,0 +1,130 @@ +"""Inventory management APIs.""" +from collections import defaultdict +from typing import Dict, Iterable, Iterator, Set, Tuple + +from dcs.unittype import UnitType + +from gen.flights.flight import Flight +from theater import ControlPoint + + +class ControlPointAircraftInventory: + """Aircraft inventory for a single control point.""" + + def __init__(self, control_point: ControlPoint) -> None: + self.control_point = control_point + self.inventory: Dict[UnitType, int] = defaultdict(int) + + def add_aircraft(self, aircraft: UnitType, count: int) -> None: + """Adds aircraft to the inventory. + + Args: + aircraft: The type of aircraft to add. + count: The number of aircraft to add. + """ + self.inventory[aircraft] += count + + def remove_aircraft(self, aircraft: UnitType, count: int) -> None: + """Removes aircraft from the inventory. + + Args: + aircraft: The type of aircraft to remove. + count: The number of aircraft to remove. + + Raises: + ValueError: The control point cannot fulfill the requested number of + aircraft. + """ + available = self.inventory[aircraft] + if available < count: + raise ValueError( + f"Cannot remove {count} {aircraft.id} from " + f"{self.control_point.name}. Only have {available}." + ) + self.inventory[aircraft] -= count + + def available(self, aircraft: UnitType) -> int: + """Returns the number of available aircraft of the given type. + + Args: + aircraft: The type of aircraft to query. + """ + return self.inventory[aircraft] + + @property + def types_available(self) -> Iterator[UnitType]: + """Iterates over all available aircraft types.""" + for aircraft, count in self.inventory.items(): + if count > 0: + yield aircraft + + @property + def all_aircraft(self) -> Iterator[Tuple[UnitType, int]]: + """Iterates over all available aircraft types, including amounts.""" + for aircraft, count in self.inventory.items(): + if count > 0: + yield aircraft, count + + @property + def total_available(self) -> int: + """Returns the total number of aircraft available.""" + # TODO: Remove? + # This probably isn't actually useful. It's used by the AI flight + # planner to determine how many flights of a given type it should + # allocate, but it should probably be making that decision based on the + # number of aircraft available to perform a particular role. + return sum(self.inventory.values()) + + def clear(self) -> None: + """Clears all aircraft from the inventory.""" + self.inventory.clear() + + +class GlobalAircraftInventory: + """Game-wide aircraft inventory.""" + def __init__(self, control_points: Iterable[ControlPoint]) -> None: + self.inventories: Dict[ControlPoint, ControlPointAircraftInventory] = { + cp: ControlPointAircraftInventory(cp) for cp in control_points + } + + def reset(self) -> None: + """Clears all control points and their inventories.""" + for inventory in self.inventories.values(): + inventory.clear() + + def set_from_control_point(self, control_point: ControlPoint) -> None: + """Set the control point's aircraft inventory. + + If the inventory for the given control point has already been set for + the turn, it will be overwritten. + """ + inventory = self.inventories[control_point] + for aircraft, count in control_point.base.aircraft.items(): + inventory.add_aircraft(aircraft, count) + + def for_control_point( + self, + control_point: ControlPoint) -> ControlPointAircraftInventory: + """Returns the inventory specific to the given control point.""" + return self.inventories[control_point] + + @property + def available_types_for_player(self) -> Iterator[UnitType]: + """Iterates over all aircraft types available to the player.""" + seen: Set[UnitType] = set() + for control_point, inventory in self.inventories.items(): + if control_point.captured: + for aircraft in inventory.types_available: + if aircraft not in seen: + seen.add(aircraft) + yield aircraft + + def claim_for_flight(self, flight: Flight) -> None: + """Removes aircraft from the inventory for the given flight.""" + inventory = self.for_control_point(flight.from_cp) + inventory.remove_aircraft(flight.unit_type, flight.count) + + def return_from_flight(self, flight: Flight) -> None: + """Returns a flight's aircraft to the inventory.""" + inventory = self.for_control_point(flight.from_cp) + inventory.add_aircraft(flight.unit_type, flight.count) diff --git a/game/models/game_stats.py b/game/models/game_stats.py index 2690d861..e6d628f4 100644 --- a/game/models/game_stats.py +++ b/game/models/game_stats.py @@ -1,3 +1,5 @@ +from typing import List + class FactionTurnMetadata: """ Store metadata about a faction @@ -31,10 +33,8 @@ class GameStats: Store statistics for the current game """ - data_per_turn: [GameTurnMetadata] = [] - def __init__(self): - self.data_per_turn = [] + self.data_per_turn: List[GameTurnMetadata] = [] def update(self, game): """ diff --git a/game/operation/frontlineattack.py b/game/operation/frontlineattack.py index 48c5965c..19255247 100644 --- a/game/operation/frontlineattack.py +++ b/game/operation/frontlineattack.py @@ -1,7 +1,8 @@ -from game.db import assigned_units_split - -from .operation import * +from dcs.terrain.terrain import Terrain +from gen.conflictgen import Conflict +from .operation import Operation +from .. import db MAX_DISTANCE_BETWEEN_GROUPS = 12000 diff --git a/game/operation/operation.py b/game/operation/operation.py index c0ce5e67..ecc82e51 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -1,30 +1,51 @@ -from typing import Set +import logging +import os +from pathlib import Path +from typing import List, Optional, Set -from gen import * -from gen.airfields import AIRFIELD_DATA -from gen.beacons import load_beacons_for_terrain -from gen.radios import RadioRegistry -from gen.tacan import TacanRegistry +from dcs import Mission +from dcs.action import DoScript, DoScriptFile +from dcs.coalition import Coalition from dcs.countries import country_dict from dcs.lua.parse import loads +from dcs.mapping import Point from dcs.terrain.terrain import Terrain -from userdata.debriefing import * +from dcs.translation import String +from dcs.triggers import TriggerStart +from dcs.unittype import UnitType + +from gen import Conflict, VisualGenerator +from gen.aircraft import AIRCRAFT_DATA, AircraftConflictGenerator, FlightData +from gen.airfields import AIRFIELD_DATA +from gen.airsupportgen import AirSupport, AirSupportConflictGenerator +from gen.armor import GroundConflictGenerator, JtacInfo +from gen.beacons import load_beacons_for_terrain +from gen.briefinggen import BriefingGenerator +from gen.environmentgen import EnviromentGenerator +from gen.forcedoptionsgen import ForcedOptionsGenerator +from gen.groundobjectsgen import GroundObjectsGenerator +from gen.kneeboard import KneeboardGenerator +from gen.radios import RadioFrequency, RadioRegistry +from gen.tacan import TacanRegistry +from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator +from theater import ControlPoint +from .. import db +from ..debriefing import Debriefing class Operation: attackers_starting_position = None # type: db.StartingPosition defenders_starting_position = None # type: db.StartingPosition - current_mission = None # type: dcs.Mission - regular_mission = None # type: dcs.Mission - quick_mission = None # type: dcs.Mission + current_mission = None # type: Mission + regular_mission = None # type: Mission + quick_mission = None # type: Mission conflict = None # type: Conflict - armorgen = None # type: ArmorConflictGenerator airgen = None # type: AircraftConflictGenerator triggersgen = None # type: TriggersGenerator airsupportgen = None # type: AirSupportConflictGenerator visualgen = None # type: VisualGenerator - envgen = None # type: EnvironmentGenerator + envgen = None # type: EnviromentGenerator groundobjectgen = None # type: GroundObjectsGenerator briefinggen = None # type: BriefingGenerator forcedoptionsgen = None # type: ForcedOptionsGenerator @@ -43,7 +64,7 @@ class Operation: defender_name: str, from_cp: ControlPoint, departure_cp: ControlPoint, - to_cp: ControlPoint = None): + to_cp: ControlPoint): self.game = game self.attacker_name = attacker_name self.attacker_country = db.FACTIONS[attacker_name]["country"] @@ -55,7 +76,7 @@ class Operation: self.to_cp = to_cp self.is_quick = False - def units_of(self, country_name: str) -> typing.Collection[UnitType]: + def units_of(self, country_name: str) -> List[UnitType]: return [] def is_successfull(self, debriefing: Debriefing) -> bool: @@ -68,32 +89,14 @@ class Operation: def initialize(self, mission: Mission, conflict: Conflict): self.current_mission = mission self.conflict = conflict - self.radio_registry = RadioRegistry() - self.tacan_registry = TacanRegistry() - self.airgen = AircraftConflictGenerator( - mission, conflict, self.game.settings, self.game, - self.radio_registry) - self.airsupportgen = AirSupportConflictGenerator( - mission, conflict, self.game, self.radio_registry, - self.tacan_registry) - self.triggersgen = TriggersGenerator(mission, conflict, self.game) - self.visualgen = VisualGenerator(mission, conflict, self.game) - self.envgen = EnviromentGenerator(mission, conflict, self.game) - self.forcedoptionsgen = ForcedOptionsGenerator(mission, conflict, self.game) - self.groundobjectgen = GroundObjectsGenerator( - mission, - conflict, - self.game, - self.radio_registry, - self.tacan_registry - ) - self.briefinggen = BriefingGenerator(mission, conflict, self.game) + self.briefinggen = BriefingGenerator(self.current_mission, + self.conflict, self.game) def prepare(self, terrain: Terrain, is_quick: bool): with open("resources/default_options.lua", "r") as f: options_dict = loads(f.read())["options"] - self.current_mission = dcs.Mission(terrain) + self.current_mission = Mission(terrain) print(self.game.player_country) print(country_dict[db.country_id_from_name(self.game.player_country)]) @@ -124,9 +127,16 @@ class Operation: self.defenders_starting_position = None else: self.attackers_starting_position = self.departure_cp.at - self.defenders_starting_position = self.to_cp.at + # TODO: Is this possible? + if self.to_cp is not None: + self.defenders_starting_position = self.to_cp.at + else: + self.defenders_starting_position = None def generate(self): + radio_registry = RadioRegistry() + tacan_registry = TacanRegistry() + # Dedup beacon/radio frequencies, since some maps have some frequencies # used multiple times. beacons = load_beacons_for_terrain(self.game.theater.terrain.name) @@ -138,7 +148,7 @@ class Operation: logging.error( f"TACAN beacon has no channel: {beacon.callsign}") else: - self.tacan_registry.reserve(beacon.tacan_channel) + tacan_registry.reserve(beacon.tacan_channel) for airfield, data in AIRFIELD_DATA.items(): if data.theater == self.game.theater.terrain.name: @@ -150,16 +160,26 @@ class Operation: # beacon list. for frequency in unique_map_frequencies: - self.radio_registry.reserve(frequency) + radio_registry.reserve(frequency) # Generate meteo + envgen = EnviromentGenerator(self.current_mission, self.conflict, + self.game) if self.environment_settings is None: - self.environment_settings = self.envgen.generate() + self.environment_settings = envgen.generate() else: - self.envgen.load(self.environment_settings) + envgen.load(self.environment_settings) # Generate ground object first - self.groundobjectgen.generate() + + groundobjectgen = GroundObjectsGenerator( + self.current_mission, + self.conflict, + self.game, + radio_registry, + tacan_registry + ) + groundobjectgen.generate() # Generate destroyed units for d in self.game.get_destroyed_units(): @@ -180,24 +200,27 @@ class Operation: dead=True, ) - # Air Support (Tanker & Awacs) - self.airsupportgen.generate(self.is_awacs_enabled) + airsupportgen = AirSupportConflictGenerator( + self.current_mission, self.conflict, self.game, radio_registry, + tacan_registry) + airsupportgen.generate(self.is_awacs_enabled) # Generate Activity on the map - for cp in self.game.theater.controlpoints: - side = cp.captured - if side: - country = self.current_mission.country(self.game.player_country) - else: - country = self.current_mission.country(self.game.enemy_country) - if cp.id in self.game.planners.keys(): - self.airgen.generate_flights( - cp, - country, - self.game.planners[cp.id], - self.groundobjectgen.runways - ) + airgen = AircraftConflictGenerator( + self.current_mission, self.conflict, self.game.settings, self.game, + radio_registry) + + airgen.generate_flights( + self.current_mission.country(self.game.player_country), + self.game.blue_ato, + groundobjectgen.runways + ) + airgen.generate_flights( + self.current_mission.country(self.game.enemy_country), + self.game.red_ato, + groundobjectgen.runways + ) # Generate ground units on frontline everywhere jtacs: List[JtacInfo] = [] @@ -221,18 +244,20 @@ class Operation: self.current_mission.groundControl.red_tactical_commander = self.ca_slots # Triggers - if self.game.is_player_attack(self.conflict.attackers_country): - cp = self.conflict.from_cp - else: - cp = self.conflict.to_cp - self.triggersgen.generate() + triggersgen = TriggersGenerator(self.current_mission, self.conflict, + self.game) + triggersgen.generate() # Options - self.forcedoptionsgen.generate() + forcedoptionsgen = ForcedOptionsGenerator(self.current_mission, + self.conflict, self.game) + forcedoptionsgen.generate() # Generate Visuals Smoke Effects + visualgen = VisualGenerator(self.current_mission, self.conflict, + self.game) if self.game.settings.perf_smoke_gen: - self.visualgen.generate() + visualgen.generate() # Inject Plugins Lua Scripts listOfPluginsScripts = [] @@ -327,19 +352,20 @@ class Operation: trigger.add_action(DoScript(String(lua))) self.current_mission.triggerrules.triggers.append(trigger) - self.assign_channels_to_flights() + self.assign_channels_to_flights(airgen.flights, + airsupportgen.air_support) kneeboard_generator = KneeboardGenerator(self.current_mission) - for dynamic_runway in self.groundobjectgen.runways.values(): + for dynamic_runway in groundobjectgen.runways.values(): self.briefinggen.add_dynamic_runway(dynamic_runway) - for tanker in self.airsupportgen.air_support.tankers: + for tanker in airsupportgen.air_support.tankers: self.briefinggen.add_tanker(tanker) kneeboard_generator.add_tanker(tanker) if self.is_awacs_enabled: - for awacs in self.airsupportgen.air_support.awacs: + for awacs in airsupportgen.air_support.awacs: self.briefinggen.add_awacs(awacs) kneeboard_generator.add_awacs(awacs) @@ -347,21 +373,23 @@ class Operation: self.briefinggen.add_jtac(jtac) kneeboard_generator.add_jtac(jtac) - for flight in self.airgen.flights: + for flight in airgen.flights: self.briefinggen.add_flight(flight) kneeboard_generator.add_flight(flight) self.briefinggen.generate() kneeboard_generator.generate() - def assign_channels_to_flights(self) -> None: + def assign_channels_to_flights(self, flights: List[FlightData], + air_support: AirSupport) -> None: """Assigns preset radio channels for client flights.""" - for flight in self.airgen.flights: + for flight in flights: if not flight.client_units: continue - self.assign_channels_to_flight(flight) + self.assign_channels_to_flight(flight, air_support) - def assign_channels_to_flight(self, flight: FlightData) -> None: + def assign_channels_to_flight(self, flight: FlightData, + air_support: AirSupport) -> None: """Assigns preset radio channels for a client flight.""" airframe = flight.aircraft_type @@ -371,5 +399,7 @@ class Operation: logging.warning(f"No aircraft data for {airframe.id}") return - aircraft_data.channel_allocator.assign_channels_for_flight( - flight, self.airsupportgen.air_support) + if aircraft_data.channel_allocator is not None: + aircraft_data.channel_allocator.assign_channels_for_flight( + flight, air_support + ) diff --git a/userdata/persistency.py b/game/persistency.py similarity index 82% rename from userdata/persistency.py rename to game/persistency.py index 9e55dda0..617274ea 100644 --- a/userdata/persistency.py +++ b/game/persistency.py @@ -2,8 +2,9 @@ import logging import os import pickle import shutil +from typing import Optional -_dcs_saved_game_folder = None # type: str +_dcs_saved_game_folder: Optional[str] = None _file_abs_path = None def setup(user_folder: str): @@ -40,30 +41,33 @@ def restore_game(): try: save = pickle.load(f) return save - except: - logging.error("Invalid Save game") + except Exception: + logging.exception("Invalid Save game") return None + def load_game(path): with open(path, "rb") as f: try: save = pickle.load(f) save.savepath = path return save - except: - logging.error("Invalid Save game") + except Exception: + logging.exception("Invalid Save game") return None + def save_game(game) -> bool: try: with open(_temporary_save_file(), "wb") as f: pickle.dump(game, f) shutil.copy(_temporary_save_file(), game.savepath) return True - except Exception as e: - logging.error(e) + except Exception: + logging.exception("Could not save game") return False + def autosave(game) -> bool: """ Autosave to the autosave location @@ -74,7 +78,7 @@ def autosave(game) -> bool: with open(_autosave_path(), "wb") as f: pickle.dump(game, f) return True - except Exception as e: - logging.error(e) + except Exception: + logging.exception("Could not save game") return False diff --git a/gen/__init__.py b/gen/__init__.py index ad11614f..6fd6547c 100644 --- a/gen/__init__.py +++ b/gen/__init__.py @@ -1,4 +1,3 @@ -from .aaa import * from .aircraft import * from .armor import * from .airsupportgen import * @@ -12,4 +11,3 @@ from .forcedoptionsgen import * from .kneeboard import * from . import naming - diff --git a/gen/aaa.py b/gen/aaa.py deleted file mode 100644 index d9822202..00000000 --- a/gen/aaa.py +++ /dev/null @@ -1,51 +0,0 @@ -from .conflictgen import * -from .naming import * - -from dcs.mission import * -from dcs.mission import * - -from .conflictgen import * -from .naming import * - -DISTANCE_FACTOR = 0.5, 1 -EXTRA_AA_MIN_DISTANCE = 50000 -EXTRA_AA_MAX_DISTANCE = 150000 -EXTRA_AA_POSITION_FROM_CP = 550 - -class ExtraAAConflictGenerator: - def __init__(self, mission: Mission, conflict: Conflict, game, player_country: Country, enemy_country: Country): - self.mission = mission - self.game = game - self.conflict = conflict - self.player_country = player_country - self.enemy_country = enemy_country - - def generate(self): - - for cp in self.game.theater.controlpoints: - if cp.is_global: - continue - - if cp.position.distance_to_point(self.conflict.position) < EXTRA_AA_MIN_DISTANCE: - continue - - if cp.position.distance_to_point(self.conflict.from_cp.position) < EXTRA_AA_MIN_DISTANCE: - continue - - if cp.position.distance_to_point(self.conflict.to_cp.position) < EXTRA_AA_MIN_DISTANCE: - continue - - if cp.position.distance_to_point(self.conflict.position) > EXTRA_AA_MAX_DISTANCE: - continue - - country_name = cp.captured and self.player_country or self.enemy_country - position = cp.position.point_from_heading(0, EXTRA_AA_POSITION_FROM_CP) - - self.mission.vehicle_group( - country=self.mission.country(country_name), - name=namegen.next_basedefense_name(), - _type=db.EXTRA_AA[country_name], - position=position, - group_size=1 - ) - diff --git a/gen/aircraft.py b/gen/aircraft.py index ec5dec26..f9cd63f1 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -1,30 +1,84 @@ +import logging +import random from dataclasses import dataclass -from typing import Type +from typing import Dict, List, Optional, Tuple, Type, Union from dcs import helicopters -from dcs.action import ActivateGroup, AITaskPush, MessageToAll -from dcs.condition import TimeAfter, CoalitionHasAirdrome, PartOfCoalitionInZone +from dcs.action import AITaskPush, ActivateGroup, MessageToAll +from dcs.condition import CoalitionHasAirdrome, PartOfCoalitionInZone, TimeAfter +from dcs.country import Country from dcs.flyingunit import FlyingUnit -from dcs.helicopters import helicopter_map, UH_1H +from dcs.helicopters import UH_1H, helicopter_map +from dcs.mapping import Point +from dcs.mission import Mission, StartType +from dcs.planes import ( + AJS37, + B_17G, + Bf_109K_4, + FW_190A8, + FW_190D9, + F_14B, + I_16, + JF_17, + Ju_88A4, + P_47D_30, + P_51D, + P_51D_30_NA, + SpitfireLFMkIX, + SpitfireLFMkIXCW, + Su_33, +) +from dcs.point import PointAction +from dcs.task import ( + AntishipStrike, + AttackGroup, + Bombing, + CAP, + CAS, + ControlledTask, + EPLRS, + EngageTargets, + Escort, + GroundAttack, + MainTask, + NoTask, + OptROE, + OptRTBOnBingoFuel, + OptRTBOnOutOfAmmo, + OptReactOnThreat, + OptRestrictAfterburner, + OptRestrictJettison, + OrbitAction, + PinpointStrike, + SEAD, + StartCommand, + Targets, + Task, +) from dcs.terrain.terrain import Airport, NoParkingSlotError -from dcs.triggers import TriggerOnce, Event +from dcs.translation import String +from dcs.triggers import Event, TriggerOnce +from dcs.unitgroup import FlyingGroup, Group, ShipGroup, StaticGroup +from dcs.unittype import FlyingType, UnitType +from game import db from game.data.cap_capabilities_db import GUNFIGHTERS from game.settings import Settings from game.utils import nm_to_meter from gen.airfields import RunwayData from gen.airsupportgen import AirSupport +from gen.ato import AirTaskingOrder from gen.callsigns import create_group_callsign_from_unit -from gen.flights.ai_flight_planner import FlightPlanner from gen.flights.flight import ( Flight, FlightType, FlightWaypoint, FlightWaypointType, ) -from gen.radios import get_radio, MHz, Radio, RadioFrequency, RadioRegistry -from .conflictgen import * -from .naming import * +from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio +from theater.controlpoint import ControlPoint, ControlPointType +from .naming import namegen +from .conflictgen import Conflict WARM_START_HELI_AIRSPEED = 120 WARM_START_HELI_ALT = 500 @@ -264,8 +318,12 @@ class CommonRadioChannelAllocator(RadioChannelAllocator): def assign_channels_for_flight(self, flight: FlightData, air_support: AirSupport) -> None: - flight.assign_channel( - self.intra_flight_radio_index, 1, flight.intra_flight_channel) + if self.intra_flight_radio_index is not None: + flight.assign_channel( + self.intra_flight_radio_index, 1, flight.intra_flight_channel) + + if self.inter_flight_radio_index is None: + return # For cases where the inter-flight and intra-flight radios share presets # (the JF-17 only has one set of channels, even though it can use two @@ -335,8 +393,10 @@ class ViggenRadioChannelAllocator(RadioChannelAllocator): # the guard channel. radio_id = 1 flight.assign_channel(radio_id, 1, flight.intra_flight_channel) - flight.assign_channel(radio_id, 4, flight.departure.atc) - flight.assign_channel(radio_id, 5, flight.arrival.atc) + if flight.departure.atc is not None: + flight.assign_channel(radio_id, 4, flight.departure.atc) + if flight.arrival.atc is not None: + flight.assign_channel(radio_id, 5, flight.arrival.atc) # TODO: Assign divert to 6 when we support divert airfields. @@ -348,8 +408,10 @@ class SCR522RadioChannelAllocator(RadioChannelAllocator): air_support: AirSupport) -> None: radio_id = 1 flight.assign_channel(radio_id, 1, flight.intra_flight_channel) - flight.assign_channel(radio_id, 2, flight.departure.atc) - flight.assign_channel(radio_id, 3, flight.arrival.atc) + if flight.departure.atc is not None: + flight.assign_channel(radio_id, 2, flight.departure.atc) + if flight.arrival.atc is not None: + flight.assign_channel(radio_id, 3, flight.arrival.atc) # TODO : Some GCI on Channel 4 ? @@ -471,8 +533,6 @@ AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"] class AircraftConflictGenerator: - escort_targets = [] # type: typing.List[typing.Tuple[FlyingGroup, int]] - def __init__(self, mission: Mission, conflict: Conflict, settings: Settings, game, radio_registry: RadioRegistry): self.m = mission @@ -480,7 +540,7 @@ class AircraftConflictGenerator: self.settings = settings self.conflict = conflict self.radio_registry = radio_registry - self.escort_targets = [] + self.escort_targets: List[Tuple[FlyingGroup, int]] = [] self.flights: List[FlightData] = [] def get_intra_flight_channel(self, airframe: UnitType) -> RadioFrequency: @@ -502,33 +562,23 @@ class AircraftConflictGenerator: def _start_type(self) -> StartType: return self.settings.cold_start and StartType.Cold or StartType.Warm - def _setup_group(self, group: FlyingGroup, for_task: typing.Type[Task], + def _setup_group(self, group: FlyingGroup, for_task: Type[Task], flight: Flight, dynamic_runways: Dict[str, RunwayData]): did_load_loadout = False unit_type = group.units[0].unit_type if unit_type in db.PLANE_PAYLOAD_OVERRIDES: override_loadout = db.PLANE_PAYLOAD_OVERRIDES[unit_type] - if type(override_loadout) == dict: + # Clear pylons + for p in group.units: + p.pylons.clear() - # Clear pylons - for p in group.units: - p.pylons.clear() - - # Now load loadout - if for_task in db.PLANE_PAYLOAD_OVERRIDES[unit_type]: - payload_name = db.PLANE_PAYLOAD_OVERRIDES[unit_type][for_task] - group.load_loadout(payload_name) - did_load_loadout = True - logging.info("Loaded overridden payload for {} - {} for task {}".format(unit_type, payload_name, for_task)) - elif "*" in db.PLANE_PAYLOAD_OVERRIDES[unit_type]: - payload_name = db.PLANE_PAYLOAD_OVERRIDES[unit_type]["*"] - group.load_loadout(payload_name) - did_load_loadout = True - logging.info("Loaded overridden payload for {} - {} for task {}".format(unit_type, payload_name, for_task)) - elif issubclass(override_loadout, MainTask): - group.load_task_default_loadout(override_loadout) + # Now load loadout + if for_task in db.PLANE_PAYLOAD_OVERRIDES[unit_type]: + payload_name = db.PLANE_PAYLOAD_OVERRIDES[unit_type][for_task] + group.load_loadout(payload_name) did_load_loadout = True + logging.info("Loaded overridden payload for {} - {} for task {}".format(unit_type, payload_name, for_task)) if not did_load_loadout: group.load_task_default_loadout(for_task) @@ -590,7 +640,7 @@ class AircraftConflictGenerator: # Special case so Su 33 carrier take off if unit_type is Su_33: - if task is not CAP: + if flight.flight_type is not CAP: for unit in group.units: unit.fuel = Su_33.fuel_max / 2.2 else: @@ -613,9 +663,12 @@ class AircraftConflictGenerator: # so just use the first runway. return runways[0] - def _generate_at_airport(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, airport: Airport = None, start_type = None) -> FlyingGroup: + def _generate_at_airport(self, name: str, side: Country, + unit_type: FlyingType, count: int, + client_count: int, + airport: Optional[Airport] = None, + start_type=None) -> FlyingGroup: assert count > 0 - assert unit is not None if start_type is None: start_type = self._start_type() @@ -633,7 +686,6 @@ class AircraftConflictGenerator: def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, at: Point) -> FlyingGroup: assert count > 0 - assert unit is not None if unit_type in helicopters.helicopter_map.values(): alt = WARM_START_HELI_ALT @@ -660,9 +712,11 @@ class AircraftConflictGenerator: group.points[0].alt_type = "RADIO" return group - def _generate_at_group(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, at: typing.Union[ShipGroup, StaticGroup], start_type=None) -> FlyingGroup: + def _generate_at_group(self, name: str, side: Country, + unit_type: FlyingType, count: int, client_count: int, + at: Union[ShipGroup, StaticGroup], + start_type=None) -> FlyingGroup: assert count > 0 - assert unit is not None if start_type is None: start_type = self._start_type() @@ -688,7 +742,7 @@ class AircraftConflictGenerator: return self._generate_at_group(name, side, unit_type, count, client_count, at) else: return self._generate_inflight(name, side, unit_type, count, client_count, at.position) - elif issubclass(at, Airport): + elif isinstance(at, Airport): takeoff_ban = unit_type in db.TAKEOFF_BAN ai_ban = client_count == 0 and self.settings.only_player_takeoff @@ -707,8 +761,9 @@ class AircraftConflictGenerator: point.alt_type = "RADIO" return point - def _rtb_for(self, group: FlyingGroup, cp: ControlPoint, at: db.StartingPosition = None): - if not at: + def _rtb_for(self, group: FlyingGroup, cp: ControlPoint, + at: Optional[db.StartingPosition] = None): + if at is None: at = cp.at position = at if isinstance(at, Point) else at.position @@ -751,31 +806,28 @@ class AircraftConflictGenerator: else: logging.warning("Pylon not found ! => Pylon" + key + " on " + str(flight.unit_type)) - - def generate_flights(self, cp, country, flight_planner: FlightPlanner, - dynamic_runways: Dict[str, RunwayData]): - # Clear pydcs parking slots - if cp.airport is not None: - logging.info("CLEARING SLOTS @ " + cp.airport.name) - logging.info("===============") + def clear_parking_slots(self) -> None: + for cp in self.game.theater.controlpoints: if cp.airport is not None: - for ps in cp.airport.parking_slots: - logging.info("SLOT : " + str(ps.unit_id)) - ps.unit_id = None - logging.info("----------------") - logging.info("===============") + for parking_slot in cp.airport.parking_slots: + parking_slot.unit_id = None - for flight in flight_planner.flights: - - if flight.client_count == 0 and self.game.position_culled(flight.from_cp.position): - logging.info("Flight not generated : culled") - continue - logging.info("Generating flight : " + str(flight.unit_type)) - group = self.generate_planned_flight(cp, country, flight) - self.setup_flight_group(group, flight, flight.flight_type, - dynamic_runways) - self.setup_group_activation_trigger(flight, group) + def generate_flights(self, country, ato: AirTaskingOrder, + dynamic_runways: Dict[str, RunwayData]) -> None: + self.clear_parking_slots() + for package in ato.packages: + for flight in package.flights: + culled = self.game.position_culled(flight.from_cp.position) + if flight.client_count == 0 and culled: + logging.info("Flight not generated: culled") + continue + logging.info(f"Generating flight: {flight.unit_type}") + group = self.generate_planned_flight(flight.from_cp, country, + flight) + self.setup_flight_group(group, flight, flight.flight_type, + dynamic_runways) + self.setup_group_activation_trigger(flight, group) def setup_group_activation_trigger(self, flight, group): if flight.scheduled_in > 0 and flight.client_count == 0: @@ -932,6 +984,14 @@ class AircraftConflictGenerator: group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire)) group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire)) group.points[0].tasks.append(OptRestrictJettison(True)) + elif flight_type == FlightType.ESCORT: + group.task = Escort.name + self._setup_group(group, Escort, flight, dynamic_runways) + # TODO: Cleanup duplication... + group.points[0].tasks.clear() + group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire)) + group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire)) + group.points[0].tasks.append(OptRestrictJettison(True)) group.points[0].tasks.append(OptRTBOnBingoFuel(True)) group.points[0].tasks.append(OptRestrictAfterburner(True)) @@ -952,6 +1012,7 @@ class AircraftConflictGenerator: # pt.tasks.append(engagetgt) elif point.waypoint_type == FlightWaypointType.LANDING_POINT: pt.type = "Land" + pt.action = PointAction.Landing elif point.waypoint_type == FlightWaypointType.INGRESS_STRIKE: if group.units[0].unit_type == B_17G: diff --git a/gen/airfields.py b/gen/airfields.py index b7e08712..b3185158 100644 --- a/gen/airfields.py +++ b/gen/airfields.py @@ -195,10 +195,12 @@ AIRFIELD_DATA = { runway_length=8623, atc=AtcData(MHz(3, 750), MHz(121, 0), MHz(38, 400), MHz(250, 0)), outer_ndb={ - "22": ("AP", MHz(443, 0)), "4": "443.00 (AN)" + "22": ("AP", MHz(443, 0)), + "04": ("AN", MHz(443)), }, inner_ndb={ - "22": ("P", MHz(215, 0)), "4": "215.00 (N)" + "22": ("P", MHz(215, 0)), + "04": ("N", MHz(215)), }, ), diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 791c80b6..8a98dba7 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -1,8 +1,21 @@ from dataclasses import dataclass, field +from typing import List, Type +from dcs.mission import Mission, StartType +from dcs.planes import IL_78M +from dcs.task import ( + AWACS, + ActivateBeaconCommand, + MainTask, + Refueling, + SetImmortalCommand, + SetInvisibleCommand, +) + +from game import db +from .naming import namegen from .callsigns import callsign_for_support_unit -from .conflictgen import * -from .naming import * +from .conflictgen import Conflict from .radios import RadioFrequency, RadioRegistry from .tacan import TacanBand, TacanChannel, TacanRegistry @@ -49,7 +62,7 @@ class AirSupportConflictGenerator: self.tacan_registry = tacan_registry @classmethod - def support_tasks(cls) -> typing.Collection[typing.Type[MainTask]]: + def support_tasks(cls) -> List[Type[MainTask]]: return [Refueling, AWACS] def generate(self, is_awacs_enabled): @@ -76,6 +89,7 @@ class AirSupportConflictGenerator: speed=574, tacanchannel=str(tacan), ) + tanker_group.set_frequency(freq.mhz) callsign = callsign_for_support_unit(tanker_group) tacan_callsign = { @@ -118,6 +132,8 @@ class AirSupportConflictGenerator: frequency=freq.mhz, start_type=StartType.Warm, ) + awacs_flight.set_frequency(freq.mhz) + awacs_flight.points[0].tasks.append(SetInvisibleCommand(True)) awacs_flight.points[0].tasks.append(SetImmortalCommand(True)) diff --git a/gen/armor.py b/gen/armor.py index 5042149f..426dc05a 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -1,13 +1,39 @@ +import logging +import random from dataclasses import dataclass +from typing import List +from dcs import Mission from dcs.action import AITaskPush -from dcs.condition import TimeAfter, UnitDamaged, Or, GroupLifeLess -from dcs.triggers import TriggerOnce, Event +from dcs.condition import GroupLifeLess, Or, TimeAfter, UnitDamaged +from dcs.country import Country +from dcs.mapping import Point +from dcs.planes import MQ_9_Reaper +from dcs.point import PointAction +from dcs.task import ( + AttackGroup, + ControlledTask, + EPLRS, + FireAtPoint, + GoToWaypoint, + Hold, + OrbitAction, + SetImmortalCommand, + SetInvisibleCommand, +) +from dcs.triggers import Event, TriggerOnce +from dcs.unit import Vehicle +from dcs.unittype import VehicleType -from gen import namegen -from gen.ground_forces.ai_ground_planner import CombatGroupRole, DISTANCE_FROM_FRONTLINE +from game import db +from .naming import namegen +from gen.ground_forces.ai_ground_planner import ( + CombatGroupRole, + DISTANCE_FROM_FRONTLINE, +) from .callsigns import callsign_for_support_unit -from .conflictgen import * +from .conflictgen import Conflict +from .ground_forces.combat_stance import CombatStance SPREAD_DISTANCE_FACTOR = 0.1, 0.3 SPREAD_DISTANCE_SIZE_FACTOR = 0.1 @@ -48,7 +74,7 @@ class GroundConflictGenerator: self.jtacs: List[JtacInfo] = [] def _group_point(self, point) -> Point: - distance = randint( + distance = random.randint( int(self.conflict.size * SPREAD_DISTANCE_FACTOR[0]), int(self.conflict.size * SPREAD_DISTANCE_FACTOR[1]), ) @@ -165,7 +191,7 @@ class GroundConflictGenerator: heading=forward_heading, move_formation=PointAction.OffRoad) - for i in range(randint(3, 10)): + for i in range(random.randint(3, 10)): u = random.choice(possible_infantry_units) position = infantry_position.random_point_within(55, 5) self.mission.vehicle_group( @@ -183,6 +209,11 @@ class GroundConflictGenerator: return for dcs_group, group in ally_groups: + + if hasattr(group.units[0], 'eplrs'): + if group.units[0].eplrs: + dcs_group.points[0].tasks.append(EPLRS(dcs_group.id)) + if group.role == CombatGroupRole.ARTILLERY: # Fire on any ennemy in range if self.game.settings.perf_artillery: diff --git a/gen/ato.py b/gen/ato.py new file mode 100644 index 00000000..ee3a3c55 --- /dev/null +++ b/gen/ato.py @@ -0,0 +1,136 @@ +"""Air Tasking Orders. + +The classes of the Air Tasking Order (ATO) define all of the missions that have +been planned, and which aircraft have been assigned to them. Each planned +mission, or "package" is composed of individual flights. The package may contain +dissimilar aircraft performing different roles, but all for the same goal. For +example, the package to strike an enemy airfield may contain an escort flight, +a SEAD flight, and the strike aircraft themselves. CAP packages may contain only +the single CAP flight. +""" +from collections import defaultdict +from dataclasses import dataclass, field +import logging +from typing import Dict, Iterator, List, Optional + +from dcs.mapping import Point +from .flights.flight import Flight, FlightType +from theater.missiontarget import MissionTarget + + +@dataclass(frozen=True) +class Task: + """The main task of a flight or package.""" + + #: The type of task. + task_type: FlightType + + #: The location of the objective. + location: str + + +@dataclass +class Package: + """A mission package.""" + + #: The mission target. Currently can be either a ControlPoint or a + #: TheaterGroundObject (non-ControlPoint map objectives). + target: MissionTarget + + #: The set of flights in the package. + flights: List[Flight] = field(default_factory=list) + + delay: int = field(default=0) + + join_point: Optional[Point] = field(default=None, init=False, hash=False) + split_point: Optional[Point] = field(default=None, init=False, hash=False) + ingress_point: Optional[Point] = field(default=None, init=False, hash=False) + egress_point: Optional[Point] = field(default=None, init=False, hash=False) + + def add_flight(self, flight: Flight) -> None: + """Adds a flight to the package.""" + self.flights.append(flight) + + def remove_flight(self, flight: Flight) -> None: + """Removes a flight from the package.""" + self.flights.remove(flight) + if not self.flights: + self.ingress_point = None + self.egress_point = None + + @property + def primary_task(self) -> Optional[FlightType]: + if not self.flights: + return None + + flight_counts: Dict[FlightType, int] = defaultdict(lambda: 0) + for flight in self.flights: + flight_counts[flight.flight_type] += 1 + + # The package will contain a mix of mission types, but in general we can + # determine the goal of the mission because some mission types are more + # likely to be the main task than others. For example, a package with + # only CAP flights is a CAP package, a flight with CAP and strike is a + # strike package, a flight with CAP and DEAD is a DEAD package, and a + # flight with strike and SEAD is an OCA/Strike package. The type of + # package is determined by the highest priority flight in the package. + task_priorities = [ + FlightType.CAS, + FlightType.STRIKE, + FlightType.ANTISHIP, + FlightType.BAI, + FlightType.EVAC, + FlightType.TROOP_TRANSPORT, + FlightType.RECON, + FlightType.ELINT, + FlightType.DEAD, + FlightType.SEAD, + FlightType.LOGISTICS, + FlightType.INTERCEPTION, + FlightType.TARCAP, + FlightType.CAP, + FlightType.BARCAP, + FlightType.EWAR, + FlightType.ESCORT, + ] + for task in task_priorities: + if flight_counts[task]: + return task + + # If we get here, our task_priorities list above is incomplete. Log the + # issue and return the type of *any* flight in the package. + some_mission = next(iter(self.flights)).flight_type + logging.warning(f"Unhandled mission type: {some_mission}") + return some_mission + + @property + def package_description(self) -> str: + """Generates a package description based on flight composition.""" + task = self.primary_task + if task is None: + return "No mission" + return task.name + + def __hash__(self) -> int: + # TODO: Far from perfect. Number packages? + return hash(self.target.name) + + +@dataclass +class AirTaskingOrder: + """The entire ATO for one coalition.""" + + #: The set of all planned packages in the ATO. + packages: List[Package] = field(default_factory=list) + + def add_package(self, package: Package) -> None: + """Adds a package to the ATO.""" + self.packages.append(package) + + def remove_package(self, package: Package) -> None: + """Removes a package from the ATO.""" + self.packages.remove(package) + + def clear(self) -> None: + """Removes all packages from the ATO.""" + self.packages.clear() diff --git a/gen/briefinggen.py b/gen/briefinggen.py index 10e07001..82744a8a 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -106,7 +106,7 @@ class BriefingGenerator(MissionInfoGenerator): aircraft = flight.aircraft_type flight_unit_name = db.unit_type_name(aircraft) self.description += "-" * 50 + "\n" - self.description += f"{flight_unit_name} x {flight.size + 2}\n\n" + self.description += f"{flight_unit_name} x {flight.size}\n\n" for i, wpt in enumerate(flight.waypoints): self.description += f"#{i + 1} -- {wpt.name} : {wpt.description}\n" diff --git a/gen/conflictgen.py b/gen/conflictgen.py index 9b83b51e..3c9eecfe 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -1,21 +1,11 @@ import logging -import typing -import pdb -import dcs +import random +from typing import Tuple -from random import randint -from dcs import Mission +from dcs.country import Country +from dcs.mapping import Point -from dcs.mission import * -from dcs.vehicles import * -from dcs.unitgroup import * -from dcs.unittype import * -from dcs.mapping import * -from dcs.point import * -from dcs.task import * -from dcs.country import * - -from theater import * +from theater import ConflictTheater, ControlPoint AIR_DISTANCE = 40000 @@ -65,24 +55,6 @@ def _heading_sum(h, a) -> int: class Conflict: - attackers_side = None # type: str - defenders_side = None # type: str - attackers_country = None # type: Country - defenders_country = None # type: Country - from_cp = None # type: ControlPoint - to_cp = None # type: ControlPoint - position = None # type: Point - size = None # type: int - radials = None # type: typing.List[int] - - heading = None # type: int - distance = None # type: int - - ground_attackers_location = None # type: Point - ground_defenders_location = None # type: Point - air_attackers_location = None # type: Point - air_defenders_location = None # type: Point - def __init__(self, theater: ConflictTheater, from_cp: ControlPoint, @@ -155,7 +127,7 @@ class Conflict: else: return self.position - def find_ground_position(self, at: Point, heading: int, max_distance: int = 40000) -> typing.Optional[Point]: + def find_ground_position(self, at: Point, heading: int, max_distance: int = 40000) -> Point: return Conflict._find_ground_position(at, max_distance, heading, self.theater) @classmethod @@ -163,7 +135,7 @@ class Conflict: return from_cp.has_frontline and to_cp.has_frontline @classmethod - def frontline_position(cls, theater: ConflictTheater, from_cp: ControlPoint, to_cp: ControlPoint) -> typing.Optional[typing.Tuple[Point, int]]: + def frontline_position(cls, theater: ConflictTheater, from_cp: ControlPoint, to_cp: ControlPoint) -> Tuple[Point, int]: attack_heading = from_cp.position.heading_between_point(to_cp.position) attack_distance = from_cp.position.distance_to_point(to_cp.position) middle_point = from_cp.position.point_from_heading(attack_heading, attack_distance / 2) @@ -174,9 +146,7 @@ class Conflict: @classmethod - def frontline_vector(cls, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater) -> typing.Optional[typing.Tuple[Point, int, int]]: - initial, heading = cls.frontline_position(theater, from_cp, to_cp) - + def frontline_vector(cls, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater) -> Tuple[Point, int, int]: """ probe_end_point = initial.point_from_heading(heading, FRONTLINE_LENGTH) probe = geometry.LineString([(initial.x, initial.y), (probe_end_point.x, probe_end_point.y) ]) @@ -193,9 +163,6 @@ class Conflict: return Point(*intersection.xy[0]), _heading_sum(heading, 90), intersection.length """ frontline = cls.frontline_position(theater, from_cp, to_cp) - if not frontline: - return None - center_position, heading = frontline left_position, right_position = None, None @@ -243,7 +210,7 @@ class Conflict: """ @classmethod - def _find_ground_position(cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater) -> typing.Optional[Point]: + def _find_ground_position(cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater) -> Point: pos = initial for _ in range(0, int(max_distance), 500): if theater.is_on_land(pos): @@ -302,10 +269,14 @@ class Conflict: distance = to_cp.size * GROUND_DISTANCE_FACTOR attackers_location = position.point_from_heading(attack_heading, distance) - attackers_location = Conflict._find_ground_position(attackers_location, distance * 2, _heading_sum(attack_heading, 180), theater) + attackers_location = Conflict._find_ground_position( + attackers_location, int(distance * 2), + _heading_sum(attack_heading, 180), theater) defenders_location = position.point_from_heading(defense_heading, distance) - defenders_location = Conflict._find_ground_position(defenders_location, distance * 2, _heading_sum(defense_heading, 180), theater) + defenders_location = Conflict._find_ground_position( + defenders_location, int(distance * 2), + _heading_sum(defense_heading, 180), theater) return cls( position=position, @@ -429,7 +400,7 @@ class Conflict: assert cls.has_frontline_between(from_cp, to_cp) position, heading, distance = cls.frontline_vector(from_cp, to_cp, theater) - attack_position = position.point_from_heading(heading, randint(0, int(distance))) + attack_position = position.point_from_heading(heading, random.randint(0, int(distance))) attackers_position = attack_position.point_from_heading(heading - 90, AIR_DISTANCE) defenders_position = attack_position.point_from_heading(heading + 90, random.randint(*CAP_CAS_DISTANCE)) @@ -456,7 +427,9 @@ class Conflict: distance = to_cp.size * GROUND_DISTANCE_FACTOR defenders_location = position.point_from_heading(defense_heading, distance) - defenders_location = Conflict._find_ground_position(defenders_location, distance * 2, _heading_sum(defense_heading, 180), theater) + defenders_location = Conflict._find_ground_position( + defenders_location, int(distance * 2), + _heading_sum(defense_heading, 180), theater) return cls( position=position, diff --git a/gen/environmentgen.py b/gen/environmentgen.py index 1d861842..57d70452 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -1,20 +1,11 @@ import logging -import typing import random -from datetime import datetime, timedelta, time +from datetime import timedelta from dcs.mission import Mission -from dcs.triggers import * -from dcs.condition import * -from dcs.action import * -from dcs.unit import Skill -from dcs.point import MovingPoint, PointProperties -from dcs.action import * -from dcs.weather import * +from dcs.weather import Weather, Wind -from game import db -from theater import * -from gen import * +from .conflictgen import Conflict WEATHER_CLOUD_BASE = 2000, 3000 WEATHER_CLOUD_DENSITY = 1, 8 diff --git a/gen/fleet/ship_group_generator.py b/gen/fleet/ship_group_generator.py index 455d9f27..db974d6c 100644 --- a/gen/fleet/ship_group_generator.py +++ b/gen/fleet/ship_group_generator.py @@ -28,13 +28,13 @@ SHIP_MAP = { } -def generate_ship_group(game, ground_object, faction:str): +def generate_ship_group(game, ground_object, faction_name: str): """ This generate a ship group :return: Nothing, but put the group reference inside the ground object """ - faction = db.FACTIONS[faction] - if "boat" in faction.keys(): + faction = db.FACTIONS[faction_name] + if "boat" in faction: generators = faction["boat"] if len(generators) > 0: gen = random.choice(generators) diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 99cf8427..7bc3fd26 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -1,789 +1,515 @@ -import math -import operator +from __future__ import annotations + +import logging import random +import operator +from dataclasses import dataclass +from typing import Iterator, List, Optional, Set, TYPE_CHECKING, Tuple, Type + +from dcs.unittype import FlyingType, UnitType from game import db -from game.data.doctrine import MODERN_DOCTRINE from game.data.radar_db import UNITS_WITH_RADAR -from game.utils import meter_to_feet, nm_to_meter +from game.infos.information import Information +from game.utils import nm_to_meter from gen import Conflict -from gen.flights.ai_flight_planner_db import INTERCEPT_CAPABLE, CAP_CAPABLE, CAS_CAPABLE, SEAD_CAPABLE, STRIKE_CAPABLE, \ - DRONES -from gen.flights.flight import Flight, FlightType, FlightWaypoint, FlightWaypointType +from gen.ato import Package +from gen.flights.ai_flight_planner_db import ( + CAP_CAPABLE, + CAP_PREFERRED, + CAS_CAPABLE, + CAS_PREFERRED, + SEAD_CAPABLE, + SEAD_PREFERRED, + STRIKE_CAPABLE, + STRIKE_PREFERRED, +) +from gen.flights.closestairfields import ( + ClosestAirfields, + ObjectiveDistanceCache, +) +from gen.flights.flight import ( + Flight, + FlightType, +) +from gen.flights.flightplan import FlightPlanBuilder +from theater import ( + ControlPoint, + FrontLine, + MissionTarget, + TheaterGroundObject, +) + +# Avoid importing some types that cause circular imports unless type checking. +if TYPE_CHECKING: + from game import Game + from game.inventory import GlobalAircraftInventory -MISSION_DURATION = 80 +@dataclass(frozen=True) +class ProposedFlight: + """A flight outline proposed by the mission planner. + + Proposed flights haven't been assigned specific aircraft yet. They have only + a task, a required number of aircraft, and a maximum distance allowed + between the objective and the departure airfield. + """ + + #: The flight's role. + task: FlightType + + #: The number of aircraft required. + num_aircraft: int + + #: The maximum distance between the objective and the departure airfield. + max_distance: int + + def __str__(self) -> str: + return f"{self.task.name} {self.num_aircraft} ship" -class FlightPlanner: +@dataclass(frozen=True) +class ProposedMission: + """A mission outline proposed by the mission planner. - def __init__(self, from_cp, game): - # TODO : have the flight planner depend on a 'stance' setting : [Defensive, Aggresive... etc] and faction doctrine - # TODO : the flight planner should plan package and operations - self.from_cp = from_cp + Proposed missions haven't been assigned aircraft yet. They have only an + objective location and a list of proposed flights that are required for the + mission. + """ + + #: The mission objective. + location: MissionTarget + + #: The proposed flights that are required for the mission. + flights: List[ProposedFlight] + + def __str__(self) -> str: + flights = ', '.join([str(f) for f in self.flights]) + return f"{self.location.name}: {flights}" + + +class AircraftAllocator: + """Finds suitable aircraft for proposed missions.""" + + def __init__(self, closest_airfields: ClosestAirfields, + global_inventory: GlobalAircraftInventory, + is_player: bool) -> None: + self.closest_airfields = closest_airfields + self.global_inventory = global_inventory + self.is_player = is_player + + def find_aircraft_for_flight( + self, flight: ProposedFlight + ) -> Optional[Tuple[ControlPoint, UnitType]]: + """Finds aircraft suitable for the given mission. + + Searches for aircraft capable of performing the given mission within the + maximum allowed range. If insufficient aircraft are available for the + mission, None is returned. + + Airfields are searched ordered nearest to farthest from the target and + searched twice. The first search looks for aircraft which prefer the + mission type, and the second search looks for any aircraft which are + capable of the mission type. For example, an F-14 from a nearby carrier + will be preferred for the CAP of an airfield that has only F-16s, but if + the carrier has only F/A-18s the F-16s will be used for CAP instead. + + Note that aircraft *will* be removed from the global inventory on + success. This is to ensure that the same aircraft are not matched twice + on subsequent calls. If the found aircraft are not used, the caller is + responsible for returning them to the inventory. + """ + result = self.find_aircraft_of_type( + flight, self.preferred_aircraft_for_task(flight.task) + ) + if result is not None: + return result + return self.find_aircraft_of_type( + flight, self.capable_aircraft_for_task(flight.task) + ) + + @staticmethod + def preferred_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: + cap_missions = (FlightType.BARCAP, FlightType.CAP, FlightType.TARCAP) + if task in cap_missions: + return CAP_PREFERRED + elif task == FlightType.CAS: + return CAS_PREFERRED + elif task in (FlightType.DEAD, FlightType.SEAD): + return SEAD_PREFERRED + elif task == FlightType.STRIKE: + return STRIKE_PREFERRED + elif task == FlightType.ESCORT: + return CAP_PREFERRED + else: + return [] + + @staticmethod + def capable_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: + cap_missions = (FlightType.BARCAP, FlightType.CAP, FlightType.TARCAP) + if task in cap_missions: + return CAP_CAPABLE + elif task == FlightType.CAS: + return CAS_CAPABLE + elif task in (FlightType.DEAD, FlightType.SEAD): + return SEAD_CAPABLE + elif task == FlightType.STRIKE: + return STRIKE_CAPABLE + elif task == FlightType.ESCORT: + return CAP_CAPABLE + else: + logging.error(f"Unplannable flight type: {task}") + return [] + + def find_aircraft_of_type( + self, flight: ProposedFlight, types: List[Type[FlyingType]], + ) -> Optional[Tuple[ControlPoint, UnitType]]: + airfields_in_range = self.closest_airfields.airfields_within( + flight.max_distance + ) + for airfield in airfields_in_range: + if not airfield.is_friendly(self.is_player): + continue + inventory = self.global_inventory.for_control_point(airfield) + for aircraft, available in inventory.all_aircraft: + if aircraft in types and available >= flight.num_aircraft: + inventory.remove_aircraft(aircraft, flight.num_aircraft) + return airfield, aircraft + + return None + + +class PackageBuilder: + """Builds a Package for the flights it receives.""" + + def __init__(self, location: MissionTarget, + closest_airfields: ClosestAirfields, + global_inventory: GlobalAircraftInventory, + is_player: bool) -> None: + self.package = Package(location) + self.allocator = AircraftAllocator(closest_airfields, global_inventory, + is_player) + self.global_inventory = global_inventory + + def plan_flight(self, plan: ProposedFlight) -> bool: + """Allocates aircraft for the given flight and adds them to the package. + + If no suitable aircraft are available, False is returned. If the failed + flight was critical and the rest of the mission will be scrubbed, the + caller should return any previously planned flights to the inventory + using release_planned_aircraft. + """ + assignment = self.allocator.find_aircraft_for_flight(plan) + if assignment is None: + return False + airfield, aircraft = assignment + flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task) + self.package.add_flight(flight) + return True + + def build(self) -> Package: + """Returns the built package.""" + return self.package + + def release_planned_aircraft(self) -> None: + """Returns any planned flights to the inventory.""" + flights = list(self.package.flights) + for flight in flights: + self.global_inventory.return_from_flight(flight) + self.package.remove_flight(flight) + + +class ObjectiveFinder: + """Identifies potential objectives for the mission planner.""" + + # TODO: Merge into doctrine. + AIRFIELD_THREAT_RANGE = nm_to_meter(150) + SAM_THREAT_RANGE = nm_to_meter(100) + + def __init__(self, game: Game, is_player: bool) -> None: self.game = game - self.aircraft_inventory = {} # local copy of the airbase inventory + self.is_player = is_player - if from_cp.captured: - self.faction = self.game.player_faction - else: - self.faction = self.game.enemy_faction + def enemy_sams(self) -> Iterator[TheaterGroundObject]: + """Iterates over all enemy SAM sites.""" + # Control points might have the same ground object several times, for + # some reason. + found_targets: Set[str] = set() + for cp in self.enemy_control_points(): + for ground_object in cp.ground_objects: + if ground_object.name in found_targets: + continue - if "doctrine" in self.faction.keys(): - self.doctrine = self.faction["doctrine"] - else: - self.doctrine = MODERN_DOCTRINE + if ground_object.dcs_identifier != "AA": + continue + if not self.object_has_radar(ground_object): + continue - def reset(self): + # TODO: Yield in order of most threatening. + # Need to sort in order of how close their defensive range comes + # to friendly assets. To do that we need to add effective range + # information to the database. + yield ground_object + found_targets.add(ground_object.name) + + def threatening_sams(self) -> Iterator[TheaterGroundObject]: + """Iterates over enemy SAMs in threat range of friendly control points. + + SAM sites are sorted by their closest proximity to any friendly control + point (airfield or fleet). """ - Reset the planned flights and available units + sams: List[Tuple[TheaterGroundObject, int]] = [] + for sam in self.enemy_sams(): + ranges: List[int] = [] + for cp in self.friendly_control_points(): + ranges.append(sam.distance_to(cp)) + sams.append((sam, min(ranges))) + + sams = sorted(sams, key=operator.itemgetter(1)) + for sam, _range in sams: + yield sam + + def strike_targets(self) -> Iterator[TheaterGroundObject]: + """Iterates over enemy strike targets. + + Targets are sorted by their closest proximity to any friendly control + point (airfield or fleet). """ - self.aircraft_inventory = dict({k: v for k, v in self.from_cp.base.aircraft.items()}) - self.interceptor_flights = [] - self.cap_flights = [] - self.cas_flights = [] - self.strike_flights = [] - self.sead_flights = [] - self.custom_flights = [] - self.flights = [] - self.potential_sead_targets = [] - self.potential_strike_targets = [] + targets: List[Tuple[TheaterGroundObject, int]] = [] + # Control points might have the same ground object several times, for + # some reason. + found_targets: Set[str] = set() + for enemy_cp in self.enemy_control_points(): + for ground_object in enemy_cp.ground_objects: + if ground_object.name in found_targets: + continue + ranges: List[int] = [] + for friendly_cp in self.friendly_control_points(): + ranges.append(ground_object.distance_to(friendly_cp)) + targets.append((ground_object, min(ranges))) + found_targets.add(ground_object.name) + targets = sorted(targets, key=operator.itemgetter(1)) + for target, _range in targets: + yield target - def plan_flights(self): + @staticmethod + def object_has_radar(ground_object: TheaterGroundObject) -> bool: + """Returns True if the ground object contains a unit with radar.""" + for group in ground_object.groups: + for unit in group.units: + if db.unit_type_from_name(unit.type) in UNITS_WITH_RADAR: + return True + return False - self.reset() - self.compute_sead_targets() - self.compute_strike_targets() + def front_lines(self) -> Iterator[FrontLine]: + """Iterates over all active front lines in the theater.""" + for cp in self.friendly_control_points(): + for connected in cp.connected_points: + if connected.is_friendly(self.is_player): + continue - # The priority is to assign air-superiority fighter or interceptor to interception roles, so they can scramble if there is an attacker - # self.commision_interceptors() + if Conflict.has_frontline_between(cp, connected): + yield FrontLine(cp, connected) - # Then some CAP patrol for the next 2 hours - self.commision_cap() + def vulnerable_control_points(self) -> Iterator[ControlPoint]: + """Iterates over friendly CPs that are vulnerable to enemy CPs. - # Then setup cas - self.commision_cas() + Vulnerability is defined as any enemy CP within threat range of of the + CP. + """ + for cp in self.friendly_control_points(): + airfields_in_proximity = self.closest_airfields_to(cp) + airfields_in_threat_range = airfields_in_proximity.airfields_within( + self.AIRFIELD_THREAT_RANGE + ) + for airfield in airfields_in_threat_range: + if not airfield.is_friendly(self.is_player): + yield cp + break - # Then prepare some sead flights if required - self.commision_sead() + def friendly_control_points(self) -> Iterator[ControlPoint]: + """Iterates over all friendly control points.""" + return (c for c in self.game.theater.controlpoints if + c.is_friendly(self.is_player)) - self.commision_strike() + def enemy_control_points(self) -> Iterator[ControlPoint]: + """Iterates over all enemy control points.""" + return (c for c in self.game.theater.controlpoints if + not c.is_friendly(self.is_player)) - # TODO : commision ANTISHIP + def all_possible_targets(self) -> Iterator[MissionTarget]: + """Iterates over all possible mission targets in the theater. - def remove_flight(self, index): - try: - flight = self.flights[index] - if flight in self.interceptor_flights: self.interceptor_flights.remove(flight) - if flight in self.cap_flights: self.cap_flights.remove(flight) - if flight in self.cas_flights: self.cas_flights.remove(flight) - if flight in self.strike_flights: self.strike_flights.remove(flight) - if flight in self.sead_flights: self.sead_flights.remove(flight) - if flight in self.custom_flights: self.custom_flights.remove(flight) - self.flights.remove(flight) - except IndexError: + Valid mission targets are control points (airfields and carriers), front + lines, and ground objects (SAM sites, factories, resource extraction + sites, etc). + """ + for cp in self.game.theater.controlpoints: + yield cp + yield from cp.ground_objects + yield from self.front_lines() + + @staticmethod + def closest_airfields_to(location: MissionTarget) -> ClosestAirfields: + """Returns the closest airfields to the given location.""" + return ObjectiveDistanceCache.get_closest_airfields(location) + + +class CoalitionMissionPlanner: + """Coalition flight planning AI. + + This class is responsible for automatically planning missions for the + coalition at the start of the turn. + + The primary goal of the mission planner is to protect existing friendly + assets. Missions will be planned with the following priorities: + + 1. CAP for airfields/fleets in close proximity to the enemy to prevent heavy + losses of friendly aircraft. + 2. CAP for front line areas to protect ground and CAS units. + 3. DEAD to reduce necessity of SEAD for future missions. + 4. CAS to protect friendly ground units. + 5. Strike missions to reduce the enemy's resources. + + TODO: Anti-ship and airfield strikes to reduce enemy sortie rates. + TODO: BAI to prevent enemy forces from reaching the front line. + TODO: Should fleets always have a CAP? + + TODO: Stance and doctrine-specific planning behavior. + """ + + # TODO: Merge into doctrine, also limit by aircraft. + MAX_CAP_RANGE = nm_to_meter(100) + MAX_CAS_RANGE = nm_to_meter(50) + MAX_SEAD_RANGE = nm_to_meter(150) + MAX_STRIKE_RANGE = nm_to_meter(150) + + NON_CAP_MIN_DELAY = 1 + NON_CAP_MAX_DELAY = 5 + + def __init__(self, game: Game, is_player: bool) -> None: + self.game = game + self.is_player = is_player + self.objective_finder = ObjectiveFinder(self.game, self.is_player) + self.ato = self.game.blue_ato if is_player else self.game.red_ato + + def propose_missions(self) -> Iterator[ProposedMission]: + """Identifies and iterates over potential mission in priority order.""" + # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP. + for cp in self.objective_finder.vulnerable_control_points(): + yield ProposedMission(cp, [ + ProposedFlight(FlightType.CAP, 2, self.MAX_CAP_RANGE), + ]) + + # Find front lines, plan CAP. + for front_line in self.objective_finder.front_lines(): + yield ProposedMission(front_line, [ + ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE), + ProposedFlight(FlightType.CAS, 2, self.MAX_CAS_RANGE), + ]) + + # Find enemy SAM sites with ranges that cover friendly CPs, front lines, + # or objects, plan DEAD. + # Find enemy SAM sites with ranges that extend to within 50 nmi of + # friendly CPs, front, lines, or objects, plan DEAD. + for sam in self.objective_finder.threatening_sams(): + yield ProposedMission(sam, [ + ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE), + # TODO: Max escort range. + ProposedFlight(FlightType.ESCORT, 2, self.MAX_SEAD_RANGE), + ]) + + # Plan strike missions. + for target in self.objective_finder.strike_targets(): + yield ProposedMission(target, [ + ProposedFlight(FlightType.STRIKE, 2, self.MAX_STRIKE_RANGE), + # TODO: Max escort range. + ProposedFlight(FlightType.SEAD, 2, self.MAX_STRIKE_RANGE), + ProposedFlight(FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE), + ]) + + def plan_missions(self) -> None: + """Identifies and plans mission for the turn.""" + for proposed_mission in self.propose_missions(): + self.plan_mission(proposed_mission) + + self.stagger_missions() + + for cp in self.objective_finder.friendly_control_points(): + inventory = self.game.aircraft_inventory.for_control_point(cp) + for aircraft, available in inventory.all_aircraft: + self.message("Unused aircraft", + f"{available} {aircraft.id} from {cp}") + + def plan_mission(self, mission: ProposedMission) -> None: + """Allocates aircraft for a proposed mission and adds it to the ATO.""" + builder = PackageBuilder( + mission.location, + self.objective_finder.closest_airfields_to(mission.location), + self.game.aircraft_inventory, + self.is_player + ) + + missing_types: Set[FlightType] = set() + for proposed_flight in mission.flights: + if not builder.plan_flight(proposed_flight): + missing_types.add(proposed_flight.task) + + if missing_types: + missing_types_str = ", ".join( + sorted([t.name for t in missing_types])) + builder.release_planned_aircraft() + self.message( + "Insufficient aircraft", + f"Not enough aircraft in range for {mission.location.name} " + f"capable of: {missing_types_str}") return - - def commision_interceptors(self): - """ - Pick some aircraft to assign them to interception roles - """ - - # At least try to generate one interceptor group - number_of_interceptor_groups = min(max(sum([v for k, v in self.aircraft_inventory.items()]) / 4, self.doctrine["MAX_NUMBER_OF_INTERCEPTION_GROUP"]), 1) - possible_interceptors = [k for k in self.aircraft_inventory.keys() if k in INTERCEPT_CAPABLE] - - if len(possible_interceptors) <= 0: - possible_interceptors = [k for k,v in self.aircraft_inventory.items() if k in CAP_CAPABLE and v >= 2] - - if number_of_interceptor_groups > 0: - inventory = dict({k: v for k, v in self.aircraft_inventory.items() if k in possible_interceptors}) - for i in range(number_of_interceptor_groups): - try: - unit = random.choice([k for k,v in inventory.items() if v >= 2]) - except IndexError: - break - inventory[unit] = inventory[unit] - 2 - flight = Flight(unit, 2, self.from_cp, FlightType.INTERCEPTION) - flight.scheduled_in = 1 - flight.points = [] - - self.interceptor_flights.append(flight) - self.flights.append(flight) - - # Update inventory - for k, v in inventory.items(): - self.aircraft_inventory[k] = v - - def commision_cap(self): - """ - Pick some aircraft to assign them to defensive CAP roles (BARCAP) - """ - - possible_aircraft = [k for k, v in self.aircraft_inventory.items() if k in CAP_CAPABLE and v >= 2] - inventory = dict({k: v for k, v in self.aircraft_inventory.items() if k in possible_aircraft}) - - offset = random.randint(0,5) - for i in range(int(MISSION_DURATION/self.doctrine["CAP_EVERY_X_MINUTES"])): - - try: - unit = random.choice([k for k, v in inventory.items() if v >= 2]) - except IndexError: - break - - inventory[unit] = inventory[unit] - 2 - flight = Flight(unit, 2, self.from_cp, FlightType.CAP) - - flight.points = [] - flight.scheduled_in = offset + i*random.randint(self.doctrine["CAP_EVERY_X_MINUTES"] - 5, self.doctrine["CAP_EVERY_X_MINUTES"] + 5) - - if len(self._get_cas_locations()) > 0: - enemy_cp = random.choice(self._get_cas_locations()) - self.generate_frontline_cap(flight, flight.from_cp, enemy_cp) - else: - self.generate_barcap(flight, flight.from_cp) - - self.cap_flights.append(flight) - self.flights.append(flight) - - # Update inventory - for k, v in inventory.items(): - self.aircraft_inventory[k] = v - - def commision_cas(self): - """ - Pick some aircraft to assign them to CAS - """ - - possible_aircraft = [k for k, v in self.aircraft_inventory.items() if k in CAS_CAPABLE and v >= 2] - inventory = dict({k: v for k, v in self.aircraft_inventory.items() if k in possible_aircraft}) - cas_location = self._get_cas_locations() - - if len(cas_location) > 0: - - offset = random.randint(0,5) - for i in range(int(MISSION_DURATION/self.doctrine["CAS_EVERY_X_MINUTES"])): - - try: - unit = random.choice([k for k, v in inventory.items() if v >= 2]) - except IndexError: - break - - inventory[unit] = inventory[unit] - 2 - flight = Flight(unit, 2, self.from_cp, FlightType.CAS) - flight.points = [] - flight.scheduled_in = offset + i * random.randint(self.doctrine["CAS_EVERY_X_MINUTES"] - 5, self.doctrine["CAS_EVERY_X_MINUTES"] + 5) - location = random.choice(cas_location) - - self.generate_cas(flight, flight.from_cp, location) - - self.cas_flights.append(flight) - self.flights.append(flight) - - # Update inventory - for k, v in inventory.items(): - self.aircraft_inventory[k] = v - - def commision_sead(self): - """ - Pick some aircraft to assign them to SEAD tasks - """ - - possible_aircraft = [k for k, v in self.aircraft_inventory.items() if k in SEAD_CAPABLE and v >= 2] - inventory = dict({k: v for k, v in self.aircraft_inventory.items() if k in possible_aircraft}) - - if len(self.potential_sead_targets) > 0: - - offset = random.randint(0,5) - for i in range(int(MISSION_DURATION/self.doctrine["SEAD_EVERY_X_MINUTES"])): - - if len(self.potential_sead_targets) <= 0: - break - - try: - unit = random.choice([k for k, v in inventory.items() if v >= 2]) - except IndexError: - break - - inventory[unit] = inventory[unit] - 2 - flight = Flight(unit, 2, self.from_cp, random.choice([FlightType.SEAD, FlightType.DEAD])) - - flight.points = [] - flight.scheduled_in = offset + i*random.randint(self.doctrine["SEAD_EVERY_X_MINUTES"] - 5, self.doctrine["SEAD_EVERY_X_MINUTES"] + 5) - - location = self.potential_sead_targets[0][0] - self.potential_sead_targets.pop(0) - - self.generate_sead(flight, location, []) - - self.sead_flights.append(flight) - self.flights.append(flight) - - # Update inventory - for k, v in inventory.items(): - self.aircraft_inventory[k] = v - - - def commision_strike(self): - """ - Pick some aircraft to assign them to STRIKE tasks - """ - possible_aircraft = [k for k, v in self.aircraft_inventory.items() if k in STRIKE_CAPABLE and v >= 2] - inventory = dict({k: v for k, v in self.aircraft_inventory.items() if k in possible_aircraft}) - - if len(self.potential_strike_targets) > 0: - - offset = random.randint(0,5) - for i in range(int(MISSION_DURATION/self.doctrine["STRIKE_EVERY_X_MINUTES"])): - - if len(self.potential_strike_targets) <= 0: - break - - try: - unit = random.choice([k for k, v in inventory.items() if v >= 2]) - except IndexError: - break - - if unit in DRONES: - count = 1 - else: - count = 2 - - inventory[unit] = inventory[unit] - count - flight = Flight(unit, count, self.from_cp, FlightType.STRIKE) - - flight.points = [] - flight.scheduled_in = offset + i*random.randint(self.doctrine["STRIKE_EVERY_X_MINUTES"] - 5, self.doctrine["STRIKE_EVERY_X_MINUTES"] + 5) - - location = self.potential_strike_targets[0][0] - self.potential_strike_targets.pop(0) - - self.generate_strike(flight, location) - - self.strike_flights.append(flight) - self.flights.append(flight) - - # Update inventory - for k, v in inventory.items(): - self.aircraft_inventory[k] = v - - def _get_cas_locations(self): - return self._get_cas_locations_for_cp(self.from_cp) - - def _get_cas_locations_for_cp(self, for_cp): - cas_locations = [] - for cp in for_cp.connected_points: - if cp.captured != for_cp.captured: - cas_locations.append(cp) - return cas_locations - - def compute_strike_targets(self): - """ - @return a list of potential strike targets in range - """ - - # target, distance - self.potential_strike_targets = [] - - for cp in [c for c in self.game.theater.controlpoints if c.captured != self.from_cp.captured]: - - # Compute distance to current cp - distance = math.hypot(cp.position.x - self.from_cp.position.x, - cp.position.y - self.from_cp.position.y) - - if distance > 2*self.doctrine["STRIKE_MAX_RANGE"]: - # Then it's unlikely any child ground object is in range - return - - added_group = [] - for g in cp.ground_objects: - if g.group_id in added_group or g.is_dead: continue - - # Compute distance to current cp - distance = math.hypot(cp.position.x - self.from_cp.position.x, - cp.position.y - self.from_cp.position.y) - - if distance < self.doctrine["SEAD_MAX_RANGE"]: - self.potential_strike_targets.append((g, distance)) - added_group.append(g) - - self.potential_strike_targets.sort(key=operator.itemgetter(1)) - - def compute_sead_targets(self): - """ - @return a list of potential sead targets in range - """ - - # target, distance - self.potential_sead_targets = [] - - for cp in [c for c in self.game.theater.controlpoints if c.captured != self.from_cp.captured]: - - # Compute distance to current cp - distance = math.hypot(cp.position.x - self.from_cp.position.x, - cp.position.y - self.from_cp.position.y) - - # Then it's unlikely any ground object is range - if distance > 2*self.doctrine["SEAD_MAX_RANGE"]: - return - - for g in cp.ground_objects: - - if g.dcs_identifier == "AA": - - # Check that there is at least one unit with a radar in the ground objects unit groups - number_of_units = sum([len([r for r in group.units if db.unit_type_from_name(r.type) in UNITS_WITH_RADAR]) for group in g.groups]) - if number_of_units <= 0: - continue - - # Compute distance to current cp - distance = math.hypot(cp.position.x - self.from_cp.position.x, - cp.position.y - self.from_cp.position.y) - - if distance < self.doctrine["SEAD_MAX_RANGE"]: - self.potential_sead_targets.append((g, distance)) - - self.potential_sead_targets.sort(key=operator.itemgetter(1)) - - def __repr__(self): - return "-"*40 + "\n" + self.from_cp.name + " planned flights :\n"\ - + "-"*40 + "\n" + "\n".join([repr(f) for f in self.flights]) + "\n" + "-"*40 - - def get_available_aircraft(self): - base_aircraft_inventory = dict({k: v for k, v in self.from_cp.base.aircraft.items()}) - for f in self.flights: - if f.unit_type in base_aircraft_inventory.keys(): - base_aircraft_inventory[f.unit_type] = base_aircraft_inventory[f.unit_type] - f.count - if base_aircraft_inventory[f.unit_type] <= 0: - del base_aircraft_inventory[f.unit_type] - return base_aircraft_inventory - - - def generate_strike(self, flight, location): - - flight.flight_type = FlightType.STRIKE - ascend = self.generate_ascend_point(flight.from_cp) - flight.points.append(ascend) - - heading = flight.from_cp.position.heading_between_point(location.position) - ingress_heading = heading - 180 + 25 - egress_heading = heading - 180 - 25 - - ingress_pos = location.position.point_from_heading(ingress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"]) - ingress_point = FlightWaypoint( - FlightWaypointType.INGRESS_STRIKE, - ingress_pos.x, - ingress_pos.y, - self.doctrine["INGRESS_ALT"] + package = builder.build() + flight_plan_builder = FlightPlanBuilder(self.game, package, + self.is_player) + for flight in package.flights: + flight_plan_builder.populate_flight_plan(flight) + self.ato.add_package(package) + + def stagger_missions(self) -> None: + def start_time_generator(count: int, earliest: int, latest: int, + margin: int) -> Iterator[int]: + interval = latest // count + for time in range(earliest, latest, interval): + error = random.randint(-margin, margin) + yield max(0, time + error) + + dca_types = ( + FlightType.BARCAP, + FlightType.CAP, + FlightType.INTERCEPTION, ) - ingress_point.pretty_name = "INGRESS on " + location.obj_name - ingress_point.description = "INGRESS on " + location.obj_name - ingress_point.name = "INGRESS" - flight.points.append(ingress_point) - if len(location.groups) > 0 and location.dcs_identifier == "AA": - for g in location.groups: - for j, u in enumerate(g.units): - point = FlightWaypoint( - FlightWaypointType.TARGET_POINT, - u.position.x, - u.position.y, - 0 - ) - point.description = "STRIKE " + "[" + str(location.obj_name) + "] : " + u.type + " #" + str(j) - point.pretty_name = "STRIKE " + "[" + str(location.obj_name) + "] : " + u.type + " #" + str(j) - point.name = location.obj_name + "#" + str(j) - point.only_for_player = True - ingress_point.targets.append(location) - flight.points.append(point) - else: - if hasattr(location, "obj_name"): - buildings = self.game.theater.find_ground_objects_by_obj_name(location.obj_name) - print(buildings) - for building in buildings: - print("BUILDING " + str(building.is_dead) + " " + str(building.dcs_identifier)) - if building.is_dead: - continue + non_dca_packages = [p for p in self.ato.packages if + p.primary_task not in dca_types] - point = FlightWaypoint( - FlightWaypointType.TARGET_POINT, - building.position.x, - building.position.y, - 0 - ) - point.description = "STRIKE on " + building.obj_name + " " + building.category + " [" + str(building.dcs_identifier) + " ]" - point.pretty_name = "STRIKE on " + building.obj_name + " " + building.category + " [" + str(building.dcs_identifier) + " ]" - point.name = building.obj_name - point.only_for_player = True - ingress_point.targets.append(building) - flight.points.append(point) - else: - point = FlightWaypoint( - FlightWaypointType.TARGET_GROUP_LOC, - location.position.x, - location.position.y, - 0 - ) - point.description = "STRIKE on " + location.obj_name - point.pretty_name = "STRIKE on " + location.obj_name - point.name = location.obj_name - point.only_for_player = True - ingress_point.targets.append(location) - flight.points.append(point) - - egress_pos = location.position.point_from_heading(egress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"]) - egress_point = FlightWaypoint( - FlightWaypointType.EGRESS, - egress_pos.x, - egress_pos.y, - self.doctrine["EGRESS_ALT"] + start_time = start_time_generator( + count=len(non_dca_packages), + earliest=5, + latest=90, + margin=5 ) - egress_point.name = "EGRESS" - egress_point.pretty_name = "EGRESS from " + location.obj_name - egress_point.description = "EGRESS from " + location.obj_name - flight.points.append(egress_point) + for package in non_dca_packages: + package.delay = next(start_time) + for flight in package.flights: + flight.scheduled_in = package.delay - descend = self.generate_descend_point(flight.from_cp) - flight.points.append(descend) + def message(self, title, text) -> None: + """Emits a planning message to the player. - rtb = self.generate_rtb_waypoint(flight.from_cp) - flight.points.append(rtb) - - def generate_barcap(self, flight, for_cp): + If the mission planner belongs to the players coalition, this emits a + message to the info panel. """ - Generate a barcap flight at a given location - :param flight: Flight to setup - :param for_cp: CP to protect - """ - flight.flight_type = FlightType.BARCAP if for_cp.is_carrier else FlightType.CAP - patrol_alt = random.randint(self.doctrine["PATROL_ALT_RANGE"][0], self.doctrine["PATROL_ALT_RANGE"][1]) - - if len(for_cp.ground_objects) > 0: - loc = random.choice(for_cp.ground_objects) - hdg = for_cp.position.heading_between_point(loc.position) - radius = random.randint(self.doctrine["CAP_PATTERN_LENGTH"][0], self.doctrine["CAP_PATTERN_LENGTH"][1]) - orbit0p = loc.position.point_from_heading(hdg - 90, radius) - orbit1p = loc.position.point_from_heading(hdg + 90, radius) - else: - loc = for_cp.position.point_from_heading(random.randint(0, 360), random.randint(self.doctrine["CAP_DISTANCE_FROM_CP"][0], self.doctrine["CAP_DISTANCE_FROM_CP"][1])) - hdg = for_cp.position.heading_between_point(loc) - radius = random.randint(self.doctrine["CAP_PATTERN_LENGTH"][0], self.doctrine["CAP_PATTERN_LENGTH"][1]) - orbit0p = loc.point_from_heading(hdg - 90, radius) - orbit1p = loc.point_from_heading(hdg + 90, radius) - - # Create points - ascend = self.generate_ascend_point(flight.from_cp) - flight.points.append(ascend) - - orbit0 = FlightWaypoint( - FlightWaypointType.PATROL_TRACK, - orbit0p.x, - orbit0p.y, - patrol_alt - ) - orbit0.name = "ORBIT 0" - orbit0.description = "Standby between this point and the next one" - orbit0.pretty_name = "Race-track start" - flight.points.append(orbit0) - - orbit1 = FlightWaypoint( - FlightWaypointType.PATROL, - orbit1p.x, - orbit1p.y, - patrol_alt - ) - orbit1.name = "ORBIT 1" - orbit1.description = "Standby between this point and the previous one" - orbit1.pretty_name = "Race-track end" - flight.points.append(orbit1) - - orbit0.targets.append(for_cp) - obj_added = [] - for ground_object in for_cp.ground_objects: - if ground_object.obj_name not in obj_added and not ground_object.airbase_group: - orbit0.targets.append(ground_object) - obj_added.append(ground_object.obj_name) - - descend = self.generate_descend_point(flight.from_cp) - flight.points.append(descend) - - rtb = self.generate_rtb_waypoint(flight.from_cp) - flight.points.append(rtb) - - - def generate_frontline_cap(self, flight, ally_cp, enemy_cp): - """ - Generate a cap flight for the frontline between ally_cp and enemy cp in order to ensure air superiority and - protect friendly CAP airbase - :param flight: Flight to setup - :param ally_cp: CP to protect - :param enemy_cp: Enemy connected cp - """ - flight.flight_type = FlightType.CAP - patrol_alt = random.randint(self.doctrine["PATROL_ALT_RANGE"][0], self.doctrine["PATROL_ALT_RANGE"][1]) - - # Find targets waypoints - ingress, heading, distance = Conflict.frontline_vector(ally_cp, enemy_cp, self.game.theater) - center = ingress.point_from_heading(heading, distance / 2) - orbit_center = center.point_from_heading(heading - 90, random.randint(nm_to_meter(6), nm_to_meter(15))) - - combat_width = distance / 2 - if combat_width > 500000: - combat_width = 500000 - if combat_width < 35000: - combat_width = 35000 - - radius = combat_width*1.25 - orbit0p = orbit_center.point_from_heading(heading, radius) - orbit1p = orbit_center.point_from_heading(heading + 180, radius) - - # Create points - ascend = self.generate_ascend_point(flight.from_cp) - flight.points.append(ascend) - - orbit0 = FlightWaypoint( - FlightWaypointType.PATROL_TRACK, - orbit0p.x, - orbit0p.y, - patrol_alt - ) - orbit0.name = "ORBIT 0" - orbit0.description = "Standby between this point and the next one" - orbit0.pretty_name = "Race-track start" - flight.points.append(orbit0) - - orbit1 = FlightWaypoint( - FlightWaypointType.PATROL, - orbit1p.x, - orbit1p.y, - patrol_alt - ) - orbit1.name = "ORBIT 1" - orbit1.description = "Standby between this point and the previous one" - orbit1.pretty_name = "Race-track end" - flight.points.append(orbit1) - - # Note : Targets of a PATROL TRACK waypoints are the points to be defended - orbit0.targets.append(flight.from_cp) - orbit0.targets.append(center) - - descend = self.generate_descend_point(flight.from_cp) - flight.points.append(descend) - - rtb = self.generate_rtb_waypoint(flight.from_cp) - flight.points.append(rtb) - - - def generate_sead(self, flight, location, custom_targets = []): - """ - Generate a sead flight at a given location - :param flight: Flight to setup - :param location: Location of the SEAD target - :param custom_targets: Custom targets if any - """ - flight.points = [] - flight.flight_type = random.choice([FlightType.SEAD, FlightType.DEAD]) - - ascend = self.generate_ascend_point(flight.from_cp) - flight.points.append(ascend) - - heading = flight.from_cp.position.heading_between_point(location.position) - ingress_heading = heading - 180 + 25 - egress_heading = heading - 180 - 25 - - ingress_pos = location.position.point_from_heading(ingress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"]) - ingress_point = FlightWaypoint( - FlightWaypointType.INGRESS_SEAD, - ingress_pos.x, - ingress_pos.y, - self.doctrine["INGRESS_ALT"] - ) - ingress_point.name = "INGRESS" - ingress_point.pretty_name = "INGRESS on " + location.obj_name - ingress_point.description = "INGRESS on " + location.obj_name - flight.points.append(ingress_point) - - if len(custom_targets) > 0: - for target in custom_targets: - point = FlightWaypoint( - FlightWaypointType.TARGET_POINT, - target.position.x, - target.position.y, - 0 - ) - point.alt_type = "RADIO" - if flight.flight_type == FlightType.DEAD: - point.description = "DEAD on " + target.type - point.pretty_name = "DEAD on " + location.obj_name - point.only_for_player = True - else: - point.description = "SEAD on " + location.obj_name - point.pretty_name = "SEAD on " + location.obj_name - point.only_for_player = True - flight.points.append(point) - ingress_point.targets.append(location) - ingress_point.targetGroup = location - else: - point = FlightWaypoint( - FlightWaypointType.TARGET_GROUP_LOC, - location.position.x, - location.position.y, - 0 + if self.is_player: + self.game.informations.append( + Information(title, text, self.game.turn) ) - point.alt_type = "RADIO" - if flight.flight_type == FlightType.DEAD: - point.description = "DEAD on " + location.obj_name - point.pretty_name = "DEAD on " + location.obj_name - point.only_for_player = True - else: - point.description = "SEAD on " + location.obj_name - point.pretty_name = "SEAD on " + location.obj_name - point.only_for_player = True - ingress_point.targets.append(location) - ingress_point.targetGroup = location - flight.points.append(point) - - egress_pos = location.position.point_from_heading(egress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"]) - egress_point = FlightWaypoint( - FlightWaypointType.EGRESS, - egress_pos.x, - egress_pos.y, - self.doctrine["EGRESS_ALT"] - ) - egress_point.name = "EGRESS" - egress_point.pretty_name = "EGRESS from " + location.obj_name - egress_point.description = "EGRESS from " + location.obj_name - flight.points.append(egress_point) - - descend = self.generate_descend_point(flight.from_cp) - flight.points.append(descend) - - rtb = self.generate_rtb_waypoint(flight.from_cp) - flight.points.append(rtb) - - - def generate_cas(self, flight, from_cp, location): - """ - Generate a CAS flight at a given location - :param flight: Flight to setup - :param location: Location of the CAS targets - """ - is_helo = hasattr(flight.unit_type, "helicopter") and flight.unit_type.helicopter - cap_alt = 1000 - flight.points = [] - flight.flight_type = FlightType.CAS - - ingress, heading, distance = Conflict.frontline_vector(from_cp, location, self.game.theater) - center = ingress.point_from_heading(heading, distance / 2) - egress = ingress.point_from_heading(heading, distance) - - ascend = self.generate_ascend_point(flight.from_cp) - if is_helo: - cap_alt = 500 - ascend.alt = 500 - flight.points.append(ascend) - - ingress_point = FlightWaypoint( - FlightWaypointType.INGRESS_CAS, - ingress.x, - ingress.y, - cap_alt - ) - ingress_point.alt_type = "RADIO" - ingress_point.name = "INGRESS" - ingress_point.pretty_name = "INGRESS" - ingress_point.description = "Ingress into CAS area" - flight.points.append(ingress_point) - - center_point = FlightWaypoint( - FlightWaypointType.CAS, - center.x, - center.y, - cap_alt - ) - center_point.alt_type = "RADIO" - center_point.description = "Provide CAS" - center_point.name = "CAS" - center_point.pretty_name = "CAS" - flight.points.append(center_point) - - egress_point = FlightWaypoint( - FlightWaypointType.EGRESS, - egress.x, - egress.y, - cap_alt - ) - egress_point.alt_type = "RADIO" - egress_point.description = "Egress from CAS area" - egress_point.name = "EGRESS" - egress_point.pretty_name = "EGRESS" - flight.points.append(egress_point) - - descend = self.generate_descend_point(flight.from_cp) - if is_helo: - descend.alt = 300 - flight.points.append(descend) - - rtb = self.generate_rtb_waypoint(flight.from_cp) - flight.points.append(rtb) - - def generate_ascend_point(self, from_cp): - """ - Generate ascend point - :param from_cp: Airport you're taking off from - :return: - """ - ascend_heading = from_cp.heading - pos_ascend = from_cp.position.point_from_heading(ascend_heading, 10000) - ascend = FlightWaypoint( - FlightWaypointType.ASCEND_POINT, - pos_ascend.x, - pos_ascend.y, - self.doctrine["PATTERN_ALTITUDE"] - ) - ascend.name = "ASCEND" - ascend.alt_type = "RADIO" - ascend.description = "Ascend" - ascend.pretty_name = "Ascend" - return ascend - - def generate_descend_point(self, from_cp): - """ - Generate approach/descend point - :param from_cp: Airport you're landing at - :return: - """ - ascend_heading = from_cp.heading - descend = from_cp.position.point_from_heading(ascend_heading - 180, 10000) - descend = FlightWaypoint( - FlightWaypointType.DESCENT_POINT, - descend.x, - descend.y, - self.doctrine["PATTERN_ALTITUDE"] - ) - descend.name = "DESCEND" - descend.alt_type = "RADIO" - descend.description = "Descend to pattern alt" - descend.pretty_name = "Descend to pattern alt" - return descend - - def generate_rtb_waypoint(self, from_cp): - """ - Generate RTB landing point - :param from_cp: Airport you're landing at - :return: - """ - rtb = from_cp.position - rtb = FlightWaypoint( - FlightWaypointType.LANDING_POINT, - rtb.x, - rtb.y, - 0 - ) - rtb.name = "LANDING" - rtb.alt_type = "RADIO" - rtb.description = "RTB" - rtb.pretty_name = "RTB" - return rtb + else: + logging.info(f"{title}: {text}") diff --git a/gen/flights/ai_flight_planner_db.py b/gen/flights/ai_flight_planner_db.py index e1393a1a..715a7f66 100644 --- a/gen/flights/ai_flight_planner_db.py +++ b/gen/flights/ai_flight_planner_db.py @@ -1,5 +1,78 @@ -from dcs.planes import * -from dcs.helicopters import * +from dcs.helicopters import ( + AH_1W, + AH_64A, + AH_64D, + Ka_50, + Mi_24V, + Mi_28N, + Mi_8MT, + OH_58D, + SA342L, + SA342M, + UH_1H, +) +from dcs.planes import ( + AJS37, + AV8BNA, + A_10A, + A_10C, + A_10C_2, + A_20G, + B_17G, + Bf_109K_4, + C_101CC, + FA_18C_hornet, + FW_190A8, + FW_190D9, + F_14B, + F_15C, + F_15E, + F_16A, + F_16C_50, + F_4E, + F_5E_3, + F_86F_Sabre, + F_A_18C, + JF_17, + J_11A, + Ju_88A4, + L_39ZA, + MQ_9_Reaper, + M_2000C, + MiG_15bis, + MiG_19P, + MiG_21Bis, + MiG_23MLD, + MiG_25PD, + MiG_27K, + MiG_29A, + MiG_29G, + MiG_29K, + MiG_29S, + MiG_31, + Mirage_2000_5, + P_47D_30, + P_47D_30bl1, + P_47D_40, + P_51D, + P_51D_30_NA, + RQ_1A_Predator, + SpitfireLFMkIX, + SpitfireLFMkIXCW, + Su_17M4, + Su_24M, + Su_24MR, + Su_25, + Su_25T, + Su_25TM, + Su_27, + Su_30, + Su_33, + Su_34, + Tornado_GR4, + Tornado_IDS, + WingLoong_I, +) # Interceptor are the aircraft prioritized for interception tasks # If none is available, the AI will use regular CAP-capable aircraft instead @@ -7,6 +80,10 @@ from pydcs_extensions.a4ec.a4ec import A_4E_C from pydcs_extensions.mb339.mb339 import MB_339PAN from pydcs_extensions.rafale.rafale import Rafale_A_S, Rafale_M +# TODO: These lists really ought to be era (faction) dependent. +# Factions which have F-5s, F-86s, and A-4s will should prefer F-5s for CAP, but +# factions that also have F-4s should not. + INTERCEPT_CAPABLE = [ MiG_21Bis, MiG_25PD, @@ -77,6 +154,42 @@ CAP_CAPABLE = [ Rafale_M, ] +CAP_PREFERRED = [ + MiG_15bis, + MiG_19P, + MiG_21Bis, + MiG_23MLD, + MiG_25PD, + MiG_29A, + MiG_29G, + MiG_29S, + MiG_31, + + Su_27, + J_11A, + Su_30, + Su_33, + + M_2000C, + Mirage_2000_5, + + F_86F_Sabre, + F_14B, + F_15C, + + P_51D_30_NA, + P_51D, + + SpitfireLFMkIXCW, + SpitfireLFMkIX, + + Bf_109K_4, + FW_190D9, + FW_190A8, + + Rafale_M, +] + # Used for CAS (Close air support) and BAI (Battlefield Interdiction) CAS_CAPABLE = [ @@ -155,6 +268,59 @@ CAS_CAPABLE = [ RQ_1A_Predator ] +CAS_PREFERRED = [ + Su_17M4, + Su_24M, + Su_24MR, + Su_25, + Su_25T, + Su_25TM, + Su_34, + + JF_17, + + A_10A, + A_10C, + A_10C_2, + AV8BNA, + + F_15E, + + Tornado_GR4, + + C_101CC, + MB_339PAN, + L_39ZA, + AJS37, + + SA342M, + SA342L, + OH_58D, + + AH_64A, + AH_64D, + AH_1W, + + UH_1H, + + Mi_8MT, + Mi_28N, + Mi_24V, + Ka_50, + + P_47D_30, + P_47D_30bl1, + P_47D_40, + A_20G, + + A_4E_C, + Rafale_A_S, + + WingLoong_I, + MQ_9_Reaper, + RQ_1A_Predator +] + # Aircraft used for SEAD / DEAD tasks SEAD_CAPABLE = [ F_4E, @@ -179,6 +345,12 @@ SEAD_CAPABLE = [ Rafale_A_S ] +SEAD_PREFERRED = [ + F_4E, + Su_25T, + Tornado_IDS, +] + # Aircraft used for Strike mission STRIKE_CAPABLE = [ MiG_15bis, @@ -236,6 +408,15 @@ STRIKE_CAPABLE = [ ] +STRIKE_PREFERRED = [ + AJS37, + F_15E, + Tornado_GR4, + + A_20G, + B_17G, +] + ANTISHIP_CAPABLE = [ Su_24M, Su_17M4, diff --git a/gen/flights/closestairfields.py b/gen/flights/closestairfields.py new file mode 100644 index 00000000..a6045dde --- /dev/null +++ b/gen/flights/closestairfields.py @@ -0,0 +1,51 @@ +"""Objective adjacency lists.""" +from typing import Dict, Iterator, List, Optional + +from theater import ConflictTheater, ControlPoint, MissionTarget + + +class ClosestAirfields: + """Precalculates which control points are closes to the given target.""" + + def __init__(self, target: MissionTarget, + all_control_points: List[ControlPoint]) -> None: + self.target = target + self.closest_airfields: List[ControlPoint] = sorted( + all_control_points, key=lambda c: self.target.distance_to(c) + ) + + def airfields_within(self, meters: int) -> 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) < meters: + yield cp + else: + break + + +class ObjectiveDistanceCache: + theater: Optional[ConflictTheater] = None + closest_airfields: Dict[str, ClosestAirfields] = {} + + @classmethod + def set_theater(cls, theater: ConflictTheater) -> None: + if cls.theater is not None: + cls.closest_airfields = {} + cls.theater = theater + + @classmethod + def get_closest_airfields(cls, location: MissionTarget) -> ClosestAirfields: + if cls.theater is None: + raise RuntimeError( + "Call ObjectiveDistanceCache.set_theater before using" + ) + + if location.name not in cls.closest_airfields: + cls.closest_airfields[location.name] = ClosestAirfields( + location, cls.theater.controlpoints + ) + return cls.closest_airfields[location.name] diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 0c5c7956..90b27ccf 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -1,10 +1,10 @@ from enum import Enum -from typing import List +from typing import Dict, Optional from game import db from dcs.unittype import UnitType from dcs.point import MovingPoint, PointAction -from theater.controlpoint import ControlPoint +from theater.controlpoint import ControlPoint, MissionTarget class FlightType(Enum): @@ -47,6 +47,8 @@ class FlightWaypointType(Enum): TARGET_GROUP_LOC = 13 # A target group approximate location TARGET_SHIP = 14 # A target ship known location CUSTOM = 15 # User waypoint (no specific behaviour) + JOIN = 16 + SPLIT = 17 class PredefinedWaypointCategory(Enum): @@ -71,8 +73,8 @@ class FlightWaypoint: self.alt_type = "BARO" self.name = "" self.description = "" - self.targets = [] - self.targetGroup = None + self.targets: List[MissionTarget] = [] + self.targetGroup: Optional[MissionTarget] = None self.obj_name = "" self.pretty_name = "" self.category: PredefinedWaypointCategory = PredefinedWaypointCategory.NOT_PREDEFINED @@ -108,15 +110,9 @@ class FlightWaypoint: class Flight: - unit_type: UnitType = None - from_cp = None - points: List[FlightWaypoint] = [] - flight_type: FlightType = None count: int = 0 client_count: int = 0 - targets = [] use_custom_loadout = False - loadout = {} preset_loadout_name = "" start_type = "Runway" group = False # Contains DCS Mission group data after mission has been generated @@ -124,14 +120,14 @@ class Flight: # How long before this flight should take off scheduled_in = 0 - def __init__(self, unit_type: UnitType, count: int, from_cp, flight_type: FlightType): + def __init__(self, unit_type: UnitType, count: int, from_cp: ControlPoint, flight_type: FlightType): self.unit_type = unit_type self.count = count self.from_cp = from_cp self.flight_type = flight_type - self.points = [] - self.targets = [] - self.loadout = {} + self.points: List[FlightWaypoint] = [] + self.targets: List[MissionTarget] = [] + self.loadout: Dict[str, str] = {} self.start_type = "Runway" def __repr__(self): @@ -141,10 +137,10 @@ class Flight: # Test if __name__ == '__main__': - from pydcs.dcs.planes import A_10C + from dcs.planes import A_10C from theater import ControlPoint, Point, List - from_cp = ControlPoint(0, "AA", Point(0, 0), None, [], 0, 0) + from_cp = ControlPoint(0, "AA", Point(0, 0), Point(0, 0), [], 0, 0) f = Flight(A_10C(), 4, from_cp, FlightType.CAS) f.scheduled_in = 50 print(f) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py new file mode 100644 index 00000000..5e5fefe2 --- /dev/null +++ b/gen/flights/flightplan.py @@ -0,0 +1,442 @@ +"""Flight plan generation. + +Flights are first planned generically by either the player or by the +MissionPlanner. Those only plan basic information like the objective, aircraft +type, and the size of the flight. The FlightPlanBuilder is responsible for +generating the waypoints for the mission. +""" +from __future__ import annotations + +import logging +import random +from typing import List, Optional, TYPE_CHECKING + +from dcs.mapping import Point +from dcs.unit import Unit + +from game.data.doctrine import Doctrine, MODERN_DOCTRINE +from game.utils import nm_to_meter +from gen.ato import Package +from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject +from .closestairfields import ObjectiveDistanceCache +from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType +from .waypointbuilder import WaypointBuilder +from ..conflictgen import Conflict + +if TYPE_CHECKING: + from game import Game + + +class InvalidObjectiveLocation(RuntimeError): + """Raised when the objective location is invalid for the mission type.""" + def __init__(self, task: FlightType, location: MissionTarget) -> None: + super().__init__( + f"{location.name} is not valid for {task.name} missions." + ) + + +class FlightPlanBuilder: + """Generates flight plans for flights.""" + + def __init__(self, game: Game, package: Package, is_player: bool) -> None: + self.game = game + self.package = package + self.is_player = is_player + if is_player: + faction = self.game.player_faction + else: + faction = self.game.enemy_faction + self.doctrine: Doctrine = faction.get("doctrine", MODERN_DOCTRINE) + + def populate_flight_plan( + self, flight: Flight, + # TODO: Custom targets should be an attribute of the flight. + custom_targets: Optional[List[Unit]] = None) -> None: + """Creates a default flight plan for the given mission.""" + if flight not in self.package.flights: + raise RuntimeError("Flight must be a part of the package") + self.generate_missing_package_waypoints() + + # TODO: Flesh out mission types. + try: + task = flight.flight_type + if task == FlightType.ANTISHIP: + logging.error( + "Anti-ship flight plan generation not implemented" + ) + elif task == FlightType.BAI: + logging.error("BAI flight plan generation not implemented") + elif task == FlightType.BARCAP: + self.generate_barcap(flight) + elif task == FlightType.CAP: + self.generate_barcap(flight) + elif task == FlightType.CAS: + self.generate_cas(flight) + elif task == FlightType.DEAD: + self.generate_sead(flight, custom_targets) + elif task == FlightType.ELINT: + logging.error("ELINT flight plan generation not implemented") + elif task == FlightType.ESCORT: + self.generate_escort(flight) + elif task == FlightType.EVAC: + logging.error("Evac flight plan generation not implemented") + elif task == FlightType.EWAR: + logging.error("EWar flight plan generation not implemented") + elif task == FlightType.INTERCEPTION: + logging.error( + "Intercept flight plan generation not implemented" + ) + elif task == FlightType.LOGISTICS: + logging.error( + "Logistics flight plan generation not implemented" + ) + elif task == FlightType.RECON: + logging.error("Recon flight plan generation not implemented") + elif task == FlightType.SEAD: + self.generate_sead(flight, custom_targets) + elif task == FlightType.STRIKE: + self.generate_strike(flight) + elif task == FlightType.TARCAP: + self.generate_frontline_cap(flight) + elif task == FlightType.TROOP_TRANSPORT: + logging.error( + "Troop transport flight plan generation not implemented" + ) + except InvalidObjectiveLocation as ex: + logging.error(f"Could not create flight plan: {ex}") + + def generate_missing_package_waypoints(self) -> None: + if self.package.ingress_point is None: + self.package.ingress_point = self._ingress_point() + if self.package.egress_point is None: + self.package.egress_point = self._egress_point() + if self.package.join_point is None: + self.package.join_point = self._join_point() + if self.package.split_point is None: + self.package.split_point = self._split_point() + + def generate_strike(self, flight: Flight) -> None: + """Generates a strike flight plan. + + Args: + flight: The flight to generate the flight plan for. + """ + location = self.package.target + + # TODO: Support airfield strikes. + if not isinstance(location, TheaterGroundObject): + raise InvalidObjectiveLocation(flight.flight_type, location) + + builder = WaypointBuilder(self.doctrine) + builder.ascent(flight.from_cp) + builder.join(self.package.join_point) + builder.ingress_strike(self.package.ingress_point, location) + + if len(location.groups) > 0 and location.dcs_identifier == "AA": + # TODO: Replace with DEAD? + # Strike missions on SEAD targets target units. + for g in location.groups: + for j, u in enumerate(g.units): + builder.strike_point(u, f"{u.type} #{j}", location) + else: + # TODO: Does this actually happen? + # ConflictTheater is built with the belief that multiple ground + # objects have the same name. If that's the case, + # TheaterGroundObject needs some refactoring because it behaves very + # differently for SAM sites than it does for strike targets. + buildings = self.game.theater.find_ground_objects_by_obj_name( + location.obj_name + ) + for building in buildings: + if building.is_dead: + continue + + builder.strike_point( + building, + f"{building.obj_name} {building.category}", + location + ) + + builder.egress(self.package.egress_point, location) + builder.split(self.package.split_point) + builder.rtb(flight.from_cp) + + flight.points = builder.build() + + def generate_barcap(self, flight: Flight) -> None: + """Generate a BARCAP flight at a given location. + + Args: + flight: The flight to generate the flight plan for. + """ + location = self.package.target + + if isinstance(location, FrontLine): + raise InvalidObjectiveLocation(flight.flight_type, location) + + patrol_alt = random.randint( + self.doctrine.min_patrol_altitude, + self.doctrine.max_patrol_altitude + ) + + closest_cache = ObjectiveDistanceCache.get_closest_airfields(location) + for airfield in closest_cache.closest_airfields: + if airfield.captured != self.is_player: + closest_airfield = airfield + break + else: + logging.error("Could not find any enemy airfields") + return + + heading = location.position.heading_between_point( + closest_airfield.position + ) + + end = location.position.point_from_heading( + heading, + random.randint(self.doctrine.cap_min_distance_from_cp, + self.doctrine.cap_max_distance_from_cp) + ) + diameter = random.randint( + self.doctrine.cap_min_track_length, + self.doctrine.cap_max_track_length + ) + start = end.point_from_heading(heading - 180, diameter) + + builder = WaypointBuilder(self.doctrine) + builder.ascent(flight.from_cp) + builder.race_track(start, end, patrol_alt) + builder.rtb(flight.from_cp) + flight.points = builder.build() + + def generate_frontline_cap(self, flight: Flight) -> None: + """Generate a CAP flight plan for the given front line. + + Args: + flight: The flight to generate the flight plan for. + """ + location = self.package.target + + if not isinstance(location, FrontLine): + raise InvalidObjectiveLocation(flight.flight_type, location) + + ally_cp, enemy_cp = location.control_points + patrol_alt = random.randint(self.doctrine.min_patrol_altitude, + self.doctrine.max_patrol_altitude) + + # Find targets waypoints + ingress, heading, distance = Conflict.frontline_vector( + ally_cp, enemy_cp, self.game.theater + ) + center = ingress.point_from_heading(heading, distance / 2) + orbit_center = center.point_from_heading( + heading - 90, random.randint(nm_to_meter(6), nm_to_meter(15)) + ) + + combat_width = distance / 2 + if combat_width > 500000: + combat_width = 500000 + if combat_width < 35000: + combat_width = 35000 + + radius = combat_width*1.25 + orbit0p = orbit_center.point_from_heading(heading, radius) + orbit1p = orbit_center.point_from_heading(heading + 180, radius) + + # Create points + builder = WaypointBuilder(self.doctrine) + builder.ascent(flight.from_cp) + builder.race_track(orbit0p, orbit1p, patrol_alt) + builder.rtb(flight.from_cp) + flight.points = builder.build() + + def generate_sead(self, flight: Flight, + custom_targets: Optional[List[Unit]]) -> None: + """Generate a SEAD/DEAD flight at a given location. + + Args: + flight: The flight to generate the flight plan for. + custom_targets: Specific radar equipped units selected by the user. + """ + location = self.package.target + + if not isinstance(location, TheaterGroundObject): + raise InvalidObjectiveLocation(flight.flight_type, location) + + if custom_targets is None: + custom_targets = [] + + builder = WaypointBuilder(self.doctrine) + builder.ascent(flight.from_cp) + builder.join(self.package.join_point) + builder.ingress_sead(self.package.ingress_point, location) + + # TODO: Unify these. + # There doesn't seem to be any reason to treat the UI fragged missions + # different from the automatic missions. + if custom_targets: + for target in custom_targets: + point = FlightWaypoint( + FlightWaypointType.TARGET_POINT, + target.position.x, + target.position.y, + 0 + ) + point.alt_type = "RADIO" + if flight.flight_type == FlightType.DEAD: + builder.dead_point(target, location.name, location) + else: + builder.sead_point(target, location.name, location) + else: + if flight.flight_type == FlightType.DEAD: + builder.dead_area(location) + else: + builder.sead_area(location) + + builder.egress(self.package.egress_point, location) + builder.split(self.package.split_point) + builder.rtb(flight.from_cp) + + flight.points = builder.build() + + def generate_escort(self, flight: Flight) -> None: + # TODO: Decide common waypoints for the package ahead of time. + # Packages should determine some common points like push, ingress, + # egress, and split points ahead of time so they can be shared by all + # flights. + + patrol_alt = random.randint( + self.doctrine.min_patrol_altitude, + self.doctrine.max_patrol_altitude + ) + + builder = WaypointBuilder(self.doctrine) + builder.ascent(flight.from_cp) + builder.join(self.package.join_point) + builder.race_track( + self.package.ingress_point, + self.package.egress_point, + patrol_alt + ) + builder.split(self.package.split_point) + builder.rtb(flight.from_cp) + + flight.points = builder.build() + + def generate_cas(self, flight: Flight) -> None: + """Generate a CAS flight plan for the given target. + + Args: + flight: The flight to generate the flight plan for. + """ + location = self.package.target + + if not isinstance(location, FrontLine): + raise InvalidObjectiveLocation(flight.flight_type, location) + + is_helo = getattr(flight.unit_type, "helicopter", False) + cap_alt = 500 if is_helo else 1000 + + ingress, heading, distance = Conflict.frontline_vector( + location.control_points[0], location.control_points[1], + self.game.theater + ) + center = ingress.point_from_heading(heading, distance / 2) + egress = ingress.point_from_heading(heading, distance) + + builder = WaypointBuilder(self.doctrine) + builder.ascent(flight.from_cp, is_helo) + builder.join(self.package.join_point) + builder.ingress_cas(ingress, location) + builder.cas(center, cap_alt) + builder.egress(egress, location) + builder.split(self.package.split_point) + builder.rtb(flight.from_cp, is_helo) + + flight.points = builder.build() + + # TODO: Make a model for the waypoint builder and use that in the UI. + def generate_ascend_point(self, departure: ControlPoint) -> FlightWaypoint: + """Generate ascend point. + + Args: + departure: Departure airfield or carrier. + """ + builder = WaypointBuilder(self.doctrine) + builder.ascent(departure) + return builder.build()[0] + + def generate_descend_point(self, arrival: ControlPoint) -> FlightWaypoint: + """Generate approach/descend point. + + Args: + arrival: Arrival airfield or carrier. + """ + builder = WaypointBuilder(self.doctrine) + builder.descent(arrival) + return builder.build()[0] + + def generate_rtb_waypoint(self, arrival: ControlPoint) -> FlightWaypoint: + """Generate RTB landing point. + + Args: + arrival: Arrival airfield or carrier. + """ + builder = WaypointBuilder(self.doctrine) + builder.land(arrival) + return builder.build()[0] + + def _join_point(self) -> Point: + ingress_point = self.package.ingress_point + assert ingress_point is not None + heading = self._heading_to_package_airfield(ingress_point) + return ingress_point.point_from_heading(heading, + -self.doctrine.join_distance) + + def _split_point(self) -> Point: + egress_point = self.package.egress_point + assert egress_point is not None + heading = self._heading_to_package_airfield(egress_point) + return egress_point.point_from_heading(heading, + -self.doctrine.split_distance) + + def _ingress_point(self) -> Point: + heading = self._target_heading_to_package_airfield() + return self.package.target.position.point_from_heading( + heading - 180 + 25, self.doctrine.ingress_egress_distance + ) + + def _egress_point(self) -> Point: + heading = self._target_heading_to_package_airfield() + return self.package.target.position.point_from_heading( + heading - 180 - 25, self.doctrine.ingress_egress_distance + ) + + def _target_heading_to_package_airfield(self) -> int: + return self._heading_to_package_airfield(self.package.target.position) + + def _heading_to_package_airfield(self, point: Point) -> int: + return self.package_airfield().position.heading_between_point(point) + + # TODO: Set ingress/egress/join/split points in the Package. + def package_airfield(self) -> ControlPoint: + # 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( + "Cannot determine source airfield for package with no flights" + ) + + # The package airfield is either the flight's airfield (when there is no + # package) or the closest airfield to the objective that is the + # departure airfield for some flight in the package. + cache = ObjectiveDistanceCache.get_closest_airfields( + self.package.target + ) + for airfield in cache.closest_airfields: + for flight in self.package.flights: + if flight.from_cp == airfield: + return airfield + raise RuntimeError( + "Could not find any airfield assigned to this package" + ) diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py new file mode 100644 index 00000000..6fc3fd85 --- /dev/null +++ b/gen/flights/waypointbuilder.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from typing import List, Optional, Union + +from dcs.mapping import Point +from dcs.unit import Unit + +from game.data.doctrine import Doctrine +from game.utils import nm_to_meter +from theater import ControlPoint, MissionTarget, TheaterGroundObject +from .flight import FlightWaypoint, FlightWaypointType + + +class WaypointBuilder: + def __init__(self, doctrine: Doctrine) -> None: + self.doctrine = doctrine + self.waypoints: List[FlightWaypoint] = [] + self.ingress_point: Optional[FlightWaypoint] = None + + def build(self) -> List[FlightWaypoint]: + return self.waypoints + + def ascent(self, departure: ControlPoint, is_helo: bool = False) -> None: + """Create ascent waypoint for the given departure airfield or carrier. + + Args: + departure: Departure airfield or carrier. + """ + # TODO: Pick runway based on wind direction. + heading = departure.heading + position = departure.position.point_from_heading( + heading, nm_to_meter(5) + ) + waypoint = FlightWaypoint( + FlightWaypointType.ASCEND_POINT, + position.x, + position.y, + 500 if is_helo else self.doctrine.pattern_altitude + ) + waypoint.name = "ASCEND" + waypoint.alt_type = "RADIO" + waypoint.description = "Ascend" + waypoint.pretty_name = "Ascend" + self.waypoints.append(waypoint) + + def descent(self, arrival: ControlPoint, is_helo: bool = False) -> None: + """Create descent waypoint for the given arrival airfield or carrier. + + Args: + arrival: Arrival airfield or carrier. + """ + # TODO: Pick runway based on wind direction. + # ControlPoint.heading is the departure heading. + heading = (arrival.heading + 180) % 360 + position = arrival.position.point_from_heading( + heading, nm_to_meter(5) + ) + waypoint = FlightWaypoint( + FlightWaypointType.DESCENT_POINT, + position.x, + position.y, + 300 if is_helo else self.doctrine.pattern_altitude + ) + waypoint.name = "DESCEND" + waypoint.alt_type = "RADIO" + waypoint.description = "Descend to pattern altitude" + waypoint.pretty_name = "Ascend" + self.waypoints.append(waypoint) + + def land(self, arrival: ControlPoint) -> None: + """Create descent waypoint for the given arrival airfield or carrier. + + Args: + arrival: Arrival airfield or carrier. + """ + position = arrival.position + waypoint = FlightWaypoint( + FlightWaypointType.LANDING_POINT, + position.x, + position.y, + 0 + ) + waypoint.name = "LANDING" + waypoint.alt_type = "RADIO" + waypoint.description = "Land" + waypoint.pretty_name = "Land" + self.waypoints.append(waypoint) + + def join(self, position: Point) -> None: + waypoint = FlightWaypoint( + FlightWaypointType.JOIN, + position.x, + position.y, + self.doctrine.ingress_altitude + ) + waypoint.pretty_name = "Join" + waypoint.description = "Rendezvous with package" + waypoint.name = "JOIN" + self.waypoints.append(waypoint) + + def split(self, position: Point) -> None: + waypoint = FlightWaypoint( + FlightWaypointType.SPLIT, + position.x, + position.y, + self.doctrine.ingress_altitude + ) + waypoint.pretty_name = "Split" + waypoint.description = "Depart from package" + waypoint.name = "SPLIT" + self.waypoints.append(waypoint) + + def ingress_cas(self, position: Point, objective: MissionTarget) -> None: + self._ingress(FlightWaypointType.INGRESS_CAS, position, objective) + + def ingress_sead(self, position: Point, objective: MissionTarget) -> None: + self._ingress(FlightWaypointType.INGRESS_SEAD, position, objective) + + def ingress_strike(self, position: Point, objective: MissionTarget) -> None: + self._ingress(FlightWaypointType.INGRESS_STRIKE, position, objective) + + def _ingress(self, ingress_type: FlightWaypointType, position: Point, + objective: MissionTarget) -> None: + if self.ingress_point is not None: + raise RuntimeError("A flight plan can have only one ingress point.") + + waypoint = FlightWaypoint( + ingress_type, + position.x, + position.y, + self.doctrine.ingress_altitude + ) + waypoint.pretty_name = "INGRESS on " + objective.name + waypoint.description = "INGRESS on " + objective.name + waypoint.name = "INGRESS" + self.waypoints.append(waypoint) + self.ingress_point = waypoint + + def egress(self, position: Point, target: MissionTarget) -> None: + waypoint = FlightWaypoint( + FlightWaypointType.EGRESS, + position.x, + position.y, + self.doctrine.ingress_altitude + ) + waypoint.pretty_name = "EGRESS from " + target.name + waypoint.description = "EGRESS from " + target.name + waypoint.name = "EGRESS" + self.waypoints.append(waypoint) + + def dead_point(self, target: Union[TheaterGroundObject, Unit], name: str, + location: MissionTarget) -> None: + self._target_point(target, name, f"STRIKE [{location.name}]: {name}", + location) + # TODO: Seems fishy. + if self.ingress_point is not None: + self.ingress_point.targetGroup = location + + def sead_point(self, target: Union[TheaterGroundObject, Unit], name: str, + location: MissionTarget) -> None: + self._target_point(target, name, f"STRIKE [{location.name}]: {name}", + location) + # TODO: Seems fishy. + if self.ingress_point is not None: + self.ingress_point.targetGroup = location + + def strike_point(self, target: Union[TheaterGroundObject, Unit], name: str, + location: MissionTarget) -> None: + self._target_point(target, name, f"STRIKE [{location.name}]: {name}", + location) + + def _target_point(self, target: Union[TheaterGroundObject, Unit], name: str, + description: str, location: MissionTarget) -> None: + if self.ingress_point is None: + raise RuntimeError( + "An ingress point must be added before target points." + ) + + waypoint = FlightWaypoint( + FlightWaypointType.TARGET_POINT, + target.position.x, + target.position.y, + 0 + ) + waypoint.description = description + waypoint.pretty_name = description + waypoint.name = name + waypoint.only_for_player = True + self.waypoints.append(waypoint) + # TODO: This seems wrong, but it's what was there before. + self.ingress_point.targets.append(location) + + def sead_area(self, target: MissionTarget) -> None: + self._target_area(f"SEAD on {target.name}", target) + # TODO: Seems fishy. + if self.ingress_point is not None: + self.ingress_point.targetGroup = target + + def dead_area(self, target: MissionTarget) -> None: + self._target_area(f"DEAD on {target.name}", target) + # TODO: Seems fishy. + if self.ingress_point is not None: + self.ingress_point.targetGroup = target + + def _target_area(self, name: str, location: MissionTarget) -> None: + if self.ingress_point is None: + raise RuntimeError( + "An ingress point must be added before target points." + ) + + waypoint = FlightWaypoint( + FlightWaypointType.TARGET_GROUP_LOC, + location.position.x, + location.position.y, + 0 + ) + waypoint.description = name + waypoint.pretty_name = name + waypoint.name = name + waypoint.only_for_player = True + self.waypoints.append(waypoint) + # TODO: This seems wrong, but it's what was there before. + self.ingress_point.targets.append(location) + + def cas(self, position: Point, altitude: int) -> None: + waypoint = FlightWaypoint( + FlightWaypointType.CAS, + position.x, + position.y, + altitude + ) + waypoint.alt_type = "RADIO" + waypoint.description = "Provide CAS" + waypoint.name = "CAS" + waypoint.pretty_name = "CAS" + self.waypoints.append(waypoint) + + def race_track_start(self, position: Point, altitude: int) -> None: + """Creates a racetrack start waypoint. + + Args: + position: Position of the waypoint. + altitude: Altitude of the racetrack in meters. + """ + waypoint = FlightWaypoint( + FlightWaypointType.PATROL_TRACK, + position.x, + position.y, + altitude + ) + waypoint.name = "RACETRACK START" + waypoint.description = "Orbit between this point and the next point" + waypoint.pretty_name = "Race-track start" + self.waypoints.append(waypoint) + + # TODO: Does this actually do anything? + # orbit0.targets.append(location) + # Note: Targets of PATROL TRACK waypoints are the points to be defended. + # orbit0.targets.append(flight.from_cp) + # orbit0.targets.append(center) + + def race_track_end(self, position: Point, altitude: int) -> None: + """Creates a racetrack end waypoint. + + Args: + position: Position of the waypoint. + altitude: Altitude of the racetrack in meters. + """ + waypoint = FlightWaypoint( + FlightWaypointType.PATROL, + position.x, + position.y, + altitude + ) + waypoint.name = "RACETRACK END" + waypoint.description = "Orbit between this point and the previous point" + waypoint.pretty_name = "Race-track end" + self.waypoints.append(waypoint) + + def race_track(self, start: Point, end: Point, altitude: int) -> None: + """Creates two waypoint for a racetrack orbit. + + Args: + start: The beginning racetrack waypoint. + end: The ending racetrack waypoint. + altitude: The racetrack altitude. + """ + self.race_track_start(start, altitude) + self.race_track_end(end, altitude) + + def rtb(self, arrival: ControlPoint, is_helo: bool = False) -> None: + """Creates descent ant landing waypoints for the given control point. + + Args: + arrival: Arrival airfield or carrier. + """ + self.descent(arrival, is_helo) + self.land(arrival) diff --git a/gen/ground_forces/ai_ground_planner.py b/gen/ground_forces/ai_ground_planner.py index 877c9831..bda87407 100644 --- a/gen/ground_forces/ai_ground_planner.py +++ b/gen/ground_forces/ai_ground_planner.py @@ -1,13 +1,13 @@ import random from enum import Enum +from typing import Dict, List -from dcs.vehicles import * - -from gen import Conflict -from gen.ground_forces.combat_stance import CombatStance -from theater import ControlPoint +from dcs.vehicles import Armor, Artillery, Infantry, Unarmed +from dcs.unittype import VehicleType import pydcs_extensions.frenchpack.frenchpack as frenchpack +from gen.ground_forces.combat_stance import CombatStance +from theater import ControlPoint TYPE_TANKS = [ Armor.MBT_T_55, @@ -207,8 +207,8 @@ GROUP_SIZES_BY_COMBAT_STANCE = { class CombatGroup: - def __init__(self, role:CombatGroupRole): - self.units = [] + def __init__(self, role: CombatGroupRole): + self.units: List[VehicleType] = [] self.role = role self.assigned_enemy_cp = None self.start_position = None @@ -222,33 +222,22 @@ class CombatGroup: class GroundPlanner: - cp = None - combat_groups_dict = {} - connected_enemy_cp = [] - - tank_groups = [] - apc_group = [] - ifv_group = [] - art_group = [] - shorad_groups = [] - logi_groups = [] - def __init__(self, cp:ControlPoint, game): self.cp = cp self.game = game self.connected_enemy_cp = [cp for cp in self.cp.connected_points if cp.captured != self.cp.captured] - self.tank_groups = [] - self.apc_group = [] - self.ifv_group = [] - self.art_group = [] - self.atgm_group = [] - self.logi_groups = [] - self.shorad_groups = [] + self.tank_groups: List[CombatGroup] = [] + self.apc_group: List[CombatGroup] = [] + self.ifv_group: List[CombatGroup] = [] + self.art_group: List[CombatGroup] = [] + self.atgm_group: List[CombatGroup] = [] + self.logi_groups: List[CombatGroup] = [] + self.shorad_groups: List[CombatGroup] = [] - self.units_per_cp = {} + self.units_per_cp: Dict[int, List[CombatGroup]] = {} for cp in self.connected_enemy_cp: self.units_per_cp[cp.id] = [] - self.reserve = [] + self.reserve: List[CombatGroup] = [] def plan_groundwar(self): diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index d1cd5378..2d1894e8 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -1,11 +1,23 @@ -from dcs.statics import * -from dcs.unit import Ship, Vehicle +import logging +import random +from typing import Dict, Iterator -from game.data.building_data import FORTIFICATION_UNITS_ID, FORTIFICATION_UNITS +from dcs import Mission +from dcs.statics import fortification_map, warehouse_map +from dcs.task import ( + ActivateBeaconCommand, + ActivateICLSCommand, + EPLRS, + OptAlarmState, +) +from dcs.unit import Ship, Vehicle +from dcs.unitgroup import StaticGroup + +from game import db +from game.data.building_data import FORTIFICATION_UNITS, FORTIFICATION_UNITS_ID from game.db import unit_type_from_name from .airfields import RunwayData -from .conflictgen import * -from .naming import * +from .conflictgen import Conflict from .radios import RadioRegistry from .tacan import TacanBand, TacanRegistry @@ -26,7 +38,7 @@ class GroundObjectsGenerator: self.icls_alloc = iter(range(1, 21)) self.runways: Dict[str, RunwayData] = {} - def generate_farps(self, number_of_units=1) -> typing.Collection[StaticGroup]: + def generate_farps(self, number_of_units=1) -> Iterator[StaticGroup]: if self.conflict.is_vector: center = self.conflict.center heading = self.conflict.heading - 90 @@ -80,6 +92,10 @@ class GroundObjectsGenerator: vehicle.heading = u.heading vehicle.player_can_drive = True vg.add_unit(vehicle) + + if hasattr(utype, 'eplrs'): + if utype.eplrs: + vg.points[0].tasks.append(EPLRS(vg.id)) else: vg = self.m.ship_group(side, g.name, utype, position=g.position, heading=g.units[0].heading) diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 6843e395..e7a86bc5 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -82,6 +82,8 @@ class KneeboardPageWriter: def table(self, cells: List[List[str]], headers: Optional[List[str]] = None) -> None: + if headers is None: + headers = [] table = tabulate(cells, headers=headers, numalign="right") self.text(table, font=self.table_font) @@ -136,7 +138,7 @@ class FlightPlanBuilder: def add_waypoint_row(self, waypoint: NumberedWaypoint) -> None: self.rows.append([ - waypoint.number, + str(waypoint.number), waypoint.waypoint.pretty_name, str(int(units.meters_to_feet(waypoint.waypoint.alt))) ]) @@ -194,7 +196,7 @@ class BriefingPage(KneeboardPage): tankers.append([ tanker.callsign, tanker.variant, - tanker.tacan, + str(tanker.tacan), self.format_frequency(tanker.freq), ]) writer.table(tankers, headers=["Callsign", "Type", "TACAN", "UHF"]) @@ -225,12 +227,22 @@ class BriefingPage(KneeboardPage): atc = "" if runway.atc is not None: atc = self.format_frequency(runway.atc) + if runway.tacan is None: + tacan = "" + else: + tacan = str(runway.tacan) + if runway.ils is not None: + ils = str(runway.ils) + elif runway.icls is not None: + ils = str(runway.icls) + else: + ils = "" return [ row_title, runway.airfield_name, atc, - runway.tacan or "", - runway.ils or runway.icls or "", + tacan, + ils, runway.runway_name, ] diff --git a/gen/missiles/missiles_group_generator.py b/gen/missiles/missiles_group_generator.py index c63fcca9..4e2e9d73 100644 --- a/gen/missiles/missiles_group_generator.py +++ b/gen/missiles/missiles_group_generator.py @@ -8,13 +8,13 @@ MISSILES_MAP = { } -def generate_missile_group(game, ground_object, faction:str): +def generate_missile_group(game, ground_object, faction_name: str): """ This generate a ship group :return: Nothing, but put the group reference inside the ground object """ - faction = db.FACTIONS[faction] - if "missiles" in faction.keys(): + faction = db.FACTIONS[faction_name] + if "missiles" in faction: generators = faction["missiles"] if len(generators) > 0: gen = random.choice(generators) diff --git a/gen/triggergen.py b/gen/triggergen.py index fbd8062e..ba87bb3e 100644 --- a/gen/triggergen.py +++ b/gen/triggergen.py @@ -1,19 +1,12 @@ -import typing -import random -from datetime import datetime, timedelta, time - +from dcs.action import MarkToAll +from dcs.condition import TimeAfter from dcs.mission import Mission -from dcs.triggers import * -from dcs.condition import * -from dcs.action import * +from dcs.task import Option +from dcs.translation import String +from dcs.triggers import Event, TriggerOnce from dcs.unit import Skill -from dcs.point import MovingPoint, PointProperties -from dcs.action import * -from game import db -from theater import * -from gen.airsupportgen import AirSupportConflictGenerator -from gen import * +from .conflictgen import Conflict PUSH_TRIGGER_SIZE = 3000 PUSH_TRIGGER_ACTIVATION_AGL = 25 diff --git a/gen/visualgen.py b/gen/visualgen.py index c3be7cad..5bc315e5 100644 --- a/gen/visualgen.py +++ b/gen/visualgen.py @@ -1,18 +1,20 @@ -import typing +from __future__ import annotations + import random -from datetime import datetime, timedelta +from typing import TYPE_CHECKING +from dcs.mapping import Point from dcs.mission import Mission -from dcs.statics import * from dcs.unit import Static +from dcs.unittype import StaticType -from theater import * -from .conflictgen import * -#from game.game import Game -from game import db +if TYPE_CHECKING: + from game import Game + +from .conflictgen import Conflict, FRONTLINE_LENGTH -class MarkerSmoke(unittype.StaticType): +class MarkerSmoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" @@ -20,7 +22,7 @@ class MarkerSmoke(unittype.StaticType): rate = 0.1 -class Smoke(unittype.StaticType): +class Smoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" @@ -28,7 +30,7 @@ class Smoke(unittype.StaticType): rate = 1 -class BigSmoke(unittype.StaticType): +class BigSmoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" @@ -36,7 +38,7 @@ class BigSmoke(unittype.StaticType): rate = 1 -class MassiveSmoke(unittype.StaticType): +class MassiveSmoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" @@ -44,7 +46,7 @@ class MassiveSmoke(unittype.StaticType): rate = 1 -class Outpost(unittype.StaticType): +class Outpost(StaticType): id = "outpost" name = "outpost" category = "Fortifications" @@ -90,9 +92,7 @@ def turn_heading(heading, fac): class VisualGenerator: - game = None # type: Game - - def __init__(self, mission: Mission, conflict: Conflict, game): + def __init__(self, mission: Mission, conflict: Conflict, game: Game): self.mission = mission self.conflict = conflict self.game = game diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..045a50e6 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,12 @@ +[mypy] +namespace_packages = True + +[mypy-dcs.*] +follow_imports=silent +ignore_missing_imports = True + +[mypy-PIL.*] +ignore_missing_imports = True + +[mypy-winreg.*] +ignore_missing_imports = True \ No newline at end of file diff --git a/pydcs b/pydcs index ceea62a8..c203e5a1 160000 --- a/pydcs +++ b/pydcs @@ -1 +1 @@ -Subproject commit ceea62a8e0731c21b3e1a3e90682aa0affc168f1 +Subproject commit c203e5a1b8d5eb42d559dab074e668bf37fa5158 diff --git a/pydcs_extensions/frenchpack/frenchpack.py b/pydcs_extensions/frenchpack/frenchpack.py index 12d51827..2d5f5433 100644 --- a/pydcs_extensions/frenchpack/frenchpack.py +++ b/pydcs_extensions/frenchpack/frenchpack.py @@ -25,7 +25,7 @@ class ERC_90(unittype.VehicleType): detection_range = 0 threat_range = 4000 air_weapon_dist = 4000 - eprls = True + eplrs = True class VAB__50(unittype.VehicleType): @@ -34,7 +34,7 @@ class VAB__50(unittype.VehicleType): detection_range = 0 threat_range = 1200 air_weapon_dist = 1200 - eprls = True + eplrs = True class VAB_T20_13(unittype.VehicleType): @@ -43,7 +43,7 @@ class VAB_T20_13(unittype.VehicleType): detection_range = 0 threat_range = 2000 air_weapon_dist = 2000 - eprls = True + eplrs = True class VAB_MEPHISTO(unittype.VehicleType): @@ -52,7 +52,7 @@ class VAB_MEPHISTO(unittype.VehicleType): detection_range = 0 threat_range = 4000 air_weapon_dist = 4000 - eprls = True + eplrs = True class VBL__50(unittype.VehicleType): @@ -61,7 +61,7 @@ class VBL__50(unittype.VehicleType): detection_range = 0 threat_range = 1200 air_weapon_dist = 1200 - eprls = True + eplrs = True class VBL_AANF1(unittype.VehicleType): @@ -70,7 +70,7 @@ class VBL_AANF1(unittype.VehicleType): detection_range = 0 threat_range = 1000 air_weapon_dist = 1000 - eprls = True + eplrs = True class VBAE_CRAB(unittype.VehicleType): @@ -79,7 +79,7 @@ class VBAE_CRAB(unittype.VehicleType): detection_range = 0 threat_range = 3500 air_weapon_dist = 3500 - eprls = True + eplrs = True class VBAE_CRAB_MMP(unittype.VehicleType): @@ -88,7 +88,7 @@ class VBAE_CRAB_MMP(unittype.VehicleType): detection_range = 0 threat_range = 3500 air_weapon_dist = 3500 - eprls = True + eplrs = True class AMX_30B2(unittype.VehicleType): @@ -121,7 +121,7 @@ class DIM__TOYOTA_BLUE(unittype.VehicleType): detection_range = 0 threat_range = 1200 air_weapon_dist = 1200 - eprls = True + eplrs = True class DIM__TOYOTA_GREEN(unittype.VehicleType): @@ -130,7 +130,7 @@ class DIM__TOYOTA_GREEN(unittype.VehicleType): detection_range = 0 threat_range = 1200 air_weapon_dist = 1200 - eprls = True + eplrs = True class DIM__TOYOTA_DESERT(unittype.VehicleType): @@ -139,7 +139,7 @@ class DIM__TOYOTA_DESERT(unittype.VehicleType): detection_range = 0 threat_range = 1200 air_weapon_dist = 1200 - eprls = True + eplrs = True class DIM__KAMIKAZE(unittype.VehicleType): @@ -148,7 +148,7 @@ class DIM__KAMIKAZE(unittype.VehicleType): detection_range = 0 threat_range = 50 air_weapon_dist = 50 - eprls = True + eplrs = True ## FORTIFICATION @@ -187,7 +187,7 @@ class TRM_2000(unittype.VehicleType): detection_range = 3500 threat_range = 0 air_weapon_dist = 0 - eprls = True + eplrs = True class TRM_2000_Fuel(unittype.VehicleType): id = "TRM2000_Citerne" @@ -195,7 +195,7 @@ class TRM_2000_Fuel(unittype.VehicleType): detection_range = 3500 threat_range = 0 air_weapon_dist = 0 - eprls = True + eplrs = True class VAB_MEDICAL(unittype.VehicleType): id = "VABH" @@ -203,7 +203,7 @@ class VAB_MEDICAL(unittype.VehicleType): detection_range = 0 threat_range = 0 air_weapon_dist = 0 - eprls = True + eplrs = True class VAB(unittype.VehicleType): id = "VAB_RADIO" @@ -211,7 +211,7 @@ class VAB(unittype.VehicleType): detection_range = 0 threat_range = 0 air_weapon_dist = 0 - eprls = True + eplrs = True class VBL(unittype.VehicleType): id = "VBL-Radio" @@ -219,7 +219,7 @@ class VBL(unittype.VehicleType): detection_range = 0 threat_range = 0 air_weapon_dist = 0 - eprls = True + eplrs = True class Tracma_TD_1500(unittype.VehicleType): id = "Tracma" @@ -236,7 +236,7 @@ class SMOKE_SAM_IR(unittype.VehicleType): detection_range = 20000 threat_range = 20000 air_weapon_dist = 20000 - eprls = True + eplrs = True class _53T2(unittype.VehicleType): id = "AA20" @@ -251,7 +251,7 @@ class TRM_2000_53T2(unittype.VehicleType): detection_range = 6000 threat_range = 2000 air_weapon_dist = 2000 - eprls = True + eplrs = True class TRM_2000_PAMELA(unittype.VehicleType): id = "TRMMISTRAL" @@ -259,7 +259,7 @@ class TRM_2000_PAMELA(unittype.VehicleType): detection_range = 8000 threat_range = 10000 air_weapon_dist = 10000 - eprls = True + eplrs = True ## INFANTRY @@ -285,4 +285,4 @@ class VAB_MORTIER(unittype.VehicleType): detection_range = 0 threat_range = 15000 air_weapon_dist = 15000 - eprls = True \ No newline at end of file + eplrs = True \ No newline at end of file diff --git a/qt_ui/dialogs.py b/qt_ui/dialogs.py new file mode 100644 index 00000000..e09dd92a --- /dev/null +++ b/qt_ui/dialogs.py @@ -0,0 +1,65 @@ +"""Application-wide dialog management.""" +from typing import Optional + +from gen.flights.flight import Flight +from theater.missiontarget import MissionTarget +from .models import GameModel, PackageModel +from .windows.mission.QEditFlightDialog import QEditFlightDialog +from .windows.mission.QPackageDialog import ( + QEditPackageDialog, + QNewPackageDialog, +) + + +class Dialog: + """Dialog management singleton. + + Opens dialogs and keeps references to dialog windows so that their creators + do not need to worry about the lifetime of the dialog object, and can open + dialogs without needing to have their own reference to common data like the + game model. + """ + + #: The game model. Is only None before initialization, as the game model + #: itself is responsible for handling the case where no game is loaded. + game_model: Optional[GameModel] = None + + new_package_dialog: Optional[QNewPackageDialog] = None + edit_package_dialog: Optional[QEditPackageDialog] = None + edit_flight_dialog: Optional[QEditFlightDialog] = None + + @classmethod + def set_game(cls, game_model: GameModel) -> None: + """Sets the game model.""" + cls.game_model = game_model + + @classmethod + def open_new_package_dialog(cls, mission_target: MissionTarget): + """Opens the dialog to create a new package with the given target.""" + cls.new_package_dialog = QNewPackageDialog( + cls.game_model.game, + cls.game_model.ato_model, + mission_target + ) + cls.new_package_dialog.show() + + @classmethod + def open_edit_package_dialog(cls, package_model: PackageModel): + """Opens the dialog to edit the given package.""" + cls.edit_package_dialog = QEditPackageDialog( + cls.game_model.game, + cls.game_model.ato_model, + package_model + ) + cls.edit_package_dialog.show() + + @classmethod + def open_edit_flight_dialog(cls, package_model: PackageModel, + flight: Flight) -> None: + """Opens the dialog to edit the given flight.""" + cls.edit_flight_dialog = QEditFlightDialog( + cls.game_model.game, + package_model.package, + flight + ) + cls.edit_flight_dialog.show() diff --git a/userdata/liberation_install.py b/qt_ui/liberation_install.py similarity index 99% rename from userdata/liberation_install.py rename to qt_ui/liberation_install.py index 5f19ec0a..0440043d 100644 --- a/userdata/liberation_install.py +++ b/qt_ui/liberation_install.py @@ -4,13 +4,14 @@ from shutil import copyfile import dcs -from userdata import persistency +from game import persistency global __dcs_saved_game_directory global __dcs_installation_directory PREFERENCES_FILE_PATH = "liberation_preferences.json" + def init(): global __dcs_saved_game_directory global __dcs_installation_directory diff --git a/userdata/liberation_theme.py b/qt_ui/liberation_theme.py similarity index 68% rename from userdata/liberation_theme.py rename to qt_ui/liberation_theme.py index 703d15ee..79714209 100644 --- a/userdata/liberation_theme.py +++ b/qt_ui/liberation_theme.py @@ -1,7 +1,7 @@ import json +import logging import os - -import qt_ui.uiconstants as CONST +from typing import Dict global __theme_index @@ -10,29 +10,44 @@ THEME_PREFERENCES_FILE_PATH = "liberation_theme.json" DEFAULT_THEME_INDEX = 1 +# new themes can be added here +THEMES: Dict[int, Dict[str, str]] = { + 0: {'themeName': 'Vanilla', + 'themeFile': 'windows-style.css', + 'themeIcons': 'medium', + }, + + 1: {'themeName': 'DCS World', + 'themeFile': 'style-dcs.css', + 'themeIcons': 'light', + }, + +} + + def init(): global __theme_index __theme_index = DEFAULT_THEME_INDEX - print("init setting theme index to " + str(__theme_index)) if os.path.isfile(THEME_PREFERENCES_FILE_PATH): try: with(open(THEME_PREFERENCES_FILE_PATH)) as prefs: pref_data = json.loads(prefs.read()) __theme_index = pref_data["theme_index"] - print(__theme_index) set_theme_index(__theme_index) save_theme_config() - print("file setting theme index to " + str(__theme_index)) except: # is this necessary? set_theme_index(DEFAULT_THEME_INDEX) - print("except setting theme index to " + str(__theme_index)) + logging.exception("Unable to change theme") else: # is this necessary? set_theme_index(DEFAULT_THEME_INDEX) - print("else setting theme index to " + str(__theme_index)) + logging.error( + f"Using default theme because {THEME_PREFERENCES_FILE_PATH} " + "does not exist" + ) # set theme index then use save_theme_config to save to file @@ -49,19 +64,19 @@ def get_theme_index(): # get theme name based on current index def get_theme_name(): - theme_name = CONST.THEMES[get_theme_index()]['themeName'] + theme_name = THEMES[get_theme_index()]['themeName'] return theme_name # get theme icon sub-folder name based on current index def get_theme_icons(): - theme_icons = CONST.THEMES[get_theme_index()]['themeIcons'] + theme_icons = THEMES[get_theme_index()]['themeIcons'] return str(theme_icons) # get theme stylesheet css based on current index def get_theme_css_file(): - theme_file = CONST.THEMES[get_theme_index()]['themeFile'] + theme_file = THEMES[get_theme_index()]['themeFile'] return str(theme_file) diff --git a/qt_ui/logging_config.py b/qt_ui/logging_config.py new file mode 100644 index 00000000..739e8ac0 --- /dev/null +++ b/qt_ui/logging_config.py @@ -0,0 +1,22 @@ +"""Logging APIs.""" +import logging +import os +from logging.handlers import RotatingFileHandler + + +def init_logging(version: str) -> None: + """Initializes the logging configuration.""" + if not os.path.isdir("./logs"): + os.mkdir("logs") + + fmt = "%(asctime)s :: %(levelname)s :: %(message)s" + logging.basicConfig(level=logging.DEBUG, format=fmt) + logger = logging.getLogger() + + handler = RotatingFileHandler('./logs/liberation.log', 'a', 5000000, 1) + handler.setLevel(logging.INFO) + handler.setFormatter(logging.Formatter(fmt)) + + logger.addHandler(handler) + + logger.info(f"DCS Liberation {version}") diff --git a/qt_ui/main.py b/qt_ui/main.py index e019d32c..9503fb58 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -1,5 +1,3 @@ -from userdata import logging_config - import logging import os import sys @@ -9,11 +7,17 @@ from PySide2 import QtWidgets from PySide2.QtGui import QPixmap from PySide2.QtWidgets import QApplication, QSplashScreen -from qt_ui import uiconstants +from game import persistency +from qt_ui import ( + liberation_install, + liberation_theme, + logging_config, + uiconstants, +) from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.QLiberationWindow import QLiberationWindow -from qt_ui.windows.preferences.QLiberationFirstStartWindow import QLiberationFirstStartWindow -from userdata import liberation_install, persistency, liberation_theme +from qt_ui.windows.preferences.QLiberationFirstStartWindow import \ + QLiberationFirstStartWindow # Logging setup logging_config.init_logging(uiconstants.VERSION_STRING) diff --git a/qt_ui/models.py b/qt_ui/models.py new file mode 100644 index 00000000..87d52538 --- /dev/null +++ b/qt_ui/models.py @@ -0,0 +1,268 @@ +"""Qt data models for game objects.""" +from typing import Any, Callable, Dict, Iterator, TypeVar, Optional + +from PySide2.QtCore import ( + QAbstractListModel, + QModelIndex, + Qt, + Signal, +) +from PySide2.QtGui import QIcon + +from game import db +from game.game import Game +from gen.ato import AirTaskingOrder, Package +from gen.flights.flight import Flight +from qt_ui.uiconstants import AIRCRAFT_ICONS +from theater.missiontarget import MissionTarget + + +class DeletableChildModelManager: + """Manages lifetimes for child models. + + Qt's data models don't have a good way of modeling related data aside from + lists, tables, or trees of similar objects. We could build one monolithic + GameModel that tracks all of the data in the game and use the parent/child + relationships of that model to index down into the ATO, packages, flights, + etc, but doing so is error prone because it requires us to manually manage + that relationship tree and keep our own mappings from row/column into + specific members. + + However, creating child models outside of the tree means that removing an + item from the parent will not signal the child's deletion to any views, so + we must track this explicitly. + + Any model which has child data types should use this class to track the + deletion of child models. All child model types must define a signal named + `deleted`. This signal will be emitted when the child model is being + deleted. Any views displaying such data should subscribe to those events and + update their display accordingly. + """ + + #: The type of data owned by models created by this class. + DataType = TypeVar("DataType") + + #: The type of model managed by this class. + ModelType = TypeVar("ModelType") + + ModelDict = Dict[DataType, ModelType] + + def __init__(self, create_model: Callable[[DataType], ModelType]) -> None: + self.create_model = create_model + self.models: DeletableChildModelManager.ModelDict = {} + + def acquire(self, data: DataType) -> ModelType: + """Returns a model for the given child data. + + If a model has already been created for the given data, it will be + returned. The data type must be hashable. + """ + if data in self.models: + return self.models[data] + model = self.create_model(data) + self.models[data] = model + return model + + def release(self, data: DataType) -> None: + """Releases the model matching the given data, if one exists. + + If the given data has had a model created for it, that model will be + deleted and its `deleted` signal will be emitted. + """ + if data in self.models: + model = self.models[data] + del self.models[data] + model.deleted.emit() + + def clear(self) -> None: + """Deletes all managed models.""" + for data in list(self.models.keys()): + self.release(data) + + +class NullListModel(QAbstractListModel): + """Generic empty list model.""" + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return 0 + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: + return None + + +class PackageModel(QAbstractListModel): + """The model for an ATO package.""" + + #: Emitted when this package is being deleted from the ATO. + deleted = Signal() + + def __init__(self, package: Package) -> None: + super().__init__() + self.package = package + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(self.package.flights) + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: + if not index.isValid(): + return None + flight = self.flight_at_index(index) + if role == Qt.DisplayRole: + return self.text_for_flight(flight) + if role == Qt.DecorationRole: + return self.icon_for_flight(flight) + return None + + @staticmethod + def text_for_flight(flight: Flight) -> str: + """Returns the text that should be displayed for the flight.""" + task = flight.flight_type.name + count = flight.count + name = db.unit_type_name(flight.unit_type) + delay = flight.scheduled_in + origin = flight.from_cp.name + return f"[{task}] {count} x {name} from {origin} in {delay} minutes" + + @staticmethod + def icon_for_flight(flight: Flight) -> Optional[QIcon]: + """Returns the icon that should be displayed for the flight.""" + name = db.unit_type_name(flight.unit_type) + if name in AIRCRAFT_ICONS: + return QIcon(AIRCRAFT_ICONS[name]) + return None + + def add_flight(self, flight: Flight) -> None: + """Adds the given flight to the package.""" + self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) + self.package.add_flight(flight) + self.endInsertRows() + + def delete_flight_at_index(self, index: QModelIndex) -> None: + """Removes the flight at the given index from the package.""" + self.delete_flight(self.flight_at_index(index)) + + def delete_flight(self, flight: Flight) -> None: + """Removes the given flight from the package. + + If the flight is using claimed inventory, the caller is responsible for + returning that inventory. + """ + index = self.package.flights.index(flight) + self.beginRemoveRows(QModelIndex(), index, index) + self.package.remove_flight(flight) + self.endRemoveRows() + + def flight_at_index(self, index: QModelIndex) -> Flight: + """Returns the flight located at the given index.""" + return self.package.flights[index.row()] + + @property + def mission_target(self) -> MissionTarget: + """Returns the mission target of the package.""" + package = self.package + target = package.target + return target + + @property + def description(self) -> str: + """Returns the description of the package.""" + return self.package.package_description + + @property + def flights(self) -> Iterator[Flight]: + """Iterates over the flights in the package.""" + for flight in self.package.flights: + yield flight + + +class AtoModel(QAbstractListModel): + """The model for an AirTaskingOrder.""" + + def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None: + super().__init__() + self.game = game + self.ato = ato + self.package_models = DeletableChildModelManager(PackageModel) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(self.ato.packages) + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: + if not index.isValid(): + return None + if role == Qt.DisplayRole: + package = self.ato.packages[index.row()] + return f"{package.package_description} {package.target.name}" + return None + + def add_package(self, package: Package) -> None: + """Adds a package to the ATO.""" + self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) + self.ato.add_package(package) + self.endInsertRows() + + def delete_package_at_index(self, index: QModelIndex) -> None: + """Removes the package at the given index from the ATO.""" + self.delete_package(self.package_at_index(index)) + + def delete_package(self, package: Package) -> None: + """Removes the given package from the ATO.""" + self.package_models.release(package) + index = self.ato.packages.index(package) + self.beginRemoveRows(QModelIndex(), index, index) + self.ato.remove_package(package) + for flight in package.flights: + self.game.aircraft_inventory.return_from_flight(flight) + self.endRemoveRows() + + def package_at_index(self, index: QModelIndex) -> Package: + """Returns the package at the given index.""" + return self.ato.packages[index.row()] + + def replace_from_game(self, game: Optional[Game]) -> None: + """Updates the ATO object to match the updated game object. + + If the game is None (as is the case when no game has been loaded), an + empty ATO will be used. + """ + self.beginResetModel() + self.game = game + self.package_models.clear() + if self.game is not None: + self.ato = game.blue_ato + else: + self.ato = AirTaskingOrder() + self.endResetModel() + + def get_package_model(self, index: QModelIndex) -> PackageModel: + """Returns a model for the package at the given index.""" + return self.package_models.acquire(self.package_at_index(index)) + + @property + def packages(self) -> Iterator[PackageModel]: + """Iterates over all the packages in the ATO.""" + for package in self.ato.packages: + yield self.package_models.acquire(package) + + +class GameModel: + """A model for the Game object. + + This isn't a real Qt data model, but simplifies management of the game and + its ATO objects. + """ + def __init__(self) -> None: + self.game: Optional[Game] = None + # TODO: Add red ATO model, add cheat option to show red flight plan. + self.ato_model = AtoModel(self.game, AirTaskingOrder()) + + def set(self, game: Optional[Game]) -> None: + """Updates the managed Game object. + + The argument will be None when no game has been loaded. In this state, + much of the UI is still visible and needs to handle that behavior. To + simplify that case, the AtoModel will model an empty ATO when no game is + loaded. + """ + self.game = game + self.ato_model.replace_from_game(self.game) diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index ada6c94c..5c97dc72 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -1,14 +1,12 @@ -# URL for UI links import os from typing import Dict from PySide2.QtGui import QColor, QFont, QPixmap -from game.event import UnitsDeliveryEvent, FrontlineAttackEvent from theater.theatergroundobject import CATEGORY_MAP -from userdata.liberation_theme import get_theme_icons +from .liberation_theme import get_theme_icons -VERSION_STRING = "2.1.3" +VERSION_STRING = "2.1.4" URLS : Dict[str, str] = { "Manual": "https://github.com/khopa/dcs_liberation/wiki", @@ -28,20 +26,6 @@ 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) -# new themes can be added here -THEMES: Dict[int, Dict[str, str]] = { - 0: {'themeName': 'Vanilla', - 'themeFile': 'windows-style.css', - 'themeIcons': 'medium', - }, - - 1: {'themeName': 'DCS World', - 'themeFile': 'style-dcs.css', - 'themeIcons': 'light', - }, - -} - COLORS: Dict[str, QColor] = { "white": QColor(255, 255, 255), "white_transparent": QColor(255, 255, 255, 35), @@ -85,10 +69,10 @@ def load_icons(): ICONS["Hangar"] = QPixmap("./resources/ui/misc/hangar.png") ICONS["Terrain_Caucasus"] = QPixmap("./resources/ui/terrain_caucasus.gif") - ICONS["Terrain_Persian_Gulf"] = QPixmap("./resources/ui/terrain_pg.gif") + ICONS["Terrain_PersianGulf"] = QPixmap("./resources/ui/terrain_pg.gif") ICONS["Terrain_Nevada"] = QPixmap("./resources/ui/terrain_nevada.gif") ICONS["Terrain_Normandy"] = QPixmap("./resources/ui/terrain_normandy.gif") - ICONS["Terrain_Channel"] = QPixmap("./resources/ui/terrain_channel.gif") + ICONS["Terrain_TheChannel"] = QPixmap("./resources/ui/terrain_channel.gif") ICONS["Terrain_Syria"] = QPixmap("./resources/ui/terrain_syria.gif") ICONS["Dawn"] = QPixmap("./resources/ui/daytime/dawn.png") @@ -127,15 +111,12 @@ EVENT_ICONS: Dict[str, QPixmap] = {} def load_event_icons(): for image in os.listdir("./resources/ui/events/"): - print(image) if image.endswith(".PNG"): EVENT_ICONS[image[:-4]] = QPixmap(os.path.join("./resources/ui/events/", image)) def load_aircraft_icons(): for aircraft in os.listdir("./resources/ui/units/aircrafts/"): - print(aircraft) if aircraft.endswith(".jpg"): - print(aircraft[:-7] + " : " + os.path.join("./resources/ui/units/aircrafts/", aircraft) + " ") AIRCRAFT_ICONS[aircraft[:-7]] = QPixmap(os.path.join("./resources/ui/units/aircrafts/", aircraft)) AIRCRAFT_ICONS["F-16C_50"] = AIRCRAFT_ICONS["F-16C"] AIRCRAFT_ICONS["FA-18C_hornet"] = AIRCRAFT_ICONS["FA-18C"] @@ -144,7 +125,5 @@ def load_aircraft_icons(): def load_vehicle_icons(): for vehicle in os.listdir("./resources/ui/units/vehicles/"): - print(vehicle) if vehicle.endswith(".jpg"): - print(vehicle[:-7] + " : " + os.path.join("./resources/ui/units/vehicles/", vehicle) + " ") VEHICLES_ICONS[vehicle[:-7]] = QPixmap(os.path.join("./resources/ui/units/vehicles/", vehicle)) diff --git a/qt_ui/widgets/QFlightSizeSpinner.py b/qt_ui/widgets/QFlightSizeSpinner.py new file mode 100644 index 00000000..a2619507 --- /dev/null +++ b/qt_ui/widgets/QFlightSizeSpinner.py @@ -0,0 +1,13 @@ +"""Spin box for selecting the number of aircraft in a flight.""" +from PySide2.QtWidgets import QSpinBox + + +class QFlightSizeSpinner(QSpinBox): + """Spin box for selecting the number of aircraft in a flight.""" + + def __init__(self, min_size: int = 1, max_size: int = 4, + default_size: int = 2) -> None: + super().__init__() + self.setMinimum(min_size) + self.setMaximum(max_size) + self.setValue(default_size) diff --git a/qt_ui/widgets/QLabeledWidget.py b/qt_ui/widgets/QLabeledWidget.py new file mode 100644 index 00000000..88459896 --- /dev/null +++ b/qt_ui/widgets/QLabeledWidget.py @@ -0,0 +1,17 @@ +"""A layout containing a widget with an associated label.""" +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QHBoxLayout, QLabel, QWidget + + +class QLabeledWidget(QHBoxLayout): + """A layout containing a widget with an associated label. + + Best used for vertical forms, where the given widget is the input and the + label is used to name the input. + """ + + def __init__(self, text: str, widget: QWidget) -> None: + super().__init__() + self.addWidget(QLabel(text)) + self.addStretch() + self.addWidget(widget, alignment=Qt.AlignRight) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 30725095..fae3a11d 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -1,16 +1,17 @@ -from PySide2.QtWidgets import QFrame, QHBoxLayout, QPushButton, QVBoxLayout, QGroupBox +from typing import Optional -from game import Game -from qt_ui.widgets.QBudgetBox import QBudgetBox -from qt_ui.widgets.QFactionsInfos import QFactionsInfos -from qt_ui.windows.finances.QFinancesMenu import QFinancesMenu -from qt_ui.windows.stats.QStatsWindow import QStatsWindow -from qt_ui.widgets.QTurnCounter import QTurnCounter +from PySide2.QtWidgets import QFrame, QGroupBox, QHBoxLayout, QPushButton import qt_ui.uiconstants as CONST +from game import Game +from game.event import CAP, CAS, FrontlineAttackEvent +from qt_ui.widgets.QBudgetBox import QBudgetBox +from qt_ui.widgets.QFactionsInfos import QFactionsInfos +from qt_ui.widgets.QTurnCounter import QTurnCounter from qt_ui.windows.GameUpdateSignal import GameUpdateSignal -from qt_ui.windows.mission.QMissionPlanning import QMissionPlanning from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow +from qt_ui.windows.stats.QStatsWindow import QStatsWindow +from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow class QTopPanel(QFrame): @@ -33,10 +34,10 @@ class QTopPanel(QFrame): self.passTurnButton.setProperty("style", "btn-primary") self.passTurnButton.clicked.connect(self.passTurn) - self.proceedButton = QPushButton("Mission Planning") + self.proceedButton = QPushButton("Take off") self.proceedButton.setIcon(CONST.ICONS["Proceed"]) - self.proceedButton.setProperty("style", "btn-success") - self.proceedButton.clicked.connect(self.proceed) + self.proceedButton.setProperty("style", "start-button") + self.proceedButton.clicked.connect(self.launch_mission) if self.game and self.game.turn == 0: self.proceedButton.setEnabled(False) @@ -75,7 +76,7 @@ class QTopPanel(QFrame): self.layout.setContentsMargins(0,0,0,0) self.setLayout(self.layout) - def setGame(self, game:Game): + def setGame(self, game: Optional[Game]): self.game = game if game is not None: self.turnCounter.setCurrentTurn(self.game.turn, self.game.current_day) @@ -100,9 +101,31 @@ class QTopPanel(QFrame): GameUpdateSignal.get_instance().updateGame(self.game) self.proceedButton.setEnabled(True) - def proceed(self): - self.subwindow = QMissionPlanning(self.game) - self.subwindow.show() + def launch_mission(self): + """Finishes planning and waits for mission completion.""" + # TODO: Refactor this nonsense. + game_event = None + for event in self.game.events: + if isinstance(event, + FrontlineAttackEvent) and event.is_player_attacking: + game_event = event + if game_event is None: + game_event = FrontlineAttackEvent( + self.game, + self.game.theater.controlpoints[0], + self.game.theater.controlpoints[0], + self.game.theater.controlpoints[0].position, + self.game.player_name, + self.game.enemy_name) + game_event.is_awacs_enabled = True + game_event.ca_slots = 1 + game_event.departure_cp = self.game.theater.controlpoints[0] + game_event.player_attacking({CAS: {}, CAP: {}}) + game_event.depart_from = self.game.theater.controlpoints[0] + + self.game.initiate_event(game_event) + waiting = QWaitingForMissionResultWindow(game_event, self.game) + waiting.show() def budget_update(self, game:Game): self.budgetBox.setGame(game) diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py new file mode 100644 index 00000000..e4c178c4 --- /dev/null +++ b/qt_ui/widgets/ato.py @@ -0,0 +1,252 @@ +"""Widgets for displaying air tasking orders.""" +import logging +from typing import Optional + +from PySide2.QtCore import QItemSelectionModel, QModelIndex, QSize, Qt +from PySide2.QtWidgets import ( + QAbstractItemView, + QGroupBox, + QHBoxLayout, + QListView, + QPushButton, + QSplitter, + QVBoxLayout, +) + +from gen.ato import Package +from gen.flights.flight import Flight +from ..models import AtoModel, GameModel, NullListModel, PackageModel +from qt_ui.windows.GameUpdateSignal import GameUpdateSignal + + +class QFlightList(QListView): + """List view for displaying the flights of a package.""" + + def __init__(self, model: Optional[PackageModel]) -> None: + super().__init__() + self.package_model = model + self.set_package(model) + self.setIconSize(QSize(91, 24)) + self.setSelectionBehavior(QAbstractItemView.SelectItems) + + def set_package(self, model: Optional[PackageModel]) -> None: + """Sets the package model to display.""" + if model is None: + self.disconnect_model() + else: + self.package_model = model + self.setModel(model) + # noinspection PyUnresolvedReferences + model.deleted.connect(self.disconnect_model) + self.selectionModel().setCurrentIndex( + model.index(0, 0, QModelIndex()), + QItemSelectionModel.Select + ) + + def disconnect_model(self) -> None: + """Clears the listview of any model attachments. + + Displays an empty list until set_package is called with a valid model. + """ + model = self.model() + if model is not None and isinstance(model, PackageModel): + model.deleted.disconnect(self.disconnect_model) + self.setModel(NullListModel()) + + @property + def selected_item(self) -> Optional[Flight]: + """Returns the selected flight, if any.""" + index = self.currentIndex() + if not index.isValid(): + return None + return self.package_model.flight_at_index(index) + + +class QFlightPanel(QGroupBox): + """The flight display portion of the ATO panel. + + Displays the flights assigned to the selected package, and includes edit and + delete buttons for flight management. + """ + + def __init__(self, game_model: GameModel, + package_model: Optional[PackageModel] = None) -> None: + super().__init__("Flights") + self.game_model = game_model + self.package_model = package_model + + self.vbox = QVBoxLayout() + self.setLayout(self.vbox) + + self.flight_list = QFlightList(package_model) + self.vbox.addWidget(self.flight_list) + + self.button_row = QHBoxLayout() + self.vbox.addLayout(self.button_row) + + self.edit_button = QPushButton("Edit") + self.edit_button.clicked.connect(self.on_edit) + self.button_row.addWidget(self.edit_button) + + self.delete_button = QPushButton("Delete") + # noinspection PyTypeChecker + self.delete_button.setProperty("style", "btn-danger") + self.delete_button.clicked.connect(self.on_delete) + self.button_row.addWidget(self.delete_button) + + self.selection_changed.connect(self.on_selection_changed) + self.on_selection_changed() + + def set_package(self, model: Optional[PackageModel]) -> None: + """Sets the package model to display.""" + self.package_model = model + self.flight_list.set_package(model) + self.on_selection_changed() + + @property + def selection_changed(self): + """Returns the signal emitted when the flight selection changes.""" + return self.flight_list.selectionModel().selectionChanged + + def on_selection_changed(self) -> None: + """Updates the status of the edit and delete buttons.""" + index = self.flight_list.currentIndex() + enabled = index.isValid() + self.edit_button.setEnabled(enabled) + self.delete_button.setEnabled(enabled) + + def on_edit(self) -> None: + """Opens the flight edit dialog.""" + index = self.flight_list.currentIndex() + if not index.isValid(): + logging.error(f"Cannot edit flight when no flight is selected.") + return + from qt_ui.dialogs import Dialog + Dialog.open_edit_flight_dialog( + self.package_model, self.package_model.flight_at_index(index) + ) + + def on_delete(self) -> None: + """Removes the selected flight from the package.""" + index = self.flight_list.currentIndex() + if not index.isValid(): + logging.error(f"Cannot delete flight when no flight is selected.") + return + self.game_model.game.aircraft_inventory.return_from_flight( + self.flight_list.selected_item) + self.package_model.delete_flight_at_index(index) + GameUpdateSignal.get_instance().redraw_flight_paths() + + +class QPackageList(QListView): + """List view for displaying the packages of an ATO.""" + + def __init__(self, model: AtoModel) -> None: + super().__init__() + self.ato_model = model + self.setModel(model) + self.setIconSize(QSize(91, 24)) + self.setSelectionBehavior(QAbstractItemView.SelectItems) + + @property + def selected_item(self) -> Optional[Package]: + """Returns the selected package, if any.""" + index = self.currentIndex() + if not index.isValid(): + return None + return self.ato_model.package_at_index(index) + + +class QPackagePanel(QGroupBox): + """The package display portion of the ATO panel. + + Displays the package assigned to the player's ATO, and includes edit and + delete buttons for package management. + """ + + def __init__(self, model: AtoModel) -> None: + super().__init__("Packages") + self.ato_model = model + self.ato_model.layoutChanged.connect(self.on_selection_changed) + + self.vbox = QVBoxLayout() + self.setLayout(self.vbox) + + self.package_list = QPackageList(self.ato_model) + self.vbox.addWidget(self.package_list) + + self.button_row = QHBoxLayout() + self.vbox.addLayout(self.button_row) + + self.edit_button = QPushButton("Edit") + self.edit_button.clicked.connect(self.on_edit) + self.button_row.addWidget(self.edit_button) + + self.delete_button = QPushButton("Delete") + # noinspection PyTypeChecker + self.delete_button.setProperty("style", "btn-danger") + self.delete_button.clicked.connect(self.on_delete) + self.button_row.addWidget(self.delete_button) + + self.selection_changed.connect(self.on_selection_changed) + self.on_selection_changed() + + @property + def selection_changed(self): + """Returns the signal emitted when the flight selection changes.""" + return self.package_list.selectionModel().selectionChanged + + def on_selection_changed(self) -> None: + """Updates the status of the edit and delete buttons.""" + index = self.package_list.currentIndex() + enabled = index.isValid() + self.edit_button.setEnabled(enabled) + self.delete_button.setEnabled(enabled) + + def on_edit(self) -> None: + """Opens the package edit dialog.""" + index = self.package_list.currentIndex() + if not index.isValid(): + logging.error(f"Cannot edit package when no package is selected.") + return + from qt_ui.dialogs import Dialog + Dialog.open_edit_package_dialog(self.ato_model.get_package_model(index)) + + def on_delete(self) -> None: + """Removes the package from the ATO.""" + index = self.package_list.currentIndex() + if not index.isValid(): + logging.error(f"Cannot delete package when no package is selected.") + return + self.ato_model.delete_package_at_index(index) + GameUpdateSignal.get_instance().redraw_flight_paths() + + +class QAirTaskingOrderPanel(QSplitter): + """A split panel for displaying the packages and flights of an ATO. + + Used as the left-bar of the main UI. The top half of the panel displays the + packages of the player's ATO, and the bottom half displays the flights of + the selected package. + """ + def __init__(self, game_model: GameModel) -> None: + super().__init__(Qt.Vertical) + self.ato_model = game_model.ato_model + + self.package_panel = QPackagePanel(self.ato_model) + self.package_panel.selection_changed.connect(self.on_package_change) + self.ato_model.rowsInserted.connect(self.on_package_change) + self.addWidget(self.package_panel) + + self.flight_panel = QFlightPanel(game_model) + self.addWidget(self.flight_panel) + + def on_package_change(self) -> None: + """Sets the newly selected flight for display in the bottom panel.""" + index = self.package_panel.package_list.currentIndex() + if index.isValid(): + self.flight_panel.set_package( + self.ato_model.get_package_model(index) + ) + else: + self.flight_panel.set_package(None) diff --git a/qt_ui/widgets/combos/QAircraftTypeSelector.py b/qt_ui/widgets/combos/QAircraftTypeSelector.py new file mode 100644 index 00000000..1f490e4d --- /dev/null +++ b/qt_ui/widgets/combos/QAircraftTypeSelector.py @@ -0,0 +1,16 @@ +"""Combo box for selecting aircraft types.""" +from typing import Iterable + +from PySide2.QtWidgets import QComboBox + +from dcs.planes import PlaneType + + +class QAircraftTypeSelector(QComboBox): + """Combo box for selecting among the given aircraft types.""" + + def __init__(self, aircraft_types: Iterable[PlaneType]) -> None: + super().__init__() + for aircraft in aircraft_types: + self.addItem(f"{aircraft.id}", userData=aircraft) + self.model().sort(0) diff --git a/qt_ui/widgets/combos/QFlightTypeComboBox.py b/qt_ui/widgets/combos/QFlightTypeComboBox.py new file mode 100644 index 00000000..9577b26c --- /dev/null +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -0,0 +1,105 @@ +"""Combo box for selecting a flight's task type.""" +import logging +from typing import Iterator + +from PySide2.QtWidgets import QComboBox + +from gen.flights.flight import FlightType +from theater import ( + ConflictTheater, + ControlPoint, + FrontLine, + MissionTarget, + TheaterGroundObject, +) + + +class QFlightTypeComboBox(QComboBox): + """Combo box for selecting a flight task type.""" + + COMMON_ENEMY_MISSIONS = [ + FlightType.ESCORT, + FlightType.TARCAP, + FlightType.SEAD, + FlightType.DEAD, + # TODO: FlightType.ELINT, + # TODO: FlightType.EWAR, + # TODO: FlightType.RECON, + ] + + FRIENDLY_AIRBASE_MISSIONS = [ + FlightType.CAP, + # TODO: FlightType.INTERCEPTION + # TODO: FlightType.LOGISTICS + ] + + FRIENDLY_CARRIER_MISSIONS = [ + FlightType.BARCAP, + # TODO: FlightType.INTERCEPTION + # TODO: Buddy tanking for the A-4? + # TODO: Rescue chopper? + # TODO: Inter-ship logistics? + ] + + ENEMY_CARRIER_MISSIONS = [ + FlightType.ESCORT, + FlightType.TARCAP, + # TODO: FlightType.ANTISHIP + ] + + ENEMY_AIRBASE_MISSIONS = [ + # TODO: FlightType.STRIKE + ] + COMMON_ENEMY_MISSIONS + + FRIENDLY_GROUND_OBJECT_MISSIONS = [ + FlightType.CAP, + # TODO: FlightType.LOGISTICS + # TODO: FlightType.TROOP_TRANSPORT + ] + + ENEMY_GROUND_OBJECT_MISSIONS = [ + FlightType.STRIKE, + ] + COMMON_ENEMY_MISSIONS + + FRONT_LINE_MISSIONS = [ + FlightType.CAS, + # TODO: FlightType.TROOP_TRANSPORT + # TODO: FlightType.EVAC + ] + COMMON_ENEMY_MISSIONS + + # TODO: Add BAI missions after we have useful BAI targets. + + def __init__(self, theater: ConflictTheater, target: MissionTarget) -> None: + super().__init__() + self.theater = theater + self.target = target + for mission_type in self.mission_types_for_target(): + self.addItem(mission_type.name, userData=mission_type) + + def mission_types_for_target(self) -> Iterator[FlightType]: + if isinstance(self.target, ControlPoint): + friendly = self.target.captured + fleet = self.target.is_fleet + if friendly: + if fleet: + yield from self.FRIENDLY_CARRIER_MISSIONS + else: + yield from self.FRIENDLY_AIRBASE_MISSIONS + else: + if fleet: + yield from self.ENEMY_CARRIER_MISSIONS + else: + yield from self.ENEMY_AIRBASE_MISSIONS + elif isinstance(self.target, TheaterGroundObject): + # TODO: Filter more based on the category. + friendly = self.target.parent_control_point(self.theater).captured + if friendly: + yield from self.FRIENDLY_GROUND_OBJECT_MISSIONS + else: + yield from self.ENEMY_GROUND_OBJECT_MISSIONS + elif isinstance(self.target, FrontLine): + yield from self.FRONT_LINE_MISSIONS + else: + logging.error( + f"Unhandled target type: {self.target.__class__.__name__}" + ) diff --git a/qt_ui/widgets/combos/QOriginAirfieldSelector.py b/qt_ui/widgets/combos/QOriginAirfieldSelector.py new file mode 100644 index 00000000..b0995a6b --- /dev/null +++ b/qt_ui/widgets/combos/QOriginAirfieldSelector.py @@ -0,0 +1,41 @@ +"""Combo box for selecting a departure airfield.""" +from typing import Iterable + +from PySide2.QtWidgets import QComboBox + +from dcs.planes import PlaneType +from game.inventory import GlobalAircraftInventory +from theater.controlpoint import ControlPoint + + +class QOriginAirfieldSelector(QComboBox): + """A combo box for selecting a flight's departure airfield. + + The combo box will automatically be populated with all departure airfields + that have unassigned inventory of the given aircraft type. + """ + + def __init__(self, global_inventory: GlobalAircraftInventory, + origins: Iterable[ControlPoint], + aircraft: PlaneType) -> None: + super().__init__() + self.global_inventory = global_inventory + self.origins = list(origins) + self.aircraft = aircraft + self.rebuild_selector() + + def change_aircraft(self, aircraft: PlaneType) -> None: + if self.aircraft == aircraft: + return + self.aircraft = aircraft + self.rebuild_selector() + + def rebuild_selector(self) -> None: + self.clear() + for origin in self.origins: + inventory = self.global_inventory.for_control_point(origin) + available = inventory.available(self.aircraft) + if available: + self.addItem(f"{origin.name} ({available} available)", origin) + self.model().sort(0) + self.update() diff --git a/qt_ui/widgets/map/QFrontLine.py b/qt_ui/widgets/map/QFrontLine.py new file mode 100644 index 00000000..f1425893 --- /dev/null +++ b/qt_ui/widgets/map/QFrontLine.py @@ -0,0 +1,82 @@ +"""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 qt_ui.dialogs import Dialog +from qt_ui.windows.mission.QPackageDialog import QNewPackageDialog +from theater.missiontarget import MissionTarget + + +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: MissionTarget) -> None: + super().__init__(x1, y1, x2, y2) + self.mission_target = mission_target + 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) + + 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) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 41194ca0..3d775367 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -1,26 +1,34 @@ -import typing -from typing import Dict +import logging +from typing import Dict, List, Optional, Tuple -from PySide2 import QtCore -from PySide2.QtCore import Qt, QRect, QPointF -from PySide2.QtGui import QPixmap, QBrush, QColor, QWheelEvent, QPen, QFont -from PySide2.QtWidgets import QGraphicsView, QFrame, QGraphicsOpacityEffect +from PySide2.QtCore import Qt +from PySide2.QtGui import QBrush, QColor, QPen, QPixmap, QWheelEvent +from PySide2.QtWidgets import ( + QFrame, + QGraphicsItem, + QGraphicsOpacityEffect, + QGraphicsScene, + QGraphicsView, +) from dcs import Point from dcs.mapping import point_from_heading import qt_ui.uiconstants as CONST from game import Game, db from game.data.radar_db import UNITS_WITH_RADAR -from game.event import UnitsDeliveryEvent, Event, ControlPointType from gen import Conflict +from gen.flights.flight import Flight +from qt_ui.models import GameModel from qt_ui.widgets.map.QLiberationScene import QLiberationScene from qt_ui.widgets.map.QMapControlPoint import QMapControlPoint from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject +from qt_ui.widgets.map.QFrontLine import QFrontLine from qt_ui.windows.GameUpdateSignal import GameUpdateSignal -from theater import ControlPoint +from theater import ControlPoint, FrontLine class QLiberationMap(QGraphicsView): + WAYPOINT_SIZE = 4 instance = None display_rules: Dict[str, bool] = { @@ -32,11 +40,13 @@ class QLiberationMap(QGraphicsView): "flight_paths": False } - def __init__(self, game: Game): + def __init__(self, game_model: GameModel): super(QLiberationMap, self).__init__() QLiberationMap.instance = self + self.game_model = game_model + self.game: Optional[Game] = game_model.game - self.frontline_vector_cache = {} + self.flight_path_items: List[QGraphicsItem] = [] self.setMinimumSize(800,600) self.setMaximumHeight(2160) @@ -45,7 +55,11 @@ class QLiberationMap(QGraphicsView): self.factorized = 1 self.init_scene() self.connectSignals() - self.setGame(game) + self.setGame(game_model.game) + + GameUpdateSignal.get_instance().flight_paths_changed.connect( + lambda: self.draw_flight_plans(self.scene()) + ) def init_scene(self): scene = QLiberationScene(self) @@ -59,9 +73,9 @@ class QLiberationMap(QGraphicsView): def connectSignals(self): GameUpdateSignal.get_instance().gameupdated.connect(self.setGame) - def setGame(self, game: Game): + def setGame(self, game: Optional[Game]): self.game = game - print("Reloading Map Canvas") + logging.debug("Reloading Map Canvas") if self.game is not None: self.reload_scene() @@ -124,8 +138,10 @@ class QLiberationMap(QGraphicsView): pos = self._transform_point(cp.position) - scene.addItem(QMapControlPoint(self, pos[0] - CONST.CP_SIZE / 2, pos[1] - CONST.CP_SIZE / 2, CONST.CP_SIZE, - CONST.CP_SIZE, cp, self.game)) + scene.addItem(QMapControlPoint(self, pos[0] - CONST.CP_SIZE / 2, + pos[1] - CONST.CP_SIZE / 2, + CONST.CP_SIZE, + CONST.CP_SIZE, cp, self.game_model)) if cp.captured: pen = QPen(brush=CONST.COLORS[playerColor]) @@ -168,38 +184,7 @@ class QLiberationMap(QGraphicsView): if self.get_display_rule("lines"): self.scene_create_lines_for_cp(cp, playerColor, enemyColor) - for cp in self.game.theater.controlpoints: - - if cp.captured: - pen = QPen(brush=CONST.COLORS[playerColor]) - brush = CONST.COLORS[playerColor+"_transparent"] - - flight_path_pen = QPen(brush=CONST.COLORS[playerColor]) - flight_path_pen.setColor(CONST.COLORS[playerColor]) - - else: - pen = QPen(brush=CONST.COLORS[enemyColor]) - brush = CONST.COLORS[enemyColor+"_transparent"] - - flight_path_pen = QPen(brush=CONST.COLORS[enemyColor]) - flight_path_pen.setColor(CONST.COLORS[enemyColor]) - - flight_path_pen.setWidth(1) - flight_path_pen.setStyle(Qt.DashDotLine) - - pos = self._transform_point(cp.position) - if self.get_display_rule("flight_paths"): - if cp.id in self.game.planners.keys(): - planner = self.game.planners[cp.id] - for flight in planner.flights: - scene.addEllipse(pos[0], pos[1], 4, 4) - prev_pos = list(pos) - for point in flight.points: - new_pos = self._transform_point(Point(point.x, point.y)) - scene.addLine(prev_pos[0]+2, prev_pos[1]+2, new_pos[0]+2, new_pos[1]+2, flight_path_pen) - scene.addEllipse(new_pos[0], new_pos[1], 4, 4, pen, brush) - prev_pos = list(new_pos) - scene.addLine(prev_pos[0] + 2, prev_pos[1] + 2, pos[0] + 2, pos[1] + 2, flight_path_pen) + self.draw_flight_plans(scene) for cp in self.game.theater.controlpoints: pos = self._transform_point(cp.position) @@ -209,6 +194,53 @@ class QLiberationMap(QGraphicsView): text.setDefaultTextColor(Qt.white) text.setPos(pos[0] + CONST.CP_SIZE + 1, pos[1] - CONST.CP_SIZE / 2 + 1) + def draw_flight_plans(self, scene) -> None: + for item in self.flight_path_items: + try: + scene.removeItem(item) + except RuntimeError: + # Something may have caused those items to already be removed. + pass + self.flight_path_items.clear() + if not self.get_display_rule("flight_paths"): + return + for package in self.game_model.ato_model.packages: + for flight in package.flights: + self.draw_flight_plan(scene, flight) + + def draw_flight_plan(self, scene: QGraphicsScene, flight: Flight) -> None: + is_player = flight.from_cp.captured + pos = self._transform_point(flight.from_cp.position) + + self.draw_waypoint(scene, pos, is_player) + prev_pos = tuple(pos) + for point in flight.points: + new_pos = self._transform_point(Point(point.x, point.y)) + self.draw_flight_path(scene, prev_pos, new_pos, is_player) + self.draw_waypoint(scene, new_pos, is_player) + prev_pos = tuple(new_pos) + self.draw_flight_path(scene, prev_pos, pos, is_player) + + def draw_waypoint(self, scene: QGraphicsScene, position: Tuple[int, int], + player: bool) -> None: + waypoint_pen = self.waypoint_pen(player) + waypoint_brush = self.waypoint_brush(player) + self.flight_path_items.append(scene.addEllipse( + position[0], position[1], self.WAYPOINT_SIZE, + self.WAYPOINT_SIZE, waypoint_pen, waypoint_brush + )) + + def draw_flight_path(self, scene: QGraphicsScene, pos0: Tuple[int, int], + pos1: Tuple[int, int], player: bool): + flight_path_pen = self.flight_path_pen(player) + # Draw the line to the *middle* of the waypoint. + offset = self.WAYPOINT_SIZE // 2 + self.flight_path_items.append(scene.addLine( + pos0[0] + offset, pos0[1] + offset, + pos1[0] + offset, pos1[1] + offset, + flight_path_pen + )) + def scene_create_lines_for_cp(self, cp: ControlPoint, playerColor, enemyColor): scene = self.scene() pos = self._transform_point(cp.position) @@ -234,31 +266,12 @@ class QLiberationMap(QGraphicsView): p1 = point_from_heading(pos2[0], pos2[1], h+180, 25) p2 = point_from_heading(pos2[0], pos2[1], h, 25) - frontline_pen = QPen(brush=CONST.COLORS["bright_red"]) - frontline_pen.setColor(CONST.COLORS["orange"]) - frontline_pen.setWidth(8) - scene.addLine(p1[0], p1[1], p2[0], p2[1], pen=frontline_pen) + scene.addItem(QFrontLine(p1[0], p1[1], p2[0], p2[1], + FrontLine(cp, connected_cp))) else: scene.addLine(pos[0], pos[1], pos2[0], pos2[1], pen=pen) - def _frontline_vector(self, from_cp: ControlPoint, to_cp: ControlPoint): - # Cache mechanism to avoid performing frontline vector computation on every frame - key = str(from_cp.id) + "_" + str(to_cp.id) - if key in self.frontline_vector_cache: - return self.frontline_vector_cache[key] - else: - frontline = Conflict.frontline_vector(from_cp, to_cp, self.game.theater) - self.frontline_vector_cache[key] = frontline - return frontline - - def _frontline_center(self, from_cp: ControlPoint, to_cp: ControlPoint) -> typing.Optional[Point]: - frontline_vector = self._frontline_vector(from_cp, to_cp) - if frontline_vector: - return frontline_vector[0].point_from_heading(frontline_vector[1], frontline_vector[2]/2) - else: - return None - def wheelEvent(self, event: QWheelEvent): if event.angleDelta().y() > 0: @@ -308,6 +321,29 @@ class QLiberationMap(QGraphicsView): return X > treshold and X or treshold, Y > treshold and Y or treshold + def base_faction_color_name(self, player: bool) -> str: + if player: + return self.game.get_player_color() + else: + return self.game.get_enemy_color() + + def waypoint_pen(self, player: bool) -> QPen: + name = self.base_faction_color_name(player) + return QPen(brush=CONST.COLORS[name]) + + def waypoint_brush(self, player: bool) -> QColor: + name = self.base_faction_color_name(player) + return CONST.COLORS[f"{name}_transparent"] + + def flight_path_pen(self, player: bool) -> QPen: + name = self.base_faction_color_name(player) + color = CONST.COLORS[name] + pen = QPen(brush=color) + pen.setColor(color) + pen.setWidth(1) + pen.setStyle(Qt.DashDotLine) + return pen + def addBackground(self): scene = self.scene() diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py index 09061e16..f5b2e1c4 100644 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ b/qt_ui/widgets/map/QMapControlPoint.py @@ -1,100 +1,65 @@ -from PySide2.QtCore import QRect, Qt +from typing import Optional + from PySide2.QtGui import QColor, QPainter -from PySide2.QtWidgets import QGraphicsRectItem, QGraphicsSceneHoverEvent, QGraphicsSceneContextMenuEvent, QMenu, \ - QAction, QGraphicsSceneMouseEvent -import qt_ui.uiconstants as CONST -from game import Game +import qt_ui.uiconstants as const +from qt_ui.models import GameModel from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 -from theater import ControlPoint, db +from theater import ControlPoint +from .QMapObject import QMapObject -class QMapControlPoint(QGraphicsRectItem): - - def __init__(self, parent, x: float, y: float, w: float, h: float, model: ControlPoint, game: Game): - super(QMapControlPoint, self).__init__(x, y, w, h) - self.model = model - self.game = game +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.setAcceptHoverEvents(True) self.setZValue(1) - self.setToolTip(self.model.name) - - - def paint(self, painter, option, widget=None): - #super(QMapControlPoint, self).paint(painter, option, widget) + self.setToolTip(self.control_point.name) + self.base_details_dialog: Optional[QBaseMenu2] = None + def paint(self, painter, option, widget=None) -> None: if self.parent.get_display_rule("cp"): painter.save() painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(self.brush_color) painter.setPen(self.pen_color) - if self.model.has_runway(): + if self.control_point.has_runway(): if self.isUnderMouse(): - painter.setBrush(CONST.COLORS["white"]) + painter.setBrush(const.COLORS["white"]) painter.setPen(self.pen_color) r = option.rect painter.drawEllipse(r.x(), r.y(), r.width(), r.height()) - - #gauge = QRect(r.x(), - # r.y()+CONST.CP_SIZE/2 + 2, - # r.width(), - # CONST.CP_SIZE / 4) - - #painter.setBrush(CONST.COLORS["bright_red"]) - #painter.setPen(CONST.COLORS["black"]) - #painter.drawRect(gauge) - - #gauge2 = QRect(r.x(), - # r.y() + CONST.CP_SIZE / 2 + 2, - # r.width()*self.model.base.strength, - # CONST.CP_SIZE / 4) - - #painter.setBrush(CONST.COLORS["green"]) - #painter.drawRect(gauge2) - else: - # TODO : not drawing sunk carriers. Can be improved to display sunk carrier. - pass + # TODO: Draw sunk carriers differently. + # Either don't draw them at all, or perhaps use a sunk ship icon. painter.restore() - def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): - self.update() - self.setCursor(Qt.PointingHandCursor) - - def mouseMoveEvent(self, event:QGraphicsSceneMouseEvent): - self.update() - - def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent): - self.update() - - def mousePressEvent(self, event:QGraphicsSceneMouseEvent): - self.openBaseMenu() - #self.contextMenuEvent(event) - - def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent): - - if self.model.captured: - openBaseMenu = QAction("Open base menu") - else: - openBaseMenu = QAction("Open intel menu") - - openBaseMenu.triggered.connect(self.openBaseMenu) - - menu = QMenu("Menu", self.parent) - menu.addAction(openBaseMenu) - menu.exec_(event.screenPos()) - - @property - def brush_color(self)->QColor: - return self.model.captured and CONST.COLORS["blue"] or CONST.COLORS["super_red"] + 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 self.model.captured and CONST.COLORS["white"] or CONST.COLORS["white"] + return const.COLORS["white"] - def openBaseMenu(self): - self.baseMenu = QBaseMenu2(self.window(), self.model, self.game) - self.baseMenu.show() \ No newline at end of file + @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() diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py index a79ce1ab..1ed9f3d2 100644 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ b/qt_ui/widgets/map/QMapGroundObject.py @@ -1,86 +1,86 @@ -from PySide2.QtCore import QPoint, QRect, QPointF, Qt -from PySide2.QtGui import QPainter, QBrush -from PySide2.QtWidgets import QGraphicsRectItem, QGraphicsItem, QGraphicsSceneHoverEvent, QGraphicsSceneMouseEvent +from typing import List, Optional -import qt_ui.uiconstants as CONST -from game import db, Game +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 qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu from theater import TheaterGroundObject, ControlPoint +from .QMapObject import QMapObject -class QMapGroundObject(QGraphicsRectItem): - - def __init__(self, parent, x: float, y: float, w: float, h: float, cp: ControlPoint, model: TheaterGroundObject, game:Game, buildings=[]): - super(QMapGroundObject, self).__init__(x, y, w, h) - self.model = model - self.cp = cp +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.setAcceptHoverEvents(True) self.setZValue(2) - self.buildings = buildings + self.buildings = buildings if buildings is not None else [] self.setFlag(QGraphicsItem.ItemIgnoresTransformations, False) + self.ground_object_dialog: Optional[QGroundObjectMenu] = None - if len(self.model.groups) > 0: + if self.ground_object.groups: units = {} - for g in self.model.groups: - print(g) + for g in self.ground_object.groups: for u in g.units: - if u.type in units.keys(): + if u.type in units: units[u.type] = units[u.type]+1 else: units[u.type] = 1 - tooltip = "[" + self.model.obj_name + "]" + "\n" + tooltip = "[" + self.ground_object.obj_name + "]" + "\n" for unit in units.keys(): tooltip = tooltip + str(unit) + "x" + str(units[unit]) + "\n" self.setToolTip(tooltip[:-1]) else: - tooltip = "[" + self.model.obj_name + "]" + "\n" + tooltip = "[" + self.ground_object.obj_name + "]" + "\n" for building in buildings: if not building.is_dead: tooltip = tooltip + str(building.dcs_identifier) + "\n" self.setToolTip(tooltip[:-1]) - def mousePressEvent(self, event:QGraphicsSceneMouseEvent): - self.openEditionMenu() - - def paint(self, painter, option, widget=None): - #super(QMapControlPoint, self).paint(painter, option, widget) - - playerIcons = "_blue" - enemyIcons = "" + def paint(self, painter, option, widget=None) -> None: + player_icons = "_blue" + enemy_icons = "" if self.parent.get_display_rule("go"): painter.save() - cat = self.model.category - if cat == "aa" and self.model.sea_object: + cat = self.ground_object.category + if cat == "aa" and self.ground_object.sea_object: cat = "ship" - rect = QRect(option.rect.x()+2,option.rect.y(),option.rect.width()-2,option.rect.height()) + rect = QRect(option.rect.x() + 2, option.rect.y(), + option.rect.width() - 2, option.rect.height()) - is_dead = self.model.is_dead + is_dead = self.ground_object.is_dead for building in self.buildings: if not building.is_dead: is_dead = False break - if not is_dead and not self.cp.captured: - painter.drawPixmap(rect, CONST.ICONS[cat + enemyIcons]) + if not is_dead and not self.control_point.captured: + painter.drawPixmap(rect, const.ICONS[cat + enemy_icons]) elif not is_dead: - painter.drawPixmap(rect, CONST.ICONS[cat + playerIcons]) + painter.drawPixmap(rect, const.ICONS[cat + player_icons]) else: - painter.drawPixmap(rect, CONST.ICONS["destroyed"]) + painter.drawPixmap(rect, const.ICONS["destroyed"]) - self.drawHealthGauge(painter, option) + self.draw_health_gauge(painter, option) painter.restore() - def drawHealthGauge(self, painter, option): + def draw_health_gauge(self, painter, option) -> None: units_alive = 0 units_dead = 0 - if len(self.model.groups) == 0: + if len(self.ground_object.groups) == 0: for building in self.buildings: if building.dcs_identifier in FORTIFICATION_BUILDINGS: continue @@ -89,7 +89,7 @@ class QMapGroundObject(QGraphicsRectItem): else: units_alive += 1 - for g in self.model.groups: + for g in self.ground_object.groups: units_alive += len(g.units) if hasattr(g, "units_losts"): units_dead += len(g.units_losts) @@ -97,22 +97,18 @@ class QMapGroundObject(QGraphicsRectItem): 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 hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): - self.update() - self.setCursor(Qt.PointingHandCursor) - - def mouseMoveEvent(self, event:QGraphicsSceneMouseEvent): - self.update() - self.setCursor(Qt.PointingHandCursor) - - def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent): - self.update() - - def openEditionMenu(self): - self.editionMenu = QGroundObjectMenu(self.window(), self.model, self.buildings, self.cp, self.game) - self.editionMenu.show() + 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() diff --git a/qt_ui/widgets/map/QMapObject.py b/qt_ui/widgets/map/QMapObject.py new file mode 100644 index 00000000..c98cce5e --- /dev/null +++ b/qt_ui/widgets/map/QMapObject.py @@ -0,0 +1,75 @@ +"""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 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 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) + + new_package_action = QAction(f"New package") + new_package_action.triggered.connect(self.open_new_package_dialog) + menu.addAction(new_package_action) + + 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) diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py index dd32dd58..8a52d555 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Optional + from PySide2.QtCore import QObject, Signal from game import Game @@ -19,21 +23,31 @@ class GameUpdateSignal(QObject): budgetupdated = Signal(Game) debriefingReceived = Signal(DebriefingSignal) + flight_paths_changed = Signal() + def __init__(self): super(GameUpdateSignal, self).__init__() GameUpdateSignal.instance = self - def updateGame(self, game: Game): + def redraw_flight_paths(self) -> None: + # noinspection PyUnresolvedReferences + self.flight_paths_changed.emit() + + def updateGame(self, game: Optional[Game]): + # noinspection PyUnresolvedReferences self.gameupdated.emit(game) def updateBudget(self, game: Game): + # noinspection PyUnresolvedReferences self.budgetupdated.emit(game) def sendDebriefing(self, game: Game, gameEvent: Event, debriefing: Debriefing): sig = DebriefingSignal(game, gameEvent, debriefing) + # noinspection PyUnresolvedReferences self.gameupdated.emit(game) + # noinspection PyUnresolvedReferences self.debriefingReceived.emit(sig) @staticmethod - def get_instance(): + def get_instance() -> GameUpdateSignal: return GameUpdateSignal.instance diff --git a/qt_ui/windows/QDebriefingWindow.py b/qt_ui/windows/QDebriefingWindow.py index 752d19d5..e0ecce57 100644 --- a/qt_ui/windows/QDebriefingWindow.py +++ b/qt_ui/windows/QDebriefingWindow.py @@ -1,8 +1,15 @@ from PySide2.QtGui import QIcon, QPixmap -from PySide2.QtWidgets import QLabel, QDialog, QVBoxLayout, QGroupBox, QGridLayout, QPushButton +from PySide2.QtWidgets import ( + QDialog, + QGridLayout, + QGroupBox, + QLabel, + QPushButton, + QVBoxLayout, +) -from game.game import Event, db, Game -from userdata.debriefing import Debriefing +from game.debriefing import Debriefing +from game.game import Event, Game, db class QDebriefingWindow(QDialog): diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index bf086c5d..3933083c 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -1,23 +1,35 @@ import logging import sys import webbrowser +from typing import Optional from PySide2.QtCore import Qt from PySide2.QtGui import QIcon -from PySide2.QtWidgets import QWidget, QVBoxLayout, QMainWindow, QAction, QMessageBox, QDesktopWidget, \ - QSplitter, QFileDialog +from PySide2.QtWidgets import ( + QAction, + QDesktopWidget, + QFileDialog, + QMainWindow, + QMessageBox, + QSplitter, + QVBoxLayout, + QWidget, +) import qt_ui.uiconstants as CONST -from game import Game +from game import Game, persistency +from qt_ui.dialogs import Dialog +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 QLiberationMap -from qt_ui.windows.GameUpdateSignal import GameUpdateSignal, DebriefingSignal +from qt_ui.windows.GameUpdateSignal import DebriefingSignal, GameUpdateSignal from qt_ui.windows.QDebriefingWindow import QDebriefingWindow -from qt_ui.windows.newgame.QNewGameWizard import NewGameWizard from qt_ui.windows.infos.QInfoPanel import QInfoPanel -from qt_ui.windows.preferences.QLiberationPreferencesWindow import QLiberationPreferencesWindow -from userdata import persistency +from qt_ui.windows.newgame.QNewGameWizard import NewGameWizard +from qt_ui.windows.preferences.QLiberationPreferencesWindow import \ + QLiberationPreferencesWindow class QLiberationWindow(QMainWindow): @@ -25,6 +37,10 @@ class QLiberationWindow(QMainWindow): def __init__(self): super(QLiberationWindow, self).__init__() + self.game: Optional[Game] = None + self.game_model = GameModel() + Dialog.set_game(self.game_model) + self.ato_panel = None self.info_panel = None self.setGame(persistency.restore_game()) @@ -44,16 +60,19 @@ class QLiberationWindow(QMainWindow): self.setGeometry(0, 0, screen.width(), screen.height()) self.setWindowState(Qt.WindowMaximized) - def initUi(self): - - self.liberation_map = QLiberationMap(self.game) + self.ato_panel = QAirTaskingOrderPanel(self.game_model) + self.liberation_map = QLiberationMap(self.game_model) self.info_panel = QInfoPanel(self.game) hbox = QSplitter(Qt.Horizontal) - hbox.addWidget(self.info_panel) - hbox.addWidget(self.liberation_map) - hbox.setSizes([2, 8]) + vbox = QSplitter(Qt.Vertical) + hbox.addWidget(self.ato_panel) + hbox.addWidget(vbox) + vbox.addWidget(self.liberation_map) + vbox.addWidget(self.info_panel) + hbox.setSizes([100, 600]) + vbox.setSizes([600, 100]) vbox = QVBoxLayout() vbox.setMargin(0) @@ -210,10 +229,13 @@ class QLiberationWindow(QMainWindow): def exit(self): sys.exit(0) - def setGame(self, game: Game): + def setGame(self, game: Optional[Game]): + if game is not None: + game.on_load() self.game = game if self.info_panel: self.info_panel.setGame(game) + self.game_model.set(self.game) def showAboutDialog(self): text = "