mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
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:
commit
71143536bf
2
.gitignore
vendored
2
.gitignore
vendored
@ -18,7 +18,7 @@ env/
|
||||
/liberation_preferences.json
|
||||
/state.json
|
||||
|
||||
logs/
|
||||
/logs/
|
||||
|
||||
qt_ui/logs/liberation.log
|
||||
|
||||
|
||||
52
changelog.md
52
changelog.md
@ -1,19 +1,65 @@
|
||||
# 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
|
||||
|
||||
* **[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
|
||||
|
||||
# 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
|
||||
|
||||
* **[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
|
||||
|
||||
* **[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
|
||||
|
||||
Saves from 3.x are not compatible with 4.0.
|
||||
|
||||
80
doc/fuel-consumption-measurement.md
Normal file
80
doc/fuel-consumption-measurement.md
Normal 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
239
game/coalition.py
Normal 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)
|
||||
1
game/commander/__init__.py
Normal file
1
game/commander/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .theatercommander import TheaterCommander
|
||||
78
game/commander/aircraftallocator.py
Normal file
78
game/commander/aircraftallocator.py
Normal 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
|
||||
52
game/commander/garrisons.py
Normal file
52
game/commander/garrisons.py
Normal 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)
|
||||
58
game/commander/missionproposals.py
Normal file
58
game/commander/missionproposals.py
Normal 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}"
|
||||
76
game/commander/missionscheduler.py
Normal file
76
game/commander/missionscheduler.py
Normal 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
|
||||
246
game/commander/objectivefinder.py
Normal file
246
game/commander/objectivefinder.py
Normal 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)
|
||||
98
game/commander/packagebuilder.py
Normal file
98
game/commander/packagebuilder.py
Normal 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)
|
||||
228
game/commander/packagefulfiller.py
Normal file
228
game/commander/packagefulfiller.py
Normal 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
|
||||
11
game/commander/tasks/compound/aewcsupport.py
Normal file
11
game/commander/tasks/compound/aewcsupport.py
Normal 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)]
|
||||
15
game/commander/tasks/compound/attackairinfrastructure.py
Normal file
15
game/commander/tasks/compound/attackairinfrastructure.py
Normal 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)]
|
||||
15
game/commander/tasks/compound/attackbuildings.py
Normal file
15
game/commander/tasks/compound/attackbuildings.py
Normal 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)]
|
||||
12
game/commander/tasks/compound/attackgarrisons.py
Normal file
12
game/commander/tasks/compound/attackgarrisons.py
Normal 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)]
|
||||
51
game/commander/tasks/compound/capturebase.py
Normal file
51
game/commander/tasks/compound/capturebase.py
Normal 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
|
||||
13
game/commander/tasks/compound/capturebases.py
Normal file
13
game/commander/tasks/compound/capturebases.py
Normal 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)]
|
||||
19
game/commander/tasks/compound/defendbase.py
Normal file
19
game/commander/tasks/compound/defendbase.py
Normal 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)]
|
||||
13
game/commander/tasks/compound/defendbases.py
Normal file
13
game/commander/tasks/compound/defendbases.py
Normal 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)]
|
||||
24
game/commander/tasks/compound/degradeiads.py
Normal file
24
game/commander/tasks/compound/degradeiads.py
Normal 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)
|
||||
19
game/commander/tasks/compound/destroyenemygroundunits.py
Normal file
19
game/commander/tasks/compound/destroyenemygroundunits.py
Normal 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)]
|
||||
11
game/commander/tasks/compound/frontlinedefense.py
Normal file
11
game/commander/tasks/compound/frontlinedefense.py
Normal 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)]
|
||||
27
game/commander/tasks/compound/interdictreinforcements.py
Normal file
27
game/commander/tasks/compound/interdictreinforcements.py
Normal 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)]
|
||||
34
game/commander/tasks/compound/nextaction.py
Normal file
34
game/commander/tasks/compound/nextaction.py
Normal 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()]
|
||||
12
game/commander/tasks/compound/protectairspace.py
Normal file
12
game/commander/tasks/compound/protectairspace.py
Normal 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)]
|
||||
@ -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)]
|
||||
11
game/commander/tasks/compound/refuelingsupport.py
Normal file
11
game/commander/tasks/compound/refuelingsupport.py
Normal 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)]
|
||||
14
game/commander/tasks/compound/theatersupport.py
Normal file
14
game/commander/tasks/compound/theatersupport.py
Normal 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()]
|
||||
75
game/commander/tasks/frontlinestancetask.py
Normal file
75
game/commander/tasks/frontlinestancetask.py
Normal 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
|
||||
178
game/commander/tasks/packageplanningtask.py
Normal file
178
game/commander/tasks/packageplanningtask.py
Normal 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
|
||||
27
game/commander/tasks/primitive/aewc.py
Normal file
27
game/commander/tasks/primitive/aewc.py
Normal 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
|
||||
14
game/commander/tasks/primitive/aggressiveattack.py
Normal file
14
game/commander/tasks/primitive/aggressiveattack.py
Normal 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
|
||||
26
game/commander/tasks/primitive/antiship.py
Normal file
26
game/commander/tasks/primitive/antiship.py
Normal 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)
|
||||
25
game/commander/tasks/primitive/antishipping.py
Normal file
25
game/commander/tasks/primitive/antishipping.py
Normal 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()
|
||||
25
game/commander/tasks/primitive/bai.py
Normal file
25
game/commander/tasks/primitive/bai.py
Normal 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()
|
||||
28
game/commander/tasks/primitive/barcap.py
Normal file
28
game/commander/tasks/primitive/barcap.py
Normal 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
|
||||
28
game/commander/tasks/primitive/breakthroughattack.py
Normal file
28
game/commander/tasks/primitive/breakthroughattack.py
Normal 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)
|
||||
23
game/commander/tasks/primitive/cas.py
Normal file
23
game/commander/tasks/primitive/cas.py
Normal 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)
|
||||
26
game/commander/tasks/primitive/convoyinterdiction.py
Normal file
26
game/commander/tasks/primitive/convoyinterdiction.py
Normal 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()
|
||||
46
game/commander/tasks/primitive/dead.py
Normal file
46
game/commander/tasks/primitive/dead.py
Normal 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)
|
||||
14
game/commander/tasks/primitive/defensivestance.py
Normal file
14
game/commander/tasks/primitive/defensivestance.py
Normal 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
|
||||
14
game/commander/tasks/primitive/eliminationattack.py
Normal file
14
game/commander/tasks/primitive/eliminationattack.py
Normal 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
|
||||
29
game/commander/tasks/primitive/oca.py
Normal file
29
game/commander/tasks/primitive/oca.py
Normal 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()
|
||||
22
game/commander/tasks/primitive/refueling.py
Normal file
22
game/commander/tasks/primitive/refueling.py
Normal 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)
|
||||
14
game/commander/tasks/primitive/retreatstance.py
Normal file
14
game/commander/tasks/primitive/retreatstance.py
Normal 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
|
||||
26
game/commander/tasks/primitive/strike.py
Normal file
26
game/commander/tasks/primitive/strike.py
Normal 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()
|
||||
16
game/commander/tasks/theatercommandertask.py
Normal file
16
game/commander/tasks/theatercommandertask.py
Normal 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:
|
||||
...
|
||||
88
game/commander/theatercommander.py
Normal file
88
game/commander/theatercommander.py
Normal 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
|
||||
176
game/commander/theaterstate.py
Normal file
176
game/commander/theaterstate.py
Normal 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(),
|
||||
)
|
||||
@ -25,6 +25,7 @@ class AlicCodes:
|
||||
AirDefence.SNR_75V.id: 126,
|
||||
AirDefence.HQ_7_LN_SP.id: 127,
|
||||
AirDefence.HQ_7_STR_SP.id: 128,
|
||||
AirDefence.RLS_19J6.id: 130,
|
||||
AirDefence.Roland_ADS.id: 201,
|
||||
AirDefence.Patriot_str.id: 202,
|
||||
AirDefence.Hawk_sr.id: 203,
|
||||
@ -33,6 +34,7 @@ class AlicCodes:
|
||||
AirDefence.Hawk_cwar.id: 206,
|
||||
AirDefence.Gepard.id: 207,
|
||||
AirDefence.Vulcan.id: 208,
|
||||
AirDefence.NASAMS_Radar_MPQ64F1.id: 209,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
from dataclasses import dataclass
|
||||
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.savecompat import has_save_compat_for
|
||||
from game.utils import Distance, feet, nautical_miles
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -26,13 +27,26 @@ class Doctrine:
|
||||
antiship: bool
|
||||
|
||||
rendezvous_altitude: Distance
|
||||
|
||||
#: The minimum distance between the departure airfield and the hold point.
|
||||
hold_distance: Distance
|
||||
|
||||
#: The minimum distance between the hold point and the join point.
|
||||
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
|
||||
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
|
||||
egress_altitude: Distance
|
||||
|
||||
min_patrol_altitude: Distance
|
||||
max_patrol_altitude: Distance
|
||||
@ -65,6 +79,32 @@ class Doctrine:
|
||||
|
||||
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(
|
||||
cap=True,
|
||||
@ -73,13 +113,12 @@ MODERN_DOCTRINE = Doctrine(
|
||||
strike=True,
|
||||
antiship=True,
|
||||
rendezvous_altitude=feet(25000),
|
||||
hold_distance=nautical_miles(15),
|
||||
hold_distance=nautical_miles(25),
|
||||
push_distance=nautical_miles(20),
|
||||
join_distance=nautical_miles(20),
|
||||
split_distance=nautical_miles(20),
|
||||
ingress_egress_distance=nautical_miles(45),
|
||||
max_ingress_distance=nautical_miles(45),
|
||||
min_ingress_distance=nautical_miles(10),
|
||||
ingress_altitude=feet(20000),
|
||||
egress_altitude=feet(20000),
|
||||
min_patrol_altitude=feet(15000),
|
||||
max_patrol_altitude=feet(33000),
|
||||
pattern_altitude=feet(5000),
|
||||
@ -111,13 +150,12 @@ COLDWAR_DOCTRINE = Doctrine(
|
||||
strike=True,
|
||||
antiship=True,
|
||||
rendezvous_altitude=feet(22000),
|
||||
hold_distance=nautical_miles(10),
|
||||
hold_distance=nautical_miles(15),
|
||||
push_distance=nautical_miles(10),
|
||||
join_distance=nautical_miles(10),
|
||||
split_distance=nautical_miles(10),
|
||||
ingress_egress_distance=nautical_miles(30),
|
||||
max_ingress_distance=nautical_miles(30),
|
||||
min_ingress_distance=nautical_miles(10),
|
||||
ingress_altitude=feet(18000),
|
||||
egress_altitude=feet(18000),
|
||||
min_patrol_altitude=feet(10000),
|
||||
max_patrol_altitude=feet(24000),
|
||||
pattern_altitude=feet(5000),
|
||||
@ -148,14 +186,13 @@ WWII_DOCTRINE = Doctrine(
|
||||
sead=False,
|
||||
strike=True,
|
||||
antiship=True,
|
||||
hold_distance=nautical_miles(5),
|
||||
hold_distance=nautical_miles(10),
|
||||
push_distance=nautical_miles(5),
|
||||
join_distance=nautical_miles(5),
|
||||
split_distance=nautical_miles(5),
|
||||
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),
|
||||
egress_altitude=feet(8000),
|
||||
min_patrol_altitude=feet(4000),
|
||||
max_patrol_altitude=feet(15000),
|
||||
pattern_altitude=feet(5000),
|
||||
|
||||
1288
game/data/weapons.py
1288
game/data/weapons.py
File diff suppressed because it is too large
Load Diff
21
game/db.py
21
game/db.py
@ -29,8 +29,9 @@ from dcs.ships import (
|
||||
CV_1143_5,
|
||||
)
|
||||
from dcs.terrain.terrain import Airport
|
||||
from dcs.unit import Ship
|
||||
from dcs.unitgroup import ShipGroup, StaticGroup
|
||||
from dcs.unittype import UnitType
|
||||
from dcs.unittype import UnitType, FlyingType, ShipType, VehicleType
|
||||
from dcs.vehicles import (
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@ -317,6 +318,8 @@ REWARDS = {
|
||||
"comms": 10,
|
||||
"oil": 10,
|
||||
"derrick": 8,
|
||||
"village": 0.25,
|
||||
"allycamp": 0.5,
|
||||
}
|
||||
|
||||
"""
|
||||
@ -326,7 +329,7 @@ REWARDS = {
|
||||
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 name == "CVN-71 Theodore Roosevelt":
|
||||
return CVN_71
|
||||
@ -359,7 +362,15 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]:
|
||||
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():
|
||||
if v.name == name:
|
||||
return k
|
||||
@ -372,7 +383,7 @@ class DefaultLiveries:
|
||||
|
||||
|
||||
OH_58D.Liveries = DefaultLiveries
|
||||
F_16C_50.Liveries = DefaultLiveries
|
||||
F_16C_50.Liveries = DefaultLiveries # type: ignore
|
||||
P_51D_30_NA.Liveries = DefaultLiveries
|
||||
Ju_88A4.Liveries = DefaultLiveries
|
||||
B_17G.Liveries = DefaultLiveries
|
||||
|
||||
@ -29,7 +29,7 @@ from game.radio.channels import (
|
||||
ViggenRadioChannelAllocator,
|
||||
NoOpChannelAllocator,
|
||||
)
|
||||
from game.utils import Distance, Speed, feet, kph, knots
|
||||
from game.utils import Distance, Speed, feet, kph, knots, nautical_miles
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen.aircraft import FlightData
|
||||
@ -98,7 +98,7 @@ class PatrolConfig:
|
||||
@classmethod
|
||||
def from_data(cls, data: dict[str, Any]) -> PatrolConfig:
|
||||
altitude = data.get("altitude", None)
|
||||
speed = data.get("altitude", None)
|
||||
speed = data.get("speed", None)
|
||||
return PatrolConfig(
|
||||
feet(altitude) if altitude is not None else None,
|
||||
knots(speed) if speed is not None else None,
|
||||
@ -106,18 +106,55 @@ class PatrolConfig:
|
||||
|
||||
|
||||
@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
|
||||
lha_capable: bool
|
||||
always_keeps_gun: bool
|
||||
|
||||
# If true, the aircraft does not use the guns as the last resort weapons, but as a main weapon.
|
||||
# It'll RTB when it doesn't have gun ammo left.
|
||||
# If true, the aircraft does not use the guns as the last resort weapons, but as a
|
||||
# main weapon. It'll RTB when it doesn't have gun ammo left.
|
||||
gunfighter: bool
|
||||
|
||||
max_group_size: int
|
||||
patrol_altitude: Optional[Distance]
|
||||
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]
|
||||
channel_allocator: Optional[RadioChannelAllocator]
|
||||
channel_namer: Type[ChannelNamer]
|
||||
@ -147,13 +184,52 @@ class AircraftType(UnitType[FlyingType]):
|
||||
def max_speed(self) -> 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:
|
||||
from gen.radios import ChannelInUseError, MHz
|
||||
from gen.radios import ChannelInUseError, kHz
|
||||
|
||||
if self.intra_flight_radio is not None:
|
||||
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:
|
||||
radio_registry.reserve(freq)
|
||||
except ChannelInUseError:
|
||||
@ -222,6 +298,25 @@ class AircraftType(UnitType[FlyingType]):
|
||||
radio_config = RadioConfig.from_data(data.get("radios", {}))
|
||||
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:
|
||||
introduction = data["introduced"]
|
||||
if introduction is None:
|
||||
@ -233,7 +328,10 @@ class AircraftType(UnitType[FlyingType]):
|
||||
yield AircraftType(
|
||||
dcs_unit_type=aircraft,
|
||||
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,
|
||||
country_of_origin=data.get("origin", "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),
|
||||
patrol_altitude=patrol_config.altitude,
|
||||
patrol_speed=patrol_config.speed,
|
||||
max_mission_range=mission_range,
|
||||
fuel_consumption=fuel_consumption,
|
||||
intra_flight_radio=radio_config.intra_flight,
|
||||
channel_allocator=radio_config.channel_allocator,
|
||||
channel_namer=radio_config.channel_namer,
|
||||
|
||||
@ -15,7 +15,7 @@ from game.dcs.unittype import UnitType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroundUnitType(UnitType[VehicleType]):
|
||||
class GroundUnitType(UnitType[Type[VehicleType]]):
|
||||
unit_class: Optional[GroundUnitClass]
|
||||
spawn_weight: int
|
||||
|
||||
@ -88,7 +88,10 @@ class GroundUnitType(UnitType[VehicleType]):
|
||||
unit_class=unit_class,
|
||||
spawn_weight=data.get("spawn_weight", 0),
|
||||
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,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
|
||||
@ -4,12 +4,12 @@ from typing import TypeVar, Generic, Type
|
||||
|
||||
from dcs.unittype import UnitType as DcsUnitType
|
||||
|
||||
DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=DcsUnitType)
|
||||
DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitType(Generic[DcsUnitTypeT]):
|
||||
dcs_unit_type: Type[DcsUnitTypeT]
|
||||
dcs_unit_type: DcsUnitTypeT
|
||||
name: str
|
||||
description: str
|
||||
year_introduced: str
|
||||
|
||||
@ -15,9 +15,9 @@ from typing import (
|
||||
Iterator,
|
||||
List,
|
||||
TYPE_CHECKING,
|
||||
Union,
|
||||
)
|
||||
|
||||
from game import db
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.theater import Airfield, ControlPoint
|
||||
@ -77,8 +77,8 @@ class GroundLosses:
|
||||
player_airlifts: List[AirliftUnits] = field(default_factory=list)
|
||||
enemy_airlifts: List[AirliftUnits] = field(default_factory=list)
|
||||
|
||||
player_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
|
||||
enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
|
||||
player_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
|
||||
enemy_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
|
||||
|
||||
player_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.
|
||||
killed_ground_units: List[str]
|
||||
|
||||
#: Names of static units that were destroyed during the mission.
|
||||
destroyed_statics: List[str]
|
||||
#: List of descriptions of destroyed statics. Format of each element is a mapping of
|
||||
#: 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.
|
||||
base_capture_events: List[str]
|
||||
@ -134,10 +135,8 @@ class Debriefing:
|
||||
self.game = game
|
||||
self.unit_map = unit_map
|
||||
|
||||
self.player_country = game.player_country
|
||||
self.enemy_country = game.enemy_country
|
||||
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.player_country = game.blue.country_name
|
||||
self.enemy_country = game.red.country_name
|
||||
|
||||
self.air_losses = self.dead_aircraft()
|
||||
self.ground_losses = self.dead_ground_units()
|
||||
@ -164,7 +163,7 @@ class Debriefing:
|
||||
yield from self.ground_losses.enemy_airlifts
|
||||
|
||||
@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.enemy_ground_objects
|
||||
|
||||
@ -370,13 +369,13 @@ class PollDebriefingFileThread(threading.Thread):
|
||||
self.game = game
|
||||
self.unit_map = unit_map
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
def stopped(self):
|
||||
def stopped(self) -> bool:
|
||||
return self._stop_event.is_set()
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
if os.path.isfile("state.json"):
|
||||
last_modified = os.path.getmtime("state.json")
|
||||
else:
|
||||
@ -401,7 +400,7 @@ class PollDebriefingFileThread(threading.Thread):
|
||||
|
||||
|
||||
def wait_for_debriefing(
|
||||
callback: Callable[[Debriefing], None], game: Game, unit_map
|
||||
callback: Callable[[Debriefing], None], game: Game, unit_map: UnitMap
|
||||
) -> PollDebriefingFileThread:
|
||||
thread = PollDebriefingFileThread(callback, game, unit_map)
|
||||
thread.start()
|
||||
|
||||
@ -1,14 +1,10 @@
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .event import Event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater import ConflictTheater
|
||||
|
||||
|
||||
class AirWarEvent(Event):
|
||||
"""Event handler for the air battle"""
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return "AirWar"
|
||||
|
||||
@ -5,7 +5,6 @@ from typing import List, TYPE_CHECKING, Type
|
||||
|
||||
from dcs.mapping import Point
|
||||
from dcs.task import Task
|
||||
from dcs.unittype import VehicleType
|
||||
|
||||
from game import persistency
|
||||
from game.debriefing import AirLosses, Debriefing
|
||||
@ -38,13 +37,13 @@ class Event:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
game,
|
||||
game: Game,
|
||||
from_cp: ControlPoint,
|
||||
target_cp: ControlPoint,
|
||||
location: Point,
|
||||
attacker_name: str,
|
||||
defender_name: str,
|
||||
):
|
||||
) -> None:
|
||||
self.game = game
|
||||
self.from_cp = from_cp
|
||||
self.to_cp = target_cp
|
||||
@ -54,7 +53,7 @@ class Event:
|
||||
|
||||
@property
|
||||
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
|
||||
def tasks(self) -> List[Type[Task]]:
|
||||
@ -115,10 +114,10 @@ class Event:
|
||||
|
||||
def complete_aircraft_transfers(self, debriefing: Debriefing) -> None:
|
||||
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.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:
|
||||
@ -155,8 +154,8 @@ class Event:
|
||||
pilot.record.missions_flown += 1
|
||||
|
||||
def commit_pilot_experience(self) -> None:
|
||||
self._commit_pilot_experience(self.game.blue_ato)
|
||||
self._commit_pilot_experience(self.game.red_ato)
|
||||
self._commit_pilot_experience(self.game.blue.ato)
|
||||
self._commit_pilot_experience(self.game.red.ato)
|
||||
|
||||
@staticmethod
|
||||
def commit_front_line_losses(debriefing: Debriefing) -> None:
|
||||
@ -220,10 +219,10 @@ class Event:
|
||||
for loss in debriefing.ground_object_losses:
|
||||
# TODO: This should be stored in the TGO, not in the pydcs Group.
|
||||
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_losts.append(loss.unit)
|
||||
loss.group.units_losts.append(loss.unit) # type: ignore
|
||||
|
||||
def commit_building_losses(self, debriefing: Debriefing) -> None:
|
||||
for loss in debriefing.building_losses:
|
||||
@ -265,7 +264,7 @@ class Event:
|
||||
except Exception:
|
||||
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")
|
||||
|
||||
self.commit_air_losses(debriefing)
|
||||
@ -298,15 +297,16 @@ class Event:
|
||||
|
||||
delta = 0.0
|
||||
player_won = True
|
||||
status_msg: str = ""
|
||||
ally_casualties = debriefing.casualty_count(cp)
|
||||
enemy_casualties = debriefing.casualty_count(enemy_cp)
|
||||
ally_units_alive = cp.base.total_armor
|
||||
enemy_units_alive = enemy_cp.base.total_armor
|
||||
|
||||
print(ally_units_alive)
|
||||
print(enemy_units_alive)
|
||||
print(ally_casualties)
|
||||
print(enemy_casualties)
|
||||
print(f"Remaining allied units: {ally_units_alive}")
|
||||
print(f"Remaining enemy units: {enemy_units_alive}")
|
||||
print(f"Allied casualties {ally_casualties}")
|
||||
print(f"Enemy casualties {enemy_casualties}")
|
||||
|
||||
ratio = (1.0 + enemy_casualties) / (1.0 + ally_casualties)
|
||||
|
||||
@ -319,24 +319,31 @@ class Event:
|
||||
if ally_units_alive == 0:
|
||||
player_won = False
|
||||
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:
|
||||
player_won = True
|
||||
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:
|
||||
player_won = False
|
||||
delta = STRONG_DEFEAT_INFLUENCE
|
||||
status_msg = f"Allied forces are retreating along the {cp.name}-{enemy_cp.name} frontline, suffering a strong defeat."
|
||||
else:
|
||||
if enemy_casualties > ally_casualties:
|
||||
player_won = True
|
||||
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
|
||||
delta = STRONG_DEFEAT_INFLUENCE
|
||||
status_msg = f"Allied forces break through the {cp.name}-{enemy_cp.name} frontline, winning a strong victory"
|
||||
else:
|
||||
if ratio > 3:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
|
||||
if (
|
||||
@ -346,54 +353,66 @@ class Event:
|
||||
# Even with casualties if the enemy is overwhelmed, they are going to lose ground
|
||||
player_won = True
|
||||
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 (
|
||||
ally_units_alive > 3 * enemy_units_alive
|
||||
and player_aggresive
|
||||
):
|
||||
player_won = True
|
||||
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:
|
||||
# But is the enemy is not outnumbered, we lose
|
||||
# But if the enemy is not outnumbered, we lose
|
||||
player_won = False
|
||||
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
|
||||
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:
|
||||
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
|
||||
if player_won and cp.stances[enemy_cp.id] in [
|
||||
CombatStance.DEFENSIVE,
|
||||
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
|
||||
|
||||
if player_won:
|
||||
print(cp.name + " won ! factor > " + str(delta))
|
||||
cp.base.affect_strength(delta)
|
||||
enemy_cp.base.affect_strength(-delta)
|
||||
# Handle the case where there are no casualties at all on either side but both sides still have units
|
||||
if delta == 0.0:
|
||||
print(status_msg)
|
||||
info = Information(
|
||||
"Frontline Report",
|
||||
"Our ground forces from "
|
||||
+ cp.name
|
||||
+ " are making progress toward "
|
||||
+ enemy_cp.name,
|
||||
f"Our ground forces from {cp.name} reached a stalemate with enemy forces from {enemy_cp.name}.",
|
||||
self.game.turn,
|
||||
)
|
||||
self.game.informations.append(info)
|
||||
else:
|
||||
print(cp.name + " lost ! factor > " + str(delta))
|
||||
enemy_cp.base.affect_strength(delta)
|
||||
cp.base.affect_strength(-delta)
|
||||
info = Information(
|
||||
"Frontline Report",
|
||||
"Our ground forces from "
|
||||
+ cp.name
|
||||
+ " are losing ground against the enemy forces from "
|
||||
+ enemy_cp.name,
|
||||
self.game.turn,
|
||||
)
|
||||
self.game.informations.append(info)
|
||||
if player_won:
|
||||
print(status_msg)
|
||||
cp.base.affect_strength(delta)
|
||||
enemy_cp.base.affect_strength(-delta)
|
||||
info = Information(
|
||||
"Frontline Report",
|
||||
f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}",
|
||||
self.game.turn,
|
||||
)
|
||||
self.game.informations.append(info)
|
||||
else:
|
||||
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:
|
||||
""" "
|
||||
|
||||
@ -8,5 +8,5 @@ class FrontlineAttackEvent(Event):
|
||||
future unique Event handling
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return "Frontline attack"
|
||||
|
||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import itertools
|
||||
import logging
|
||||
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
|
||||
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.groundunittype import GroundUnitType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater.start_generator import ModSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class Faction:
|
||||
@ -81,10 +84,10 @@ class Faction:
|
||||
requirements: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# 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
|
||||
helicopter_carrier: List[Type[UnitType]] = field(default_factory=list)
|
||||
helicopter_carrier: List[Type[ShipType]] = field(default_factory=list)
|
||||
|
||||
# Possible carrier names
|
||||
carrier_names: List[str] = field(default_factory=list)
|
||||
@ -257,7 +260,7 @@ class Faction:
|
||||
if unit.unit_class is unit_class:
|
||||
yield unit
|
||||
|
||||
def apply_mod_settings(self, mod_settings) -> Faction:
|
||||
def apply_mod_settings(self, mod_settings: ModSettings) -> Faction:
|
||||
# aircraft
|
||||
if not mod_settings.a4_skyhawk:
|
||||
self.remove_aircraft("A-4E-C")
|
||||
@ -319,17 +322,17 @@ class Faction:
|
||||
self.remove_air_defenses("KS19Generator")
|
||||
return self
|
||||
|
||||
def remove_aircraft(self, name):
|
||||
def remove_aircraft(self, name: str) -> None:
|
||||
for i in self.aircrafts:
|
||||
if i.dcs_unit_type.id == name:
|
||||
self.aircrafts.remove(i)
|
||||
|
||||
def remove_air_defenses(self, name):
|
||||
def remove_air_defenses(self, name: str) -> None:
|
||||
for i in self.air_defenses:
|
||||
if i == name:
|
||||
self.air_defenses.remove(i)
|
||||
|
||||
def remove_vehicle(self, name):
|
||||
def remove_vehicle(self, name: str) -> None:
|
||||
for i in self.frontline_units:
|
||||
if i.dcs_unit_type.id == name:
|
||||
self.frontline_units.remove(i)
|
||||
@ -342,7 +345,7 @@ def load_ship(name: str) -> Optional[Type[ShipType]]:
|
||||
return None
|
||||
|
||||
|
||||
def load_all_ships(data) -> List[Type[ShipType]]:
|
||||
def load_all_ships(data: list[str]) -> List[Type[ShipType]]:
|
||||
items = []
|
||||
for name in data:
|
||||
item = load_ship(name)
|
||||
|
||||
3
game/flightplan/__init__.py
Normal file
3
game/flightplan/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .holdzonegeometry import HoldZoneGeometry
|
||||
from .ipzonegeometry import IpZoneGeometry
|
||||
from .joinzonegeometry import JoinZoneGeometry
|
||||
108
game/flightplan/holdzonegeometry.py
Normal file
108
game/flightplan/holdzonegeometry.py
Normal 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)
|
||||
118
game/flightplan/ipzonegeometry.py
Normal file
118
game/flightplan/ipzonegeometry.py
Normal 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)
|
||||
103
game/flightplan/joinzonegeometry.py
Normal file
103
game/flightplan/joinzonegeometry.py
Normal 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)
|
||||
418
game/game.py
418
game/game.py
@ -1,48 +1,43 @@
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
import math
|
||||
from collections import Iterator
|
||||
from datetime import date, datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Type, Union, cast
|
||||
|
||||
from dcs.action import Coalition
|
||||
from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors
|
||||
from dcs.mapping import Point
|
||||
from dcs.task import CAP, CAS, PinpointStrike
|
||||
from dcs.vehicles import AirDefence
|
||||
from pydcs_extensions.a4ec.a4ec import A_4E_C
|
||||
from faker import Faker
|
||||
|
||||
from game import db
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
from game.models.game_stats import GameStats
|
||||
from game.plugins import LuaPluginManager
|
||||
from gen import aircraft, naming
|
||||
from gen import naming
|
||||
from gen.ato import AirTaskingOrder
|
||||
from gen.conflictgen import Conflict
|
||||
from gen.flights.ai_flight_planner import CoalitionMissionPlanner
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import FlightType
|
||||
from gen.ground_forces.ai_ground_planner import GroundPlanner
|
||||
from . import persistency
|
||||
from .coalition import Coalition
|
||||
from .debriefing import Debriefing
|
||||
from .event.event import Event
|
||||
from .event.frontlineattack import FrontlineAttackEvent
|
||||
from .factions.faction import Faction
|
||||
from .income import Income
|
||||
from .infos.information import Information
|
||||
from .navmesh import NavMesh
|
||||
from .procurement import AircraftProcurementRequest, ProcurementAi
|
||||
from .procurement import AircraftProcurementRequest
|
||||
from .profiling import logged_duration
|
||||
from .settings import Settings, AutoAtoBehavior
|
||||
from .settings import Settings
|
||||
from .squadrons import AirWing
|
||||
from .theater import ConflictTheater
|
||||
from .theater import ConflictTheater, ControlPoint
|
||||
from .theater.bullseye import Bullseye
|
||||
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
|
||||
from .threatzones import ThreatZones
|
||||
from .transfers import PendingTransfers
|
||||
from .unitmap import UnitMap
|
||||
from .weather import Conditions, TimeOfDay
|
||||
|
||||
@ -100,151 +95,97 @@ class Game:
|
||||
self.settings = settings
|
||||
self.events: List[Event] = []
|
||||
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
|
||||
# increment this to turn 0 before it reaches the player.
|
||||
self.turn = -1
|
||||
# NB: This is the *start* date. It is never updated.
|
||||
self.date = date(start_date.year, start_date.month, start_date.day)
|
||||
self.game_stats = GameStats()
|
||||
self.game_stats.update(self)
|
||||
self.notes = ""
|
||||
self.ground_planners: dict[int, GroundPlanner] = {}
|
||||
self.informations = []
|
||||
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.
|
||||
self.__culling_zones: List[Point] = []
|
||||
self.__destroyed_units: List[str] = []
|
||||
self.__destroyed_units: list[dict[str, Union[float, str]]] = []
|
||||
self.savepath = ""
|
||||
self.budget = player_budget
|
||||
self.enemy_budget = enemy_budget
|
||||
self.current_unit_id = 0
|
||||
self.current_group_id = 0
|
||||
self.name_generator = naming.namegen
|
||||
|
||||
self.conditions = self.generate_conditions()
|
||||
|
||||
self.blue_transit_network = TransitNetwork()
|
||||
self.red_transit_network = TransitNetwork()
|
||||
|
||||
self.blue_procurement_requests: List[AircraftProcurementRequest] = []
|
||||
self.red_procurement_requests: List[AircraftProcurementRequest] = []
|
||||
|
||||
self.blue_ato = AirTaskingOrder()
|
||||
self.red_ato = AirTaskingOrder()
|
||||
|
||||
self.blue_bullseye = Bullseye(Point(0, 0))
|
||||
self.red_bullseye = Bullseye(Point(0, 0))
|
||||
self.sanitize_sides(player_faction, enemy_faction)
|
||||
self.blue = Coalition(self, player_faction, player_budget, player=True)
|
||||
self.red = Coalition(self, enemy_faction, enemy_budget, player=False)
|
||||
self.blue.set_opponent(self.red)
|
||||
self.red.set_opponent(self.blue)
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
self.__dict__.update(state)
|
||||
# Regenerate any state that was not persisted.
|
||||
self.on_load()
|
||||
|
||||
def ato_for(self, player: bool) -> AirTaskingOrder:
|
||||
if player:
|
||||
return self.blue_ato
|
||||
return self.red_ato
|
||||
@property
|
||||
def coalitions(self) -> Iterator[Coalition]:
|
||||
yield self.blue
|
||||
yield self.red
|
||||
|
||||
def procurement_requests_for(
|
||||
self, player: bool
|
||||
) -> List[AircraftProcurementRequest]:
|
||||
if player:
|
||||
return self.blue_procurement_requests
|
||||
return self.red_procurement_requests
|
||||
def ato_for(self, player: bool) -> AirTaskingOrder:
|
||||
return self.coalition_for(player).ato
|
||||
|
||||
def transit_network_for(self, player: bool) -> TransitNetwork:
|
||||
if player:
|
||||
return self.blue_transit_network
|
||||
return self.red_transit_network
|
||||
return self.coalition_for(player).transit_network
|
||||
|
||||
def generate_conditions(self) -> Conditions:
|
||||
return Conditions.generate(
|
||||
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
|
||||
:return:
|
||||
"""
|
||||
if self.player_country == self.enemy_country:
|
||||
if self.player_country == "USA":
|
||||
self.enemy_country = "USAF Aggressors"
|
||||
elif self.player_country == "Russia":
|
||||
self.enemy_country = "USSR"
|
||||
if player_faction.country == enemy_faction.country:
|
||||
if player_faction.country == "USA":
|
||||
enemy_faction.country = "USAF Aggressors"
|
||||
elif player_faction.country == "Russia":
|
||||
enemy_faction.country = "USSR"
|
||||
else:
|
||||
self.enemy_country = "Russia"
|
||||
enemy_faction.country = "Russia"
|
||||
|
||||
def faction_for(self, player: bool) -> Faction:
|
||||
if player:
|
||||
return self.player_faction
|
||||
return self.enemy_faction
|
||||
return self.coalition_for(player).faction
|
||||
|
||||
def faker_for(self, player: bool) -> Faker:
|
||||
if player:
|
||||
return self.blue_faker
|
||||
return self.red_faker
|
||||
return self.coalition_for(player).faker
|
||||
|
||||
def air_wing_for(self, player: bool) -> AirWing:
|
||||
if player:
|
||||
return self.blue_air_wing
|
||||
return self.red_air_wing
|
||||
return self.coalition_for(player).air_wing
|
||||
|
||||
def country_for(self, player: bool) -> str:
|
||||
if player:
|
||||
return self.player_country
|
||||
return self.enemy_country
|
||||
return self.coalition_for(player).country_name
|
||||
|
||||
def bullseye_for(self, player: bool) -> Bullseye:
|
||||
if player:
|
||||
return self.blue_bullseye
|
||||
return self.red_bullseye
|
||||
return self.coalition_for(player).bullseye
|
||||
|
||||
def _roll(self, prob, mult):
|
||||
if self.settings.version == "dev":
|
||||
# always generate all events for dev
|
||||
return 100
|
||||
else:
|
||||
return random.randint(1, 100) <= prob * mult
|
||||
|
||||
def _generate_player_event(self, event_class, player_cp, enemy_cp):
|
||||
def _generate_player_event(
|
||||
self, event_class: Type[Event], player_cp: ControlPoint, enemy_cp: ControlPoint
|
||||
) -> None:
|
||||
self.events.append(
|
||||
event_class(
|
||||
self,
|
||||
player_cp,
|
||||
enemy_cp,
|
||||
enemy_cp.position,
|
||||
self.player_faction.name,
|
||||
self.enemy_faction.name,
|
||||
self.blue.faction.name,
|
||||
self.red.faction.name,
|
||||
)
|
||||
)
|
||||
|
||||
@ -259,7 +200,7 @@ class Game:
|
||||
else:
|
||||
return USAFAggressors
|
||||
|
||||
def _generate_events(self):
|
||||
def _generate_events(self) -> None:
|
||||
for front_line in self.theater.conflicts():
|
||||
self._generate_player_event(
|
||||
FrontlineAttackEvent,
|
||||
@ -267,27 +208,21 @@ class Game:
|
||||
front_line.red_cp,
|
||||
)
|
||||
|
||||
def adjust_budget(self, amount: float, player: bool) -> None:
|
||||
def coalition_for(self, player: bool) -> Coalition:
|
||||
if player:
|
||||
self.budget += amount
|
||||
else:
|
||||
self.enemy_budget += amount
|
||||
return self.blue
|
||||
return self.red
|
||||
|
||||
def process_player_income(self):
|
||||
self.budget += Income(self, player=True).total
|
||||
def adjust_budget(self, amount: float, player: bool) -> None:
|
||||
self.coalition_for(player).adjust_budget(amount)
|
||||
|
||||
def process_enemy_income(self):
|
||||
# TODO: Clean up save compat.
|
||||
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:
|
||||
@staticmethod
|
||||
def initiate_event(event: Event) -> UnitMap:
|
||||
# assert event in self.events
|
||||
logging.info("Generating {} (regular)".format(event))
|
||||
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))
|
||||
event.commit(debriefing)
|
||||
|
||||
@ -296,16 +231,6 @@ class Game:
|
||||
else:
|
||||
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:
|
||||
if not hasattr(self, "name_generator"):
|
||||
self.name_generator = naming.namegen
|
||||
@ -320,36 +245,50 @@ class Game:
|
||||
self.compute_conflicts_position()
|
||||
if not game_still_initializing:
|
||||
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:
|
||||
"""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(
|
||||
Information("End of turn #" + str(self.turn), "-" * 40, 0)
|
||||
)
|
||||
self.turn += 1
|
||||
|
||||
# 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.compute_transit_networks()
|
||||
# The coalition-specific turn finalization *must* happen before unit deliveries,
|
||||
# since the coalition-specific finalization handles transit network updates and
|
||||
# transfer processing. If in the other order, units may be delivered to captured
|
||||
# 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:
|
||||
control_point.process_turn(self)
|
||||
|
||||
self.blue_air_wing.replenish()
|
||||
self.red_air_wing.replenish()
|
||||
|
||||
if not skipped:
|
||||
for cp in self.theater.player_points():
|
||||
cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY)
|
||||
@ -360,14 +299,21 @@ class Game:
|
||||
|
||||
self.conditions = self.generate_conditions()
|
||||
|
||||
self.process_enemy_income()
|
||||
self.process_player_income()
|
||||
|
||||
def begin_turn_0(self) -> None:
|
||||
"""Initialization for the first turn of the game."""
|
||||
self.turn = 0
|
||||
self.blue.preinit_turn_0()
|
||||
self.red.preinit_turn_0()
|
||||
self.initialize_turn()
|
||||
|
||||
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")
|
||||
with logged_duration("Turn finalization"):
|
||||
self.finish_turn(no_action)
|
||||
@ -377,7 +323,7 @@ class Game:
|
||||
# Autosave progress
|
||||
persistency.autosave(self)
|
||||
|
||||
def check_win_loss(self):
|
||||
def check_win_loss(self) -> TurnState:
|
||||
player_airbases = {
|
||||
cp for cp in self.theater.player_points() if cp.runway_is_operational()
|
||||
}
|
||||
@ -394,24 +340,50 @@ class Game:
|
||||
|
||||
def set_bullseye(self) -> None:
|
||||
player_cp, enemy_cp = self.theater.closest_opposing_control_points()
|
||||
self.blue_bullseye = Bullseye(enemy_cp.position)
|
||||
self.red_bullseye = Bullseye(player_cp.position)
|
||||
self.blue.bullseye = Bullseye(enemy_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._generate_events()
|
||||
|
||||
self.set_bullseye()
|
||||
|
||||
# Update statistics
|
||||
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
|
||||
turn_state = self.check_win_loss()
|
||||
if turn_state in (TurnState.LOSS, TurnState.WIN):
|
||||
@ -422,59 +394,26 @@ class Game:
|
||||
self.compute_conflicts_position()
|
||||
with logged_duration("Threat zone computation"):
|
||||
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.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:
|
||||
if cp.has_frontline:
|
||||
gplanner = GroundPlanner(cp, self)
|
||||
gplanner.plan_groundwar()
|
||||
self.ground_planners[cp.id] = gplanner
|
||||
|
||||
self.plan_procurement()
|
||||
|
||||
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 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 initialize_turn_for(self, player: bool) -> None:
|
||||
self.aircraft_inventory.reset(player)
|
||||
for cp in self.theater.control_points_for(player):
|
||||
self.aircraft_inventory.set_from_control_point(cp)
|
||||
self.coalition_for(player).initialize_turn()
|
||||
|
||||
def message(self, text: str) -> None:
|
||||
self.informations.append(Information(text, turn=self.turn))
|
||||
@ -487,48 +426,36 @@ class Game:
|
||||
def current_day(self) -> date:
|
||||
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
|
||||
"""
|
||||
self.current_unit_id += 1
|
||||
return self.current_unit_id
|
||||
|
||||
def next_group_id(self):
|
||||
def next_group_id(self) -> int:
|
||||
"""
|
||||
Next unit id for pre-generated units
|
||||
"""
|
||||
self.current_group_id += 1
|
||||
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:
|
||||
return TransitNetworkBuilder(self.theater, player).build()
|
||||
|
||||
def compute_threat_zones(self) -> None:
|
||||
self.blue_threat_zone = ThreatZones.for_faction(self, player=True)
|
||||
self.red_threat_zone = ThreatZones.for_faction(self, player=False)
|
||||
self.blue_navmesh = NavMesh.from_threat_zones(
|
||||
self.red_threat_zone, self.theater
|
||||
)
|
||||
self.red_navmesh = NavMesh.from_threat_zones(
|
||||
self.blue_threat_zone, self.theater
|
||||
)
|
||||
self.blue.compute_threat_zones()
|
||||
self.red.compute_threat_zones()
|
||||
self.blue.compute_nav_meshes()
|
||||
self.red.compute_nav_meshes()
|
||||
|
||||
def threat_zone_for(self, player: bool) -> ThreatZones:
|
||||
if player:
|
||||
return self.blue_threat_zone
|
||||
return self.red_threat_zone
|
||||
return self.coalition_for(player).threat_zone
|
||||
|
||||
def navmesh_for(self, player: bool) -> NavMesh:
|
||||
if player:
|
||||
return self.blue_navmesh
|
||||
return self.red_navmesh
|
||||
return self.coalition_for(player).nav_mesh
|
||||
|
||||
def compute_conflicts_position(self):
|
||||
def compute_conflicts_position(self) -> None:
|
||||
"""
|
||||
Compute the current conflict center position(s), mainly used for culling calculation
|
||||
: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 len(zones) == 0:
|
||||
cpoint = None
|
||||
min_distance = sys.maxsize
|
||||
min_distance = math.inf
|
||||
for cp in self.theater.player_points():
|
||||
for cp2 in self.theater.enemy_points():
|
||||
d = cp.position.distance_to_point(cp2.position)
|
||||
@ -569,7 +496,7 @@ class Game:
|
||||
if cpoint is not None:
|
||||
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:
|
||||
if package.primary_task is FlightType.BARCAP:
|
||||
# BARCAPs will be planned at most locations on smaller theaters,
|
||||
@ -587,15 +514,15 @@ class Game:
|
||||
|
||||
self.__culling_zones = zones
|
||||
|
||||
def add_destroyed_units(self, data):
|
||||
pos = Point(data["x"], data["z"])
|
||||
def add_destroyed_units(self, data: dict[str, Union[float, str]]) -> None:
|
||||
pos = Point(cast(float, data["x"]), cast(float, data["z"]))
|
||||
if self.theater.is_on_land(pos):
|
||||
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
|
||||
|
||||
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
|
||||
:param pos: Position you are tryng to spawn stuff at
|
||||
@ -608,38 +535,17 @@ class Game:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_culling_zones(self):
|
||||
def get_culling_zones(self) -> list[Point]:
|
||||
"""
|
||||
Check culling points
|
||||
:return: List of culling zones
|
||||
"""
|
||||
return self.__culling_zones
|
||||
|
||||
# 1 = red, 2 = blue
|
||||
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):
|
||||
def process_win_loss(self, turn_state: TurnState) -> None:
|
||||
if turn_state is TurnState.WIN:
|
||||
return self.message(
|
||||
"Congratulations, you are victorious! Start a new campaign to continue."
|
||||
self.message(
|
||||
"Congratulations, you are victorious! Start a new campaign to continue."
|
||||
)
|
||||
elif turn_state is TurnState.LOSS:
|
||||
return self.message(
|
||||
"Game Over, you lose. Start a new campaign to continue."
|
||||
)
|
||||
self.message("Game Over, you lose. Start a new campaign to continue.")
|
||||
|
||||
127
game/htn.py
Normal file
127
game/htn.py
Normal 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)
|
||||
@ -14,10 +14,10 @@ class BuildingIncome:
|
||||
name: str
|
||||
category: str
|
||||
number: int
|
||||
income_per_building: int
|
||||
income_per_building: float
|
||||
|
||||
@property
|
||||
def income(self) -> int:
|
||||
def income(self) -> float:
|
||||
return self.number * self.income_per_building
|
||||
|
||||
|
||||
|
||||
@ -2,13 +2,13 @@ import datetime
|
||||
|
||||
|
||||
class Information:
|
||||
def __init__(self, title="", text="", turn=0):
|
||||
def __init__(self, title: str = "", text: str = "", turn: int = 0) -> None:
|
||||
self.title = title
|
||||
self.text = text
|
||||
self.turn = turn
|
||||
self.timestamp = datetime.datetime.now()
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return "[{}][{}] {} {}".format(
|
||||
self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if self.timestamp is not None
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
"""Inventory management APIs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING, Type
|
||||
|
||||
from dcs.unittype import FlyingType
|
||||
from collections import defaultdict, Iterator, Iterable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from gen.flights.flight import Flight
|
||||
@ -18,7 +16,12 @@ class ControlPointAircraftInventory:
|
||||
|
||||
def __init__(self, control_point: ControlPoint) -> None:
|
||||
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:
|
||||
"""Adds aircraft to the inventory.
|
||||
@ -67,7 +70,7 @@ class ControlPointAircraftInventory:
|
||||
yield aircraft
|
||||
|
||||
@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."""
|
||||
for aircraft, count in self.inventory.items():
|
||||
if count > 0:
|
||||
@ -82,14 +85,22 @@ class GlobalAircraftInventory:
|
||||
"""Game-wide aircraft inventory."""
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clears all control points and their inventories."""
|
||||
def clone(self) -> GlobalAircraftInventory:
|
||||
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():
|
||||
inventory.clear()
|
||||
if inventory.control_point.captured == for_player:
|
||||
inventory.clear()
|
||||
|
||||
def set_from_control_point(self, control_point: ControlPoint) -> None:
|
||||
"""Set the control point's aircraft inventory.
|
||||
@ -110,7 +121,7 @@ class GlobalAircraftInventory:
|
||||
@property
|
||||
def available_types_for_player(self) -> Iterator[AircraftType]:
|
||||
"""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():
|
||||
if control_point.captured:
|
||||
for aircraft in inventory.types_available:
|
||||
|
||||
@ -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
|
||||
@ -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:
|
||||
@ -10,7 +15,7 @@ class FactionTurnMetadata:
|
||||
vehicles_count: int = 0
|
||||
sam_count: int = 0
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.aircraft_count = 0
|
||||
self.vehicles_count = 0
|
||||
self.sam_count = 0
|
||||
@ -24,7 +29,7 @@ class GameTurnMetadata:
|
||||
allied_units: FactionTurnMetadata
|
||||
enemy_units: FactionTurnMetadata
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.allied_units = FactionTurnMetadata()
|
||||
self.enemy_units = FactionTurnMetadata()
|
||||
|
||||
@ -34,15 +39,19 @@ class GameStats:
|
||||
Store statistics for the current game
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.data_per_turn: List[GameTurnMetadata] = []
|
||||
|
||||
def update(self, game):
|
||||
def update(self, game: Game) -> None:
|
||||
"""
|
||||
Save data for current turn
|
||||
: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()
|
||||
|
||||
for cp in game.theater.controlpoints:
|
||||
|
||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
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.action import DoScript, DoScriptFile
|
||||
@ -16,11 +16,11 @@ from dcs.triggers import TriggerStart
|
||||
|
||||
from game.plugins import LuaPluginManager
|
||||
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.airfields import AIRFIELD_DATA
|
||||
from gen.airsupportgen import AirSupport, AirSupportConflictGenerator
|
||||
from gen.armor import GroundConflictGenerator, JtacInfo
|
||||
from gen.airsupportgen import AirSupportConflictGenerator
|
||||
from gen.armor import GroundConflictGenerator
|
||||
from gen.beacons import load_beacons_for_terrain
|
||||
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
|
||||
from gen.cargoshipgen import CargoShipGenerator
|
||||
@ -29,6 +29,7 @@ from gen.environmentgen import EnvironmentGenerator
|
||||
from gen.forcedoptionsgen import ForcedOptionsGenerator
|
||||
from gen.groundobjectsgen import GroundObjectsGenerator
|
||||
from gen.kneeboard import KneeboardGenerator
|
||||
from gen.lasercoderegistry import LaserCodeRegistry
|
||||
from gen.naming import namegen
|
||||
from gen.radios import RadioFrequency, RadioRegistry
|
||||
from gen.tacan import TacanRegistry
|
||||
@ -50,6 +51,7 @@ class Operation:
|
||||
groundobjectgen: GroundObjectsGenerator
|
||||
radio_registry: RadioRegistry
|
||||
tacan_registry: TacanRegistry
|
||||
laser_code_registry: LaserCodeRegistry
|
||||
game: Game
|
||||
trigger_radius = TRIGGER_RADIUS_MEDIUM
|
||||
is_quick = None
|
||||
@ -58,11 +60,11 @@ class Operation:
|
||||
enemy_awacs_enabled = True
|
||||
ca_slots = 1
|
||||
unit_map: UnitMap
|
||||
jtacs: List[JtacInfo] = []
|
||||
plugin_scripts: List[str] = []
|
||||
air_support = AirSupport()
|
||||
|
||||
@classmethod
|
||||
def prepare(cls, game: Game):
|
||||
def prepare(cls, game: Game) -> None:
|
||||
with open("resources/default_options.lua", "r") as f:
|
||||
options_dict = loads(f.read())["options"]
|
||||
cls._set_mission(Mission(game.theater.terrain))
|
||||
@ -70,20 +72,6 @@ class Operation:
|
||||
cls._setup_mission_coalitions()
|
||||
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
|
||||
def air_conflict(cls) -> Conflict:
|
||||
assert cls.game
|
||||
@ -95,10 +83,10 @@ class Operation:
|
||||
return Conflict(
|
||||
cls.game.theater,
|
||||
FrontLine(player_cp, enemy_cp),
|
||||
cls.game.player_faction.name,
|
||||
cls.game.enemy_faction.name,
|
||||
cls.game.player_country,
|
||||
cls.game.enemy_country,
|
||||
cls.game.blue.faction.name,
|
||||
cls.game.red.faction.name,
|
||||
cls.current_mission.country(cls.game.blue.country_name),
|
||||
cls.current_mission.country(cls.game.red.country_name),
|
||||
mid_point,
|
||||
)
|
||||
|
||||
@ -107,20 +95,19 @@ class Operation:
|
||||
cls.current_mission = mission
|
||||
|
||||
@classmethod
|
||||
def _setup_mission_coalitions(cls):
|
||||
def _setup_mission_coalitions(cls) -> None:
|
||||
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(
|
||||
"red", bullseye=cls.game.red_bullseye.to_pydcs()
|
||||
"red", bullseye=cls.game.red.bullseye.to_pydcs()
|
||||
)
|
||||
cls.current_mission.coalition["neutrals"] = Coalition(
|
||||
"neutrals", bullseye=Bullseye(Point(0, 0)).to_pydcs()
|
||||
)
|
||||
|
||||
p_country = cls.game.player_country
|
||||
e_country = cls.game.enemy_country
|
||||
|
||||
p_country = cls.game.blue.country_name
|
||||
e_country = cls.game.red.country_name
|
||||
cls.current_mission.coalition["blue"].add_country(
|
||||
country_dict[db.country_id_from_name(p_country)]()
|
||||
)
|
||||
@ -174,10 +161,9 @@ class Operation:
|
||||
def notify_info_generators(
|
||||
cls,
|
||||
groundobjectgen: GroundObjectsGenerator,
|
||||
airsupportgen: AirSupportConflictGenerator,
|
||||
jtacs: List[JtacInfo],
|
||||
air_support: AirSupport,
|
||||
airgen: AircraftConflictGenerator,
|
||||
):
|
||||
) -> None:
|
||||
"""Generates subscribed MissionInfoGenerator objects (currently kneeboards and briefings)"""
|
||||
|
||||
gens: List[MissionInfoGenerator] = [
|
||||
@ -188,15 +174,15 @@ class Operation:
|
||||
for dynamic_runway in groundobjectgen.runways.values():
|
||||
gen.add_dynamic_runway(dynamic_runway)
|
||||
|
||||
for tanker in airsupportgen.air_support.tankers:
|
||||
for tanker in air_support.tankers:
|
||||
if tanker.blue:
|
||||
gen.add_tanker(tanker)
|
||||
|
||||
for aewc in airsupportgen.air_support.awacs:
|
||||
for aewc in air_support.awacs:
|
||||
if aewc.blue:
|
||||
gen.add_awacs(aewc)
|
||||
|
||||
for jtac in jtacs:
|
||||
for jtac in air_support.jtacs:
|
||||
if jtac.blue:
|
||||
gen.add_jtac(jtac)
|
||||
|
||||
@ -221,6 +207,10 @@ class Operation:
|
||||
for frequency in unique_map_frequencies:
|
||||
cls.radio_registry.reserve(frequency)
|
||||
|
||||
@classmethod
|
||||
def create_laser_code_registry(cls) -> None:
|
||||
cls.laser_code_registry = LaserCodeRegistry()
|
||||
|
||||
@classmethod
|
||||
def assign_channels_to_flights(
|
||||
cls, flights: List[FlightData], air_support: AirSupport
|
||||
@ -265,7 +255,7 @@ class Operation:
|
||||
# beacon list.
|
||||
|
||||
@classmethod
|
||||
def _generate_ground_units(cls):
|
||||
def _generate_ground_units(cls) -> None:
|
||||
cls.groundobjectgen = GroundObjectsGenerator(
|
||||
cls.current_mission,
|
||||
cls.game,
|
||||
@ -280,18 +270,23 @@ class Operation:
|
||||
"""Add destroyed units to the Mission"""
|
||||
for d in cls.game.get_destroyed_units():
|
||||
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:
|
||||
continue
|
||||
|
||||
pos = Point(d["x"], d["z"])
|
||||
pos = Point(cast(float, d["x"]), cast(float, d["z"]))
|
||||
if (
|
||||
utype is not None
|
||||
and not cls.game.position_culled(pos)
|
||||
and cls.game.settings.perf_destroyed_units
|
||||
):
|
||||
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="",
|
||||
_type=utype,
|
||||
hidden=True,
|
||||
@ -303,18 +298,22 @@ class Operation:
|
||||
@classmethod
|
||||
def generate(cls) -> UnitMap:
|
||||
"""Build the final Mission to be exported"""
|
||||
cls.air_support = AirSupport()
|
||||
cls.create_unit_map()
|
||||
cls.create_radio_registries()
|
||||
cls.create_laser_code_registry()
|
||||
# Set mission time and weather conditions.
|
||||
EnvironmentGenerator(cls.current_mission, cls.game.conditions).generate()
|
||||
cls._generate_ground_units()
|
||||
cls._generate_transports()
|
||||
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.assign_channels_to_flights(
|
||||
cls.airgen.flights, cls.airsupportgen.air_support
|
||||
)
|
||||
cls._generate_ground_conflicts()
|
||||
|
||||
# Triggers
|
||||
triggersgen = TriggersGenerator(cls.current_mission, cls.game)
|
||||
@ -334,7 +333,7 @@ class Operation:
|
||||
if cls.game.settings.perf_smoke_gen:
|
||||
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
|
||||
cls.plugin_scripts.clear()
|
||||
@ -346,9 +345,7 @@ class Operation:
|
||||
cls.assign_channels_to_flights(
|
||||
cls.airgen.flights, cls.airsupportgen.air_support
|
||||
)
|
||||
cls.notify_info_generators(
|
||||
cls.groundobjectgen, cls.airsupportgen, cls.jtacs, cls.airgen
|
||||
)
|
||||
cls.notify_info_generators(cls.groundobjectgen, cls.air_support, cls.airgen)
|
||||
cls.reset_naming_ids()
|
||||
return cls.unit_map
|
||||
|
||||
@ -364,6 +361,7 @@ class Operation:
|
||||
cls.game,
|
||||
cls.radio_registry,
|
||||
cls.tacan_registry,
|
||||
cls.air_support,
|
||||
)
|
||||
cls.airsupportgen.generate()
|
||||
|
||||
@ -374,6 +372,7 @@ class Operation:
|
||||
cls.game,
|
||||
cls.radio_registry,
|
||||
cls.tacan_registry,
|
||||
cls.laser_code_registry,
|
||||
cls.unit_map,
|
||||
air_support=cls.airsupportgen.air_support,
|
||||
)
|
||||
@ -381,32 +380,31 @@ class Operation:
|
||||
cls.airgen.clear_parking_slots()
|
||||
|
||||
cls.airgen.generate_flights(
|
||||
cls.current_mission.country(cls.game.player_country),
|
||||
cls.game.blue_ato,
|
||||
cls.current_mission.country(cls.game.blue.country_name),
|
||||
cls.game.blue.ato,
|
||||
cls.groundobjectgen.runways,
|
||||
)
|
||||
cls.airgen.generate_flights(
|
||||
cls.current_mission.country(cls.game.enemy_country),
|
||||
cls.game.red_ato,
|
||||
cls.current_mission.country(cls.game.red.country_name),
|
||||
cls.game.red.ato,
|
||||
cls.groundobjectgen.runways,
|
||||
)
|
||||
cls.airgen.spawn_unused_aircraft(
|
||||
cls.current_mission.country(cls.game.player_country),
|
||||
cls.current_mission.country(cls.game.enemy_country),
|
||||
cls.current_mission.country(cls.game.blue.country_name),
|
||||
cls.current_mission.country(cls.game.red.country_name),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _generate_ground_conflicts(cls) -> None:
|
||||
"""For each frontline in the Operation, generate the ground conflicts and JTACs"""
|
||||
cls.jtacs = []
|
||||
for front_line in cls.game.theater.conflicts():
|
||||
player_cp = front_line.blue_cp
|
||||
enemy_cp = front_line.red_cp
|
||||
conflict = Conflict.frontline_cas_conflict(
|
||||
cls.game.player_faction.name,
|
||||
cls.game.enemy_faction.name,
|
||||
cls.current_mission.country(cls.game.player_country),
|
||||
cls.current_mission.country(cls.game.enemy_country),
|
||||
cls.game.blue.faction.name,
|
||||
cls.game.red.faction.name,
|
||||
cls.current_mission.country(cls.game.blue.country_name),
|
||||
cls.current_mission.country(cls.game.red.country_name),
|
||||
front_line,
|
||||
cls.game.theater,
|
||||
)
|
||||
@ -420,10 +418,13 @@ class Operation:
|
||||
player_gp,
|
||||
enemy_gp,
|
||||
player_cp.stances[enemy_cp.id],
|
||||
enemy_cp.stances[player_cp.id],
|
||||
cls.unit_map,
|
||||
cls.radio_registry,
|
||||
cls.air_support,
|
||||
cls.laser_code_registry,
|
||||
)
|
||||
ground_conflict_gen.generate()
|
||||
cls.jtacs.extend(ground_conflict_gen.jtacs)
|
||||
|
||||
@classmethod
|
||||
def _generate_transports(cls) -> None:
|
||||
@ -432,15 +433,12 @@ class Operation:
|
||||
CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate()
|
||||
|
||||
@classmethod
|
||||
def reset_naming_ids(cls):
|
||||
def reset_naming_ids(cls) -> None:
|
||||
namegen.reset_numbers()
|
||||
|
||||
@classmethod
|
||||
def generate_lua(
|
||||
cls,
|
||||
airgen: AircraftConflictGenerator,
|
||||
airsupportgen: AirSupportConflictGenerator,
|
||||
jtacs: List[JtacInfo],
|
||||
cls, airgen: AircraftConflictGenerator, air_support: AirSupport
|
||||
) -> None:
|
||||
# TODO: Refactor this
|
||||
luaData = {
|
||||
@ -453,8 +451,8 @@ class Operation:
|
||||
"BlueAA": {},
|
||||
} # type: ignore
|
||||
|
||||
for tanker in airsupportgen.air_support.tankers:
|
||||
luaData["Tankers"][tanker.callsign] = {
|
||||
for i, tanker in enumerate(air_support.tankers):
|
||||
luaData["Tankers"][i] = {
|
||||
"dcsGroupName": tanker.group_name,
|
||||
"callsign": tanker.callsign,
|
||||
"variant": tanker.variant,
|
||||
@ -462,23 +460,23 @@ class Operation:
|
||||
"tacan": str(tanker.tacan.number) + tanker.tacan.band.name,
|
||||
}
|
||||
|
||||
if airsupportgen.air_support.awacs:
|
||||
for awacs in airsupportgen.air_support.awacs:
|
||||
luaData["AWACs"][awacs.callsign] = {
|
||||
"dcsGroupName": awacs.group_name,
|
||||
"callsign": awacs.callsign,
|
||||
"radio": awacs.freq.mhz,
|
||||
}
|
||||
for i, awacs in enumerate(air_support.awacs):
|
||||
luaData["AWACs"][i] = {
|
||||
"dcsGroupName": awacs.group_name,
|
||||
"callsign": awacs.callsign,
|
||||
"radio": awacs.freq.mhz,
|
||||
}
|
||||
|
||||
for jtac in jtacs:
|
||||
luaData["JTACs"][jtac.callsign] = {
|
||||
for i, jtac in enumerate(air_support.jtacs):
|
||||
luaData["JTACs"][i] = {
|
||||
"dcsGroupName": jtac.group_name,
|
||||
"callsign": jtac.callsign,
|
||||
"zone": jtac.region,
|
||||
"dcsUnit": jtac.unit_name,
|
||||
"laserCode": jtac.code,
|
||||
"radio": jtac.freq.mhz,
|
||||
}
|
||||
|
||||
flight_count = 0
|
||||
for flight in airgen.flights:
|
||||
if flight.friendly and flight.flight_type in [
|
||||
FlightType.ANTISHIP,
|
||||
@ -499,7 +497,7 @@ class Operation:
|
||||
elif hasattr(flightTarget, "name"):
|
||||
flightTargetName = flightTarget.name
|
||||
flightTargetType = flightType + " TGT (Airbase)"
|
||||
luaData["TargetPoints"][flightTargetName] = {
|
||||
luaData["TargetPoints"][flight_count] = {
|
||||
"name": flightTargetName,
|
||||
"type": flightTargetType,
|
||||
"position": {
|
||||
@ -507,6 +505,7 @@ class Operation:
|
||||
"y": flightTarget.position.y,
|
||||
},
|
||||
}
|
||||
flight_count += 1
|
||||
|
||||
for cp in cls.game.theater.controlpoints:
|
||||
for ground_object in cp.ground_objects:
|
||||
@ -592,7 +591,8 @@ class Operation:
|
||||
zone = data["zone"]
|
||||
laserCode = data["laserCode"]
|
||||
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 += "}"
|
||||
|
||||
# Process the Target Points
|
||||
|
||||
23
game/orderedset.py
Normal file
23
game/orderedset.py
Normal 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()
|
||||
@ -1,15 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import shutil
|
||||
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
|
||||
|
||||
|
||||
def setup(user_folder: str):
|
||||
def setup(user_folder: str) -> None:
|
||||
global _dcs_saved_game_folder
|
||||
_dcs_saved_game_folder = user_folder
|
||||
if not save_dir().exists():
|
||||
@ -38,7 +42,7 @@ def mission_path_for(name: str) -> str:
|
||||
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:
|
||||
try:
|
||||
save = pickle.load(f)
|
||||
@ -49,7 +53,7 @@ def load_game(path):
|
||||
return None
|
||||
|
||||
|
||||
def save_game(game) -> bool:
|
||||
def save_game(game: Game) -> bool:
|
||||
try:
|
||||
with open(_temporary_save_file(), "wb") as f:
|
||||
pickle.dump(game, f)
|
||||
@ -60,7 +64,7 @@ def save_game(game) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def autosave(game) -> bool:
|
||||
def autosave(game: Game) -> bool:
|
||||
"""
|
||||
Autosave to the autosave location
|
||||
:param game: Game to save
|
||||
|
||||
@ -38,7 +38,7 @@ class PluginSettings:
|
||||
self.settings = Settings()
|
||||
self.initialize_settings()
|
||||
|
||||
def set_settings(self, settings: Settings):
|
||||
def set_settings(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
self.initialize_settings()
|
||||
|
||||
@ -146,7 +146,7 @@ class LuaPlugin(PluginSettings):
|
||||
|
||||
return cls(definition)
|
||||
|
||||
def set_settings(self, settings: Settings):
|
||||
def set_settings(self, settings: Settings) -> None:
|
||||
super().set_settings(settings)
|
||||
for option in self.definition.options:
|
||||
option.set_settings(self.settings)
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dcs import Point
|
||||
from game.utils import Heading
|
||||
|
||||
|
||||
class PointWithHeading(Point):
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
super(PointWithHeading, self).__init__(0, 0)
|
||||
self.heading = 0
|
||||
self.heading: Heading = Heading.from_degrees(0)
|
||||
|
||||
@staticmethod
|
||||
def from_point(point: Point, heading: int):
|
||||
def from_point(point: Point, heading: Heading) -> PointWithHeading:
|
||||
p = PointWithHeading()
|
||||
p.x = point.x
|
||||
p.y = point.y
|
||||
|
||||
9
game/positioned.py
Normal file
9
game/positioned.py
Normal file
@ -0,0 +1,9 @@
|
||||
from typing import Protocol
|
||||
|
||||
from dcs import Point
|
||||
|
||||
|
||||
class Positioned(Protocol):
|
||||
@property
|
||||
def position(self) -> Point:
|
||||
raise NotImplementedError
|
||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
|
||||
|
||||
from game import db
|
||||
@ -11,7 +11,7 @@ from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.factions.faction import Faction
|
||||
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.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import FlightType
|
||||
@ -25,15 +25,13 @@ FRONTLINE_RESERVES_FACTOR = 1.3
|
||||
@dataclass(frozen=True)
|
||||
class AircraftProcurementRequest:
|
||||
near: MissionTarget
|
||||
range: Distance
|
||||
task_capability: FlightType
|
||||
number: int
|
||||
|
||||
def __str__(self) -> str:
|
||||
task = self.task_capability.value
|
||||
distance = self.range.nautical_miles
|
||||
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:
|
||||
@ -72,7 +70,9 @@ class ProcurementAi:
|
||||
return 1
|
||||
|
||||
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
|
||||
cp_aircraft = cp.allocated_aircraft(self.game)
|
||||
aircraft_investment += cp_aircraft.total_value
|
||||
@ -209,24 +209,28 @@ class ProcurementAi:
|
||||
return GroundUnitClass.Tank
|
||||
return worst_balanced
|
||||
|
||||
def _affordable_aircraft_for_task(
|
||||
self,
|
||||
task: FlightType,
|
||||
airbase: ControlPoint,
|
||||
number: int,
|
||||
max_price: float,
|
||||
def affordable_aircraft_for(
|
||||
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
|
||||
) -> Optional[AircraftType]:
|
||||
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:
|
||||
continue
|
||||
if unit.price * number > max_price:
|
||||
if unit.price * request.number > budget:
|
||||
continue
|
||||
if not airbase.can_operate(unit):
|
||||
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):
|
||||
if task in squadron.auto_assignable_mission_types:
|
||||
if (
|
||||
squadron.operates_from(airbase)
|
||||
and request.task_capability
|
||||
in squadron.auto_assignable_mission_types
|
||||
):
|
||||
break
|
||||
else:
|
||||
continue
|
||||
@ -239,13 +243,6 @@ class ProcurementAi:
|
||||
break
|
||||
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(
|
||||
self, request: AircraftProcurementRequest, budget: float
|
||||
) -> Tuple[float, bool]:
|
||||
@ -265,7 +262,7 @@ class ProcurementAi:
|
||||
return budget, False
|
||||
|
||||
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)):
|
||||
# No airbases in range of this request. Skip it.
|
||||
continue
|
||||
@ -291,7 +288,7 @@ class ProcurementAi:
|
||||
) -> Iterator[ControlPoint]:
|
||||
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
|
||||
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):
|
||||
continue
|
||||
if cp.unclaimed_parking(self.game) < request.number:
|
||||
@ -316,7 +313,9 @@ class ProcurementAi:
|
||||
continue
|
||||
|
||||
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:
|
||||
# Control point is already sufficiently defended.
|
||||
continue
|
||||
@ -343,7 +342,9 @@ class ProcurementAi:
|
||||
if not cp.can_recruit_ground_units(self.game):
|
||||
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:
|
||||
continue
|
||||
|
||||
@ -356,7 +357,9 @@ class ProcurementAi:
|
||||
def cost_ratio_of_ground_unit(
|
||||
self, control_point: ControlPoint, unit_class: GroundUnitClass
|
||||
) -> 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
|
||||
total_cost = 0
|
||||
for unit_type, count in allocations.all.items():
|
||||
|
||||
@ -5,7 +5,8 @@ import timeit
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from typing import Iterator
|
||||
from types import TracebackType
|
||||
from typing import Iterator, Optional, Type
|
||||
|
||||
|
||||
@contextmanager
|
||||
@ -23,7 +24,12 @@ class MultiEventTracer:
|
||||
def __enter__(self) -> MultiEventTracer:
|
||||
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():
|
||||
logging.debug("%s took %s", event, duration)
|
||||
|
||||
|
||||
@ -72,6 +72,9 @@ class CommonRadioChannelAllocator(RadioChannelAllocator):
|
||||
for awacs in air_support.awacs:
|
||||
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:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc)
|
||||
|
||||
|
||||
48
game/savecompat.py
Normal file
48
game/savecompat.py
Normal 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
|
||||
@ -1,7 +1,7 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from enum import Enum, unique
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
from dcs.forcedoptions import ForcedOptions
|
||||
|
||||
@ -55,6 +55,7 @@ class Settings:
|
||||
automate_runway_repair: bool = False
|
||||
automate_front_line_reinforcements: bool = False
|
||||
automate_aircraft_reinforcements: bool = False
|
||||
automate_front_line_stance: bool = True
|
||||
restrict_weapons_by_date: bool = False
|
||||
disable_legacy_aewc: bool = True
|
||||
disable_legacy_tanker: bool = True
|
||||
@ -104,7 +105,7 @@ class Settings:
|
||||
def set_plugin_option(self, identifier: str, enabled: bool) -> None:
|
||||
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
|
||||
# can provide save compatibility for new settings options (which
|
||||
# normally would not be present in the unpickled object) by creating a
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
@ -13,17 +14,20 @@ from typing import (
|
||||
Optional,
|
||||
Iterator,
|
||||
Sequence,
|
||||
Any,
|
||||
)
|
||||
|
||||
import yaml
|
||||
from faker import Faker
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.settings import AutoAtoBehavior
|
||||
from game.settings import AutoAtoBehavior, Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.coalition import Coalition
|
||||
from gen.flights.flight import FlightType
|
||||
from game.theater import ControlPoint
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -71,6 +75,33 @@ class Pilot:
|
||||
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
|
||||
class Squadron:
|
||||
name: str
|
||||
@ -80,6 +111,7 @@ class Squadron:
|
||||
aircraft: AircraftType
|
||||
livery: Optional[str]
|
||||
mission_types: tuple[FlightType, ...]
|
||||
operating_bases: OperatingBases
|
||||
|
||||
#: 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
|
||||
@ -95,16 +127,10 @@ class Squadron:
|
||||
init=False, hash=False, compare=False
|
||||
)
|
||||
|
||||
# We need a reference to the Game so that we can access the Faker without needing to
|
||||
# persist it to the save game, or having to reconstruct it (it's not cheap) each
|
||||
# time we create or load a squadron.
|
||||
game: Game = field(hash=False, compare=False)
|
||||
player: bool
|
||||
coalition: Coalition = field(hash=False, compare=False)
|
||||
settings: Settings = field(hash=False, compare=False)
|
||||
|
||||
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)
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -112,9 +138,13 @@ class Squadron:
|
||||
return self.name
|
||||
return f'{self.name} "{self.nickname}"'
|
||||
|
||||
@property
|
||||
def player(self) -> bool:
|
||||
return self.coalition.player
|
||||
|
||||
@property
|
||||
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]:
|
||||
if self.pilot_limits_enabled:
|
||||
@ -130,7 +160,7 @@ class Squadron:
|
||||
if not self.player:
|
||||
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.
|
||||
if preference is AutoAtoBehavior.Default:
|
||||
@ -178,12 +208,17 @@ class Squadron:
|
||||
self.current_roster.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:
|
||||
if not self.pilot_limits_enabled:
|
||||
return
|
||||
|
||||
replenish_count = min(
|
||||
self.game.settings.squadron_replenishment_rate,
|
||||
self.settings.squadron_replenishment_rate,
|
||||
self._number_of_unfilled_pilot_slots,
|
||||
)
|
||||
if replenish_count > 0:
|
||||
@ -196,7 +231,7 @@ class Squadron:
|
||||
def send_on_leave(pilot: Pilot) -> None:
|
||||
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:
|
||||
raise RuntimeError(
|
||||
f"Cannot return {pilot} from leave because {self} is full"
|
||||
@ -205,7 +240,7 @@ class Squadron:
|
||||
|
||||
@property
|
||||
def faker(self) -> Faker:
|
||||
return self.game.faker_for(self.player)
|
||||
return self.coalition.faker
|
||||
|
||||
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
|
||||
return [p for p in self.current_roster if p.status == status]
|
||||
@ -227,7 +262,7 @@ class Squadron:
|
||||
|
||||
@property
|
||||
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
|
||||
def number_of_available_pilots(self) -> int:
|
||||
@ -247,11 +282,19 @@ class Squadron:
|
||||
def can_auto_assign(self, task: FlightType) -> bool:
|
||||
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:
|
||||
return self.current_roster[index]
|
||||
|
||||
@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.flight import FlightType
|
||||
|
||||
@ -285,12 +328,13 @@ class Squadron:
|
||||
aircraft=unit_type,
|
||||
livery=data.get("livery"),
|
||||
mission_types=tuple(mission_types),
|
||||
operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),
|
||||
pilot_pool=pilots,
|
||||
game=game,
|
||||
player=player,
|
||||
coalition=coalition,
|
||||
settings=game.settings,
|
||||
)
|
||||
|
||||
def __setstate__(self, state) -> None:
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# TODO: Remove save compat.
|
||||
if "auto_assignable_mission_types" not in state:
|
||||
state["auto_assignable_mission_types"] = set(state["mission_types"])
|
||||
@ -298,9 +342,9 @@ class Squadron:
|
||||
|
||||
|
||||
class SquadronLoader:
|
||||
def __init__(self, game: Game, player: bool) -> None:
|
||||
def __init__(self, game: Game, coalition: Coalition) -> None:
|
||||
self.game = game
|
||||
self.player = player
|
||||
self.coalition = coalition
|
||||
|
||||
@staticmethod
|
||||
def squadron_directories() -> Iterator[Path]:
|
||||
@ -311,8 +355,8 @@ class SquadronLoader:
|
||||
|
||||
def load(self) -> dict[AircraftType, list[Squadron]]:
|
||||
squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
|
||||
country = self.game.country_for(self.player)
|
||||
faction = self.game.faction_for(self.player)
|
||||
country = self.coalition.country_name
|
||||
faction = self.coalition.faction
|
||||
any_country = country.startswith("Combined Joint Task Forces ")
|
||||
for directory in self.squadron_directories():
|
||||
for path, squadron in self.load_squadrons_from(directory):
|
||||
@ -346,7 +390,7 @@ class SquadronLoader:
|
||||
for squadron_path in directory.glob("*/*.yaml"):
|
||||
try:
|
||||
yield squadron_path, Squadron.from_yaml(
|
||||
squadron_path, self.game, self.player
|
||||
squadron_path, self.game, self.coalition
|
||||
)
|
||||
except Exception as ex:
|
||||
raise RuntimeError(
|
||||
@ -355,29 +399,29 @@ class SquadronLoader:
|
||||
|
||||
|
||||
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
|
||||
|
||||
self.game = game
|
||||
self.player = player
|
||||
self.squadrons = SquadronLoader(game, player).load()
|
||||
self.squadrons = SquadronLoader(game, coalition).load()
|
||||
|
||||
count = itertools.count(1)
|
||||
for aircraft in game.faction_for(player).aircrafts:
|
||||
for aircraft in coalition.faction.aircrafts:
|
||||
if aircraft in self.squadrons:
|
||||
continue
|
||||
self.squadrons[aircraft] = [
|
||||
Squadron(
|
||||
name=f"Squadron {next(count):03}",
|
||||
nickname=self.random_nickname(),
|
||||
country=game.country_for(player),
|
||||
country=coalition.country_name,
|
||||
role="Flying Squadron",
|
||||
aircraft=aircraft,
|
||||
livery=None,
|
||||
mission_types=tuple(tasks_for_aircraft(aircraft)),
|
||||
operating_bases=OperatingBases.default_for_aircraft(aircraft),
|
||||
pilot_pool=[],
|
||||
game=game,
|
||||
player=player,
|
||||
coalition=coalition,
|
||||
settings=game.settings,
|
||||
)
|
||||
]
|
||||
|
||||
@ -412,6 +456,10 @@ class AirWing:
|
||||
def squadron_at_index(self, index: int) -> Squadron:
|
||||
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:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.replenish_lost_pilots()
|
||||
|
||||
@ -6,15 +6,15 @@ from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.dcs.unittype import UnitType
|
||||
|
||||
BASE_MAX_STRENGTH = 1
|
||||
BASE_MIN_STRENGTH = 0
|
||||
BASE_MAX_STRENGTH = 1.0
|
||||
BASE_MIN_STRENGTH = 0.0
|
||||
|
||||
|
||||
class Base:
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.aircraft: dict[AircraftType, int] = {}
|
||||
self.armor: dict[GroundUnitType, int] = {}
|
||||
self.strength = 1
|
||||
self.strength = 1.0
|
||||
|
||||
@property
|
||||
def total_aircraft(self) -> int:
|
||||
@ -31,7 +31,7 @@ class Base:
|
||||
total += unit_type.price * count
|
||||
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(
|
||||
[
|
||||
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():
|
||||
if unit_count <= 0:
|
||||
continue
|
||||
@ -56,7 +56,7 @@ class Base:
|
||||
|
||||
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():
|
||||
target_dict: dict[Any, int]
|
||||
if unit_type in self.aircraft:
|
||||
@ -75,7 +75,7 @@ class Base:
|
||||
if target_dict[unit_type] == 0:
|
||||
del target_dict[unit_type]
|
||||
|
||||
def affect_strength(self, amount):
|
||||
def affect_strength(self, amount: float) -> None:
|
||||
self.strength += amount
|
||||
if self.strength > BASE_MAX_STRENGTH:
|
||||
self.strength = BASE_MAX_STRENGTH
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# DO NOT EDIT:
|
||||
# This file is generated by resources/tools/export_coordinates.py.
|
||||
from game.theater.projections import TransverseMercator
|
||||
|
||||
PARAMETERS = TransverseMercator(
|
||||
|
||||
@ -5,7 +5,7 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
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.countries import (
|
||||
@ -29,14 +29,14 @@ from dcs.terrain import (
|
||||
persiangulf,
|
||||
syria,
|
||||
thechannel,
|
||||
marianaislands,
|
||||
)
|
||||
from dcs.terrain.terrain import Airport, Terrain
|
||||
from dcs.unitgroup import (
|
||||
FlyingGroup,
|
||||
Group,
|
||||
ShipGroup,
|
||||
StaticGroup,
|
||||
VehicleGroup,
|
||||
PlaneGroup,
|
||||
)
|
||||
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
|
||||
from pyproj import CRS, Transformer
|
||||
@ -51,15 +51,20 @@ from .controlpoint import (
|
||||
MissionTarget,
|
||||
OffMapSpawn,
|
||||
)
|
||||
from .seasonalconditions import SeasonalConditions
|
||||
from .frontline import FrontLine
|
||||
from .landmap import Landmap, load_landmap, poly_contains
|
||||
from .latlon import LatLon
|
||||
from .projections import TransverseMercator
|
||||
from ..helipad import Helipad
|
||||
from ..point_with_heading import PointWithHeading
|
||||
from ..positioned import Positioned
|
||||
from ..profiling import logged_duration
|
||||
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_SMALL = 600
|
||||
@ -182,7 +187,7 @@ class MizCampaignLoader:
|
||||
def red(self) -> Country:
|
||||
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:
|
||||
if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
|
||||
yield group
|
||||
@ -306,26 +311,26 @@ class MizCampaignLoader:
|
||||
control_point.captured = blue
|
||||
control_point.captured_invert = group.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for group in self.carriers(blue):
|
||||
for ship in self.carriers(blue):
|
||||
# TODO: Name the 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_invert = group.late_activation
|
||||
control_point.captured_invert = ship.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for group in self.lhas(blue):
|
||||
for ship in self.lhas(blue):
|
||||
# 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_invert = group.late_activation
|
||||
control_point.captured_invert = ship.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for group in self.fobs(blue):
|
||||
for fob in self.fobs(blue):
|
||||
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_invert = group.late_activation
|
||||
control_point.captured_invert = fob.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
|
||||
return control_points
|
||||
@ -386,99 +391,129 @@ class MizCampaignLoader:
|
||||
origin, list(reversed(waypoints))
|
||||
)
|
||||
|
||||
def objective_info(self, group: Group) -> Tuple[ControlPoint, Distance]:
|
||||
closest = self.theater.closest_control_point(group.position)
|
||||
distance = meters(closest.position.distance_to_point(group.position))
|
||||
def objective_info(
|
||||
self, near: Positioned, allow_naval: bool = False
|
||||
) -> 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
|
||||
|
||||
def add_preset_locations(self) -> None:
|
||||
for group in self.offshore_strike_targets:
|
||||
closest, distance = self.objective_info(group)
|
||||
for static in self.offshore_strike_targets:
|
||||
closest, distance = self.objective_info(static)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
for ship in self.ships:
|
||||
closest, distance = self.objective_info(ship, allow_naval=True)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
for static in self.helipads:
|
||||
closest, distance = self.objective_info(static)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
for static in self.factories:
|
||||
closest, distance = self.objective_info(static)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
for static in self.ammunition_depots:
|
||||
closest, distance = self.objective_info(static)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
for static in self.strike_targets:
|
||||
closest, distance = self.objective_info(static)
|
||||
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:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.scenery.append(group)
|
||||
for scenery_group in self.scenery:
|
||||
closest, distance = self.objective_info(scenery_group)
|
||||
closest.preset_locations.scenery.append(scenery_group)
|
||||
|
||||
def populate_theater(self) -> None:
|
||||
for control_point in self.control_points.values():
|
||||
@ -505,7 +540,7 @@ class ConflictTheater:
|
||||
"""
|
||||
daytime_map: Dict[str, Tuple[int, int]]
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.controlpoints: List[ControlPoint] = []
|
||||
self.point_to_ll_transformer = Transformer.from_crs(
|
||||
self.projection_parameters.to_crs(), CRS("WGS84")
|
||||
@ -537,10 +572,12 @@ class ConflictTheater:
|
||||
CRS("WGS84"), self.projection_parameters.to_crs()
|
||||
)
|
||||
|
||||
def add_controlpoint(self, point: ControlPoint):
|
||||
def add_controlpoint(self, point: ControlPoint) -> None:
|
||||
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 = []
|
||||
for cp in self.controlpoints:
|
||||
for g in cp.ground_objects:
|
||||
@ -582,12 +619,12 @@ class ConflictTheater:
|
||||
|
||||
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
|
||||
`extend_dist` determines how far inside the zone the point should be placed"""
|
||||
if self.is_on_land(point):
|
||||
return point
|
||||
point = geometry.Point(point.x, point.y)
|
||||
if self.is_on_land(near):
|
||||
return near
|
||||
point = geometry.Point(near.x, near.y)
|
||||
nearest_points = []
|
||||
if not self.landmap:
|
||||
raise RuntimeError("Landmap not initialized")
|
||||
@ -628,10 +665,14 @@ class ConflictTheater:
|
||||
def enemy_points(self) -> List[ControlPoint]:
|
||||
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_distance = point.distance_to_point(closest.position)
|
||||
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)
|
||||
if distance < closest_distance:
|
||||
closest = control_point
|
||||
@ -699,6 +740,7 @@ class ConflictTheater:
|
||||
"Normandy": NormandyTheater,
|
||||
"The Channel": TheChannelTheater,
|
||||
"Syria": SyriaTheater,
|
||||
"MarianaIslands": MarianaIslandsTheater,
|
||||
}
|
||||
theater = theaters[data["theater"]]
|
||||
t = theater()
|
||||
@ -713,6 +755,10 @@ class ConflictTheater:
|
||||
MizCampaignLoader(directory / miz, t).populate_theater()
|
||||
return t
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
raise NotImplementedError
|
||||
@ -742,6 +788,12 @@ class CaucasusTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
from .seasonalconditions.caucasus import CONDITIONS
|
||||
|
||||
return CONDITIONS
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .caucasus import PARAMETERS
|
||||
@ -764,6 +816,12 @@ class PersianGulfTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
from .seasonalconditions.persiangulf import CONDITIONS
|
||||
|
||||
return CONDITIONS
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .persiangulf import PARAMETERS
|
||||
@ -786,6 +844,12 @@ class NevadaTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
from .seasonalconditions.nevada import CONDITIONS
|
||||
|
||||
return CONDITIONS
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .nevada import PARAMETERS
|
||||
@ -808,6 +872,12 @@ class NormandyTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
from .seasonalconditions.normandy import CONDITIONS
|
||||
|
||||
return CONDITIONS
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .normandy import PARAMETERS
|
||||
@ -830,6 +900,12 @@ class TheChannelTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
from .seasonalconditions.thechannel import CONDITIONS
|
||||
|
||||
return CONDITIONS
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .thechannel import PARAMETERS
|
||||
@ -852,8 +928,39 @@ class SyriaTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
from .seasonalconditions.syria import CONDITIONS
|
||||
|
||||
return CONDITIONS
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .syria import 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
|
||||
|
||||
@ -36,6 +36,7 @@ from dcs.unittype import FlyingType
|
||||
from game import db
|
||||
from game.point_with_heading import PointWithHeading
|
||||
from game.scenery_group import SceneryGroup
|
||||
from game.utils import Heading
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
from gen.runways import RunwayAssigner, RunwayData
|
||||
@ -44,6 +45,7 @@ from .missiontarget import MissionTarget
|
||||
from .theatergroundobject import (
|
||||
GenericCarrierGroundObject,
|
||||
TheaterGroundObject,
|
||||
BuildingGroundObject,
|
||||
)
|
||||
from ..dcs.aircrafttype import AircraftType
|
||||
from ..dcs.groundunittype import GroundUnitType
|
||||
@ -272,6 +274,9 @@ class ControlPointStatus(IntEnum):
|
||||
|
||||
|
||||
class ControlPoint(MissionTarget, ABC):
|
||||
# Not sure what distance DCS uses, but assuming it's about 2NM since that's roughly
|
||||
# the distance of the circle on the map.
|
||||
CAPTURE_DISTANCE = nautical_miles(2)
|
||||
|
||||
position = None # type: Point
|
||||
name = None # type: str
|
||||
@ -292,15 +297,15 @@ class ControlPoint(MissionTarget, ABC):
|
||||
at: db.StartingPosition,
|
||||
size: int,
|
||||
importance: float,
|
||||
has_frontline=True,
|
||||
cptype=ControlPointType.AIRBASE,
|
||||
):
|
||||
has_frontline: bool = True,
|
||||
cptype: ControlPointType = ControlPointType.AIRBASE,
|
||||
) -> None:
|
||||
super().__init__(name, position)
|
||||
# TODO: Should be Airbase specific.
|
||||
self.id = cp_id
|
||||
self.full_name = name
|
||||
self.at = at
|
||||
self.connected_objectives: List[TheaterGroundObject] = []
|
||||
self.connected_objectives: List[TheaterGroundObject[Any]] = []
|
||||
self.preset_locations = PresetLocations()
|
||||
self.helipads: List[Helipad] = []
|
||||
|
||||
@ -324,25 +329,29 @@ class ControlPoint(MissionTarget, ABC):
|
||||
|
||||
self.target_position: Optional[Point] = None
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{__class__}: {self.name}>"
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__}: {self.name}>"
|
||||
|
||||
@property
|
||||
def ground_objects(self) -> List[TheaterGroundObject]:
|
||||
def ground_objects(self) -> List[TheaterGroundObject[Any]]:
|
||||
return list(self.connected_objectives)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def heading(self) -> int:
|
||||
def heading(self) -> Heading:
|
||||
...
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def is_global(self):
|
||||
def is_isolated(self) -> bool:
|
||||
return not self.connected_points
|
||||
|
||||
@property
|
||||
def is_global(self) -> bool:
|
||||
return self.is_isolated
|
||||
|
||||
def transitive_connected_friendly_points(
|
||||
self, seen: Optional[Set[ControlPoint]] = None
|
||||
) -> List[ControlPoint]:
|
||||
@ -430,21 +439,21 @@ class ControlPoint(MissionTarget, ABC):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_carrier(self):
|
||||
def is_carrier(self) -> bool:
|
||||
"""
|
||||
:return: Whether this control point is an aircraft carrier
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_fleet(self):
|
||||
def is_fleet(self) -> bool:
|
||||
"""
|
||||
:return: Whether this control point is a boat (mobile)
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_lha(self):
|
||||
def is_lha(self) -> bool:
|
||||
"""
|
||||
:return: Whether this control point is an LHA
|
||||
"""
|
||||
@ -464,7 +473,7 @@ class ControlPoint(MissionTarget, ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def total_aircraft_parking(self):
|
||||
def total_aircraft_parking(self) -> int:
|
||||
"""
|
||||
:return: The maximum number of aircraft that can be stored in this
|
||||
control point
|
||||
@ -496,7 +505,7 @@ class ControlPoint(MissionTarget, ABC):
|
||||
...
|
||||
|
||||
# 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
|
||||
:return: Carrier group name
|
||||
@ -522,10 +531,12 @@ class ControlPoint(MissionTarget, ABC):
|
||||
return None
|
||||
|
||||
# TODO: Should be Airbase specific.
|
||||
def is_connected(self, to) -> bool:
|
||||
def is_connected(self, to: ControlPoint) -> bool:
|
||||
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 = []
|
||||
for g in self.ground_objects:
|
||||
if g.obj_name == obj_name:
|
||||
@ -547,7 +558,7 @@ class ControlPoint(MissionTarget, ABC):
|
||||
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
|
||||
# base is least defended first. The closest approximation of unit
|
||||
# strength we have is price
|
||||
@ -621,7 +632,7 @@ class ControlPoint(MissionTarget, ABC):
|
||||
|
||||
# TODO: Should be Airbase specific.
|
||||
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_air_units(game)
|
||||
self.depopulate_uncapturable_tgos()
|
||||
@ -638,11 +649,7 @@ class ControlPoint(MissionTarget, ABC):
|
||||
...
|
||||
|
||||
def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]:
|
||||
if self.captured:
|
||||
ato = game.blue_ato
|
||||
else:
|
||||
ato = game.red_ato
|
||||
|
||||
ato = game.coalition_for(self.captured).ato
|
||||
transferring: defaultdict[AircraftType, int] = defaultdict(int)
|
||||
for package in ato.packages:
|
||||
for flight in package.flights:
|
||||
@ -750,27 +757,48 @@ class ControlPoint(MissionTarget, ABC):
|
||||
return self.captured != other.captured
|
||||
|
||||
@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 (
|
||||
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
|
||||
def active_ammo_depots_count(self) -> int:
|
||||
"""Return the number of available ammo depots"""
|
||||
return len(
|
||||
[
|
||||
obj
|
||||
for obj in self.connected_objectives
|
||||
if obj.category == "ammo" and not obj.is_dead
|
||||
]
|
||||
)
|
||||
return len(list(self.active_ammo_depots))
|
||||
|
||||
@property
|
||||
def total_ammo_depots_count(self) -> int:
|
||||
"""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
|
||||
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"])
|
||||
|
||||
@property
|
||||
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
|
||||
def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]:
|
||||
return []
|
||||
|
||||
@property
|
||||
@ -805,8 +833,8 @@ class ControlPoint(MissionTarget, ABC):
|
||||
|
||||
class Airfield(ControlPoint):
|
||||
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__(
|
||||
airport.id,
|
||||
airport.name,
|
||||
@ -852,8 +880,8 @@ class Airfield(ControlPoint):
|
||||
return len(self.airport.parking_slots)
|
||||
|
||||
@property
|
||||
def heading(self) -> int:
|
||||
return self.airport.runways[0].heading
|
||||
def heading(self) -> Heading:
|
||||
return Heading.from_degrees(self.airport.runways[0].heading)
|
||||
|
||||
def runway_is_operational(self) -> bool:
|
||||
return not self.runway_status.damaged
|
||||
@ -917,12 +945,15 @@ class NavalControlPoint(ControlPoint, ABC):
|
||||
yield from super().mission_types(for_player)
|
||||
|
||||
@property
|
||||
def heading(self) -> int:
|
||||
return 0 # TODO compute heading
|
||||
def heading(self) -> 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:
|
||||
if g.dcs_identifier in ["CARRIER", "LHA"]:
|
||||
if isinstance(g, GenericCarrierGroundObject) and g.dcs_identifier in [
|
||||
"CARRIER",
|
||||
"LHA",
|
||||
]:
|
||||
return g
|
||||
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]
|
||||
) -> RunwayData:
|
||||
# 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)
|
||||
|
||||
@property
|
||||
@ -1001,7 +1034,7 @@ class Carrier(NavalControlPoint):
|
||||
raise RuntimeError("Carriers cannot be captured")
|
||||
|
||||
@property
|
||||
def is_carrier(self):
|
||||
def is_carrier(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_operate(self, aircraft: AircraftType) -> bool:
|
||||
@ -1082,14 +1115,16 @@ class OffMapSpawn(ControlPoint):
|
||||
return True
|
||||
|
||||
@property
|
||||
def heading(self) -> int:
|
||||
return 0
|
||||
def heading(self) -> Heading:
|
||||
return Heading.from_degrees(0)
|
||||
|
||||
def active_runway(
|
||||
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
|
||||
) -> RunwayData:
|
||||
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
|
||||
def runway_status(self) -> RunwayStatus:
|
||||
@ -1131,7 +1166,9 @@ class Fob(ControlPoint):
|
||||
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
|
||||
) -> RunwayData:
|
||||
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
|
||||
def runway_status(self) -> RunwayStatus:
|
||||
@ -1158,8 +1195,8 @@ class Fob(ControlPoint):
|
||||
return False
|
||||
|
||||
@property
|
||||
def heading(self) -> int:
|
||||
return 0
|
||||
def heading(self) -> Heading:
|
||||
return Heading.from_degrees(0)
|
||||
|
||||
@property
|
||||
def can_deploy_ground_units(self) -> bool:
|
||||
|
||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator, List, Tuple
|
||||
from typing import Iterator, List, Tuple, Any
|
||||
|
||||
from dcs.mapping import Point
|
||||
|
||||
@ -11,7 +11,7 @@ from .controlpoint import (
|
||||
ControlPoint,
|
||||
MissionTarget,
|
||||
)
|
||||
from ..utils import pairwise
|
||||
from ..utils import Heading, pairwise
|
||||
|
||||
|
||||
FRONTLINE_MIN_CP_DISTANCE = 5000
|
||||
@ -27,9 +27,9 @@ class FrontLineSegment:
|
||||
point_b: Point
|
||||
|
||||
@property
|
||||
def attack_heading(self) -> float:
|
||||
def attack_heading(self) -> Heading:
|
||||
"""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
|
||||
def attack_distance(self) -> float:
|
||||
@ -66,12 +66,31 @@ class FrontLine(MissionTarget):
|
||||
self.segments: List[FrontLineSegment] = [
|
||||
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:
|
||||
if player:
|
||||
return self.red_cp
|
||||
return self.blue_cp
|
||||
return self.control_point_friendly_to(not player)
|
||||
|
||||
def is_friendly(self, to_player: bool) -> bool:
|
||||
"""Returns True if the objective is in friendly territory."""
|
||||
@ -87,14 +106,6 @@ class FrontLine(MissionTarget):
|
||||
]
|
||||
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
|
||||
def points(self) -> Iterator[Point]:
|
||||
yield self.segments[0].point_a
|
||||
@ -107,12 +118,12 @@ class FrontLine(MissionTarget):
|
||||
return self.blue_cp, self.red_cp
|
||||
|
||||
@property
|
||||
def attack_distance(self):
|
||||
def attack_distance(self) -> float:
|
||||
"""The total distance of all segments"""
|
||||
return sum(i.attack_distance for i in self.segments)
|
||||
|
||||
@property
|
||||
def attack_heading(self):
|
||||
def attack_heading(self) -> Heading:
|
||||
"""The heading of the active attack segment from player to enemy control point"""
|
||||
return self.active_segment.attack_heading
|
||||
|
||||
@ -139,16 +150,19 @@ class FrontLine(MissionTarget):
|
||||
"""
|
||||
if distance < self.segments[0].attack_distance:
|
||||
return self.blue_cp.position.point_from_heading(
|
||||
self.segments[0].attack_heading, distance
|
||||
self.segments[0].attack_heading.degrees, distance
|
||||
)
|
||||
remaining_dist = distance
|
||||
for segment in self.segments:
|
||||
if remaining_dist < segment.attack_distance:
|
||||
return segment.point_a.point_from_heading(
|
||||
segment.attack_heading, remaining_dist
|
||||
segment.attack_heading.degrees, remaining_dist
|
||||
)
|
||||
else:
|
||||
remaining_dist -= segment.attack_distance
|
||||
raise RuntimeError(
|
||||
f"Could not find front line point {distance} from {self.blue_cp}"
|
||||
)
|
||||
|
||||
@property
|
||||
def _position_distance(self) -> float:
|
||||
|
||||
@ -14,7 +14,7 @@ class Landmap:
|
||||
exclusion_zones: MultiPolygon
|
||||
sea_zones: MultiPolygon
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self) -> None:
|
||||
if not self.inclusion_zones.is_valid:
|
||||
raise RuntimeError("Inclusion zones not valid")
|
||||
if not self.exclusion_zones.is_valid:
|
||||
@ -36,13 +36,5 @@ def load_landmap(filename: str) -> Optional[Landmap]:
|
||||
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))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
10
game/theater/marianaislands.py
Normal file
10
game/theater/marianaislands.py
Normal 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,
|
||||
)
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Sequence
|
||||
from typing import Iterator, TYPE_CHECKING, List, Union
|
||||
|
||||
from dcs.mapping import Point
|
||||
@ -20,7 +21,7 @@ class MissionTarget:
|
||||
self.name = name
|
||||
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."""
|
||||
return self.position.distance_to_point(other.position)
|
||||
|
||||
@ -45,5 +46,5 @@ class MissionTarget:
|
||||
]
|
||||
|
||||
@property
|
||||
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
|
||||
def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]:
|
||||
return []
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# DO NOT EDIT:
|
||||
# This file is generated by resources/tools/export_coordinates.py.
|
||||
from game.theater.projections import TransverseMercator
|
||||
|
||||
PARAMETERS = TransverseMercator(
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# DO NOT EDIT:
|
||||
# This file is generated by resources/tools/export_coordinates.py.
|
||||
from game.theater.projections import TransverseMercator
|
||||
|
||||
PARAMETERS = TransverseMercator(
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# DO NOT EDIT:
|
||||
# This file is generated by resources/tools/export_coordinates.py.
|
||||
from game.theater.projections import TransverseMercator
|
||||
|
||||
PARAMETERS = TransverseMercator(
|
||||
|
||||
1
game/theater/seasonalconditions/__init__.py
Normal file
1
game/theater/seasonalconditions/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .seasonalconditions import *
|
||||
36
game/theater/seasonalconditions/caucasus.py
Normal file
36
game/theater/seasonalconditions/caucasus.py
Normal 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,
|
||||
),
|
||||
},
|
||||
)
|
||||
38
game/theater/seasonalconditions/marianaislands.py
Normal file
38
game/theater/seasonalconditions/marianaislands.py
Normal 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,
|
||||
),
|
||||
},
|
||||
)
|
||||
36
game/theater/seasonalconditions/nevada.py
Normal file
36
game/theater/seasonalconditions/nevada.py
Normal 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
Loading…
x
Reference in New Issue
Block a user