Merge branch 'develop' into helipads

# Conflicts:
#	game/game.py
#	game/operation/operation.py
#	game/theater/conflicttheater.py
#	game/theater/controlpoint.py
#	gen/groundobjectsgen.py
#	resources/campaigns/golan_heights_lite.miz
This commit is contained in:
Khopa 2021-08-02 19:34:05 +02:00
commit 71143536bf
408 changed files with 9630 additions and 5172 deletions

2
.gitignore vendored
View File

@ -18,7 +18,7 @@ env/
/liberation_preferences.json /liberation_preferences.json
/state.json /state.json
logs/ /logs/
qt_ui/logs/liberation.log qt_ui/logs/liberation.log

View File

@ -1,19 +1,65 @@
# 5.0.0 # 5.0.0
Saves from 3.x are not compatible with 5.0. Saves from 4.x are not compatible with 5.0.
## Features/Improvements ## Features/Improvements
* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated.
* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts.
* **[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.
* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points.
* **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken.
* **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron.
* **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet).
* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons.
## Fixes ## Fixes
# 4.0.1 * **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning.
Saves from 4.0.0 are compatible with 4.0.1. # 4.1.0
Saves from 4.0.0 are compatible with 4.1.0.
## Features/Improvements ## Features/Improvements
* **[Campaign]** Air defense sites now generate a fixed number of launchers per type.
* **[Campaign]** Added support for Mariana Islands map.
* **[Campaign AI]** Adjustments to aircraft selection priorities for most mission types.
* **[Engine]** Support for DCS 2.7.4.9632 and newer, including the Marianas map, F-16 JSOWs, NASAMS, and Tin Shield EWR.
* **[Flight Planning]** CAP patrol altitudes are now set per-aircraft. By default the altitude will be set based on the aircraft's maximum speed.
* **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR
* **[Mission Generation]** SAM sites are now headed towards the center of the conflict
* **[Mods]** Support for latest version of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts.
* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI.
* **[Plugins]** Updated SkynetIADS to 2.2.0 (adds NASAMS support).
* **[UI]** Added ability to take notes and have those notes appear as a kneeboard page.
* **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet).
* **[UI]** Google search link added to unit information when there is no information provided.
* **[UI]** Control point name displayed with ground object group name on map.
* **[UI]** Buy or Replace will now show the correct price for generated ground objects like sams.
* **[UI]** Improved logging for frontline movement to be more descriptive about what happened and why.
* **[UI]** Brought ruler map module into source, which should fix file integrity issues with the module.
## Fixes ## Fixes
* **[Campaign]** Fixed the Silkworm generator to include launchers and not all radars.
* **[Data]** Fixed Introduction dates for targeting pods (ATFLIR and LITENING were both a few years too early).
* **[Data]** Removed SA-10 from Syria 2011 faction.
* **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money
* **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles.
* **[Mission Generation]** The lua data for other plugins is now generated correctly
* **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs
* **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation.
* **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured.
* **[Mission Generation]** Planned transfers which will be impossible after a base capture will no longer prevent the mission result submit.
* **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed.
* **[Mission Generation]** Fixed a potential bug with laser code generation where it would generate invalid codes.
* **[UI]** Statistics window tick marks are now always integers.
* **[UI]** Statistics window now shows the correct info for the turn
* **[UI]** Toggling custom loadout for an aircraft with no preset loadouts no longer breaks the flight.
# 4.0.0 # 4.0.0
Saves from 3.x are not compatible with 4.0. Saves from 3.x are not compatible with 4.0.

View File

@ -0,0 +1,80 @@
# Measuring estimated fuel consumption
To estimate fuel consumption numbers for an aircraft, create a mission with a
typical heavy load for the aircraft. For example, to measure for the F/A-18C, a
loadout with two bags, two GBU-31s, two sidewinders, an AMRAAM, and an ATFLIR.
Do **not** drop bags or weapons during the test flight.
Start the aircraft on the ground at a large airport (for example, Akrotiri) at a
parking space at the opposite end of the takeoff runway so you can estimate long
taxi fuel consumption.
When you enter the jet, note the amount of fuel below, then taxi to the far end
of the runway. Hold short and note the remaining fuel below.
Follow a typical takeoff pattern for the aircraft. For the F/A-18C, this might
be AB takeoff, reduce to MIL at 350KIAS, and maintian 350KIAS/0.85 mach until
cruise altitude (angles 25).
Once you reach angels 25, pause the game. Note your remaining fuel below and
measure the distance traveled from takeoff. Mark your location on the map.
Level out and increase to cruise speed if needed. Liberation assumes 0.85 mach
for supersonic aircraft, for subsonic aircraft it depends so pick something
reasonable and note your descision in a comment in the file when done. Maintain
speed, heading, and altitude for a long distance (the longer the distance, the
more accurate the result, but be careful to leave enough fuel for the final
section). Once complete, note the distance traveled and the remaining fuel.
Finally, increase speed as you would for an attack. At least MIL power,
potentially use AB sparingly, etc. The goal is to measure fuel consumption per
mile traveled during an attack run.
```
start:
taxi end:
to 25k distance:
at 25k fuel:
cruise (.85 mach) distance:
cruise (.85 mach) end fuel:
combat distance:
combat end fuel:
```
Finally, fill out the data in the aircraft data. Below is an example for the
F/A-18C:
```
start: 15290
taxi end: 15120
climb distance: 40NM
at 25k fuel: 13350
cruise (.85 mach) distance: 100NM
cruise (.85 mach) end fuel: 11140
combat distance: 100NM
combat end fuel: 8390
taxi = start - taxi end = 15290 - 15120 = 170
climb fuel = taxi end - at 25k fuel = 15120 - 13350 = 1770
climb ppm = climb fuel / climb distance = 1770 / 40 = 44.25
cruise fuel = at 25k fuel - cruise end fuel = 13350 - 11140 = 2210
cruise ppm = cruise fuel / cruise distance = 2210 / 100 = 22.1
combat fuel = cruise end fuel - combat end fuel = 11140 - 8390 = 2750
combat ppm = combat fuel / combat distance = 2750 / 100 = 27.5
```
```yaml
fuel:
# Parking A1 to RWY 32 at Akrotiri.
taxi: 170
# AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft.
climb_ppm: 44.25
# 0.85 mach for 100NM.
cruise_ppm: 22.1
# ~0.9 mach for 100NM. Occasional AB use.
combat_ppm: 27.5
min_safe: 2000
```
The last entry (`min_safe`) is the minimum amount of fuel that the aircraft
should land with.

239
game/coalition.py Normal file
View File

@ -0,0 +1,239 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional
from dcs import Point
from faker import Faker
from game.commander import TheaterCommander
from game.commander.missionscheduler import MissionScheduler
from game.income import Income
from game.inventory import GlobalAircraftInventory
from game.navmesh import NavMesh
from game.orderedset import OrderedSet
from game.profiling import logged_duration, MultiEventTracer
from game.savecompat import has_save_compat_for
from game.threatzones import ThreatZones
from game.transfers import PendingTransfers
if TYPE_CHECKING:
from game import Game
from game.data.doctrine import Doctrine
from game.factions.faction import Faction
from game.procurement import AircraftProcurementRequest, ProcurementAi
from game.squadrons import AirWing
from game.theater.bullseye import Bullseye
from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
from gen import AirTaskingOrder
class Coalition:
def __init__(
self, game: Game, faction: Faction, budget: float, player: bool
) -> None:
self.game = game
self.player = player
self.faction = faction
self.budget = budget
self.ato = AirTaskingOrder()
self.transit_network = TransitNetwork()
self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet()
self.bullseye = Bullseye(Point(0, 0))
self.faker = Faker(self.faction.locales)
self.air_wing = AirWing(game, self)
self.transfers = PendingTransfers(game, player)
# Late initialized because the two coalitions in the game are mutually
# dependent, so must be both constructed before this property can be set.
self._opponent: Optional[Coalition] = None
# Volatile properties that are not persisted to the save file since they can be
# recomputed on load. Keeping this data out of the save file makes save compat
# breaks less frequent. Each of these properties has a non-underscore-prefixed
# @property that should be used for non-Optional access.
#
# All of these are late-initialized (whether via on_load or called later), but
# will be non-None after the game has finished loading.
self._threat_zone: Optional[ThreatZones] = None
self._navmesh: Optional[NavMesh] = None
self.on_load()
@property
def doctrine(self) -> Doctrine:
return self.faction.doctrine
@property
def coalition_id(self) -> int:
if self.player:
return 2
return 1
@property
def country_name(self) -> str:
return self.faction.country
@property
def opponent(self) -> Coalition:
assert self._opponent is not None
return self._opponent
@property
def threat_zone(self) -> ThreatZones:
assert self._threat_zone is not None
return self._threat_zone
@property
def nav_mesh(self) -> NavMesh:
assert self._navmesh is not None
return self._navmesh
@property
def aircraft_inventory(self) -> GlobalAircraftInventory:
return self.game.aircraft_inventory
def __getstate__(self) -> dict[str, Any]:
state = self.__dict__.copy()
# Avoid persisting any volatile types that can be deterministically
# recomputed on load for the sake of save compatibility.
del state["_threat_zone"]
del state["_navmesh"]
del state["faker"]
return state
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
# Begin save compat
old_procurement_requests = state["procurement_requests"]
if isinstance(old_procurement_requests, list):
state["procurement_requests"] = OrderedSet(old_procurement_requests)
# End save compat
self.__dict__.update(state)
# Regenerate any state that was not persisted.
self.on_load()
def on_load(self) -> None:
self.faker = Faker(self.faction.locales)
def set_opponent(self, opponent: Coalition) -> None:
if self._opponent is not None:
raise RuntimeError("Double-initialization of Coalition.opponent")
self._opponent = opponent
def adjust_budget(self, amount: float) -> None:
self.budget += amount
def compute_threat_zones(self) -> None:
self._threat_zone = ThreatZones.for_faction(self.game, self.player)
def compute_nav_meshes(self) -> None:
self._navmesh = NavMesh.from_threat_zones(
self.opponent.threat_zone, self.game.theater
)
def update_transit_network(self) -> None:
self.transit_network = TransitNetworkBuilder(
self.game.theater, self.player
).build()
def set_bullseye(self, bullseye: Bullseye) -> None:
self.bullseye = bullseye
def end_turn(self) -> None:
"""Processes coalition-specific turn finalization.
For more information on turn finalization in general, see the documentation for
`Game.finish_turn`.
"""
self.air_wing.replenish()
self.budget += Income(self.game, self.player).total
# Need to recompute before transfers and deliveries to account for captures.
# This happens in in initialize_turn as well, because cheating doesn't advance a
# turn but can capture bases so we need to recompute there as well.
self.update_transit_network()
# Must happen *before* unit deliveries are handled, or else new units will spawn
# one hop ahead. ControlPoint.process_turn handles unit deliveries. The
# coalition-specific turn-end happens before the theater-wide turn-end, so this
# is handled correctly.
self.transfers.perform_transfers()
def preinit_turn_0(self) -> None:
"""Runs final Coalition initialization.
Final initialization occurs before Game.initialize_turn runs for turn 0.
"""
self.air_wing.populate_for_turn_0()
def initialize_turn(self) -> None:
"""Processes coalition-specific turn initialization.
For more information on turn initialization in general, see the documentation
for `Game.initialize_turn`.
"""
# Needs to happen *before* planning transfers so we don't cancel them.
self.ato.clear()
self.air_wing.reset()
self.refund_outstanding_orders()
self.procurement_requests.clear()
with logged_duration("Transit network identification"):
self.update_transit_network()
with logged_duration("Procurement of airlift assets"):
self.transfers.order_airlift_assets()
with logged_duration("Transport planning"):
self.transfers.plan_transports()
self.plan_missions()
self.plan_procurement()
def refund_outstanding_orders(self) -> None:
# TODO: Split orders between air and ground units.
# This isn't quite right. If the player has ground purchases automated we should
# be refunding the ground units, and if they have air automated but not ground
# we should be refunding air units.
if self.player and not self.game.settings.automate_aircraft_reinforcements:
return
for cp in self.game.theater.control_points_for(self.player):
cp.pending_unit_deliveries.refund_all(self)
def plan_missions(self) -> None:
color = "Blue" if self.player else "Red"
with MultiEventTracer() as tracer:
with tracer.trace(f"{color} mission planning"):
with tracer.trace(f"{color} mission identification"):
TheaterCommander(self.game, self.player).plan_missions(tracer)
with tracer.trace(f"{color} mission scheduling"):
MissionScheduler(
self, self.game.settings.desired_player_mission_duration
).schedule_missions()
def plan_procurement(self) -> 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. For the default starting budget of 2000 this gives 600
# to ground forces and 1400 to aircraft. After that the budget will be spent
# proportionally based on how much is already invested.
if self.player:
manage_runways = self.game.settings.automate_runway_repair
manage_front_line = self.game.settings.automate_front_line_reinforcements
manage_aircraft = self.game.settings.automate_aircraft_reinforcements
else:
manage_runways = True
manage_front_line = True
manage_aircraft = True
self.budget = ProcurementAi(
self.game,
self.player,
self.faction,
manage_runways,
manage_front_line,
manage_aircraft,
).spend_budget(self.budget)
def add_procurement_request(self, request: AircraftProcurementRequest) -> None:
self.procurement_requests.add(request)

View File

@ -0,0 +1 @@
from .theatercommander import TheaterCommander

View File

@ -0,0 +1,78 @@
from typing import Optional, Tuple
from game.commander.missionproposals import ProposedFlight
from game.inventory import GlobalAircraftInventory
from game.squadrons import AirWing, Squadron
from game.theater import ControlPoint, MissionTarget
from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ClosestAirfields
from gen.flights.flight import FlightType
class AircraftAllocator:
"""Finds suitable aircraft for proposed missions."""
def __init__(
self,
air_wing: AirWing,
closest_airfields: ClosestAirfields,
global_inventory: GlobalAircraftInventory,
is_player: bool,
) -> None:
self.air_wing = air_wing
self.closest_airfields = closest_airfields
self.global_inventory = global_inventory
self.is_player = is_player
def find_squadron_for_flight(
self, target: MissionTarget, flight: ProposedFlight
) -> Optional[Tuple[ControlPoint, Squadron]]:
"""Finds aircraft suitable for the given mission.
Searches for aircraft capable of performing the given mission within the
maximum allowed range. If insufficient aircraft are available for the
mission, None is returned.
Airfields are searched ordered nearest to farthest from the target and
searched twice. The first search looks for aircraft which prefer the
mission type, and the second search looks for any aircraft which are
capable of the mission type. For example, an F-14 from a nearby carrier
will be preferred for the CAP of an airfield that has only F-16s, but if
the carrier has only F/A-18s the F-16s will be used for CAP instead.
Note that aircraft *will* be removed from the global inventory on
success. This is to ensure that the same aircraft are not matched twice
on subsequent calls. If the found aircraft are not used, the caller is
responsible for returning them to the inventory.
"""
return self.find_aircraft_for_task(target, flight, flight.task)
def find_aircraft_for_task(
self, target: MissionTarget, flight: ProposedFlight, task: FlightType
) -> Optional[Tuple[ControlPoint, Squadron]]:
types = aircraft_for_task(task)
for airfield in self.closest_airfields.operational_airfields:
if not airfield.is_friendly(self.is_player):
continue
inventory = self.global_inventory.for_control_point(airfield)
for aircraft in types:
if not airfield.can_operate(aircraft):
continue
if inventory.available(aircraft) < flight.num_aircraft:
continue
distance_to_target = meters(target.distance_to(airfield))
if distance_to_target > aircraft.max_mission_range:
continue
# Valid location with enough aircraft available. Find a squadron to fit
# the role.
squadrons = self.air_wing.auto_assignable_for_task_with_type(
aircraft, task
)
for squadron in squadrons:
if squadron.operates_from(airfield) and squadron.can_provide_pilots(
flight.num_aircraft
):
inventory.remove_aircraft(aircraft, flight.num_aircraft)
return airfield, squadron
return None

View File

@ -0,0 +1,52 @@
from __future__ import annotations
from collections import Iterator
from dataclasses import dataclass
from game.theater import ControlPoint
from game.theater.theatergroundobject import VehicleGroupGroundObject
from game.utils import meters
@dataclass
class Garrisons:
blocking_capture: list[VehicleGroupGroundObject]
defending_front_line: list[VehicleGroupGroundObject]
@property
def in_priority_order(self) -> Iterator[VehicleGroupGroundObject]:
yield from self.blocking_capture
yield from self.defending_front_line
def eliminate(self, garrison: VehicleGroupGroundObject) -> None:
if garrison in self.blocking_capture:
self.blocking_capture.remove(garrison)
if garrison in self.defending_front_line:
self.defending_front_line.remove(garrison)
def __contains__(self, item: VehicleGroupGroundObject) -> bool:
return item in self.in_priority_order
@classmethod
def for_control_point(cls, control_point: ControlPoint) -> Garrisons:
"""Categorize garrison groups based on target priority.
Any garrisons blocking base capture are the highest priority.
"""
blocking = []
defending = []
garrisons = [
tgo
for tgo in control_point.ground_objects
if isinstance(tgo, VehicleGroupGroundObject) and not tgo.is_dead
]
for garrison in garrisons:
if (
meters(garrison.distance_to(control_point))
< ControlPoint.CAPTURE_DISTANCE
):
blocking.append(garrison)
else:
defending.append(garrison)
return Garrisons(blocking, defending)

View File

@ -0,0 +1,58 @@
from dataclasses import field, dataclass
from enum import Enum, auto
from typing import Optional
from game.theater import MissionTarget
from gen.flights.flight import FlightType
class EscortType(Enum):
AirToAir = auto()
Sead = auto()
@dataclass(frozen=True)
class ProposedFlight:
"""A flight outline proposed by the mission planner.
Proposed flights haven't been assigned specific aircraft yet. They have only
a task, a required number of aircraft, and a maximum distance allowed
between the objective and the departure airfield.
"""
#: The flight's role.
task: FlightType
#: The number of aircraft required.
num_aircraft: int
#: The type of threat this flight defends against if it is an escort. Escort
#: flights will be pruned if the rest of the package is not threatened by
#: the threat they defend against. If this flight is not an escort, this
#: field is None.
escort_type: Optional[EscortType] = field(default=None)
def __str__(self) -> str:
return f"{self.task} {self.num_aircraft} ship"
@dataclass(frozen=True)
class ProposedMission:
"""A mission outline proposed by the mission planner.
Proposed missions haven't been assigned aircraft yet. They have only an
objective location and a list of proposed flights that are required for the
mission.
"""
#: The mission objective.
location: MissionTarget
#: The proposed flights that are required for the mission.
flights: list[ProposedFlight]
asap: bool = field(default=False)
def __str__(self) -> str:
flights = ", ".join([str(f) for f in self.flights])
return f"{self.location.name}: {flights}"

View File

@ -0,0 +1,76 @@
from __future__ import annotations
import logging
import random
from collections import defaultdict
from datetime import timedelta
from typing import Iterator, Dict, TYPE_CHECKING
from game.theater import MissionTarget
from gen.flights.flight import FlightType
from gen.flights.traveltime import TotEstimator
if TYPE_CHECKING:
from game.coalition import Coalition
class MissionScheduler:
def __init__(self, coalition: Coalition, desired_mission_length: timedelta) -> None:
self.coalition = coalition
self.desired_mission_length = desired_mission_length
def schedule_missions(self) -> None:
"""Identifies and plans mission for the turn."""
def start_time_generator(
count: int, earliest: int, latest: int, margin: int
) -> Iterator[timedelta]:
interval = (latest - earliest) // count
for time in range(earliest, latest, interval):
error = random.randint(-margin, margin)
yield timedelta(seconds=max(0, time + error))
dca_types = {
FlightType.BARCAP,
FlightType.TARCAP,
}
previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta)
non_dca_packages = [
p for p in self.coalition.ato.packages if p.primary_task not in dca_types
]
start_time = start_time_generator(
count=len(non_dca_packages),
earliest=5 * 60,
latest=int(self.desired_mission_length.total_seconds()),
margin=5 * 60,
)
for package in self.coalition.ato.packages:
tot = TotEstimator(package).earliest_tot()
if package.primary_task in dca_types:
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
elif package.auto_asap:
package.set_tot_asap()
else:
# But other packages should be spread out a bit. Note that take
# times are delayed, but all aircraft will become active at
# mission start. This makes it more worthwhile to attack enemy
# airfields to hit grounded aircraft, since they're more likely
# to be present. Runway and air started aircraft will be
# delayed until their takeoff time by AirConflictGenerator.
package.time_over_target = next(start_time) + tot

View File

