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:
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,
|
||||
),
|
||||
},
|
||||
)
|
||||
36
game/theater/seasonalconditions/normandy.py
Normal file
36
game/theater/seasonalconditions/normandy.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=20.0,
|
||||
winter_avg_temperature=0.0,
|
||||
temperature_day_night_difference=5.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,
|
||||
),
|
||||
},
|
||||
)
|
||||
37
game/theater/seasonalconditions/persiangulf.py
Normal file
37
game/theater/seasonalconditions/persiangulf.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
|
||||
|
||||
CONDITIONS = SeasonalConditions(
|
||||
summer_avg_pressure=29.98, # TODO: Find real-world data
|
||||
winter_avg_pressure=29.80, # TODO: Find real-world data
|
||||
summer_avg_temperature=32.5,
|
||||
winter_avg_temperature=15.0,
|
||||
temperature_day_night_difference=2.0,
|
||||
weather_type_chances={
|
||||
# TODO: Find real-world data for all these values
|
||||
Season.Winter: WeatherTypeChances(
|
||||
# Winter there is some rain in PG (Dubai)
|
||||
thunderstorm=1,
|
||||
raining=15,
|
||||
cloudy=35,
|
||||
clear_skies=50,
|
||||
),
|
||||
Season.Spring: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=2,
|
||||
cloudy=18,
|
||||
clear_skies=80,
|
||||
),
|
||||
Season.Summer: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=1,
|
||||
cloudy=8,
|
||||
clear_skies=90,
|
||||
),
|
||||
Season.Fall: WeatherTypeChances(
|
||||
thunderstorm=1,
|
||||
raining=2,
|
||||
cloudy=18,
|
||||
clear_skies=80,
|
||||
),
|
||||
},
|
||||
)
|
||||
48
game/theater/seasonalconditions/seasonalconditions.py
Normal file
48
game/theater/seasonalconditions/seasonalconditions.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Season(Enum):
|
||||
Winter = "winter"
|
||||
Spring = "spring"
|
||||
Summer = "summer"
|
||||
Fall = "fall"
|
||||
|
||||
|
||||
def determine_season(day: datetime.date) -> Season:
|
||||
# Note: This logic doesn't need to be very precise
|
||||
# Currently refers strictly to northern-hemisphere seasons
|
||||
day_of_year = day.timetuple().tm_yday
|
||||
season_length = 365.0 / 4
|
||||
winter_end_day = season_length / 2
|
||||
if day_of_year < winter_end_day:
|
||||
return Season.Winter
|
||||
elif day_of_year < winter_end_day + season_length:
|
||||
return Season.Spring
|
||||
elif day_of_year < winter_end_day + season_length * 2:
|
||||
return Season.Summer
|
||||
elif day_of_year < winter_end_day + season_length * 3:
|
||||
return Season.Fall
|
||||
else:
|
||||
return Season.Winter
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeatherTypeChances:
|
||||
thunderstorm: float
|
||||
raining: float
|
||||
cloudy: float
|
||||
clear_skies: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SeasonalConditions:
|
||||
# Units are inHg and degrees Celsius
|
||||
summer_avg_pressure: float
|
||||
winter_avg_pressure: float
|
||||
summer_avg_temperature: float
|
||||
winter_avg_temperature: float
|
||||
temperature_day_night_difference: float
|
||||
|
||||
weather_type_chances: dict[Season, WeatherTypeChances]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user