diff --git a/game/db.py b/game/db.py index c73f7dbf..05eaad06 100644 --- a/game/db.py +++ b/game/db.py @@ -1244,7 +1244,7 @@ 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) -> Optional[UnitType]: +def unit_type_from_name(name: str) -> Optional[Type[UnitType]]: if name in vehicle_map: return vehicle_map[name] elif name in plane_map: diff --git a/game/event/event.py b/game/event/event.py index 8cc4aea7..db8e5ee6 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging import math -from typing import Dict, List, Optional, Type, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Type from dcs.mapping import Point from dcs.task import Task @@ -11,12 +11,12 @@ from dcs.unittype import UnitType from game import db, persistency from game.debriefing import Debriefing from game.infos.information import Information -from game.operation.operation import Operation +from game.theater import ControlPoint from gen.ground_forces.combat_stance import CombatStance -from theater import ControlPoint if TYPE_CHECKING: from ..game import Game + from game.operation.operation import Operation DIFFICULTY_LOG_BASE = 1.1 EVENT_DEPARTURE_MAX_DISTANCE = 340000 @@ -107,14 +107,16 @@ class Event: for destroyed_aircraft in debriefing.killed_aircrafts: try: cpid = int(destroyed_aircraft.split("|")[3]) - type = db.unit_type_from_name(destroyed_aircraft.split("|")[4]) - if cpid in cp_map.keys(): + aircraft = db.unit_type_from_name( + destroyed_aircraft.split("|")[4]) + if cpid in cp_map: cp = cp_map[cpid] - if type in cp.base.aircraft.keys(): - logging.info("Aircraft destroyed : " + str(type)) - cp.base.aircraft[type] = max(0, cp.base.aircraft[type]-1) - except Exception as e: - print(e) + if aircraft in cp.base.aircraft: + logging.info(f"Aircraft destroyed: {aircraft}") + cp.base.aircraft[aircraft] = max( + 0, cp.base.aircraft[aircraft] - 1) + except Exception: + logging.exception("Failed to commit destroyed aircraft") # ------------------------------ # Destroyed ground units @@ -123,13 +125,13 @@ class Event: for killed_ground_unit in debriefing.killed_ground_units: try: cpid = int(killed_ground_unit.split("|")[3]) - type = db.unit_type_from_name(killed_ground_unit.split("|")[4]) + aircraft = db.unit_type_from_name(killed_ground_unit.split("|")[4]) if cpid in cp_map.keys(): killed_unit_count_by_cp[cpid] = killed_unit_count_by_cp[cpid] + 1 cp = cp_map[cpid] - if type in cp.base.armor.keys(): - logging.info("Ground unit destroyed : " + str(type)) - cp.base.armor[type] = max(0, cp.base.armor[type] - 1) + if aircraft in cp.base.armor.keys(): + logging.info("Ground unit destroyed : " + str(aircraft)) + cp.base.armor[aircraft] = max(0, cp.base.armor[aircraft] - 1) except Exception as e: print(e) @@ -352,11 +354,13 @@ class Event: logging.info(info.text) - class UnitsDeliveryEvent(Event): + informational = True - def __init__(self, attacker_name: str, defender_name: str, from_cp: ControlPoint, to_cp: ControlPoint, game): + def __init__(self, attacker_name: str, defender_name: str, + from_cp: ControlPoint, to_cp: ControlPoint, + game: Game) -> None: super(UnitsDeliveryEvent, self).__init__(game=game, location=to_cp.position, from_cp=from_cp, @@ -364,17 +368,16 @@ class UnitsDeliveryEvent(Event): attacker_name=attacker_name, defender_name=defender_name) - self.units: Dict[UnitType, int] = {} + self.units: Dict[Type[UnitType], int] = {} - def __str__(self): + def __str__(self) -> str: return "Pending delivery to {}".format(self.to_cp) - def deliver(self, units: Dict[UnitType, int]): + def deliver(self, units: Dict[Type[UnitType], int]) -> None: for k, v in units.items(): self.units[k] = self.units.get(k, 0) + v - def skip(self): - + def skip(self) -> None: for k, v in self.units.items(): info = Information("Ally Reinforcement", str(k.id) + " x " + str(v) + " at " + self.to_cp.name, self.game.turn) self.game.informations.append(info) diff --git a/game/game.py b/game/game.py index d69f9cc8..dd03be4f 100644 --- a/game/game.py +++ b/game/game.py @@ -26,7 +26,7 @@ from .event.frontlineattack import FrontlineAttackEvent from .factions.faction import Faction from .infos.information import Information from .settings import Settings -from .theater import ConflictTheater, ControlPoint +from .theater import ConflictTheater, ControlPoint, OffMapSpawn from .weather import Conditions, TimeOfDay COMMISION_UNIT_VARIETY = 4 @@ -151,7 +151,7 @@ class Game: reward = PLAYER_BUDGET_BASE * len(self.theater.player_points()) for cp in self.theater.player_points(): for g in cp.ground_objects: - if g.category in REWARDS.keys(): + if g.category in REWARDS.keys() and not g.is_dead: reward = reward + REWARDS[g.category] return reward else: @@ -160,9 +160,6 @@ class Game: def _budget_player(self): self.budget += self.budget_reward_amount - def awacs_expense_commit(self): - self.budget -= AWACS_BUDGET_COST - def units_delivery_event(self, to_cp: ControlPoint) -> UnitsDeliveryEvent: event = UnitsDeliveryEvent(attacker_name=self.player_name, defender_name=self.player_name, @@ -172,10 +169,6 @@ class Game: self.events.append(event) return event - def units_delivery_remove(self, event: Event): - if event in self.events: - self.events.remove(event) - def initiate_event(self, event: Event): #assert event in self.events logging.info("Generating {} (regular)".format(event)) @@ -202,12 +195,6 @@ class Game: LuaPluginManager.load_settings(self.settings) ObjectiveDistanceCache.set_theater(self.theater) - # Save game compatibility. - - # TODO: Remove in 2.3. - if not hasattr(self, "conditions"): - self.conditions = self.generate_conditions() - def pass_turn(self, no_action: bool = False) -> None: logging.info("Pass turn") self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0)) @@ -248,6 +235,7 @@ class Game: self.aircraft_inventory.reset() for cp in self.theater.controlpoints: + cp.pending_unit_deliveries = self.units_delivery_event(cp) self.aircraft_inventory.set_from_control_point(cp) # Plan flights & combat for next turn @@ -274,7 +262,7 @@ class Game: production = 0.0 for enemy_point in self.theater.enemy_points(): for g in enemy_point.ground_objects: - if g.category in REWARDS.keys(): + if g.category in REWARDS.keys() and not g.is_dead: production = production + REWARDS[g.category] production = production * 0.75 @@ -289,6 +277,9 @@ class Game: if len(potential_cp_armor) == 0: potential_cp_armor = self.theater.enemy_points() + potential_cp_armor = [p for p in potential_cp_armor if + not isinstance(p, OffMapSpawn)] + i = 0 potential_units = db.FACTIONS[self.enemy_name].frontline_units @@ -325,7 +316,7 @@ class Game: if i > 50 or budget_for_aircraft <= 0: break target_cp = random.choice(potential_cp_armor) - if target_cp.base.total_planes >= MAX_AIRCRAFT: + if target_cp.base.total_aircraft >= MAX_AIRCRAFT: continue unit = random.choice(potential_units) price = db.PRICES[unit] * 2 diff --git a/game/inventory.py b/game/inventory.py index 89f5afa1..80adb72b 100644 --- a/game/inventory.py +++ b/game/inventory.py @@ -1,11 +1,15 @@ """Inventory management APIs.""" -from collections import defaultdict -from typing import Dict, Iterable, Iterator, Set, Tuple +from __future__ import annotations -from dcs.unittype import UnitType +from collections import defaultdict +from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING, Type + +from dcs.unittype import FlyingType from gen.flights.flight import Flight -from theater import ControlPoint + +if TYPE_CHECKING: + from game.theater import ControlPoint class ControlPointAircraftInventory: @@ -13,9 +17,9 @@ class ControlPointAircraftInventory: def __init__(self, control_point: ControlPoint) -> None: self.control_point = control_point - self.inventory: Dict[UnitType, int] = defaultdict(int) + self.inventory: Dict[Type[FlyingType], int] = defaultdict(int) - def add_aircraft(self, aircraft: UnitType, count: int) -> None: + def add_aircraft(self, aircraft: Type[FlyingType], count: int) -> None: """Adds aircraft to the inventory. Args: @@ -24,7 +28,7 @@ class ControlPointAircraftInventory: """ self.inventory[aircraft] += count - def remove_aircraft(self, aircraft: UnitType, count: int) -> None: + def remove_aircraft(self, aircraft: Type[FlyingType], count: int) -> None: """Removes aircraft from the inventory. Args: @@ -43,7 +47,7 @@ class ControlPointAircraftInventory: ) self.inventory[aircraft] -= count - def available(self, aircraft: UnitType) -> int: + def available(self, aircraft: Type[FlyingType]) -> int: """Returns the number of available aircraft of the given type. Args: @@ -55,14 +59,14 @@ class ControlPointAircraftInventory: return 0 @property - def types_available(self) -> Iterator[UnitType]: + def types_available(self) -> Iterator[FlyingType]: """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]]: + def all_aircraft(self) -> Iterator[Tuple[FlyingType, int]]: """Iterates over all available aircraft types, including amounts.""" for aircraft, count in self.inventory.items(): if count > 0: @@ -102,9 +106,9 @@ class GlobalAircraftInventory: return self.inventories[control_point] @property - def available_types_for_player(self) -> Iterator[UnitType]: + def available_types_for_player(self) -> Iterator[FlyingType]: """Iterates over all aircraft types available to the player.""" - seen: Set[UnitType] = set() + seen: Set[FlyingType] = set() for control_point, inventory in self.inventories.items(): if control_point.captured: for aircraft in inventory.types_available: diff --git a/game/models/frontline_data.py b/game/models/frontline_data.py index 94947135..586ebd58 100644 --- a/game/models/frontline_data.py +++ b/game/models/frontline_data.py @@ -1,4 +1,4 @@ -from theater import ControlPoint +from game.theater import ControlPoint class FrontlineData: diff --git a/game/operation/operation.py b/game/operation/operation.py index 0ff06ebe..1e01065b 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -15,6 +15,7 @@ from dcs.triggers import TriggerStart from dcs.unittype import UnitType from game.plugins import LuaPluginManager +from game.theater import ControlPoint from gen import Conflict, FlightType, VisualGenerator from gen.aircraft import AIRCRAFT_DATA, AircraftConflictGenerator, FlightData from gen.airfields import AIRFIELD_DATA @@ -29,7 +30,6 @@ 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 diff --git a/game/settings.py b/game/settings.py index ad496b65..ff73b63c 100644 --- a/game/settings.py +++ b/game/settings.py @@ -6,10 +6,10 @@ from typing import Dict, Optional class Settings: # Generator settings inverted: bool = False - do_not_generate_carrier: bool = False # TODO : implement - do_not_generate_lha: bool = False # TODO : implement - do_not_generate_player_navy: bool = True # TODO : implement - do_not_generate_enemy_navy: bool = True # TODO : implement + do_not_generate_carrier: bool = False + do_not_generate_lha: bool = False + do_not_generate_player_navy: bool = False + do_not_generate_enemy_navy: bool = False # Difficulty settings player_skill: str = "Good" diff --git a/game/theater/base.py b/game/theater/base.py index 47b3580e..ba8a72f8 100644 --- a/game/theater/base.py +++ b/game/theater/base.py @@ -4,9 +4,8 @@ import math import typing from typing import Dict, Type -from dcs.planes import PlaneType from dcs.task import CAP, CAS, Embarking, PinpointStrike, Task -from dcs.unittype import UnitType, VehicleType +from dcs.unittype import FlyingType, UnitType, VehicleType from dcs.vehicles import AirDefence, Armor from game import db @@ -21,20 +20,16 @@ BASE_MIN_STRENGTH = 0 class Base: - aircraft = {} # type: typing.Dict[PlaneType, int] - armor = {} # type: typing.Dict[VehicleType, int] - aa = {} # type: typing.Dict[AirDefence, int] - strength = 1 # type: float def __init__(self): - self.aircraft = {} - self.armor = {} - self.aa = {} + self.aircraft: Dict[Type[FlyingType], int] = {} + self.armor: Dict[VehicleType, int] = {} + self.aa: Dict[AirDefence, int] = {} self.commision_points: Dict[Type, float] = {} self.strength = 1 @property - def total_planes(self) -> int: + def total_aircraft(self) -> int: return sum(self.aircraft.values()) @property @@ -83,7 +78,7 @@ class Base: logging.info("{} for {} ({}): {}".format(self, for_type, count, result)) return result - def _find_best_planes(self, for_type: Task, count: int) -> typing.Dict[PlaneType, int]: + def _find_best_planes(self, for_type: Task, count: int) -> typing.Dict[FlyingType, int]: return self._find_best_unit(self.aircraft, for_type, count) def _find_best_armor(self, for_type: Task, count: int) -> typing.Dict[Armor, int]: @@ -155,7 +150,7 @@ class Base: if task: count = sum([v for k, v in self.aircraft.items() if db.unit_task(k) == task]) else: - count = self.total_planes + count = self.total_aircraft count = int(math.ceil(count * PLANES_SCRAMBLE_FACTOR * self.strength)) return min(min(max(count, PLANES_SCRAMBLE_MIN_BASE), int(PLANES_SCRAMBLE_MAX_BASE * multiplier)), count) @@ -167,18 +162,18 @@ class Base: # previous logic removed because we always want the full air defense capabilities. return self.total_aa - def scramble_sweep(self, multiplier: float) -> typing.Dict[PlaneType, int]: + def scramble_sweep(self, multiplier: float) -> typing.Dict[FlyingType, int]: return self._find_best_planes(CAP, self.scramble_count(multiplier, CAP)) def scramble_last_defense(self): # return as many CAP-capable aircraft as we can since this is the last defense of the base # (but not more than 20 - that's just nuts) - return self._find_best_planes(CAP, min(self.total_planes, 20)) + return self._find_best_planes(CAP, min(self.total_aircraft, 20)) - def scramble_cas(self, multiplier: float) -> typing.Dict[PlaneType, int]: + def scramble_cas(self, multiplier: float) -> typing.Dict[FlyingType, int]: return self._find_best_planes(CAS, self.scramble_count(multiplier, CAS)) - def scramble_interceptors(self, multiplier: float) -> typing.Dict[PlaneType, int]: + def scramble_interceptors(self, multiplier: float) -> typing.Dict[FlyingType, int]: return self._find_best_planes(CAP, self.scramble_count(multiplier, CAP)) def assemble_attack(self) -> typing.Dict[Armor, int]: diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index c0b373ce..7766c90e 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -1,13 +1,28 @@ from __future__ import annotations -import logging +import itertools import json +import logging from dataclasses import dataclass +from functools import cached_property from itertools import tee from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple, Union +from dcs import Mission +from dcs.countries import ( + CombinedJointTaskForcesBlue, + CombinedJointTaskForcesRed, +) +from dcs.country import Country from dcs.mapping import Point +from dcs.planes import F_15C +from dcs.ships import ( + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + USS_Arleigh_Burke_IIa, +) +from dcs.statics import Fortification from dcs.terrain import ( caucasus, nevada, @@ -16,11 +31,20 @@ from dcs.terrain import ( syria, thechannel, ) -from dcs.terrain.terrain import Terrain +from dcs.terrain.terrain import Airport, Terrain +from dcs.unitgroup import ( + FlyingGroup, + Group, + ShipGroup, + StaticGroup, + VehicleGroup, +) +from dcs.vehicles import AirDefence, Armor from gen.flights.flight import FlightType -from .controlpoint import ControlPoint, MissionTarget +from .controlpoint import ControlPoint, MissionTarget, OffMapSpawn from .landmap import Landmap, load_landmap, poly_contains +from ..utils import nm_to_meter Numeric = Union[int, float] @@ -73,6 +97,266 @@ def pairwise(iterable): return zip(a, b) +class MizCampaignLoader: + BLUE_COUNTRY = CombinedJointTaskForcesBlue() + RED_COUNTRY = CombinedJointTaskForcesRed() + + OFF_MAP_UNIT_TYPE = F_15C.id + + CV_UNIT_TYPE = CVN_74_John_C__Stennis.id + LHA_UNIT_TYPE = LHA_1_Tarawa.id + FRONT_LINE_UNIT_TYPE = Armor.APC_M113.id + + EWR_UNIT_TYPE = AirDefence.EWR_55G6.id + SAM_UNIT_TYPE = AirDefence.SAM_SA_10_S_300PS_SR_64H6E.id + GARRISON_UNIT_TYPE = AirDefence.SAM_SA_19_Tunguska_2S6.id + STRIKE_TARGET_UNIT_TYPE = Fortification.Workshop_A.id + OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id + SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id + + # Multiple options for the required SAMs so campaign designers can more + # easily see the coverage of their IADS. Designers focused on campaigns that + # will primarily use SA-2s can place SA-2 launchers to ensure that they will + # have adequate coverage, and designers focused on campaigns that will + # primarily use SA-10s can do the same. + REQUIRED_SAM_UNIT_TYPES = { + AirDefence.SAM_Hawk_LN_M192, + AirDefence.SAM_Patriot_LN_M901, + AirDefence.SAM_SA_10_S_300PS_LN_5P85C, + AirDefence.SAM_SA_10_S_300PS_LN_5P85D, + AirDefence.SAM_SA_2_LN_SM_90, + AirDefence.SAM_SA_3_S_125_LN_5P73, + } + + BASE_DEFENSE_RADIUS = nm_to_meter(2) + + def __init__(self, miz: Path, theater: ConflictTheater) -> None: + self.theater = theater + self.mission = Mission() + self.mission.load_file(str(miz)) + self.control_point_id = itertools.count(1000) + + # If there are no red carriers there usually aren't red units. Make sure + # both countries are initialized so we don't have to deal with None. + if self.mission.country(self.BLUE_COUNTRY.name) is None: + self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY) + if self.mission.country(self.RED_COUNTRY.name) is None: + self.mission.coalition["red"].add_country(self.RED_COUNTRY) + + @staticmethod + def control_point_from_airport(airport: Airport) -> ControlPoint: + # TODO: Radials? + radials = LAND + + # The wiki says this is a legacy property and to just use regular. + size = SIZE_REGULAR + + # The importance is taken from the periodicity of the airport's + # warehouse divided by 10. 30 is the default, and out of range (valid + # values are between 1.0 and 1.4). If it is used, pick the default + # importance. + if airport.periodicity == 30: + importance = IMPORTANCE_MEDIUM + else: + importance = airport.periodicity / 10 + + cp = ControlPoint.from_airport(airport, radials, size, importance) + cp.captured = airport.is_blue() + + # Use the unlimited aircraft option to determine if an airfield should + # be owned by the player when the campaign is "inverted". + cp.captured_invert = airport.unlimited_aircrafts + + return cp + + def country(self, blue: bool) -> Country: + country = self.mission.country( + self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name) + # Should be guaranteed because we initialized them. + assert country + return country + + @property + def blue(self) -> Country: + return self.country(blue=True) + + @property + def red(self) -> Country: + return self.country(blue=False) + + def off_map_spawns(self, blue: bool) -> Iterator[FlyingGroup]: + for group in self.country(blue).plane_group: + if group.units[0].type == self.OFF_MAP_UNIT_TYPE: + yield group + + def carriers(self, blue: bool) -> Iterator[ShipGroup]: + for group in self.country(blue).ship_group: + if group.units[0].type == self.CV_UNIT_TYPE: + yield group + + def lhas(self, blue: bool) -> Iterator[ShipGroup]: + for group in self.country(blue).ship_group: + if group.units[0].type == self.LHA_UNIT_TYPE: + yield group + + @property + def ships(self) -> Iterator[ShipGroup]: + for group in self.blue.ship_group: + if group.units[0].type == self.SHIP_UNIT_TYPE: + yield group + + @property + def ewrs(self) -> Iterator[VehicleGroup]: + for group in self.blue.vehicle_group: + if group.units[0].type == self.EWR_UNIT_TYPE: + yield group + + @property + def sams(self) -> Iterator[VehicleGroup]: + for group in self.blue.vehicle_group: + if group.units[0].type == self.SAM_UNIT_TYPE: + yield group + + @property + def garrisons(self) -> Iterator[VehicleGroup]: + for group in self.blue.vehicle_group: + if group.units[0].type == self.GARRISON_UNIT_TYPE: + yield group + + @property + def strike_targets(self) -> Iterator[StaticGroup]: + for group in self.blue.static_group: + if group.units[0].type == self.STRIKE_TARGET_UNIT_TYPE: + yield group + + @property + def offshore_strike_targets(self) -> Iterator[StaticGroup]: + for group in self.blue.static_group: + if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE: + yield group + + @property + def required_sams(self) -> Iterator[VehicleGroup]: + for group in self.red.vehicle_group: + if group.units[0].type == self.REQUIRED_SAM_UNIT_TYPES: + yield group + + @cached_property + def control_points(self) -> Dict[int, ControlPoint]: + control_points = {} + for airport in self.mission.terrain.airport_list(): + if airport.is_blue() or airport.is_red(): + control_point = self.control_point_from_airport(airport) + control_points[control_point.id] = control_point + + for blue in (False, True): + for group in self.off_map_spawns(blue): + control_point = OffMapSpawn(next(self.control_point_id), + str(group.name), group.position) + control_point.captured = blue + control_point.captured_invert = group.late_activation + control_points[control_point.id] = control_point + for group in self.carriers(blue): + # TODO: Name the carrier. + control_point = ControlPoint.carrier( + "carrier", group.position, next(self.control_point_id)) + control_point.captured = blue + control_point.captured_invert = group.late_activation + control_points[control_point.id] = control_point + for group in self.lhas(blue): + # TODO: Name the LHA. + control_point = ControlPoint.lha( + "lha", group.position, next(self.control_point_id)) + control_point.captured = blue + control_point.captured_invert = group.late_activation + control_points[control_point.id] = control_point + + return control_points + + @property + def front_line_path_groups(self) -> Iterator[VehicleGroup]: + for group in self.country(blue=True).vehicle_group: + if group.units[0].type == self.FRONT_LINE_UNIT_TYPE: + yield group + + @cached_property + def front_lines(self) -> Dict[str, ComplexFrontLine]: + # Dict of front line ID to a front line. + front_lines = {} + for group in self.front_line_path_groups: + # The unit will have its first waypoint at the source CP and the + # final waypoint at the destination CP. Intermediate waypoints + # define the curve of the front line. + waypoints = [p.position for p in group.points] + origin = self.mission.terrain.nearest_airport(waypoints[0]) + if origin is None: + raise RuntimeError( + f"No airport near the first waypoint of {group.name}") + destination = self.mission.terrain.nearest_airport(waypoints[-1]) + if destination is None: + raise RuntimeError( + f"No airport near the final waypoint of {group.name}") + + # Snap the begin and end points to the control points. + waypoints[0] = origin.position + waypoints[-1] = destination.position + front_line_id = f"{origin.id}|{destination.id}" + front_lines[front_line_id] = ComplexFrontLine(origin, waypoints) + self.control_points[origin.id].connect( + self.control_points[destination.id]) + self.control_points[destination.id].connect( + self.control_points[origin.id]) + return front_lines + + def objective_info(self, group: Group) -> Tuple[ControlPoint, int]: + closest = self.theater.closest_control_point(group.position) + distance = closest.position.distance_to_point(group.position) + return closest, distance + + def add_preset_locations(self) -> None: + for group in self.garrisons: + closest, distance = self.objective_info(group) + if distance < self.BASE_DEFENSE_RADIUS: + closest.preset_locations.base_garrisons.append(group.position) + else: + logging.warning( + f"Found garrison unit too far from base: {group.name}") + + for group in self.sams: + closest, distance = self.objective_info(group) + if distance < self.BASE_DEFENSE_RADIUS: + closest.preset_locations.base_air_defense.append(group.position) + else: + closest.preset_locations.sams.append(group.position) + + for group in self.ewrs: + closest, distance = self.objective_info(group) + closest.preset_locations.ewrs.append(group.position) + + for group in self.strike_targets: + closest, distance = self.objective_info(group) + closest.preset_locations.strike_locations.append(group.position) + + for group in self.offshore_strike_targets: + closest, distance = self.objective_info(group) + closest.preset_locations.offshore_strike_locations.append( + group.position) + + for group in self.ships: + closest, distance = self.objective_info(group) + closest.preset_locations.ships.append(group.position) + + for group in self.required_sams: + closest, distance = self.objective_info(group) + closest.preset_locations.required_sams.append(group.position) + + def populate_theater(self) -> None: + for control_point in self.control_points.values(): + self.theater.add_controlpoint(control_point) + self.add_preset_locations() + self.theater.set_frontline_data(self.front_lines) + + class ConflictTheater: terrain: Terrain @@ -83,17 +367,35 @@ class ConflictTheater: land_poly = None # type: Polygon """ daytime_map: Dict[str, Tuple[int, int]] - frontline_data: Optional[Dict[str, ComplexFrontLine]] = None + _frontline_data: Optional[Dict[str, ComplexFrontLine]] = None def __init__(self): self.controlpoints: List[ControlPoint] = [] - self.frontline_data = FrontLine.load_json_frontlines(self) + self._frontline_data: Optional[Dict[str, ComplexFrontLine]] = None """ self.land_poly = geometry.Polygon(self.landmap[0][0]) for x in self.landmap[1]: self.land_poly = self.land_poly.difference(geometry.Polygon(x)) """ + @property + def frontline_data(self) -> Optional[Dict[str, ComplexFrontLine]]: + if self._frontline_data is None: + self.load_frontline_data_from_file() + return self._frontline_data + + def load_frontline_data_from_file(self) -> None: + if self._frontline_data is not None: + logging.warning("Replacing existing frontline data from file") + self._frontline_data = FrontLine.load_json_frontlines(self) + if self._frontline_data is None: + self._frontline_data = {} + + def set_frontline_data(self, data: Dict[str, ComplexFrontLine]) -> None: + if self._frontline_data is not None: + logging.warning("Replacing existing frontline data") + self._frontline_data = data + def add_controlpoint(self, point: ControlPoint, connected_to: Optional[List[ControlPoint]] = None): if connected_to is None: @@ -153,11 +455,21 @@ class ConflictTheater: def enemy_points(self) -> List[ControlPoint]: return [point for point in self.controlpoints if not point.captured] + def closest_control_point(self, point: Point) -> ControlPoint: + closest = self.controlpoints[0] + closest_distance = point.distance_to_point(closest.position) + for control_point in self.controlpoints[1:]: + distance = point.distance_to_point(control_point.position) + if distance < closest_distance: + closest = control_point + closest_distance = distance + return closest + def add_json_cp(self, theater, p: dict) -> ControlPoint: if p["type"] == "airbase": - airbase = theater.terrain.airports[p["id"]].__class__ + airbase = theater.terrain.airports[p["id"]] if "radials" in p.keys(): radials = p["radials"] @@ -188,7 +500,7 @@ class ConflictTheater: return cp @staticmethod - def from_json(data: Dict[str, Any]) -> ConflictTheater: + def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater: theaters = { "Caucasus": CaucasusTheater, "Nevada": NevadaTheater, @@ -199,6 +511,12 @@ class ConflictTheater: } theater = theaters[data["theater"]] t = theater() + + miz = data.get("miz", None) + if miz is not None: + MizCampaignLoader(directory / miz, t).populate_theater() + return t + cps = {} for p in data["player_points"]: cp = t.add_json_cp(theater, p) @@ -376,10 +694,6 @@ class FrontLine(MissionTarget): """Returns a tuple of the two control points.""" return self.control_point_a, self.control_point_b - @property - def middle_point(self): - self.point_from_a(self.attack_distance / 2) - @property def attack_distance(self): """The total distance of all segments""" diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 46ac7e00..476f831a 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -1,9 +1,12 @@ from __future__ import annotations import itertools +import logging +import random import re +from dataclasses import dataclass, field from enum import Enum -from typing import Dict, Iterator, List, TYPE_CHECKING +from typing import Dict, Iterator, List, Optional, TYPE_CHECKING from dcs.mapping import Point from dcs.ships import ( @@ -13,6 +16,7 @@ from dcs.ships import ( Type_071_Amphibious_Transport_Dock, ) from dcs.terrain.terrain import Airport +from dcs.unittype import FlyingType from game import db from gen.ground_forces.combat_stance import CombatStance @@ -20,12 +24,16 @@ from .base import Base from .missiontarget import MissionTarget from .theatergroundobject import ( BaseDefenseGroundObject, + EwrGroundObject, + SamGroundObject, TheaterGroundObject, + VehicleGroupGroundObject, ) if TYPE_CHECKING: from game import Game from gen.flights.flight import FlightType + from ..event import UnitsDeliveryEvent class ControlPointType(Enum): @@ -34,6 +42,87 @@ class ControlPointType(Enum): LHA_GROUP = 2 # A group with a Tarawa carrier (Helicopters & Harrier) FARP = 4 # A FARP, with slots for helicopters FOB = 5 # A FOB (ground units only) + OFF_MAP = 6 + + +class LocationType(Enum): + BaseAirDefense = "base air defense" + Coastal = "coastal defense" + Ewr = "EWR" + Garrison = "garrison" + MissileSite = "missile site" + OffshoreStrikeTarget = "offshore strike target" + Sam = "SAM" + Ship = "ship" + Shorad = "SHORAD" + StrikeTarget = "strike target" + + +@dataclass +class PresetLocations: + """Defines the preset locations loaded from the campaign mission file.""" + + #: Locations used for spawning ground defenses for bases. + base_garrisons: List[Point] = field(default_factory=list) + + #: Locations used for spawning air defenses for bases. Used by SAMs, AAA, + #: and SHORADs. + base_air_defense: List[Point] = field(default_factory=list) + + #: Locations used by EWRs. + ewrs: List[Point] = field(default_factory=list) + + #: Locations used by SAMs outside of bases. + sams: List[Point] = field(default_factory=list) + + #: Locations used by non-carrier ships. Carriers and LHAs are not random. + ships: List[Point] = field(default_factory=list) + + #: Locations used by coastal defenses. + coastal_defenses: List[Point] = field(default_factory=list) + + #: Locations used by ground based strike objectives. + strike_locations: List[Point] = field(default_factory=list) + + #: Locations used by offshore strike objectives. + offshore_strike_locations: List[Point] = field(default_factory=list) + + #: Locations of SAMs which should always be spawned. + required_sams: List[Point] = field(default_factory=list) + + @staticmethod + def _random_from(points: List[Point]) -> Optional[Point]: + """Finds, removes, and returns a random position from the given list.""" + if not points: + return None + point = random.choice(points) + points.remove(point) + return point + + def random_for(self, location_type: LocationType) -> Optional[Point]: + """Returns a position suitable for the given location type. + + The location, if found, will be claimed by the caller and not available + to subsequent calls. + """ + if location_type == LocationType.Garrison: + return self._random_from(self.base_garrisons) + if location_type == LocationType.Sam: + return self._random_from(self.sams) + if location_type == LocationType.BaseAirDefense: + return self._random_from(self.base_air_defense) + if location_type == LocationType.Ewr: + return self._random_from(self.ewrs) + if location_type == LocationType.Shorad: + return self._random_from(self.base_garrisons) + if location_type == LocationType.OffshoreStrikeTarget: + return self._random_from(self.offshore_strike_locations) + if location_type == LocationType.Ship: + return self._random_from(self.ships) + if location_type == LocationType.StrikeTarget: + return self._random_from(self.strike_locations) + logging.error(f"Unknown location type: {location_type}") + return None class ControlPoint(MissionTarget): @@ -57,6 +146,7 @@ class ControlPoint(MissionTarget): self.at = at self.connected_objectives: List[TheaterGroundObject] = [] self.base_defenses: List[BaseDefenseGroundObject] = [] + self.preset_locations = PresetLocations() self.size = size self.importance = importance @@ -69,6 +159,7 @@ class ControlPoint(MissionTarget): self.cptype = cptype self.stances: Dict[int, CombatStance] = {} self.airport = None + self.pending_unit_deliveries: Optional[UnitsDeliveryEvent] = None @property def ground_objects(self) -> List[TheaterGroundObject]: @@ -79,7 +170,7 @@ class ControlPoint(MissionTarget): def from_airport(cls, airport: Airport, radials: List[int], size: int, importance: float, has_frontline=True): assert airport obj = cls(airport.id, airport.name, airport.position, airport, radials, size, importance, has_frontline, cptype=ControlPointType.AIRBASE) - obj.airport = airport() + obj.airport = airport return obj @classmethod @@ -144,7 +235,7 @@ class ControlPoint(MissionTarget): return result @property - def available_aircraft_slots(self): + def total_aircraft_parking(self): """ :return: The maximum number of aircraft that can be stored in this control point """ @@ -157,7 +248,7 @@ class ControlPoint(MissionTarget): else: return 0 - def connect(self, to): + def connect(self, to: ControlPoint) -> None: self.connected_points.append(to) self.stances[to.id] = CombatStance.DEFENSIVE @@ -222,6 +313,24 @@ class ControlPoint(MissionTarget): def is_friendly(self, to_player: bool) -> bool: return self.captured == to_player + def clear_base_defenses(self) -> None: + for base_defense in self.base_defenses: + if isinstance(base_defense, EwrGroundObject): + self.preset_locations.ewrs.append(base_defense.position) + elif isinstance(base_defense, SamGroundObject): + self.preset_locations.base_air_defense.append( + base_defense.position) + elif isinstance(base_defense, VehicleGroupGroundObject): + self.preset_locations.base_garrisons.append( + base_defense.position) + else: + logging.error( + "Could not determine preset location type for " + f"{base_defense}. Assuming garrison type.") + self.preset_locations.base_garrisons.append( + base_defense.position) + self.base_defenses = [] + def capture(self, game: Game, for_player: bool) -> None: if for_player: self.captured = True @@ -233,9 +342,8 @@ class ControlPoint(MissionTarget): self.base.aircraft = {} self.base.armor = {} - # Handle cyclic dependency. + self.clear_base_defenses() from .start_generator import BaseDefenseGenerator - self.base_defenses = [] BaseDefenseGenerator(game, self).generate() def mission_types(self, for_player: bool) -> Iterator[FlightType]: @@ -260,3 +368,41 @@ class ControlPoint(MissionTarget): yield from [ # TODO: FlightType.STRIKE ] + + def can_land(self, aircraft: FlyingType) -> bool: + if self.is_carrier and aircraft not in db.CARRIER_CAPABLE: + return False + if self.is_lha and aircraft not in db.LHA_CAPABLE: + return False + return True + + @property + def expected_aircraft_next_turn(self) -> int: + total = self.base.total_aircraft + assert self.pending_unit_deliveries + for unit_bought in self.pending_unit_deliveries.units: + if issubclass(unit_bought, FlyingType): + total += self.pending_unit_deliveries.units[unit_bought] + return total + + @property + def unclaimed_parking(self) -> int: + return self.total_aircraft_parking - self.expected_aircraft_next_turn + + +class OffMapSpawn(ControlPoint): + def __init__(self, id: int, name: str, position: Point): + from . import IMPORTANCE_MEDIUM, SIZE_REGULAR + super().__init__(id, name, position, at=position, radials=[], + size=SIZE_REGULAR, importance=IMPORTANCE_MEDIUM, + has_frontline=False, cptype=ControlPointType.OFF_MAP) + + def capture(self, game: Game, for_player: bool) -> None: + raise RuntimeError("Off map control points cannot be captured") + + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + yield from [] + + @property + def total_aircraft_parking(self) -> int: + return 1000 diff --git a/game/theater/frontline.py b/game/theater/frontline.py deleted file mode 100644 index 3b57f9b6..00000000 --- a/game/theater/frontline.py +++ /dev/null @@ -1 +0,0 @@ -"""Only here to keep compatibility for save games generated in version 2.2.0""" diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 95bc1c69..c5232a32 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -4,7 +4,7 @@ import logging import math import pickle import random -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike @@ -13,6 +13,18 @@ from dcs.vehicles import AirDefence from game import Game, db from game.factions.faction import Faction from game.settings import Settings +from game.theater import LocationType +from game.theater.conflicttheater import IMPORTANCE_HIGH, IMPORTANCE_LOW +from game.theater.theatergroundobject import ( + BuildingGroundObject, + CarrierGroundObject, + EwrGroundObject, + LhaGroundObject, + MissileSiteGroundObject, + SamGroundObject, + ShipGroundObject, + VehicleGroupGroundObject, +) from game.version import VERSION from gen import namegen from gen.defenses.armor_group_generator import generate_armor_group @@ -21,29 +33,17 @@ from gen.fleet.ship_group_generator import ( generate_lha_group, generate_ship_group, ) -from gen.locations.preset_location_finder import PresetLocationFinder -from gen.locations.preset_locations import PresetLocation +from gen.locations.preset_location_finder import MizDataLocationFinder from gen.missiles.missiles_group_generator import generate_missile_group from gen.sam.sam_group_generator import ( generate_anti_air_group, generate_ewr_group, generate_shorad_group, ) -from theater import ( +from . import ( ConflictTheater, ControlPoint, ControlPointType, - TheaterGroundObject, -) -from game.theater.conflicttheater import IMPORTANCE_HIGH, IMPORTANCE_LOW -from game.theater.theatergroundobject import ( - EwrGroundObject, - SamGroundObject, - BuildingGroundObject, - CarrierGroundObject, - LhaGroundObject, - MissileSiteGroundObject, - ShipGroundObject, - VehicleGroupGroundObject, + OffMapSpawn, ) GroundObjectTemplates = Dict[str, Dict[str, Any]] @@ -139,7 +139,13 @@ class GameGenerator: control_point.base.commision_points = {} control_point.base.strength = 1 + # The tasks here are confusing. PinpointStrike for some reason means + # ground units. for task in [PinpointStrike, CAP, CAS, AirDefence]: + if isinstance(control_point, OffMapSpawn): + # Off-map spawn locations start with no aircraft. + continue + if IMPORTANCE_HIGH <= control_point.importance <= IMPORTANCE_LOW: raise ValueError( f"CP importance must be between {IMPORTANCE_LOW} and " @@ -164,11 +170,155 @@ class GameGenerator: control_point.base.commision_units({unit_type: count_per_type}) +class LocationFinder: + def __init__(self, game: Game, control_point: ControlPoint) -> None: + self.game = game + self.control_point = control_point + self.miz_data = MizDataLocationFinder.compute_possible_locations( + game.theater.terrain.name, control_point.full_name) + + def location_for(self, location_type: LocationType) -> Optional[Point]: + position = self.control_point.preset_locations.random_for(location_type) + if position is not None: + return position + + logging.warning(f"No campaign location for %s at %s", + location_type.value, self.control_point) + position = self.random_from_miz_data( + location_type == LocationType.OffshoreStrikeTarget) + if position is not None: + return position + + logging.debug(f"No mizdata location for %s at %s", location_type.value, + self.control_point) + position = self.random_position(location_type) + if position is not None: + return position + + logging.error(f"Could not find position for %s at %s", + location_type.value, self.control_point) + return None + + def random_from_miz_data(self, offshore: bool) -> Optional[Point]: + if offshore: + locations = self.miz_data.offshore_locations + else: + locations = self.miz_data.ashore_locations + if self.miz_data.offshore_locations: + preset = random.choice(locations) + locations.remove(preset) + return preset.position + return None + + def random_position(self, location_type: LocationType) -> Optional[Point]: + # TODO: Flesh out preset locations so we never hit this case. + logging.warning("Falling back to random location for %s at %s", + location_type.value, self.control_point) + + is_base_defense = location_type in { + LocationType.BaseAirDefense, + LocationType.Garrison, + LocationType.Shorad, + } + + on_land = location_type not in { + LocationType.OffshoreStrikeTarget, + LocationType.Ship, + } + + avoid_others = location_type not in { + LocationType.Garrison, + LocationType.MissileSite, + LocationType.Sam, + LocationType.Ship, + LocationType.Shorad, + } + + if is_base_defense: + min_range = 400 + max_range = 3200 + elif location_type == LocationType.Ship: + min_range = 5000 + max_range = 40000 + elif location_type == LocationType.MissileSite: + min_range = 2500 + max_range = 40000 + else: + min_range = 10000 + max_range = 40000 + + position = self._find_random_position(min_range, max_range, + on_land, is_base_defense, + avoid_others) + + # Retry once, searching a bit further (On some big airbases, 3200 is too + # short (Ex : Incirlik)), but searching farther on every base would be + # problematic, as some base defense units would end up very far away + # from small airfields. + if position is None and is_base_defense: + position = self._find_random_position(3200, 4800, + on_land, is_base_defense, + avoid_others) + return position + + def _find_random_position(self, min_range: int, max_range: int, + on_ground: bool, is_base_defense: bool, + avoid_others: bool) -> Optional[Point]: + """ + Find a valid ground object location + :param on_ground: Whether it should be on ground or on sea (True = on + ground) + :param theater: Theater object + :param min_range: Minimal range from point + :param max_range: Max range from point + :param is_base_defense: True if the location is for base defense. + :return: + """ + near = self.control_point.position + others = self.control_point.ground_objects + + def is_valid(point: Optional[Point]) -> bool: + if point is None: + return False + + if on_ground and not self.game.theater.is_on_land(point): + return False + elif not on_ground and not self.game.theater.is_in_sea(point): + return False + + if avoid_others: + for other in others: + if other.position.distance_to_point(point) < 10000: + return False + + if is_base_defense: + # If it's a base defense we don't care how close it is to other + # points. + return True + + # Else verify that it's not too close to another control point. + for control_point in self.game.theater.controlpoints: + if control_point != self.control_point: + if control_point.position.distance_to_point(point) < 30000: + return False + for ground_obj in control_point.ground_objects: + if ground_obj.position.distance_to_point(point) < 10000: + return False + return True + + for _ in range(300): + # Check if on land or sea + p = near.random_point_within(max_range, min_range) + if is_valid(p): + return p + return None + + class ControlPointGroundObjectGenerator: def __init__(self, game: Game, control_point: ControlPoint) -> None: self.game = game self.control_point = control_point - self.preset_locations = PresetLocationFinder.compute_possible_locations(game.theater.terrain.name, control_point.full_name) + self.location_finder = LocationFinder(game, control_point) @property def faction_name(self) -> str: @@ -205,11 +355,9 @@ class ControlPointGroundObjectGenerator: self.generate_ship() def generate_ship(self) -> None: - point = find_location(False, self.control_point.position, - self.game.theater, 5000, 40000, [], False) + point = self.location_finder.location_for( + LocationType.OffshoreStrikeTarget) if point is None: - logging.error( - f"Could not find point for {self.control_point}'s navy") return group_id = self.game.next_group_id() @@ -223,26 +371,10 @@ class ControlPointGroundObjectGenerator: g.groups.append(group) self.control_point.connected_objectives.append(g) - def pick_preset_location(self, offshore=False) -> Optional[PresetLocation]: - """ - Return a preset location if any is setup and still available for this point - @:param offshore Whether this should be an offshore location - @:return The preset location if found; None if it couldn't be found - """ - if offshore: - if len(self.preset_locations.offshore_locations) > 0: - location = random.choice(self.preset_locations.offshore_locations) - self.preset_locations.offshore_locations.remove(location) - logging.info("Picked a preset offshore location") - return location - else: - if len(self.preset_locations.ashore_locations) > 0: - location = random.choice(self.preset_locations.ashore_locations) - self.preset_locations.ashore_locations.remove(location) - logging.info("Picked a preset ashore location") - return location - logging.info("No preset location found") - return None + +class NoOpGroundObjectGenerator(ControlPointGroundObjectGenerator): + def generate(self) -> bool: + return True class CarrierGroundObjectGenerator(ControlPointGroundObjectGenerator): @@ -299,6 +431,7 @@ class BaseDefenseGenerator: def __init__(self, game: Game, control_point: ControlPoint) -> None: self.game = game self.control_point = control_point + self.location_finder = LocationFinder(game, control_point) @property def faction_name(self) -> str: @@ -317,10 +450,8 @@ class BaseDefenseGenerator: self.generate_base_defenses() def generate_ewr(self) -> None: - position = self._find_location() + position = self.location_finder.location_for(LocationType.Ewr) if position is None: - logging.error("Could not find position for " - f"{self.control_point} EWR") return group_id = self.game.next_group_id() @@ -350,10 +481,8 @@ class BaseDefenseGenerator: self.generate_garrison() def generate_garrison(self) -> None: - position = self._find_location() + position = self.location_finder.location_for(LocationType.Garrison) if position is None: - logging.error("Could not find position for " - f"{self.control_point} garrison") return group_id = self.game.next_group_id() @@ -368,10 +497,9 @@ class BaseDefenseGenerator: self.control_point.base_defenses.append(g) def generate_sam(self) -> None: - position = self._find_location() + position = self.location_finder.location_for( + LocationType.BaseAirDefense) if position is None: - logging.error("Could not find position for " - f"{self.control_point} SAM") return group_id = self.game.next_group_id() @@ -385,10 +513,9 @@ class BaseDefenseGenerator: self.control_point.base_defenses.append(g) def generate_shorad(self) -> None: - position = self._find_location() + position = self.location_finder.location_for( + LocationType.BaseAirDefense) if position is None: - logging.error("Could not find position for " - f"{self.control_point} SHORAD") return group_id = self.game.next_group_id() @@ -401,20 +528,6 @@ class BaseDefenseGenerator: g.groups.append(group) self.control_point.base_defenses.append(g) - def _find_location(self) -> Optional[Point]: - position = find_location(True, self.control_point.position, - self.game.theater, 400, 3200, [], True) - - # Retry once, searching a bit further (On some big airbase, 3200 is too short (Ex : Incirlik)) - # But searching farther on every base would be problematic, as some base defense units - # would end up very far away from small airfields. - # (I know it's not good for performance, but this is only done on campaign generation) - # TODO : Make the whole process less stupid with preset possible positions for each airbase - if position is None: - position = find_location(True, self.control_point.position, - self.game.theater, 3200, 4800, [], True) - return position - class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): def __init__(self, game: Game, control_point: ControlPoint, @@ -442,15 +555,31 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): # Always generate at least one AA point. self.generate_aa_site() + skip_sams = self.generate_required_aa() + # And between 2 and 7 other objectives. amount = random.randrange(2, 7) for i in range(amount): # 1 in 4 additional objectives are AA. if random.randint(0, 3) == 0: - self.generate_aa_site() + if skip_sams > 0: + skip_sams -= 1 + else: + self.generate_aa_site() else: self.generate_ground_point() + def generate_required_aa(self) -> int: + """Generates the AA sites that are required by the campaign. + + Returns: + The number of AA sites that were generated. + """ + sams = self.control_point.preset_locations.required_sams + for position in sams: + self.generate_aa_at(position) + return len(sams) + def generate_ground_point(self) -> None: try: category = random.choice(self.faction.building_set) @@ -461,23 +590,14 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): obj_name = namegen.random_objective_name() template = random.choice(list(self.templates[category].values())) - offshore = category == "oil" + if category == "oil": + location_type = LocationType.OffshoreStrikeTarget + else: + location_type = LocationType.StrikeTarget # Pick from preset locations - location = self.pick_preset_location(offshore) - - # Else try the old algorithm - if location is None: - point = find_location(not offshore, - self.control_point.position, - self.game.theater, 10000, 40000, - self.control_point.ground_objects) - else: - point = location.position - + point = self.location_finder.location_for(location_type) if point is None: - logging.error( - f"Could not find point for {obj_name} at {self.control_point}") return object_id = 0 @@ -495,24 +615,12 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.control_point.connected_objectives.append(g) def generate_aa_site(self) -> None: - obj_name = namegen.random_objective_name() - - # Pick from preset locations - location = self.pick_preset_location(False) - - # If no preset location, then try the old algorithm - if location is None: - position = find_location(True, self.control_point.position, - self.game.theater, 10000, 40000, - self.control_point.ground_objects) - else: - position = location.position - + position = self.location_finder.location_for(LocationType.Sam) if position is None: - logging.error( - f"Could not find point for {obj_name} at {self.control_point}") return + self.generate_aa_at(position) + def generate_aa_at(self, position: Point) -> None: group_id = self.game.next_group_id() g = SamGroundObject(namegen.random_objective_name(), group_id, @@ -527,22 +635,8 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.generate_missile_site() def generate_missile_site(self) -> None: - - # Pick from preset locations - location = self.pick_preset_location(False) - - # If no preset location, then try the old algorithm - if location is None: - position = find_location(True, self.control_point.position, - self.game.theater, 2500, 40000, - [], False) - else: - position = location.position - - + position = self.location_finder.location_for(LocationType.MissileSite) if position is None: - logging.info( - f"Could not find point for {self.control_point} missile site") return group_id = self.game.next_group_id() @@ -577,72 +671,9 @@ class GroundObjectGenerator: generator = CarrierGroundObjectGenerator(self.game, control_point) elif control_point.cptype == ControlPointType.LHA_GROUP: generator = LhaGroundObjectGenerator(self.game, control_point) + elif isinstance(control_point, OffMapSpawn): + generator = NoOpGroundObjectGenerator(self.game, control_point) else: generator = AirbaseGroundObjectGenerator(self.game, control_point, self.templates) return generator.generate() - - -# TODO: https://stackoverflow.com/a/19482012/632035 -# A lot of the time spent on mission generation is spent in this function since -# just randomly guess up to 1800 times and often fail. This is particularly -# problematic while trying to find placement for navies in Nevada. -def find_location(on_ground: bool, near: Point, theater: ConflictTheater, - min_range: int, max_range: int, - others: List[TheaterGroundObject], - is_base_defense: bool = False) -> Optional[Point]: - """ - Find a valid ground object location - :param on_ground: Whether it should be on ground or on sea (True = on - ground) - :param near: Point - :param theater: Theater object - :param min_range: Minimal range from point - :param max_range: Max range from point - :param others: Other already existing ground objects - :param is_base_defense: True if the location is for base defense. - :return: - """ - point = None - for _ in range(300): - - # Check if on land or sea - p = near.random_point_within(max_range, min_range) - if on_ground and theater.is_on_land(p): - point = p - elif not on_ground and theater.is_in_sea(p): - point = p - - if point: - for angle in range(0, 360, 45): - p = point.point_from_heading(angle, 2500) - if on_ground and not theater.is_on_land(p): - point = None - break - elif not on_ground and not theater.is_in_sea(p): - point = None - break - if point: - for other in others: - if other.position.distance_to_point(point) < 10000: - point = None - break - - if point: - for control_point in theater.controlpoints: - if is_base_defense: - break - if control_point.position != near: - if point is None: - break - if control_point.position.distance_to_point(point) < 30000: - point = None - break - for ground_obj in control_point.ground_objects: - if ground_obj.position.distance_to_point(point) < 10000: - point = None - break - - if point: - return point - return None diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index c267a0eb..7f3f44a9 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -243,8 +243,8 @@ class BaseDefenseGroundObject(TheaterGroundObject): # TODO: Differentiate types. -# This type gets used both for AA sites (SAM, AAA, or SHORAD) but also for the -# armor garrisons at airbases. These should each be split into their own types. +# This type gets used both for AA sites (SAM, AAA, or SHORAD). These should each +# be split into their own types. class SamGroundObject(BaseDefenseGroundObject): def __init__(self, name: str, group_id: int, position: Point, control_point: ControlPoint, for_airbase: bool) -> None: diff --git a/game/utils.py b/game/utils.py index 44652472..58bc5018 100644 --- a/game/utils.py +++ b/game/utils.py @@ -12,3 +12,7 @@ def meter_to_nm(value_in_meter: float) -> int: def nm_to_meter(value_in_nm: float) -> int: return int(value_in_nm * 1852) + + +def knots_to_kph(knots: float) -> int: + return int(knots * 1.852) diff --git a/game/weather.py b/game/weather.py index d6775614..e8efd6e7 100644 --- a/game/weather.py +++ b/game/weather.py @@ -10,7 +10,7 @@ from typing import Optional from dcs.weather import Weather as PydcsWeather, Wind from game.settings import Settings -from theater import ConflictTheater +from game.theater import ConflictTheater class TimeOfDay(Enum): diff --git a/gen/aircraft.py b/gen/aircraft.py index 9eccfb17..f3690915 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -70,7 +70,13 @@ 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 game.theater.controlpoint import ( + ControlPoint, + ControlPointType, + OffMapSpawn, +) +from game.theater.theatergroundobject import TheaterGroundObject +from game.utils import knots_to_kph, nm_to_meter from gen.airsupportgen import AirSupport from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit @@ -83,8 +89,6 @@ from gen.flights.flight import ( ) from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio from gen.runways import RunwayData -from theater import TheaterGroundObject -from game.theater.controlpoint import ControlPoint, ControlPointType from .conflictgen import Conflict from .flights.flightplan import ( CasFlightPlan, @@ -92,7 +96,7 @@ from .flights.flightplan import ( PatrollingFlightPlan, SweepFlightPlan, ) -from .flights.traveltime import TotEstimator +from .flights.traveltime import GroundSpeed, TotEstimator from .naming import namegen from .runways import RunwayAssigner @@ -691,6 +695,18 @@ class AircraftConflictGenerator: return StartType.Cold return StartType.Warm + def determine_runway(self, cp: ControlPoint, dynamic_runways) -> RunwayData: + fallback = RunwayData(cp.full_name, runway_heading=0, runway_name="") + if cp.cptype == ControlPointType.AIRBASE: + assigner = RunwayAssigner(self.game.conditions) + return assigner.get_preferred_runway(cp.airport) + elif cp.is_fleet: + return dynamic_runways.get(cp.name, fallback) + else: + logging.warning( + f"Unhandled departure/arrival control point: {cp.cptype}") + return fallback + def _setup_group(self, group: FlyingGroup, for_task: Type[Task], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData]) -> None: @@ -748,19 +764,9 @@ class AircraftConflictGenerator: channel = self.get_intra_flight_channel(unit_type) group.set_frequency(channel.mhz) - # TODO: Support for different departure/arrival airfields. - cp = flight.from_cp - fallback_runway = RunwayData(cp.full_name, runway_heading=0, - runway_name="") - if cp.cptype == ControlPointType.AIRBASE: - assigner = RunwayAssigner(self.game.conditions) - departure_runway = assigner.get_preferred_runway( - flight.from_cp.airport) - elif cp.is_fleet: - departure_runway = dynamic_runways.get(cp.name, fallback_runway) - else: - logging.warning(f"Unhandled departure control point: {cp.cptype}") - departure_runway = fallback_runway + divert = None + if flight.divert is not None: + divert = self.determine_runway(flight.divert, dynamic_runways) self.flights.append(FlightData( package=package, @@ -770,10 +776,9 @@ class AircraftConflictGenerator: friendly=flight.from_cp.captured, # Set later. departure_delay=timedelta(), - departure=departure_runway, - arrival=departure_runway, - # TODO: Support for divert airfields. - divert=None, + departure=self.determine_runway(flight.departure, dynamic_runways), + arrival=self.determine_runway(flight.arrival, dynamic_runways), + divert=divert, # Waypoints are added later, after they've had their TOTs set. waypoints=[], intra_flight_channel=channel @@ -804,31 +809,37 @@ class AircraftConflictGenerator: group_size=count, parking_slots=None) - def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, at: Point) -> FlyingGroup: - assert count > 0 + def _generate_inflight(self, name: str, side: Country, flight: Flight, + origin: ControlPoint) -> FlyingGroup: + assert flight.count > 0 + at = origin.position - if unit_type in helicopters.helicopter_map.values(): + alt_type = "RADIO" + if isinstance(origin, OffMapSpawn): + alt = flight.flight_plan.waypoints[0].alt + alt_type = flight.flight_plan.waypoints[0].alt_type + elif flight.unit_type in helicopters.helicopter_map.values(): alt = WARM_START_HELI_ALT - speed = WARM_START_HELI_AIRSPEED else: alt = WARM_START_ALTITUDE - speed = WARM_START_AIRSPEED + + speed = knots_to_kph(GroundSpeed.for_flight(flight, alt)) pos = Point(at.x + random.randint(100, 1000), at.y + random.randint(100, 1000)) - logging.info("airgen: {} for {} at {} at {}".format(unit_type, side.id, alt, speed)) + logging.info("airgen: {} for {} at {} at {}".format(flight.unit_type, side.id, alt, speed)) group = self.m.flight_group( country=side, name=name, - aircraft_type=unit_type, + aircraft_type=flight.unit_type, airport=None, position=pos, altitude=alt, speed=speed, maintask=None, - group_size=count) + group_size=flight.count) - group.points[0].alt_type = "RADIO" + group.points[0].alt_type = alt_type return group def _generate_at_group(self, name: str, side: Country, @@ -974,9 +985,8 @@ class AircraftConflictGenerator: group = self._generate_inflight( name=namegen.next_unit_name(country, cp.id, flight.unit_type), side=country, - unit_type=flight.unit_type, - count=flight.count, - at=cp.position) + flight=flight, + origin=cp) elif cp.is_fleet: group_name = cp.get_carrier_group_name() group = self._generate_at_group( @@ -1002,9 +1012,8 @@ class AircraftConflictGenerator: group = self._generate_inflight( name=namegen.next_unit_name(country, cp.id, flight.unit_type), side=country, - unit_type=flight.unit_type, - count=flight.count, - at=cp.position) + flight=flight, + origin=cp) group.points[0].alt = 1500 return group diff --git a/gen/conflictgen.py b/gen/conflictgen.py index 6a5a8e07..35be5956 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -5,7 +5,8 @@ from typing import Tuple from dcs.country import Country from dcs.mapping import Point -from theater import ConflictTheater, ControlPoint, FrontLine +from game.theater.conflicttheater import ConflictTheater, FrontLine +from game.theater.controlpoint import ControlPoint AIR_DISTANCE = 40000 diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index ee458908..182e0455 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -16,11 +16,24 @@ from typing import ( Type, ) -from dcs.unittype import FlyingType, UnitType +from dcs.unittype import FlyingType from game import db from game.data.radar_db import UNITS_WITH_RADAR from game.infos.information import Information +from game.theater import ( + ControlPoint, + FrontLine, + MissionTarget, + OffMapSpawn, + SamGroundObject, + TheaterGroundObject, +) +# Avoid importing some types that cause circular imports unless type checking. +from game.theater.theatergroundobject import ( + EwrGroundObject, + NavalGroundObject, VehicleGroupGroundObject, +) from game.utils import nm_to_meter from gen import Conflict from gen.ato import Package @@ -46,19 +59,6 @@ from gen.flights.flight import ( ) from gen.flights.flightplan import FlightPlanBuilder from gen.flights.traveltime import TotEstimator -from theater import ( - ControlPoint, - FrontLine, - MissionTarget, - TheaterGroundObject, - SamGroundObject, -) - -# Avoid importing some types that cause circular imports unless type checking. -from game.theater.theatergroundobject import ( - EwrGroundObject, - NavalGroundObject, VehicleGroupGroundObject, -) if TYPE_CHECKING: from game import Game @@ -119,7 +119,7 @@ class AircraftAllocator: def find_aircraft_for_flight( self, flight: ProposedFlight - ) -> Optional[Tuple[ControlPoint, UnitType]]: + ) -> Optional[Tuple[ControlPoint, FlyingType]]: """Finds aircraft suitable for the given mission. Searches for aircraft capable of performing the given mission within the @@ -190,7 +190,7 @@ class AircraftAllocator: def find_aircraft_of_type( self, flight: ProposedFlight, types: List[Type[FlyingType]], - ) -> Optional[Tuple[ControlPoint, UnitType]]: + ) -> Optional[Tuple[ControlPoint, FlyingType]]: airfields_in_range = self.closest_airfields.airfields_within( flight.max_distance ) @@ -214,6 +214,8 @@ class PackageBuilder: global_inventory: GlobalAircraftInventory, is_player: bool, start_type: str) -> None: + self.closest_airfields = closest_airfields + self.is_player = is_player self.package = Package(location) self.allocator = AircraftAllocator(closest_airfields, global_inventory, is_player) @@ -232,11 +234,32 @@ class PackageBuilder: if assignment is None: return False airfield, aircraft = assignment - flight = Flight(self.package, aircraft, plan.num_aircraft, airfield, - plan.task, self.start_type) + if isinstance(airfield, OffMapSpawn): + start_type = "In Flight" + else: + start_type = self.start_type + + flight = Flight(self.package, aircraft, plan.num_aircraft, plan.task, + start_type, departure=airfield, arrival=airfield, + divert=self.find_divert_field(aircraft, airfield)) self.package.add_flight(flight) return True + def find_divert_field(self, aircraft: FlyingType, + arrival: ControlPoint) -> Optional[ControlPoint]: + divert_limit = nm_to_meter(150) + for airfield in self.closest_airfields.airfields_within(divert_limit): + if airfield.captured != self.is_player: + continue + if airfield == arrival: + continue + if not airfield.can_land(aircraft): + continue + if isinstance(airfield, OffMapSpawn): + continue + return airfield + return None + def build(self) -> Package: """Returns the built package.""" return self.package @@ -406,6 +429,9 @@ class ObjectiveFinder: CP. """ for cp in self.friendly_control_points(): + if isinstance(cp, OffMapSpawn): + # Off-map spawn locations don't need protection. + continue airfields_in_proximity = self.closest_airfields_to(cp) airfields_in_threat_range = airfields_in_proximity.airfields_within( self.AIRFIELD_THREAT_RANGE diff --git a/gen/flights/closestairfields.py b/gen/flights/closestairfields.py index a6045dde..5bba28db 100644 --- a/gen/flights/closestairfields.py +++ b/gen/flights/closestairfields.py @@ -1,7 +1,7 @@ """Objective adjacency lists.""" from typing import Dict, Iterator, List, Optional -from theater import ConflictTheater, ControlPoint, MissionTarget +from game.theater import ConflictTheater, ControlPoint, MissionTarget class ClosestAirfields: diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 2b5e35ea..276a6396 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta from enum import Enum -from typing import Dict, List, Optional, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Type from dcs.mapping import Point from dcs.point import MovingPoint, PointAction @@ -65,6 +65,7 @@ class FlightWaypointType(Enum): INGRESS_DEAD = 20 INGRESS_SWEEP = 21 INGRESS_BAI = 22 + DIVERT = 23 class FlightWaypoint: @@ -132,13 +133,16 @@ class FlightWaypoint: class Flight: - def __init__(self, package: Package, unit_type: FlyingType, count: int, - from_cp: ControlPoint, flight_type: FlightType, - start_type: str) -> None: + def __init__(self, package: Package, unit_type: Type[FlyingType], + count: int, flight_type: FlightType, start_type: str, + departure: ControlPoint, arrival: ControlPoint, + divert: Optional[ControlPoint]) -> None: self.package = package self.unit_type = unit_type self.count = count - self.from_cp = from_cp + self.departure = departure + self.arrival = arrival + self.divert = divert self.flight_type = flight_type # TODO: Replace with FlightPlan. self.targets: List[MissionTarget] = [] @@ -157,6 +161,10 @@ class Flight: custom_waypoints=[] ) + @property + def from_cp(self) -> ControlPoint: + return self.departure + @property def points(self) -> List[FlightWaypoint]: return self.flight_plan.waypoints[1:] diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 918861e2..d8758e32 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -7,20 +7,19 @@ generating the waypoints for the mission. """ from __future__ import annotations -import math -from datetime import timedelta -from functools import cached_property import logging +import math import random from dataclasses import dataclass +from datetime import timedelta +from functools import cached_property from typing import Iterator, List, Optional, Set, TYPE_CHECKING, Tuple 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 ( +from game.theater import ( ControlPoint, FrontLine, MissionTarget, @@ -28,6 +27,7 @@ from theater import ( TheaterGroundObject, ) from game.theater.theatergroundobject import EwrGroundObject +from game.utils import nm_to_meter from .closestairfields import ObjectiveDistanceCache from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType from .traveltime import GroundSpeed, TravelTime @@ -68,6 +68,10 @@ class FlightPlan: @property def waypoints(self) -> List[FlightWaypoint]: """A list of all waypoints in the flight plan, in order.""" + return list(self.iter_waypoints()) + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + """Iterates over all waypoints in the flight plan, in order.""" raise NotImplementedError @property @@ -166,8 +170,7 @@ class FlightPlan: class LoiterFlightPlan(FlightPlan): hold: FlightWaypoint - @property - def waypoints(self) -> List[FlightWaypoint]: + def iter_waypoints(self) -> Iterator[FlightWaypoint]: raise NotImplementedError @property @@ -193,8 +196,7 @@ class FormationFlightPlan(LoiterFlightPlan): join: FlightWaypoint split: FlightWaypoint - @property - def waypoints(self) -> List[FlightWaypoint]: + def iter_waypoints(self) -> Iterator[FlightWaypoint]: raise NotImplementedError @property @@ -295,8 +297,7 @@ class PatrollingFlightPlan(FlightPlan): return self.patrol_end_time return None - @property - def waypoints(self) -> List[FlightWaypoint]: + def iter_waypoints(self) -> Iterator[FlightWaypoint]: raise NotImplementedError @property @@ -312,15 +313,17 @@ class PatrollingFlightPlan(FlightPlan): class BarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint land: FlightWaypoint + divert: Optional[FlightWaypoint] - @property - def waypoints(self) -> List[FlightWaypoint]: - return [ + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from [ self.takeoff, self.patrol_start, self.patrol_end, self.land, ] + if self.divert is not None: + yield self.divert @dataclass(frozen=True) @@ -328,16 +331,18 @@ class CasFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint target: FlightWaypoint land: FlightWaypoint + divert: Optional[FlightWaypoint] - @property - def waypoints(self) -> List[FlightWaypoint]: - return [ + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from [ self.takeoff, self.patrol_start, self.target, self.patrol_end, self.land, ] + if self.divert is not None: + yield self.divert def request_escort_at(self) -> Optional[FlightWaypoint]: return self.patrol_start @@ -350,16 +355,18 @@ class CasFlightPlan(PatrollingFlightPlan): class TarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint land: FlightWaypoint + divert: Optional[FlightWaypoint] lead_time: timedelta - @property - def waypoints(self) -> List[FlightWaypoint]: - return [ + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from [ self.takeoff, self.patrol_start, self.patrol_end, self.land, ] + if self.divert is not None: + yield self.divert @property def tot_offset(self) -> timedelta: @@ -386,10 +393,6 @@ class TarCapFlightPlan(PatrollingFlightPlan): return super().patrol_end_time -# TODO: Remove when breaking save compat. -FrontLineCapFlightPlan = TarCapFlightPlan - - @dataclass(frozen=True) class StrikeFlightPlan(FormationFlightPlan): takeoff: FlightWaypoint @@ -400,19 +403,23 @@ class StrikeFlightPlan(FormationFlightPlan): egress: FlightWaypoint split: FlightWaypoint land: FlightWaypoint + divert: Optional[FlightWaypoint] - @property - def waypoints(self) -> List[FlightWaypoint]: - return [ + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from [ self.takeoff, self.hold, self.join, self.ingress - ] + self.targets + [ + ] + yield from self.targets + yield from[ self.egress, self.split, self.land, ] + if self.divert is not None: + yield self.divert @property def package_speed_waypoints(self) -> Set[FlightWaypoint]: @@ -511,17 +518,19 @@ class SweepFlightPlan(LoiterFlightPlan): sweep_start: FlightWaypoint sweep_end: FlightWaypoint land: FlightWaypoint + divert: Optional[FlightWaypoint] lead_time: timedelta - @property - def waypoints(self) -> List[FlightWaypoint]: - return [ + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from [ self.takeoff, self.hold, self.sweep_start, self.sweep_end, self.land, ] + if self.divert is not None: + yield self.divert @property def tot_waypoint(self) -> Optional[FlightWaypoint]: @@ -567,9 +576,8 @@ class SweepFlightPlan(LoiterFlightPlan): class CustomFlightPlan(FlightPlan): custom_waypoints: List[FlightWaypoint] - @property - def waypoints(self) -> List[FlightWaypoint]: - return self.custom_waypoints + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from self.custom_waypoints @property def tot_waypoint(self) -> Optional[FlightWaypoint]: @@ -774,10 +782,11 @@ class FlightPlanBuilder: package=self.package, flight=flight, patrol_duration=self.doctrine.cap_duration, - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), patrol_start=start, patrol_end=end, - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) def generate_sweep(self, flight: Flight) -> SweepFlightPlan: @@ -800,11 +809,12 @@ class FlightPlanBuilder: package=self.package, flight=flight, lead_time=timedelta(minutes=5), - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), hold=builder.hold(self._hold_point(flight)), sweep_start=start, sweep_end=end, - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) def racetrack_for_objective(self, @@ -900,10 +910,11 @@ class FlightPlanBuilder: # requests an escort the CAP flight will remain on station for the # duration of the escorted mission, or until it is winchester/bingo. patrol_duration=self.doctrine.cap_duration, - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), patrol_start=start, patrol_end=end, - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) def generate_dead(self, flight: Flight, @@ -965,14 +976,15 @@ class FlightPlanBuilder: return StrikeFlightPlan( package=self.package, flight=flight, - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), hold=builder.hold(self._hold_point(flight)), join=builder.join(self.package.waypoints.join), ingress=ingress, targets=[target], egress=egress, split=builder.split(self.package.waypoints.split), - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) def generate_cas(self, flight: Flight) -> CasFlightPlan: @@ -999,11 +1011,12 @@ class FlightPlanBuilder: package=self.package, flight=flight, patrol_duration=self.doctrine.cas_duration, - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), patrol_start=builder.ingress_cas(ingress, location), target=builder.cas(center), patrol_end=builder.egress(egress, location), - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) @staticmethod @@ -1030,7 +1043,7 @@ class FlightPlanBuilder: def _hold_point(self, flight: Flight) -> Point: assert self.package.waypoints is not None - origin = flight.from_cp.position + origin = flight.departure.position target = self.package.target.position join = self.package.waypoints.join origin_to_target = origin.distance_to_point(target) @@ -1118,14 +1131,15 @@ class FlightPlanBuilder: return StrikeFlightPlan( package=self.package, flight=flight, - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), hold=builder.hold(self._hold_point(flight)), join=builder.join(self.package.waypoints.join), ingress=ingress, targets=target_waypoints, egress=builder.egress(self.package.waypoints.egress, location), split=builder.split(self.package.waypoints.split), - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) def _retreating_rendezvous_point(self, attack_transition: Point) -> Point: @@ -1201,7 +1215,7 @@ class FlightPlanBuilder: ) for airfield in cache.closest_airfields: for flight in self.package.flights: - if flight.from_cp == airfield: + if flight.departure == airfield: return airfield raise RuntimeError( "Could not find any airfield assigned to this package" diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 742dfce3..7cc45069 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -45,20 +45,21 @@ class GroundSpeed: return int(cls.from_mach(mach, altitude)) # knots @staticmethod - def from_mach(mach: float, altitude: int) -> float: + def from_mach(mach: float, altitude_m: int) -> float: """Returns the ground speed in knots for the given mach and altitude. Args: mach: The mach number to convert to ground speed. - altitude: The altitude in feet. + altitude_m: The altitude in meters. Returns: The ground speed corresponding to the given altitude and mach number in knots. """ # https://www.grc.nasa.gov/WWW/K-12/airplane/atmos.html - if altitude <= 36152: - temperature_f = 59 - 0.00356 * altitude + altitude_ft = altitude_m * 3.28084 + if altitude_ft <= 36152: + temperature_f = 59 - 0.00356 * altitude_ft else: # There's another formula for altitudes over 82k feet, but we better # not be planning waypoints that high... diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index f220ebb4..74731929 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -8,11 +8,14 @@ from dcs.unit import Unit from dcs.unitgroup import VehicleGroup from game.data.doctrine import Doctrine -from game.utils import nm_to_meter +from game.theater import ( + ControlPoint, + MissionTarget, + OffMapSpawn, + TheaterGroundObject, +) from game.weather import Conditions -from theater import ControlPoint, MissionTarget, TheaterGroundObject from .flight import Flight, FlightWaypoint, FlightWaypointType -from ..runways import RunwayAssigner @dataclass(frozen=True) @@ -34,8 +37,7 @@ class WaypointBuilder: def is_helo(self) -> bool: return getattr(self.flight.unit_type, "helicopter", False) - @staticmethod - def takeoff(departure: ControlPoint) -> FlightWaypoint: + def takeoff(self, departure: ControlPoint) -> FlightWaypoint: """Create takeoff waypoint for the given arrival airfield or carrier. Note that the takeoff waypoint will automatically be created by pydcs @@ -46,36 +48,93 @@ class WaypointBuilder: departure: Departure airfield or carrier. """ position = departure.position - waypoint = FlightWaypoint( - FlightWaypointType.TAKEOFF, - position.x, - position.y, - 0 - ) - waypoint.name = "TAKEOFF" - waypoint.alt_type = "RADIO" - waypoint.description = "Takeoff" - waypoint.pretty_name = "Takeoff" + if isinstance(departure, OffMapSpawn): + waypoint = FlightWaypoint( + FlightWaypointType.NAV, + position.x, + position.y, + 500 if self.is_helo else self.doctrine.rendezvous_altitude + ) + waypoint.name = "NAV" + waypoint.alt_type = "BARO" + waypoint.description = "Enter theater" + waypoint.pretty_name = "Enter theater" + else: + waypoint = FlightWaypoint( + FlightWaypointType.TAKEOFF, + position.x, + position.y, + 0 + ) + waypoint.name = "TAKEOFF" + waypoint.alt_type = "RADIO" + waypoint.description = "Takeoff" + waypoint.pretty_name = "Takeoff" return waypoint - @staticmethod - def land(arrival: ControlPoint) -> FlightWaypoint: + def land(self, arrival: ControlPoint) -> FlightWaypoint: """Create descent waypoint for the given arrival airfield or carrier. Args: arrival: Arrival airfield or carrier. """ position = arrival.position + if isinstance(arrival, OffMapSpawn): + waypoint = FlightWaypoint( + FlightWaypointType.NAV, + position.x, + position.y, + 500 if self.is_helo else self.doctrine.rendezvous_altitude + ) + waypoint.name = "NAV" + waypoint.alt_type = "BARO" + waypoint.description = "Exit theater" + waypoint.pretty_name = "Exit theater" + else: + waypoint = FlightWaypoint( + FlightWaypointType.LANDING_POINT, + position.x, + position.y, + 0 + ) + waypoint.name = "LANDING" + waypoint.alt_type = "RADIO" + waypoint.description = "Land" + waypoint.pretty_name = "Land" + return waypoint + + def divert(self, + divert: Optional[ControlPoint]) -> Optional[FlightWaypoint]: + """Create divert waypoint for the given arrival airfield or carrier. + + Args: + divert: Divert airfield or carrier. + """ + if divert is None: + return None + + position = divert.position + if isinstance(divert, OffMapSpawn): + if self.is_helo: + altitude = 500 + else: + altitude = self.doctrine.rendezvous_altitude + altitude_type = "BARO" + else: + altitude = 0 + altitude_type = "RADIO" + waypoint = FlightWaypoint( - FlightWaypointType.LANDING_POINT, + FlightWaypointType.DIVERT, position.x, position.y, - 0 + altitude ) - waypoint.name = "LANDING" - waypoint.alt_type = "RADIO" - waypoint.description = "Land" - waypoint.pretty_name = "Land" + waypoint.alt_type = altitude_type + waypoint.name = "DIVERT" + waypoint.description = "Divert" + waypoint.pretty_name = "Divert" + waypoint.only_for_player = True return waypoint def hold(self, position: Point) -> FlightWaypoint: diff --git a/gen/ground_forces/ai_ground_planner.py b/gen/ground_forces/ai_ground_planner.py index db1deb03..b0f14df4 100644 --- a/gen/ground_forces/ai_ground_planner.py +++ b/gen/ground_forces/ai_ground_planner.py @@ -2,12 +2,12 @@ import random from enum import Enum from typing import Dict, List -from dcs.vehicles import Armor, Artillery, Infantry, Unarmed from dcs.unittype import VehicleType +from dcs.vehicles import Armor, Artillery, Infantry, Unarmed import pydcs_extensions.frenchpack.frenchpack as frenchpack +from game.theater import ControlPoint from gen.ground_forces.combat_stance import CombatStance -from theater import ControlPoint TYPE_TANKS = [ Armor.MBT_T_55, diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index edd58e6d..09385c78 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -20,14 +20,14 @@ from dcs.task import ( EPLRS, OptAlarmState, ) -from dcs.unit import Ship, Vehicle, Unit +from dcs.unit import Ship, Unit, Vehicle from dcs.unitgroup import Group, ShipGroup, StaticGroup from dcs.unittype import StaticType, UnitType from game import db from game.data.building_data import FORTIFICATION_UNITS, FORTIFICATION_UNITS_ID from game.db import unit_type_from_name -from theater import ControlPoint, TheaterGroundObject +from game.theater import ControlPoint, TheaterGroundObject from game.theater.theatergroundobject import ( BuildingGroundObject, CarrierGroundObject, GenericCarrierGroundObject, diff --git a/gen/locations/preset_location_finder.py b/gen/locations/preset_location_finder.py index 41386d90..4df32466 100644 --- a/gen/locations/preset_location_finder.py +++ b/gen/locations/preset_location_finder.py @@ -8,7 +8,7 @@ from gen.locations.preset_control_point_locations import PresetControlPointLocat from gen.locations.preset_locations import PresetLocation -class PresetLocationFinder: +class MizDataLocationFinder: @staticmethod def compute_possible_locations(terrain_name: str, cp_name: str) -> PresetControlPointLocations: diff --git a/gen/radios.py b/gen/radios.py index c2180fe3..87b8661f 100644 --- a/gen/radios.py +++ b/gen/radios.py @@ -134,7 +134,7 @@ RADIOS: List[Radio] = [ Radio("RSIU-4V", MHz(100), MHz(150), step=MHz(1)), # MiG-21bis - Radio("RSIU-5V", MHz(100), MHz(150), step=MHz(1)), + Radio("RSIU-5V", MHz(118), MHz(140), step=MHz(1)), # Ka-50 # Note: Also capable of 100MHz-150MHz, but we can't model gaps. diff --git a/gen/runways.py b/gen/runways.py index 5323c37b..658cc846 100644 --- a/gen/runways.py +++ b/gen/runways.py @@ -7,8 +7,8 @@ from typing import Iterator, Optional from dcs.terrain.terrain import Airport +from game.theater import ControlPoint, ControlPointType from game.weather import Conditions -from theater import ControlPoint, ControlPointType from .airfields import AIRFIELD_DATA from .radios import RadioFrequency from .tacan import TacanChannel diff --git a/qt_ui/widgets/base/QAirportInformation.py b/qt_ui/widgets/base/QAirportInformation.py deleted file mode 100644 index 4fc1474c..00000000 --- a/qt_ui/widgets/base/QAirportInformation.py +++ /dev/null @@ -1,52 +0,0 @@ -from PySide2.QtWidgets import QGridLayout, QLabel, QGroupBox, QVBoxLayout, QLCDNumber - -from theater import ControlPoint, Airport - - -class QAirportInformation(QGroupBox): - - def __init__(self, cp:ControlPoint, airport:Airport): - super(QAirportInformation, self).__init__(airport.name) - self.cp = cp - self.airport = airport - self.init_ui() - - def init_ui(self): - self.layout = QGridLayout() - - # Runway information - self.runways = QGroupBox("Runways") - self.runwayLayout = QGridLayout() - for i, runway in enumerate(self.airport.runways): - - # Seems like info is missing in pydcs, even if the attribute is there - lr = "" - if runway.leftright == 1: - lr = "L" - elif runway.leftright == 2: - lr = "R" - - self.runwayLayout.addWidget(QLabel("Runway " + str(runway.heading) + lr), i, 0) - - # Seems like info is missing in pydcs, even if the attribute is there - if runway.ils: - self.runwayLayout.addWidget(QLabel("ILS "), i, 1) - self.runwayLayout.addWidget(QLCDNumber(6, runway.ils), i, 1) - else: - self.runwayLayout.addWidget(QLabel("NO ILS"), i, 1) - - - self.runways.setLayout(self.runwayLayout) - self.layout.addWidget(self.runways, 0, 0) - - self.layout.addWidget(QLabel("Parking Slots :"), 1, 0) - self.layout.addWidget(QLabel(str(len(self.airport.parking_slots))), 1, 1) - - - stretch = QVBoxLayout() - stretch.addStretch() - - self.layout.addLayout(stretch, 2, 0) - self.setLayout(self.layout) - - diff --git a/qt_ui/widgets/combos/QAircraftTypeSelector.py b/qt_ui/widgets/combos/QAircraftTypeSelector.py index 1f490e4d..2be6e48c 100644 --- a/qt_ui/widgets/combos/QAircraftTypeSelector.py +++ b/qt_ui/widgets/combos/QAircraftTypeSelector.py @@ -3,13 +3,13 @@ from typing import Iterable from PySide2.QtWidgets import QComboBox -from dcs.planes import PlaneType +from dcs.unittype import FlyingType class QAircraftTypeSelector(QComboBox): """Combo box for selecting among the given aircraft types.""" - def __init__(self, aircraft_types: Iterable[PlaneType]) -> None: + def __init__(self, aircraft_types: Iterable[FlyingType]) -> None: super().__init__() for aircraft in aircraft_types: self.addItem(f"{aircraft.id}", userData=aircraft) diff --git a/qt_ui/widgets/combos/QArrivalAirfieldSelector.py b/qt_ui/widgets/combos/QArrivalAirfieldSelector.py new file mode 100644 index 00000000..c5d89b90 --- /dev/null +++ b/qt_ui/widgets/combos/QArrivalAirfieldSelector.py @@ -0,0 +1,40 @@ +"""Combo box for selecting a departure airfield.""" +from typing import Iterable + +from PySide2.QtWidgets import QComboBox +from dcs.unittype import FlyingType + +from game import db +from game.theater.controlpoint import ControlPoint + + +class QArrivalAirfieldSelector(QComboBox): + """A combo box for selecting a flight's arrival or divert airfield. + + The combo box will automatically be populated with all airfields the given + aircraft type is able to land at. + """ + + def __init__(self, destinations: Iterable[ControlPoint], + aircraft: FlyingType, optional_text: str) -> None: + super().__init__() + self.destinations = list(destinations) + self.aircraft = aircraft + self.optional_text = optional_text + self.rebuild_selector() + self.setCurrentIndex(0) + + def change_aircraft(self, aircraft: FlyingType) -> None: + if self.aircraft == aircraft: + return + self.aircraft = aircraft + self.rebuild_selector() + + def rebuild_selector(self) -> None: + self.clear() + for destination in self.destinations: + if destination.can_land(self.aircraft): + self.addItem(destination.name, destination) + self.model().sort(0) + self.insertItem(0, self.optional_text, None) + self.update() diff --git a/qt_ui/widgets/combos/QFlightTypeComboBox.py b/qt_ui/widgets/combos/QFlightTypeComboBox.py index 6ba9e455..1918dd4d 100644 --- a/qt_ui/widgets/combos/QFlightTypeComboBox.py +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -2,7 +2,7 @@ from PySide2.QtWidgets import QComboBox -from theater import ConflictTheater, MissionTarget +from game.theater import ConflictTheater, MissionTarget class QFlightTypeComboBox(QComboBox): diff --git a/qt_ui/widgets/combos/QOriginAirfieldSelector.py b/qt_ui/widgets/combos/QOriginAirfieldSelector.py index 14bdbb47..ce1c6301 100644 --- a/qt_ui/widgets/combos/QOriginAirfieldSelector.py +++ b/qt_ui/widgets/combos/QOriginAirfieldSelector.py @@ -3,7 +3,7 @@ from typing import Iterable from PySide2.QtCore import Signal from PySide2.QtWidgets import QComboBox -from dcs.planes import PlaneType +from dcs.unittype import FlyingType from game.inventory import GlobalAircraftInventory from game.theater.controlpoint import ControlPoint @@ -20,7 +20,7 @@ class QOriginAirfieldSelector(QComboBox): def __init__(self, global_inventory: GlobalAircraftInventory, origins: Iterable[ControlPoint], - aircraft: PlaneType) -> None: + aircraft: FlyingType) -> None: super().__init__() self.global_inventory = global_inventory self.origins = list(origins) @@ -28,7 +28,7 @@ class QOriginAirfieldSelector(QComboBox): self.rebuild_selector() self.currentIndexChanged.connect(self.index_changed) - def change_aircraft(self, aircraft: PlaneType) -> None: + def change_aircraft(self, aircraft: FlyingType) -> None: if self.aircraft == aircraft: return self.aircraft = aircraft diff --git a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py index 8af3c3f4..8f40afde 100644 --- a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py +++ b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py @@ -1,10 +1,10 @@ from PySide2.QtGui import QStandardItem, QStandardItemModel from game import Game +from game.theater import ControlPointType from gen import BuildingGroundObject, Conflict, FlightWaypointType from gen.flights.flight import FlightWaypoint from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox -from theater import ControlPointType class QPredefinedWaypointSelectionComboBox(QFilteredComboBox): diff --git a/qt_ui/widgets/map/QFrontLine.py b/qt_ui/widgets/map/QFrontLine.py index 1849f5ff..2ca71953 100644 --- a/qt_ui/widgets/map/QFrontLine.py +++ b/qt_ui/widgets/map/QFrontLine.py @@ -13,11 +13,11 @@ from PySide2.QtWidgets import ( ) import qt_ui.uiconstants as const +from game.theater import FrontLine from qt_ui.dialogs import Dialog from qt_ui.models import GameModel from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.mission.QPackageDialog import QNewPackageDialog -from theater import FrontLine class QFrontLine(QGraphicsLineItem): diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index fb5802c3..50fc5fba 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -3,7 +3,7 @@ from __future__ import annotations import datetime import logging import math -from typing import Iterable, List, Optional, Tuple, Iterator +from typing import Iterable, Iterator, List, Optional, Tuple from PySide2.QtCore import QPointF, Qt from PySide2.QtGui import ( @@ -27,6 +27,13 @@ from dcs.mapping import point_from_heading import qt_ui.uiconstants as CONST from game import Game, db +from game.theater import ControlPoint +from game.theater.conflicttheater import FrontLine +from game.theater.theatergroundobject import ( + EwrGroundObject, + MissileSiteGroundObject, + TheaterGroundObject, +) from game.utils import meter_to_feet from game.weather import TimeOfDay from gen import Conflict @@ -39,13 +46,7 @@ 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.windows.GameUpdateSignal import GameUpdateSignal -from theater import ControlPoint -from game.theater.conflicttheater import FrontLine -from game.theater.theatergroundobject import ( - EwrGroundObject, - MissileSiteGroundObject, - TheaterGroundObject, -) + def binomial(i: int, n: int) -> float: """Binomial coefficient""" @@ -373,6 +374,10 @@ class QLiberationMap(QGraphicsView): FlightWaypointType.TARGET_SHIP, ) for idx, point in enumerate(flight.flight_plan.waypoints[1:]): + if point.waypoint_type == FlightWaypointType.DIVERT: + # Don't clutter the map showing divert points. + continue + new_pos = self._transform_point(Point(point.x, point.y)) self.draw_flight_path(scene, prev_pos, new_pos, is_player, selected) @@ -386,7 +391,6 @@ class QLiberationMap(QGraphicsView): self.draw_waypoint_info(scene, idx + 1, point, new_pos, flight.flight_plan) prev_pos = tuple(new_pos) - self.draw_flight_path(scene, prev_pos, pos, is_player, selected) def draw_waypoint(self, scene: QGraphicsScene, position: Tuple[int, int], player: bool, selected: bool) -> None: diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py index e59cdbfb..0f88bf7e 100644 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ b/qt_ui/widgets/map/QMapControlPoint.py @@ -4,9 +4,9 @@ from PySide2.QtGui import QColor, QPainter from PySide2.QtWidgets import QAction, QMenu import qt_ui.uiconstants as const +from game.theater import ControlPoint from qt_ui.models import GameModel from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 -from theater import ControlPoint from .QMapObject import QMapObject from ...displayoptions import DisplayOptions from ...windows.GameUpdateSignal import GameUpdateSignal @@ -79,11 +79,8 @@ class QMapControlPoint(QMapObject): for connected in self.control_point.connected_points: if connected.captured: + menu.addAction(self.capture_action) break - else: - return - - menu.addAction(self.capture_action) def cheat_capture(self) -> None: self.control_point.capture(self.game_model.game, for_player=True) diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py index a7d857f3..f1d3e542 100644 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ b/qt_ui/widgets/map/QMapGroundObject.py @@ -8,8 +8,8 @@ import qt_ui.uiconstants as const from game import Game from game.data.building_data import FORTIFICATION_BUILDINGS from game.db import REWARDS +from game.theater import ControlPoint, TheaterGroundObject from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu -from theater import ControlPoint, TheaterGroundObject from .QMapObject import QMapObject from ...displayoptions import DisplayOptions diff --git a/qt_ui/widgets/map/QMapObject.py b/qt_ui/widgets/map/QMapObject.py index a3c57c19..16f07061 100644 --- a/qt_ui/widgets/map/QMapObject.py +++ b/qt_ui/widgets/map/QMapObject.py @@ -47,9 +47,12 @@ class QMapObject(QGraphicsRectItem): 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) + # Not all locations have valid objetives. Off-map spawns, for example, + # have no mission types. + if list(self.mission_target.mission_types(for_player=True)): + new_package_action = QAction(f"New package") + new_package_action.triggered.connect(self.open_new_package_dialog) + menu.addAction(new_package_action) self.add_context_menu_actions(menu) diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index cf5e1a34..3740448a 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -2,12 +2,12 @@ from PySide2.QtCore import Qt from PySide2.QtGui import QCloseEvent, QPixmap from PySide2.QtWidgets import QDialog, QGridLayout, QHBoxLayout, QLabel, QWidget +from game.theater import ControlPoint, ControlPointType from qt_ui.models import GameModel from qt_ui.uiconstants import EVENT_ICONS from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour -from theater import ControlPoint, ControlPointType class QBaseMenu2(QDialog): @@ -18,7 +18,6 @@ class QBaseMenu2(QDialog): # Attrs self.cp = cp self.game_model = game_model - self.is_carrier = self.cp.cptype in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP] self.objectName = "menuDialogue" # Widgets @@ -58,7 +57,7 @@ class QBaseMenu2(QDialog): title = QLabel("" + self.cp.name + "") title.setAlignment(Qt.AlignLeft | Qt.AlignTop) title.setProperty("style", "base-title") - unitsPower = QLabel("{} / {} / Runway : {}".format(self.cp.base.total_planes, self.cp.base.total_armor, + unitsPower = QLabel("{} / {} / Runway : {}".format(self.cp.base.total_aircraft, self.cp.base.total_armor, "Available" if self.cp.has_runway() else "Unavailable")) self.topLayout.addWidget(title) self.topLayout.addWidget(unitsPower) diff --git a/qt_ui/windows/basemenu/QBaseMenuTabs.py b/qt_ui/windows/basemenu/QBaseMenuTabs.py index 0c82c86e..1e705372 100644 --- a/qt_ui/windows/basemenu/QBaseMenuTabs.py +++ b/qt_ui/windows/basemenu/QBaseMenuTabs.py @@ -1,43 +1,34 @@ -from PySide2.QtWidgets import QFrame, QGridLayout, QLabel, QTabWidget +from PySide2.QtWidgets import QTabWidget +from game.theater import ControlPoint, OffMapSpawn from qt_ui.models import GameModel from qt_ui.windows.basemenu.airfield.QAirfieldCommand import QAirfieldCommand from qt_ui.windows.basemenu.base_defenses.QBaseDefensesHQ import QBaseDefensesHQ from qt_ui.windows.basemenu.ground_forces.QGroundForcesHQ import QGroundForcesHQ from qt_ui.windows.basemenu.intel.QIntelInfo import QIntelInfo -from theater import ControlPoint class QBaseMenuTabs(QTabWidget): def __init__(self, cp: ControlPoint, game_model: GameModel): super(QBaseMenuTabs, self).__init__() - self.cp = cp - if cp: - - if not cp.captured: - if not cp.is_carrier: - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Base Defenses") - self.intel = QIntelInfo(cp, game_model.game) - self.addTab(self.intel, "Intel") - else: - if cp.has_runway(): - self.airfield_command = QAirfieldCommand(cp, game_model) - self.addTab(self.airfield_command, "Airfield Command") - - if not cp.is_carrier: - self.ground_forces_hq = QGroundForcesHQ(cp, game_model) - self.addTab(self.ground_forces_hq, "Ground Forces HQ") - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Base Defenses") - else: - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Fleet") + if not cp.captured: + if not cp.is_carrier and not isinstance(cp, OffMapSpawn): + self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) + self.addTab(self.base_defenses_hq, "Base Defenses") + self.intel = QIntelInfo(cp, game_model.game) + self.addTab(self.intel, "Intel") else: - tabError = QFrame() - l = QGridLayout() - l.addWidget(QLabel("No Control Point")) - tabError.setLayout(l) - self.addTab(tabError, "No Control Point") \ No newline at end of file + if cp.has_runway(): + self.airfield_command = QAirfieldCommand(cp, game_model) + self.addTab(self.airfield_command, "Airfield Command") + + if cp.is_carrier: + self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) + self.addTab(self.base_defenses_hq, "Fleet") + elif not isinstance(cp, OffMapSpawn): + self.ground_forces_hq = QGroundForcesHQ(cp, game_model) + self.addTab(self.ground_forces_hq, "Ground Forces HQ") + self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) + self.addTab(self.base_defenses_hq, "Base Defenses") \ No newline at end of file diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py index b41ac68a..7f462c57 100644 --- a/qt_ui/windows/basemenu/QRecruitBehaviour.py +++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py @@ -1,3 +1,6 @@ +import logging +from typing import Type + from PySide2.QtWidgets import ( QGroupBox, QHBoxLayout, @@ -6,17 +9,17 @@ from PySide2.QtWidgets import ( QSizePolicy, QSpacerItem, ) -import logging from dcs.unittype import UnitType -from theater import db - +from game import db +from game.event import UnitsDeliveryEvent +from game.theater import ControlPoint +from qt_ui.models import GameModel class QRecruitBehaviour: - game = None - cp = None - deliveryEvent = None + game_model: GameModel + cp: ControlPoint existing_units_labels = None bought_amount_labels = None maximum_units = -1 @@ -24,12 +27,16 @@ class QRecruitBehaviour: BUDGET_FORMAT = "Available Budget: ${}M" def __init__(self) -> None: - self.deliveryEvent = None self.bought_amount_labels = {} self.existing_units_labels = {} self.recruitable_types = [] self.update_available_budget() + @property + def pending_deliveries(self) -> UnitsDeliveryEvent: + assert self.cp.pending_unit_deliveries + return self.cp.pending_unit_deliveries + @property def budget(self) -> int: return self.game_model.game.budget @@ -47,7 +54,7 @@ class QRecruitBehaviour: exist.setLayout(existLayout) existing_units = self.cp.base.total_units_of_type(unit_type) - scheduled_units = self.deliveryEvent.units.get(unit_type, 0) + scheduled_units = self.pending_deliveries.units.get(unit_type, 0) unitName = QLabel("" + db.unit_type_name_2(unit_type) + "") unitName.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)) @@ -100,10 +107,10 @@ class QRecruitBehaviour: return row + 1 - def _update_count_label(self, unit_type: UnitType): + def _update_count_label(self, unit_type: Type[UnitType]): self.bought_amount_labels[unit_type].setText("{}".format( - unit_type in self.deliveryEvent.units and "{}".format(self.deliveryEvent.units[unit_type]) or "0" + unit_type in self.pending_deliveries.units and "{}".format(self.pending_deliveries.units[unit_type]) or "0" )) self.existing_units_labels[unit_type].setText("{}".format( @@ -119,17 +126,10 @@ class QRecruitBehaviour: child.setText( QRecruitBehaviour.BUDGET_FORMAT.format(self.budget)) - def buy(self, unit_type): - - if self.maximum_units > 0: - if self.total_units + 1 > self.maximum_units: - logging.info("Not enough space left !") - # TODO : display modal warning - return - + def buy(self, unit_type: Type[UnitType]): price = db.PRICES[unit_type] if self.budget >= price: - self.deliveryEvent.deliver({unit_type: 1}) + self.pending_deliveries.deliver({unit_type: 1}) self.budget -= price else: # TODO : display modal warning @@ -138,12 +138,12 @@ class QRecruitBehaviour: self.update_available_budget() def sell(self, unit_type): - if self.deliveryEvent.units.get(unit_type, 0) > 0: + if self.pending_deliveries.units.get(unit_type, 0) > 0: price = db.PRICES[unit_type] self.budget += price - self.deliveryEvent.units[unit_type] = self.deliveryEvent.units[unit_type] - 1 - if self.deliveryEvent.units[unit_type] == 0: - del self.deliveryEvent.units[unit_type] + self.pending_deliveries.units[unit_type] = self.pending_deliveries.units[unit_type] - 1 + if self.pending_deliveries.units[unit_type] == 0: + del self.pending_deliveries.units[unit_type] elif self.cp.base.total_units_of_type(unit_type) > 0: price = db.PRICES[unit_type] self.budget += price @@ -152,25 +152,6 @@ class QRecruitBehaviour: self._update_count_label(unit_type) self.update_available_budget() - @property - def total_units(self): - - total = 0 - for unit_type in self.recruitables_types: - total += self.cp.base.total_units(unit_type) - print(unit_type, total, self.cp.base.total_units(unit_type)) - print("--------------------------------") - - if self.deliveryEvent: - for unit_bought in self.deliveryEvent.units: - if db.unit_task(unit_bought) in self.recruitables_types: - total += self.deliveryEvent.units[unit_bought] - print(unit_bought, total, self.deliveryEvent.units[unit_bought]) - - print("=============================") - - return total - def set_maximum_units(self, maximum_units): """ Set the maximum number of units that can be bought @@ -181,4 +162,4 @@ class QRecruitBehaviour: """ Set the maximum number of units that can be bought """ - self.recruitables_types = recruitables_types \ No newline at end of file + self.recruitables_types = recruitables_types diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index a01aaaa9..7c159e94 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -1,3 +1,4 @@ +import logging from typing import Optional, Set from PySide2.QtCore import Qt @@ -11,13 +12,14 @@ from PySide2.QtWidgets import ( QVBoxLayout, QWidget, ) +from dcs.task import CAP, CAS from dcs.unittype import UnitType -from game.event.event import UnitsDeliveryEvent +from game import db +from game.theater import ControlPoint from qt_ui.models import GameModel from qt_ui.uiconstants import ICONS from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour -from theater import CAP, CAS, ControlPoint, db class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): @@ -25,25 +27,18 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): QFrame.__init__(self) self.cp = cp self.game_model = game_model - self.deliveryEvent: Optional[UnitsDeliveryEvent] = None self.bought_amount_labels = {} self.existing_units_labels = {} - for event in self.game_model.game.events: - if event.__class__ == UnitsDeliveryEvent and event.from_cp == self.cp: - self.deliveryEvent = event - if not self.deliveryEvent: - self.deliveryEvent = self.game_model.game.units_delivery_event(self.cp) - # Determine maximum number of aircrafts that can be bought - self.set_maximum_units(self.cp.available_aircraft_slots) + self.set_maximum_units(self.cp.total_aircraft_parking) self.set_recruitable_types([CAP, CAS]) self.bought_amount_labels = {} self.existing_units_labels = {} - self.hangar_status = QHangarStatus(self.total_units, self.cp.available_aircraft_slots) + self.hangar_status = QHangarStatus(self.cp) self.init_ui() @@ -86,13 +81,18 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): self.setLayout(main_layout) def buy(self, unit_type): + if self.maximum_units > 0: + if self.cp.unclaimed_parking <= 0: + logging.debug(f"No space for additional aircraft at {self.cp}.") + return + super().buy(unit_type) - self.hangar_status.update_label(self.total_units, self.cp.available_aircraft_slots) + self.hangar_status.update_label() def sell(self, unit_type: UnitType): # Don't need to remove aircraft from the inventory if we're canceling # orders. - if self.deliveryEvent.units.get(unit_type, 0) <= 0: + if self.pending_deliveries.units.get(unit_type, 0) <= 0: global_inventory = self.game_model.game.aircraft_inventory inventory = global_inventory.for_control_point(self.cp) try: @@ -105,22 +105,26 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): "assigned to a mission?", QMessageBox.Ok) return super().sell(unit_type) - self.hangar_status.update_label(self.total_units, self.cp.available_aircraft_slots) + self.hangar_status.update_label() class QHangarStatus(QHBoxLayout): - def __init__(self, current_amount: int, max_amount: int): - super(QHangarStatus, self).__init__() + def __init__(self, control_point: ControlPoint) -> None: + super().__init__() + self.control_point = control_point + self.icon = QLabel() self.icon.setPixmap(ICONS["Hangar"]) self.text = QLabel("") - self.update_label(current_amount, max_amount) + self.update_label() self.addWidget(self.icon, Qt.AlignLeft) self.addWidget(self.text, Qt.AlignLeft) self.addStretch(50) self.setAlignment(Qt.AlignLeft) - def update_label(self, current_amount: int, max_amount: int): - self.text.setText("{}/{}".format(current_amount, max_amount)) + def update_label(self) -> None: + current_amount = self.control_point.expected_aircraft_next_turn + max_amount = self.control_point.total_aircraft_parking + self.text.setText(f"{current_amount}/{max_amount}") diff --git a/qt_ui/windows/basemenu/airfield/QAirfieldCommand.py b/qt_ui/windows/basemenu/airfield/QAirfieldCommand.py index 9965115a..97c804bf 100644 --- a/qt_ui/windows/basemenu/airfield/QAirfieldCommand.py +++ b/qt_ui/windows/basemenu/airfield/QAirfieldCommand.py @@ -1,10 +1,10 @@ from PySide2.QtWidgets import QFrame, QGridLayout, QGroupBox, QVBoxLayout +from game.theater import ControlPoint from qt_ui.models import GameModel from qt_ui.windows.basemenu.airfield.QAircraftRecruitmentMenu import \ QAircraftRecruitmentMenu from qt_ui.windows.mission.QPlannedFlightsView import QPlannedFlightsView -from theater import ControlPoint class QAirfieldCommand(QFrame): diff --git a/qt_ui/windows/basemenu/base_defenses/QBaseDefenseGroupInfo.py b/qt_ui/windows/basemenu/base_defenses/QBaseDefenseGroupInfo.py index 350cf5e8..6d46b35b 100644 --- a/qt_ui/windows/basemenu/base_defenses/QBaseDefenseGroupInfo.py +++ b/qt_ui/windows/basemenu/base_defenses/QBaseDefenseGroupInfo.py @@ -1,10 +1,16 @@ from PySide2.QtCore import Qt -from PySide2.QtWidgets import QGridLayout, QLabel, QGroupBox, QPushButton, QVBoxLayout +from PySide2.QtWidgets import ( + QGridLayout, + QGroupBox, + QLabel, + QPushButton, + QVBoxLayout, +) +from game.theater import ControlPoint, TheaterGroundObject from qt_ui.dialogs import Dialog from qt_ui.uiconstants import VEHICLES_ICONS from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu -from theater import ControlPoint, TheaterGroundObject class QBaseDefenseGroupInfo(QGroupBox): diff --git a/qt_ui/windows/basemenu/base_defenses/QBaseDefensesHQ.py b/qt_ui/windows/basemenu/base_defenses/QBaseDefensesHQ.py index 5ad1f6c9..75a45eb0 100644 --- a/qt_ui/windows/basemenu/base_defenses/QBaseDefensesHQ.py +++ b/qt_ui/windows/basemenu/base_defenses/QBaseDefensesHQ.py @@ -1,7 +1,9 @@ from PySide2.QtWidgets import QFrame, QGridLayout + from game import Game -from qt_ui.windows.basemenu.base_defenses.QBaseInformation import QBaseInformation -from theater import ControlPoint +from game.theater import ControlPoint +from qt_ui.windows.basemenu.base_defenses.QBaseInformation import \ + QBaseInformation class QBaseDefensesHQ(QFrame): diff --git a/qt_ui/windows/basemenu/base_defenses/QBaseInformation.py b/qt_ui/windows/basemenu/base_defenses/QBaseInformation.py index f5325887..50ec2f81 100644 --- a/qt_ui/windows/basemenu/base_defenses/QBaseInformation.py +++ b/qt_ui/windows/basemenu/base_defenses/QBaseInformation.py @@ -1,10 +1,15 @@ from PySide2.QtGui import Qt -from PySide2.QtWidgets import QGridLayout, QLabel, QGroupBox, QVBoxLayout, QFrame, QWidget, QScrollArea +from PySide2.QtWidgets import ( + QFrame, + QGridLayout, + QScrollArea, + QVBoxLayout, + QWidget, +) -from game import db -from qt_ui.uiconstants import AIRCRAFT_ICONS, VEHICLES_ICONS -from qt_ui.windows.basemenu.base_defenses.QBaseDefenseGroupInfo import QBaseDefenseGroupInfo -from theater import ControlPoint, Airport +from game.theater import Airport, ControlPoint +from qt_ui.windows.basemenu.base_defenses.QBaseDefenseGroupInfo import \ + QBaseDefenseGroupInfo class QBaseInformation(QFrame): diff --git a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py index ec1cabf6..c359eaaf 100644 --- a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py @@ -6,11 +6,12 @@ from PySide2.QtWidgets import ( QVBoxLayout, QWidget, ) +from dcs.task import PinpointStrike -from game.event import UnitsDeliveryEvent +from game import db +from game.theater import ControlPoint from qt_ui.models import GameModel from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour -from theater import ControlPoint, PinpointStrike, db class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): @@ -23,12 +24,6 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): self.bought_amount_labels = {} self.existing_units_labels = {} - for event in self.game_model.game.events: - if event.__class__ == UnitsDeliveryEvent and event.from_cp == self.cp: - self.deliveryEvent = event - if not self.deliveryEvent: - self.deliveryEvent = self.game_model.game.units_delivery_event(self.cp) - self.init_ui() def init_ui(self): @@ -61,4 +56,4 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): scroll.setWidgetResizable(True) scroll.setWidget(scroll_content) main_layout.addWidget(scroll) - self.setLayout(main_layout) \ No newline at end of file + self.setLayout(main_layout) diff --git a/qt_ui/windows/basemenu/ground_forces/QGroundForcesHQ.py b/qt_ui/windows/basemenu/ground_forces/QGroundForcesHQ.py index bb18594f..39cba843 100644 --- a/qt_ui/windows/basemenu/ground_forces/QGroundForcesHQ.py +++ b/qt_ui/windows/basemenu/ground_forces/QGroundForcesHQ.py @@ -1,11 +1,11 @@ from PySide2.QtWidgets import QFrame, QGridLayout +from game.theater import ControlPoint from qt_ui.models import GameModel from qt_ui.windows.basemenu.ground_forces.QArmorRecruitmentMenu import \ QArmorRecruitmentMenu from qt_ui.windows.basemenu.ground_forces.QGroundForcesStrategy import \ QGroundForcesStrategy -from theater import ControlPoint class QGroundForcesHQ(QFrame): diff --git a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py index 0b7b4db6..3aee8c50 100644 --- a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py +++ b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py @@ -1,8 +1,9 @@ -from PySide2.QtWidgets import QLabel, QGroupBox, QVBoxLayout +from PySide2.QtWidgets import QGroupBox, QLabel, QVBoxLayout from game import Game -from qt_ui.windows.basemenu.ground_forces.QGroundForcesStrategySelector import QGroundForcesStrategySelector -from theater import ControlPoint +from game.theater import ControlPoint +from qt_ui.windows.basemenu.ground_forces.QGroundForcesStrategySelector import \ + QGroundForcesStrategySelector class QGroundForcesStrategy(QGroupBox): diff --git a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategySelector.py b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategySelector.py index 09c3fa5b..4acd8731 100644 --- a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategySelector.py +++ b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategySelector.py @@ -1,6 +1,6 @@ from PySide2.QtWidgets import QComboBox -from theater import ControlPoint, CombatStance +from game.theater import CombatStance, ControlPoint class QGroundForcesStrategySelector(QComboBox): diff --git a/qt_ui/windows/basemenu/intel/QIntelInfo.py b/qt_ui/windows/basemenu/intel/QIntelInfo.py index bc7cb13b..e422ef3a 100644 --- a/qt_ui/windows/basemenu/intel/QIntelInfo.py +++ b/qt_ui/windows/basemenu/intel/QIntelInfo.py @@ -1,11 +1,14 @@ +from PySide2.QtWidgets import ( + QFrame, + QGridLayout, + QGroupBox, + QLabel, + QVBoxLayout, +) +from dcs.task import CAP, CAS, Embarking, PinpointStrike - -from PySide2.QtWidgets import QLabel, QGroupBox, QVBoxLayout, QFrame, QGridLayout -from dcs.task import Embarking, CAS, PinpointStrike, CAP - -from game import Game -from qt_ui.windows.basemenu.ground_forces.QGroundForcesStrategySelector import QGroundForcesStrategySelector -from theater import ControlPoint, db +from game import Game, db +from game.theater import ControlPoint class QIntelInfo(QFrame): diff --git a/qt_ui/windows/groundobject/QBuildingInfo.py b/qt_ui/windows/groundobject/QBuildingInfo.py index e474a59f..fcf6366b 100644 --- a/qt_ui/windows/groundobject/QBuildingInfo.py +++ b/qt_ui/windows/groundobject/QBuildingInfo.py @@ -2,7 +2,7 @@ import os from PySide2.QtGui import QPixmap from PySide2.QtWidgets import QGroupBox, QHBoxLayout, QVBoxLayout, QLabel - +from game.db import REWARDS class QBuildingInfo(QGroupBox): @@ -28,6 +28,13 @@ class QBuildingInfo(QGroupBox): layout = QVBoxLayout() layout.addWidget(self.header) layout.addWidget(self.name) + + if self.building.category in REWARDS.keys(): + income_label_text = 'Value: ' + str(REWARDS[self.building.category]) + "M" + if self.building.is_dead: + income_label_text = '' + income_label_text + '' + self.reward = QLabel(income_label_text) + layout.addWidget(self.reward) + footer = QHBoxLayout() self.setLayout(layout) - diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py index dcfed0a3..abbf5c8c 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -2,20 +2,31 @@ import logging from PySide2 import QtCore from PySide2.QtGui import Qt -from PySide2.QtWidgets import QHBoxLayout, QDialog, QGridLayout, QLabel, QGroupBox, QVBoxLayout, QPushButton, \ - QComboBox, QSpinBox, QMessageBox +from PySide2.QtWidgets import ( + QComboBox, + QDialog, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, +) from dcs import Point from game import Game, db from game.data.building_data import FORTIFICATION_BUILDINGS -from game.db import PRICES, unit_type_of, PinpointStrike -from gen.defenses.armor_group_generator import generate_armor_group_of_type_and_size +from game.db import PRICES, PinpointStrike, REWARDS, unit_type_of +from game.theater import ControlPoint, TheaterGroundObject +from gen.defenses.armor_group_generator import \ + generate_armor_group_of_type_and_size from gen.sam.sam_group_generator import get_faction_possible_sams_generator from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.QBudgetBox import QBudgetBox from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.groundobject.QBuildingInfo import QBuildingInfo -from theater import ControlPoint, TheaterGroundObject class QGroundObjectMenu(QDialog): @@ -51,6 +62,8 @@ class QGroundObjectMenu(QDialog): self.mainLayout.addWidget(self.intelBox) else: self.mainLayout.addWidget(self.buildingBox) + if self.cp.captured: + self.mainLayout.addWidget(self.financesBox) self.actionLayout = QHBoxLayout() @@ -104,12 +117,26 @@ class QGroundObjectMenu(QDialog): self.buildingBox = QGroupBox("Buildings :") self.buildingsLayout = QGridLayout() + j = 0 + total_income = 0 + received_income = 0 for i, building in enumerate(self.buildings): if building.dcs_identifier not in FORTIFICATION_BUILDINGS: self.buildingsLayout.addWidget(QBuildingInfo(building, self.ground_object), j/3, j%3) j = j + 1 + if building.category in REWARDS.keys(): + total_income = total_income + REWARDS[building.category] + if not building.is_dead: + received_income = received_income + REWARDS[building.category] + + self.financesBox = QGroupBox("Finances: ") + self.financesBoxLayout = QGridLayout() + self.financesBoxLayout.addWidget(QLabel("Available: " + str(total_income) + "M"), 2, 1) + self.financesBoxLayout.addWidget(QLabel("Receiving: " + str(received_income) + "M"), 2, 2) + + self.financesBox.setLayout(self.financesBoxLayout) self.buildingBox.setLayout(self.buildingsLayout) self.intelBox.setLayout(self.intelLayout) diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index f4fe6041..0e0bf773 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -10,15 +10,17 @@ from PySide2.QtWidgets import ( from dcs.planes import PlaneType from game import Game +from game.theater import ControlPoint, OffMapSpawn from gen.ato import Package from gen.flights.flight import Flight from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.QFlightSizeSpinner import QFlightSizeSpinner from qt_ui.widgets.QLabeledWidget import QLabeledWidget from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector +from qt_ui.widgets.combos.QArrivalAirfieldSelector import \ + QArrivalAirfieldSelector from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector -from theater import ControlPoint class QFlightCreator(QDialog): @@ -49,16 +51,30 @@ class QFlightCreator(QDialog): self.on_aircraft_changed) layout.addLayout(QLabeledWidget("Aircraft:", self.aircraft_selector)) - self.airfield_selector = QOriginAirfieldSelector( + self.departure = QOriginAirfieldSelector( self.game.aircraft_inventory, [cp for cp in game.theater.controlpoints if cp.captured], self.aircraft_selector.currentData() ) - self.airfield_selector.availability_changed.connect(self.update_max_size) - layout.addLayout(QLabeledWidget("Airfield:", self.airfield_selector)) + self.departure.availability_changed.connect(self.update_max_size) + layout.addLayout(QLabeledWidget("Departure:", self.departure)) + + self.arrival = QArrivalAirfieldSelector( + [cp for cp in game.theater.controlpoints if cp.captured], + self.aircraft_selector.currentData(), + "Same as departure" + ) + layout.addLayout(QLabeledWidget("Arrival:", self.arrival)) + + self.divert = QArrivalAirfieldSelector( + [cp for cp in game.theater.controlpoints if cp.captured], + self.aircraft_selector.currentData(), + "None" + ) + layout.addLayout(QLabeledWidget("Divert:", self.divert)) self.flight_size_spinner = QFlightSizeSpinner() - self.update_max_size(self.airfield_selector.available) + self.update_max_size(self.departure.available) layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner)) self.client_slots_spinner = QFlightSizeSpinner( @@ -82,10 +98,16 @@ class QFlightCreator(QDialog): def verify_form(self) -> Optional[str]: aircraft: PlaneType = self.aircraft_selector.currentData() - origin: ControlPoint = self.airfield_selector.currentData() + origin: ControlPoint = self.departure.currentData() + arrival: ControlPoint = self.arrival.currentData() + divert: ControlPoint = self.divert.currentData() size: int = self.flight_size_spinner.value() if not origin.captured: return f"{origin.name} is not owned by your coalition." + if arrival is not None and not arrival.captured: + return f"{arrival.name} is not owned by your coalition." + if divert is not None and not divert.captured: + return f"{divert.name} is not owned by your coalition." available = origin.base.aircraft.get(aircraft, 0) if not available: return f"{origin.name} has no {aircraft.id} available." @@ -104,14 +126,22 @@ class QFlightCreator(QDialog): task = self.task_selector.currentData() aircraft = self.aircraft_selector.currentData() - origin = self.airfield_selector.currentData() + origin = self.departure.currentData() + arrival = self.arrival.currentData() + divert = self.divert.currentData() size = self.flight_size_spinner.value() - if self.game.settings.perf_ai_parking_start: + if arrival is None: + arrival = origin + + if isinstance(origin, OffMapSpawn): + start_type = "In Flight" + elif self.game.settings.perf_ai_parking_start: start_type = "Cold" else: start_type = "Warm" - flight = Flight(self.package, aircraft, size, origin, task, start_type) + flight = Flight(self.package, aircraft, size, task, start_type, origin, + arrival, divert) flight.client_count = self.client_slots_spinner.value() # noinspection PyUnresolvedReferences @@ -120,7 +150,9 @@ class QFlightCreator(QDialog): def on_aircraft_changed(self, index: int) -> None: new_aircraft = self.aircraft_selector.itemData(index) - self.airfield_selector.change_aircraft(new_aircraft) + self.departure.change_aircraft(new_aircraft) + self.arrival.change_aircraft(new_aircraft) + self.divert.change_aircraft(new_aircraft) def update_max_size(self, available: int) -> None: self.flight_size_spinner.setMaximum(min(available, 4)) diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py index 381d8e39..c8d4562f 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py @@ -42,15 +42,7 @@ class QFlightWaypointList(QTableView): self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"]) - # The first waypoint is set up by pydcs at mission generation time, so - # we need to add that waypoint manually. - takeoff = FlightWaypoint(self.flight.from_cp.position.x, - self.flight.from_cp.position.y, 0) - takeoff.description = "Take Off" - takeoff.name = takeoff.pretty_name = "Take Off from " + self.flight.from_cp.name - takeoff.alt_type = "RADIO" - - waypoints = itertools.chain([takeoff], self.flight.points) + waypoints = self.flight.flight_plan.waypoints for row, waypoint in enumerate(waypoints): self.add_waypoint_row(row, self.flight, waypoint) self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)), diff --git a/qt_ui/windows/newgame/QCampaignList.py b/qt_ui/windows/newgame/QCampaignList.py index 617869bc..86ce0461 100644 --- a/qt_ui/windows/newgame/QCampaignList.py +++ b/qt_ui/windows/newgame/QCampaignList.py @@ -12,7 +12,7 @@ from PySide2.QtGui import QStandardItem, QStandardItemModel from PySide2.QtWidgets import QAbstractItemView, QListView import qt_ui.uiconstants as CONST -from theater import ConflictTheater +from game.theater import ConflictTheater @dataclass(frozen=True) @@ -29,14 +29,16 @@ class Campaign: data = json.load(campaign_file) sanitized_theater = data["theater"].replace(" ", "") - return cls(data["name"], f"Terrain_{sanitized_theater}", data.get("authors", "???"), - data.get("description", ""), ConflictTheater.from_json(data)) + return cls(data["name"], f"Terrain_{sanitized_theater}", + data.get("authors", "???"), + data.get("description", ""), + ConflictTheater.from_json(path.parent, data)) def load_campaigns() -> List[Campaign]: campaign_dir = Path("resources\\campaigns") campaigns = [] - for path in campaign_dir.iterdir(): + for path in campaign_dir.glob("*.json"): try: logging.debug(f"Loading campaign from {path}...") campaign = Campaign.from_json(path) diff --git a/resources/campaigns/inherent_resolve.json b/resources/campaigns/inherent_resolve.json index fc5969a5..66befcd5 100644 --- a/resources/campaigns/inherent_resolve.json +++ b/resources/campaigns/inherent_resolve.json @@ -3,82 +3,5 @@ "theater": "Syria", "authors": "Khopa", "description": "