@ -0,0 +1,246 @@
from __future__ import annotations
import math
import operator
from collections import Iterator, Iterable
from typing import TypeVar, TYPE_CHECKING
from game.theater import (
ControlPoint,
OffMapSpawn,
MissionTarget,
Fob,
FrontLine,
Airfield,
)
from game.theater.theatergroundobject import (
BuildingGroundObject,
IadsGroundObject,
NavalGroundObject,
)
from game.utils import meters, nautical_miles
from gen.flights.closestairfields import ObjectiveDistanceCache, ClosestAirfields
if TYPE_CHECKING:
from game import Game
from game.transfers import CargoShip, Convoy
MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget)
class ObjectiveFinder:
"""Identifies potential objectives for the mission planner."""
# TODO: Merge into doctrine.
AIRFIELD_THREAT_RANGE = nautical_miles(150)
SAM_THREAT_RANGE = nautical_miles(100)
def __init__(self, game: Game, is_player: bool) -> None:
self.game = game
self.is_player = is_player
def enemy_air_defenses(self) -> Iterator[IadsGroundObject]:
"""Iterates over all enemy SAM sites."""
for cp in self.enemy_control_points():
for ground_object in cp.ground_objects:
if ground_object.is_dead:
continue
if isinstance(ground_object, IadsGroundObject):
yield ground_object
def enemy_ships(self) -> Iterator[NavalGroundObject]:
for cp in self.enemy_control_points():
for ground_object in cp.ground_objects:
if not isinstance(ground_object, NavalGroundObject):
continue
if ground_object.is_dead:
continue
yield ground_object
def threatening_ships(self) -> Iterator[NavalGroundObject]:
"""Iterates over enemy ships near friendly control points.
Groups are sorted by their closest proximity to any friendly control
point (airfield or fleet).
"""
return self._targets_by_range(self.enemy_ships())
def _targets_by_range(
self, targets: Iterable[MissionTargetType]
) -> Iterator[MissionTargetType]:
target_ranges: list[tuple[MissionTargetType, float]] = []
for target in targets:
ranges: list[float] = []
for cp in self.friendly_control_points():
ranges.append(target.distance_to(cp))
target_ranges.append((target, min(ranges)))
target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
for target, _range in target_ranges:
yield target
def strike_targets(self) -> Iterator[BuildingGroundObject]:
"""Iterates over enemy strike targets.
Targets are sorted by their closest proximity to any friendly control
point (airfield or fleet).
"""
targets: list[tuple[BuildingGroundObject, float]] = []
# Building objectives are made of several individual TGOs (one per
# building).
found_targets: set[str] = set()
for enemy_cp in self.enemy_control_points():
for ground_object in enemy_cp.ground_objects:
# TODO: Reuse ground_object.mission_types.
# The mission types for ground objects are currently not
# accurate because we include things like strike and BAI for all
# targets since they have different planning behavior (waypoint
# generation is better for players with strike when the targets
# are stationary, AI behavior against weaker air defenses is
# better with BAI), so that's not a useful filter. Once we have
# better control over planning profiles and target dependent
# loadouts we can clean this up.
if not isinstance(ground_object, BuildingGroundObject):
# Other group types (like ships, SAMs, garrisons, etc) have better
# suited mission types like anti-ship, DEAD, and BAI.
continue
if isinstance(enemy_cp, Fob) and ground_object.is_control_point:
# This is the FOB structure itself. Can't be repaired or
# targeted by the player, so shouldn't be targetable by the
# AI.
continue
if ground_object.is_dead:
continue
if ground_object.name in found_targets:
continue
ranges: list[float] = []
for friendly_cp in self.friendly_control_points():
ranges.append(ground_object.distance_to(friendly_cp))
targets.append((ground_object, min(ranges)))
found_targets.add(ground_object.name)
targets = sorted(targets, key=operator.itemgetter(1))
for target, _range in targets:
yield target
def front_lines(self) -> Iterator[FrontLine]:
"""Iterates over all active front lines in the theater."""
yield from self.game.theater.conflicts()
def vulnerable_control_points(self) -> Iterator[ControlPoint]:
"""Iterates over friendly CPs that are vulnerable to enemy CPs.
Vulnerability is defined as any enemy CP within threat range of of the
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.operational_airfields_within(
self.AIRFIELD_THREAT_RANGE
)
)
for airfield in airfields_in_threat_range:
if not airfield.is_friendly(self.is_player):
yield cp
break
def oca_targets(self, min_aircraft: int) -> Iterator[ControlPoint]:
airfields = []
for control_point in self.enemy_control_points():
if not isinstance(control_point, Airfield):
continue
if control_point.base.total_aircraft >= min_aircraft:
airfields.append(control_point)
return self._targets_by_range(airfields)
def convoys(self) -> Iterator[Convoy]:
for front_line in self.front_lines():
yield from self.game.coalition_for(
self.is_player
).transfers.convoys.travelling_to(
front_line.control_point_hostile_to(self.is_player)
)
def cargo_ships(self) -> Iterator[CargoShip]:
for front_line in self.front_lines():
yield from self.game.coalition_for(
self.is_player
).transfers.cargo_ships.travelling_to(
front_line.control_point_hostile_to(self.is_player)
)
def friendly_control_points(self) -> Iterator[ControlPoint]:
"""Iterates over all friendly control points."""
return (
c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player)
)
def farthest_friendly_control_point(self) -> ControlPoint:
"""Finds the friendly control point that is farthest from any threats."""
threat_zones = self.game.threat_zone_for(not self.is_player)
farthest = None
max_distance = meters(0)
for cp in self.friendly_control_points():
if isinstance(cp, OffMapSpawn):
continue
distance = threat_zones.distance_to_threat(cp.position)
if distance > max_distance:
farthest = cp
max_distance = distance
if farthest is None:
raise RuntimeError("Found no friendly control points. You probably lost.")
return farthest
def closest_friendly_control_point(self) -> ControlPoint:
"""Finds the friendly control point that is closest to any threats."""
threat_zones = self.game.threat_zone_for(not self.is_player)
closest = None
min_distance = meters(math.inf)
for cp in self.friendly_control_points():
if isinstance(cp, OffMapSpawn):
continue
distance = threat_zones.distance_to_threat(cp.position)
if distance < min_distance:
closest = cp
min_distance = distance
if closest is None:
raise RuntimeError("Found no friendly control points. You probably lost.")
return closest
def enemy_control_points(self) -> Iterator[ControlPoint]:
"""Iterates over all enemy control points."""
return (
c
for c in self.game.theater.controlpoints
if not c.is_friendly(self.is_player)
)
def prioritized_unisolated_points(self) -> list[ControlPoint]:
prioritized = []
capturable_later = []
for cp in self.game.theater.control_points_for(not self.is_player):
if cp.is_isolated:
continue
if cp.has_active_frontline:
prioritized.append(cp)
else:
capturable_later.append(cp)
prioritized.extend(self._targets_by_range(capturable_later))
return prioritized
@staticmethod
def closest_airfields_to(location: MissionTarget) -> ClosestAirfields:
"""Returns the closest airfields to the given location."""
return ObjectiveDistanceCache.get_closest_airfields(location)

View File

@ -0,0 +1,98 @@
from typing import Optional
from game.commander.missionproposals import ProposedFlight
from game.dcs.aircrafttype import AircraftType
from game.inventory import GlobalAircraftInventory
from game.squadrons import AirWing
from game.theater import MissionTarget, OffMapSpawn, ControlPoint
from game.utils import nautical_miles
from gen import Package
from game.commander.aircraftallocator import AircraftAllocator
from gen.flights.closestairfields import ClosestAirfields
from gen.flights.flight import Flight
class PackageBuilder:
"""Builds a Package for the flights it receives."""
def __init__(
self,
location: MissionTarget,
closest_airfields: ClosestAirfields,
global_inventory: GlobalAircraftInventory,
air_wing: AirWing,
is_player: bool,
package_country: str,
start_type: str,
asap: bool,
) -> None:
self.closest_airfields = closest_airfields
self.is_player = is_player
self.package_country = package_country
self.package = Package(location, auto_asap=asap)
self.allocator = AircraftAllocator(
air_wing, closest_airfields, global_inventory, is_player
)
self.global_inventory = global_inventory
self.start_type = start_type
def plan_flight(self, plan: ProposedFlight) -> bool:
"""Allocates aircraft for the given flight and adds them to the package.
If no suitable aircraft are available, False is returned. If the failed
flight was critical and the rest of the mission will be scrubbed, the
caller should return any previously planned flights to the inventory
using release_planned_aircraft.
"""
assignment = self.allocator.find_squadron_for_flight(self.package.target, plan)
if assignment is None:
return False
airfield, squadron = assignment
if isinstance(airfield, OffMapSpawn):
start_type = "In Flight"
else:
start_type = self.start_type
flight = Flight(
self.package,
self.package_country,
squadron,
plan.num_aircraft,
plan.task,
start_type,
departure=airfield,
arrival=airfield,
divert=self.find_divert_field(squadron.aircraft, airfield),
)
self.package.add_flight(flight)
return True
def find_divert_field(
self, aircraft: AircraftType, arrival: ControlPoint
) -> Optional[ControlPoint]:
divert_limit = nautical_miles(150)
for airfield in self.closest_airfields.operational_airfields_within(
divert_limit
):
if airfield.captured != self.is_player:
continue
if airfield == arrival:
continue
if not airfield.can_operate(aircraft):
continue
if isinstance(airfield, OffMapSpawn):
continue
return airfield
return None
def build(self) -> Package:
"""Returns the built package."""
return self.package
def release_planned_aircraft(self) -> None:
"""Returns any planned flights to the inventory."""
flights = list(self.package.flights)
for flight in flights:
self.global_inventory.return_from_flight(flight)
flight.clear_roster()
self.package.remove_flight(flight)

View File

@ -0,0 +1,228 @@
from __future__ import annotations
import logging
from collections import defaultdict
from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional
from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType
from game.data.doctrine import Doctrine
from game.inventory import GlobalAircraftInventory
from game.procurement import AircraftProcurementRequest
from game.profiling import MultiEventTracer
from game.settings import Settings
from game.squadrons import AirWing
from game.theater import ConflictTheater
from game.threatzones import ThreatZones
from gen import AirTaskingOrder, Package
from game.commander.packagebuilder import PackageBuilder
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
from gen.flights.flightplan import FlightPlanBuilder
if TYPE_CHECKING:
from game.coalition import Coalition
class PackageFulfiller:
"""Responsible for package aircraft allocation and flight plan layout."""
def __init__(
self,
coalition: Coalition,
theater: ConflictTheater,
aircraft_inventory: GlobalAircraftInventory,
settings: Settings,
) -> None:
self.coalition = coalition
self.theater = theater
self.aircraft_inventory = aircraft_inventory
self.player_missions_asap = settings.auto_ato_player_missions_asap
self.default_start_type = settings.default_start_type
@property
def is_player(self) -> bool:
return self.coalition.player
@property
def ato(self) -> AirTaskingOrder:
return self.coalition.ato
@property
def air_wing(self) -> AirWing:
return self.coalition.air_wing
@property
def doctrine(self) -> Doctrine:
return self.coalition.doctrine
@property
def threat_zones(self) -> ThreatZones:
return self.coalition.opponent.threat_zone
def add_procurement_request(self, request: AircraftProcurementRequest) -> None:
self.coalition.add_procurement_request(request)
def air_wing_can_plan(self, mission_type: FlightType) -> bool:
"""Returns True if it is possible for the air wing to plan this mission type.
Not all mission types can be fulfilled by all air wings. Many factions do not
have AEW&C aircraft, so they will never be able to plan those missions. It's
also possible for the player to exclude mission types from their squadron
designs.
"""
return self.air_wing.can_auto_plan(mission_type)
def plan_flight(
self,
mission: ProposedMission,
flight: ProposedFlight,
builder: PackageBuilder,
missing_types: Set[FlightType],
purchase_multiplier: int,
) -> None:
if not builder.plan_flight(flight):
missing_types.add(flight.task)
purchase_order = AircraftProcurementRequest(
near=mission.location,
task_capability=flight.task,
number=flight.num_aircraft * purchase_multiplier,
)
# Reserves are planned for critical missions, so prioritize those orders
# over aircraft needed for non-critical missions.
self.add_procurement_request(purchase_order)
def scrub_mission_missing_aircraft(
self,
mission: ProposedMission,
builder: PackageBuilder,
missing_types: Set[FlightType],
not_attempted: Iterable[ProposedFlight],
purchase_multiplier: int,
) -> None:
# Try to plan the rest of the mission just so we can count the missing
# types to buy.
for flight in not_attempted:
self.plan_flight(
mission, flight, builder, missing_types, purchase_multiplier
)
missing_types_str = ", ".join(sorted([t.name for t in missing_types]))
builder.release_planned_aircraft()
color = "Blue" if self.is_player else "Red"
logging.debug(
f"{color}: not enough aircraft in range for {mission.location.name} "
f"capable of: {missing_types_str}"
)
def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]:
threats = defaultdict(bool)
for flight in builder.package.flights:
if self.threat_zones.waypoints_threatened_by_aircraft(
flight.flight_plan.escorted_waypoints()
):
threats[EscortType.AirToAir] = True
if self.threat_zones.waypoints_threatened_by_radar_sam(
list(flight.flight_plan.escorted_waypoints())
):
threats[EscortType.Sead] = True
return threats
def plan_mission(
self,
mission: ProposedMission,
purchase_multiplier: int,
tracer: MultiEventTracer,
) -> Optional[Package]:
"""Allocates aircraft for a proposed mission and adds it to the ATO."""
builder = PackageBuilder(
mission.location,
ObjectiveDistanceCache.get_closest_airfields(mission.location),
self.aircraft_inventory,
self.air_wing,
self.is_player,
self.coalition.country_name,
self.default_start_type,
mission.asap,
)
# Attempt to plan all the main elements of the mission first. Escorts
# will be planned separately so we can prune escorts for packages that
# are not expected to encounter that type of threat.
missing_types: Set[FlightType] = set()
escorts = []
for proposed_flight in mission.flights:
if not self.air_wing_can_plan(proposed_flight.task):
# This air wing can never plan this mission type because they do not
# have compatible aircraft or squadrons. Skip fulfillment so that we
# don't place the purchase request.
continue
if proposed_flight.escort_type is not None:
# Escorts are planned after the primary elements of the package.
# If the package does not need escorts they may be pruned.
escorts.append(proposed_flight)
continue
with tracer.trace("Flight planning"):
self.plan_flight(
mission,
proposed_flight,
builder,
missing_types,
purchase_multiplier,
)
if missing_types:
self.scrub_mission_missing_aircraft(
mission, builder, missing_types, escorts, purchase_multiplier
)
return None
if not builder.package.flights:
# The non-escort part of this mission is unplannable by this faction. Scrub
# the mission and do not attempt planning escorts because there's no reason
# to buy them because this mission will never be planned.
return None
# Create flight plans for the main flights of the package so we can
# determine threats. This is done *after* creating all of the flights
# rather than as each flight is added because the flight plan for
# flights that will rendezvous with their package will be affected by
# the other flights in the package. Escorts will not be able to
# contribute to this.
flight_plan_builder = FlightPlanBuilder(
builder.package, self.coalition, self.theater
)
for flight in builder.package.flights:
with tracer.trace("Flight plan population"):
flight_plan_builder.populate_flight_plan(flight)
needed_escorts = self.check_needed_escorts(builder)
for escort in escorts:
# This list was generated from the not None set, so this should be
# impossible.
assert escort.escort_type is not None
if needed_escorts[escort.escort_type]:
with tracer.trace("Flight planning"):
self.plan_flight(
mission, escort, builder, missing_types, purchase_multiplier
)
# Check again for unavailable aircraft. If the escort was required and
# none were found, scrub the mission.
if missing_types:
self.scrub_mission_missing_aircraft(
mission, builder, missing_types, escorts, purchase_multiplier
)
return None
package = builder.build()
# Add flight plans for escorts.
for flight in package.flights:
if not flight.flight_plan.waypoints:
with tracer.trace("Flight plan population"):
flight_plan_builder.populate_flight_plan(flight)
if package.has_players and self.player_missions_asap:
package.auto_asap = True
package.set_tot_asap()
return package

View File

@ -0,0 +1,11 @@
from collections import Iterator
from game.commander.tasks.primitive.aewc import PlanAewc
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class PlanAewcSupport(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for target in state.aewc_targets:
yield [PlanAewc(target)]

View File

@ -0,0 +1,15 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.primitive.oca import PlanOcaStrike
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
@dataclass(frozen=True)
class AttackAirInfrastructure(CompoundTask[TheaterState]):
aircraft_cold_start: bool
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for garrison in state.oca_targets:
yield [PlanOcaStrike(garrison, self.aircraft_cold_start)]

View File

@ -0,0 +1,15 @@
from collections import Iterator
from game.commander.tasks.primitive.strike import PlanStrike
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class AttackBuildings(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for building in state.strike_targets:
# Ammo depots are targeted based on the needs of the front line by
# ReduceEnemyFrontLineCapacity. No reason to target them before that front
# line is active.
if not building.is_ammo_depot:
yield [PlanStrike(building)]

View File

@ -0,0 +1,12 @@
from collections import Iterator
from game.commander.tasks.primitive.bai import PlanBai
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class AttackGarrisons(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for garrisons in state.enemy_garrisons.values():
for garrison in garrisons.in_priority_order:
yield [PlanBai(garrison)]

View File

@ -0,0 +1,51 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.compound.destroyenemygroundunits import (
DestroyEnemyGroundUnits,
)
from game.commander.tasks.compound.reduceenemyfrontlinecapacity import (
ReduceEnemyFrontLineCapacity,
)
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, ControlPoint
@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.context.coalition.player)]
yield [DestroyEnemyGroundUnits(self.front_line)]
if self.worth_destroying_ammo_depots(state):
yield [ReduceEnemyFrontLineCapacity(self.enemy_cp(state))]
def enemy_cp(self, state: TheaterState) -> ControlPoint:
return self.front_line.control_point_hostile_to(state.context.coalition.player)
def units_deployable(self, state: TheaterState, player: bool) -> int:
cp = self.front_line.control_point_friendly_to(player)
ammo_depots = list(state.ammo_dumps_at(cp))
return cp.deployable_front_line_units_with(len(ammo_depots))
def unit_cap(self, state: TheaterState, player: bool) -> int:
cp = self.front_line.control_point_friendly_to(player)
ammo_depots = list(state.ammo_dumps_at(cp))
return cp.front_line_capacity_with(len(ammo_depots))
def enemy_has_ammo_dumps(self, state: TheaterState) -> bool:
return bool(state.ammo_dumps_at(self.enemy_cp(state)))
def worth_destroying_ammo_depots(self, state: TheaterState) -> bool:
if not self.enemy_has_ammo_dumps(state):
return False
friendly_cap = self.unit_cap(state, state.context.coalition.player)
enemy_deployable = self.units_deployable(state, state.context.coalition.player)
# If the enemy can currently deploy 50% more units than we possibly could, it's
# worth killing an ammo depot.
return enemy_deployable / friendly_cap > 1.5

View File

@ -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)]

View File

@ -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.context.coalition.player)]
yield [RetreatStance(self.front_line, state.context.coalition.player)]
yield [PlanCas(self.front_line)]

View File

@ -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)]

View File

@ -0,0 +1,24 @@
from collections import Iterator
from typing import Union
from game.commander.tasks.primitive.antiship import PlanAntiShip
from game.commander.tasks.primitive.dead import PlanDead
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
class DegradeIads(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for air_defense in state.threatening_air_defenses:
yield [self.plan_against(air_defense)]
for detector in state.detecting_air_defenses:
yield [self.plan_against(detector)]
@staticmethod
def plan_against(
target: Union[IadsGroundObject, NavalGroundObject]
) -> Union[PlanDead, PlanAntiShip]:
if isinstance(target, IadsGroundObject):
return PlanDead(target)
return PlanAntiShip(target)

View File

@ -0,0 +1,19 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.primitive.aggressiveattack import AggressiveAttack
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.context.coalition.player)]
yield [AggressiveAttack(self.front_line, state.context.coalition.player)]
yield [PlanCas(self.front_line)]

View File

@ -0,0 +1,11 @@
from collections import Iterator
from game.commander.tasks.primitive.cas import PlanCas
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class FrontLineDefense(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for front_line in state.vulnerable_front_lines:
yield [PlanCas(front_line)]

View File

@ -0,0 +1,27 @@
from collections import Iterator
from game.commander.tasks.primitive.antishipping import PlanAntiShipping
from game.commander.tasks.primitive.convoyinterdiction import PlanConvoyInterdiction
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class InterdictReinforcements(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
# These will only rarely get planned. When a convoy is travelling multiple legs,
# they're targetable after the first leg. The reason for this is that
# procurement happens *after* mission planning so that the missions that could
# not be filled will guide the procurement process. Procurement is the stage
# that convoys are created (because they're created to move ground units that
# were just purchased), so we haven't created any yet. Any incomplete transfers
# from the previous turn (multi-leg journeys) will still be present though so
# they can be targeted.
#
# Even after this is fixed, the player's convoys that were created through the
# UI will never be targeted on the first turn of their journey because the AI
# stops planning after the start of the turn. We could potentially fix this by
# moving opfor mission planning until the takeoff button is pushed.
for convoy in state.enemy_convoys:
yield [PlanConvoyInterdiction(convoy)]
for ship in state.enemy_shipping:
yield [PlanAntiShipping(ship)]

View File

@ -0,0 +1,34 @@
from collections import Iterator
from dataclasses import dataclass
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.interdictreinforcements import (
InterdictReinforcements,
)
from game.commander.tasks.compound.protectairspace import ProtectAirSpace
from game.commander.tasks.compound.theatersupport import TheaterSupport
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
@dataclass(frozen=True)
class PlanNextAction(CompoundTask[TheaterState]):
aircraft_cold_start: bool
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
yield [TheaterSupport()]
yield [ProtectAirSpace()]
yield [CaptureBases()]
yield [DefendBases()]
yield [InterdictReinforcements()]
yield [AttackGarrisons()]
yield [AttackAirInfrastructure(self.aircraft_cold_start)]
yield [AttackBuildings()]
yield [DegradeIads()]

View File

@ -0,0 +1,12 @@
from collections import Iterator
from game.commander.tasks.primitive.barcap import PlanBarcap
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class ProtectAirSpace(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for cp, needed in state.barcaps_needed.items():
if needed > 0:
yield [PlanBarcap(cp, needed)]

View File

@ -0,0 +1,16 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.primitive.strike import PlanStrike
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
from game.theater import ControlPoint
@dataclass(frozen=True)
class ReduceEnemyFrontLineCapacity(CompoundTask[TheaterState]):
control_point: ControlPoint
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for ammo_dump in state.ammo_dumps_at(self.control_point):
yield [PlanStrike(ammo_dump)]

View File

@ -0,0 +1,11 @@
from collections import Iterator
from game.commander.tasks.primitive.refueling import PlanRefueling
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class PlanRefuelingSupport(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for target in state.refueling_targets:
yield [PlanRefueling(target)]

View File

@ -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()]

View File

@ -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.theater import FrontLine
from gen.ground_forces.combat_stance import CombatStance
if TYPE_CHECKING:
from game.coalition import Coalition
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.context.coalition.player
or state.context.settings.automate_front_line_stance
)
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, coalition: Coalition) -> None:
self.friendly_cp.stances[self.enemy_cp.id] = self.stance

View File

@ -0,0 +1,178 @@
from __future__ import annotations
import itertools
import operator
from abc import abstractmethod
from dataclasses import dataclass, field
from enum import unique, IntEnum, auto
from typing import TYPE_CHECKING, Optional, Generic, TypeVar, Iterator, Union
from game.commander.missionproposals import ProposedFlight, EscortType, ProposedMission
from game.commander.packagefulfiller import PackageFulfiller
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.settings import AutoAtoBehavior
from game.theater import MissionTarget
from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
from game.utils import Distance, meters
from gen import Package
from gen.flights.flight import FlightType
if TYPE_CHECKING:
from game.coalition import Coalition
MissionTargetT = TypeVar("MissionTargetT", bound=MissionTarget)
@unique
class RangeType(IntEnum):
Detection = auto()
Threat = auto()
# TODO: Refactor so that we don't need to call up to the mission planner.
# Bypass type checker due to https://github.com/python/mypy/issues/5374
@dataclass # type: ignore
class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
target: MissionTargetT
flights: list[ProposedFlight] = field(init=False)
package: Optional[Package] = field(init=False, default=None)
def __post_init__(self) -> None:
self.flights = []
self.package = Package(self.target)
def preconditions_met(self, state: TheaterState) -> bool:
if (
state.context.coalition.player
and state.context.settings.auto_ato_behavior is AutoAtoBehavior.Disabled
):
return False
return self.fulfill_mission(state)
def execute(self, coalition: Coalition) -> None:
if self.package is None:
raise RuntimeError("Attempted to execute failed package planning task")
for flight in self.package.flights:
coalition.aircraft_inventory.claim_for_flight(flight)
coalition.ato.add_package(self.package)
@abstractmethod
def propose_flights(self) -> None:
...
def propose_flight(
self,
task: FlightType,
num_aircraft: int,
escort_type: Optional[EscortType] = None,
) -> None:
self.flights.append(ProposedFlight(task, num_aircraft, escort_type))
@property
def asap(self) -> bool:
return False
@property
def purchase_multiplier(self) -> int:
"""The multiplier for aircraft quantity when missions could not be fulfilled.
For missions that do not schedule in rounds like BARCAPs do, this should be one
to ensure that the we only purchase enough aircraft to plan the mission once.
For missions that repeat within the same turn, however, we may need to buy for
the same mission more than once. If three rounds of BARCAP still need to be
fulfilled, this would return 3, and we'd triplicate the purchase order.
There is a small misbehavior here that's not symptomatic for our current mission
planning: multi-round, multi-flight packages will only purchase multiple sets of
aircraft for whatever is unavailable for the *first* failed package. For
example, if we extend this to CAS and have no CAS aircraft but enough TARCAP
aircraft for one round, we'll order CAS for every round but will not order any
TARCAP aircraft, since we can't know that TARCAP aircraft are needed until we
attempt to plan the second mission *without returning the first round aircraft*.
"""
return 1
def fulfill_mission(self, state: TheaterState) -> bool:
self.propose_flights()
fulfiller = PackageFulfiller(
state.context.coalition,
state.context.theater,
state.available_aircraft,
state.context.settings,
)
self.package = fulfiller.plan_mission(
ProposedMission(self.target, self.flights),
self.purchase_multiplier,
state.context.tracer,
)
return self.package is not None
def propose_common_escorts(self) -> None:
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
def iter_iads_ranges(
self, state: TheaterState, range_type: RangeType
) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
target_ranges: list[
tuple[Union[IadsGroundObject, NavalGroundObject], Distance]
] = []
all_iads: Iterator[
Union[IadsGroundObject, NavalGroundObject]
] = itertools.chain(state.enemy_air_defenses, state.enemy_ships)
for target in all_iads:
distance = meters(target.distance_to(self.target))
if range_type is RangeType.Detection:
target_range = target.max_detection_range()
elif range_type is RangeType.Threat:
target_range = target.max_threat_range()
else:
raise ValueError(f"Unknown RangeType: {range_type}")
if not target_range:
continue
# IADS out of range of our target area will have a positive
# distance_to_threat and should be pruned. The rest have a decreasing
# distance_to_threat as overlap increases. The most negative distance has
# the greatest coverage of the target and should be treated as the highest
# priority threat.
distance_to_threat = distance - target_range
if distance_to_threat > meters(0):
continue
target_ranges.append((target, distance_to_threat))
# TODO: Prioritize IADS by vulnerability?
target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
for target, _range in target_ranges:
yield target
def iter_detecting_iads(
self, state: TheaterState
) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
return self.iter_iads_ranges(state, RangeType.Detection)
def iter_iads_threats(
self, state: TheaterState
) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
return self.iter_iads_ranges(state, RangeType.Threat)
def target_area_preconditions_met(
self, state: TheaterState, ignore_iads: bool = False
) -> bool:
"""Checks if the target area has been cleared of threats."""
threatened = False
# Non-blocking, but analyzed so we can pick detectors worth eliminating.
for detector in self.iter_detecting_iads(state):
if detector not in state.detecting_air_defenses:
state.detecting_air_defenses.append(detector)
if not ignore_iads:
for iads_threat in self.iter_iads_threats(state):
threatened = True
if iads_threat not in state.threatening_air_defenses:
state.threatening_air_defenses.append(iads_threat)
return not threatened

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater import MissionTarget
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:
state.aewc_targets.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.AEWC, 1)
@property
def asap(self) -> bool:
# Supports all the early CAP flights, so should be in the air ASAP.
return True

View File

@ -0,0 +1,14 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from gen.ground_forces.combat_stance import CombatStance
class AggressiveAttack(FrontLineStanceTask):
@property
def stance(self) -> CombatStance:
return CombatStance.AGGRESSIVE
@property
def have_sufficient_front_line_advantage(self) -> bool:
return self.ground_force_balance >= 0.8

View File

@ -0,0 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.missionproposals import EscortType
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater.theatergroundobject import NavalGroundObject
from gen.flights.flight import FlightType
@dataclass
class PlanAntiShip(PackagePlanningTask[NavalGroundObject]):
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.threatening_air_defenses:
return False
if not self.target_area_preconditions_met(state, ignore_iads=True):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.eliminate_ship(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.ANTISHIP, 2)
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.transfers import CargoShip
from gen.flights.flight import FlightType
@dataclass
class PlanAntiShipping(PackagePlanningTask[CargoShip]):
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.enemy_shipping:
return False
if not self.target_area_preconditions_met(state):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.enemy_shipping.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.ANTISHIP, 2)
self.propose_common_escorts()

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater.theatergroundobject import VehicleGroupGroundObject
from gen.flights.flight import FlightType
@dataclass
class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]):
def preconditions_met(self, state: TheaterState) -> bool:
if not state.has_garrison(self.target):
return False
if not self.target_area_preconditions_met(state):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.eliminate_garrison(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.BAI, 2)
self.propose_common_escorts()

View File

@ -0,0 +1,28 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater import ControlPoint
from gen.flights.flight import FlightType
@dataclass
class PlanBarcap(PackagePlanningTask[ControlPoint]):
max_orders: int
def preconditions_met(self, state: TheaterState) -> bool:
if not state.barcaps_needed[self.target]:
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.barcaps_needed[self.target] -= 1
def propose_flights(self) -> None:
self.propose_flight(FlightType.BARCAP, 2)
@property
def purchase_multiplier(self) -> int:
return self.max_orders

View File

@ -0,0 +1,28 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from game.commander.theaterstate import TheaterState
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 >= 2.0
def opposing_garrisons_eliminated(self, state: TheaterState) -> bool:
garrisons = state.enemy_garrisons[self.enemy_cp]
return not bool(garrisons.blocking_capture)
def preconditions_met(self, state: TheaterState) -> bool:
if not super().preconditions_met(state):
return False
return self.opposing_garrisons_eliminated(state)
def apply_effects(self, state: TheaterState) -> None:
super().apply_effects(state)
state.active_front_lines.remove(self.front_line)

View File

@ -0,0 +1,23 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater import FrontLine
from gen.flights.flight import FlightType
@dataclass
class PlanCas(PackagePlanningTask[FrontLine]):
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.vulnerable_front_lines:
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.vulnerable_front_lines.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.CAS, 2)
self.propose_flight(FlightType.TARCAP, 2)

View File

@ -0,0 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.transfers import Convoy
from gen.flights.flight import FlightType
@dataclass
class PlanConvoyInterdiction(PackagePlanningTask[Convoy]):
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.enemy_convoys:
return False
if not self.target_area_preconditions_met(state):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.enemy_convoys.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.BAI, 2)
self.propose_common_escorts()

View File

@ -0,0 +1,46 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.missionproposals import EscortType
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater.theatergroundobject import IadsGroundObject
from gen.flights.flight import FlightType
@dataclass
class PlanDead(PackagePlanningTask[IadsGroundObject]):
def preconditions_met(self, state: TheaterState) -> bool:
if (
self.target not in state.threatening_air_defenses
and self.target not in state.detecting_air_defenses
):
return False
if not self.target_area_preconditions_met(state, ignore_iads=True):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.eliminate_air_defense(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.DEAD, 2)
# Only include SEAD against SAMs that still have emitters. No need to
# suppress an EWR, and SEAD isn't useful against a SAM that no longer has a
# working track radar.
#
# For SAMs without track radars and EWRs, we still want a SEAD escort if
# needed.
#
# Note that there is a quirk here: we should potentially be included a SEAD
# escort *and* SEAD when the target is a radar SAM but the flight path is
# also threatened by SAMs. We don't want to include a SEAD escort if the
# package is *only* threatened by the target though. Could be improved, but
# needs a decent refactor to the escort planning to do so.
if self.target.has_live_radar_sam:
self.propose_flight(FlightType.SEAD, 2)
else:
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)

View File

@ -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

View File

@ -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.5

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater import ControlPoint
from gen.flights.flight import FlightType
@dataclass
class PlanOcaStrike(PackagePlanningTask[ControlPoint]):
aircraft_cold_start: bool
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.oca_targets:
return False
if not self.target_area_preconditions_met(state):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.oca_targets.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.OCA_RUNWAY, 2)
if self.aircraft_cold_start:
self.propose_flight(FlightType.OCA_AIRCRAFT, 2)
self.propose_common_escorts()

View File

@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater import MissionTarget
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:
state.refueling_targets.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.REFUELING, 1)

View File

@ -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

View File

@ -0,0 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater.theatergroundobject import TheaterGroundObject
from gen.flights.flight import FlightType
@dataclass
class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]):
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.strike_targets:
return False
if not self.target_area_preconditions_met(state):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.strike_targets.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.STRIKE, 2)
self.propose_common_escorts()

View File

@ -0,0 +1,16 @@
from __future__ import annotations
from abc import abstractmethod
from typing import TYPE_CHECKING
from game.commander.theaterstate import TheaterState
from game.htn import PrimitiveTask
if TYPE_CHECKING:
from game.coalition import Coalition
class TheaterCommanderTask(PrimitiveTask[TheaterState]):
@abstractmethod
def execute(self, coalition: Coalition) -> None:
...

View File

@ -0,0 +1,88 @@
"""The Theater Commander is the highest level campaign AI.
Target selection is performed with a hierarchical-task-network (HTN, linked below).
These work by giving the planner an initial "task" which decomposes into other tasks
until a concrete set of actions is formed. For example, the "capture base" task may
decompose in the following manner:
* Defend
* Reinforce front line
* Set front line stance to defend
* Destroy enemy front line units
* Set front line stance to elimination
* Plan CAS at front line
* Prepare
* Destroy enemy IADS
* Plan DEAD against SAM Armadillo
* ...
* Destroy enemy front line units
* Set front line stance to elimination
* Plan CAS at front line
* Inhibit
* Destroy enemy unit production infrastructure
* Destroy factory at Palmyra
* ...
* Destroy enemy front line units
* Set front line stance to elimination
* Plan CAS at front line
* Attack
* Set front line stance to breakthrough
* Destroy enemy front line units
* Set front line stance to elimination
* Plan CAS at front line
This is not a reflection of the actual task composition but illustrates the capability
of the system. Each task has preconditions which are checked before the task is
decomposed. If preconditions are not met the task is ignored and the next is considered.
For example the task to destroy the factory at Palmyra might be excluded until the air
defenses protecting it are eliminated; or defensive air operations might be excluded if
the enemy does not have sufficient air forces, or if the protected target has sufficient
SAM coverage.
Each action updates the world state, which causes each action to account for the result
of the tasks executed before it. Above, the preconditions for attacking the factory at
Palmyra may not have been met due to the IADS coverage, leading the planning to decide
on an attack against the IADS in the area instead. When planning the next task in the
same turn, the world state will have been updated to account for the (hopefully)
destroyed SAM sites, allowing the planner to choose the mission to attack the factory.
Preconditions can be aware of previous actions as well. A precondition for "Plan CAS at
front line" can be "No CAS missions planned at front line" to avoid over-planning CAS
even though it is a primitive task used by many other tasks.
https://en.wikipedia.org/wiki/Hierarchical_task_network
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from game.commander.tasks.compound.nextaction import PlanNextAction
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
from game.commander.theaterstate import TheaterState
from game.htn import Planner
from game.profiling import MultiEventTracer
if TYPE_CHECKING:
from game import Game
class TheaterCommander(Planner[TheaterState, TheaterCommanderTask]):
def __init__(self, game: Game, player: bool) -> None:
super().__init__(
PlanNextAction(
aircraft_cold_start=game.settings.default_start_type == "Cold"
)
)
self.game = game
self.player = player
def plan_missions(self, tracer: MultiEventTracer) -> None:
state = TheaterState.from_game(self.game, self.player, tracer)
while True:
result = self.plan(state)
if result is None:
# Planned all viable tasks this turn.
return
for task in result.tasks:
task.execute(self.game.coalition_for(self.player))
state = result.end_state

