diff --git a/changelog.md b/changelog.md index 47edf277..18893136 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,7 @@ Saves from 2.3 are not compatible with 2.4. * **[Campaign AI]** Auto-purchase now prefers the best aircraft for the task, but will attempt to maintain some variety. * **[Campaign AI]** Opfor now sells off odd aircraft since they're unlikely to be used. * **[Campaign AI]** Reserve aircraft will be ordered if needed to prioritize next turn's CAP/CAS over offensive missions. +* **[Campaign AI]** Multiple rounds of CAP will be planned (roughly 90 minutes of coverage). Default starting budget has increased to account for the increased need for aircraft. * **[Mission Generator]** Multiple groups are created for complex SAM sites (SAMs with additional point defense or SHORADS), improving Skynet behavior. * **[Skynet]** Point defenses are now configured to remain on to protect the site they accompany. * **[Balance]** Opfor now gains income using the same rules as the player, significantly increasing their income relative to the player for most campaigns. diff --git a/game/game.py b/game/game.py index 8a7313f7..a0647b5a 100644 --- a/game/game.py +++ b/game/game.py @@ -318,13 +318,18 @@ class Game: def plan_procurement(self, blue_planner: CoalitionMissionPlanner, red_planner: CoalitionMissionPlanner) -> None: + # The first turn needs to buy a *lot* of aircraft to fill CAPs, so it + # gets much more of the budget that turn. Otherwise budget (after + # repairs) is split evenly between air and ground. + ground_portion = 0.1 if self.turn == 0 else 0.5 self.budget = ProcurementAi( self, for_player=True, faction=self.player_faction, manage_runways=self.settings.automate_runway_repair, manage_front_line=self.settings.automate_front_line_reinforcements, - manage_aircraft=self.settings.automate_aircraft_reinforcements + manage_aircraft=self.settings.automate_aircraft_reinforcements, + front_line_budget_share=ground_portion ).spend_budget(self.budget, blue_planner.procurement_requests) self.enemy_budget = ProcurementAi( @@ -333,7 +338,8 @@ class Game: faction=self.enemy_faction, manage_runways=True, manage_front_line=True, - manage_aircraft=True + manage_aircraft=True, + front_line_budget_share=ground_portion ).spend_budget(self.enemy_budget, red_planner.procurement_requests) def message(self, text: str) -> None: diff --git a/game/procurement.py b/game/procurement.py index 090bdb3a..5f378c67 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -38,13 +38,17 @@ class AircraftProcurementRequest: class ProcurementAi: def __init__(self, game: Game, for_player: bool, faction: Faction, manage_runways: bool, manage_front_line: bool, - manage_aircraft: bool) -> None: + manage_aircraft: bool, front_line_budget_share: float) -> None: + if front_line_budget_share > 1.0: + raise ValueError + self.game = game self.is_player = for_player self.faction = faction self.manage_runways = manage_runways self.manage_front_line = manage_front_line self.manage_aircraft = manage_aircraft + self.front_line_budget_share = front_line_budget_share self.threat_zones = self.game.threat_zone_for(not self.is_player) def spend_budget( @@ -53,7 +57,7 @@ class ProcurementAi: if self.manage_runways: budget = self.repair_runways(budget) if self.manage_front_line: - armor_budget = math.ceil(budget / 2) + armor_budget = math.ceil(budget * self.front_line_budget_share) budget -= armor_budget budget += self.reinforce_front_line(armor_budget) diff --git a/gen/ato.py b/gen/ato.py index cfca4584..fd7da75f 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -118,6 +118,15 @@ class Package: return max(times) return None + @property + def mission_departure_time(self) -> Optional[timedelta]: + times = [] + for flight in self.flights: + times.append(flight.flight_plan.mission_departure_time) + if times: + return max(times) + return None + def add_flight(self, flight: Flight) -> None: """Adds a flight to the package.""" self.flights.append(flight) diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 9322f85e..b40c7abe 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -481,6 +481,14 @@ class CoalitionMissionPlanner: """ # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP. for cp in self.objective_finder.vulnerable_control_points(): + # Plan three rounds of CAP to give ~90 minutes coverage. Spacing + # these out appropriately is done in stagger_missions. + yield ProposedMission(cp, [ + ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), + ]) + yield ProposedMission(cp, [ + ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), + ]) yield ProposedMission(cp, [ ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), ]) @@ -698,10 +706,12 @@ class CoalitionMissionPlanner: dca_types = { FlightType.BARCAP, - FlightType.INTERCEPTION, FlightType.TARCAP, } + previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict( + timedelta + ) non_dca_packages = [p for p in self.ato.packages if p.primary_task not in dca_types] @@ -714,8 +724,22 @@ class CoalitionMissionPlanner: for package in self.ato.packages: tot = TotEstimator(package).earliest_tot() if package.primary_task in dca_types: - # All CAP missions should be on station ASAP. - package.time_over_target = tot + previous_end_time = previous_cap_end_time[package.target] + if tot > previous_end_time: + # Can't get there exactly on time, so get there ASAP. This + # will typically only happen for the first CAP at each + # target. + package.time_over_target = tot + else: + package.time_over_target = previous_end_time + + departure_time = package.mission_departure_time + # Should be impossible for CAPs + if departure_time is None: + logging.error( + f"Could not determine mission end time for {package}") + continue + previous_cap_end_time[package.target] = departure_time else: # But other packages should be spread out a bit. Note that take # times are delayed, but all aircraft will become active at diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index a8d0ce03..75ab8c42 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -243,6 +243,11 @@ class FlightPlan: else: return timedelta(minutes=5) + @property + def mission_departure_time(self) -> timedelta: + """The time that the mission is complete and the flight RTBs.""" + raise NotImplementedError + @dataclass(frozen=True) class LoiterFlightPlan(FlightPlan): @@ -356,6 +361,10 @@ class FormationFlightPlan(LoiterFlightPlan): GroundSpeed.for_flight(self.flight, self.hold.alt) ) + @property + def mission_departure_time(self) -> timedelta: + return self.split_time + @dataclass(frozen=True) class PatrollingFlightPlan(FlightPlan): @@ -406,6 +415,10 @@ class PatrollingFlightPlan(FlightPlan): def tot_waypoint(self) -> Optional[FlightWaypoint]: return self.patrol_start + @property + def mission_departure_time(self) -> timedelta: + return self.patrol_end_time + @dataclass(frozen=True) class BarCapFlightPlan(PatrollingFlightPlan): @@ -678,6 +691,9 @@ class SweepFlightPlan(LoiterFlightPlan): GroundSpeed.for_flight(self.flight, self.hold.alt) ) + def mission_departure_time(self) -> timedelta: + return self.sweep_end_time + @dataclass(frozen=True) class CustomFlightPlan(FlightPlan): @@ -708,6 +724,10 @@ class CustomFlightPlan(FlightPlan): self, waypoint: FlightWaypoint) -> Optional[timedelta]: return None + @property + def mission_departure_time(self) -> timedelta: + return self.package.time_over_target + class FlightPlanBuilder: """Generates flight plans for flights.""" diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 3a14fafb..971b5ab1 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -30,7 +30,7 @@ jinja_env = Environment( ) -DEFAULT_BUDGET = 650 +DEFAULT_BUDGET = 1600 class NewGameWizard(QtWidgets.QWizard):