In this scenario, you start from Jordan, and have to fight your way through eastern Syria.

", - "player_points": [ - { - "type": "airbase", - "id": "King Hussein Air College", - "size": 1000, - "importance": 1.4 - }, - { - "type": "airbase", - "id": "Incirlik", - "size": 1000, - "importance": 1.4, - "captured_invert": true - }, - { - "type": "carrier", - "id": 1001, - "x": -210000, - "y": -200000, - "captured_invert": true - }, - { - "type": "lha", - "id": 1002, - "x": -131000, - "y": -161000, - "captured_invert": true - } - ], - "enemy_points": [ - { - "type": "airbase", - "id": "Khalkhalah", - "size": 1000, - "importance": 1.2 - }, - { - "type": "airbase", - "id": "Palmyra", - "size": 1000, - "importance": 1 - }, - { - "type": "airbase", - "id": "Tabqa", - "size": 1000, - "importance": 1 - }, - { - "type": "airbase", - "id": "Jirah", - "size": 1000, - "importance": 1, - "captured_invert": true - } - ], - "links": [ - [ - "Khalkhalah", - "King Hussein Air College" - ], - [ - "Incirlik", - "Incirlik" - ], - [ - "Khalkhalah", - "Palmyra" - ], - [ - "Palmyra", - "Tabqa" - ], - [ - "Jirah", - "Tabqa" - ] - ] + "miz": "inherent_resolve.miz" } \ No newline at end of file diff --git a/resources/campaigns/inherent_resolve.miz b/resources/campaigns/inherent_resolve.miz new file mode 100644 index 00000000..cb4e2f87 Binary files /dev/null and b/resources/campaigns/inherent_resolve.miz differ diff --git a/theater/__init__.py b/theater/__init__.py deleted file mode 100644 index f6b256d8..00000000 --- a/theater/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# For save game compatibility. Remove before 2.3. -from game.theater import * diff --git a/theater/base.py b/theater/base.py deleted file mode 100644 index fc28c91b..00000000 --- a/theater/base.py +++ /dev/null @@ -1,2 +0,0 @@ -# For save compat. Remove in 2.3. -from game.theater.base import * diff --git a/theater/conflicttheater.py b/theater/conflicttheater.py deleted file mode 100644 index e1566178..00000000 --- a/theater/conflicttheater.py +++ /dev/null @@ -1,2 +0,0 @@ -# For save compat. Remove in 2.3. -from game.theater.conflicttheater import * diff --git a/theater/controlpoint.py b/theater/controlpoint.py deleted file mode 100644 index 90a6b164..00000000 --- a/theater/controlpoint.py +++ /dev/null @@ -1,2 +0,0 @@ -# For save compat. Remove in 2.3. -from game.theater.controlpoint import * diff --git a/theater/frontline.py b/theater/frontline.py deleted file mode 100644 index 5ddb5706..00000000 --- a/theater/frontline.py +++ /dev/null @@ -1,3 +0,0 @@ -# For save compat. Remove in 2.3. -from game.theater.frontline import * -from game.theater.conflicttheater import FrontLine \ No newline at end of file diff --git a/theater/theatergroundobject.py b/theater/theatergroundobject.py deleted file mode 100644 index 3c77455d..00000000 --- a/theater/theatergroundobject.py +++ /dev/null @@ -1,2 +0,0 @@ -# For save compat. Remove in 2.3. -from game.theater.theatergroundobject import *