View File

@ -0,0 +1,176 @@
from __future__ import annotations
import dataclasses
import itertools
import math
from collections import Iterator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Union, Optional
from game.commander.garrisons import Garrisons
from game.commander.objectivefinder import ObjectiveFinder
from game.htn import WorldState
from game.inventory import GlobalAircraftInventory
from game.profiling import MultiEventTracer
from game.settings import Settings
from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater
from game.theater.theatergroundobject import (
TheaterGroundObject,
NavalGroundObject,
IadsGroundObject,
VehicleGroupGroundObject,
BuildingGroundObject,
)
from game.threatzones import ThreatZones
from gen.ground_forces.combat_stance import CombatStance
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
from game.transfers import Convoy, CargoShip
@dataclass(frozen=True)
class PersistentContext:
coalition: Coalition
theater: ConflictTheater
settings: Settings
tracer: MultiEventTracer
@dataclass
class TheaterState(WorldState["TheaterState"]):
context: PersistentContext
barcaps_needed: dict[ControlPoint, int]
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]
enemy_air_defenses: list[IadsGroundObject]
threatening_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]]
detecting_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]]
enemy_convoys: list[Convoy]
enemy_shipping: list[CargoShip]
enemy_ships: list[NavalGroundObject]
enemy_garrisons: dict[ControlPoint, Garrisons]
oca_targets: list[ControlPoint]
strike_targets: list[TheaterGroundObject[Any]]
enemy_barcaps: list[ControlPoint]
threat_zones: ThreatZones
available_aircraft: GlobalAircraftInventory
def _rebuild_threat_zones(self) -> None:
"""Recreates the theater's threat zones based on the current planned state."""
self.threat_zones = ThreatZones.for_threats(
self.context.coalition.opponent.doctrine,
barcap_locations=self.enemy_barcaps,
air_defenses=itertools.chain(self.enemy_air_defenses, self.enemy_ships),
)
def eliminate_air_defense(self, target: IadsGroundObject) -> None:
if target in self.threatening_air_defenses:
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)
self._rebuild_threat_zones()
def eliminate_ship(self, target: NavalGroundObject) -> None:
if target in self.threatening_air_defenses:
self.threatening_air_defenses.remove(target)
if target in self.detecting_air_defenses:
self.detecting_air_defenses.remove(target)
self.enemy_ships.remove(target)
self._rebuild_threat_zones()
def has_garrison(self, target: VehicleGroupGroundObject) -> bool:
return target in self.enemy_garrisons[target.control_point]
def eliminate_garrison(self, target: VehicleGroupGroundObject) -> None:
self.enemy_garrisons[target.control_point].eliminate(target)
def ammo_dumps_at(
self, control_point: ControlPoint
) -> Iterator[BuildingGroundObject]:
for target in self.strike_targets:
if target.control_point != control_point:
continue
if target.is_ammo_depot:
assert isinstance(target, BuildingGroundObject)
yield target
def clone(self) -> TheaterState:
# Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly
# expensive.
return TheaterState(
context=self.context,
barcaps_needed=dict(self.barcaps_needed),
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),
enemy_air_defenses=list(self.enemy_air_defenses),
enemy_convoys=list(self.enemy_convoys),
enemy_shipping=list(self.enemy_shipping),
enemy_ships=list(self.enemy_ships),
enemy_garrisons={
cp: dataclasses.replace(g) for cp, g in self.enemy_garrisons.items()
},
oca_targets=list(self.oca_targets),
strike_targets=list(self.strike_targets),
enemy_barcaps=list(self.enemy_barcaps),
threat_zones=self.threat_zones,
available_aircraft=self.available_aircraft.clone(),
# Persistent properties are not copied. These are a way for failed subtasks
# to communicate requirements to other tasks. For example, the task to
# attack enemy garrisons might fail because the target area has IADS
# protection. In that case, the preconditions of PlanBai would fail, but
# would add the IADS that prevented it from being planned to the list of
# IADS threats so that DegradeIads will consider it a threat later.
threatening_air_defenses=self.threatening_air_defenses,
detecting_air_defenses=self.detecting_air_defenses,
)
@classmethod
def from_game(
cls, game: Game, player: bool, tracer: MultiEventTracer
) -> TheaterState:
coalition = game.coalition_for(player)
finder = ObjectiveFinder(game, player)
ordered_capturable_points = finder.prioritized_unisolated_points()
context = PersistentContext(coalition, game.theater, game.settings, 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)
return TheaterState(
context=context,
barcaps_needed={
cp: barcap_rounds 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()},
vulnerable_front_lines=list(finder.front_lines()),
aewc_targets=[finder.farthest_friendly_control_point()],
refueling_targets=[finder.closest_friendly_control_point()],
enemy_air_defenses=list(finder.enemy_air_defenses()),
threatening_air_defenses=[],
detecting_air_defenses=[],
enemy_convoys=list(finder.convoys()),
enemy_shipping=list(finder.cargo_ships()),
enemy_ships=list(finder.enemy_ships()),
enemy_garrisons={
cp: Garrisons.for_control_point(cp) for cp in ordered_capturable_points
},
oca_targets=list(finder.oca_targets(min_aircraft=20)),
strike_targets=list(finder.strike_targets()),
enemy_barcaps=list(game.theater.control_points_for(not player)),
threat_zones=game.threat_zone_for(not player),
available_aircraft=game.aircraft_inventory.clone(),
)

View File

@ -25,6 +25,7 @@ class AlicCodes:
AirDefence.SNR_75V.id: 126, AirDefence.SNR_75V.id: 126,
AirDefence.HQ_7_LN_SP.id: 127, AirDefence.HQ_7_LN_SP.id: 127,
AirDefence.HQ_7_STR_SP.id: 128, AirDefence.HQ_7_STR_SP.id: 128,
AirDefence.RLS_19J6.id: 130,
AirDefence.Roland_ADS.id: 201, AirDefence.Roland_ADS.id: 201,
AirDefence.Patriot_str.id: 202, AirDefence.Patriot_str.id: 202,
AirDefence.Hawk_sr.id: 203, AirDefence.Hawk_sr.id: 203,
@ -33,6 +34,7 @@ class AlicCodes:
AirDefence.Hawk_cwar.id: 206, AirDefence.Hawk_cwar.id: 206,
AirDefence.Gepard.id: 207, AirDefence.Gepard.id: 207,
AirDefence.Vulcan.id: 208, AirDefence.Vulcan.id: 208,
AirDefence.NASAMS_Radar_MPQ64F1.id: 209,
} }
@classmethod @classmethod

View File

@ -1,9 +1,10 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from dcs.task import Reconnaissance from typing import Any
from game.utils import Distance, feet, nautical_miles
from game.data.groundunitclass import GroundUnitClass from game.data.groundunitclass import GroundUnitClass
from game.savecompat import has_save_compat_for
from game.utils import Distance, feet, nautical_miles
@dataclass @dataclass
@ -26,13 +27,26 @@ class Doctrine:
antiship: bool antiship: bool
rendezvous_altitude: Distance rendezvous_altitude: Distance
#: The minimum distance between the departure airfield and the hold point.
hold_distance: Distance hold_distance: Distance
#: The minimum distance between the hold point and the join point.
push_distance: Distance push_distance: Distance
#: The distance between the join point and the ingress point. Only used for the
#: fallback flight plan layout (when the departure airfield is near a threat zone).
join_distance: Distance join_distance: Distance
split_distance: Distance
ingress_egress_distance: Distance #: The maximum distance between the ingress point (beginning of the attack) and
#: target.
max_ingress_distance: Distance
#: The minimum distance between the ingress point (beginning of the attack) and
#: target.
min_ingress_distance: Distance
ingress_altitude: Distance ingress_altitude: Distance
egress_altitude: Distance
min_patrol_altitude: Distance min_patrol_altitude: Distance
max_patrol_altitude: Distance max_patrol_altitude: Distance
@ -65,6 +79,32 @@ class Doctrine:
ground_unit_procurement_ratios: GroundUnitProcurementRatios ground_unit_procurement_ratios: GroundUnitProcurementRatios
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
if "max_ingress_distance" not in state:
try:
state["max_ingress_distance"] = state["ingress_distance"]
del state["ingress_distance"]
except KeyError:
state["max_ingress_distance"] = state["ingress_egress_distance"]
del state["ingress_egress_distance"]
max_ip: Distance = state["max_ingress_distance"]
if "min_ingress_distance" not in state:
if max_ip < nautical_miles(10):
min_ip = nautical_miles(5)
else:
min_ip = nautical_miles(10)
state["min_ingress_distance"] = min_ip
self.__dict__.update(state)
class MissionPlannerMaxRanges:
@has_save_compat_for(5)
def __init__(self) -> None:
pass
MODERN_DOCTRINE = Doctrine( MODERN_DOCTRINE = Doctrine(
cap=True, cap=True,
@ -73,13 +113,12 @@ MODERN_DOCTRINE = Doctrine(
strike=True, strike=True,
antiship=True, antiship=True,
rendezvous_altitude=feet(25000), rendezvous_altitude=feet(25000),
hold_distance=nautical_miles(15), hold_distance=nautical_miles(25),
push_distance=nautical_miles(20), push_distance=nautical_miles(20),
join_distance=nautical_miles(20), join_distance=nautical_miles(20),
split_distance=nautical_miles(20), max_ingress_distance=nautical_miles(45),
ingress_egress_distance=nautical_miles(45), min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(20000), ingress_altitude=feet(20000),
egress_altitude=feet(20000),
min_patrol_altitude=feet(15000), min_patrol_altitude=feet(15000),
max_patrol_altitude=feet(33000), max_patrol_altitude=feet(33000),
pattern_altitude=feet(5000), pattern_altitude=feet(5000),
@ -111,13 +150,12 @@ COLDWAR_DOCTRINE = Doctrine(
strike=True, strike=True,
antiship=True, antiship=True,
rendezvous_altitude=feet(22000), rendezvous_altitude=feet(22000),
hold_distance=nautical_miles(10), hold_distance=nautical_miles(15),
push_distance=nautical_miles(10), push_distance=nautical_miles(10),
join_distance=nautical_miles(10), join_distance=nautical_miles(10),
split_distance=nautical_miles(10), max_ingress_distance=nautical_miles(30),
ingress_egress_distance=nautical_miles(30), min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(18000), ingress_altitude=feet(18000),
egress_altitude=feet(18000),
min_patrol_altitude=feet(10000), min_patrol_altitude=feet(10000),
max_patrol_altitude=feet(24000), max_patrol_altitude=feet(24000),
pattern_altitude=feet(5000), pattern_altitude=feet(5000),
@ -148,14 +186,13 @@ WWII_DOCTRINE = Doctrine(
sead=False, sead=False,
strike=True, strike=True,
antiship=True, antiship=True,
hold_distance=nautical_miles(5), hold_distance=nautical_miles(10),
push_distance=nautical_miles(5), push_distance=nautical_miles(5),
join_distance=nautical_miles(5), join_distance=nautical_miles(5),
split_distance=nautical_miles(5),
rendezvous_altitude=feet(10000), rendezvous_altitude=feet(10000),
ingress_egress_distance=nautical_miles(7), max_ingress_distance=nautical_miles(7),
min_ingress_distance=nautical_miles(5),
ingress_altitude=feet(8000), ingress_altitude=feet(8000),
egress_altitude=feet(8000),
min_patrol_altitude=feet(4000), min_patrol_altitude=feet(4000),
max_patrol_altitude=feet(15000), max_patrol_altitude=feet(15000),
pattern_altitude=feet(5000), pattern_altitude=feet(5000),

File diff suppressed because it is too large Load Diff

View File

@ -29,8 +29,9 @@ from dcs.ships import (
CV_1143_5, CV_1143_5,
) )
from dcs.terrain.terrain import Airport from dcs.terrain.terrain import Airport
from dcs.unit import Ship
from dcs.unitgroup import ShipGroup, StaticGroup from dcs.unitgroup import ShipGroup, StaticGroup
from dcs.unittype import UnitType from dcs.unittype import UnitType, FlyingType, ShipType, VehicleType
from dcs.vehicles import ( from dcs.vehicles import (
vehicle_map, vehicle_map,
) )
@ -255,7 +256,7 @@ Aircraft livery overrides. Syntax as follows:
`Identifier` is aircraft identifier (as used troughout the file) and "LiveryName" (with double quotes) `Identifier` is aircraft identifier (as used troughout the file) and "LiveryName" (with double quotes)
is livery name as found in mission editor. is livery name as found in mission editor.
""" """
PLANE_LIVERY_OVERRIDES = { PLANE_LIVERY_OVERRIDES: dict[Type[FlyingType], str] = {
FA_18C_hornet: "VFA-34", # default livery for the hornet is blue angels one FA_18C_hornet: "VFA-34", # default livery for the hornet is blue angels one
} }
@ -317,6 +318,8 @@ REWARDS = {
"comms": 10, "comms": 10,
"oil": 10, "oil": 10,
"derrick": 8, "derrick": 8,
"village": 0.25,
"allycamp": 0.5,
} }
""" """
@ -326,7 +329,7 @@ REWARDS = {
StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point] StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point]
def upgrade_to_supercarrier(unit, name: str): def upgrade_to_supercarrier(unit: Type[ShipType], name: str) -> Type[ShipType]:
if unit == Stennis: if unit == Stennis:
if name == "CVN-71 Theodore Roosevelt": if name == "CVN-71 Theodore Roosevelt":
return CVN_71 return CVN_71
@ -359,7 +362,15 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]:
return None return None
def country_id_from_name(name): def vehicle_type_from_name(name: str) -> Type[VehicleType]:
return vehicle_map[name]
def ship_type_from_name(name: str) -> Type[ShipType]:
return ship_map[name]
def country_id_from_name(name: str) -> int:
for k, v in country_dict.items(): for k, v in country_dict.items():
if v.name == name: if v.name == name:
return k return k
@ -372,7 +383,7 @@ class DefaultLiveries:
OH_58D.Liveries = DefaultLiveries OH_58D.Liveries = DefaultLiveries
F_16C_50.Liveries = DefaultLiveries F_16C_50.Liveries = DefaultLiveries # type: ignore
P_51D_30_NA.Liveries = DefaultLiveries P_51D_30_NA.Liveries = DefaultLiveries
Ju_88A4.Liveries = DefaultLiveries Ju_88A4.Liveries = DefaultLiveries
B_17G.Liveries = DefaultLiveries B_17G.Liveries = DefaultLiveries

View File

@ -29,7 +29,7 @@ from game.radio.channels import (
ViggenRadioChannelAllocator, ViggenRadioChannelAllocator,
NoOpChannelAllocator, NoOpChannelAllocator,
) )
from game.utils import Distance, Speed, feet, kph, knots from game.utils import Distance, Speed, feet, kph, knots, nautical_miles
if TYPE_CHECKING: if TYPE_CHECKING:
from gen.aircraft import FlightData from gen.aircraft import FlightData
@ -98,7 +98,7 @@ class PatrolConfig:
@classmethod @classmethod
def from_data(cls, data: dict[str, Any]) -> PatrolConfig: def from_data(cls, data: dict[str, Any]) -> PatrolConfig:
altitude = data.get("altitude", None) altitude = data.get("altitude", None)
speed = data.get("altitude", None) speed = data.get("speed", None)
return PatrolConfig( return PatrolConfig(
feet(altitude) if altitude is not None else None, feet(altitude) if altitude is not None else None,
knots(speed) if speed is not None else None, knots(speed) if speed is not None else None,
@ -106,18 +106,55 @@ class PatrolConfig:
@dataclass(frozen=True) @dataclass(frozen=True)
class AircraftType(UnitType[FlyingType]): class FuelConsumption:
#: The estimated taxi fuel requirement, in pounds.
taxi: int
#: The estimated fuel consumption for a takeoff climb, in pounds per nautical mile.
climb: float
#: The estimated fuel consumption for cruising, in pounds per nautical mile.
cruise: float
#: The estimated fuel consumption for combat speeds, in pounds per nautical mile.
combat: float
#: The minimum amount of fuel that the aircraft should land with, in pounds. This is
#: a reserve amount for landing delays or emergencies.
min_safe: int
@classmethod
def from_data(cls, data: dict[str, Any]) -> FuelConsumption:
return FuelConsumption(
int(data["taxi"]),
float(data["climb_ppm"]),
float(data["cruise_ppm"]),
float(data["combat_ppm"]),
int(data["min_safe"]),
)
# TODO: Split into PlaneType and HelicopterType?
@dataclass(frozen=True)
class AircraftType(UnitType[Type[FlyingType]]):
carrier_capable: bool carrier_capable: bool
lha_capable: bool lha_capable: bool
always_keeps_gun: bool always_keeps_gun: bool
# If true, the aircraft does not use the guns as the last resort weapons, but as a main weapon. # If true, the aircraft does not use the guns as the last resort weapons, but as a
# It'll RTB when it doesn't have gun ammo left. # main weapon. It'll RTB when it doesn't have gun ammo left.
gunfighter: bool gunfighter: bool
max_group_size: int max_group_size: int
patrol_altitude: Optional[Distance] patrol_altitude: Optional[Distance]
patrol_speed: Optional[Speed] patrol_speed: Optional[Speed]
#: The maximum range between the origin airfield and the target for which the auto-
#: planner will consider this aircraft usable for a mission.
max_mission_range: Distance
fuel_consumption: Optional[FuelConsumption]
intra_flight_radio: Optional[Radio] intra_flight_radio: Optional[Radio]
channel_allocator: Optional[RadioChannelAllocator] channel_allocator: Optional[RadioChannelAllocator]
channel_namer: Type[ChannelNamer] channel_namer: Type[ChannelNamer]
@ -147,13 +184,52 @@ class AircraftType(UnitType[FlyingType]):
def max_speed(self) -> Speed: def max_speed(self) -> Speed:
return kph(self.dcs_unit_type.max_speed) return kph(self.dcs_unit_type.max_speed)
@property
def preferred_patrol_altitude(self) -> Distance:
if self.patrol_altitude:
return self.patrol_altitude
else:
# Estimate based on max speed.
# Aircaft with max speed 600 kph will prefer patrol at 10 000 ft
# Aircraft with max speed 2800 kph will prefer pratrol at 33 000 ft
altitude_for_lowest_speed = feet(10 * 1000)
altitude_for_highest_speed = feet(33 * 1000)
lowest_speed = kph(600)
highest_speed = kph(2800)
factor = (self.max_speed - lowest_speed).kph / (
highest_speed - lowest_speed
).kph
altitude = (
altitude_for_lowest_speed
+ (altitude_for_highest_speed - altitude_for_lowest_speed) * factor
)
logging.debug(
f"Preferred patrol altitude for {self.dcs_unit_type.id}: {altitude.feet}"
)
rounded_altitude = feet(round(1000 * round(altitude.feet / 1000)))
return max(
altitude_for_lowest_speed,
min(altitude_for_highest_speed, rounded_altitude),
)
def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency: def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
from gen.radios import ChannelInUseError, MHz from gen.radios import ChannelInUseError, kHz
if self.intra_flight_radio is not None: if self.intra_flight_radio is not None:
return radio_registry.alloc_for_radio(self.intra_flight_radio) return radio_registry.alloc_for_radio(self.intra_flight_radio)
freq = MHz(self.dcs_unit_type.radio_frequency) # The default radio frequency is set in megahertz. For some aircraft, it is a
# floating point value. For all current aircraft, adjusting to kilohertz will be
# sufficient to convert to an integer.
in_khz = float(self.dcs_unit_type.radio_frequency) * 1000
if not in_khz.is_integer():
logging.warning(
f"Found unexpected sub-kHz default radio for {self}: {in_khz} kHz. "
"Truncating to integer. The truncated frequency may not be valid for "
"the aircraft."
)
freq = kHz(int(in_khz))
try: try:
radio_registry.reserve(freq) radio_registry.reserve(freq)
except ChannelInUseError: except ChannelInUseError:
@ -222,6 +298,25 @@ class AircraftType(UnitType[FlyingType]):
radio_config = RadioConfig.from_data(data.get("radios", {})) radio_config = RadioConfig.from_data(data.get("radios", {}))
patrol_config = PatrolConfig.from_data(data.get("patrol", {})) patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
try:
mission_range = nautical_miles(int(data["max_range"]))
except (KeyError, ValueError):
mission_range = (
nautical_miles(50) if aircraft.helicopter else nautical_miles(150)
)
logging.warning(
f"{aircraft.id} does not specify a max_range. Defaulting to "
f"{mission_range.nautical_miles}NM"
)
fuel_data = data.get("fuel")
if fuel_data is not None:
fuel_consumption: Optional[FuelConsumption] = FuelConsumption.from_data(
fuel_data
)
else:
fuel_consumption = None
try: try:
introduction = data["introduced"] introduction = data["introduced"]
if introduction is None: if introduction is None:
@ -233,7 +328,10 @@ class AircraftType(UnitType[FlyingType]):
yield AircraftType( yield AircraftType(
dcs_unit_type=aircraft, dcs_unit_type=aircraft,
name=variant, name=variant,
description=data.get("description", "No data."), description=data.get(
"description",
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
),
year_introduced=introduction, year_introduced=introduction,
country_of_origin=data.get("origin", "No data."), country_of_origin=data.get("origin", "No data."),
manufacturer=data.get("manufacturer", "No data."), manufacturer=data.get("manufacturer", "No data."),
@ -246,6 +344,8 @@ class AircraftType(UnitType[FlyingType]):
max_group_size=data.get("max_group_size", aircraft.group_size_max), max_group_size=data.get("max_group_size", aircraft.group_size_max),
patrol_altitude=patrol_config.altitude, patrol_altitude=patrol_config.altitude,
patrol_speed=patrol_config.speed, patrol_speed=patrol_config.speed,
max_mission_range=mission_range,
fuel_consumption=fuel_consumption,
intra_flight_radio=radio_config.intra_flight, intra_flight_radio=radio_config.intra_flight,
channel_allocator=radio_config.channel_allocator, channel_allocator=radio_config.channel_allocator,
channel_namer=radio_config.channel_namer, channel_namer=radio_config.channel_namer,

View File

@ -15,7 +15,7 @@ from game.dcs.unittype import UnitType
@dataclass(frozen=True) @dataclass(frozen=True)
class GroundUnitType(UnitType[VehicleType]): class GroundUnitType(UnitType[Type[VehicleType]]):
unit_class: Optional[GroundUnitClass] unit_class: Optional[GroundUnitClass]
spawn_weight: int spawn_weight: int
@ -88,7 +88,10 @@ class GroundUnitType(UnitType[VehicleType]):
unit_class=unit_class, unit_class=unit_class,
spawn_weight=data.get("spawn_weight", 0), spawn_weight=data.get("spawn_weight", 0),
name=variant, name=variant,
description=data.get("description", "No data."), description=data.get(
"description",
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
),
year_introduced=introduction, year_introduced=introduction,
country_of_origin=data.get("origin", "No data."), country_of_origin=data.get("origin", "No data."),
manufacturer=data.get("manufacturer", "No data."), manufacturer=data.get("manufacturer", "No data."),

View File

@ -4,12 +4,12 @@ from typing import TypeVar, Generic, Type
from dcs.unittype import UnitType as DcsUnitType from dcs.unittype import UnitType as DcsUnitType
DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=DcsUnitType) DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType])
@dataclass(frozen=True) @dataclass(frozen=True)
class UnitType(Generic[DcsUnitTypeT]): class UnitType(Generic[DcsUnitTypeT]):
dcs_unit_type: Type[DcsUnitTypeT] dcs_unit_type: DcsUnitTypeT
name: str name: str
description: str description: str
year_introduced: str year_introduced: str

View File

@ -15,9 +15,9 @@ from typing import (
Iterator, Iterator,
List, List,
TYPE_CHECKING, TYPE_CHECKING,
Union,
) )
from game import db
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.theater import Airfield, ControlPoint from game.theater import Airfield, ControlPoint
@ -77,8 +77,8 @@ class GroundLosses:
player_airlifts: List[AirliftUnits] = field(default_factory=list) player_airlifts: List[AirliftUnits] = field(default_factory=list)
enemy_airlifts: List[AirliftUnits] = field(default_factory=list) enemy_airlifts: List[AirliftUnits] = field(default_factory=list)
player_ground_objects: List[GroundObjectUnit] = field(default_factory=list) player_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list) enemy_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
player_buildings: List[Building] = field(default_factory=list) player_buildings: List[Building] = field(default_factory=list)
enemy_buildings: List[Building] = field(default_factory=list) enemy_buildings: List[Building] = field(default_factory=list)
@ -104,8 +104,9 @@ class StateData:
#: Names of vehicle (and ship) units that were killed during the mission. #: Names of vehicle (and ship) units that were killed during the mission.
killed_ground_units: List[str] killed_ground_units: List[str]
#: Names of static units that were destroyed during the mission. #: List of descriptions of destroyed statics. Format of each element is a mapping of
destroyed_statics: List[str] #: the coordinate type ("x", "y", "z", "type", "orientation") to the value.
destroyed_statics: List[dict[str, Union[float, str]]]
#: Mangled names of bases that were captured during the mission. #: Mangled names of bases that were captured during the mission.
base_capture_events: List[str] base_capture_events: List[str]
@ -134,10 +135,8 @@ class Debriefing:
self.game = game self.game = game
self.unit_map = unit_map self.unit_map = unit_map
self.player_country = game.player_country self.player_country = game.blue.country_name
self.enemy_country = game.enemy_country self.enemy_country = game.red.country_name
self.player_country_id = db.country_id_from_name(game.player_country)
self.enemy_country_id = db.country_id_from_name(game.enemy_country)
self.air_losses = self.dead_aircraft() self.air_losses = self.dead_aircraft()
self.ground_losses = self.dead_ground_units() self.ground_losses = self.dead_ground_units()
@ -164,7 +163,7 @@ class Debriefing:
yield from self.ground_losses.enemy_airlifts yield from self.ground_losses.enemy_airlifts
@property @property
def ground_object_losses(self) -> Iterator[GroundObjectUnit]: def ground_object_losses(self) -> Iterator[GroundObjectUnit[Any]]:
yield from self.ground_losses.player_ground_objects yield from self.ground_losses.player_ground_objects
yield from self.ground_losses.enemy_ground_objects yield from self.ground_losses.enemy_ground_objects
@ -370,13 +369,13 @@ class PollDebriefingFileThread(threading.Thread):
self.game = game self.game = game
self.unit_map = unit_map self.unit_map = unit_map
def stop(self): def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
def stopped(self): def stopped(self) -> bool:
return self._stop_event.is_set() return self._stop_event.is_set()
def run(self): def run(self) -> None:
if os.path.isfile("state.json"): if os.path.isfile("state.json"):
last_modified = os.path.getmtime("state.json") last_modified = os.path.getmtime("state.json")
else: else:
@ -401,7 +400,7 @@ class PollDebriefingFileThread(threading.Thread):
def wait_for_debriefing( def wait_for_debriefing(
callback: Callable[[Debriefing], None], game: Game, unit_map callback: Callable[[Debriefing], None], game: Game, unit_map: UnitMap
) -> PollDebriefingFileThread: ) -> PollDebriefingFileThread:
thread = PollDebriefingFileThread(callback, game, unit_map) thread = PollDebriefingFileThread(callback, game, unit_map)
thread.start() thread.start()

View File

@ -1,14 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from .event import Event from .event import Event
if TYPE_CHECKING:
from game.theater import ConflictTheater
class AirWarEvent(Event): class AirWarEvent(Event):
"""Event handler for the air battle""" """Event handler for the air battle"""
def __str__(self): def __str__(self) -> str:
return "AirWar" return "AirWar"

