diff --git a/game/ato/flight.py b/game/ato/flight.py index 220cc630..178637a5 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -12,6 +12,7 @@ from .flightmembers import FlightMembers from .flightroster import FlightRoster from .flightstate import FlightState, Navigating, Uninitialized from .flightstate.killed import Killed +from .flighttype import FlightType from ..sidc import ( Entity, SidcDescribable, @@ -31,7 +32,6 @@ if TYPE_CHECKING: from game.data.weapons import WeaponType from .flightmember import FlightMember from .flightplans.flightplan import FlightPlan - from .flighttype import FlightType from .flightwaypoint import FlightWaypoint from .package import Package from .starttype import StartType @@ -58,7 +58,8 @@ class Flight(SidcDescribable): self.coalition = squadron.coalition self.squadron = squadron self.flight_type = flight_type - self.squadron.claim_inventory(count) + if flight_type != FlightType.IDLE: + self.squadron.claim_inventory(count) if roster is None: self.roster = FlightMembers(self, initial_size=count) else: diff --git a/game/ato/flightplans/aewc.py b/game/ato/flightplans/aewc.py index 7534ce32..367eeab2 100644 --- a/game/ato/flightplans/aewc.py +++ b/game/ato/flightplans/aewc.py @@ -12,7 +12,7 @@ from game.utils import Distance, Heading, Speed, feet, knots, meters, nautical_m class AewcFlightPlan(PatrollingFlightPlan[PatrollingLayout]): @property def patrol_duration(self) -> timedelta: - return timedelta(hours=4) + return self.flight.coalition.doctrine.aewc.duration @property def patrol_speed(self) -> Speed: diff --git a/game/ato/flightplans/flightplanbuildertypes.py b/game/ato/flightplans/flightplanbuildertypes.py index 45d9ca56..c35aec41 100644 --- a/game/ato/flightplans/flightplanbuildertypes.py +++ b/game/ato/flightplans/flightplanbuildertypes.py @@ -64,6 +64,7 @@ class FlightPlanBuilderTypes: FlightType.TRANSPORT: AirliftFlightPlan.builder_type(), FlightType.FERRY: FerryFlightPlan.builder_type(), FlightType.AIR_ASSAULT: AirAssaultFlightPlan.builder_type(), + FlightType.IDLE: BarCapFlightPlan.builder_type(), } try: return builder_dict[flight.flight_type] diff --git a/game/ato/flighttype.py b/game/ato/flighttype.py index 18216ea3..16b8bff4 100644 --- a/game/ato/flighttype.py +++ b/game/ato/flighttype.py @@ -57,6 +57,7 @@ class FlightType(Enum): REFUELING = "Refueling" FERRY = "Ferry" AIR_ASSAULT = "Air Assault" + IDLE = "Idle" def __str__(self) -> str: return self.value diff --git a/game/commander/missionscheduler.py b/game/commander/missionscheduler.py index fe308219..499c1f71 100644 --- a/game/commander/missionscheduler.py +++ b/game/commander/missionscheduler.py @@ -47,6 +47,17 @@ class MissionScheduler: margin=5 * 60, ) for package in self.coalition.ato.packages: + if package.time_over_target > datetime.min: + if package.primary_task in dca_types: + if ( + package.mission_departure_time is not None + and package.mission_departure_time + > previous_cap_end_time[package.target] + ): + previous_cap_end_time[package.target] = ( + package.mission_departure_time + ) + continue # If package already has TOT, leave it. tot = TotEstimator(package).earliest_tot(now) if package.primary_task in dca_types: previous_end_time = previous_cap_end_time[package.target] diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index bc8d7166..9355b151 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -24,6 +24,7 @@ from game.theater.theatergroundobject import ( VehicleGroupGroundObject, ) from game.threatzones import ThreatZones +from game.ato.flighttype import FlightType if TYPE_CHECKING: from game import Game @@ -77,7 +78,8 @@ class TheaterState(WorldState["TheaterState"]): self.threatening_air_defenses.remove(target) if target in self.detecting_air_defenses: self.detecting_air_defenses.remove(target) - self.enemy_air_defenses.remove(target) + if target in self.enemy_air_defenses: + self.enemy_air_defenses.remove(target) self._rebuild_threat_zones() def eliminate_ship(self, target: NavalGroundObject) -> None: @@ -85,7 +87,8 @@ class TheaterState(WorldState["TheaterState"]): self.threatening_air_defenses.remove(target) if target in self.detecting_air_defenses: self.detecting_air_defenses.remove(target) - self.enemy_ships.remove(target) + if target in self.enemy_ships: + self.enemy_ships.remove(target) self._rebuild_threat_zones() def has_battle_position(self, target: VehicleGroupGroundObject) -> bool: @@ -155,21 +158,16 @@ class TheaterState(WorldState["TheaterState"]): tracer, ) - # Plan enough rounds of CAP that the target has coverage over the expected - # mission duration. - mission_duration = game.settings.desired_player_mission_duration.total_seconds() - barcap_duration = coalition.doctrine.cap.duration.total_seconds() - barcap_rounds = math.ceil(mission_duration / barcap_duration) - refueling_targets: list[MissionTarget] = [] theater_refuling_point = finder.preferred_theater_refueling_control_point() if theater_refuling_point is not None: refueling_targets.append(theater_refuling_point) - return TheaterState( + theater_state = TheaterState( context=context, barcaps_needed={ - cp: barcap_rounds for cp in finder.vulnerable_control_points() + cp: cls._barcap_rounds(game, player, now, cp) + for cp in finder.vulnerable_control_points() }, active_front_lines=list(finder.front_lines()), front_line_stances={f: None for f in finder.front_lines()}, @@ -191,3 +189,62 @@ class TheaterState(WorldState["TheaterState"]): enemy_barcaps=list(game.theater.control_points_for(not player)), threat_zones=game.threat_zone_for(not player), ) + + # Look through packages already planned in the ATO and eliminate from the + # list of targets. + for package in coalition.ato.packages: + if isinstance(package.target, NavalGroundObject): + theater_state.eliminate_ship(package.target) + if package.primary_task == FlightType.BAI and isinstance( + package.target, VehicleGroupGroundObject + ): + theater_state.eliminate_battle_position(package.target) + if isinstance(package.target, IadsGroundObject): + theater_state.eliminate_air_defense(package.target) + if ( + package.primary_task == FlightType.STRIKE + and isinstance(package.target, TheaterGroundObject) + and package.target in theater_state.strike_targets + ): + theater_state.strike_targets.remove(package.target) + if package.primary_task == FlightType.AEWC: + # If a planned AEWC mission covers the target beyond the planned mission duration, it can safely be removed + if ( + package.time_over_target + coalition.doctrine.aewc.duration + > now + game.settings.desired_player_mission_duration + ) and package.target in theater_state.aewc_targets: + theater_state.aewc_targets.remove(package.target) + if ( + package.primary_task + in ( + FlightType.OCA_AIRCRAFT, + FlightType.OCA_RUNWAY, + ) + and isinstance(package.target, ControlPoint) + and package.target in theater_state.oca_targets + ): + theater_state.oca_targets.remove(package.target) + return theater_state + + @classmethod + def _barcap_rounds( + cls, game: Game, player: bool, now: datetime, control_point: ControlPoint + ) -> int: + """Calculate number of additional rounds of CAP required to cover mission duration.""" + coalition = game.coalition_for(player) + + # Look through ATO for any existing planned CAP missions and calculate last planned CAP end + planned_cap_coverage_end_time = now + for package in coalition.ato.packages: + if package.target == control_point: + cap_end_time = ( + package.time_over_target + coalition.doctrine.cap.duration + ) + if cap_end_time > planned_cap_coverage_end_time: + planned_cap_coverage_end_time = cap_end_time + # When mission is expected to finish + mission_end_time = now + game.settings.desired_player_mission_duration + return math.ceil( + (mission_end_time - planned_cap_coverage_end_time).total_seconds() + / coalition.doctrine.cap.duration.total_seconds() + ) diff --git a/game/data/doctrine.py b/game/data/doctrine.py index cfa95cbb..4fdfb35e 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -52,6 +52,16 @@ class Helicopter: ) +@dataclass +class Aewc: + #: The duration that AEWC flights will remain on-station + duration: timedelta + + @staticmethod + def from_dict(data: dict[str, Any]) -> Aewc: + return Aewc(duration=timedelta(minutes=data["duration_minutes"])) + + @dataclass class Cas: #: The duration that CAP flights will remain on-station. @@ -169,6 +179,9 @@ class Doctrine: #: Helicopter specific doctrines. helicopter: Helicopter + #: Doctrine for AEWC missions. + aewc: Aewc + #: Doctrine for CAS missions. cas: Cas @@ -238,6 +251,7 @@ class Doctrine: data["ground_unit_procurement_ratios"] ), helicopter=Helicopter.from_dict(data["helicopter"]), + aewc=Aewc.from_dict(data["aewc"]), cas=Cas.from_dict(data["cas"]), cap=Cap.from_dict(data["cap"]), sweep=Sweep.from_dict(data["sweep"]), diff --git a/game/missiongenerator/aircraft/aircraftgenerator.py b/game/missiongenerator/aircraft/aircraftgenerator.py index 8c13589e..d4be6484 100644 --- a/game/missiongenerator/aircraft/aircraftgenerator.py +++ b/game/missiongenerator/aircraft/aircraftgenerator.py @@ -148,7 +148,7 @@ class AircraftGenerator: faction.country, squadron, 1, - FlightType.BARCAP, + FlightType.IDLE, StartType.COLD, divert=None, ) diff --git a/game/sim/missionsimulation.py b/game/sim/missionsimulation.py index e46ce7c0..7ebad725 100644 --- a/game/sim/missionsimulation.py +++ b/game/sim/missionsimulation.py @@ -102,6 +102,12 @@ class MissionSimulation: # Always skip combat as we are processing results from DCS. Any combat has already # been resolved in-game self.tick(events, CombatResolutionMethod.SKIP, force_continue=True) + self.game.blue.plan_missions(self.game.simulation_time) + self.game.red.plan_missions(self.game.simulation_time) + self.game.game_stats.update(self.game) + # Generate begin_new_turn event which triggers a refresh of the React map screen to + # show newly planned missions. + events.begin_new_turn() def finish(self) -> None: self.unit_map = None diff --git a/resources/doctrines/coldwar.yaml b/resources/doctrines/coldwar.yaml index aa0f6b6a..570cd4bb 100644 --- a/resources/doctrines/coldwar.yaml +++ b/resources/doctrines/coldwar.yaml @@ -6,6 +6,8 @@ max_ingress_distance_nm: 30 min_ingress_distance_nm: 10 rendezvous_altitude_ft_msl: 22000 combat_altitude_ft_msl: 18000 +aewc: + duration_minutes: 240 cap: duration_minutes: 30 min_track_length_nm: 12 diff --git a/resources/doctrines/modern.yaml b/resources/doctrines/modern.yaml index 0772601f..d1d8e23c 100644 --- a/resources/doctrines/modern.yaml +++ b/resources/doctrines/modern.yaml @@ -6,6 +6,8 @@ max_ingress_distance_nm: 45 min_ingress_distance_nm: 10 rendezvous_altitude_ft_msl: 25000 combat_altitude_ft_msl: 20000 +aewc: + duration_minutes: 240 cap: duration_minutes: 30 min_track_length_nm: 15 diff --git a/resources/doctrines/ww2.yaml b/resources/doctrines/ww2.yaml index a6075e61..829474d8 100644 --- a/resources/doctrines/ww2.yaml +++ b/resources/doctrines/ww2.yaml @@ -6,6 +6,8 @@ max_ingress_distance_nm: 7 min_ingress_distance_nm: 5 rendezvous_altitude_ft_msl: 10000 combat_altitude_ft_msl: 8000 +aewc: + duration_minutes: 240 cap: duration_minutes: 30 min_track_length_nm: 8