From 0a416ab758b1d36856094ce2fa0d9af428bcacaa Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 12 Jul 2021 20:31:38 -0700 Subject: [PATCH] Let the TheaterCommander manage front line stance. This improves the AI behavior by choosing the stances non-randomly: * Breakthrough will be used if the base is expected to be capturable and the coalition outnumbers the enemy by 20%. * Elimination will be used if the coalition has at least as many units as the enemy. * Defensive will be used if the coalition has at least half as many units as the enemy. * Retreat will be used if the coalition is significantly outnumbers. This also exposes the option to the player. --- changelog.md | 1 + game/commander/garrisons.py | 6 +- game/commander/tasks/compound/capturebase.py | 19 +++++ game/commander/tasks/compound/capturebases.py | 13 ++++ game/commander/tasks/compound/defendbase.py | 19 +++++ game/commander/tasks/compound/defendbases.py | 13 ++++ .../tasks/compound/destroyenemygroundunits.py | 17 +++++ game/commander/tasks/compound/nextaction.py | 12 +-- .../tasks/compound/theatersupport.py | 14 ++++ game/commander/tasks/frontlinestancetask.py | 75 +++++++++++++++++++ game/commander/tasks/packageplanningtask.py | 3 + game/commander/tasks/primitive/aewc.py | 2 + game/commander/tasks/primitive/antiship.py | 2 + .../commander/tasks/primitive/antishipping.py | 2 + game/commander/tasks/primitive/bai.py | 2 + game/commander/tasks/primitive/barcap.py | 2 + .../tasks/primitive/breakthroughattack.py | 37 +++++++++ game/commander/tasks/primitive/cas.py | 2 + .../tasks/primitive/convoyinterdiction.py | 2 + game/commander/tasks/primitive/dead.py | 2 + .../tasks/primitive/defensivestance.py | 14 ++++ .../tasks/primitive/eliminationattack.py | 14 ++++ game/commander/tasks/primitive/oca.py | 2 + game/commander/tasks/primitive/refueling.py | 2 + .../tasks/primitive/retreatstance.py | 14 ++++ game/commander/tasks/primitive/strike.py | 2 + game/commander/theaterstate.py | 21 +++++- game/game.py | 19 +++-- game/operation/operation.py | 1 + game/settings.py | 1 + game/theater/controlpoint.py | 7 ++ game/theater/frontline.py | 17 ++++- gen/armor.py | 30 +------- gen/flights/ai_flight_planner.py | 7 +- qt_ui/windows/settings/QSettingsWindow.py | 22 +++++- 35 files changed, 361 insertions(+), 57 deletions(-) create mode 100644 game/commander/tasks/compound/capturebase.py create mode 100644 game/commander/tasks/compound/capturebases.py create mode 100644 game/commander/tasks/compound/defendbase.py create mode 100644 game/commander/tasks/compound/defendbases.py create mode 100644 game/commander/tasks/compound/destroyenemygroundunits.py create mode 100644 game/commander/tasks/compound/theatersupport.py create mode 100644 game/commander/tasks/frontlinestancetask.py create mode 100644 game/commander/tasks/primitive/breakthroughattack.py create mode 100644 game/commander/tasks/primitive/defensivestance.py create mode 100644 game/commander/tasks/primitive/eliminationattack.py create mode 100644 game/commander/tasks/primitive/retreatstance.py diff --git a/changelog.md b/changelog.md index 8810dc72..ce1855ac 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Saves from 3.x are not compatible with 5.0. ## Features/Improvements * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. +* **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. ## Fixes diff --git a/game/commander/garrisons.py b/game/commander/garrisons.py index 192a6ea7..ac685e24 100644 --- a/game/commander/garrisons.py +++ b/game/commander/garrisons.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import Iterator from dataclasses import dataclass -from game.theater import ConflictTheater +from game.theater import ConflictTheater, ControlPoint from game.theater.theatergroundobject import VehicleGroupGroundObject from game.utils import meters, nautical_miles @@ -53,9 +53,7 @@ class Garrisons: continue for garrison in garrisons: - # Not sure what distance DCS uses, but assuming it's about 2NM since - # that's roughly the distance of the circle on the map. - if meters(garrison.distance_to(cp)) < nautical_miles(2): + if meters(garrison.distance_to(cp)) < ControlPoint.CAPTURE_DISTANCE: blocking.append(garrison) else: defending.append(garrison) diff --git a/game/commander/tasks/compound/capturebase.py b/game/commander/tasks/compound/capturebase.py new file mode 100644 index 00000000..747b7599 --- /dev/null +++ b/game/commander/tasks/compound/capturebase.py @@ -0,0 +1,19 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.destroyenemygroundunits import ( + DestroyEnemyGroundUnits, +) +from game.commander.tasks.primitive.breakthroughattack import BreakthroughAttack +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import FrontLine + + +@dataclass(frozen=True) +class CaptureBase(CompoundTask[TheaterState]): + front_line: FrontLine + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [BreakthroughAttack(self.front_line, state.player)] + yield [DestroyEnemyGroundUnits(self.front_line)] diff --git a/game/commander/tasks/compound/capturebases.py b/game/commander/tasks/compound/capturebases.py new file mode 100644 index 00000000..3d338046 --- /dev/null +++ b/game/commander/tasks/compound/capturebases.py @@ -0,0 +1,13 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.capturebase import CaptureBase +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class CaptureBases(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for front in state.active_front_lines: + yield [CaptureBase(front)] diff --git a/game/commander/tasks/compound/defendbase.py b/game/commander/tasks/compound/defendbase.py new file mode 100644 index 00000000..69a008e5 --- /dev/null +++ b/game/commander/tasks/compound/defendbase.py @@ -0,0 +1,19 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.primitive.cas import PlanCas +from game.commander.tasks.primitive.defensivestance import DefensiveStance +from game.commander.tasks.primitive.retreatstance import RetreatStance +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import FrontLine + + +@dataclass(frozen=True) +class DefendBase(CompoundTask[TheaterState]): + front_line: FrontLine + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [DefensiveStance(self.front_line, state.player)] + yield [RetreatStance(self.front_line, state.player)] + yield [PlanCas(self.front_line)] diff --git a/game/commander/tasks/compound/defendbases.py b/game/commander/tasks/compound/defendbases.py new file mode 100644 index 00000000..df18fdc3 --- /dev/null +++ b/game/commander/tasks/compound/defendbases.py @@ -0,0 +1,13 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.defendbase import DefendBase +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class DefendBases(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for front in state.active_front_lines: + yield [DefendBase(front)] diff --git a/game/commander/tasks/compound/destroyenemygroundunits.py b/game/commander/tasks/compound/destroyenemygroundunits.py new file mode 100644 index 00000000..90bbbb9f --- /dev/null +++ b/game/commander/tasks/compound/destroyenemygroundunits.py @@ -0,0 +1,17 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.primitive.cas import PlanCas +from game.commander.tasks.primitive.eliminationattack import EliminationAttack +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import FrontLine + + +@dataclass(frozen=True) +class DestroyEnemyGroundUnits(CompoundTask[TheaterState]): + front_line: FrontLine + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [EliminationAttack(self.front_line, state.player)] + yield [PlanCas(self.front_line)] diff --git a/game/commander/tasks/compound/nextaction.py b/game/commander/tasks/compound/nextaction.py index 8863600b..3b4559d3 100644 --- a/game/commander/tasks/compound/nextaction.py +++ b/game/commander/tasks/compound/nextaction.py @@ -1,19 +1,19 @@ from collections import Iterator from dataclasses import dataclass -from game.commander.tasks.compound.aewcsupport import PlanAewcSupport from game.commander.tasks.compound.attackairinfrastructure import ( AttackAirInfrastructure, ) from game.commander.tasks.compound.attackbuildings import AttackBuildings from game.commander.tasks.compound.attackgarrisons import AttackGarrisons +from game.commander.tasks.compound.capturebases import CaptureBases +from game.commander.tasks.compound.defendbases import DefendBases from game.commander.tasks.compound.degradeiads import DegradeIads -from game.commander.tasks.compound.frontlinedefense import FrontLineDefense from game.commander.tasks.compound.interdictreinforcements import ( InterdictReinforcements, ) from game.commander.tasks.compound.protectairspace import ProtectAirSpace -from game.commander.tasks.compound.refuelingsupport import PlanRefuelingSupport +from game.commander.tasks.compound.theatersupport import TheaterSupport from game.commander.theaterstate import TheaterState from game.htn import CompoundTask, Method @@ -23,10 +23,10 @@ class PlanNextAction(CompoundTask[TheaterState]): aircraft_cold_start: bool def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - yield [PlanAewcSupport()] - yield [PlanRefuelingSupport()] + yield [TheaterSupport()] yield [ProtectAirSpace()] - yield [FrontLineDefense()] + yield [CaptureBases()] + yield [DefendBases()] yield [InterdictReinforcements()] yield [AttackGarrisons()] yield [AttackAirInfrastructure(self.aircraft_cold_start)] diff --git a/game/commander/tasks/compound/theatersupport.py b/game/commander/tasks/compound/theatersupport.py new file mode 100644 index 00000000..379ba7c2 --- /dev/null +++ b/game/commander/tasks/compound/theatersupport.py @@ -0,0 +1,14 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.aewcsupport import PlanAewcSupport +from game.commander.tasks.compound.refuelingsupport import PlanRefuelingSupport +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class TheaterSupport(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [PlanAewcSupport()] + yield [PlanRefuelingSupport()] diff --git a/game/commander/tasks/frontlinestancetask.py b/game/commander/tasks/frontlinestancetask.py new file mode 100644 index 00000000..ae27f1a6 --- /dev/null +++ b/game/commander/tasks/frontlinestancetask.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import math +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from game.commander.tasks.theatercommandertask import TheaterCommanderTask +from game.commander.theaterstate import TheaterState +from game.profiling import MultiEventTracer +from game.theater import FrontLine +from gen.ground_forces.combat_stance import CombatStance + +if TYPE_CHECKING: + from gen.flights.ai_flight_planner import CoalitionMissionPlanner + + +class FrontLineStanceTask(TheaterCommanderTask, ABC): + def __init__(self, front_line: FrontLine, player: bool) -> None: + self.front_line = front_line + self.friendly_cp = self.front_line.control_point_friendly_to(player) + self.enemy_cp = self.front_line.control_point_hostile_to(player) + + @property + @abstractmethod + def stance(self) -> CombatStance: + ... + + @staticmethod + def management_allowed(state: TheaterState) -> bool: + return not state.player or state.stance_automation_enabled + + def better_stance_already_set(self, state: TheaterState) -> bool: + current_stance = state.front_line_stances[self.front_line] + if current_stance is None: + return False + preference = ( + CombatStance.RETREAT, + CombatStance.DEFENSIVE, + CombatStance.AMBUSH, + CombatStance.AGGRESSIVE, + CombatStance.ELIMINATION, + CombatStance.BREAKTHROUGH, + ) + current_rating = preference.index(current_stance) + new_rating = preference.index(self.stance) + return current_rating >= new_rating + + @property + @abstractmethod + def have_sufficient_front_line_advantage(self) -> bool: + ... + + @property + def ground_force_balance(self) -> float: + # TODO: Planned CAS missions should reduce the expected opposing force size. + friendly_forces = self.friendly_cp.deployable_front_line_units + enemy_forces = self.enemy_cp.deployable_front_line_units + if enemy_forces == 0: + return math.inf + return friendly_forces / enemy_forces + + def preconditions_met(self, state: TheaterState) -> bool: + if not self.management_allowed(state): + return False + if self.better_stance_already_set(state): + return False + return self.have_sufficient_front_line_advantage + + def apply_effects(self, state: TheaterState) -> None: + state.front_line_stances[self.front_line] = self.stance + + def execute( + self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer + ) -> None: + self.friendly_cp.stances[self.enemy_cp.id] = self.stance diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index 6d310abb..7196a0b9 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -40,6 +40,9 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): def __post_init__(self) -> None: self.flights = [] + def preconditions_met(self, state: TheaterState) -> bool: + return not state.player or state.ato_automation_enabled + def execute( self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer ) -> None: diff --git a/game/commander/tasks/primitive/aewc.py b/game/commander/tasks/primitive/aewc.py index 77ec0901..8153aac6 100644 --- a/game/commander/tasks/primitive/aewc.py +++ b/game/commander/tasks/primitive/aewc.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanAewc(PackagePlanningTask[MissionTarget]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False return self.target in state.aewc_targets def apply_effects(self, state: TheaterState) -> None: diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py index cf9741e5..fcdc2273 100644 --- a/game/commander/tasks/primitive/antiship.py +++ b/game/commander/tasks/primitive/antiship.py @@ -13,6 +13,8 @@ from gen.flights.flight import FlightType @dataclass class PlanAntiShip(PackagePlanningTask[NavalGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.threatening_air_defenses: return False return self.target_area_preconditions_met(state, ignore_iads=True) diff --git a/game/commander/tasks/primitive/antishipping.py b/game/commander/tasks/primitive/antishipping.py index 370afcfd..64be2cb9 100644 --- a/game/commander/tasks/primitive/antishipping.py +++ b/game/commander/tasks/primitive/antishipping.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanAntiShipping(PackagePlanningTask[CargoShip]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.enemy_shipping: return False return self.target_area_preconditions_met(state) diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py index bbbabb96..1b03ce53 100644 --- a/game/commander/tasks/primitive/bai.py +++ b/game/commander/tasks/primitive/bai.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.enemy_garrisons: return False return self.target_area_preconditions_met(state) diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py index 40fcc684..9707445c 100644 --- a/game/commander/tasks/primitive/barcap.py +++ b/game/commander/tasks/primitive/barcap.py @@ -19,6 +19,8 @@ class PlanBarcap(TheaterCommanderTask): target: ControlPoint def preconditions_met(self, state: TheaterState) -> bool: + if state.player and not state.ato_automation_enabled: + return False return self.target in state.vulnerable_control_points def apply_effects(self, state: TheaterState) -> None: diff --git a/game/commander/tasks/primitive/breakthroughattack.py b/game/commander/tasks/primitive/breakthroughattack.py new file mode 100644 index 00000000..3f32754d --- /dev/null +++ b/game/commander/tasks/primitive/breakthroughattack.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from game.commander.theaterstate import TheaterState +from game.theater import ControlPoint +from game.theater.theatergroundobject import VehicleGroupGroundObject +from game.utils import meters +from gen.ground_forces.combat_stance import CombatStance + + +class BreakthroughAttack(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.BREAKTHROUGH + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 1.2 + + @property + def opposing_garrisons_eliminated(self) -> bool: + # TODO: Should operate on TheaterState to account for BAIs planned this turn. + for tgo in self.enemy_cp.ground_objects: + if not isinstance(tgo, VehicleGroupGroundObject): + continue + if meters(tgo.distance_to(self.enemy_cp)) < ControlPoint.CAPTURE_DISTANCE: + return False + return True + + def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False + return self.opposing_garrisons_eliminated + + def apply_effects(self, state: TheaterState) -> None: + super().apply_effects(state) + state.active_front_lines.remove(self.front_line) diff --git a/game/commander/tasks/primitive/cas.py b/game/commander/tasks/primitive/cas.py index 63f1812a..14255c2e 100644 --- a/game/commander/tasks/primitive/cas.py +++ b/game/commander/tasks/primitive/cas.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanCas(PackagePlanningTask[FrontLine]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False return self.target in state.vulnerable_front_lines def apply_effects(self, state: TheaterState) -> None: diff --git a/game/commander/tasks/primitive/convoyinterdiction.py b/game/commander/tasks/primitive/convoyinterdiction.py index 7eb52716..9026057d 100644 --- a/game/commander/tasks/primitive/convoyinterdiction.py +++ b/game/commander/tasks/primitive/convoyinterdiction.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanConvoyInterdiction(PackagePlanningTask[Convoy]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.enemy_convoys: return False return self.target_area_preconditions_met(state) diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py index 730eb832..77ca80cb 100644 --- a/game/commander/tasks/primitive/dead.py +++ b/game/commander/tasks/primitive/dead.py @@ -13,6 +13,8 @@ from gen.flights.flight import FlightType @dataclass class PlanDead(PackagePlanningTask[IadsGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if ( self.target not in state.threatening_air_defenses and self.target not in state.detecting_air_defenses diff --git a/game/commander/tasks/primitive/defensivestance.py b/game/commander/tasks/primitive/defensivestance.py new file mode 100644 index 00000000..3e3510e2 --- /dev/null +++ b/game/commander/tasks/primitive/defensivestance.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class DefensiveStance(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.DEFENSIVE + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 0.5 diff --git a/game/commander/tasks/primitive/eliminationattack.py b/game/commander/tasks/primitive/eliminationattack.py new file mode 100644 index 00000000..674c5653 --- /dev/null +++ b/game/commander/tasks/primitive/eliminationattack.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class EliminationAttack(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.ELIMINATION + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 1.0 diff --git a/game/commander/tasks/primitive/oca.py b/game/commander/tasks/primitive/oca.py index 9a41a2e1..f3d43b18 100644 --- a/game/commander/tasks/primitive/oca.py +++ b/game/commander/tasks/primitive/oca.py @@ -14,6 +14,8 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]): aircraft_cold_start: bool def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.oca_targets: return False return self.target_area_preconditions_met(state) diff --git a/game/commander/tasks/primitive/refueling.py b/game/commander/tasks/primitive/refueling.py index 0b78c86d..005cbc3a 100644 --- a/game/commander/tasks/primitive/refueling.py +++ b/game/commander/tasks/primitive/refueling.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanRefueling(PackagePlanningTask[MissionTarget]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False return self.target in state.refueling_targets def apply_effects(self, state: TheaterState) -> None: diff --git a/game/commander/tasks/primitive/retreatstance.py b/game/commander/tasks/primitive/retreatstance.py new file mode 100644 index 00000000..d3e4bcc8 --- /dev/null +++ b/game/commander/tasks/primitive/retreatstance.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class RetreatStance(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.RETREAT + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return True diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py index cb943c47..7090eb86 100644 --- a/game/commander/tasks/primitive/strike.py +++ b/game/commander/tasks/primitive/strike.py @@ -13,6 +13,8 @@ from gen.flights.flight import FlightType @dataclass class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.strike_targets: return False return self.target_area_preconditions_met(state) diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index bc168f04..d80e38e9 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -3,12 +3,13 @@ from __future__ import annotations import dataclasses import itertools from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Any, Union, Optional from game.commander.garrisons import Garrisons from game.commander.objectivefinder import ObjectiveFinder from game.data.doctrine import Doctrine from game.htn import WorldState +from game.settings import AutoAtoBehavior from game.theater import ControlPoint, FrontLine, MissionTarget from game.theater.theatergroundobject import ( TheaterGroundObject, @@ -17,6 +18,7 @@ from game.theater.theatergroundobject import ( ) from game.threatzones import ThreatZones from game.transfers import Convoy, CargoShip +from gen.ground_forces.combat_stance import CombatStance if TYPE_CHECKING: from game import Game @@ -24,7 +26,12 @@ if TYPE_CHECKING: @dataclass class TheaterState(WorldState["TheaterState"]): + player: bool + stance_automation_enabled: bool + ato_automation_enabled: bool vulnerable_control_points: list[ControlPoint] + active_front_lines: list[FrontLine] + front_line_stances: dict[FrontLine, Optional[CombatStance]] vulnerable_front_lines: list[FrontLine] aewc_targets: list[MissionTarget] refueling_targets: list[MissionTarget] @@ -69,7 +76,12 @@ class TheaterState(WorldState["TheaterState"]): # Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly # expensive. return TheaterState( + player=self.player, + stance_automation_enabled=self.stance_automation_enabled, + ato_automation_enabled=self.ato_automation_enabled, vulnerable_control_points=list(self.vulnerable_control_points), + active_front_lines=list(self.active_front_lines), + front_line_stances=dict(self.front_line_stances), vulnerable_front_lines=list(self.vulnerable_front_lines), aewc_targets=list(self.aewc_targets), refueling_targets=list(self.refueling_targets), @@ -96,8 +108,15 @@ class TheaterState(WorldState["TheaterState"]): @classmethod def from_game(cls, game: Game, player: bool) -> TheaterState: finder = ObjectiveFinder(game, player) + auto_stance = game.settings.automate_front_line_stance + auto_ato = game.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled return TheaterState( + player=player, + stance_automation_enabled=auto_stance, + ato_automation_enabled=auto_ato, vulnerable_control_points=list(finder.vulnerable_control_points()), + active_front_lines=list(finder.front_lines()), + front_line_stances={f: None for f in finder.front_lines()}, vulnerable_front_lines=list(finder.front_lines()), aewc_targets=[finder.farthest_friendly_control_point()], refueling_targets=[finder.closest_friendly_control_point()], diff --git a/game/game.py b/game/game.py index 567ab2ab..fce069f8 100644 --- a/game/game.py +++ b/game/game.py @@ -24,6 +24,7 @@ from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType from gen.ground_forces.ai_ground_planner import GroundPlanner from . import persistency +from .commander import TheaterCommander from .debriefing import Debriefing from .event.event import Event from .event.frontlineattack import FrontlineAttackEvent @@ -32,7 +33,7 @@ from .income import Income from .infos.information import Information from .navmesh import NavMesh from .procurement import AircraftProcurementRequest, ProcurementAi -from .profiling import logged_duration +from .profiling import logged_duration, MultiEventTracer from .settings import Settings, AutoAtoBehavior from .squadrons import AirWing from .theater import ConflictTheater, ControlPoint @@ -504,13 +505,15 @@ class Game: with logged_duration("Transport planning"): self.transfers.plan_transports() - if not player or ( - player and self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled - ): - color = "Blue" if player else "Red" - with logged_duration(f"{color} mission planning"): - mission_planner = CoalitionMissionPlanner(self, player) - mission_planner.plan_missions() + color = "Blue" if player else "Red" + with MultiEventTracer() as tracer: + mission_planner = CoalitionMissionPlanner(self, player) + with tracer.trace(f"{color} mission planning"): + with tracer.trace(f"{color} mission identification"): + commander = TheaterCommander(self, player) + commander.plan_missions(mission_planner, tracer) + with tracer.trace(f"{color} mission fulfillment"): + mission_planner.fulfill_missions() self.plan_procurement_for(player) diff --git a/game/operation/operation.py b/game/operation/operation.py index da3f1c4a..56cfcf66 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -397,6 +397,7 @@ class Operation: player_gp, enemy_gp, player_cp.stances[enemy_cp.id], + enemy_cp.stances[player_cp.id], cls.unit_map, ) ground_conflict_gen.generate() diff --git a/game/settings.py b/game/settings.py index fc297cb9..e76e0816 100644 --- a/game/settings.py +++ b/game/settings.py @@ -55,6 +55,7 @@ class Settings: automate_runway_repair: bool = False automate_front_line_reinforcements: bool = False automate_aircraft_reinforcements: bool = False + automate_front_line_stance: bool = True restrict_weapons_by_date: bool = False disable_legacy_aewc: bool = True disable_legacy_tanker: bool = True diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index ecf77341..8fb111f5 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -271,6 +271,9 @@ class ControlPointStatus(IntEnum): class ControlPoint(MissionTarget, ABC): + # Not sure what distance DCS uses, but assuming it's about 2NM since that's roughly + # the distance of the circle on the map. + CAPTURE_DISTANCE = nautical_miles(2) position = None # type: Point name = None # type: str @@ -727,6 +730,10 @@ class ControlPoint(MissionTarget, ABC): return self.captured != other.captured + @property + def deployable_front_line_units(self) -> int: + return min(self.frontline_unit_count_limit, self.base.total_armor) + @property def frontline_unit_count_limit(self) -> int: return ( diff --git a/game/theater/frontline.py b/game/theater/frontline.py index c8f2fd6b..2f1b6067 100644 --- a/game/theater/frontline.py +++ b/game/theater/frontline.py @@ -71,15 +71,26 @@ class FrontLine(MissionTarget): self.point_from_a(self._position_distance), ) + def __eq__(self, other: Any) -> bool: + if not isinstance(other, FrontLine): + return False + return (self.blue_cp, self.red_cp) == (other.blue_cp, other.red_cp) + + def __hash__(self) -> int: + return hash((self.blue_cp, self.red_cp)) + def __setstate__(self, state: dict[str, Any]) -> None: self.__dict__.update(state) if not hasattr(self, "position"): self.position = self.point_from_a(self._position_distance) - def control_point_hostile_to(self, player: bool) -> ControlPoint: + def control_point_friendly_to(self, player: bool) -> ControlPoint: if player: - return self.red_cp - return self.blue_cp + return self.blue_cp + return self.red_cp + + def control_point_hostile_to(self, player: bool) -> ControlPoint: + return self.control_point_friendly_to(not player) def is_friendly(self, to_player: bool) -> bool: """Returns True if the objective is in friendly territory.""" diff --git a/gen/armor.py b/gen/armor.py index f9fb1a8a..6db4f632 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -86,43 +86,19 @@ class GroundConflictGenerator: player_planned_combat_groups: List[CombatGroup], enemy_planned_combat_groups: List[CombatGroup], player_stance: CombatStance, + enemy_stance: CombatStance, unit_map: UnitMap, ) -> None: self.mission = mission self.conflict = conflict self.enemy_planned_combat_groups = enemy_planned_combat_groups self.player_planned_combat_groups = player_planned_combat_groups - self.player_stance = CombatStance(player_stance) - self.enemy_stance = self._enemy_stance() + self.player_stance = player_stance + self.enemy_stance = enemy_stance self.game = game self.unit_map = unit_map self.jtacs: List[JtacInfo] = [] - def _enemy_stance(self) -> CombatStance: - """Picks the enemy stance according to the number of planned groups on the frontline for each side""" - if len(self.enemy_planned_combat_groups) > len( - self.player_planned_combat_groups - ): - return random.choice( - [ - CombatStance.AGGRESSIVE, - CombatStance.AGGRESSIVE, - CombatStance.AGGRESSIVE, - CombatStance.ELIMINATION, - CombatStance.BREAKTHROUGH, - ] - ) - else: - return random.choice( - [ - CombatStance.DEFENSIVE, - CombatStance.DEFENSIVE, - CombatStance.DEFENSIVE, - CombatStance.AMBUSH, - CombatStance.AGGRESSIVE, - ] - ) - def generate(self) -> None: position = Conflict.frontline_position( self.conflict.front_line, self.game.theater diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index de290661..cbe234b1 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -247,14 +247,9 @@ class CoalitionMissionPlanner: """ return self.game.air_wing_for(self.is_player).can_auto_plan(mission_type) - def plan_missions(self) -> None: + def fulfill_missions(self) -> None: """Identifies and plans mission for the turn.""" player = "Blue" if self.is_player else "Red" - with logged_duration(f"{player} mission identification and fulfillment"): - with MultiEventTracer() as tracer: - commander = TheaterCommander(self.game, self.is_player) - commander.plan_missions(self, tracer) - with logged_duration(f"{player} mission scheduling"): self.stagger_missions() diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 67cc0e3b..5aba6a7d 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -101,7 +101,7 @@ class HqAutomationSettingsBox(QGroupBox): front_line = QCheckBox() front_line.setChecked(self.game.settings.automate_front_line_reinforcements) - front_line.toggled.connect(self.set_front_line_automation) + front_line.toggled.connect(self.set_front_line_reinforcement_automation) layout.addWidget(QLabel("Automate front-line purchases"), 1, 0) layout.addWidget(front_line, 1, 1, Qt.AlignRight) @@ -147,12 +147,30 @@ class HqAutomationSettingsBox(QGroupBox): ) layout.addWidget(self.auto_ato_player_missions_asap, 4, 1, Qt.AlignRight) + self.automate_front_line_stance = QCheckBox() + self.automate_front_line_stance.setChecked( + self.game.settings.automate_front_line_stance + ) + self.automate_front_line_stance.toggled.connect( + self.set_front_line_stance_automation + ) + + layout.addWidget( + QLabel("Automatically manage front line stances"), + 5, + 0, + ) + layout.addWidget(self.automate_front_line_stance, 5, 1, Qt.AlignRight) + def set_runway_automation(self, value: bool) -> None: self.game.settings.automate_runway_repair = value - def set_front_line_automation(self, value: bool) -> None: + def set_front_line_reinforcement_automation(self, value: bool) -> None: self.game.settings.automate_front_line_reinforcements = value + def set_front_line_stance_automation(self, value: bool) -> None: + self.game.settings.automate_front_line_stance = value + def set_aircraft_automation(self, value: bool) -> None: self.game.settings.automate_aircraft_reinforcements = value