View File

@ -5,7 +5,6 @@ from typing import List, TYPE_CHECKING, Type
from dcs.mapping import Point from dcs.mapping import Point
from dcs.task import Task from dcs.task import Task
from dcs.unittype import VehicleType
from game import persistency from game import persistency
from game.debriefing import AirLosses, Debriefing from game.debriefing import AirLosses, Debriefing
@ -38,13 +37,13 @@ class Event:
def __init__( def __init__(
self, self,
game, game: Game,
from_cp: ControlPoint, from_cp: ControlPoint,
target_cp: ControlPoint, target_cp: ControlPoint,
location: Point, location: Point,
attacker_name: str, attacker_name: str,
defender_name: str, defender_name: str,
): ) -> None:
self.game = game self.game = game
self.from_cp = from_cp self.from_cp = from_cp
self.to_cp = target_cp self.to_cp = target_cp
@ -54,7 +53,7 @@ class Event:
@property @property
def is_player_attacking(self) -> bool: def is_player_attacking(self) -> bool:
return self.attacker_name == self.game.player_faction.name return self.attacker_name == self.game.blue.faction.name
@property @property
def tasks(self) -> List[Type[Task]]: def tasks(self) -> List[Type[Task]]:
@ -115,10 +114,10 @@ class Event:
def complete_aircraft_transfers(self, debriefing: Debriefing) -> None: def complete_aircraft_transfers(self, debriefing: Debriefing) -> None:
self._transfer_aircraft( self._transfer_aircraft(
self.game.blue_ato, debriefing.air_losses, for_player=True self.game.blue.ato, debriefing.air_losses, for_player=True
) )
self._transfer_aircraft( self._transfer_aircraft(
self.game.red_ato, debriefing.air_losses, for_player=False self.game.red.ato, debriefing.air_losses, for_player=False
) )
def commit_air_losses(self, debriefing: Debriefing) -> None: def commit_air_losses(self, debriefing: Debriefing) -> None:
@ -155,8 +154,8 @@ class Event:
pilot.record.missions_flown += 1 pilot.record.missions_flown += 1
def commit_pilot_experience(self) -> None: def commit_pilot_experience(self) -> None:
self._commit_pilot_experience(self.game.blue_ato) self._commit_pilot_experience(self.game.blue.ato)
self._commit_pilot_experience(self.game.red_ato) self._commit_pilot_experience(self.game.red.ato)
@staticmethod @staticmethod
def commit_front_line_losses(debriefing: Debriefing) -> None: def commit_front_line_losses(debriefing: Debriefing) -> None:
@ -220,10 +219,10 @@ class Event:
for loss in debriefing.ground_object_losses: for loss in debriefing.ground_object_losses:
# TODO: This should be stored in the TGO, not in the pydcs Group. # TODO: This should be stored in the TGO, not in the pydcs Group.
if not hasattr(loss.group, "units_losts"): if not hasattr(loss.group, "units_losts"):
loss.group.units_losts = [] loss.group.units_losts = [] # type: ignore
loss.group.units.remove(loss.unit) loss.group.units.remove(loss.unit)
loss.group.units_losts.append(loss.unit) loss.group.units_losts.append(loss.unit) # type: ignore
def commit_building_losses(self, debriefing: Debriefing) -> None: def commit_building_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.building_losses: for loss in debriefing.building_losses:
@ -265,7 +264,7 @@ class Event:
except Exception: except Exception:
logging.exception(f"Could not process base capture {captured}") logging.exception(f"Could not process base capture {captured}")
def commit(self, debriefing: Debriefing): def commit(self, debriefing: Debriefing) -> None:
logging.info("Committing mission results") logging.info("Committing mission results")
self.commit_air_losses(debriefing) self.commit_air_losses(debriefing)
@ -298,15 +297,16 @@ class Event:
delta = 0.0 delta = 0.0
player_won = True player_won = True
status_msg: str = ""
ally_casualties = debriefing.casualty_count(cp) ally_casualties = debriefing.casualty_count(cp)
enemy_casualties = debriefing.casualty_count(enemy_cp) enemy_casualties = debriefing.casualty_count(enemy_cp)
ally_units_alive = cp.base.total_armor ally_units_alive = cp.base.total_armor
enemy_units_alive = enemy_cp.base.total_armor enemy_units_alive = enemy_cp.base.total_armor
print(ally_units_alive) print(f"Remaining allied units: {ally_units_alive}")
print(enemy_units_alive) print(f"Remaining enemy units: {enemy_units_alive}")
print(ally_casualties) print(f"Allied casualties {ally_casualties}")
print(enemy_casualties) print(f"Enemy casualties {enemy_casualties}")
ratio = (1.0 + enemy_casualties) / (1.0 + ally_casualties) ratio = (1.0 + enemy_casualties) / (1.0 + ally_casualties)
@ -319,24 +319,31 @@ class Event:
if ally_units_alive == 0: if ally_units_alive == 0:
player_won = False player_won = False
delta = STRONG_DEFEAT_INFLUENCE delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"No allied units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces suffer a strong defeat."
elif enemy_units_alive == 0: elif enemy_units_alive == 0:
player_won = True player_won = True
delta = STRONG_DEFEAT_INFLUENCE delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"No enemy units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces win a strong victory."
elif cp.stances[enemy_cp.id] == CombatStance.RETREAT: elif cp.stances[enemy_cp.id] == CombatStance.RETREAT:
player_won = False player_won = False
delta = STRONG_DEFEAT_INFLUENCE delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Allied forces are retreating along the {cp.name}-{enemy_cp.name} frontline, suffering a strong defeat."
else: else:
if enemy_casualties > ally_casualties: if enemy_casualties > ally_casualties:
player_won = True player_won = True
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH: if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
delta = STRONG_DEFEAT_INFLUENCE delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Allied forces break through the {cp.name}-{enemy_cp.name} frontline, winning a strong victory"
else: else:
if ratio > 3: if ratio > 3:
delta = STRONG_DEFEAT_INFLUENCE delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Enemy casualties massively outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a strong victory."
elif ratio < 1.5: elif ratio < 1.5:
delta = MINOR_DEFEAT_INFLUENCE delta = MINOR_DEFEAT_INFLUENCE
status_msg = f"Enemy casualties minorly outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a minor victory."
else: else:
delta = DEFEAT_INFLUENCE delta = DEFEAT_INFLUENCE
status_msg = f"Enemy casualties outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces claim a victory."
elif ally_casualties > enemy_casualties: elif ally_casualties > enemy_casualties:
if ( if (
@ -346,54 +353,66 @@ class Event:
# Even with casualties if the enemy is overwhelmed, they are going to lose ground # Even with casualties if the enemy is overwhelmed, they are going to lose ground
player_won = True player_won = True
delta = MINOR_DEFEAT_INFLUENCE delta = MINOR_DEFEAT_INFLUENCE
status_msg = f"Despite suffering losses, allied forces still outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a minor victory."
elif ( elif (
ally_units_alive > 3 * enemy_units_alive ally_units_alive > 3 * enemy_units_alive
and player_aggresive and player_aggresive
): ):
player_won = True player_won = True
delta = STRONG_DEFEAT_INFLUENCE delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Despite suffering losses, allied forces still heavily outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a major victory."
else: else:
# But is the enemy is not outnumbered, we lose # But if the enemy is not outnumbered, we lose
player_won = False player_won = False
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH: if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
delta = STRONG_DEFEAT_INFLUENCE delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces have overextended themselves, suffering a major defeat."
else: else:
delta = STRONG_DEFEAT_INFLUENCE delta = DEFEAT_INFLUENCE
status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces suffer a defeat."
# No progress with defensive strategies # No progress with defensive strategies
if player_won and cp.stances[enemy_cp.id] in [ if player_won and cp.stances[enemy_cp.id] in [
CombatStance.DEFENSIVE, CombatStance.DEFENSIVE,
CombatStance.AMBUSH, CombatStance.AMBUSH,
]: ]:
print("Defensive stance, progress is limited") print(
f"Allied forces have adopted a defensive stance along the {cp.name}-{enemy_cp.name} "
f"frontline, making only limited progress."
)
delta = MINOR_DEFEAT_INFLUENCE delta = MINOR_DEFEAT_INFLUENCE
if player_won: # Handle the case where there are no casualties at all on either side but both sides still have units
print(cp.name + " won ! factor > " + str(delta)) if delta == 0.0:
cp.base.affect_strength(delta) print(status_msg)
enemy_cp.base.affect_strength(-delta)
info = Information( info = Information(
"Frontline Report", "Frontline Report",
"Our ground forces from " f"Our ground forces from {cp.name} reached a stalemate with enemy forces from {enemy_cp.name}.",
+ cp.name
+ " are making progress toward "
+ enemy_cp.name,
self.game.turn, self.game.turn,
) )
self.game.informations.append(info) self.game.informations.append(info)
else: else:
print(cp.name + " lost ! factor > " + str(delta)) if player_won:
enemy_cp.base.affect_strength(delta) print(status_msg)
cp.base.affect_strength(-delta) cp.base.affect_strength(delta)
info = Information( enemy_cp.base.affect_strength(-delta)
"Frontline Report", info = Information(
"Our ground forces from " "Frontline Report",
+ cp.name f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}",
+ " are losing ground against the enemy forces from " self.game.turn,
+ enemy_cp.name, )
self.game.turn, self.game.informations.append(info)
) else:
self.game.informations.append(info) print(status_msg)
enemy_cp.base.affect_strength(delta)
cp.base.affect_strength(-delta)
info = Information(
"Frontline Report",
f"Our ground forces from {cp.name} are losing ground against the enemy forces from "
f"{enemy_cp.name}. {status_msg}",
self.game.turn,
)
self.game.informations.append(info)
def redeploy_units(self, cp: ControlPoint) -> None: def redeploy_units(self, cp: ControlPoint) -> None:
""" " """ "

View File

@ -8,5 +8,5 @@ class FrontlineAttackEvent(Event):
future unique Event handling future unique Event handling
""" """
def __str__(self): def __str__(self) -> str:
return "Frontline attack" return "Frontline attack"

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import itertools import itertools
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional, Dict, Type, List, Any, Iterator from typing import Optional, Dict, Type, List, Any, Iterator, TYPE_CHECKING
import dcs import dcs
from dcs.countries import country_dict from dcs.countries import country_dict
@ -25,6 +25,9 @@ from game.data.groundunitclass import GroundUnitClass
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
if TYPE_CHECKING:
from game.theater.start_generator import ModSettings
@dataclass @dataclass
class Faction: class Faction:
@ -81,10 +84,10 @@ class Faction:
requirements: Dict[str, str] = field(default_factory=dict) requirements: Dict[str, str] = field(default_factory=dict)
# possible aircraft carrier units # possible aircraft carrier units
aircraft_carrier: List[Type[UnitType]] = field(default_factory=list) aircraft_carrier: List[Type[ShipType]] = field(default_factory=list)
# possible helicopter carrier units # possible helicopter carrier units
helicopter_carrier: List[Type[UnitType]] = field(default_factory=list) helicopter_carrier: List[Type[ShipType]] = field(default_factory=list)
# Possible carrier names # Possible carrier names
carrier_names: List[str] = field(default_factory=list) carrier_names: List[str] = field(default_factory=list)
@ -257,7 +260,7 @@ class Faction:
if unit.unit_class is unit_class: if unit.unit_class is unit_class:
yield unit yield unit
def apply_mod_settings(self, mod_settings) -> Faction: def apply_mod_settings(self, mod_settings: ModSettings) -> Faction:
# aircraft # aircraft
if not mod_settings.a4_skyhawk: if not mod_settings.a4_skyhawk:
self.remove_aircraft("A-4E-C") self.remove_aircraft("A-4E-C")
@ -319,17 +322,17 @@ class Faction:
self.remove_air_defenses("KS19Generator") self.remove_air_defenses("KS19Generator")
return self return self
def remove_aircraft(self, name): def remove_aircraft(self, name: str) -> None:
for i in self.aircrafts: for i in self.aircrafts:
if i.dcs_unit_type.id == name: if i.dcs_unit_type.id == name:
self.aircrafts.remove(i) self.aircrafts.remove(i)
def remove_air_defenses(self, name): def remove_air_defenses(self, name: str) -> None:
for i in self.air_defenses: for i in self.air_defenses:
if i == name: if i == name:
self.air_defenses.remove(i) self.air_defenses.remove(i)
def remove_vehicle(self, name): def remove_vehicle(self, name: str) -> None:
for i in self.frontline_units: for i in self.frontline_units:
if i.dcs_unit_type.id == name: if i.dcs_unit_type.id == name:
self.frontline_units.remove(i) self.frontline_units.remove(i)
@ -342,7 +345,7 @@ def load_ship(name: str) -> Optional[Type[ShipType]]:
return None return None
def load_all_ships(data) -> List[Type[ShipType]]: def load_all_ships(data: list[str]) -> List[Type[ShipType]]:
items = [] items = []
for name in data: for name in data:
item = load_ship(name) item = load_ship(name)

View File

@ -0,0 +1,3 @@
from .holdzonegeometry import HoldZoneGeometry
from .ipzonegeometry import IpZoneGeometry
from .joinzonegeometry import JoinZoneGeometry

View File

@ -0,0 +1,108 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import shapely.ops
from dcs import Point
from shapely.geometry import Point as ShapelyPoint, Polygon, MultiPolygon
from game.theater import ConflictTheater
from game.utils import nautical_miles
if TYPE_CHECKING:
from game.coalition import Coalition
class HoldZoneGeometry:
"""Defines the zones used for finding optimal hold point placement.
The zones themselves are stored in the class rather than just the resulting hold
point so that the zones can be drawn in the map for debugging purposes.
"""
def __init__(
self,
target: Point,
home: Point,
ip: Point,
join: Point,
coalition: Coalition,
theater: ConflictTheater,
) -> None:
# Hold points are placed one of two ways. Either approach guarantees:
#
# * Safe hold point.
# * Minimum distance to the join point.
# * Not closer to the target than the join point.
#
# 1. As near the join point as possible with a specific distance from the
# departure airfield. This prevents loitering directly above the airfield but
# also keeps the hold point close to the departure airfield.
#
# 2. Alternatively, if the entire home zone is excluded by the above criteria,
# as neat the departure airfield as possible within a minimum distance from
# the join point, with a restricted turn angle at the join point. This
# handles the case where we need to backtrack from the departure airfield and
# the join point to place the hold point, but the turn angle limit restricts
# the maximum distance of the backtrack while maintaining the direction of
# the flight plan.
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
self.join = ShapelyPoint(join.x, join.y)
self.join_bubble = self.join.buffer(coalition.doctrine.push_distance.meters)
join_to_target_distance = join.distance_to_point(target)
self.target_bubble = ShapelyPoint(target.x, target.y).buffer(
join_to_target_distance
)
self.home_bubble = self.home.buffer(coalition.doctrine.hold_distance.meters)
excluded_zones = shapely.ops.unary_union(
[self.join_bubble, self.target_bubble, self.threat_zone]
)
if not isinstance(excluded_zones, MultiPolygon):
excluded_zones = MultiPolygon([excluded_zones])
self.excluded_zones = excluded_zones
join_heading = ip.heading_between_point(join)
# Arbitrarily large since this is later constrained by the map boundary, and
# we'll be picking a location close to the IP anyway. Just used to avoid real
# distance calculations to project to the map edge.
large_distance = nautical_miles(400).meters
turn_limit = 40
join_limit_ccw = join.point_from_heading(
join_heading - turn_limit, large_distance
)
join_limit_cw = join.point_from_heading(
join_heading + turn_limit, large_distance
)
join_direction_limit_wedge = Polygon(
[
(join.x, join.y),
(join_limit_ccw.x, join_limit_ccw.y),
(join_limit_cw.x, join_limit_cw.y),
]
)
permissible_zones = (
coalition.nav_mesh.map_bounds(theater)
.intersection(join_direction_limit_wedge)
.difference(self.excluded_zones)
.difference(self.home_bubble)
)
if not isinstance(permissible_zones, MultiPolygon):
permissible_zones = MultiPolygon([permissible_zones])
self.permissible_zones = permissible_zones
self.preferred_lines = self.home_bubble.boundary.difference(self.excluded_zones)
def find_best_hold_point(self) -> Point:
if self.preferred_lines.is_empty:
hold, _ = shapely.ops.nearest_points(self.permissible_zones, self.home)
else:
hold, _ = shapely.ops.nearest_points(self.preferred_lines, self.join)
return Point(hold.x, hold.y)

View File

@ -0,0 +1,118 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import shapely.ops
from dcs import Point
from shapely.geometry import Point as ShapelyPoint, MultiPolygon
from game.utils import nautical_miles, meters
if TYPE_CHECKING:
from game.coalition import Coalition
class IpZoneGeometry:
"""Defines the zones used for finding optimal IP placement.
The zones themselves are stored in the class rather than just the resulting IP so
that the zones can be drawn in the map for debugging purposes.
"""
def __init__(
self,
target: Point,
home: Point,
coalition: Coalition,
) -> None:
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
max_ip_distance = coalition.doctrine.max_ingress_distance
min_ip_distance = coalition.doctrine.min_ingress_distance
# The minimum distance between the home location and the IP.
min_distance_from_home = nautical_miles(5)
# The distance that is expected to be needed between the beginning of the attack
# and weapon release. This buffers the threat zone to give a 5nm window between
# the edge of the "safe" zone and the actual threat so that "safe" IPs are less
# likely to end up with the attacker entering a threatened area.
attack_distance_buffer = nautical_miles(5)
home_threatened = coalition.opponent.threat_zone.threatened(home)
shapely_target = ShapelyPoint(target.x, target.y)
home_to_target_distance = meters(home.distance_to_point(target))
self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference(
self.home.buffer(min_distance_from_home.meters)
)
# If the home zone is not threatened and home is within LAR, constrain the max
# range to the home-to-target distance to prevent excessive backtracking.
#
# If the home zone *is* threatened, we need to back out of the zone to
# rendezvous anyway.
if not home_threatened and (
min_ip_distance < home_to_target_distance < max_ip_distance
):
max_ip_distance = home_to_target_distance
max_ip_bubble = shapely_target.buffer(max_ip_distance.meters)
min_ip_bubble = shapely_target.buffer(min_ip_distance.meters)
self.ip_bubble = max_ip_bubble.difference(min_ip_bubble)
# The intersection of the home bubble and IP bubble will be all the points that
# are within the valid IP range that are not farther from home than the target
# is. However, if the origin airfield is threatened but there are safe
# placements for the IP, we should not constrain to the home zone. In this case
# we'll either end up with a safe zone outside the home zone and pick the
# closest point in to to home (minimizing backtracking), or we'll have no safe
# IP anywhere within range of the target, and we'll later pick the IP nearest
# the edge of the threat zone.
if home_threatened:
self.permissible_zone = self.ip_bubble
else:
self.permissible_zone = self.ip_bubble.intersection(self.home_bubble)
if self.permissible_zone.is_empty:
# If home is closer to the target than the min range, there will not be an
# IP solution that's close enough to home, in which case we need to ignore
# the home bubble.
self.permissible_zone = self.ip_bubble
safe_zones = self.permissible_zone.difference(
self.threat_zone.buffer(attack_distance_buffer.meters)
)
if not isinstance(safe_zones, MultiPolygon):
safe_zones = MultiPolygon([safe_zones])
self.safe_zones = safe_zones
def _unsafe_ip(self) -> ShapelyPoint:
unthreatened_home_zone = self.home_bubble.difference(self.threat_zone)
if unthreatened_home_zone.is_empty:
# Nowhere in our home zone is safe. The package will need to exit the
# threatened area to hold and rendezvous. Pick the IP closest to the
# edge of the threat zone.
return shapely.ops.nearest_points(
self.permissible_zone, self.threat_zone.boundary
)[0]
# No safe point in the IP zone, but the home zone is safe. Pick the max-
# distance IP that's closest to the untreatened home zone.
return shapely.ops.nearest_points(
self.permissible_zone, unthreatened_home_zone
)[0]
def _safe_ip(self) -> ShapelyPoint:
# We have a zone of possible IPs that are safe, close enough, and in range. Pick
# the IP in the zone that's closest to the target.
return shapely.ops.nearest_points(self.safe_zones, self.home)[0]
def find_best_ip(self) -> Point:
if self.safe_zones.is_empty:
ip = self._unsafe_ip()
else:
ip = self._safe_ip()
return Point(ip.x, ip.y)

View File

@ -0,0 +1,103 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import shapely.ops
from dcs import Point
from shapely.geometry import (
Point as ShapelyPoint,
Polygon,
MultiPolygon,
MultiLineString,
)
from game.utils import nautical_miles
if TYPE_CHECKING:
from game.coalition import Coalition
class JoinZoneGeometry:
"""Defines the zones used for finding optimal join point placement.
The zones themselves are stored in the class rather than just the resulting join
point so that the zones can be drawn in the map for debugging purposes.
"""
def __init__(
self, target: Point, home: Point, ip: Point, coalition: Coalition
) -> None:
# Normal join placement is based on the path from home to the IP. If no path is
# found it means that the target is on a direct path. In that case we instead
# want to enforce that the join point is:
#
# * Not closer to the target than the IP.
# * Not too close to the home airfield.
# * Not threatened.
# * A minimum distance from the IP.
# * Not too sharp a turn at the ingress point.
self.ip = ShapelyPoint(ip.x, ip.y)
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
self.ip_bubble = self.ip.buffer(coalition.doctrine.join_distance.meters)
ip_distance = ip.distance_to_point(target)
self.target_bubble = ShapelyPoint(target.x, target.y).buffer(ip_distance)
# The minimum distance between the home location and the IP.
min_distance_from_home = nautical_miles(5)
self.home_bubble = self.home.buffer(min_distance_from_home.meters)
excluded_zones = shapely.ops.unary_union(
[self.ip_bubble, self.target_bubble, self.threat_zone]
)
if not isinstance(excluded_zones, MultiPolygon):
excluded_zones = MultiPolygon([excluded_zones])
self.excluded_zones = excluded_zones
ip_heading = target.heading_between_point(ip)
# Arbitrarily large since this is later constrained by the map boundary, and
# we'll be picking a location close to the IP anyway. Just used to avoid real
# distance calculations to project to the map edge.
large_distance = nautical_miles(400).meters
turn_limit = 40
ip_limit_ccw = ip.point_from_heading(ip_heading - turn_limit, large_distance)
ip_limit_cw = ip.point_from_heading(ip_heading + turn_limit, large_distance)
ip_direction_limit_wedge = Polygon(
[
(ip.x, ip.y),
(ip_limit_ccw.x, ip_limit_ccw.y),
(ip_limit_cw.x, ip_limit_cw.y),
]
)
permissible_zones = ip_direction_limit_wedge.difference(
self.excluded_zones
).difference(self.home_bubble)
if permissible_zones.is_empty:
permissible_zones = MultiPolygon([])
if not isinstance(permissible_zones, MultiPolygon):
permissible_zones = MultiPolygon([permissible_zones])
self.permissible_zones = permissible_zones
preferred_lines = ip_direction_limit_wedge.intersection(
self.excluded_zones.boundary
).difference(self.home_bubble)
if preferred_lines.is_empty:
preferred_lines = MultiLineString([])
if not isinstance(preferred_lines, MultiLineString):
preferred_lines = MultiLineString([preferred_lines])
self.preferred_lines = preferred_lines
def find_best_join_point(self) -> Point:
if self.preferred_lines.is_empty:
join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip)
else:
join, _ = shapely.ops.nearest_points(self.preferred_lines, self.home)
return Point(join.x, join.y)

View File

