Merge remote-tracking branch 'khopa/develop' into helipads

# Conflicts:
#	changelog.md
This commit is contained in:
Khopa
2021-09-08 21:56:45 +02:00
132 changed files with 2856 additions and 648 deletions

View File

@@ -35,10 +35,10 @@ class DefaultSquadronAssigner:
pass
def assign(self) -> None:
for control_point, squadron_configs in self.config.by_location.items():
if not control_point.is_friendly(self.coalition.player):
continue
for squadron_config in squadron_configs:
for control_point in self.game.theater.control_points_for(
self.coalition.player
):
for squadron_config in self.config.by_location[control_point]:
squadron_def = self.find_squadron_for(squadron_config, control_point)
if squadron_def is None:
logging.info(

View File

@@ -105,8 +105,7 @@ class MizCampaignLoader:
@staticmethod
def control_point_from_airport(airport: Airport) -> ControlPoint:
cp = Airfield(airport)
cp.captured = airport.is_blue()
cp = Airfield(airport, starts_blue=airport.is_blue())
# Use the unlimited aircraft option to determine if an airfield should
# be owned by the player when the campaign is "inverted".
@@ -249,30 +248,38 @@ class MizCampaignLoader:
for blue in (False, True):
for group in self.off_map_spawns(blue):
control_point = OffMapSpawn(
next(self.control_point_id), str(group.name), group.position
next(self.control_point_id),
str(group.name),
group.position,
starts_blue=blue,
)
control_point.captured = blue
control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point
for ship in self.carriers(blue):
control_point = Carrier(
ship.name, ship.position, next(self.control_point_id)
ship.name,
ship.position,
next(self.control_point_id),
starts_blue=blue,
)
control_point.captured = blue
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for ship in self.lhas(blue):
control_point = Lha(
ship.name, ship.position, next(self.control_point_id)
ship.name,
ship.position,
next(self.control_point_id),
starts_blue=blue,
)
control_point.captured = blue
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for fob in self.fobs(blue):
control_point = Fob(
str(fob.name), fob.position, next(self.control_point_id)
str(fob.name),
fob.position,
next(self.control_point_id),
starts_blue=blue,
)
control_point.captured = blue
control_point.captured_invert = fob.late_activation
control_points[control_point.id] = control_point

View File

@@ -61,13 +61,256 @@ class SquadronDefGenerator:
adjective = random.choice(
(
None,
"Red",
"Blue",
"Green",
"Golden",
"Aggressive",
"Alpha",
"Ancient",
"Angelic",
"Angry",
"Apoplectic",
"Aquamarine",
"Astral",
"Avenging",
"Azure",
"Badass",
"Barbaric",
"Battle",
"Battling",
"Bellicose",
"Belligerent",
"Big",
"Bionic",
"Black",
"Bladed",
"Blazoned",
"Blood",
"Bloody",
"Blue",
"Bold",
"Boxing",
"Brash",
"Brass",
"Brave",
"Brazen",
"Brown",
"Brutal",
"Brzone",
"Burning",
"Buzzing",
"Celestial",
"Clever",
"Cloud",
"Cobalt",
"Copper",
"Coral",
"Crazy",
"Crimson",
"Crouching",
"Cursed",
"Cyan",
"Danger",
"Dangerous",
"Dapper",
"Daring",
"Dark",
"Dawn",
"Day",
"Deadly",
"Death",
"Defiant",
"Demon",
"Desert",
"Devil",
"Devil's",
"Diabolical",
"Diamond",
"Dire",
"Dirty",
"Doom",
"Doomed",
"Double",
"Drunken",
"Dusk",
"Dusty",
"Eager",
"Ebony",
"Electric",
"Emerald",
"Eternal",
"Evil",
"Faithful",
"Famous",
"Fanged",
"Fearless",
"Feisty",
"Ferocious",
"Fierce",
"Fiery",
"Fighting",
"Fire",
"First",
"Flame",
"Flaming",
"Flying",
"Forest",
"Frenzied",
"Frosty",
"Frozen",
"Furious",
"Gallant",
"Ghost",
"Giant",
"Gigantic",
"Glaring",
"Global",
"Gold",
"Golden",
"Green",
"Grey",
"Grim",
"Grizzly",
"Growling",
"Grumpy",
"Hammer",
"Hard",
"Hardy",
"Heavy",
"Hell",
"Hell's",
"Hidden",
"Homicidal",
"Hostile",
"Howling",
"Hyper",
"Ice",
"Icy",
"Immortal",
"Indignant",
"Infamous",
"Invincible",
"Iron",
"Jolly",
"Laser",
"Lava",
"Lavender",
"Lethal",
"Light",
"Lightning",
"Livid",
"Lucky",
"Mad",
"Magenta",
"Magma",
"Maroon",
"Menacing",
"Merciless",
"Metal",
"Midnight",
"Mighty",
"Mithril",
"Mocking",
"Moon",
"Mountain",
"Muddy",
"Nasty",
"Naughty",
"Night",
"Nova",
"Nutty",
"Obsidian",
"Ocean",
"Oddball",
"Old",
"Omega",
"Onyx",
"Orange",
"Perky",
"Pink",
"Power",
"Prickly",
"Proud",
"Puckered",
"Pugnacious",
"Puking",
"Purple",
"Ragged",
"Raging",
"Rainbow",
"Rampant",
"Razor",
"Ready",
"Reaper",
"Reckless",
"Red",
"Roaring",
"Rocky",
"Rolling",
"Royal",
"Rusty",
"Sable",
"Salty",
"Sand",
"Sarcastic",
"Saucy",
"Scarlet",
"Scarred",
"Scary",
"Screaming",
"Scythed",
"Shadow",
"Shiny",
"Shocking",
"Silver",
"Sky",
"Smoke",
"Smokin'",
"Snapping",
"Snappy",
"Snarling",
"Snow",
"Soaring",
"Space",
"Spiky",
"Spiny",
"Star",
"Steady",
"Steel",
"Stone",
"Storm",
"Striking",
"Strong",
"Stubborn",
"Sun",
"Super",
"Terrible",
"Thorny",
"Thunder",
"Top",
"Tough",
"Toxic",
"Tricky",
"Turquoise",
"Typhoon",
"Ultimate",
"Ultra",
"Ultramarine",
"Vengeful",
"Venom",
"Vermillion",
"Vicious",
"Victorious",
"Vigilant",
"Violent",
"Violet",
"War",
"Water",
"Whistling",
"White",
"Wicked",
"Wild",
"Wizard",
"Wrathful",
"Yellow",
"Young",
)
)
if adjective is None:

View File

@@ -40,7 +40,7 @@ class Coalition:
self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet()
self.bullseye = Bullseye(Point(0, 0))
self.faker = Faker(self.faction.locales)
self.air_wing = AirWing(game)
self.air_wing = AirWing(player)
self.transfers = PendingTransfers(game, player)
# Late initialized because the two coalitions in the game are mutually
@@ -139,7 +139,7 @@ class Coalition:
For more information on turn finalization in general, see the documentation for
`Game.finish_turn`.
"""
self.air_wing.replenish()
self.air_wing.end_turn()
self.budget += Income(self.game, self.player).total
# Need to recompute before transfers and deliveries to account for captures.

View File

@@ -1,69 +0,0 @@
from typing import Optional, Tuple
from game.commander.missionproposals import ProposedFlight
from game.squadrons.airwing import AirWing
from game.squadrons.squadron import 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, is_player: bool
) -> None:
self.air_wing = air_wing
self.closest_airfields = closest_airfields
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
for aircraft in types:
if not airfield.can_operate(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, airfield
)
for squadron in squadrons:
if squadron.operates_from(airfield) and squadron.can_fulfill_flight(
flight.num_aircraft
):
return airfield, squadron
return None

View File

@@ -157,10 +157,7 @@ class ObjectiveFinder:
for control_point in self.enemy_control_points():
if not isinstance(control_point, Airfield):
continue
if (
control_point.allocated_aircraft(self.game).total_present
>= min_aircraft
):
if control_point.allocated_aircraft().total_present >= min_aircraft:
airfields.append(control_point)
return self._targets_by_range(airfields)

View File

@@ -1,16 +1,20 @@
from typing import Optional
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
from game.commander.aircraftallocator import AircraftAllocator
from game.commander.missionproposals import ProposedFlight
from game.dcs.aircrafttype import AircraftType
from game.squadrons.airwing import AirWing
from game.theater import MissionTarget, OffMapSpawn, ControlPoint
from game.utils import nautical_miles
from gen.ato import Package
from gen.flights.closestairfields import ClosestAirfields
from game.theater import MissionTarget, OffMapSpawn, ControlPoint
from gen.flights.flight import Flight
if TYPE_CHECKING:
from game.dcs.aircrafttype import AircraftType
from game.squadrons.airwing import AirWing
from gen.flights.closestairfields import ClosestAirfields
from .missionproposals import ProposedFlight
class PackageBuilder:
"""Builds a Package for the flights it receives."""
@@ -28,7 +32,7 @@ class PackageBuilder:
self.is_player = is_player
self.package_country = package_country
self.package = Package(location, auto_asap=asap)
self.allocator = AircraftAllocator(air_wing, closest_airfields, is_player)
self.air_wing = air_wing
self.start_type = start_type
def plan_flight(self, plan: ProposedFlight) -> bool:
@@ -39,13 +43,13 @@ class PackageBuilder:
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:
squadron = self.air_wing.best_squadron_for(
self.package.target, plan.task, plan.num_aircraft, this_turn=True
)
if squadron is None:
return False
airfield, squadron = assignment
if isinstance(airfield, OffMapSpawn):
start_type = "In Flight"
else:
start_type = squadron.location.required_aircraft_start_type
if start_type is None:
start_type = self.start_type
flight = Flight(
@@ -55,9 +59,7 @@ class PackageBuilder:
plan.num_aircraft,
plan.task,
start_type,
departure=airfield,
arrival=airfield,
divert=self.find_divert_field(squadron.aircraft, airfield),
divert=self.find_divert_field(squadron.aircraft, squadron.location),
)
self.package.add_flight(flight)
return True

View File

@@ -8,7 +8,6 @@ from dcs.task import Task
from game import persistency
from game.debriefing import Debriefing
from game.infos.information import Information
from game.operation.operation import Operation
from game.theater import ControlPoint
from gen.ato import AirTaskingOrder
@@ -173,13 +172,10 @@ class Event:
def commit_building_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.building_losses:
loss.ground_object.kill()
self.game.informations.append(
Information(
"Building destroyed",
f"{loss.ground_object.dcs_identifier} has been destroyed at "
f"location {loss.ground_object.obj_name}",
self.game.turn,
)
self.game.message(
"Building destroyed",
f"{loss.ground_object.dcs_identifier} has been destroyed at "
f"location {loss.ground_object.obj_name}",
)
@staticmethod
@@ -191,19 +187,16 @@ class Event:
for captured in debriefing.base_captures:
try:
if captured.captured_by_player:
info = Information(
self.game.message(
f"{captured.control_point} captured!",
f"We took control of {captured.control_point}.",
self.game.turn,
)
else:
info = Information(
self.game.message(
f"{captured.control_point} lost!",
f"The enemy took control of {captured.control_point}.",
self.game.turn,
)
self.game.informations.append(info)
captured.control_point.capture(self.game, captured.captured_by_player)
logging.info(f"Will run redeploy for {captured.control_point}")
self.redeploy_units(captured.control_point)
@@ -330,34 +323,28 @@ class Event:
# 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(
self.game.message(
"Frontline Report",
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:
if player_won:
print(status_msg)
cp.base.affect_strength(delta)
enemy_cp.base.affect_strength(-delta)
info = Information(
self.game.message(
"Frontline Report",
f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}",
self.game.turn,
f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}",
)
self.game.informations.append(info)
else:
print(status_msg)
enemy_cp.base.affect_strength(delta)
cp.base.affect_strength(-delta)
info = Information(
self.game.message(
"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:
""" "
@@ -410,10 +397,8 @@ class Event:
total_units_redeployed += move_count
if total_units_redeployed > 0:
text = (
self.game.message(
"Units redeployed",
f"{total_units_redeployed} units have been redeployed from "
f"{source.name} to {destination.name}"
f"{source.name} to {destination.name}",
)
info = Information("Units redeployed", text, self.game.turn)
self.game.informations.append(info)
logging.info(text)

View File

@@ -107,8 +107,8 @@ class Game:
self.game_stats = GameStats()
self.notes = ""
self.ground_planners: dict[int, GroundPlanner] = {}
self.informations = []
self.informations.append(Information("Game Start", "-" * 40, 0))
self.informations: list[Information] = []
self.message("Game Start", "-" * 40)
# 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[dict[str, Union[float, str]]] = []
@@ -125,6 +125,9 @@ class Game:
self.blue.set_opponent(self.red)
self.red.set_opponent(self.blue)
for control_point in self.theater.controlpoints:
control_point.finish_init(self)
self.blue.configure_default_air_wing(air_wing_config)
self.red.configure_default_air_wing(air_wing_config)
@@ -279,9 +282,7 @@ class Game:
Args:
skipped: True if the turn was skipped.
"""
self.informations.append(
Information("End of turn #" + str(self.turn), "-" * 40, 0)
)
self.message("End of turn #" + str(self.turn), "-" * 40)
self.turn += 1
# The coalition-specific turn finalization *must* happen before unit deliveries,
@@ -297,10 +298,6 @@ class Game:
if not skipped:
for cp in self.theater.player_points():
cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY)
elif self.turn > 1:
for cp in self.theater.player_points():
if not cp.is_carrier and not cp.is_lha:
cp.base.affect_strength(-PLAYER_BASE_STRENGTH_RECOVERY)
self.conditions = self.generate_conditions()
@@ -414,8 +411,8 @@ class Game:
gplanner.plan_groundwar()
self.ground_planners[cp.id] = gplanner
def message(self, text: str) -> None:
self.informations.append(Information(text, turn=self.turn))
def message(self, title: str, text: str = "") -> None:
self.informations.append(Information(title, text, turn=self.turn))
@property
def current_turn_time_of_day(self) -> TimeOfDay:

View File

@@ -34,7 +34,7 @@ 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
from gen.tacan import TacanRegistry, TacanUsage
from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator
from gen.visualgen import VisualGenerator
from .. import db
@@ -242,7 +242,7 @@ class Operation:
if beacon.channel is None:
logging.error(f"TACAN beacon has no channel: {beacon.callsign}")
else:
cls.tacan_registry.reserve(beacon.tacan_channel)
cls.tacan_registry.mark_unavailable(beacon.tacan_channel)
@classmethod
def _create_radio_registry(

View File

@@ -74,7 +74,7 @@ class ProcurementAi:
self.game.coalition_for(self.is_player).transfers
)
armor_investment += cp_ground_units.total_value
cp_aircraft = cp.allocated_aircraft(self.game)
cp_aircraft = cp.allocated_aircraft()
aircraft_investment += cp_aircraft.total_value
total_investment = aircraft_investment + armor_investment
@@ -221,45 +221,22 @@ class ProcurementAi:
else:
return self.game.theater.enemy_points()
@staticmethod
def squadron_rank_for_task(squadron: Squadron, task: FlightType) -> int:
return aircraft_for_task(task).index(squadron.aircraft)
def compatible_squadrons_at_airbase(
self, airbase: ControlPoint, request: AircraftProcurementRequest
) -> Iterator[Squadron]:
compatible: list[Squadron] = []
for squadron in airbase.squadrons:
if not squadron.can_auto_assign(request.task_capability):
continue
if not squadron.can_provide_pilots(request.number):
continue
distance_to_target = meters(request.near.distance_to(airbase))
if distance_to_target > squadron.aircraft.max_mission_range:
continue
compatible.append(squadron)
yield from sorted(
compatible,
key=lambda s: self.squadron_rank_for_task(s, request.task_capability),
)
def best_squadrons_for(
self, request: AircraftProcurementRequest
) -> Iterator[Squadron]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
threatened = []
for cp in distance_cache.operational_airfields:
if not cp.is_friendly(self.is_player):
for squadron in self.air_wing.best_squadrons_for(
request.near, request.task_capability, request.number, this_turn=False
):
if not squadron.can_provide_pilots(request.number):
continue
if cp.unclaimed_parking(self.game) < request.number:
if squadron.location.unclaimed_parking() < request.number:
continue
if self.threat_zones.threatened(cp.position):
threatened.append(cp)
if self.threat_zones.threatened(squadron.location.position):
threatened.append(squadron)
continue
yield from self.compatible_squadrons_at_airbase(cp, request)
for threatened_base in threatened:
yield from self.compatible_squadrons_at_airbase(threatened_base, request)
yield squadron
yield from threatened
def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
worst_supply = math.inf

View File

@@ -1,9 +1,11 @@
from abc import abstractmethod
from typing import TypeVar, Generic
from typing import TypeVar, Generic, Any
from game import Game
from game.coalition import Coalition
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.dcs.unittype import UnitType
from game.squadrons import Squadron
from game.theater import ControlPoint
@@ -90,14 +92,15 @@ class PurchaseAdapter(Generic[ItemType]):
def name_of(self, item: ItemType, multiline: bool = False) -> str:
...
@abstractmethod
def unit_type_of(self, item: ItemType) -> UnitType[Any]:
...
class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]):
def __init__(
self, control_point: ControlPoint, coalition: Coalition, game: Game
) -> None:
super().__init__(coalition)
def __init__(self, control_point: ControlPoint) -> None:
super().__init__(control_point.coalition)
self.control_point = control_point
self.game = game
def pending_delivery_quantity(self, item: Squadron) -> int:
return item.pending_deliveries
@@ -106,10 +109,7 @@ class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]):
return item.owned_aircraft
def can_buy(self, item: Squadron) -> bool:
return (
super().can_buy(item)
and self.control_point.unclaimed_parking(self.game) > 0
)
return super().can_buy(item) and self.control_point.unclaimed_parking() > 0
def can_sell(self, item: Squadron) -> bool:
return item.untasked_aircraft > 0
@@ -138,6 +138,9 @@ class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]):
separator = " "
return separator.join([item.aircraft.name, str(item)])
def unit_type_of(self, item: Squadron) -> AircraftType:
return item.aircraft
class GroundUnitPurchaseAdapter(PurchaseAdapter[GroundUnitType]):
def __init__(
@@ -178,3 +181,6 @@ class GroundUnitPurchaseAdapter(PurchaseAdapter[GroundUnitType]):
def name_of(self, item: GroundUnitType, multiline: bool = False) -> str:
return f"{item}"
def unit_type_of(self, item: GroundUnitType) -> GroundUnitType:
return item

View File

@@ -2,24 +2,24 @@ from __future__ import annotations
import itertools
from collections import defaultdict
from typing import Sequence, Iterator, TYPE_CHECKING
from typing import Sequence, Iterator, TYPE_CHECKING, Optional
from game.dcs.aircrafttype import AircraftType
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from .squadron import Squadron
from ..theater import ControlPoint
from ..theater import ControlPoint, MissionTarget
if TYPE_CHECKING:
from game import Game
from gen.flights.flight import FlightType
class AirWing:
def __init__(self, game: Game) -> None:
self.game = game
def __init__(self, player: bool) -> None:
self.player = player
self.squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
def add_squadron(self, squadron: Squadron) -> None:
squadron.location.squadrons.append(squadron)
self.squadrons[squadron.aircraft].append(squadron)
def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]:
@@ -32,6 +32,35 @@ class AirWing:
except StopIteration:
return False
def best_squadrons_for(
self, location: MissionTarget, task: FlightType, size: int, this_turn: bool
) -> list[Squadron]:
airfield_cache = ObjectiveDistanceCache.get_closest_airfields(location)
best_aircraft = aircraft_for_task(task)
ordered: list[Squadron] = []
for control_point in airfield_cache.operational_airfields:
if control_point.captured != self.player:
continue
capable_at_base = []
for squadron in control_point.squadrons:
if squadron.can_auto_assign_mission(location, task, size, this_turn):
capable_at_base.append(squadron)
ordered.extend(
sorted(
capable_at_base,
key=lambda s: best_aircraft.index(s.aircraft),
)
)
return ordered
def best_squadron_for(
self, location: MissionTarget, task: FlightType, size: int, this_turn: bool
) -> Optional[Squadron]:
for squadron in self.best_squadrons_for(location, task, size, this_turn):
return squadron
return None
@property
def available_aircraft_types(self) -> Iterator[AircraftType]:
for aircraft, squadrons in self.squadrons.items():
@@ -52,17 +81,6 @@ class AirWing:
if squadron.can_auto_assign(task) and squadron.location == base:
yield squadron
def auto_assignable_for_task_with_type(
self, aircraft: AircraftType, task: FlightType, base: ControlPoint
) -> Iterator[Squadron]:
for squadron in self.squadrons_for(aircraft):
if (
squadron.location == base
and squadron.can_auto_assign(task)
and squadron.has_available_pilots
):
yield squadron
def squadron_for(self, aircraft: AircraftType) -> Squadron:
return self.squadrons_for(aircraft)[0]
@@ -76,9 +94,9 @@ class AirWing:
for squadron in self.iter_squadrons():
squadron.populate_for_turn_0()
def replenish(self) -> None:
def end_turn(self) -> None:
for squadron in self.iter_squadrons():
squadron.replenish_lost_pilots()
squadron.end_turn()
def reset(self) -> None:
for squadron in self.iter_squadrons():

View File

@@ -11,17 +11,20 @@ from typing import (
from faker import Faker
from game.dcs.aircrafttype import AircraftType
from game.settings import AutoAtoBehavior, Settings
from game.squadrons.operatingbases import OperatingBases
from game.squadrons.pilot import Pilot, PilotStatus
from game.squadrons.squadrondef import SquadronDef
from gen.ato import Package
from gen.flights.flight import FlightType, Flight
from gen.flights.flightplan import FlightPlanBuilder
from .pilot import Pilot, PilotStatus
from ..utils import meters
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
from gen.flights.flight import FlightType
from game.theater import ControlPoint
from game.dcs.aircrafttype import AircraftType
from game.theater import ControlPoint, ConflictTheater, MissionTarget
from .operatingbases import OperatingBases
from .squadrondef import SquadronDef
@dataclass
@@ -53,6 +56,9 @@ class Squadron:
settings: Settings = field(hash=False, compare=False)
location: ControlPoint
destination: Optional[ControlPoint] = field(
init=False, hash=False, compare=False, default=None
)
owned_aircraft: int = field(init=False, hash=False, compare=False, default=0)
untasked_aircraft: int = field(init=False, hash=False, compare=False, default=0)
@@ -82,9 +88,7 @@ class Squadron:
return self.coalition.player
def assign_to_base(self, base: ControlPoint) -> None:
self.location.squadrons.remove(self)
self.location = base
self.location.squadrons.append(self)
logging.debug(f"Assigned {self} to {base}")
@property
@@ -169,6 +173,12 @@ class Squadron:
raise ValueError("Squadrons can only be created with active pilots.")
self._recruit_pilots(self.settings.squadron_pilot_limit)
def end_turn(self) -> None:
if self.destination is not None:
self.relocate_to(self.destination)
self.replenish_lost_pilots()
self.deliver_orders()
def replenish_lost_pilots(self) -> None:
if not self.pilot_limits_enabled:
return
@@ -243,6 +253,17 @@ class Squadron:
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
def can_auto_assign_mission(
self, location: MissionTarget, task: FlightType, size: int, this_turn: bool
) -> bool:
if not self.can_auto_assign(task):
return False
if this_turn and not self.can_fulfill_flight(size):
return False
distance_to_target = meters(location.distance_to(self.location))
return distance_to_target <= self.aircraft.max_mission_range
def operates_from(self, control_point: ControlPoint) -> bool:
if control_point.is_carrier:
return self.operating_bases.carrier
@@ -265,18 +286,133 @@ class Squadron:
def can_fulfill_flight(self, count: int) -> bool:
return self.can_provide_pilots(count) and self.untasked_aircraft >= count
def refund_orders(self) -> None:
self.coalition.adjust_budget(self.aircraft.price * self.pending_deliveries)
self.pending_deliveries = 0
def refund_orders(self, count: Optional[int] = None) -> None:
if count is None:
count = self.pending_deliveries
self.coalition.adjust_budget(self.aircraft.price * count)
self.pending_deliveries -= count
def deliver_orders(self) -> None:
self.cancel_overflow_orders()
self.owned_aircraft += self.pending_deliveries
self.pending_deliveries = 0
def relocate_to(self, destination: ControlPoint) -> None:
self.location = destination
if self.location == self.destination:
self.destination = None
def cancel_overflow_orders(self) -> None:
if self.pending_deliveries <= 0:
return
overflow = -self.location.unclaimed_parking()
if overflow > 0:
sell_count = min(overflow, self.pending_deliveries)
logging.debug(
f"{self.location} is overfull by {overflow} aircraft. Cancelling "
f"orders for {sell_count} aircraft to make room."
)
self.refund_orders(sell_count)
@property
def max_fulfillable_aircraft(self) -> int:
return max(self.number_of_available_pilots, self.untasked_aircraft)
@property
def expected_size_next_turn(self) -> int:
return self.owned_aircraft + self.pending_deliveries
@property
def arrival(self) -> ControlPoint:
return self.location if self.destination is None else self.destination
def plan_relocation(
self, destination: ControlPoint, theater: ConflictTheater
) -> None:
if destination == self.location:
logging.warning(
f"Attempted to plan relocation of {self} to current location "
f"{destination}. Ignoring."
)
return
if destination == self.destination:
logging.warning(
f"Attempted to plan relocation of {self} to current destination "
f"{destination}. Ignoring."
)
return
if self.expected_size_next_turn >= destination.unclaimed_parking():
raise RuntimeError(f"Not enough parking for {self} at {destination}.")
if not destination.can_operate(self.aircraft):
raise RuntimeError(f"{self} cannot operate at {destination}.")
self.destination = destination
self.replan_ferry_flights(theater)
def cancel_relocation(self) -> None:
if self.destination is None:
logging.warning(
f"Attempted to cancel relocation of squadron with no transfer order. "
"Ignoring."
)
return
if self.expected_size_next_turn >= self.location.unclaimed_parking():
raise RuntimeError(f"Not enough parking for {self} at {self.location}.")
self.destination = None
self.cancel_ferry_flights()
def replan_ferry_flights(self, theater: ConflictTheater) -> None:
self.cancel_ferry_flights()
self.plan_ferry_flights(theater)
def cancel_ferry_flights(self) -> None:
for package in self.coalition.ato.packages:
# Copy the list so our iterator remains consistent throughout the removal.
for flight in list(package.flights):
if flight.squadron == self and flight.flight_type is FlightType.FERRY:
package.remove_flight(flight)
flight.return_pilots_and_aircraft()
if not package.flights:
self.coalition.ato.remove_package(package)
def plan_ferry_flights(self, theater: ConflictTheater) -> None:
if self.destination is None:
raise RuntimeError(
f"Cannot plan ferry flights for {self} because there is no destination."
)
remaining = self.untasked_aircraft
if not remaining:
return
package = Package(self.destination)
builder = FlightPlanBuilder(package, self.coalition, theater)
while remaining:
size = min(remaining, self.aircraft.max_group_size)
self.plan_ferry_flight(builder, package, size)
remaining -= size
package.set_tot_asap()
self.coalition.ato.add_package(package)
def plan_ferry_flight(
self, builder: FlightPlanBuilder, package: Package, size: int
) -> None:
start_type = self.location.required_aircraft_start_type
if start_type is None:
start_type = self.settings.default_start_type
flight = Flight(
package,
self.coalition.country_name,
self,
size,
FlightType.FERRY,
start_type,
divert=None,
)
package.add_flight(flight)
builder.populate_flight_plan(flight)
@classmethod
def create_from(
cls,

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import heapq
import itertools
import logging
import math
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass, field
@@ -56,6 +57,7 @@ if TYPE_CHECKING:
from game import Game
from gen.flights.flight import FlightType
from game.squadrons.squadron import Squadron
from ..coalition import Coalition
from ..transfers import PendingTransfers
FREE_FRONTLINE_UNIT_SUPPLY: int = 15
@@ -281,7 +283,6 @@ class ControlPoint(MissionTarget, ABC):
position = None # type: Point
name = None # type: str
captured = False
has_frontline = True
alt = 0
@@ -295,6 +296,7 @@ class ControlPoint(MissionTarget, ABC):
name: str,
position: Point,
at: db.StartingPosition,
starts_blue: bool,
has_frontline: bool = True,
cptype: ControlPointType = ControlPointType.AIRBASE,
) -> None:
@@ -303,11 +305,12 @@ class ControlPoint(MissionTarget, ABC):
self.id = cp_id
self.full_name = name
self.at = at
self.starts_blue = starts_blue
self.connected_objectives: List[TheaterGroundObject[Any]] = []
self.preset_locations = PresetLocations()
self.helipads: List[PointWithHeading] = []
self.captured = False
self._coalition: Optional[Coalition] = None
self.captured_invert = False
# TODO: Should be Airbase specific.
self.has_frontline = has_frontline
@@ -324,15 +327,33 @@ class ControlPoint(MissionTarget, ABC):
self.target_position: Optional[Point] = None
self.squadrons: list[Squadron] = []
def __repr__(self) -> str:
return f"<{self.__class__}: {self.name}>"
@property
def coalition(self) -> Coalition:
if self._coalition is None:
raise RuntimeError("ControlPoint not fully initialized: coalition not set")
return self._coalition
def finish_init(self, game: Game) -> None:
assert self._coalition is None
self._coalition = game.coalition_for(self.starts_blue)
@property
def captured(self) -> bool:
return self.coalition.player
@property
def ground_objects(self) -> List[TheaterGroundObject[Any]]:
return list(self.connected_objectives)
@property
def squadrons(self) -> Iterator[Squadron]:
for squadron in self.coalition.air_wing.iter_squadrons():
if squadron.location == self:
yield squadron
@property
@abstractmethod
def heading(self) -> Heading:
@@ -564,36 +585,82 @@ class ControlPoint(MissionTarget, ABC):
value = airframe.price * count
game.adjust_budget(value, player=not self.captured)
game.message(
f"No valid retreat destination in range of {self.name} for {airframe}"
f"No valid retreat destination in range of {self.name} for {airframe} "
f"{count} aircraft have been captured and sold for ${value}M."
)
def aircraft_retreat_destination(
self, game: Game, airframe: AircraftType
self, squadron: Squadron
) -> Optional[ControlPoint]:
closest = ObjectiveDistanceCache.get_closest_airfields(self)
# TODO: Should be airframe dependent.
max_retreat_distance = nautical_miles(200)
max_retreat_distance = squadron.aircraft.max_mission_range
# Skip the first airbase because that's the airbase we're retreating
# from.
airfields = list(closest.operational_airfields_within(max_retreat_distance))[1:]
not_preferred: Optional[ControlPoint] = None
overfull: list[ControlPoint] = []
for airbase in airfields:
if not airbase.can_operate(airframe):
continue
if airbase.captured != self.captured:
continue
if airbase.unclaimed_parking(game) > 0:
return airbase
return None
@staticmethod
def _retreat_squadron(squadron: Squadron) -> None:
logging.error("Air unit retreat not currently implemented")
if airbase.unclaimed_parking() < squadron.owned_aircraft:
if airbase.can_operate(squadron.aircraft):
overfull.append(airbase)
continue
if squadron.operates_from(airbase):
# Has room, is a preferred base type for this squadron, and is the
# closest choice. No need to keep looking.
return airbase
if not_preferred is None and airbase.can_operate(squadron.aircraft):
# Has room and is capable of operating from this base, but it isn't
# preferred. Remember this option and use it if we can't find a
# preferred base type with room.
not_preferred = airbase
if not_preferred is not None:
# It's not our best choice but the other choices don't have room for the
# squadron and would lead to aircraft being captured.
return not_preferred
# No base was available with enough room. Find whichever base has the most room
# available so we lose as little as possible. The overfull list is already
# sorted by distance, and filtered for appropriate destinations.
base_for_fewest_losses: Optional[ControlPoint] = None
loss_count = math.inf
for airbase in overfull:
overflow = -(
airbase.unclaimed_parking()
- squadron.owned_aircraft
- squadron.pending_deliveries
)
if overflow < loss_count:
loss_count = overflow
base_for_fewest_losses = airbase
return base_for_fewest_losses
def _retreat_squadron(self, game: Game, squadron: Squadron) -> None:
destination = self.aircraft_retreat_destination(squadron)
if destination is None:
squadron.refund_orders()
self.capture_aircraft(game, squadron.aircraft, squadron.owned_aircraft)
return
logging.debug(f"{squadron} retreating to {destination} from {self}")
squadron.relocate_to(destination)
squadron.cancel_overflow_orders()
overflow = -destination.unclaimed_parking()
if overflow > 0:
logging.debug(
f"Not enough room for {squadron} at {destination}. Capturing "
f"{overflow} aircraft."
)
self.capture_aircraft(game, squadron.aircraft, overflow)
squadron.owned_aircraft -= overflow
def retreat_air_units(self, game: Game) -> None:
# TODO: Capture in order of price to retain maximum value?
for squadron in self.squadrons:
self._retreat_squadron(squadron)
self._retreat_squadron(game, squadron)
def depopulate_uncapturable_tgos(self) -> None:
for tgo in self.connected_objectives:
@@ -602,27 +669,25 @@ class ControlPoint(MissionTarget, ABC):
# TODO: Should be Airbase specific.
def capture(self, game: Game, for_player: bool) -> None:
coalition = game.coalition_for(for_player)
self.ground_unit_orders.refund_all(coalition)
for squadron in self.squadrons:
squadron.refund_orders()
new_coalition = game.coalition_for(for_player)
self.ground_unit_orders.refund_all(self.coalition)
self.retreat_ground_units(game)
self.retreat_air_units(game)
self.depopulate_uncapturable_tgos()
if for_player:
self.captured = True
else:
self.captured = False
self._coalition = new_coalition
self.base.set_strength_to_minimum()
@property
def required_aircraft_start_type(self) -> Optional[str]:
return None
@abstractmethod
def can_operate(self, aircraft: AircraftType) -> bool:
...
def unclaimed_parking(self, game: Game) -> int:
return self.total_aircraft_parking - self.allocated_aircraft(game).total
def unclaimed_parking(self) -> int:
return self.total_aircraft_parking - self.allocated_aircraft().total
@abstractmethod
def active_runway(
@@ -630,6 +695,10 @@ class ControlPoint(MissionTarget, ABC):
) -> RunwayData:
...
@property
def airdrome_id_for_landing(self) -> Optional[int]:
return None
@property
def parking_slots(self) -> Iterator[ParkingSlot]:
yield from []
@@ -651,8 +720,6 @@ class ControlPoint(MissionTarget, ABC):
def process_turn(self, game: Game) -> None:
self.ground_unit_orders.process(game)
for squadron in self.squadrons:
squadron.deliver_orders()
runway_status = self.runway_status
if runway_status is not None:
@@ -674,16 +741,22 @@ class ControlPoint(MissionTarget, ABC):
u.position.x = u.position.x + delta.x
u.position.y = u.position.y + delta.y
def allocated_aircraft(self, _game: Game) -> AircraftAllocations:
def allocated_aircraft(self) -> AircraftAllocations:
present: dict[AircraftType, int] = defaultdict(int)
on_order: dict[AircraftType, int] = defaultdict(int)
transferring: dict[AircraftType, int] = defaultdict(int)
for squadron in self.squadrons:
present[squadron.aircraft] += squadron.owned_aircraft
# TODO: Only if this is the squadron destination, not location.
on_order[squadron.aircraft] += squadron.pending_deliveries
if squadron.destination is None:
on_order[squadron.aircraft] += squadron.pending_deliveries
else:
transferring[squadron.aircraft] -= squadron.owned_aircraft
for squadron in self.coalition.air_wing.iter_squadrons():
if squadron.destination == self:
on_order[squadron.aircraft] += squadron.pending_deliveries
transferring[squadron.aircraft] += squadron.owned_aircraft
# TODO: Implement squadron transfers.
return AircraftAllocations(present, on_order, transferring={})
return AircraftAllocations(present, on_order, transferring)
def allocated_ground_units(
self, transfers: PendingTransfers
@@ -795,13 +868,14 @@ class ControlPoint(MissionTarget, ABC):
class Airfield(ControlPoint):
def __init__(self, airport: Airport, has_frontline: bool = True) -> None:
def __init__(self, airport: Airport, starts_blue: bool) -> None:
super().__init__(
airport.id,
airport.name,
airport.position,
airport,
has_frontline,
starts_blue,
has_frontline=True,
cptype=ControlPointType.AIRBASE,
)
self.airport = airport
@@ -863,6 +937,10 @@ class Airfield(ControlPoint):
assigner = RunwayAssigner(conditions)
return assigner.get_preferred_runway(self.airport)
@property
def airdrome_id_for_landing(self) -> Optional[int]:
return self.airport.id
@property
def parking_slots(self) -> Iterator[ParkingSlot]:
yield from self.airport.parking_slots
@@ -970,12 +1048,13 @@ class NavalControlPoint(ControlPoint, ABC):
class Carrier(NavalControlPoint):
def __init__(self, name: str, at: Point, cp_id: int):
def __init__(self, name: str, at: Point, cp_id: int, starts_blue: bool):
super().__init__(
cp_id,
name,
at,
at,
starts_blue,
has_frontline=False,
cptype=ControlPointType.AIRCRAFT_CARRIER_GROUP,
)
@@ -1010,12 +1089,13 @@ class Carrier(NavalControlPoint):
class Lha(NavalControlPoint):
def __init__(self, name: str, at: Point, cp_id: int):
def __init__(self, name: str, at: Point, cp_id: int, starts_blue: bool):
super().__init__(
cp_id,
name,
at,
at,
starts_blue,
has_frontline=False,
cptype=ControlPointType.LHA_GROUP,
)
@@ -1043,12 +1123,13 @@ class OffMapSpawn(ControlPoint):
def runway_is_operational(self) -> bool:
return True
def __init__(self, cp_id: int, name: str, position: Point):
def __init__(self, cp_id: int, name: str, position: Point, starts_blue: bool):
super().__init__(
cp_id,
name,
position,
at=position,
position,
starts_blue,
has_frontline=False,
cptype=ControlPointType.OFF_MAP,
)
@@ -1066,6 +1147,10 @@ class OffMapSpawn(ControlPoint):
def can_operate(self, aircraft: AircraftType) -> bool:
return True
@property
def required_aircraft_start_type(self) -> Optional[str]:
return "In Flight"
@property
def heading(self) -> Heading:
return Heading.from_degrees(0)
@@ -1096,12 +1181,13 @@ class OffMapSpawn(ControlPoint):
class Fob(ControlPoint):
def __init__(self, name: str, at: Point, cp_id: int):
def __init__(self, name: str, at: Point, cp_id: int, starts_blue: bool):
super().__init__(
cp_id,
name,
at,
at,
starts_blue,
has_frontline=True,
cptype=ControlPointType.FOB,
)

View File

@@ -8,13 +8,11 @@ from datetime import datetime
from typing import Any, Dict, Iterable, List, Set
from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence
from game import Game
from game.factions.faction import Faction
from game.scenery_group import SceneryGroup
from game.theater import Carrier, Lha, PointWithHeading
from game.theater import PointWithHeading
from game.theater.theatergroundobject import (
BuildingGroundObject,
CarrierGroundObject,
@@ -30,7 +28,6 @@ from game.theater.theatergroundobject import (
)
from game.utils import Heading
from game.version import VERSION
from gen.naming import namegen
from gen.coastal.coastal_group_generator import generate_coastal_group
from gen.defenses.armor_group_generator import generate_armor_group
from gen.fleet.ship_group_generator import (
@@ -39,6 +36,7 @@ from gen.fleet.ship_group_generator import (
generate_ship_group,
)
from gen.missiles.missiles_group_generator import generate_missile_group
from gen.naming import namegen
from gen.sam.airdefensegroupgenerator import AirDefenseRange
from gen.sam.ewr_group_generator import generate_ewr_group
from gen.sam.sam_group_generator import generate_anti_air_group
@@ -61,7 +59,6 @@ class GeneratorSettings:
start_date: datetime
player_budget: int
enemy_budget: int
midgame: bool
inverted: bool
no_carrier: bool
no_lha: bool
@@ -91,13 +88,12 @@ class GameGenerator:
generator_settings: GeneratorSettings,
mod_settings: ModSettings,
) -> None:
self.player = player
self.enemy = enemy
self.player = player.apply_mod_settings(mod_settings)
self.enemy = enemy.apply_mod_settings(mod_settings)
self.theater = theater
self.air_wing_config = air_wing_config
self.settings = settings
self.generator_settings = generator_settings
self.mod_settings = mod_settings
def generate(self) -> Game:
with logged_duration("TGO population"):
@@ -105,8 +101,8 @@ class GameGenerator:
namegen.reset()
self.prepare_theater()
game = Game(
player_faction=self.player.apply_mod_settings(self.mod_settings),
enemy_faction=self.enemy.apply_mod_settings(self.mod_settings),
player_faction=self.player,
enemy_faction=self.enemy,
theater=self.theater,
air_wing_config=self.air_wing_config,
start_date=self.generator_settings.start_date,
@@ -119,37 +115,31 @@ class GameGenerator:
game.settings.version = VERSION
return game
def should_remove_carrier(self, player: bool) -> bool:
faction = self.player if player else self.enemy
return self.generator_settings.no_carrier or not faction.carrier_names
def should_remove_lha(self, player: bool) -> bool:
faction = self.player if player else self.enemy
return self.generator_settings.no_lha or not faction.helicopter_carrier_names
def prepare_theater(self) -> None:
to_remove: List[ControlPoint] = []
# Auto-capture half the bases if midgame.
if self.generator_settings.midgame:
control_points = self.theater.controlpoints
for control_point in control_points[: len(control_points) // 2]:
control_point.captured = True
# Remove carrier and lha, invert situation if needed
for cp in self.theater.controlpoints:
if isinstance(cp, Carrier) and self.generator_settings.no_carrier:
to_remove.append(cp)
elif isinstance(cp, Lha) and self.generator_settings.no_lha:
to_remove.append(cp)
if self.generator_settings.inverted:
cp.captured = cp.captured_invert
cp.starts_blue = cp.captured_invert
if cp.is_carrier and self.should_remove_carrier(cp.starts_blue):
to_remove.append(cp)
elif cp.is_lha and self.should_remove_lha(cp.starts_blue):
to_remove.append(cp)
# do remove
for cp in to_remove:
self.theater.controlpoints.remove(cp)
# TODO: Fix this. This captures all bases for blue.
# reapply midgame inverted if needed
if self.generator_settings.midgame and self.generator_settings.inverted:
for i, cp in enumerate(reversed(self.theater.controlpoints)):
if i > len(self.theater.controlpoints):
break
else:
cp.captured = True
class ControlPointGroundObjectGenerator:
def __init__(

View File

@@ -349,15 +349,17 @@ class AirliftPlanner:
else:
transfer = self.transfer
start_type = squadron.location.required_aircraft_start_type
if start_type is None:
start_type = self.game.settings.default_start_type
flight = Flight(
self.package,
self.game.country_for(squadron.player),
squadron,
flight_size,
FlightType.TRANSPORT,
self.game.settings.default_start_type,
departure=squadron.location,
arrival=squadron.location,
start_type,
divert=None,
cargo=transfer,
)
@@ -752,7 +754,7 @@ class PendingTransfers:
)
def order_airlift_assets_at(self, control_point: ControlPoint) -> None:
unclaimed_parking = control_point.unclaimed_parking(self.game)
unclaimed_parking = control_point.unclaimed_parking()
# Buy a maximum of unclaimed_parking only to prevent that aircraft procurement
# take place at another base
gap = min(