@ -1,48 +1,43 @@
from game.dcs.aircrafttype import AircraftType
import itertools import itertools
import logging import logging
import random import math
import sys from collections import Iterator
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from enum import Enum from enum import Enum
from typing import Any, List from typing import Any, List, Type, Union, cast
from dcs.action import Coalition from dcs.action import Coalition
from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors
from dcs.mapping import Point from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence from dcs.vehicles import AirDefence
from pydcs_extensions.a4ec.a4ec import A_4E_C
from faker import Faker from faker import Faker
from game import db
from game.inventory import GlobalAircraftInventory from game.inventory import GlobalAircraftInventory
from game.models.game_stats import GameStats from game.models.game_stats import GameStats
from game.plugins import LuaPluginManager from game.plugins import LuaPluginManager
from gen import aircraft, naming from gen import naming
from gen.ato import AirTaskingOrder from gen.ato import AirTaskingOrder
from gen.conflictgen import Conflict from gen.conflictgen import Conflict
from gen.flights.ai_flight_planner import CoalitionMissionPlanner
from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
from gen.ground_forces.ai_ground_planner import GroundPlanner from gen.ground_forces.ai_ground_planner import GroundPlanner
from . import persistency from . import persistency
from .coalition import Coalition
from .debriefing import Debriefing from .debriefing import Debriefing
from .event.event import Event from .event.event import Event
from .event.frontlineattack import FrontlineAttackEvent from .event.frontlineattack import FrontlineAttackEvent
from .factions.faction import Faction from .factions.faction import Faction
from .income import Income
from .infos.information import Information from .infos.information import Information
from .navmesh import NavMesh from .navmesh import NavMesh
from .procurement import AircraftProcurementRequest, ProcurementAi from .procurement import AircraftProcurementRequest
from .profiling import logged_duration from .profiling import logged_duration
from .settings import Settings, AutoAtoBehavior from .settings import Settings
from .squadrons import AirWing from .squadrons import AirWing
from .theater import ConflictTheater from .theater import ConflictTheater, ControlPoint
from .theater.bullseye import Bullseye from .theater.bullseye import Bullseye
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
from .threatzones import ThreatZones from .threatzones import ThreatZones
from .transfers import PendingTransfers
from .unitmap import UnitMap from .unitmap import UnitMap
from .weather import Conditions, TimeOfDay from .weather import Conditions, TimeOfDay
@ -100,151 +95,97 @@ class Game:
self.settings = settings self.settings = settings
self.events: List[Event] = [] self.events: List[Event] = []
self.theater = theater self.theater = theater
self.player_faction = player_faction
self.player_country = player_faction.country
self.enemy_faction = enemy_faction
self.enemy_country = enemy_faction.country
# pass_turn() will be called when initialization is complete which will # pass_turn() will be called when initialization is complete which will
# increment this to turn 0 before it reaches the player. # increment this to turn 0 before it reaches the player.
self.turn = -1 self.turn = -1
# NB: This is the *start* date. It is never updated. # NB: This is the *start* date. It is never updated.
self.date = date(start_date.year, start_date.month, start_date.day) self.date = date(start_date.year, start_date.month, start_date.day)
self.game_stats = GameStats() self.game_stats = GameStats()
self.game_stats.update(self) self.notes = ""
self.ground_planners: dict[int, GroundPlanner] = {} self.ground_planners: dict[int, GroundPlanner] = {}
self.informations = [] self.informations = []
self.informations.append(Information("Game Start", "-" * 40, 0)) self.informations.append(Information("Game Start", "-" * 40, 0))
# Culling Zones are for areas around points of interest that contain things we may not wish to cull. # Culling Zones are for areas around points of interest that contain things we may not wish to cull.
self.__culling_zones: List[Point] = [] self.__culling_zones: List[Point] = []
self.__destroyed_units: List[str] = [] self.__destroyed_units: list[dict[str, Union[float, str]]] = []
self.savepath = "" self.savepath = ""
self.budget = player_budget
self.enemy_budget = enemy_budget
self.current_unit_id = 0 self.current_unit_id = 0
self.current_group_id = 0 self.current_group_id = 0
self.name_generator = naming.namegen self.name_generator = naming.namegen
self.conditions = self.generate_conditions() self.conditions = self.generate_conditions()
self.blue_transit_network = TransitNetwork() self.sanitize_sides(player_faction, enemy_faction)
self.red_transit_network = TransitNetwork() self.blue = Coalition(self, player_faction, player_budget, player=True)
self.red = Coalition(self, enemy_faction, enemy_budget, player=False)
self.blue_procurement_requests: List[AircraftProcurementRequest] = [] self.blue.set_opponent(self.red)
self.red_procurement_requests: List[AircraftProcurementRequest] = [] self.red.set_opponent(self.blue)
self.blue_ato = AirTaskingOrder()
self.red_ato = AirTaskingOrder()
self.blue_bullseye = Bullseye(Point(0, 0))
self.red_bullseye = Bullseye(Point(0, 0))
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints) self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
self.transfers = PendingTransfers(self)
self.sanitize_sides()
self.blue_faker = Faker(self.player_faction.locales)
self.red_faker = Faker(self.enemy_faction.locales)
self.blue_air_wing = AirWing(self, player=True)
self.red_air_wing = AirWing(self, player=False)
self.on_load(game_still_initializing=True) self.on_load(game_still_initializing=True)
def __getstate__(self) -> dict[str, Any]:
state = self.__dict__.copy()
# Avoid persisting any volatile types that can be deterministically
# recomputed on load for the sake of save compatibility.
del state["blue_threat_zone"]
del state["red_threat_zone"]
del state["blue_navmesh"]
del state["red_navmesh"]
del state["blue_faker"]
del state["red_faker"]
return state
def __setstate__(self, state: dict[str, Any]) -> None: def __setstate__(self, state: dict[str, Any]) -> None:
self.__dict__.update(state) self.__dict__.update(state)
# Regenerate any state that was not persisted. # Regenerate any state that was not persisted.
self.on_load() self.on_load()
def ato_for(self, player: bool) -> AirTaskingOrder: @property
if player: def coalitions(self) -> Iterator[Coalition]:
return self.blue_ato yield self.blue
return self.red_ato yield self.red
def procurement_requests_for( def ato_for(self, player: bool) -> AirTaskingOrder:
self, player: bool return self.coalition_for(player).ato
) -> List[AircraftProcurementRequest]:
if player:
return self.blue_procurement_requests
return self.red_procurement_requests
def transit_network_for(self, player: bool) -> TransitNetwork: def transit_network_for(self, player: bool) -> TransitNetwork:
if player: return self.coalition_for(player).transit_network
return self.blue_transit_network
return self.red_transit_network
def generate_conditions(self) -> Conditions: def generate_conditions(self) -> Conditions:
return Conditions.generate( return Conditions.generate(
self.theater, self.current_day, self.current_turn_time_of_day, self.settings self.theater, self.current_day, self.current_turn_time_of_day, self.settings
) )
def sanitize_sides(self): @staticmethod
def sanitize_sides(player_faction: Faction, enemy_faction: Faction) -> None:
""" """
Make sure the opposing factions are using different countries Make sure the opposing factions are using different countries
:return: :return:
""" """
if self.player_country == self.enemy_country: if player_faction.country == enemy_faction.country:
if self.player_country == "USA": if player_faction.country == "USA":
self.enemy_country = "USAF Aggressors" enemy_faction.country = "USAF Aggressors"
elif self.player_country == "Russia": elif player_faction.country == "Russia":
self.enemy_country = "USSR" enemy_faction.country = "USSR"
else: else:
self.enemy_country = "Russia" enemy_faction.country = "Russia"
def faction_for(self, player: bool) -> Faction: def faction_for(self, player: bool) -> Faction:
if player: return self.coalition_for(player).faction
return self.player_faction
return self.enemy_faction
def faker_for(self, player: bool) -> Faker: def faker_for(self, player: bool) -> Faker:
if player: return self.coalition_for(player).faker
return self.blue_faker
return self.red_faker
def air_wing_for(self, player: bool) -> AirWing: def air_wing_for(self, player: bool) -> AirWing:
if player: return self.coalition_for(player).air_wing
return self.blue_air_wing
return self.red_air_wing
def country_for(self, player: bool) -> str: def country_for(self, player: bool) -> str:
if player: return self.coalition_for(player).country_name
return self.player_country
return self.enemy_country
def bullseye_for(self, player: bool) -> Bullseye: def bullseye_for(self, player: bool) -> Bullseye:
if player: return self.coalition_for(player).bullseye
return self.blue_bullseye
return self.red_bullseye
def _roll(self, prob, mult): def _generate_player_event(
if self.settings.version == "dev": self, event_class: Type[Event], player_cp: ControlPoint, enemy_cp: ControlPoint
# always generate all events for dev ) -> None:
return 100
else:
return random.randint(1, 100) <= prob * mult
def _generate_player_event(self, event_class, player_cp, enemy_cp):
self.events.append( self.events.append(
event_class( event_class(
self, self,
player_cp, player_cp,
enemy_cp, enemy_cp,
enemy_cp.position, enemy_cp.position,
self.player_faction.name, self.blue.faction.name,
self.enemy_faction.name, self.red.faction.name,
) )
) )
@ -259,7 +200,7 @@ class Game:
else: else:
return USAFAggressors return USAFAggressors
def _generate_events(self): def _generate_events(self) -> None:
for front_line in self.theater.conflicts(): for front_line in self.theater.conflicts():
self._generate_player_event( self._generate_player_event(
FrontlineAttackEvent, FrontlineAttackEvent,
@ -267,27 +208,21 @@ class Game:
front_line.red_cp, front_line.red_cp,
) )
def adjust_budget(self, amount: float, player: bool) -> None: def coalition_for(self, player: bool) -> Coalition:
if player: if player:
self.budget += amount return self.blue
else: return self.red
self.enemy_budget += amount
def process_player_income(self): def adjust_budget(self, amount: float, player: bool) -> None:
self.budget += Income(self, player=True).total self.coalition_for(player).adjust_budget(amount)
def process_enemy_income(self): @staticmethod
# TODO: Clean up save compat. def initiate_event(event: Event) -> UnitMap:
if not hasattr(self, "enemy_budget"):
self.enemy_budget = 0
self.enemy_budget += Income(self, player=False).total
def initiate_event(self, event: Event) -> UnitMap:
# assert event in self.events # assert event in self.events
logging.info("Generating {} (regular)".format(event)) logging.info("Generating {} (regular)".format(event))
return event.generate() return event.generate()
def finish_event(self, event: Event, debriefing: Debriefing): def finish_event(self, event: Event, debriefing: Debriefing) -> None:
logging.info("Finishing event {}".format(event)) logging.info("Finishing event {}".format(event))
event.commit(debriefing) event.commit(debriefing)
@ -296,16 +231,6 @@ class Game:
else: else:
logging.info("finish_event: event not in the events!") logging.info("finish_event: event not in the events!")
def is_player_attack(self, event):
if isinstance(event, Event):
return (
event
and event.attacker_name
and event.attacker_name == self.player_faction.name
)
else:
raise RuntimeError(f"{event} was passed when an Event type was expected")
def on_load(self, game_still_initializing: bool = False) -> None: def on_load(self, game_still_initializing: bool = False) -> None:
if not hasattr(self, "name_generator"): if not hasattr(self, "name_generator"):
self.name_generator = naming.namegen self.name_generator = naming.namegen
@ -320,36 +245,50 @@ class Game:
self.compute_conflicts_position() self.compute_conflicts_position()
if not game_still_initializing: if not game_still_initializing:
self.compute_threat_zones() self.compute_threat_zones()
self.blue_faker = Faker(self.faction_for(player=True).locales)
self.red_faker = Faker(self.faction_for(player=False).locales)
def reset_ato(self) -> None:
self.blue_ato.clear()
self.red_ato.clear()
def finish_turn(self, skipped: bool = False) -> None: def finish_turn(self, skipped: bool = False) -> None:
"""Finalizes the current turn and advances to the next turn.
This handles the turn-end portion of passing a turn. Initialization of the next
turn is handled by `initialize_turn`. These are separate processes because while
turns may be initialized more than once under some circumstances (see the
documentation for `initialize_turn`), `finish_turn` performs the work that
should be guaranteed to happen only once per turn:
* Turn counter increment.
* Delivering units ordered the previous turn.
* Transfer progress.
* Squadron replenishment.
* Income distribution.
* Base strength (front line position) adjustment.
* Weather/time-of-day generation.
Some actions (like transit network assembly) will happen both here and in
`initialize_turn`. We need the network to be up to date so we can account for
base captures when processing the transfers that occurred last turn, but we also
need it to be up to date in the case of a re-initialization in `initialize_turn`
(such as to account for a cheat base capture) so that orders are only placed
where a supply route exists to the destination. This is a relatively cheap
operation so duplicating the effort is not a problem.
Args:
skipped: True if the turn was skipped.
"""
self.informations.append( self.informations.append(
Information("End of turn #" + str(self.turn), "-" * 40, 0) Information("End of turn #" + str(self.turn), "-" * 40, 0)
) )
self.turn += 1 self.turn += 1
# Need to recompute before transfers and deliveries to account for captures. # The coalition-specific turn finalization *must* happen before unit deliveries,
# This happens in in initialize_turn as well, because cheating doesn't advance a # since the coalition-specific finalization handles transit network updates and
# turn but can capture bases so we need to recompute there as well. # transfer processing. If in the other order, units may be delivered to captured
self.compute_transit_networks() # bases, and freshly delivered units will spawn one leg through their journey.
self.blue.end_turn()
self.red.end_turn()
# Must happen *before* unit deliveries are handled, or else new units will spawn
# one hop ahead. ControlPoint.process_turn handles unit deliveries.
self.transfers.perform_transfers()
# Needs to happen *before* planning transfers so we don't cancel them.
self.reset_ato()
for control_point in self.theater.controlpoints: for control_point in self.theater.controlpoints:
control_point.process_turn(self) control_point.process_turn(self)
self.blue_air_wing.replenish()
self.red_air_wing.replenish()
if not skipped: if not skipped:
for cp in self.theater.player_points(): for cp in self.theater.player_points():
cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY) cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY)
@ -360,14 +299,21 @@ class Game:
self.conditions = self.generate_conditions() self.conditions = self.generate_conditions()
self.process_enemy_income()
self.process_player_income()
def begin_turn_0(self) -> None: def begin_turn_0(self) -> None:
"""Initialization for the first turn of the game."""
self.turn = 0 self.turn = 0
self.blue.preinit_turn_0()
self.red.preinit_turn_0()
self.initialize_turn() self.initialize_turn()
def pass_turn(self, no_action: bool = False) -> None: def pass_turn(self, no_action: bool = False) -> None:
"""Ends the current turn and initializes the new turn.
Called both when skipping a turn or by ending the turn as the result of combat.
Args:
no_action: True if the turn was skipped.
"""
logging.info("Pass turn") logging.info("Pass turn")
with logged_duration("Turn finalization"): with logged_duration("Turn finalization"):
self.finish_turn(no_action) self.finish_turn(no_action)
@ -377,7 +323,7 @@ class Game:
# Autosave progress # Autosave progress
persistency.autosave(self) persistency.autosave(self)
def check_win_loss(self): def check_win_loss(self) -> TurnState:
player_airbases = { player_airbases = {
cp for cp in self.theater.player_points() if cp.runway_is_operational() cp for cp in self.theater.player_points() if cp.runway_is_operational()
} }
@ -394,24 +340,50 @@ class Game:
def set_bullseye(self) -> None: def set_bullseye(self) -> None:
player_cp, enemy_cp = self.theater.closest_opposing_control_points() player_cp, enemy_cp = self.theater.closest_opposing_control_points()
self.blue_bullseye = Bullseye(enemy_cp.position) self.blue.bullseye = Bullseye(enemy_cp.position)
self.red_bullseye = Bullseye(player_cp.position) self.red.bullseye = Bullseye(player_cp.position)
def initialize_turn(self) -> None: def initialize_turn(self, for_red: bool = True, for_blue: bool = True) -> None:
"""Performs turn initialization for the specified players.
Turn initialization performs all of the beginning-of-turn actions. *End-of-turn*
processing happens in `pass_turn` (despite the name, it's called both for
skipping the turn and ending the turn after combat).
Special care needs to be taken here because initialization can occur more than
once per turn. A number of events can require re-initializing a turn:
* Cheat capture. Bases changing hands invalidates many missions in both ATOs,
purchase orders, threat zones, transit networks, etc. Practically speaking,
after a base capture the turn needs to be treated as fully new. The game might
even be over after a capture.
* Cheat front line position. CAS missions are no longer in the correct location,
and the ground planner may also need changes.
* Selling/buying units at TGOs. Selling a TGO might leave missions in the ATO
with invalid targets. Buying a new SAM (or even replacing some units in a SAM)
potentially changes the threat zone and may alter mission priorities and
flight planning.
Most of the work is delegated to initialize_turn_for, which handles the
coalition-specific turn initialization. In some cases only one coalition will be
(re-) initialized. This is the case when buying or selling TGO units, since we
don't want to force the player to redo all their planning just because they
repaired a SAM, but should replan opfor when that happens. On the other hand,
base captures are significant enough (and likely enough to be the first thing
the player does in a turn) that we replan blue as well. Front lines are less
impactful but also likely to be early, so they also cause a blue replan.
Args:
for_red: True if opfor should be re-initialized.
for_blue: True if the player coalition should be re-initialized.
"""
self.events = [] self.events = []
self._generate_events() self._generate_events()
self.set_bullseye() self.set_bullseye()
# Update statistics # Update statistics
self.game_stats.update(self) self.game_stats.update(self)
self.blue_air_wing.reset()
self.red_air_wing.reset()
self.aircraft_inventory.reset()
for cp in self.theater.controlpoints:
self.aircraft_inventory.set_from_control_point(cp)
# Check for win or loss condition # Check for win or loss condition
turn_state = self.check_win_loss() turn_state = self.check_win_loss()
if turn_state in (TurnState.LOSS, TurnState.WIN): if turn_state in (TurnState.LOSS, TurnState.WIN):
@ -422,59 +394,26 @@ class Game:
self.compute_conflicts_position() self.compute_conflicts_position()
with logged_duration("Threat zone computation"): with logged_duration("Threat zone computation"):
self.compute_threat_zones() self.compute_threat_zones()
with logged_duration("Transit network identification"):
self.compute_transit_networks() # Plan Coalition specific turn
if for_blue:
self.initialize_turn_for(player=True)
if for_red:
self.initialize_turn_for(player=False)
# Plan GroundWar
self.ground_planners = {} self.ground_planners = {}
self.blue_procurement_requests.clear()
self.red_procurement_requests.clear()
with logged_duration("Procurement of airlift assets"):
self.transfers.order_airlift_assets()
with logged_duration("Transport planning"):
self.transfers.plan_transports()
with logged_duration("Blue mission planning"):
if self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled:
blue_planner = CoalitionMissionPlanner(self, is_player=True)
blue_planner.plan_missions()
with logged_duration("Red mission planning"):
red_planner = CoalitionMissionPlanner(self, is_player=False)
red_planner.plan_missions()
for cp in self.theater.controlpoints: for cp in self.theater.controlpoints:
if cp.has_frontline: if cp.has_frontline:
gplanner = GroundPlanner(cp, self) gplanner = GroundPlanner(cp, self)
gplanner.plan_groundwar() gplanner.plan_groundwar()
self.ground_planners[cp.id] = gplanner self.ground_planners[cp.id] = gplanner
self.plan_procurement() def initialize_turn_for(self, player: bool) -> None:
self.aircraft_inventory.reset(player)
def plan_procurement(self) -> None: for cp in self.theater.control_points_for(player):
# The first turn needs to buy a *lot* of aircraft to fill CAPs, so it self.aircraft_inventory.set_from_control_point(cp)
# gets much more of the budget that turn. Otherwise budget (after self.coalition_for(player).initialize_turn()
# repairs) is split evenly between air and ground. For the default
# starting budget of 2000 this gives 600 to ground forces and 1400 to
# aircraft. After that the budget will be spend proportionally based on how much is already invested
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,
).spend_budget(self.budget)
self.enemy_budget = ProcurementAi(
self,
for_player=False,
faction=self.enemy_faction,
manage_runways=True,
manage_front_line=True,
manage_aircraft=True,
).spend_budget(self.enemy_budget)
def message(self, text: str) -> None: def message(self, text: str) -> None:
self.informations.append(Information(text, turn=self.turn)) self.informations.append(Information(text, turn=self.turn))
@ -487,48 +426,36 @@ class Game:
def current_day(self) -> date: def current_day(self) -> date:
return self.date + timedelta(days=self.turn // 4) return self.date + timedelta(days=self.turn // 4)
def next_unit_id(self): def next_unit_id(self) -> int:
""" """
Next unit id for pre-generated units Next unit id for pre-generated units
""" """
self.current_unit_id += 1 self.current_unit_id += 1
return self.current_unit_id return self.current_unit_id
def next_group_id(self): def next_group_id(self) -> int:
""" """
Next unit id for pre-generated units Next unit id for pre-generated units
""" """
self.current_group_id += 1 self.current_group_id += 1
return self.current_group_id return self.current_group_id
def compute_transit_networks(self) -> None:
self.blue_transit_network = self.compute_transit_network_for(player=True)
self.red_transit_network = self.compute_transit_network_for(player=False)
def compute_transit_network_for(self, player: bool) -> TransitNetwork: def compute_transit_network_for(self, player: bool) -> TransitNetwork:
return TransitNetworkBuilder(self.theater, player).build() return TransitNetworkBuilder(self.theater, player).build()
def compute_threat_zones(self) -> None: def compute_threat_zones(self) -> None:
self.blue_threat_zone = ThreatZones.for_faction(self, player=True) self.blue.compute_threat_zones()
self.red_threat_zone = ThreatZones.for_faction(self, player=False) self.red.compute_threat_zones()
self.blue_navmesh = NavMesh.from_threat_zones( self.blue.compute_nav_meshes()
self.red_threat_zone, self.theater self.red.compute_nav_meshes()
)
self.red_navmesh = NavMesh.from_threat_zones(
self.blue_threat_zone, self.theater
)
def threat_zone_for(self, player: bool) -> ThreatZones: def threat_zone_for(self, player: bool) -> ThreatZones:
if player: return self.coalition_for(player).threat_zone
return self.blue_threat_zone
return self.red_threat_zone
def navmesh_for(self, player: bool) -> NavMesh: def navmesh_for(self, player: bool) -> NavMesh:
if player: return self.coalition_for(player).nav_mesh
return self.blue_navmesh
return self.red_navmesh
def compute_conflicts_position(self): def compute_conflicts_position(self) -> None:
""" """
Compute the current conflict center position(s), mainly used for culling calculation Compute the current conflict center position(s), mainly used for culling calculation
:return: List of points of interests :return: List of points of interests
@ -551,7 +478,7 @@ class Game:
# If there is no conflict take the center point between the two nearest opposing bases # If there is no conflict take the center point between the two nearest opposing bases
if len(zones) == 0: if len(zones) == 0:
cpoint = None cpoint = None
min_distance = sys.maxsize min_distance = math.inf
for cp in self.theater.player_points(): for cp in self.theater.player_points():
for cp2 in self.theater.enemy_points(): for cp2 in self.theater.enemy_points():
d = cp.position.distance_to_point(cp2.position) d = cp.position.distance_to_point(cp2.position)
@ -569,7 +496,7 @@ class Game:
if cpoint is not None: if cpoint is not None:
zones.append(cpoint) zones.append(cpoint)
packages = itertools.chain(self.blue_ato.packages, self.red_ato.packages) packages = itertools.chain(self.blue.ato.packages, self.red.ato.packages)
for package in packages: for package in packages:
if package.primary_task is FlightType.BARCAP: if package.primary_task is FlightType.BARCAP:
# BARCAPs will be planned at most locations on smaller theaters, # BARCAPs will be planned at most locations on smaller theaters,
@ -587,15 +514,15 @@ class Game:
self.__culling_zones = zones self.__culling_zones = zones
def add_destroyed_units(self, data): def add_destroyed_units(self, data: dict[str, Union[float, str]]) -> None:
pos = Point(data["x"], data["z"]) pos = Point(cast(float, data["x"]), cast(float, data["z"]))
if self.theater.is_on_land(pos): if self.theater.is_on_land(pos):
self.__destroyed_units.append(data) self.__destroyed_units.append(data)
def get_destroyed_units(self): def get_destroyed_units(self) -> list[dict[str, Union[float, str]]]:
return self.__destroyed_units return self.__destroyed_units
def position_culled(self, pos): def position_culled(self, pos: Point) -> bool:
""" """
Check if unit can be generated at given position depending on culling performance settings Check if unit can be generated at given position depending on culling performance settings
:param pos: Position you are tryng to spawn stuff at :param pos: Position you are tryng to spawn stuff at
@ -608,38 +535,17 @@ class Game:
return False return False
return True return True
def get_culling_zones(self): def get_culling_zones(self) -> list[Point]:
""" """
Check culling points Check culling points
:return: List of culling zones :return: List of culling zones
""" """
return self.__culling_zones return self.__culling_zones
# 1 = red, 2 = blue def process_win_loss(self, turn_state: TurnState) -> None:
def get_player_coalition_id(self):
return 2
def get_enemy_coalition_id(self):
return 1
def get_player_coalition(self):
return Coalition.Blue
def get_enemy_coalition(self):
return Coalition.Red
def get_player_color(self):
return "blue"
def get_enemy_color(self):
return "red"
def process_win_loss(self, turn_state: TurnState):
if turn_state is TurnState.WIN: if turn_state is TurnState.WIN:
return self.message( self.message(
"Congratulations, you are victorious! Start a new campaign to continue." "Congratulations, you are victorious! Start a new campaign to continue."
) )
elif turn_state is TurnState.LOSS: elif turn_state is TurnState.LOSS:
return self.message( self.message("Game Over, you lose. Start a new campaign to continue.")
"Game Over, you lose. Start a new campaign to continue."
)

127
game/htn.py Normal file
View File

@ -0,0 +1,127 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections import Iterator, deque, Sequence
from dataclasses import dataclass
from typing import Any, Generic, Optional, TypeVar
WorldStateT = TypeVar("WorldStateT", bound="WorldState[Any]")
class WorldState(ABC, Generic[WorldStateT]):
@abstractmethod
def clone(self) -> WorldStateT:
...
class Task(Generic[WorldStateT]):
pass
Method = Sequence[Task[WorldStateT]]
class PrimitiveTask(Task[WorldStateT], Generic[WorldStateT], ABC):
@abstractmethod
def preconditions_met(self, state: WorldStateT) -> bool:
...
@abstractmethod
def apply_effects(self, state: WorldStateT) -> None:
...
class CompoundTask(Task[WorldStateT], Generic[WorldStateT], ABC):
@abstractmethod
def each_valid_method(self, state: WorldStateT) -> Iterator[Method[WorldStateT]]:
...
PrimitiveTaskT = TypeVar("PrimitiveTaskT", bound=PrimitiveTask[Any])
@dataclass
class PlanningState(Generic[WorldStateT, PrimitiveTaskT]):
state: WorldStateT
tasks_to_process: deque[Task[WorldStateT]]
plan: list[PrimitiveTaskT]
methods: Optional[Iterator[Method[WorldStateT]]]
@dataclass(frozen=True)
class PlanningResult(Generic[WorldStateT, PrimitiveTaskT]):
tasks: list[PrimitiveTaskT]
end_state: WorldStateT
class PlanningHistory(Generic[WorldStateT, PrimitiveTaskT]):
def __init__(self) -> None:
self.states: list[PlanningState[WorldStateT, PrimitiveTaskT]] = []
def push(self, planning_state: PlanningState[WorldStateT, PrimitiveTaskT]) -> None:
self.states.append(planning_state)
def pop(self) -> PlanningState[WorldStateT, PrimitiveTaskT]:
return self.states.pop()
class Planner(Generic[WorldStateT, PrimitiveTaskT]):
def __init__(self, main_task: Task[WorldStateT]) -> None:
self.main_task = main_task
def plan(
self, initial_state: WorldStateT
) -> Optional[PlanningResult[WorldStateT, PrimitiveTaskT]]:
planning_state: PlanningState[WorldStateT, PrimitiveTaskT] = PlanningState(
initial_state, deque([self.main_task]), [], None
)
history: PlanningHistory[WorldStateT, PrimitiveTaskT] = PlanningHistory()
while planning_state.tasks_to_process:
task = planning_state.tasks_to_process.popleft()
if isinstance(task, PrimitiveTask):
if task.preconditions_met(planning_state.state):
task.apply_effects(planning_state.state)
# Ignore type erasure. We've already verified that this is a Planner
# with a WorldStateT and a PrimitiveTaskT, so we know that the task
# list is a list of CompoundTask[WorldStateT] and PrimitiveTaskT. We
# could scatter more unions throughout to be more explicit but
# there's no way around the type erasure that mypy uses for
# isinstance.
planning_state.plan.append(task) # type: ignore
else:
planning_state = history.pop()
else:
assert isinstance(task, CompoundTask)
# If the methods field of our current state is not None that means we're
# resuming a prior attempt to execute this task after a subtask of the
# previously selected method failed.
#
# Otherwise this is the first exectution of this task so we need to
# create the generator.
if planning_state.methods is None:
methods = task.each_valid_method(planning_state.state)
else:
methods = planning_state.methods
try:
method = next(methods)
# Push the current node back onto the stack so that we resume
# handling this task when we pop back to this state.
resume_tasks: deque[Task[WorldStateT]] = deque([task])
resume_tasks.extend(planning_state.tasks_to_process)
history.push(
PlanningState(
planning_state.state.clone(),
resume_tasks,
planning_state.plan,
methods,
)
)
planning_state.methods = None
planning_state.tasks_to_process.extendleft(reversed(method))
except StopIteration:
try:
planning_state = history.pop()
except IndexError:
# No valid plan was found.
return None
return PlanningResult(planning_state.plan, planning_state.state)

View File

@ -14,10 +14,10 @@ class BuildingIncome:
name: str name: str
category: str category: str
number: int number: int
income_per_building: int income_per_building: float
@property @property
def income(self) -> int: def income(self) -> float:
return self.number * self.income_per_building return self.number * self.income_per_building

View File

@ -2,13 +2,13 @@ import datetime
class Information: class Information:
def __init__(self, title="", text="", turn=0): def __init__(self, title: str = "", text: str = "", turn: int = 0) -> None:
self.title = title self.title = title
self.text = text self.text = text
self.turn = turn self.turn = turn
self.timestamp = datetime.datetime.now() self.timestamp = datetime.datetime.now()
def __str__(self): def __str__(self) -> str:
return "[{}][{}] {} {}".format( return "[{}][{}] {} {}".format(
self.timestamp.strftime("%Y-%m-%d %H:%M:%S") self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
if self.timestamp is not None if self.timestamp is not None

View File

@ -1,10 +1,8 @@
"""Inventory management APIs.""" """Inventory management APIs."""
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict, Iterator, Iterable
from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING, Type from typing import TYPE_CHECKING
from dcs.unittype import FlyingType
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from gen.flights.flight import Flight from gen.flights.flight import Flight
@ -18,7 +16,12 @@ class ControlPointAircraftInventory:
def __init__(self, control_point: ControlPoint) -> None: def __init__(self, control_point: ControlPoint) -> None:
self.control_point = control_point self.control_point = control_point
self.inventory: Dict[AircraftType, int] = defaultdict(int) self.inventory: dict[AircraftType, int] = defaultdict(int)
def clone(self) -> ControlPointAircraftInventory:
new = ControlPointAircraftInventory(self.control_point)
new.inventory = self.inventory.copy()
return new
def add_aircraft(self, aircraft: AircraftType, count: int) -> None: def add_aircraft(self, aircraft: AircraftType, count: int) -> None:
"""Adds aircraft to the inventory. """Adds aircraft to the inventory.
@ -67,7 +70,7 @@ class ControlPointAircraftInventory:
yield aircraft yield aircraft
@property @property
def all_aircraft(self) -> Iterator[Tuple[AircraftType, int]]: def all_aircraft(self) -> Iterator[tuple[AircraftType, int]]:
"""Iterates over all available aircraft types, including amounts.""" """Iterates over all available aircraft types, including amounts."""
for aircraft, count in self.inventory.items(): for aircraft, count in self.inventory.items():
if count > 0: if count > 0:
@ -82,14 +85,22 @@ class GlobalAircraftInventory:
"""Game-wide aircraft inventory.""" """Game-wide aircraft inventory."""
def __init__(self, control_points: Iterable[ControlPoint]) -> None: def __init__(self, control_points: Iterable[ControlPoint]) -> None:
self.inventories: Dict[ControlPoint, ControlPointAircraftInventory] = { self.inventories: dict[ControlPoint, ControlPointAircraftInventory] = {
cp: ControlPointAircraftInventory(cp) for cp in control_points cp: ControlPointAircraftInventory(cp) for cp in control_points
} }
def reset(self) -> None: def clone(self) -> GlobalAircraftInventory:
"""Clears all control points and their inventories.""" new = GlobalAircraftInventory([])
new.inventories = {
cp: inventory.clone() for cp, inventory in self.inventories.items()
}
return new
def reset(self, for_player: bool) -> None:
"""Clears the inventory of every control point owned by the given coalition."""
for inventory in self.inventories.values(): for inventory in self.inventories.values():
inventory.clear() if inventory.control_point.captured == for_player:
inventory.clear()
def set_from_control_point(self, control_point: ControlPoint) -> None: def set_from_control_point(self, control_point: ControlPoint) -> None:
"""Set the control point's aircraft inventory. """Set the control point's aircraft inventory.
@ -110,7 +121,7 @@ class GlobalAircraftInventory:
@property @property
def available_types_for_player(self) -> Iterator[AircraftType]: def available_types_for_player(self) -> Iterator[AircraftType]:
"""Iterates over all aircraft types available to the player.""" """Iterates over all aircraft types available to the player."""
seen: Set[AircraftType] = set() seen: set[AircraftType] = set()
for control_point, inventory in self.inventories.items(): for control_point, inventory in self.inventories.items():
if control_point.captured: if control_point.captured:
for aircraft in inventory.types_available: for aircraft in inventory.types_available:

View File

@ -1,13 +0,0 @@
class DestroyedUnit:
"""
Store info about a destroyed unit
"""
x: int
y: int
name: str
def __init__(self, x, y, name):
self.x = x
self.y = y
self.name = name

View File

@ -1,4 +1,9 @@
from typing import List from __future__ import annotations
from typing import List, TYPE_CHECKING
if TYPE_CHECKING:
from game import Game
class FactionTurnMetadata: class FactionTurnMetadata:
@ -10,7 +15,7 @@ class FactionTurnMetadata:
vehicles_count: int = 0 vehicles_count: int = 0
sam_count: int = 0 sam_count: int = 0
def __init__(self): def __init__(self) -> None:
self.aircraft_count = 0 self.aircraft_count = 0
self.vehicles_count = 0 self.vehicles_count = 0
self.sam_count = 0 self.sam_count = 0
@ -24,7 +29,7 @@ class GameTurnMetadata:
allied_units: FactionTurnMetadata allied_units: FactionTurnMetadata
enemy_units: FactionTurnMetadata enemy_units: FactionTurnMetadata
def __init__(self): def __init__(self) -> None:
self.allied_units = FactionTurnMetadata() self.allied_units = FactionTurnMetadata()
self.enemy_units = FactionTurnMetadata() self.enemy_units = FactionTurnMetadata()
@ -34,15 +39,19 @@ class GameStats:
Store statistics for the current game Store statistics for the current game
""" """
def __init__(self): def __init__(self) -> None:
self.data_per_turn: List[GameTurnMetadata] = [] self.data_per_turn: List[GameTurnMetadata] = []
def update(self, game): def update(self, game: Game) -> None:
""" """
Save data for current turn Save data for current turn
:param game: Game we want to save the data about :param game: Game we want to save the data about
""" """
# Remove the current turn if its just an update for this turn
if 0 < game.turn < len(self.data_per_turn):
del self.data_per_turn[-1]
turn_data = GameTurnMetadata() turn_data = GameTurnMetadata()
for cp in game.theater.controlpoints: for cp in game.theater.controlpoints:

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Iterable, List, Set, TYPE_CHECKING from typing import List, Set, TYPE_CHECKING, cast
from dcs import Mission from dcs import Mission
from dcs.action import DoScript, DoScriptFile from dcs.action import DoScript, DoScriptFile
@ -16,11 +16,11 @@ from dcs.triggers import TriggerStart
from game.plugins import LuaPluginManager from game.plugins import LuaPluginManager
from game.theater.theatergroundobject import TheaterGroundObject from game.theater.theatergroundobject import TheaterGroundObject
from gen import Conflict, FlightType, VisualGenerator, Bullseye from gen import Conflict, FlightType, VisualGenerator, Bullseye, AirSupport
from gen.aircraft import AircraftConflictGenerator, FlightData from gen.aircraft import AircraftConflictGenerator, FlightData
from gen.airfields import AIRFIELD_DATA from gen.airfields import AIRFIELD_DATA
from gen.airsupportgen import AirSupport, AirSupportConflictGenerator from gen.airsupportgen import AirSupportConflictGenerator
from gen.armor import GroundConflictGenerator, JtacInfo from gen.armor import GroundConflictGenerator
from gen.beacons import load_beacons_for_terrain from gen.beacons import load_beacons_for_terrain
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
from gen.cargoshipgen import CargoShipGenerator from gen.cargoshipgen import CargoShipGenerator
@ -29,6 +29,7 @@ from gen.environmentgen import EnvironmentGenerator
from gen.forcedoptionsgen import ForcedOptionsGenerator from gen.forcedoptionsgen import ForcedOptionsGenerator
from gen.groundobjectsgen import GroundObjectsGenerator from gen.groundobjectsgen import GroundObjectsGenerator
from gen.kneeboard import KneeboardGenerator from gen.kneeboard import KneeboardGenerator
from gen.lasercoderegistry import LaserCodeRegistry
from gen.naming import namegen from gen.naming import namegen
from gen.radios import RadioFrequency, RadioRegistry from gen.radios import RadioFrequency, RadioRegistry
from gen.tacan import TacanRegistry from gen.tacan import TacanRegistry
@ -50,6 +51,7 @@ class Operation:
groundobjectgen: GroundObjectsGenerator groundobjectgen: GroundObjectsGenerator
radio_registry: RadioRegistry radio_registry: RadioRegistry
tacan_registry: TacanRegistry tacan_registry: TacanRegistry
laser_code_registry: LaserCodeRegistry
game: Game game: Game
trigger_radius = TRIGGER_RADIUS_MEDIUM trigger_radius = TRIGGER_RADIUS_MEDIUM
is_quick = None is_quick = None
@ -58,11 +60,11 @@ class Operation:
enemy_awacs_enabled = True enemy_awacs_enabled = True
ca_slots = 1 ca_slots = 1
unit_map: UnitMap unit_map: UnitMap
jtacs: List[JtacInfo] = []
plugin_scripts: List[str] = [] plugin_scripts: List[str] = []
air_support = AirSupport()
@classmethod @classmethod
def prepare(cls, game: Game): def prepare(cls, game: Game) -> None:
with open("resources/default_options.lua", "r") as f: with open("resources/default_options.lua", "r") as f:
options_dict = loads(f.read())["options"] options_dict = loads(f.read())["options"]
cls._set_mission(Mission(game.theater.terrain)) cls._set_mission(Mission(game.theater.terrain))
@ -70,20 +72,6 @@ class Operation:
cls._setup_mission_coalitions() cls._setup_mission_coalitions()
cls.current_mission.options.load_from_dict(options_dict) cls.current_mission.options.load_from_dict(options_dict)
@classmethod
def conflicts(cls) -> Iterable[Conflict]:
assert cls.game
for frontline in cls.game.theater.conflicts():
yield Conflict(
cls.game.theater,
frontline,
cls.game.player_faction.name,
cls.game.enemy_faction.name,
cls.game.player_country,
cls.game.enemy_country,
frontline.position,
)
@classmethod @classmethod
def air_conflict(cls) -> Conflict: def air_conflict(cls) -> Conflict:
assert cls.game assert cls.game
@ -95,10 +83,10 @@ class Operation:
return Conflict( return Conflict(
cls.game.theater, cls.game.theater,
FrontLine(player_cp, enemy_cp), FrontLine(player_cp, enemy_cp),
cls.game.player_faction.name, cls.game.blue.faction.name,
cls.game.enemy_faction.name, cls.game.red.faction.name,
cls.game.player_country, cls.current_mission.country(cls.game.blue.country_name),
cls.game.enemy_country, cls.current_mission.country(cls.game.red.country_name),
mid_point, mid_point,
) )
@ -107,20 +95,19 @@ class Operation:
cls.current_mission = mission cls.current_mission = mission
@classmethod @classmethod
def _setup_mission_coalitions(cls): def _setup_mission_coalitions(cls) -> None:
cls.current_mission.coalition["blue"] = Coalition( cls.current_mission.coalition["blue"] = Coalition(
"blue", bullseye=cls.game.blue_bullseye.to_pydcs() "blue", bullseye=cls.game.blue.bullseye.to_pydcs()
) )
cls.current_mission.coalition["red"] = Coalition( cls.current_mission.coalition["red"] = Coalition(
"red", bullseye=cls.game.red_bullseye.to_pydcs() "red", bullseye=cls.game.red.bullseye.to_pydcs()
) )
cls.current_mission.coalition["neutrals"] = Coalition( cls.current_mission.coalition["neutrals"] = Coalition(
"neutrals", bullseye=Bullseye(Point(0, 0)).to_pydcs() "neutrals", bullseye=Bullseye(Point(0, 0)).to_pydcs()
) )
p_country = cls.game.player_country p_country = cls.game.blue.country_name
e_country = cls.game.enemy_country e_country = cls.game.red.country_name
cls.current_mission.coalition["blue"].add_country( cls.current_mission.coalition["blue"].add_country(
country_dict[db.country_id_from_name(p_country)]() country_dict[db.country_id_from_name(p_country)]()
) )
@ -174,10 +161,9 @@ class Operation:
def notify_info_generators( def notify_info_generators(
cls, cls,
groundobjectgen: GroundObjectsGenerator, groundobjectgen: GroundObjectsGenerator,
airsupportgen: AirSupportConflictGenerator, air_support: AirSupport,
jtacs: List[JtacInfo],
airgen: AircraftConflictGenerator, airgen: AircraftConflictGenerator,
): ) -> None:
"""Generates subscribed MissionInfoGenerator objects (currently kneeboards and briefings)""" """Generates subscribed MissionInfoGenerator objects (currently kneeboards and briefings)"""
gens: List[MissionInfoGenerator] = [ gens: List[MissionInfoGenerator] = [
@ -188,15 +174,15 @@ class Operation:
for dynamic_runway in groundobjectgen.runways.values(): for dynamic_runway in groundobjectgen.runways.values():
gen.add_dynamic_runway(dynamic_runway) gen.add_dynamic_runway(dynamic_runway)
for tanker in airsupportgen.air_support.tankers: for tanker in air_support.tankers:
if tanker.blue: if tanker.blue:
gen.add_tanker(tanker) gen.add_tanker(tanker)
for aewc in airsupportgen.air_support.awacs: for aewc in air_support.awacs:
if aewc.blue: if aewc.blue:
gen.add_awacs(aewc) gen.add_awacs(aewc)
for jtac in jtacs: for jtac in air_support.jtacs:
if jtac.blue: if jtac.blue:
gen.add_jtac(jtac) gen.add_jtac(jtac)
@ -221,6 +207,10 @@ class Operation:
for frequency in unique_map_frequencies: for frequency in unique_map_frequencies:
cls.radio_registry.reserve(frequency) cls.radio_registry.reserve(frequency)
@classmethod
def create_laser_code_registry(cls) -> None:
cls.laser_code_registry = LaserCodeRegistry()
@classmethod @classmethod
def assign_channels_to_flights( def assign_channels_to_flights(
cls, flights: List[FlightData], air_support: AirSupport cls, flights: List[FlightData], air_support: AirSupport
@ -265,7 +255,7 @@ class Operation:
# beacon list. # beacon list.
@classmethod @classmethod
def _generate_ground_units(cls): def _generate_ground_units(cls) -> None:
cls.groundobjectgen = GroundObjectsGenerator( cls.groundobjectgen = GroundObjectsGenerator(
cls.current_mission, cls.current_mission,
cls.game, cls.game,
@ -280,18 +270,23 @@ class Operation:
"""Add destroyed units to the Mission""" """Add destroyed units to the Mission"""
for d in cls.game.get_destroyed_units(): for d in cls.game.get_destroyed_units():
try: try:
utype = db.unit_type_from_name(d["type"]) type_name = d["type"]
if not isinstance(type_name, str):
raise TypeError(
"Expected the type of the destroyed static to be a string"
)
utype = db.unit_type_from_name(type_name)
except KeyError: except KeyError:
continue continue
pos = Point(d["x"], d["z"]) pos = Point(cast(float, d["x"]), cast(float, d["z"]))
if ( if (
utype is not None utype is not None
and not cls.game.position_culled(pos) and not cls.game.position_culled(pos)
and cls.game.settings.perf_destroyed_units and cls.game.settings.perf_destroyed_units
): ):
cls.current_mission.static_group( cls.current_mission.static_group(
country=cls.current_mission.country(cls.game.player_country), country=cls.current_mission.country(cls.game.blue.country_name),
name="", name="",
_type=utype, _type=utype,
hidden=True, hidden=True,
@ -303,18 +298,22 @@ class Operation:
@classmethod @classmethod
def generate(cls) -> UnitMap: def generate(cls) -> UnitMap:
"""Build the final Mission to be exported""" """Build the final Mission to be exported"""
cls.air_support = AirSupport()
cls.create_unit_map() cls.create_unit_map()
cls.create_radio_registries() cls.create_radio_registries()
cls.create_laser_code_registry()
# Set mission time and weather conditions. # Set mission time and weather conditions.
EnvironmentGenerator(cls.current_mission, cls.game.conditions).generate() EnvironmentGenerator(cls.current_mission, cls.game.conditions).generate()
cls._generate_ground_units() cls._generate_ground_units()
cls._generate_transports() cls._generate_transports()
cls._generate_destroyed_units() cls._generate_destroyed_units()
# Generate ground conflicts first so the JTACs get the first laser code (1688)
# rather than the first player flight with a TGP.
cls._generate_ground_conflicts()
cls._generate_air_units() cls._generate_air_units()
cls.assign_channels_to_flights( cls.assign_channels_to_flights(
cls.airgen.flights, cls.airsupportgen.air_support cls.airgen.flights, cls.airsupportgen.air_support
) )
cls._generate_ground_conflicts()
# Triggers # Triggers
triggersgen = TriggersGenerator(cls.current_mission, cls.game) triggersgen = TriggersGenerator(cls.current_mission, cls.game)
@ -334,7 +333,7 @@ class Operation:
if cls.game.settings.perf_smoke_gen: if cls.game.settings.perf_smoke_gen:
visualgen.generate() visualgen.generate()
cls.generate_lua(cls.airgen, cls.airsupportgen, cls.jtacs) cls.generate_lua(cls.airgen, cls.air_support)
# Inject Plugins Lua Scripts and data # Inject Plugins Lua Scripts and data
cls.plugin_scripts.clear() cls.plugin_scripts.clear()
@ -346,9 +345,7 @@ class Operation:
cls.assign_channels_to_flights( cls.assign_channels_to_flights(
cls.airgen.flights, cls.airsupportgen.air_support cls.airgen.flights, cls.airsupportgen.air_support
) )
cls.notify_info_generators( cls.notify_info_generators(cls.groundobjectgen, cls.air_support, cls.airgen)
cls.groundobjectgen, cls.airsupportgen, cls.jtacs, cls.airgen
)
cls.reset_naming_ids() cls.reset_naming_ids()
return cls.unit_map return cls.unit_map
@ -364,6 +361,7 @@ class Operation:
cls.game, cls.game,
cls.radio_registry, cls.radio_registry,
cls.tacan_registry, cls.tacan_registry,
cls.air_support,
) )
cls.airsupportgen.generate() cls.airsupportgen.generate()
@ -374,6 +372,7 @@ class Operation:
cls.game, cls.game,
cls.radio_registry, cls.radio_registry,
cls.tacan_registry, cls.tacan_registry,
cls.laser_code_registry,
cls.unit_map, cls.unit_map,
air_support=cls.airsupportgen.air_support, air_support=cls.airsupportgen.air_support,
) )
@ -381,32 +380,31 @@ class Operation:
cls.airgen.clear_parking_slots() cls.airgen.clear_parking_slots()
cls.airgen.generate_flights( cls.airgen.generate_flights(
cls.current_mission.country(cls.game.player_country), cls.current_mission.country(cls.game.blue.country_name),
cls.game.blue_ato, cls.game.blue.ato,
cls.groundobjectgen.runways, cls.groundobjectgen.runways,
) )
cls.airgen.generate_flights( cls.airgen.generate_flights(
cls.current_mission.country(cls.game.enemy_country), cls.current_mission.country(cls.game.red.country_name),
cls.game.red_ato, cls.game.red.ato,
cls.groundobjectgen.runways, cls.groundobjectgen.runways,
) )
cls.airgen.spawn_unused_aircraft( cls.airgen.spawn_unused_aircraft(
cls.current_mission.country(cls.game.player_country), cls.current_mission.country(cls.game.blue.country_name),
cls.current_mission.country(cls.game.enemy_country), cls.current_mission.country(cls.game.red.country_name),
) )
@classmethod @classmethod
def _generate_ground_conflicts(cls) -> None: def _generate_ground_conflicts(cls) -> None:
"""For each frontline in the Operation, generate the ground conflicts and JTACs""" """For each frontline in the Operation, generate the ground conflicts and JTACs"""
cls.jtacs = []
for front_line in cls.game.theater.conflicts(): for front_line in cls.game.theater.conflicts():
player_cp = front_line.blue_cp player_cp = front_line.blue_cp
enemy_cp = front_line.red_cp enemy_cp = front_line.red_cp
conflict = Conflict.frontline_cas_conflict( conflict = Conflict.frontline_cas_conflict(
cls.game.player_faction.name, cls.game.blue.faction.name,
cls.game.enemy_faction.name, cls.game.red.faction.name,
cls.current_mission.country(cls.game.player_country), cls.current_mission.country(cls.game.blue.country_name),
cls.current_mission.country(cls.game.enemy_country), cls.current_mission.country(cls.game.red.country_name),
front_line, front_line,
cls.game.theater, cls.game.theater,
) )
@ -420,10 +418,13 @@ class Operation:
player_gp, player_gp,
enemy_gp, enemy_gp,
player_cp.stances[enemy_cp.id], player_cp.stances[enemy_cp.id],
enemy_cp.stances[player_cp.id],
cls.unit_map, cls.unit_map,
cls.radio_registry,
cls.air_support,
cls.laser_code_registry,
) )
ground_conflict_gen.generate() ground_conflict_gen.generate()
cls.jtacs.extend(ground_conflict_gen.jtacs)
@classmethod @classmethod
def _generate_transports(cls) -> None: def _generate_transports(cls) -> None:
@ -432,15 +433,12 @@ class Operation:
CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate() CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate()
@classmethod @classmethod
def reset_naming_ids(cls): def reset_naming_ids(cls) -> None:
namegen.reset_numbers() namegen.reset_numbers()
@classmethod @classmethod
def generate_lua( def generate_lua(
cls, cls, airgen: AircraftConflictGenerator, air_support: AirSupport
airgen: AircraftConflictGenerator,
airsupportgen: AirSupportConflictGenerator,
jtacs: List[JtacInfo],
) -> None: ) -> None:
# TODO: Refactor this # TODO: Refactor this
luaData = { luaData = {
@ -453,8 +451,8 @@ class Operation:
"BlueAA": {}, "BlueAA": {},
} # type: ignore } # type: ignore
for tanker in airsupportgen.air_support.tankers: for i, tanker in enumerate(air_support.tankers):
luaData["Tankers"][tanker.callsign] = { luaData["Tankers"][i] = {
"dcsGroupName": tanker.group_name, "dcsGroupName": tanker.group_name,
"callsign": tanker.callsign, "callsign": tanker.callsign,
"variant": tanker.variant, "variant": tanker.variant,
@ -462,23 +460,23 @@ class Operation:
"tacan": str(tanker.tacan.number) + tanker.tacan.band.name, "tacan": str(tanker.tacan.number) + tanker.tacan.band.name,
} }
if airsupportgen.air_support.awacs: for i, awacs in enumerate(air_support.awacs):
for awacs in airsupportgen.air_support.awacs: luaData["AWACs"][i] = {
luaData["AWACs"][awacs.callsign] = { "dcsGroupName": awacs.group_name,
"dcsGroupName": awacs.group_name, "callsign": awacs.callsign,
"callsign": awacs.callsign, "radio": awacs.freq.mhz,
"radio": awacs.freq.mhz, }
}
for jtac in jtacs: for i, jtac in enumerate(air_support.jtacs):
luaData["JTACs"][jtac.callsign] = { luaData["JTACs"][i] = {
"dcsGroupName": jtac.group_name, "dcsGroupName": jtac.group_name,
"callsign": jtac.callsign, "callsign": jtac.callsign,
"zone": jtac.region, "zone": jtac.region,
"dcsUnit": jtac.unit_name, "dcsUnit": jtac.unit_name,
"laserCode": jtac.code, "laserCode": jtac.code,
"radio": jtac.freq.mhz,
} }
flight_count = 0
for flight in airgen.flights: for flight in airgen.flights:
if flight.friendly and flight.flight_type in [ if flight.friendly and flight.flight_type in [
FlightType.ANTISHIP, FlightType.ANTISHIP,
@ -499,7 +497,7 @@ class Operation:
elif hasattr(flightTarget, "name"): elif hasattr(flightTarget, "name"):
flightTargetName = flightTarget.name flightTargetName = flightTarget.name
flightTargetType = flightType + " TGT (Airbase)" flightTargetType = flightType + " TGT (Airbase)"
luaData["TargetPoints"][flightTargetName] = { luaData["TargetPoints"][flight_count] = {
"name": flightTargetName, "name": flightTargetName,
"type": flightTargetType, "type": flightTargetType,
"position": { "position": {
@ -507,6 +505,7 @@ class Operation:
"y": flightTarget.position.y, "y": flightTarget.position.y,
}, },
} }
flight_count += 1
for cp in cls.game.theater.controlpoints: for cp in cls.game.theater.controlpoints:
for ground_object in cp.ground_objects: for ground_object in cp.ground_objects:
@ -592,7 +591,8 @@ class Operation:
zone = data["zone"] zone = data["zone"]
laserCode = data["laserCode"] laserCode = data["laserCode"]
dcsUnit = data["dcsUnit"] dcsUnit = data["dcsUnit"]
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n" radio = data["radio"]
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}', radio='{radio}' }}, \n"
lua += "}" lua += "}"
# Process the Target Points # Process the Target Points

23
game/orderedset.py Normal file
View File

@ -0,0 +1,23 @@
from collections import Iterator, Iterable
from typing import Generic, TypeVar, Optional
ValueT = TypeVar("ValueT")
class OrderedSet(Generic[ValueT]):
def __init__(self, initial_data: Optional[Iterable[ValueT]] = None) -> None:
if initial_data is None:
initial_data = []
self._data: dict[ValueT, None] = {v: None for v in initial_data}
def __iter__(self) -> Iterator[ValueT]:
yield from self._data
def __contains__(self, item: ValueT) -> bool:
return item in self._data
def add(self, item: ValueT) -> None:
self._data[item] = None
def clear(self) -> None:
self._data.clear()

View File

@ -1,15 +1,19 @@
from __future__ import annotations
import logging import logging
import os import os
import pickle import pickle
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from game import Game
_dcs_saved_game_folder: Optional[str] = None _dcs_saved_game_folder: Optional[str] = None
def setup(user_folder: str): def setup(user_folder: str) -> None:
global _dcs_saved_game_folder global _dcs_saved_game_folder
_dcs_saved_game_folder = user_folder _dcs_saved_game_folder = user_folder
if not save_dir().exists(): if not save_dir().exists():
@ -38,7 +42,7 @@ def mission_path_for(name: str) -> str:
return os.path.join(base_path(), "Missions", name) return os.path.join(base_path(), "Missions", name)
def load_game(path): def load_game(path: str) -> Optional[Game]:
with open(path, "rb") as f: with open(path, "rb") as f:
try: try:
save = pickle.load(f) save = pickle.load(f)
@ -49,7 +53,7 @@ def load_game(path):
return None return None
def save_game(game) -> bool: def save_game(game: Game) -> bool:
try: try:
with open(_temporary_save_file(), "wb") as f: with open(_temporary_save_file(), "wb") as f:
pickle.dump(game, f) pickle.dump(game, f)
@ -60,7 +64,7 @@ def save_game(game) -> bool:
return False return False
def autosave(game) -> bool: def autosave(game: Game) -> bool:
""" """
Autosave to the autosave location Autosave to the autosave location
:param game: Game to save :param game: Game to save

View File

@ -38,7 +38,7 @@ class PluginSettings:
self.settings = Settings() self.settings = Settings()
self.initialize_settings() self.initialize_settings()
def set_settings(self, settings: Settings): def set_settings(self, settings: Settings) -> None:
self.settings = settings self.settings = settings
self.initialize_settings() self.initialize_settings()
@ -146,7 +146,7 @@ class LuaPlugin(PluginSettings):
return cls(definition) return cls(definition)
def set_settings(self, settings: Settings): def set_settings(self, settings: Settings) -> None:
super().set_settings(settings) super().set_settings(settings)
for option in self.definition.options: for option in self.definition.options:
option.set_settings(self.settings) option.set_settings(self.settings)

View File

@ -1,13 +1,16 @@
from __future__ import annotations
from dcs import Point from dcs import Point
from game.utils import Heading
class PointWithHeading(Point): class PointWithHeading(Point):
def __init__(self): def __init__(self) -> None:
super(PointWithHeading, self).__init__(0, 0) super(PointWithHeading, self).__init__(0, 0)
self.heading = 0 self.heading: Heading = Heading.from_degrees(0)
@staticmethod @staticmethod
def from_point(point: Point, heading: int): def from_point(point: Point, heading: Heading) -> PointWithHeading:
p = PointWithHeading() p = PointWithHeading()
p.x = point.x p.x = point.x
p.y = point.y p.y = point.y

9
game/positioned.py Normal file
View File

@ -0,0 +1,9 @@
from typing import Protocol
from dcs import Point
class Positioned(Protocol):
@property
def position(self) -> Point:
raise NotImplementedError

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import math import math
import random import random
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
from game import db from game import db
@ -11,7 +11,7 @@ from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction from game.factions.faction import Faction
from game.theater import ControlPoint, MissionTarget from game.theater import ControlPoint, MissionTarget
from game.utils import Distance from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
@ -25,15 +25,13 @@ FRONTLINE_RESERVES_FACTOR = 1.3
@dataclass(frozen=True) @dataclass(frozen=True)
class AircraftProcurementRequest: class AircraftProcurementRequest:
near: MissionTarget near: MissionTarget
range: Distance
task_capability: FlightType task_capability: FlightType
number: int number: int
def __str__(self) -> str: def __str__(self) -> str:
task = self.task_capability.value task = self.task_capability.value
distance = self.range.nautical_miles
target = self.near.name target = self.near.name
return f"{self.number} ship {task} within {distance} nm of {target}" return f"{self.number} ship {task} near {target}"
class ProcurementAi: class ProcurementAi:
@ -72,7 +70,9 @@ class ProcurementAi:
return 1 return 1
for cp in self.owned_points: for cp in self.owned_points:
cp_ground_units = cp.allocated_ground_units(self.game.transfers) cp_ground_units = cp.allocated_ground_units(
self.game.coalition_for(self.is_player).transfers
)
armor_investment += cp_ground_units.total_value armor_investment += cp_ground_units.total_value
cp_aircraft = cp.allocated_aircraft(self.game) cp_aircraft = cp.allocated_aircraft(self.game)
aircraft_investment += cp_aircraft.total_value aircraft_investment += cp_aircraft.total_value
@ -209,24 +209,28 @@ class ProcurementAi:
return GroundUnitClass.Tank return GroundUnitClass.Tank
return worst_balanced return worst_balanced
def _affordable_aircraft_for_task( def affordable_aircraft_for(
self, self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
task: FlightType,
airbase: ControlPoint,
number: int,
max_price: float,
) -> Optional[AircraftType]: ) -> Optional[AircraftType]:
best_choice: Optional[AircraftType] = None best_choice: Optional[AircraftType] = None
for unit in aircraft_for_task(task): for unit in aircraft_for_task(request.task_capability):
if unit not in self.faction.aircrafts: if unit not in self.faction.aircrafts:
continue continue
if unit.price * number > max_price: if unit.price * request.number > budget:
continue continue
if not airbase.can_operate(unit): if not airbase.can_operate(unit):
continue continue
distance_to_target = meters(request.near.distance_to(airbase))
if distance_to_target > unit.max_mission_range:
continue
for squadron in self.air_wing.squadrons_for(unit): for squadron in self.air_wing.squadrons_for(unit):
if task in squadron.auto_assignable_mission_types: if (
squadron.operates_from(airbase)
and request.task_capability
in squadron.auto_assignable_mission_types
):
break break
else: else:
continue continue
@ -239,13 +243,6 @@ class ProcurementAi:
break break
return best_choice return best_choice
def affordable_aircraft_for(
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
) -> Optional[AircraftType]:
return self._affordable_aircraft_for_task(
request.task_capability, airbase, request.number, budget
)
def fulfill_aircraft_request( def fulfill_aircraft_request(
self, request: AircraftProcurementRequest, budget: float self, request: AircraftProcurementRequest, budget: float
) -> Tuple[float, bool]: ) -> Tuple[float, bool]:
@ -265,7 +262,7 @@ class ProcurementAi:
return budget, False return budget, False
def purchase_aircraft(self, budget: float) -> float: def purchase_aircraft(self, budget: float) -> float:
for request in self.game.procurement_requests_for(self.is_player): for request in self.game.coalition_for(self.is_player).procurement_requests:
if not list(self.best_airbases_for(request)): if not list(self.best_airbases_for(request)):
# No airbases in range of this request. Skip it. # No airbases in range of this request. Skip it.
continue continue
@ -291,7 +288,7 @@ class ProcurementAi:
) -> Iterator[ControlPoint]: ) -> Iterator[ControlPoint]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near) distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
threatened = [] threatened = []
for cp in distance_cache.operational_airfields_within(request.range): for cp in distance_cache.operational_airfields:
if not cp.is_friendly(self.is_player): if not cp.is_friendly(self.is_player):
continue continue
if cp.unclaimed_parking(self.game) < request.number: if cp.unclaimed_parking(self.game) < request.number:
@ -316,7 +313,9 @@ class ProcurementAi:
continue continue
purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR
allocated = cp.allocated_ground_units(self.game.transfers) allocated = cp.allocated_ground_units(
self.game.coalition_for(self.is_player).transfers
)
if allocated.total >= purchase_target: if allocated.total >= purchase_target:
# Control point is already sufficiently defended. # Control point is already sufficiently defended.
continue continue
@ -343,7 +342,9 @@ class ProcurementAi:
if not cp.can_recruit_ground_units(self.game): if not cp.can_recruit_ground_units(self.game):
continue continue
allocated = cp.allocated_ground_units(self.game.transfers) allocated = cp.allocated_ground_units(
self.game.coalition_for(self.is_player).transfers
)
if allocated.total >= self.game.settings.reserves_procurement_target: if allocated.total >= self.game.settings.reserves_procurement_target:
continue continue
@ -356,7 +357,9 @@ class ProcurementAi:
def cost_ratio_of_ground_unit( def cost_ratio_of_ground_unit(
self, control_point: ControlPoint, unit_class: GroundUnitClass self, control_point: ControlPoint, unit_class: GroundUnitClass
) -> float: ) -> float:
allocations = control_point.allocated_ground_units(self.game.transfers) allocations = control_point.allocated_ground_units(
self.game.coalition_for(self.is_player).transfers
)
class_cost = 0 class_cost = 0
total_cost = 0 total_cost = 0
for unit_type, count in allocations.all.items(): for unit_type, count in allocations.all.items():

View File

@ -5,7 +5,8 @@ import timeit
from collections import defaultdict from collections import defaultdict
from contextlib import contextmanager from contextlib import contextmanager
from datetime import timedelta from datetime import timedelta
from typing import Iterator from types import TracebackType
from typing import Iterator, Optional, Type
@contextmanager @contextmanager
@ -23,7 +24,12 @@ class MultiEventTracer:
def __enter__(self) -> MultiEventTracer: def __enter__(self) -> MultiEventTracer:
return self return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None: def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
for event, duration in self.events.items(): for event, duration in self.events.items():
logging.debug("%s took %s", event, duration) logging.debug("%s took %s", event, duration)

View File

@ -72,6 +72,9 @@ class CommonRadioChannelAllocator(RadioChannelAllocator):
for awacs in air_support.awacs: for awacs in air_support.awacs:
flight.assign_channel(radio_id, next(channel_alloc), awacs.freq) flight.assign_channel(radio_id, next(channel_alloc), awacs.freq)
for jtac in air_support.jtacs:
flight.assign_channel(radio_id, next(channel_alloc), jtac.freq)
if flight.arrival != flight.departure and flight.arrival.atc is not None: if flight.arrival != flight.departure and flight.arrival.atc is not None:
flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc) flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc)

48
game/savecompat.py Normal file
View File

@ -0,0 +1,48 @@
"""Tools for aiding in save compat removal after compatibility breaks."""
from collections import Callable
from typing import TypeVar
from game.version import MAJOR_VERSION
ReturnT = TypeVar("ReturnT")
class DeprecatedSaveCompatError(RuntimeError):
def __init__(self, function_name: str) -> None:
super().__init__(
f"{function_name} has save compat code for a different major version."
)
def has_save_compat_for(
major: int,
) -> Callable[[Callable[..., ReturnT]], Callable[..., ReturnT]]:
"""Declares a function or method as having save compat code for a given version.
If the function has save compatibility for the current major version, there is no
change in behavior.
If the function has save compatibility for a *different* (future or past) major
version, DeprecatedSaveCompatError will be raised during startup. Since a break in
save compatibility is the definition of a major version break, there's no need to
keep around old save compat code; it only serves to mask initialization bugs.
Args:
major: The major version for which the decorated function has save
compatibility.
Returns:
The decorated function or method.
Raises:
DeprecatedSaveCompatError: The decorated function has save compat code for
another version of liberation, and that code (and the decorator declaring it)
should be removed from this branch.
"""
def decorator(func: Callable[..., ReturnT]) -> Callable[..., ReturnT]:
if major != MAJOR_VERSION:
raise DeprecatedSaveCompatError(func.__name__)
return func
return decorator

View File

@ -1,7 +1,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import timedelta from datetime import timedelta
from enum import Enum, unique from enum import Enum, unique
from typing import Dict, Optional from typing import Dict, Optional, Any
from dcs.forcedoptions import ForcedOptions from dcs.forcedoptions import ForcedOptions
@ -55,6 +55,7 @@ class Settings:
automate_runway_repair: bool = False automate_runway_repair: bool = False
automate_front_line_reinforcements: bool = False automate_front_line_reinforcements: bool = False
automate_aircraft_reinforcements: bool = False automate_aircraft_reinforcements: bool = False
automate_front_line_stance: bool = True
restrict_weapons_by_date: bool = False restrict_weapons_by_date: bool = False
disable_legacy_aewc: bool = True disable_legacy_aewc: bool = True
disable_legacy_tanker: bool = True disable_legacy_tanker: bool = True
@ -104,7 +105,7 @@ class Settings:
def set_plugin_option(self, identifier: str, enabled: bool) -> None: def set_plugin_option(self, identifier: str, enabled: bool) -> None:
self.plugins[self.plugin_settings_key(identifier)] = enabled self.plugins[self.plugin_settings_key(identifier)] = enabled
def __setstate__(self, state) -> None: def __setstate__(self, state: dict[str, Any]) -> None:
# __setstate__ is called with the dict of the object being unpickled. We # __setstate__ is called with the dict of the object being unpickled. We
# can provide save compatibility for new settings options (which # can provide save compatibility for new settings options (which
# normally would not be present in the unpickled object) by creating a # normally would not be present in the unpickled object) by creating a

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import dataclasses
import itertools import itertools
import logging import logging
import random import random
@ -13,17 +14,20 @@ from typing import (
Optional, Optional,
Iterator, Iterator,
Sequence, Sequence,
Any,
) )
import yaml import yaml
from faker import Faker from faker import Faker
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.settings import AutoAtoBehavior from game.settings import AutoAtoBehavior, Settings
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
from game.coalition import Coalition
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
from game.theater import ControlPoint
@dataclass @dataclass
@ -71,6 +75,33 @@ class Pilot:
return Pilot(faker.name()) return Pilot(faker.name())
@dataclass(frozen=True)
class OperatingBases:
shore: bool
carrier: bool
lha: bool
@classmethod
def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases:
if aircraft.dcs_unit_type.helicopter:
# Helicopters operate from anywhere by default.
return OperatingBases(shore=True, carrier=True, lha=True)
if aircraft.lha_capable:
# Marine aircraft operate from LHAs and the shore by default.
return OperatingBases(shore=True, carrier=False, lha=True)
if aircraft.carrier_capable:
# Carrier aircraft operate from carriers by default.
return OperatingBases(shore=False, carrier=True, lha=False)
# And the rest are only capable of shore operation.
return OperatingBases(shore=True, carrier=False, lha=False)
@classmethod
def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases:
return dataclasses.replace(
OperatingBases.default_for_aircraft(aircraft), **data
)
@dataclass @dataclass
class Squadron: class Squadron:
name: str name: str
@ -80,6 +111,7 @@ class Squadron:
aircraft: AircraftType aircraft: AircraftType
livery: Optional[str] livery: Optional[str]
mission_types: tuple[FlightType, ...] mission_types: tuple[FlightType, ...]
operating_bases: OperatingBases
#: The pool of pilots that have not yet been assigned to the squadron. This only #: The pool of pilots that have not yet been assigned to the squadron. This only
#: happens when a preset squadron defines more preset pilots than the squadron limit #: happens when a preset squadron defines more preset pilots than the squadron limit
@ -95,16 +127,10 @@ class Squadron:
init=False, hash=False, compare=False init=False, hash=False, compare=False
) )
# We need a reference to the Game so that we can access the Faker without needing to coalition: Coalition = field(hash=False, compare=False)
# persist it to the save game, or having to reconstruct it (it's not cheap) each settings: Settings = field(hash=False, compare=False)
# time we create or load a squadron.
game: Game = field(hash=False, compare=False)
player: bool
def __post_init__(self) -> None: def __post_init__(self) -> None:
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
raise ValueError("Squadrons can only be created with active pilots.")
self._recruit_pilots(self.game.settings.squadron_pilot_limit)
self.auto_assignable_mission_types = set(self.mission_types) self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str: def __str__(self) -> str:
@ -112,9 +138,13 @@ class Squadron:
return self.name return self.name
return f'{self.name} "{self.nickname}"' return f'{self.name} "{self.nickname}"'
@property
def player(self) -> bool:
return self.coalition.player
@property @property
def pilot_limits_enabled(self) -> bool: def pilot_limits_enabled(self) -> bool:
return self.game.settings.enable_squadron_pilot_limits return self.settings.enable_squadron_pilot_limits
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]: def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
if self.pilot_limits_enabled: if self.pilot_limits_enabled:
@ -130,7 +160,7 @@ class Squadron:
if not self.player: if not self.player:
return self.available_pilots.pop() return self.available_pilots.pop()
preference = self.game.settings.auto_ato_behavior preference = self.settings.auto_ato_behavior
# No preference, so the first pilot is fine. # No preference, so the first pilot is fine.
if preference is AutoAtoBehavior.Default: if preference is AutoAtoBehavior.Default:
@ -178,12 +208,17 @@ class Squadron:
self.current_roster.extend(new_pilots) self.current_roster.extend(new_pilots)
self.available_pilots.extend(new_pilots) self.available_pilots.extend(new_pilots)
def populate_for_turn_0(self) -> None:
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
raise ValueError("Squadrons can only be created with active pilots.")
self._recruit_pilots(self.settings.squadron_pilot_limit)
def replenish_lost_pilots(self) -> None: def replenish_lost_pilots(self) -> None:
if not self.pilot_limits_enabled: if not self.pilot_limits_enabled:
return return
replenish_count = min( replenish_count = min(
self.game.settings.squadron_replenishment_rate, self.settings.squadron_replenishment_rate,
self._number_of_unfilled_pilot_slots, self._number_of_unfilled_pilot_slots,
) )
if replenish_count > 0: if replenish_count > 0:
@ -196,7 +231,7 @@ class Squadron:
def send_on_leave(pilot: Pilot) -> None: def send_on_leave(pilot: Pilot) -> None:
pilot.send_on_leave() pilot.send_on_leave()
def return_from_leave(self, pilot: Pilot): def return_from_leave(self, pilot: Pilot) -> None:
if not self.has_unfilled_pilot_slots: if not self.has_unfilled_pilot_slots:
raise RuntimeError( raise RuntimeError(
f"Cannot return {pilot} from leave because {self} is full" f"Cannot return {pilot} from leave because {self} is full"
@ -205,7 +240,7 @@ class Squadron:
@property @property
def faker(self) -> Faker: def faker(self) -> Faker:
return self.game.faker_for(self.player) return self.coalition.faker
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]: def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster if p.status == status] return [p for p in self.current_roster if p.status == status]
@ -227,7 +262,7 @@ class Squadron:
@property @property
def _number_of_unfilled_pilot_slots(self) -> int: def _number_of_unfilled_pilot_slots(self) -> int:
return self.game.settings.squadron_pilot_limit - len(self.active_pilots) return self.settings.squadron_pilot_limit - len(self.active_pilots)
@property @property
def number_of_available_pilots(self) -> int: def number_of_available_pilots(self) -> int:
@ -247,11 +282,19 @@ class Squadron:
def can_auto_assign(self, task: FlightType) -> bool: def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types return task in self.auto_assignable_mission_types
def operates_from(self, control_point: ControlPoint) -> bool:
if control_point.is_carrier:
return self.operating_bases.carrier
elif control_point.is_lha:
return self.operating_bases.lha
else:
return self.operating_bases.shore
def pilot_at_index(self, index: int) -> Pilot: def pilot_at_index(self, index: int) -> Pilot:
return self.current_roster[index] return self.current_roster[index]
@classmethod @classmethod
def from_yaml(cls, path: Path, game: Game, player: bool) -> Squadron: def from_yaml(cls, path: Path, game: Game, coalition: Coalition) -> Squadron:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft from gen.flights.ai_flight_planner_db import tasks_for_aircraft
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
@ -285,12 +328,13 @@ class Squadron:
aircraft=unit_type, aircraft=unit_type,
livery=data.get("livery"), livery=data.get("livery"),
mission_types=tuple(mission_types), mission_types=tuple(mission_types),
operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),
pilot_pool=pilots, pilot_pool=pilots,
game=game, coalition=coalition,
player=player, settings=game.settings,
) )
def __setstate__(self, state) -> None: def __setstate__(self, state: dict[str, Any]) -> None:
# TODO: Remove save compat. # TODO: Remove save compat.
if "auto_assignable_mission_types" not in state: if "auto_assignable_mission_types" not in state:
state["auto_assignable_mission_types"] = set(state["mission_types"]) state["auto_assignable_mission_types"] = set(state["mission_types"])
@ -298,9 +342,9 @@ class Squadron:
class SquadronLoader: class SquadronLoader:
def __init__(self, game: Game, player: bool) -> None: def __init__(self, game: Game, coalition: Coalition) -> None:
self.game = game self.game = game
self.player = player self.coalition = coalition
@staticmethod @staticmethod
def squadron_directories() -> Iterator[Path]: def squadron_directories() -> Iterator[Path]:
@ -311,8 +355,8 @@ class SquadronLoader:
def load(self) -> dict[AircraftType, list[Squadron]]: def load(self) -> dict[AircraftType, list[Squadron]]:
squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list) squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
country = self.game.country_for(self.player) country = self.coalition.country_name
faction = self.game.faction_for(self.player) faction = self.coalition.faction
any_country = country.startswith("Combined Joint Task Forces ") any_country = country.startswith("Combined Joint Task Forces ")
for directory in self.squadron_directories(): for directory in self.squadron_directories():
for path, squadron in self.load_squadrons_from(directory): for path, squadron in self.load_squadrons_from(directory):
@ -346,7 +390,7 @@ class SquadronLoader:
for squadron_path in directory.glob("*/*.yaml"): for squadron_path in directory.glob("*/*.yaml"):
try: try:
yield squadron_path, Squadron.from_yaml( yield squadron_path, Squadron.from_yaml(
squadron_path, self.game, self.player squadron_path, self.game, self.coalition
) )
except Exception as ex: except Exception as ex:
raise RuntimeError( raise RuntimeError(
@ -355,29 +399,29 @@ class SquadronLoader:
class AirWing: class AirWing:
def __init__(self, game: Game, player: bool) -> None: def __init__(self, game: Game, coalition: Coalition) -> None:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft from gen.flights.ai_flight_planner_db import tasks_for_aircraft
self.game = game self.game = game
self.player = player self.squadrons = SquadronLoader(game, coalition).load()
self.squadrons = SquadronLoader(game, player).load()
count = itertools.count(1) count = itertools.count(1)
for aircraft in game.faction_for(player).aircrafts: for aircraft in coalition.faction.aircrafts:
if aircraft in self.squadrons: if aircraft in self.squadrons:
continue continue
self.squadrons[aircraft] = [ self.squadrons[aircraft] = [
Squadron( Squadron(
name=f"Squadron {next(count):03}", name=f"Squadron {next(count):03}",
nickname=self.random_nickname(), nickname=self.random_nickname(),
country=game.country_for(player), country=coalition.country_name,
role="Flying Squadron", role="Flying Squadron",
aircraft=aircraft, aircraft=aircraft,
livery=None, livery=None,
mission_types=tuple(tasks_for_aircraft(aircraft)), mission_types=tuple(tasks_for_aircraft(aircraft)),
operating_bases=OperatingBases.default_for_aircraft(aircraft),
pilot_pool=[], pilot_pool=[],
game=game, coalition=coalition,
player=player, settings=game.settings,
) )
] ]
@ -412,6 +456,10 @@ class AirWing:
def squadron_at_index(self, index: int) -> Squadron: def squadron_at_index(self, index: int) -> Squadron:
return list(self.iter_squadrons())[index] return list(self.iter_squadrons())[index]
def populate_for_turn_0(self) -> None:
for squadron in self.iter_squadrons():
squadron.populate_for_turn_0()
def replenish(self) -> None: def replenish(self) -> None:
for squadron in self.iter_squadrons(): for squadron in self.iter_squadrons():
squadron.replenish_lost_pilots() squadron.replenish_lost_pilots()

View File

@ -6,15 +6,15 @@ from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.dcs.unittype import UnitType from game.dcs.unittype import UnitType
BASE_MAX_STRENGTH = 1 BASE_MAX_STRENGTH = 1.0
BASE_MIN_STRENGTH = 0 BASE_MIN_STRENGTH = 0.0
class Base: class Base:
def __init__(self): def __init__(self) -> None:
self.aircraft: dict[AircraftType, int] = {} self.aircraft: dict[AircraftType, int] = {}
self.armor: dict[GroundUnitType, int] = {} self.armor: dict[GroundUnitType, int] = {}
self.strength = 1 self.strength = 1.0
@property @property
def total_aircraft(self) -> int: def total_aircraft(self) -> int:
@ -31,7 +31,7 @@ class Base:
total += unit_type.price * count total += unit_type.price * count
return total return total
def total_units_of_type(self, unit_type: UnitType) -> int: def total_units_of_type(self, unit_type: UnitType[Any]) -> int:
return sum( return sum(
[ [
c c
@ -40,7 +40,7 @@ class Base:
] ]
) )
def commission_units(self, units: dict[Any, int]): def commission_units(self, units: dict[Any, int]) -> None:
for unit_type, unit_count in units.items(): for unit_type, unit_count in units.items():
if unit_count <= 0: if unit_count <= 0:
continue continue
@ -56,7 +56,7 @@ class Base:
target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count
def commit_losses(self, units_lost: dict[Any, int]): def commit_losses(self, units_lost: dict[Any, int]) -> None:
for unit_type, count in units_lost.items(): for unit_type, count in units_lost.items():
target_dict: dict[Any, int] target_dict: dict[Any, int]
if unit_type in self.aircraft: if unit_type in self.aircraft:
@ -75,7 +75,7 @@ class Base:
if target_dict[unit_type] == 0: if target_dict[unit_type] == 0:
del target_dict[unit_type] del target_dict[unit_type]
def affect_strength(self, amount): def affect_strength(self, amount: float) -> None:
self.strength += amount self.strength += amount
if self.strength > BASE_MAX_STRENGTH: if self.strength > BASE_MAX_STRENGTH:
self.strength = BASE_MAX_STRENGTH self.strength = BASE_MAX_STRENGTH

View File

@ -1,3 +1,5 @@
# DO NOT EDIT:
# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator( PARAMETERS = TransverseMercator(

View File

@ -5,7 +5,7 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from functools import cached_property from functools import cached_property
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
from dcs import Mission from dcs import Mission
from dcs.countries import ( from dcs.countries import (
@ -29,14 +29,14 @@ from dcs.terrain import (
persiangulf, persiangulf,
syria, syria,
thechannel, thechannel,
marianaislands,
) )
from dcs.terrain.terrain import Airport, Terrain from dcs.terrain.terrain import Airport, Terrain
from dcs.unitgroup import ( from dcs.unitgroup import (
FlyingGroup,
Group,
ShipGroup, ShipGroup,
StaticGroup, StaticGroup,
VehicleGroup, VehicleGroup,
PlaneGroup,
) )
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
from pyproj import CRS, Transformer from pyproj import CRS, Transformer
@ -51,15 +51,20 @@ from .controlpoint import (
MissionTarget, MissionTarget,
OffMapSpawn, OffMapSpawn,
) )
from .seasonalconditions import SeasonalConditions
from .frontline import FrontLine from .frontline import FrontLine
from .landmap import Landmap, load_landmap, poly_contains from .landmap import Landmap, load_landmap, poly_contains
from .latlon import LatLon from .latlon import LatLon
from .projections import TransverseMercator from .projections import TransverseMercator
from ..helipad import Helipad from ..helipad import Helipad
from ..point_with_heading import PointWithHeading from ..point_with_heading import PointWithHeading
from ..positioned import Positioned
from ..profiling import logged_duration from ..profiling import logged_duration
from ..scenery_group import SceneryGroup from ..scenery_group import SceneryGroup
from ..utils import Distance, meters from ..utils import Distance, Heading, meters
if TYPE_CHECKING:
from . import TheaterGroundObject
SIZE_TINY = 150 SIZE_TINY = 150
SIZE_SMALL = 600 SIZE_SMALL = 600
@ -182,7 +187,7 @@ class MizCampaignLoader:
def red(self) -> Country: def red(self) -> Country:
return self.country(blue=False) return self.country(blue=False)
def off_map_spawns(self, blue: bool) -> Iterator[FlyingGroup]: def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]:
for group in self.country(blue).plane_group: for group in self.country(blue).plane_group:
if group.units[0].type == self.OFF_MAP_UNIT_TYPE: if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
yield group yield group
@ -306,26 +311,26 @@ class MizCampaignLoader:
control_point.captured = blue control_point.captured = blue
control_point.captured_invert = group.late_activation control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point control_points[control_point.id] = control_point
for group in self.carriers(blue): for ship in self.carriers(blue):
# TODO: Name the carrier. # TODO: Name the carrier.
control_point = Carrier( control_point = Carrier(
"carrier", group.position, next(self.control_point_id) "carrier", ship.position, next(self.control_point_id)
) )
control_point.captured = blue control_point.captured = blue
control_point.captured_invert = group.late_activation control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point control_points[control_point.id] = control_point
for group in self.lhas(blue): for ship in self.lhas(blue):
# TODO: Name the LHA.db # TODO: Name the LHA.db
control_point = Lha("lha", group.position, next(self.control_point_id)) control_point = Lha("lha", ship.position, next(self.control_point_id))
control_point.captured = blue control_point.captured = blue
control_point.captured_invert = group.late_activation control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point control_points[control_point.id] = control_point
for group in self.fobs(blue): for fob in self.fobs(blue):
control_point = Fob( control_point = Fob(
str(group.name), group.position, next(self.control_point_id) str(fob.name), fob.position, next(self.control_point_id)
) )
control_point.captured = blue control_point.captured = blue
control_point.captured_invert = group.late_activation control_point.captured_invert = fob.late_activation
control_points[control_point.id] = control_point control_points[control_point.id] = control_point
return control_points return control_points
@ -386,99 +391,129 @@ class MizCampaignLoader:
origin, list(reversed(waypoints)) origin, list(reversed(waypoints))
) )
def objective_info(self, group: Group) -> Tuple[ControlPoint, Distance]: def objective_info(
closest = self.theater.closest_control_point(group.position) self, near: Positioned, allow_naval: bool = False
distance = meters(closest.position.distance_to_point(group.position)) ) -> Tuple[ControlPoint, Distance]:
closest = self.theater.closest_control_point(near.position, allow_naval)
distance = meters(closest.position.distance_to_point(near.position))
return closest, distance return closest, distance
def add_preset_locations(self) -> None: def add_preset_locations(self) -> None:
for group in self.offshore_strike_targets: for static in self.offshore_strike_targets:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(static)
closest.preset_locations.offshore_strike_locations.append( closest.preset_locations.offshore_strike_locations.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
) )
for group in self.ships: for ship in self.ships:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(ship, allow_naval=True)
closest.preset_locations.ships.append( closest.preset_locations.ships.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
ship.position, Heading.from_degrees(ship.units[0].heading)
)
) )
for group in self.missile_sites: for group in self.missile_sites:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(group)
closest.preset_locations.missile_sites.append( closest.preset_locations.missile_sites.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
) )
for group in self.coastal_defenses: for group in self.coastal_defenses:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(group)
closest.preset_locations.coastal_defenses.append( closest.preset_locations.coastal_defenses.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
) )
for group in self.long_range_sams: for group in self.long_range_sams:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(group)
closest.preset_locations.long_range_sams.append( closest.preset_locations.long_range_sams.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
) )
for group in self.medium_range_sams: for group in self.medium_range_sams:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(group)
closest.preset_locations.medium_range_sams.append( closest.preset_locations.medium_range_sams.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
) )
for group in self.short_range_sams: for group in self.short_range_sams:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(group)
closest.preset_locations.short_range_sams.append( closest.preset_locations.short_range_sams.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
) )
for group in self.aaa: for group in self.aaa:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(group)
closest.preset_locations.aaa.append( closest.preset_locations.aaa.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
) )
for group in self.ewrs: for group in self.ewrs:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(group)
closest.preset_locations.ewrs.append( closest.preset_locations.ewrs.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
) )
for group in self.armor_groups: for group in self.armor_groups:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(group)
closest.preset_locations.armor_groups.append( closest.preset_locations.armor_groups.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
) )
for group in self.helipads: for static in self.helipads:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(static)
closest.helipads.append( closest.helipads.append(
Helipad.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
) )
for group in self.factories: for static in self.factories:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(static)
closest.preset_locations.factories.append( closest.preset_locations.factories.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
) )
for group in self.ammunition_depots: for static in self.ammunition_depots:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(static)
closest.preset_locations.ammunition_depots.append( closest.preset_locations.ammunition_depots.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
) )
for group in self.strike_targets: for static in self.strike_targets:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(static)
closest.preset_locations.strike_locations.append( closest.preset_locations.strike_locations.append(
PointWithHeading.from_point(group.position, group.units[0].heading) PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
) )
for group in self.scenery: for scenery_group in self.scenery:
closest, distance = self.objective_info(group) closest, distance = self.objective_info(scenery_group)
closest.preset_locations.scenery.append(group) closest.preset_locations.scenery.append(scenery_group)
def populate_theater(self) -> None: def populate_theater(self) -> None:
for control_point in self.control_points.values(): for control_point in self.control_points.values():
@ -505,7 +540,7 @@ class ConflictTheater:
""" """
daytime_map: Dict[str, Tuple[int, int]] daytime_map: Dict[str, Tuple[int, int]]
def __init__(self): def __init__(self) -> None:
self.controlpoints: List[ControlPoint] = [] self.controlpoints: List[ControlPoint] = []
self.point_to_ll_transformer = Transformer.from_crs( self.point_to_ll_transformer = Transformer.from_crs(
self.projection_parameters.to_crs(), CRS("WGS84") self.projection_parameters.to_crs(), CRS("WGS84")
@ -537,10 +572,12 @@ class ConflictTheater:
CRS("WGS84"), self.projection_parameters.to_crs() CRS("WGS84"), self.projection_parameters.to_crs()
) )
def add_controlpoint(self, point: ControlPoint): def add_controlpoint(self, point: ControlPoint) -> None:
self.controlpoints.append(point) self.controlpoints.append(point)
def find_ground_objects_by_obj_name(self, obj_name): def find_ground_objects_by_obj_name(
self, obj_name: str
) -> list[TheaterGroundObject[Any]]:
found = [] found = []
for cp in self.controlpoints: for cp in self.controlpoints:
for g in cp.ground_objects: for g in cp.ground_objects:
@ -582,12 +619,12 @@ class ConflictTheater:
return True return True
def nearest_land_pos(self, point: Point, extend_dist: int = 50) -> Point: def nearest_land_pos(self, near: Point, extend_dist: int = 50) -> Point:
"""Returns the nearest point inside a land exclusion zone from point """Returns the nearest point inside a land exclusion zone from point
`extend_dist` determines how far inside the zone the point should be placed""" `extend_dist` determines how far inside the zone the point should be placed"""
if self.is_on_land(point): if self.is_on_land(near):
return point return near
point = geometry.Point(point.x, point.y) point = geometry.Point(near.x, near.y)
nearest_points = [] nearest_points = []
if not self.landmap: if not self.landmap:
raise RuntimeError("Landmap not initialized") raise RuntimeError("Landmap not initialized")
@ -628,10 +665,14 @@ class ConflictTheater:
def enemy_points(self) -> List[ControlPoint]: def enemy_points(self) -> List[ControlPoint]:
return list(self.control_points_for(player=False)) return list(self.control_points_for(player=False))
def closest_control_point(self, point: Point) -> ControlPoint: def closest_control_point(
self, point: Point, allow_naval: bool = False
) -> ControlPoint:
closest = self.controlpoints[0] closest = self.controlpoints[0]
closest_distance = point.distance_to_point(closest.position) closest_distance = point.distance_to_point(closest.position)
for control_point in self.controlpoints[1:]: for control_point in self.controlpoints[1:]:
if control_point.is_fleet and not allow_naval:
continue
distance = point.distance_to_point(control_point.position) distance = point.distance_to_point(control_point.position)
if distance < closest_distance: if distance < closest_distance:
closest = control_point closest = control_point
@ -699,6 +740,7 @@ class ConflictTheater:
"Normandy": NormandyTheater, "Normandy": NormandyTheater,
"The Channel": TheChannelTheater, "The Channel": TheChannelTheater,
"Syria": SyriaTheater, "Syria": SyriaTheater,
"MarianaIslands": MarianaIslandsTheater,
} }
theater = theaters[data["theater"]] theater = theaters[data["theater"]]
t = theater() t = theater()
@ -713,6 +755,10 @@ class ConflictTheater:
MizCampaignLoader(directory / miz, t).populate_theater() MizCampaignLoader(directory / miz, t).populate_theater()
return t return t
@property
def seasonal_conditions(self) -> SeasonalConditions:
raise NotImplementedError
@property @property
def projection_parameters(self) -> TransverseMercator: def projection_parameters(self) -> TransverseMercator:
raise NotImplementedError raise NotImplementedError
@ -742,6 +788,12 @@ class CaucasusTheater(ConflictTheater):
"night": (0, 5), "night": (0, 5),
} }
@property
def seasonal_conditions(self) -> SeasonalConditions:
from .seasonalconditions.caucasus import CONDITIONS
return CONDITIONS
@property @property
def projection_parameters(self) -> TransverseMercator: def projection_parameters(self) -> TransverseMercator:
from .caucasus import PARAMETERS from .caucasus import PARAMETERS
@ -764,6 +816,12 @@ class PersianGulfTheater(ConflictTheater):
"night": (0, 5), "night": (0, 5),
} }
@property
def seasonal_conditions(self) -> SeasonalConditions:
from .seasonalconditions.persiangulf import CONDITIONS
return CONDITIONS
@property @property
def projection_parameters(self) -> TransverseMercator: def projection_parameters(self) -> TransverseMercator:
from .persiangulf import PARAMETERS from .persiangulf import PARAMETERS
@ -786,6 +844,12 @@ class NevadaTheater(ConflictTheater):
"night": (0, 5), "night": (0, 5),
} }
@property
def seasonal_conditions(self) -> SeasonalConditions:
from .seasonalconditions.nevada import CONDITIONS
return CONDITIONS
@property @property
def projection_parameters(self) -> TransverseMercator: def projection_parameters(self) -> TransverseMercator:
from .nevada import PARAMETERS from .nevada import PARAMETERS
@ -808,6 +872,12 @@ class NormandyTheater(ConflictTheater):
"night": (0, 5), "night": (0, 5),
} }
@property
def seasonal_conditions(self) -> SeasonalConditions:
from .seasonalconditions.normandy import CONDITIONS
return CONDITIONS
@property @property
def projection_parameters(self) -> TransverseMercator: def projection_parameters(self) -> TransverseMercator:
from .normandy import PARAMETERS from .normandy import PARAMETERS
@ -830,6 +900,12 @@ class TheChannelTheater(ConflictTheater):
"night": (0, 5), "night": (0, 5),
} }
@property
def seasonal_conditions(self) -> SeasonalConditions:
from .seasonalconditions.thechannel import CONDITIONS
return CONDITIONS
@property @property
def projection_parameters(self) -> TransverseMercator: def projection_parameters(self) -> TransverseMercator:
from .thechannel import PARAMETERS from .thechannel import PARAMETERS
@ -852,8 +928,39 @@ class SyriaTheater(ConflictTheater):
"night": (0, 5), "night": (0, 5),
} }
@property
def seasonal_conditions(self) -> SeasonalConditions:
from .seasonalconditions.syria import CONDITIONS
return CONDITIONS
@property @property
def projection_parameters(self) -> TransverseMercator: def projection_parameters(self) -> TransverseMercator:
from .syria import PARAMETERS from .syria import PARAMETERS
return PARAMETERS return PARAMETERS
class MarianaIslandsTheater(ConflictTheater):
terrain = marianaislands.MarianaIslands()
overview_image = "marianaislands.gif"
landmap = load_landmap("resources\\marianaislandslandmap.p")
daytime_map = {
"dawn": (6, 8),
"day": (8, 16),
"dusk": (16, 18),
"night": (0, 5),
}
@property
def seasonal_conditions(self) -> SeasonalConditions:
from .seasonalconditions.marianaislands import CONDITIONS
return CONDITIONS
@property
def projection_parameters(self) -> TransverseMercator:
from .marianaislands import PARAMETERS
return PARAMETERS

View File

@ -36,6 +36,7 @@ from dcs.unittype import FlyingType
from game import db from game import db
from game.point_with_heading import PointWithHeading from game.point_with_heading import PointWithHeading
from game.scenery_group import SceneryGroup from game.scenery_group import SceneryGroup
from game.utils import Heading
from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.ground_forces.combat_stance import CombatStance from gen.ground_forces.combat_stance import CombatStance
from gen.runways import RunwayAssigner, RunwayData from gen.runways import RunwayAssigner, RunwayData
@ -44,6 +45,7 @@ from .missiontarget import MissionTarget
from .theatergroundobject import ( from .theatergroundobject import (
GenericCarrierGroundObject, GenericCarrierGroundObject,
TheaterGroundObject, TheaterGroundObject,
BuildingGroundObject,
) )
from ..dcs.aircrafttype import AircraftType from ..dcs.aircrafttype import AircraftType
from ..dcs.groundunittype import GroundUnitType from ..dcs.groundunittype import GroundUnitType
@ -272,6 +274,9 @@ class ControlPointStatus(IntEnum):
class ControlPoint(MissionTarget, ABC): 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 position = None # type: Point
name = None # type: str name = None # type: str
@ -292,15 +297,15 @@ class ControlPoint(MissionTarget, ABC):
at: db.StartingPosition, at: db.StartingPosition,
size: int, size: int,
importance: float, importance: float,
has_frontline=True, has_frontline: bool = True,
cptype=ControlPointType.AIRBASE, cptype: ControlPointType = ControlPointType.AIRBASE,
): ) -> None:
super().__init__(name, position) super().__init__(name, position)
# TODO: Should be Airbase specific. # TODO: Should be Airbase specific.
self.id = cp_id self.id = cp_id
self.full_name = name self.full_name = name
self.at = at self.at = at
self.connected_objectives: List[TheaterGroundObject] = [] self.connected_objectives: List[TheaterGroundObject[Any]] = []
self.preset_locations = PresetLocations() self.preset_locations = PresetLocations()
self.helipads: List[Helipad] = [] self.helipads: List[Helipad] = []
@ -324,25 +329,29 @@ class ControlPoint(MissionTarget, ABC):
self.target_position: Optional[Point] = None self.target_position: Optional[Point] = None
def __repr__(self): def __repr__(self) -> str:
return f"<{__class__}: {self.name}>" return f"<{self.__class__}: {self.name}>"
@property @property
def ground_objects(self) -> List[TheaterGroundObject]: def ground_objects(self) -> List[TheaterGroundObject[Any]]:
return list(self.connected_objectives) return list(self.connected_objectives)
@property @property
@abstractmethod @abstractmethod
def heading(self) -> int: def heading(self) -> Heading:
... ...
def __str__(self): def __str__(self) -> str:
return self.name return self.name
@property @property
def is_global(self): def is_isolated(self) -> bool:
return not self.connected_points return not self.connected_points
@property
def is_global(self) -> bool:
return self.is_isolated
def transitive_connected_friendly_points( def transitive_connected_friendly_points(
self, seen: Optional[Set[ControlPoint]] = None self, seen: Optional[Set[ControlPoint]] = None
) -> List[ControlPoint]: ) -> List[ControlPoint]:
@ -430,21 +439,21 @@ class ControlPoint(MissionTarget, ABC):
return False return False
@property @property
def is_carrier(self): def is_carrier(self) -> bool:
""" """
:return: Whether this control point is an aircraft carrier :return: Whether this control point is an aircraft carrier
""" """
return False return False
@property @property
def is_fleet(self): def is_fleet(self) -> bool:
""" """
:return: Whether this control point is a boat (mobile) :return: Whether this control point is a boat (mobile)
""" """
return False return False
@property @property
def is_lha(self): def is_lha(self) -> bool:
""" """
:return: Whether this control point is an LHA :return: Whether this control point is an LHA
""" """
@ -464,7 +473,7 @@ class ControlPoint(MissionTarget, ABC):
@property @property
@abstractmethod @abstractmethod
def total_aircraft_parking(self): def total_aircraft_parking(self) -> int:
""" """
:return: The maximum number of aircraft that can be stored in this :return: The maximum number of aircraft that can be stored in this
control point control point
@ -496,7 +505,7 @@ class ControlPoint(MissionTarget, ABC):
... ...
# TODO: Should be naval specific. # TODO: Should be naval specific.
def get_carrier_group_name(self): def get_carrier_group_name(self) -> Optional[str]:
""" """
Get the carrier group name if the airbase is a carrier Get the carrier group name if the airbase is a carrier
:return: Carrier group name :return: Carrier group name
@ -522,10 +531,12 @@ class ControlPoint(MissionTarget, ABC):
return None return None
# TODO: Should be Airbase specific. # TODO: Should be Airbase specific.
def is_connected(self, to) -> bool: def is_connected(self, to: ControlPoint) -> bool:
return to in self.connected_points return to in self.connected_points
def find_ground_objects_by_obj_name(self, obj_name): def find_ground_objects_by_obj_name(
self, obj_name: str
) -> list[TheaterGroundObject[Any]]:
found = [] found = []
for g in self.ground_objects: for g in self.ground_objects:
if g.obj_name == obj_name: if g.obj_name == obj_name:
@ -547,7 +558,7 @@ class ControlPoint(MissionTarget, ABC):
f"vehicles have been captured and sold for ${total}M." f"vehicles have been captured and sold for ${total}M."
) )
def retreat_ground_units(self, game: Game): def retreat_ground_units(self, game: Game) -> None:
# When there are multiple valid destinations, deliver units to whichever # When there are multiple valid destinations, deliver units to whichever
# base is least defended first. The closest approximation of unit # base is least defended first. The closest approximation of unit
# strength we have is price # strength we have is price
@ -621,7 +632,7 @@ class ControlPoint(MissionTarget, ABC):
# TODO: Should be Airbase specific. # TODO: Should be Airbase specific.
def capture(self, game: Game, for_player: bool) -> None: def capture(self, game: Game, for_player: bool) -> None:
self.pending_unit_deliveries.refund_all(game) self.pending_unit_deliveries.refund_all(game.coalition_for(for_player))
self.retreat_ground_units(game) self.retreat_ground_units(game)
self.retreat_air_units(game) self.retreat_air_units(game)
self.depopulate_uncapturable_tgos() self.depopulate_uncapturable_tgos()
@ -638,11 +649,7 @@ class ControlPoint(MissionTarget, ABC):
... ...
def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]: def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]:
if self.captured: ato = game.coalition_for(self.captured).ato
ato = game.blue_ato
else:
ato = game.red_ato
transferring: defaultdict[AircraftType, int] = defaultdict(int) transferring: defaultdict[AircraftType, int] = defaultdict(int)
for package in ato.packages: for package in ato.packages:
for flight in package.flights: for flight in package.flights:
@ -750,27 +757,48 @@ class ControlPoint(MissionTarget, ABC):
return self.captured != other.captured return self.captured != other.captured
@property @property
def frontline_unit_count_limit(self) -> int: def deployable_front_line_units(self) -> int:
return self.deployable_front_line_units_with(self.active_ammo_depots_count)
def deployable_front_line_units_with(self, ammo_depot_count: int) -> int:
return min(
self.front_line_capacity_with(ammo_depot_count), self.base.total_armor
)
@classmethod
def front_line_capacity_with(cls, ammo_depot_count: int) -> int:
return ( return (
FREE_FRONTLINE_UNIT_SUPPLY FREE_FRONTLINE_UNIT_SUPPLY
+ self.active_ammo_depots_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION + ammo_depot_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION
) )
@property
def frontline_unit_count_limit(self) -> int:
return self.front_line_capacity_with(self.active_ammo_depots_count)
@property
def all_ammo_depots(self) -> Iterator[BuildingGroundObject]:
for tgo in self.connected_objectives:
if not tgo.is_ammo_depot:
continue
assert isinstance(tgo, BuildingGroundObject)
yield tgo
@property
def active_ammo_depots(self) -> Iterator[BuildingGroundObject]:
for tgo in self.all_ammo_depots:
if not tgo.is_dead:
yield tgo
@property @property
def active_ammo_depots_count(self) -> int: def active_ammo_depots_count(self) -> int:
"""Return the number of available ammo depots""" """Return the number of available ammo depots"""
return len( return len(list(self.active_ammo_depots))
[
obj
for obj in self.connected_objectives
if obj.category == "ammo" and not obj.is_dead
]
)
@property @property
def total_ammo_depots_count(self) -> int: def total_ammo_depots_count(self) -> int:
"""Return the number of ammo depots, including dead ones""" """Return the number of ammo depots, including dead ones"""
return len([obj for obj in self.connected_objectives if obj.category == "ammo"]) return len(list(self.all_ammo_depots))
@property @property
def active_fuel_depots_count(self) -> int: def active_fuel_depots_count(self) -> int:
@ -789,7 +817,7 @@ class ControlPoint(MissionTarget, ABC):
return len([obj for obj in self.connected_objectives if obj.category == "fuel"]) return len([obj for obj in self.connected_objectives if obj.category == "fuel"])
@property @property
def strike_targets(self) -> List[Union[MissionTarget, Unit]]: def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]:
return [] return []
@property @property
@ -805,8 +833,8 @@ class ControlPoint(MissionTarget, ABC):
class Airfield(ControlPoint): class Airfield(ControlPoint):
def __init__( def __init__(
self, airport: Airport, size: int, importance: float, has_frontline=True self, airport: Airport, size: int, importance: float, has_frontline: bool = True
): ) -> None:
super().__init__( super().__init__(
airport.id, airport.id,
airport.name, airport.name,
@ -852,8 +880,8 @@ class Airfield(ControlPoint):
return len(self.airport.parking_slots) return len(self.airport.parking_slots)
@property @property
def heading(self) -> int: def heading(self) -> Heading:
return self.airport.runways[0].heading return Heading.from_degrees(self.airport.runways[0].heading)
def runway_is_operational(self) -> bool: def runway_is_operational(self) -> bool:
return not self.runway_status.damaged return not self.runway_status.damaged
@ -917,12 +945,15 @@ class NavalControlPoint(ControlPoint, ABC):
yield from super().mission_types(for_player) yield from super().mission_types(for_player)
@property @property
def heading(self) -> int: def heading(self) -> Heading:
return 0 # TODO compute heading return Heading.from_degrees(0) # TODO compute heading
def find_main_tgo(self) -> TheaterGroundObject: def find_main_tgo(self) -> GenericCarrierGroundObject:
for g in self.ground_objects: for g in self.ground_objects:
if g.dcs_identifier in ["CARRIER", "LHA"]: if isinstance(g, GenericCarrierGroundObject) and g.dcs_identifier in [
"CARRIER",
"LHA",
]:
return g return g
raise RuntimeError(f"Found no carrier/LHA group for {self.name}") raise RuntimeError(f"Found no carrier/LHA group for {self.name}")
@ -944,7 +975,9 @@ class NavalControlPoint(ControlPoint, ABC):
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData] self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
) -> RunwayData: ) -> RunwayData:
# TODO: Assign TACAN and ICLS earlier so we don't need this. # TODO: Assign TACAN and ICLS earlier so we don't need this.
fallback = RunwayData(self.full_name, runway_heading=0, runway_name="") fallback = RunwayData(
self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
)
return dynamic_runways.get(self.name, fallback) return dynamic_runways.get(self.name, fallback)
@property @property
@ -1001,7 +1034,7 @@ class Carrier(NavalControlPoint):
raise RuntimeError("Carriers cannot be captured") raise RuntimeError("Carriers cannot be captured")
@property @property
def is_carrier(self): def is_carrier(self) -> bool:
return True return True
def can_operate(self, aircraft: AircraftType) -> bool: def can_operate(self, aircraft: AircraftType) -> bool:
@ -1082,14 +1115,16 @@ class OffMapSpawn(ControlPoint):
return True return True
@property @property
def heading(self) -> int: def heading(self) -> Heading:
return 0 return Heading.from_degrees(0)
def active_runway( def active_runway(
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData] self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
) -> RunwayData: ) -> RunwayData:
logging.warning("TODO: Off map spawns have no runways.") logging.warning("TODO: Off map spawns have no runways.")
return RunwayData(self.full_name, runway_heading=0, runway_name="") return RunwayData(
self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
)
@property @property
def runway_status(self) -> RunwayStatus: def runway_status(self) -> RunwayStatus:
@ -1131,7 +1166,9 @@ class Fob(ControlPoint):
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData] self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
) -> RunwayData: ) -> RunwayData:
logging.warning("TODO: FOBs have no runways.") logging.warning("TODO: FOBs have no runways.")
return RunwayData(self.full_name, runway_heading=0, runway_name="") return RunwayData(
self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
)
@property @property
def runway_status(self) -> RunwayStatus: def runway_status(self) -> RunwayStatus:
@ -1158,8 +1195,8 @@ class Fob(ControlPoint):
return False return False
@property @property
def heading(self) -> int: def heading(self) -> Heading:
return 0 return Heading.from_degrees(0)
@property @property
def can_deploy_ground_units(self) -> bool: def can_deploy_ground_units(self) -> bool:

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterator, List, Tuple from typing import Iterator, List, Tuple, Any
from dcs.mapping import Point from dcs.mapping import Point
@ -11,7 +11,7 @@ from .controlpoint import (
ControlPoint, ControlPoint,
MissionTarget, MissionTarget,
) )
from ..utils import pairwise from ..utils import Heading, pairwise
FRONTLINE_MIN_CP_DISTANCE = 5000 FRONTLINE_MIN_CP_DISTANCE = 5000
@ -27,9 +27,9 @@ class FrontLineSegment:
point_b: Point point_b: Point
@property @property
def attack_heading(self) -> float: def attack_heading(self) -> Heading:
"""The heading of the frontline segment from player to enemy control point""" """The heading of the frontline segment from player to enemy control point"""
return self.point_a.heading_between_point(self.point_b) return Heading.from_degrees(self.point_a.heading_between_point(self.point_b))
@property @property
def attack_distance(self) -> float: def attack_distance(self) -> float:
@ -66,12 +66,31 @@ class FrontLine(MissionTarget):
self.segments: List[FrontLineSegment] = [ self.segments: List[FrontLineSegment] = [
FrontLineSegment(a, b) for a, b in pairwise(route) FrontLineSegment(a, b) for a, b in pairwise(route)
] ]
self.name = f"Front line {blue_point}/{red_point}" super().__init__(
f"Front line {blue_point}/{red_point}",
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_friendly_to(self, player: bool) -> ControlPoint:
if player:
return self.blue_cp
return self.red_cp
def control_point_hostile_to(self, player: bool) -> ControlPoint: def control_point_hostile_to(self, player: bool) -> ControlPoint:
if player: return self.control_point_friendly_to(not player)
return self.red_cp
return self.blue_cp
def is_friendly(self, to_player: bool) -> bool: def is_friendly(self, to_player: bool) -> bool:
"""Returns True if the objective is in friendly territory.""" """Returns True if the objective is in friendly territory."""
@ -87,14 +106,6 @@ class FrontLine(MissionTarget):
] ]
yield from super().mission_types(for_player) yield from super().mission_types(for_player)
@property
def position(self):
"""
The position where the conflict should occur
according to the current strength of each control point.
"""
return self.point_from_a(self._position_distance)
@property @property
def points(self) -> Iterator[Point]: def points(self) -> Iterator[Point]:
yield self.segments[0].point_a yield self.segments[0].point_a
@ -107,12 +118,12 @@ class FrontLine(MissionTarget):
return self.blue_cp, self.red_cp return self.blue_cp, self.red_cp
@property @property
def attack_distance(self): def attack_distance(self) -> float:
"""The total distance of all segments""" """The total distance of all segments"""
return sum(i.attack_distance for i in self.segments) return sum(i.attack_distance for i in self.segments)
@property @property
def attack_heading(self): def attack_heading(self) -> Heading:
"""The heading of the active attack segment from player to enemy control point""" """The heading of the active attack segment from player to enemy control point"""
return self.active_segment.attack_heading return self.active_segment.attack_heading
@ -139,16 +150,19 @@ class FrontLine(MissionTarget):
""" """
if distance < self.segments[0].attack_distance: if distance < self.segments[0].attack_distance:
return self.blue_cp.position.point_from_heading( return self.blue_cp.position.point_from_heading(
self.segments[0].attack_heading, distance self.segments[0].attack_heading.degrees, distance
) )
remaining_dist = distance remaining_dist = distance
for segment in self.segments: for segment in self.segments:
if remaining_dist < segment.attack_distance: if remaining_dist < segment.attack_distance:
return segment.point_a.point_from_heading( return segment.point_a.point_from_heading(
segment.attack_heading, remaining_dist segment.attack_heading.degrees, remaining_dist
) )
else: else:
remaining_dist -= segment.attack_distance remaining_dist -= segment.attack_distance
raise RuntimeError(
f"Could not find front line point {distance} from {self.blue_cp}"
)
@property @property
def _position_distance(self) -> float: def _position_distance(self) -> float:

View File

@ -14,7 +14,7 @@ class Landmap:
exclusion_zones: MultiPolygon exclusion_zones: MultiPolygon
sea_zones: MultiPolygon sea_zones: MultiPolygon
def __post_init__(self): def __post_init__(self) -> None:
if not self.inclusion_zones.is_valid: if not self.inclusion_zones.is_valid:
raise RuntimeError("Inclusion zones not valid") raise RuntimeError("Inclusion zones not valid")
if not self.exclusion_zones.is_valid: if not self.exclusion_zones.is_valid:
@ -36,13 +36,5 @@ def load_landmap(filename: str) -> Optional[Landmap]:
return None return None
def poly_contains(x, y, poly: Union[MultiPolygon, Polygon]): def poly_contains(x: float, y: float, poly: Union[MultiPolygon, Polygon]) -> bool:
return poly.contains(geometry.Point(x, y)) return poly.contains(geometry.Point(x, y))
def poly_centroid(poly) -> Tuple[float, float]:
x_list = [vertex[0] for vertex in poly]
y_list = [vertex[1] for vertex in poly]
x = sum(x_list) / len(poly)
y = sum(y_list) / len(poly)
return (x, y)

View File

@ -0,0 +1,10 @@
# DO NOT EDIT:
# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
central_meridian=147,
false_easting=238417.99999989968,
false_northing=-1491840.000000048,
scale_factor=0.9996,
)

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections import Sequence
from typing import Iterator, TYPE_CHECKING, List, Union from typing import Iterator, TYPE_CHECKING, List, Union
from dcs.mapping import Point from dcs.mapping import Point
@ -20,7 +21,7 @@ class MissionTarget:
self.name = name self.name = name
self.position = position self.position = position
def distance_to(self, other: MissionTarget) -> int: def distance_to(self, other: MissionTarget) -> float:
"""Computes the distance to the given mission target.""" """Computes the distance to the given mission target."""
return self.position.distance_to_point(other.position) return self.position.distance_to_point(other.position)
@ -45,5 +46,5 @@ class MissionTarget:
] ]
@property @property
def strike_targets(self) -> List[Union[MissionTarget, Unit]]: def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]:
return [] return []

View File

@ -1,3 +1,5 @@
# DO NOT EDIT:
# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator( PARAMETERS = TransverseMercator(

View File

@ -1,3 +1,5 @@
# DO NOT EDIT:
# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator( PARAMETERS = TransverseMercator(

View File

@ -1,3 +1,5 @@
# DO NOT EDIT:
# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator( PARAMETERS = TransverseMercator(

View File

@ -0,0 +1 @@
from .seasonalconditions import *

View File

@ -0,0 +1,36 @@
from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
CONDITIONS = SeasonalConditions(
summer_avg_pressure=30.02, # TODO: Find real-world data
winter_avg_pressure=29.72, # TODO: Find real-world data
summer_avg_temperature=22.5,
winter_avg_temperature=3.0,
temperature_day_night_difference=6.0,
weather_type_chances={
# TODO: Find real-world data for all these values
Season.Winter: WeatherTypeChances(
thunderstorm=1,
raining=20,
cloudy=60,
clear_skies=20,
),
Season.Spring: WeatherTypeChances(
thunderstorm=1,
raining=20,
cloudy=40,
clear_skies=40,
),
Season.Summer: WeatherTypeChances(
thunderstorm=1,
raining=10,
cloudy=30,
clear_skies=60,
),
Season.Fall: WeatherTypeChances(
thunderstorm=1,
raining=30,
cloudy=50,
clear_skies=20,
),
},
)

View File

@ -0,0 +1,38 @@
from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
CONDITIONS = SeasonalConditions(
summer_avg_pressure=30.02, # TODO: Find real-world data
winter_avg_pressure=29.82, # TODO: Find real-world data
summer_avg_temperature=28.0,
winter_avg_temperature=27.0,
temperature_day_night_difference=1.0,
weather_type_chances={
# TODO: Find real-world data for all these values
Season.Winter: WeatherTypeChances(
thunderstorm=2,
raining=20,
cloudy=40,
clear_skies=40,
),
Season.Spring: WeatherTypeChances(
# Spring is dry/sunny in Marianas
thunderstorm=1,
raining=10,
cloudy=30,
clear_skies=60,
),
Season.Summer: WeatherTypeChances(
thunderstorm=2,
raining=20,
cloudy=40,
clear_skies=40,
),
Season.Fall: WeatherTypeChances(
# Rain season
thunderstorm=5,
raining=45,
cloudy=30,
clear_skies=20,
),
},
)

View File

@ -0,0 +1,36 @@
from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
CONDITIONS = SeasonalConditions(
summer_avg_pressure=30.02, # TODO: Find real-world data
winter_avg_pressure=29.72, # TODO: Find real-world data
summer_avg_temperature=31.5,
winter_avg_temperature=5.0,
temperature_day_night_difference=6.0,
weather_type_chances={
# TODO: Find real-world data for all these values
Season.Winter: WeatherTypeChances(
thunderstorm=1,
raining=10,
cloudy=50,
clear_skies=40,
),
Season.Spring: WeatherTypeChances(
thunderstorm=1,
raining=5,
cloudy=45,
clear_skies=50,
),
Season.Summer: WeatherTypeChances(
thunderstorm=1,
raining=5,
cloudy=25,
clear_skies=70,
),
Season.Fall: WeatherTypeChances(
thunderstorm=1,
raining=10,
cloudy=45,
clear_skies=45,
),
},
)

Some files were not shown because too many files have changed in this diff Show More