Assign aircraft to squadrons rather than bases.

This is needed to support the upcoming squadron transfers, since
squadrons need to bring their aircraft with them.

https://github.com/dcs-liberation/dcs_liberation/issues/1145
This commit is contained in:
Dan Albert 2021-08-15 15:41:39 -07:00
parent 99274133ff
commit 4423288a53
36 changed files with 574 additions and 870 deletions

View File

@ -8,6 +8,7 @@ Saves from 4.x are not compatible with 5.0.
* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. * **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated.
* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts. * **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts.
* **[Campaign]** (WIP) Squadrons now have a home base and will not operate out of other bases. See https://github.com/dcs-liberation/dcs_liberation/issues/1145 for status. * **[Campaign]** (WIP) Squadrons now have a home base and will not operate out of other bases. See https://github.com/dcs-liberation/dcs_liberation/issues/1145 for status.
* **[Campaign]** Aircraft now belong to squadrons rather than bases to support squadron location transfers.
* **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions.
* **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI.
* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points.

View File

@ -10,7 +10,6 @@ from game.campaignloader.defaultsquadronassigner import DefaultSquadronAssigner
from game.commander import TheaterCommander from game.commander import TheaterCommander
from game.commander.missionscheduler import MissionScheduler from game.commander.missionscheduler import MissionScheduler
from game.income import Income from game.income import Income
from game.inventory import GlobalAircraftInventory
from game.navmesh import NavMesh from game.navmesh import NavMesh
from game.orderedset import OrderedSet from game.orderedset import OrderedSet
from game.profiling import logged_duration, MultiEventTracer from game.profiling import logged_duration, MultiEventTracer
@ -88,10 +87,6 @@ class Coalition:
assert self._navmesh is not None assert self._navmesh is not None
return self._navmesh return self._navmesh
@property
def aircraft_inventory(self) -> GlobalAircraftInventory:
return self.game.aircraft_inventory
def __getstate__(self) -> dict[str, Any]: def __getstate__(self) -> dict[str, Any]:
state = self.__dict__.copy() state = self.__dict__.copy()
# Avoid persisting any volatile types that can be deterministically # Avoid persisting any volatile types that can be deterministically
@ -196,7 +191,9 @@ class Coalition:
return return
for cp in self.game.theater.control_points_for(self.player): for cp in self.game.theater.control_points_for(self.player):
cp.pending_unit_deliveries.refund_all(self) cp.ground_unit_orders.refund_all(self)
for squadron in self.air_wing.iter_squadrons():
squadron.refund_orders()
def plan_missions(self) -> None: def plan_missions(self) -> None:
color = "Blue" if self.player else "Red" color = "Blue" if self.player else "Red"

View File

@ -1,9 +1,8 @@
from typing import Optional, Tuple from typing import Optional, Tuple
from game.commander.missionproposals import ProposedFlight from game.commander.missionproposals import ProposedFlight
from game.inventory import GlobalAircraftInventory
from game.squadrons.squadron import Squadron
from game.squadrons.airwing import AirWing from game.squadrons.airwing import AirWing
from game.squadrons.squadron import Squadron
from game.theater import ControlPoint, MissionTarget from game.theater import ControlPoint, MissionTarget
from game.utils import meters from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.ai_flight_planner_db import aircraft_for_task
@ -15,15 +14,10 @@ class AircraftAllocator:
"""Finds suitable aircraft for proposed missions.""" """Finds suitable aircraft for proposed missions."""
def __init__( def __init__(
self, self, air_wing: AirWing, closest_airfields: ClosestAirfields, is_player: bool
air_wing: AirWing,
closest_airfields: ClosestAirfields,
global_inventory: GlobalAircraftInventory,
is_player: bool,
) -> None: ) -> None:
self.air_wing = air_wing self.air_wing = air_wing
self.closest_airfields = closest_airfields self.closest_airfields = closest_airfields
self.global_inventory = global_inventory
self.is_player = is_player self.is_player = is_player
def find_squadron_for_flight( def find_squadron_for_flight(
@ -56,12 +50,9 @@ class AircraftAllocator:
for airfield in self.closest_airfields.operational_airfields: for airfield in self.closest_airfields.operational_airfields:
if not airfield.is_friendly(self.is_player): if not airfield.is_friendly(self.is_player):
continue continue
inventory = self.global_inventory.for_control_point(airfield)
for aircraft in types: for aircraft in types:
if not airfield.can_operate(aircraft): if not airfield.can_operate(aircraft):
continue continue
if inventory.available(aircraft) < flight.num_aircraft:
continue
distance_to_target = meters(target.distance_to(airfield)) distance_to_target = meters(target.distance_to(airfield))
if distance_to_target > aircraft.max_mission_range: if distance_to_target > aircraft.max_mission_range:
continue continue
@ -71,9 +62,8 @@ class AircraftAllocator:
aircraft, task, airfield aircraft, task, airfield
) )
for squadron in squadrons: for squadron in squadrons:
if squadron.operates_from(airfield) and squadron.can_provide_pilots( if squadron.operates_from(airfield) and squadron.can_fulfill_flight(
flight.num_aircraft flight.num_aircraft
): ):
inventory.remove_aircraft(aircraft, flight.num_aircraft)
return airfield, squadron return airfield, squadron
return None return None

View File

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

View File

@ -1,13 +1,12 @@
from typing import Optional from typing import Optional
from game.commander.aircraftallocator import AircraftAllocator
from game.commander.missionproposals import ProposedFlight from game.commander.missionproposals import ProposedFlight
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.inventory import GlobalAircraftInventory
from game.squadrons.airwing import AirWing from game.squadrons.airwing import AirWing
from game.theater import MissionTarget, OffMapSpawn, ControlPoint from game.theater import MissionTarget, OffMapSpawn, ControlPoint
from game.utils import nautical_miles from game.utils import nautical_miles
from gen.ato import Package from gen.ato import Package
from game.commander.aircraftallocator import AircraftAllocator
from gen.flights.closestairfields import ClosestAirfields from gen.flights.closestairfields import ClosestAirfields
from gen.flights.flight import Flight from gen.flights.flight import Flight
@ -19,7 +18,6 @@ class PackageBuilder:
self, self,
location: MissionTarget, location: MissionTarget,
closest_airfields: ClosestAirfields, closest_airfields: ClosestAirfields,
global_inventory: GlobalAircraftInventory,
air_wing: AirWing, air_wing: AirWing,
is_player: bool, is_player: bool,
package_country: str, package_country: str,
@ -30,10 +28,7 @@ class PackageBuilder:
self.is_player = is_player self.is_player = is_player
self.package_country = package_country self.package_country = package_country
self.package = Package(location, auto_asap=asap) self.package = Package(location, auto_asap=asap)
self.allocator = AircraftAllocator( self.allocator = AircraftAllocator(air_wing, closest_airfields, is_player)
air_wing, closest_airfields, global_inventory, is_player
)
self.global_inventory = global_inventory
self.start_type = start_type self.start_type = start_type
def plan_flight(self, plan: ProposedFlight) -> bool: def plan_flight(self, plan: ProposedFlight) -> bool:
@ -93,6 +88,5 @@ class PackageBuilder:
"""Returns any planned flights to the inventory.""" """Returns any planned flights to the inventory."""
flights = list(self.package.flights) flights = list(self.package.flights)
for flight in flights: for flight in flights:
self.global_inventory.return_from_flight(flight) flight.return_pilots_and_aircraft()
flight.clear_roster()
self.package.remove_flight(flight) self.package.remove_flight(flight)

View File

@ -5,15 +5,14 @@ from collections import defaultdict
from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional
from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType
from game.commander.packagebuilder import PackageBuilder
from game.data.doctrine import Doctrine from game.data.doctrine import Doctrine
from game.inventory import GlobalAircraftInventory
from game.procurement import AircraftProcurementRequest from game.procurement import AircraftProcurementRequest
from game.profiling import MultiEventTracer from game.profiling import MultiEventTracer
from game.settings import Settings from game.settings import Settings
from game.squadrons import AirWing from game.squadrons import AirWing
from game.theater import ConflictTheater from game.theater import ConflictTheater
from game.threatzones import ThreatZones from game.threatzones import ThreatZones
from game.commander.packagebuilder import PackageBuilder
from gen.ato import AirTaskingOrder, Package from gen.ato import AirTaskingOrder, Package
from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
@ -27,15 +26,10 @@ class PackageFulfiller:
"""Responsible for package aircraft allocation and flight plan layout.""" """Responsible for package aircraft allocation and flight plan layout."""
def __init__( def __init__(
self, self, coalition: Coalition, theater: ConflictTheater, settings: Settings
coalition: Coalition,
theater: ConflictTheater,
aircraft_inventory: GlobalAircraftInventory,
settings: Settings,
) -> None: ) -> None:
self.coalition = coalition self.coalition = coalition
self.theater = theater self.theater = theater
self.aircraft_inventory = aircraft_inventory
self.player_missions_asap = settings.auto_ato_player_missions_asap self.player_missions_asap = settings.auto_ato_player_missions_asap
self.default_start_type = settings.default_start_type self.default_start_type = settings.default_start_type
@ -137,7 +131,6 @@ class PackageFulfiller:
builder = PackageBuilder( builder = PackageBuilder(
mission.location, mission.location,
ObjectiveDistanceCache.get_closest_airfields(mission.location), ObjectiveDistanceCache.get_closest_airfields(mission.location),
self.aircraft_inventory,
self.air_wing, self.air_wing,
self.is_player, self.is_player,
self.coalition.country_name, self.coalition.country_name,

View File

@ -53,8 +53,6 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
def execute(self, coalition: Coalition) -> None: def execute(self, coalition: Coalition) -> None:
if self.package is None: if self.package is None:
raise RuntimeError("Attempted to execute failed package planning task") 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) coalition.ato.add_package(self.package)
@abstractmethod @abstractmethod
@ -99,7 +97,6 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
fulfiller = PackageFulfiller( fulfiller = PackageFulfiller(
state.context.coalition, state.context.coalition,
state.context.theater, state.context.theater,
state.available_aircraft,
state.context.settings, state.context.settings,
) )
self.package = fulfiller.plan_mission( self.package = fulfiller.plan_mission(

View File

@ -10,9 +10,9 @@ from typing import TYPE_CHECKING, Any, Union, Optional
from game.commander.garrisons import Garrisons from game.commander.garrisons import Garrisons
from game.commander.objectivefinder import ObjectiveFinder from game.commander.objectivefinder import ObjectiveFinder
from game.htn import WorldState from game.htn import WorldState
from game.inventory import GlobalAircraftInventory
from game.profiling import MultiEventTracer from game.profiling import MultiEventTracer
from game.settings import Settings from game.settings import Settings
from game.squadrons import AirWing
from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater
from game.theater.theatergroundobject import ( from game.theater.theatergroundobject import (
TheaterGroundObject, TheaterGroundObject,
@ -58,7 +58,6 @@ class TheaterState(WorldState["TheaterState"]):
strike_targets: list[TheaterGroundObject[Any]] strike_targets: list[TheaterGroundObject[Any]]
enemy_barcaps: list[ControlPoint] enemy_barcaps: list[ControlPoint]
threat_zones: ThreatZones threat_zones: ThreatZones
available_aircraft: GlobalAircraftInventory
def _rebuild_threat_zones(self) -> None: def _rebuild_threat_zones(self) -> None:
"""Recreates the theater's threat zones based on the current planned state.""" """Recreates the theater's threat zones based on the current planned state."""
@ -122,7 +121,6 @@ class TheaterState(WorldState["TheaterState"]):
strike_targets=list(self.strike_targets), strike_targets=list(self.strike_targets),
enemy_barcaps=list(self.enemy_barcaps), enemy_barcaps=list(self.enemy_barcaps),
threat_zones=self.threat_zones, threat_zones=self.threat_zones,
available_aircraft=self.available_aircraft.clone(),
# Persistent properties are not copied. These are a way for failed subtasks # Persistent properties are not copied. These are a way for failed subtasks
# to communicate requirements to other tasks. For example, the task to # to communicate requirements to other tasks. For example, the task to
# attack enemy garrisons might fail because the target area has IADS # attack enemy garrisons might fail because the target area has IADS
@ -172,5 +170,4 @@ class TheaterState(WorldState["TheaterState"]):
strike_targets=list(finder.strike_targets()), strike_targets=list(finder.strike_targets()),
enemy_barcaps=list(game.theater.control_points_for(not player)), enemy_barcaps=list(game.theater.control_points_for(not player)),
threat_zones=game.threat_zone_for(not player), threat_zones=game.threat_zone_for(not player),
available_aircraft=game.aircraft_inventory.clone(),
) )

View File

@ -7,13 +7,12 @@ from dcs.mapping import Point
from dcs.task import Task from dcs.task import Task
from game import persistency from game import persistency
from game.debriefing import AirLosses, Debriefing from game.debriefing import Debriefing
from game.infos.information import Information from game.infos.information import Information
from game.operation.operation import Operation from game.operation.operation import Operation
from game.theater import ControlPoint from game.theater import ControlPoint
from gen.ato import AirTaskingOrder from gen.ato import AirTaskingOrder
from gen.ground_forces.combat_stance import CombatStance from gen.ground_forces.combat_stance import CombatStance
from ..dcs.groundunittype import GroundUnitType
from ..unitmap import UnitMap from ..unitmap import UnitMap
if TYPE_CHECKING: if TYPE_CHECKING:
@ -67,59 +66,6 @@ class Event:
) )
return unit_map return unit_map
@staticmethod
def _transfer_aircraft(
ato: AirTaskingOrder, losses: AirLosses, for_player: bool
) -> None:
for package in ato.packages:
for flight in package.flights:
# No need to transfer to the same location.
if flight.departure == flight.arrival:
continue
# Don't transfer to bases that were captured. Note that if the
# airfield was back-filling transfers it may overflow. We could
# attempt to be smarter in the future by performing transfers in
# order up a graph to prevent transfers to full airports and
# send overflow off-map, but overflow is fine for now.
if flight.arrival.captured != for_player:
logging.info(
f"Not transferring {flight} because {flight.arrival} "
"was captured"
)
continue
transfer_count = losses.surviving_flight_members(flight)
if transfer_count < 0:
logging.error(
f"{flight} had {flight.count} aircraft but "
f"{transfer_count} losses were recorded."
)
continue
aircraft = flight.unit_type
available = flight.departure.base.total_units_of_type(aircraft)
if available < transfer_count:
logging.error(
f"Found killed {aircraft} from {flight.departure} but "
f"that airbase has only {available} available."
)
continue
flight.departure.base.aircraft[aircraft] -= transfer_count
if aircraft not in flight.arrival.base.aircraft:
# TODO: Should use defaultdict.
flight.arrival.base.aircraft[aircraft] = 0
flight.arrival.base.aircraft[aircraft] += transfer_count
def complete_aircraft_transfers(self, debriefing: Debriefing) -> None:
self._transfer_aircraft(
self.game.blue.ato, debriefing.air_losses, for_player=True
)
self._transfer_aircraft(
self.game.red.ato, debriefing.air_losses, for_player=False
)
def commit_air_losses(self, debriefing: Debriefing) -> None: def commit_air_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.air_losses.losses: for loss in debriefing.air_losses.losses:
if loss.pilot is not None and ( if loss.pilot is not None and (
@ -127,18 +73,18 @@ class Event:
or not self.game.settings.invulnerable_player_pilots or not self.game.settings.invulnerable_player_pilots
): ):
loss.pilot.kill() loss.pilot.kill()
squadron = loss.flight.squadron
aircraft = loss.flight.unit_type aircraft = loss.flight.unit_type
cp = loss.flight.departure available = squadron.owned_aircraft
available = cp.base.total_units_of_type(aircraft)
if available <= 0: if available <= 0:
logging.error( logging.error(
f"Found killed {aircraft} from {cp} but that airbase has " f"Found killed {aircraft} from {squadron} but that airbase has "
"none available." "none available."
) )
continue continue
logging.info(f"{aircraft} destroyed from {cp}") logging.info(f"{aircraft} destroyed from {squadron}")
cp.base.aircraft[aircraft] -= 1 squadron.owned_aircraft -= 1
@staticmethod @staticmethod
def _commit_pilot_experience(ato: AirTaskingOrder) -> None: def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
@ -276,7 +222,6 @@ class Event:
self.commit_building_losses(debriefing) self.commit_building_losses(debriefing)
self.commit_damaged_runways(debriefing) self.commit_damaged_runways(debriefing)
self.commit_captures(debriefing) self.commit_captures(debriefing)
self.complete_aircraft_transfers(debriefing)
# Destroyed units carcass # Destroyed units carcass
# ------------------------- # -------------------------
@ -458,15 +403,10 @@ class Event:
source.base.commit_losses(moved_units) source.base.commit_losses(moved_units)
# Also transfer pending deliveries. # Also transfer pending deliveries.
for unit_type, count in source.pending_unit_deliveries.units.items(): for unit_type, count in source.ground_unit_orders.units.items():
if not isinstance(unit_type, GroundUnitType):
continue
if count <= 0:
# Don't transfer *sales*...
continue
move_count = int(count * move_factor) move_count = int(count * move_factor)
source.pending_unit_deliveries.sell({unit_type: move_count}) source.ground_unit_orders.sell({unit_type: move_count})
destination.pending_unit_deliveries.order({unit_type: move_count}) destination.ground_unit_orders.order({unit_type: move_count})
total_units_redeployed += move_count total_units_redeployed += move_count
if total_units_redeployed > 0: if total_units_redeployed > 0:

View File

@ -13,7 +13,6 @@ from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence from dcs.vehicles import AirDefence
from faker import Faker from faker import Faker
from game.inventory import GlobalAircraftInventory
from game.models.game_stats import GameStats from game.models.game_stats import GameStats
from game.plugins import LuaPluginManager from game.plugins import LuaPluginManager
from gen import naming from gen import naming
@ -127,8 +126,6 @@ class Game:
self.blue.configure_default_air_wing(air_wing_config) self.blue.configure_default_air_wing(air_wing_config)
self.red.configure_default_air_wing(air_wing_config) self.red.configure_default_air_wing(air_wing_config)
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
self.on_load(game_still_initializing=True) self.on_load(game_still_initializing=True)
def __setstate__(self, state: dict[str, Any]) -> None: def __setstate__(self, state: dict[str, Any]) -> None:
@ -392,9 +389,9 @@ class Game:
# Plan Coalition specific turn # Plan Coalition specific turn
if for_blue: if for_blue:
self.initialize_turn_for(player=True) self.blue.initialize_turn()
if for_red: if for_red:
self.initialize_turn_for(player=False) self.red.initialize_turn()
# Plan GroundWar # Plan GroundWar
self.ground_planners = {} self.ground_planners = {}
@ -404,12 +401,6 @@ class Game:
gplanner.plan_groundwar() gplanner.plan_groundwar()
self.ground_planners[cp.id] = gplanner self.ground_planners[cp.id] = gplanner
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: def message(self, text: str) -> None:
self.informations.append(Information(text, turn=self.turn)) self.informations.append(Information(text, turn=self.turn))

View File

@ -2,13 +2,11 @@ from __future__ import annotations
import logging import logging
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from typing import Optional, TYPE_CHECKING
from typing import Optional, TYPE_CHECKING, Any
from game.theater import ControlPoint from game.theater import ControlPoint
from .coalition import Coalition from .coalition import Coalition
from .dcs.groundunittype import GroundUnitType from .dcs.groundunittype import GroundUnitType
from .dcs.unittype import UnitType
from .theater.transitnetwork import ( from .theater.transitnetwork import (
NoPathError, NoPathError,
TransitNetwork, TransitNetwork,
@ -19,58 +17,41 @@ if TYPE_CHECKING:
from .game import Game from .game import Game
@dataclass(frozen=True) class GroundUnitOrders:
class GroundUnitSource:
control_point: ControlPoint
class PendingUnitDeliveries:
def __init__(self, destination: ControlPoint) -> None: def __init__(self, destination: ControlPoint) -> None:
self.destination = destination self.destination = destination
# Maps unit type to order quantity. # Maps unit type to order quantity.
self.units: dict[UnitType[Any], int] = defaultdict(int) self.units: dict[GroundUnitType, int] = defaultdict(int)
def __str__(self) -> str: def __str__(self) -> str:
return f"Pending delivery to {self.destination}" return f"Pending ground unit delivery to {self.destination}"
def order(self, units: dict[UnitType[Any], int]) -> None: def order(self, units: dict[GroundUnitType, int]) -> None:
for k, v in units.items(): for k, v in units.items():
self.units[k] += v self.units[k] += v
def sell(self, units: dict[UnitType[Any], int]) -> None: def sell(self, units: dict[GroundUnitType, int]) -> None:
for k, v in units.items(): for k, v in units.items():
self.units[k] -= v self.units[k] -= v
if self.units[k] == 0: if self.units[k] == 0:
del self.units[k] del self.units[k]
def refund_all(self, coalition: Coalition) -> None: def refund_all(self, coalition: Coalition) -> None:
self.refund(coalition, self.units) self._refund(coalition, self.units)
self.units = defaultdict(int) self.units = defaultdict(int)
def refund_ground_units(self, coalition: Coalition) -> None: def _refund(self, coalition: Coalition, units: dict[GroundUnitType, int]) -> None:
ground_units: dict[UnitType[Any], int] = {
u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType)
}
self.refund(coalition, ground_units)
for gu in ground_units.keys():
del self.units[gu]
def refund(self, coalition: Coalition, units: dict[UnitType[Any], int]) -> None:
for unit_type, count in units.items(): for unit_type, count in units.items():
logging.info(f"Refunding {count} {unit_type} at {self.destination.name}") logging.info(f"Refunding {count} {unit_type} at {self.destination.name}")
coalition.adjust_budget(unit_type.price * count) coalition.adjust_budget(unit_type.price * count)
def pending_orders(self, unit_type: UnitType[Any]) -> int: def pending_orders(self, unit_type: GroundUnitType) -> int:
pending_units = self.units.get(unit_type) pending_units = self.units.get(unit_type)
if pending_units is None: if pending_units is None:
pending_units = 0 pending_units = 0
return pending_units return pending_units
def available_next_turn(self, unit_type: UnitType[Any]) -> int:
current_units = self.destination.base.total_units_of_type(unit_type)
return self.pending_orders(unit_type) + current_units
def process(self, game: Game) -> None: def process(self, game: Game) -> None:
coalition = game.coalition_for(self.destination.captured) coalition = game.coalition_for(self.destination.captured)
ground_unit_source = self.find_ground_unit_source(game) ground_unit_source = self.find_ground_unit_source(game)
@ -79,36 +60,33 @@ class PendingUnitDeliveries:
f"{self.destination.name} lost its source for ground unit " f"{self.destination.name} lost its source for ground unit "
"reinforcements. Refunding purchase price." "reinforcements. Refunding purchase price."
) )
self.refund_ground_units(coalition) self.refund_all(coalition)
bought_units: dict[UnitType[Any], int] = {} bought_units: dict[GroundUnitType, int] = {}
units_needing_transfer: dict[GroundUnitType, int] = {} units_needing_transfer: dict[GroundUnitType, int] = {}
sold_units: dict[UnitType[Any], int] = {}
for unit_type, count in self.units.items(): for unit_type, count in self.units.items():
allegiance = "Ally" if self.destination.captured else "Enemy" allegiance = "Ally" if self.destination.captured else "Enemy"
d: dict[Any, int] d: dict[GroundUnitType, int]
if ( if self.destination != ground_unit_source:
isinstance(unit_type, GroundUnitType)
and self.destination != ground_unit_source
):
source = ground_unit_source source = ground_unit_source
d = units_needing_transfer d = units_needing_transfer
else: else:
source = self.destination source = self.destination
d = bought_units d = bought_units
if count >= 0: if count < 0:
logging.error(
f"Attempted sale of {unit_type} at {self.destination} but ground "
"units cannot be sold"
)
elif count > 0:
d[unit_type] = count d[unit_type] = count
game.message( game.message(
f"{allegiance} reinforcements: {unit_type} x {count} at {source}" f"{allegiance} reinforcements: {unit_type} x {count} at {source}"
) )
else:
sold_units[unit_type] = -count
game.message(f"{allegiance} sold: {unit_type} x {-count} at {source}")
self.units = defaultdict(int) self.units = defaultdict(int)
self.destination.base.commission_units(bought_units) self.destination.base.commission_units(bought_units)
self.destination.base.commit_losses(sold_units)
if units_needing_transfer: if units_needing_transfer:
if ground_unit_source is None: if ground_unit_source is None:

View File

@ -1,142 +0,0 @@
"""Inventory management APIs."""
from __future__ import annotations
from collections import defaultdict, Iterator, Iterable
from typing import TYPE_CHECKING
from game.dcs.aircrafttype import AircraftType
if TYPE_CHECKING:
from game.theater import ControlPoint
from gen.flights.flight import Flight
class ControlPointAircraftInventory:
"""Aircraft inventory for a single control point."""
def __init__(self, control_point: ControlPoint) -> None:
self.control_point = control_point
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.
Args:
aircraft: The type of aircraft to add.
count: The number of aircraft to add.
"""
self.inventory[aircraft] += count
def remove_aircraft(self, aircraft: AircraftType, count: int) -> None:
"""Removes aircraft from the inventory.
Args:
aircraft: The type of aircraft to remove.
count: The number of aircraft to remove.
Raises:
ValueError: The control point cannot fulfill the requested number of
aircraft.
"""
available = self.inventory[aircraft]
if available < count:
raise ValueError(
f"Cannot remove {count} {aircraft} from "
f"{self.control_point.name}. Only have {available}."
)
self.inventory[aircraft] -= count
def available(self, aircraft: AircraftType) -> int:
"""Returns the number of available aircraft of the given type.
Args:
aircraft: The type of aircraft to query.
"""
try:
return self.inventory[aircraft]
except KeyError:
return 0
@property
def types_available(self) -> Iterator[AircraftType]:
"""Iterates over all available aircraft types."""
for aircraft, count in self.inventory.items():
if count > 0:
yield aircraft
@property
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:
yield aircraft, count
def clear(self) -> None:
"""Clears all aircraft from the inventory."""
self.inventory.clear()
class GlobalAircraftInventory:
"""Game-wide aircraft inventory."""
def __init__(self, control_points: Iterable[ControlPoint]) -> None:
self.inventories: dict[ControlPoint, ControlPointAircraftInventory] = {
cp: ControlPointAircraftInventory(cp) for cp in control_points
}
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():
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.
If the inventory for the given control point has already been set for
the turn, it will be overwritten.
"""
inventory = self.inventories[control_point]
for aircraft, count in control_point.base.aircraft.items():
inventory.add_aircraft(aircraft, count)
def for_control_point(
self, control_point: ControlPoint
) -> ControlPointAircraftInventory:
"""Returns the inventory specific to the given control point."""
return self.inventories[control_point]
@property
def available_types_for_player(self) -> Iterator[AircraftType]:
"""Iterates over all aircraft types available to the player."""
seen: set[AircraftType] = set()
for control_point, inventory in self.inventories.items():
if control_point.captured:
for aircraft in inventory.types_available:
if not control_point.can_operate(aircraft):
continue
if aircraft not in seen:
seen.add(aircraft)
yield aircraft
def claim_for_flight(self, flight: Flight) -> None:
"""Removes aircraft from the inventory for the given flight."""
inventory = self.for_control_point(flight.from_cp)
inventory.remove_aircraft(flight.unit_type, flight.count)
def return_from_flight(self, flight: Flight) -> None:
"""Returns a flight's aircraft to the inventory."""
inventory = self.for_control_point(flight.from_cp)
inventory.add_aircraft(flight.unit_type, flight.count)

View File

@ -56,10 +56,12 @@ class GameStats:
for cp in game.theater.controlpoints: for cp in game.theater.controlpoints:
if cp.captured: if cp.captured:
turn_data.allied_units.aircraft_count += sum(cp.base.aircraft.values()) for squadron in cp.squadrons:
turn_data.allied_units.aircraft_count += squadron.owned_aircraft
turn_data.allied_units.vehicles_count += sum(cp.base.armor.values()) turn_data.allied_units.vehicles_count += sum(cp.base.armor.values())
else: else:
turn_data.enemy_units.aircraft_count += sum(cp.base.aircraft.values()) for squadron in cp.squadrons:
turn_data.enemy_units.aircraft_count += squadron.owned_aircraft
turn_data.enemy_units.vehicles_count += sum(cp.base.armor.values()) turn_data.enemy_units.vehicles_count += sum(cp.base.armor.values())
self.data_per_turn.append(turn_data) self.data_per_turn.append(turn_data)

View File

@ -7,7 +7,6 @@ from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
from game import db from game import db
from game.data.groundunitclass import GroundUnitClass from game.data.groundunitclass import GroundUnitClass
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction from game.factions.faction import Faction
from game.squadrons import Squadron from game.squadrons import Squadron
@ -98,37 +97,10 @@ class ProcurementAi:
budget -= armor_budget budget -= armor_budget
budget += self.reinforce_front_line(armor_budget) budget += self.reinforce_front_line(armor_budget)
# Don't sell overstock aircraft until after we've bought runways and
# front lines. Any budget we free up should be earmarked for aircraft.
if not self.is_player:
budget += self.sell_incomplete_squadrons()
if self.manage_aircraft: if self.manage_aircraft:
budget = self.purchase_aircraft(budget) budget = self.purchase_aircraft(budget)
return budget return budget
def sell_incomplete_squadrons(self) -> float:
# Selling incomplete squadrons gives us more money to spend on the next
# turn. This serves as a short term fix for
# https://github.com/dcs-liberation/dcs_liberation/issues/41.
#
# Only incomplete squadrons which are unlikely to get used will be sold
# rather than all unused aircraft because the unused aircraft are what
# make OCA strikes worthwhile.
#
# This option is only used by the AI since players cannot cancel sales
# (https://github.com/dcs-liberation/dcs_liberation/issues/365).
total = 0.0
for cp in self.game.theater.control_points_for(self.is_player):
inventory = self.game.aircraft_inventory.for_control_point(cp)
for aircraft, available in inventory.all_aircraft:
# We only ever plan even groups, so the odd aircraft is unlikely
# to get used.
if available % 2 == 0:
continue
inventory.remove_aircraft(aircraft, 1)
total += aircraft.price
return total
def repair_runways(self, budget: float) -> float: def repair_runways(self, budget: float) -> float:
for control_point in self.owned_points: for control_point in self.owned_points:
if budget < db.RUNWAY_REPAIR_COST: if budget < db.RUNWAY_REPAIR_COST:
@ -181,7 +153,7 @@ class ProcurementAi:
break break
budget -= unit.price budget -= unit.price
cp.pending_unit_deliveries.order({unit: 1}) cp.ground_unit_orders.order({unit: 1})
return budget return budget
@ -211,64 +183,28 @@ class ProcurementAi:
return worst_balanced return worst_balanced
@staticmethod @staticmethod
def _compatible_squadron_at(
aircraft: AircraftType, airbase: ControlPoint, task: FlightType, count: int
) -> Optional[Squadron]:
for squadron in airbase.squadrons:
if squadron.aircraft != aircraft:
continue
if not squadron.can_auto_assign(task):
continue
if not squadron.can_provide_pilots(count):
continue
return squadron
return None
def affordable_aircraft_for(
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
) -> Optional[AircraftType]:
for unit in aircraft_for_task(request.task_capability):
if unit.price * request.number > budget:
continue
squadron = self._compatible_squadron_at(
unit, airbase, request.task_capability, request.number
)
if squadron is None:
continue
distance_to_target = meters(request.near.distance_to(airbase))
if distance_to_target > unit.max_mission_range:
continue
# Affordable, compatible, and we have a squadron capable of the task.
return unit
return None
def fulfill_aircraft_request( def fulfill_aircraft_request(
self, request: AircraftProcurementRequest, budget: float squadrons: list[Squadron], quantity: int, budget: float
) -> Tuple[float, bool]: ) -> Tuple[float, bool]:
for airbase in self.best_airbases_for(request): for squadron in squadrons:
unit = self.affordable_aircraft_for(request, airbase, budget) price = squadron.aircraft.price * quantity
if unit is None: if price > budget:
# Can't afford any aircraft capable of performing the
# required mission that can operate from this airbase. We
# might be able to afford aircraft at other airbases though,
# in the case where the airbase we attempted to use is only
# able to operate expensive aircraft.
continue continue
budget -= unit.price * request.number squadron.pending_deliveries += quantity
airbase.pending_unit_deliveries.order({unit: request.number}) budget -= price
return budget, True return budget, True
return budget, False return budget, False
def purchase_aircraft(self, budget: float) -> float: def purchase_aircraft(self, budget: float) -> float:
for request in self.game.coalition_for(self.is_player).procurement_requests: for request in self.game.coalition_for(self.is_player).procurement_requests:
if not list(self.best_airbases_for(request)): squadrons = list(self.best_squadrons_for(request))
if not squadrons:
# No airbases in range of this request. Skip it. # No airbases in range of this request. Skip it.
continue continue
budget, fulfilled = self.fulfill_aircraft_request(request, budget) budget, fulfilled = self.fulfill_aircraft_request(
squadrons, request.number, budget
)
if not fulfilled: if not fulfilled:
# The request was not fulfilled because we could not afford any suitable # The request was not fulfilled because we could not afford any suitable
# aircraft. Rather than continuing, which could proceed to buy tons of # aircraft. Rather than continuing, which could proceed to buy tons of
@ -285,9 +221,32 @@ class ProcurementAi:
else: else:
return self.game.theater.enemy_points() return self.game.theater.enemy_points()
def best_airbases_for( @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 self, request: AircraftProcurementRequest
) -> Iterator[ControlPoint]: ) -> Iterator[Squadron]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near) distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
threatened = [] threatened = []
for cp in distance_cache.operational_airfields: for cp in distance_cache.operational_airfields:
@ -297,8 +256,10 @@ class ProcurementAi:
continue continue
if self.threat_zones.threatened(cp.position): if self.threat_zones.threatened(cp.position):
threatened.append(cp) threatened.append(cp)
yield cp continue
yield from threatened yield from self.compatible_squadrons_at_airbase(cp, request)
for threatened_base in threatened:
yield from self.compatible_squadrons_at_airbase(threatened_base, request)
def ground_reinforcement_candidate(self) -> Optional[ControlPoint]: def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
worst_supply = math.inf worst_supply = math.inf

180
game/purchaseadapter.py Normal file
View File

@ -0,0 +1,180 @@
from abc import abstractmethod
from typing import TypeVar, Generic
from game import Game
from game.coalition import Coalition
from game.dcs.groundunittype import GroundUnitType
from game.squadrons import Squadron
from game.theater import ControlPoint
ItemType = TypeVar("ItemType")
class TransactionError(RuntimeError):
def __init__(self, message: str) -> None:
super().__init__(message)
class PurchaseAdapter(Generic[ItemType]):
def __init__(self, coalition: Coalition) -> None:
self.coalition = coalition
def buy(self, item: ItemType, quantity: int) -> None:
for _ in range(quantity):
if self.has_pending_sales(item):
self.do_cancel_sale(item)
elif self.can_buy(item):
self.do_purchase(item)
else:
raise TransactionError(f"Cannot buy more {item}")
self.coalition.adjust_budget(-self.price_of(item))
def sell(self, item: ItemType, quantity: int) -> None:
for _ in range(quantity):
if self.has_pending_orders(item):
self.do_cancel_purchase(item)
elif self.can_sell(item):
self.do_sale(item)
else:
raise TransactionError(f"Cannot sell more {item}")
self.coalition.adjust_budget(self.price_of(item))
def has_pending_orders(self, item: ItemType) -> bool:
return self.pending_delivery_quantity(item) > 0
def has_pending_sales(self, item: ItemType) -> bool:
return self.pending_delivery_quantity(item) < 0
@abstractmethod
def current_quantity_of(self, item: ItemType) -> int:
...
@abstractmethod
def pending_delivery_quantity(self, item: ItemType) -> int:
...
def expected_quantity_next_turn(self, item: ItemType) -> int:
return self.current_quantity_of(item) + self.pending_delivery_quantity(item)
def can_buy(self, item: ItemType) -> bool:
return self.coalition.budget >= self.price_of(item)
def can_sell_or_cancel(self, item: ItemType) -> bool:
return self.can_sell(item) or self.has_pending_orders(item)
@abstractmethod
def can_sell(self, item: ItemType) -> bool:
...
@abstractmethod
def do_purchase(self, item: ItemType) -> None:
...
@abstractmethod
def do_cancel_purchase(self, item: ItemType) -> None:
...
@abstractmethod
def do_sale(self, item: ItemType) -> None:
...
@abstractmethod
def do_cancel_sale(self, item: ItemType) -> None:
...
@abstractmethod
def price_of(self, item: ItemType) -> int:
...
@abstractmethod
def name_of(self, item: ItemType, multiline: bool = False) -> str:
...
class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]):
def __init__(
self, control_point: ControlPoint, coalition: Coalition, game: Game
) -> None:
super().__init__(coalition)
self.control_point = control_point
self.game = game
def pending_delivery_quantity(self, item: Squadron) -> int:
return item.pending_deliveries
def current_quantity_of(self, item: Squadron) -> int:
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
)
def can_sell(self, item: Squadron) -> bool:
return item.untasked_aircraft > 0
def do_purchase(self, item: Squadron) -> None:
item.pending_deliveries += 1
def do_cancel_purchase(self, item: Squadron) -> None:
item.pending_deliveries -= 1
def do_sale(self, item: Squadron) -> None:
item.untasked_aircraft -= 1
item.pending_deliveries -= 1
def do_cancel_sale(self, item: Squadron) -> None:
item.untasked_aircraft += 1
item.pending_deliveries += 1
def price_of(self, item: Squadron) -> int:
return item.aircraft.price
def name_of(self, item: Squadron, multiline: bool = False) -> str:
if multiline:
separator = "<br />"
else:
separator = " "
return separator.join([item.aircraft.name, str(item)])
class GroundUnitPurchaseAdapter(PurchaseAdapter[GroundUnitType]):
def __init__(
self, control_point: ControlPoint, coalition: Coalition, game: Game
) -> None:
super().__init__(coalition)
self.control_point = control_point
self.game = game
def pending_delivery_quantity(self, item: GroundUnitType) -> int:
return self.control_point.ground_unit_orders.pending_orders(item)
def current_quantity_of(self, item: GroundUnitType) -> int:
return self.control_point.base.total_units_of_type(item)
def can_buy(self, item: GroundUnitType) -> bool:
return super().can_buy(item) and self.control_point.has_ground_unit_source(
self.game
)
def can_sell(self, item: GroundUnitType) -> bool:
return False
def do_purchase(self, item: GroundUnitType) -> None:
self.control_point.ground_unit_orders.order({item: 1})
def do_cancel_purchase(self, item: GroundUnitType) -> None:
self.control_point.ground_unit_orders.sell({item: 1})
def do_sale(self, item: GroundUnitType) -> None:
raise TransactionError("Sale of ground units not allowed")
def do_cancel_sale(self, item: GroundUnitType) -> None:
raise TransactionError("Sale of ground units not allowed")
def price_of(self, item: GroundUnitType) -> int:
return item.price
def name_of(self, item: GroundUnitType, multiline: bool = False) -> str:
return f"{item}"

View File

@ -5,12 +5,12 @@ from collections import defaultdict
from typing import Sequence, Iterator, TYPE_CHECKING from typing import Sequence, Iterator, TYPE_CHECKING
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from gen.flights.flight import FlightType
from .squadron import Squadron from .squadron import Squadron
from ..theater import ControlPoint from ..theater import ControlPoint
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
from gen.flights.flight import FlightType
class AirWing: class AirWing:
@ -32,11 +32,26 @@ class AirWing:
except StopIteration: except StopIteration:
return False return False
@property
def available_aircraft_types(self) -> Iterator[AircraftType]:
for aircraft, squadrons in self.squadrons.items():
for squadron in squadrons:
if squadron.untasked_aircraft:
yield aircraft
break
def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]: def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]:
for squadron in self.iter_squadrons(): for squadron in self.iter_squadrons():
if squadron.can_auto_assign(task): if squadron.can_auto_assign(task):
yield squadron yield squadron
def auto_assignable_for_task_at(
self, task: FlightType, base: ControlPoint
) -> Iterator[Squadron]:
for squadron in self.iter_squadrons():
if squadron.can_auto_assign(task) and squadron.location == base:
yield squadron
def auto_assignable_for_task_with_type( def auto_assignable_for_task_with_type(
self, aircraft: AircraftType, task: FlightType, base: ControlPoint self, aircraft: AircraftType, task: FlightType, base: ControlPoint
) -> Iterator[Squadron]: ) -> Iterator[Squadron]:
@ -67,7 +82,7 @@ class AirWing:
def reset(self) -> None: def reset(self) -> None:
for squadron in self.iter_squadrons(): for squadron in self.iter_squadrons():
squadron.return_all_pilots() squadron.return_all_pilots_and_aircraft()
@property @property
def size(self) -> int: def size(self) -> int:

View File

@ -54,6 +54,10 @@ class Squadron:
location: ControlPoint location: ControlPoint
owned_aircraft: int = field(init=False, hash=False, compare=False, default=0)
untasked_aircraft: int = field(init=False, hash=False, compare=False, default=0)
pending_deliveries: int = field(init=False, hash=False, compare=False, default=0)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.auto_assignable_mission_types = set(self.mission_types) self.auto_assignable_mission_types = set(self.mission_types)
@ -62,6 +66,17 @@ class Squadron:
return self.name return self.name
return f'{self.name} "{self.nickname}"' return f'{self.name} "{self.nickname}"'
def __hash__(self) -> int:
return hash(
(
self.name,
self.nickname,
self.country,
self.role,
self.aircraft,
)
)
@property @property
def player(self) -> bool: def player(self) -> bool:
return self.coalition.player return self.coalition.player
@ -165,8 +180,9 @@ class Squadron:
if replenish_count > 0: if replenish_count > 0:
self._recruit_pilots(replenish_count) self._recruit_pilots(replenish_count)
def return_all_pilots(self) -> None: def return_all_pilots_and_aircraft(self) -> None:
self.available_pilots = list(self.active_pilots) self.available_pilots = list(self.active_pilots)
self.untasked_aircraft = self.owned_aircraft
@staticmethod @staticmethod
def send_on_leave(pilot: Pilot) -> None: def send_on_leave(pilot: Pilot) -> None:
@ -238,6 +254,29 @@ class Squadron:
def pilot_at_index(self, index: int) -> Pilot: def pilot_at_index(self, index: int) -> Pilot:
return self.current_roster[index] return self.current_roster[index]
def claim_inventory(self, count: int) -> None:
if self.untasked_aircraft < count:
raise ValueError(
f"Cannot remove {count} from {self.name}. Only have "
f"{self.untasked_aircraft}."
)
self.untasked_aircraft -= count
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 deliver_orders(self) -> None:
self.owned_aircraft += self.pending_deliveries
self.pending_deliveries = 0
@property
def max_fulfillable_aircraft(self) -> int:
return max(self.number_of_available_pilots, self.untasked_aircraft)
@classmethod @classmethod
def create_from( def create_from(
cls, cls,

View File

@ -1,10 +1,4 @@
import itertools
import logging
from typing import Any
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.dcs.unittype import UnitType
BASE_MAX_STRENGTH = 1.0 BASE_MAX_STRENGTH = 1.0
BASE_MIN_STRENGTH = 0.0 BASE_MIN_STRENGTH = 0.0
@ -12,14 +6,9 @@ BASE_MIN_STRENGTH = 0.0
class Base: class Base:
def __init__(self) -> None: def __init__(self) -> None:
self.aircraft: dict[AircraftType, int] = {}
self.armor: dict[GroundUnitType, int] = {} self.armor: dict[GroundUnitType, int] = {}
self.strength = 1.0 self.strength = 1.0
@property
def total_aircraft(self) -> int:
return sum(self.aircraft.values())
@property @property
def total_armor(self) -> int: def total_armor(self) -> int:
return sum(self.armor.values()) return sum(self.armor.values())
@ -31,49 +20,24 @@ class Base:
total += unit_type.price * count total += unit_type.price * count
return total return total
def total_units_of_type(self, unit_type: UnitType[Any]) -> int: def total_units_of_type(self, unit_type: GroundUnitType) -> int:
return sum( return sum([c for t, c in self.armor.items() if t == unit_type])
[
c
for t, c in itertools.chain(self.aircraft.items(), self.armor.items())
if t == unit_type
]
)
def commission_units(self, units: dict[Any, int]) -> None: def commission_units(self, units: dict[GroundUnitType, int]) -> None:
for unit_type, unit_count in units.items(): for unit_type, unit_count in units.items():
if unit_count <= 0: if unit_count <= 0:
continue continue
self.armor[unit_type] = self.armor.get(unit_type, 0) + unit_count
target_dict: dict[Any, int] def commit_losses(self, units_lost: dict[GroundUnitType, int]) -> None:
if isinstance(unit_type, AircraftType):
target_dict = self.aircraft
elif isinstance(unit_type, GroundUnitType):
target_dict = self.armor
else:
logging.error(f"Unexpected unit type of {unit_type}")
return
target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count
def commit_losses(self, units_lost: dict[Any, int]) -> None:
for unit_type, count in units_lost.items(): for unit_type, count in units_lost.items():
target_dict: dict[Any, int] if unit_type not in self.armor:
if unit_type in self.aircraft: print("Base didn't find unit type {}".format(unit_type))
target_dict = self.aircraft
elif unit_type in self.armor:
target_dict = self.armor
else:
print("Base didn't find event type {}".format(unit_type))
continue continue
if unit_type not in target_dict: self.armor[unit_type] = max(self.armor[unit_type] - count, 0)
print("Base didn't find event type {}".format(unit_type)) if self.armor[unit_type] == 0:
continue del self.armor[unit_type]
target_dict[unit_type] = max(target_dict[unit_type] - count, 0)
if target_dict[unit_type] == 0:
del target_dict[unit_type]
def affect_strength(self, amount: float) -> None: def affect_strength(self, amount: float) -> None:
self.strength += amount self.strength += amount

View File

@ -317,9 +317,9 @@ class ControlPoint(MissionTarget, ABC):
self.cptype = cptype self.cptype = cptype
# TODO: Should be Airbase specific. # TODO: Should be Airbase specific.
self.stances: Dict[int, CombatStance] = {} self.stances: Dict[int, CombatStance] = {}
from ..unitdelivery import PendingUnitDeliveries from ..groundunitorders import GroundUnitOrders
self.pending_unit_deliveries = PendingUnitDeliveries(self) self.ground_unit_orders = GroundUnitOrders(self)
self.target_position: Optional[Point] = None self.target_position: Optional[Point] = None
@ -578,25 +578,14 @@ class ControlPoint(MissionTarget, ABC):
return airbase return airbase
return None return None
def _retreat_air_units( @staticmethod
self, game: Game, airframe: AircraftType, count: int def _retreat_squadron(squadron: Squadron) -> None:
) -> None: logging.error("Air unit retreat not currently implemented")
while count:
logging.debug(f"Retreating {count} {airframe} from {self.name}")
destination = self.aircraft_retreat_destination(game, airframe)
if destination is None:
self.capture_aircraft(game, airframe, count)
return
parking = destination.unclaimed_parking(game)
transfer_amount = min([parking, count])
destination.base.commission_units({airframe: transfer_amount})
count -= transfer_amount
def retreat_air_units(self, game: Game) -> None: def retreat_air_units(self, game: Game) -> None:
# TODO: Capture in order of price to retain maximum value? # TODO: Capture in order of price to retain maximum value?
while self.base.aircraft: for squadron in self.squadrons:
airframe, count = self.base.aircraft.popitem() self._retreat_squadron(squadron)
self._retreat_air_units(game, airframe, count)
def depopulate_uncapturable_tgos(self) -> None: def depopulate_uncapturable_tgos(self) -> None:
for tgo in self.connected_objectives: for tgo in self.connected_objectives:
@ -605,7 +594,10 @@ class ControlPoint(MissionTarget, ABC):
# TODO: Should be Airbase specific. # TODO: Should be Airbase specific.
def capture(self, game: Game, for_player: bool) -> None: def capture(self, game: Game, for_player: bool) -> None:
self.pending_unit_deliveries.refund_all(game.coalition_for(for_player)) coalition = game.coalition_for(for_player)
self.ground_unit_orders.refund_all(coalition)
for squadron in self.squadrons:
squadron.refund_orders()
self.retreat_ground_units(game) self.retreat_ground_units(game)
self.retreat_air_units(game) self.retreat_air_units(game)
self.depopulate_uncapturable_tgos() self.depopulate_uncapturable_tgos()
@ -621,19 +613,6 @@ class ControlPoint(MissionTarget, ABC):
def can_operate(self, aircraft: AircraftType) -> bool: def can_operate(self, aircraft: AircraftType) -> bool:
... ...
def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]:
ato = game.coalition_for(self.captured).ato
transferring: defaultdict[AircraftType, int] = defaultdict(int)
for package in ato.packages:
for flight in package.flights:
if flight.departure == flight.arrival:
continue
if flight.departure == self:
transferring[flight.unit_type] -= flight.count
elif flight.arrival == self:
transferring[flight.unit_type] += flight.count
return transferring
def unclaimed_parking(self, game: Game) -> int: def unclaimed_parking(self, game: Game) -> int:
return self.total_aircraft_parking - self.allocated_aircraft(game).total return self.total_aircraft_parking - self.allocated_aircraft(game).total
@ -663,7 +642,9 @@ class ControlPoint(MissionTarget, ABC):
self.runway_status.begin_repair() self.runway_status.begin_repair()
def process_turn(self, game: Game) -> None: def process_turn(self, game: Game) -> None:
self.pending_unit_deliveries.process(game) self.ground_unit_orders.process(game)
for squadron in self.squadrons:
squadron.deliver_orders()
runway_status = self.runway_status runway_status = self.runway_status
if runway_status is not None: if runway_status is not None:
@ -685,21 +666,22 @@ class ControlPoint(MissionTarget, ABC):
u.position.x = u.position.x + delta.x u.position.x = u.position.x + delta.x
u.position.y = u.position.y + delta.y u.position.y = u.position.y + delta.y
def allocated_aircraft(self, game: Game) -> AircraftAllocations: def allocated_aircraft(self, _game: Game) -> AircraftAllocations:
on_order = {} present: dict[AircraftType, int] = defaultdict(int)
for unit_bought, count in self.pending_unit_deliveries.units.items(): on_order: dict[AircraftType, int] = defaultdict(int)
if isinstance(unit_bought, AircraftType): for squadron in self.squadrons:
on_order[unit_bought] = count present[squadron.aircraft] += squadron.owned_aircraft
# TODO: Only if this is the squadron destination, not location.
on_order[squadron.aircraft] += squadron.pending_deliveries
return AircraftAllocations( # TODO: Implement squadron transfers.
self.base.aircraft, on_order, self.aircraft_transferring(game) return AircraftAllocations(present, on_order, transferring={})
)
def allocated_ground_units( def allocated_ground_units(
self, transfers: PendingTransfers self, transfers: PendingTransfers
) -> GroundUnitAllocations: ) -> GroundUnitAllocations:
on_order = {} on_order = {}
for unit_bought, count in self.pending_unit_deliveries.units.items(): for unit_bought, count in self.ground_unit_orders.units.items():
if isinstance(unit_bought, GroundUnitType): if isinstance(unit_bought, GroundUnitType):
on_order[unit_bought] = count on_order[unit_bought] = count

View File

@ -66,7 +66,6 @@ from gen.naming import namegen
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
from game.inventory import ControlPointAircraftInventory
from game.squadrons import Squadron from game.squadrons import Squadron
@ -315,29 +314,20 @@ class AirliftPlanner:
if cp.captured != self.for_player: if cp.captured != self.for_player:
continue continue
inventory = self.game.aircraft_inventory.for_control_point(cp) squadrons = air_wing.auto_assignable_for_task_at(FlightType.TRANSPORT, cp)
for unit_type, available in inventory.all_aircraft: for squadron in squadrons:
squadrons = air_wing.auto_assignable_for_task_with_type( if self.compatible_with_mission(squadron.aircraft, cp):
unit_type, FlightType.TRANSPORT, cp while (
) squadron.untasked_aircraft
for squadron in squadrons: and squadron.has_available_pilots
if self.compatible_with_mission(unit_type, cp): and self.transfer.transport is None
while ( ):
available self.create_airlift_flight(squadron)
and squadron.has_available_pilots
and self.transfer.transport is None
):
flight_size = self.create_airlift_flight(
squadron, inventory
)
available -= flight_size
if self.package.flights: if self.package.flights:
self.game.ato_for(self.for_player).add_package(self.package) self.game.ato_for(self.for_player).add_package(self.package)
def create_airlift_flight( def create_airlift_flight(self, squadron: Squadron) -> int:
self, squadron: Squadron, inventory: ControlPointAircraftInventory available_aircraft = squadron.untasked_aircraft
) -> int:
available_aircraft = inventory.available(squadron.aircraft)
capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2 capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2
required = math.ceil(self.transfer.size / capacity_each) required = math.ceil(self.transfer.size / capacity_each)
flight_size = min( flight_size = min(
@ -348,8 +338,8 @@ class AirliftPlanner:
# TODO: Use number_of_available_pilots directly once feature flag is gone. # TODO: Use number_of_available_pilots directly once feature flag is gone.
# The number of currently available pilots is not relevant when pilot limits # The number of currently available pilots is not relevant when pilot limits
# are disabled. # are disabled.
if not squadron.can_provide_pilots(flight_size): if not squadron.can_fulfill_flight(flight_size):
flight_size = squadron.number_of_available_pilots flight_size = squadron.max_fulfillable_aircraft
capacity = flight_size * capacity_each capacity = flight_size * capacity_each
if capacity < self.transfer.size: if capacity < self.transfer.size:
@ -359,16 +349,15 @@ class AirliftPlanner:
else: else:
transfer = self.transfer transfer = self.transfer
player = inventory.control_point.captured
flight = Flight( flight = Flight(
self.package, self.package,
self.game.country_for(player), self.game.country_for(squadron.player),
squadron, squadron,
flight_size, flight_size,
FlightType.TRANSPORT, FlightType.TRANSPORT,
self.game.settings.default_start_type, self.game.settings.default_start_type,
departure=inventory.control_point, departure=squadron.location,
arrival=inventory.control_point, arrival=squadron.location,
divert=None, divert=None,
cargo=transfer, cargo=transfer,
) )
@ -381,7 +370,6 @@ class AirliftPlanner:
self.package, self.game.coalition_for(self.for_player), self.game.theater self.package, self.game.coalition_for(self.for_player), self.game.theater
) )
planner.populate_flight_plan(flight) planner.populate_flight_plan(flight)
self.game.aircraft_inventory.claim_for_flight(flight)
return flight_size return flight_size
@ -652,8 +640,7 @@ class PendingTransfers:
flight.package.remove_flight(flight) flight.package.remove_flight(flight)
if not flight.package.flights: if not flight.package.flights:
self.game.ato_for(self.player).remove_package(flight.package) self.game.ato_for(self.player).remove_package(flight.package)
self.game.aircraft_inventory.return_from_flight(flight) flight.return_pilots_and_aircraft()
flight.clear_roster()
@cancel_transport.register @cancel_transport.register
def _cancel_transport_convoy( def _cancel_transport_convoy(
@ -756,16 +743,12 @@ class PendingTransfers:
return 0 return 0
def current_airlift_capacity(self, control_point: ControlPoint) -> int: @staticmethod
inventory = self.game.aircraft_inventory.for_control_point(control_point) def current_airlift_capacity(control_point: ControlPoint) -> int:
squadrons = self.game.air_wing_for(
control_point.captured
).auto_assignable_for_task(FlightType.TRANSPORT)
unit_types = {s.aircraft for s in squadrons}
return sum( return sum(
count s.owned_aircraft
for unit_type, count in inventory.all_aircraft for s in control_point.squadrons
if unit_type in unit_types if s.can_auto_assign(FlightType.TRANSPORT)
) )
def order_airlift_assets_at(self, control_point: ControlPoint) -> None: def order_airlift_assets_at(self, control_point: ControlPoint) -> None:

View File

@ -108,7 +108,7 @@ from .naming import namegen
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
from game.squadrons import Pilot from game.squadrons import Pilot, Squadron
WARM_START_HELI_ALT = meters(500) WARM_START_HELI_ALT = meters(500)
WARM_START_ALTITUDE = meters(3000) WARM_START_ALTITUDE = meters(3000)
@ -594,8 +594,7 @@ class AircraftConflictGenerator:
def spawn_unused_aircraft( def spawn_unused_aircraft(
self, player_country: Country, enemy_country: Country self, player_country: Country, enemy_country: Country
) -> None: ) -> None:
inventories = self.game.aircraft_inventory.inventories for control_point in self.game.theater.controlpoints:
for control_point, inventory in inventories.items():
if not isinstance(control_point, Airfield): if not isinstance(control_point, Airfield):
continue continue
@ -605,11 +604,9 @@ class AircraftConflictGenerator:
else: else:
country = enemy_country country = enemy_country
for aircraft, available in inventory.all_aircraft: for squadron in control_point.squadrons:
try: try:
self._spawn_unused_at( self._spawn_unused_at(control_point, country, faction, squadron)
control_point, country, faction, aircraft, available
)
except NoParkingSlotError: except NoParkingSlotError:
# If we run out of parking, stop spawning aircraft. # If we run out of parking, stop spawning aircraft.
return return
@ -619,17 +616,16 @@ class AircraftConflictGenerator:
control_point: Airfield, control_point: Airfield,
country: Country, country: Country,
faction: Faction, faction: Faction,
aircraft: AircraftType, squadron: Squadron,
number: int,
) -> None: ) -> None:
for _ in range(number): for _ in range(squadron.untasked_aircraft):
# Creating a flight even those this isn't a fragged mission lets us # Creating a flight even those this isn't a fragged mission lets us
# reuse the existing debriefing code. # reuse the existing debriefing code.
# TODO: Special flight type? # TODO: Special flight type?
flight = Flight( flight = Flight(
Package(control_point), Package(control_point),
faction.country, faction.country,
self.game.air_wing_for(control_point.captured).squadron_for(aircraft), squadron,
1, 1,
FlightType.BARCAP, FlightType.BARCAP,
"Cold", "Cold",
@ -641,16 +637,13 @@ class AircraftConflictGenerator:
group = self._generate_at_airport( group = self._generate_at_airport(
name=namegen.next_aircraft_name(country, control_point.id, flight), name=namegen.next_aircraft_name(country, control_point.id, flight),
side=country, side=country,
unit_type=aircraft.dcs_unit_type, unit_type=squadron.aircraft.dcs_unit_type,
count=1, count=1,
start_type="Cold", start_type="Cold",
airport=control_point.airport, airport=control_point.airport,
) )
if aircraft in faction.liveries_overrides: self._setup_livery(flight, group)
livery = random.choice(faction.liveries_overrides[aircraft])
for unit in group.units:
unit.livery_id = livery
group.uncontrolled = True group.uncontrolled = True
self.unit_map.add_aircraft(group, flight) self.unit_map.add_aircraft(group, flight)

View File

@ -290,6 +290,7 @@ class Flight:
self.package = package self.package = package
self.country = country self.country = country
self.squadron = squadron self.squadron = squadron
self.squadron.claim_inventory(count)
if roster is None: if roster is None:
self.roster = FlightRoster(self.squadron, initial_size=count) self.roster = FlightRoster(self.squadron, initial_size=count)
else: else:
@ -338,6 +339,7 @@ class Flight:
return self.flight_plan.waypoints[1:] return self.flight_plan.waypoints[1:]
def resize(self, new_size: int) -> None: def resize(self, new_size: int) -> None:
self.squadron.claim_inventory(new_size - self.count)
self.roster.resize(new_size) self.roster.resize(new_size)
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
@ -347,8 +349,9 @@ class Flight:
def missing_pilots(self) -> int: def missing_pilots(self) -> int:
return self.roster.missing_pilots return self.roster.missing_pilots
def clear_roster(self) -> None: def return_pilots_and_aircraft(self) -> None:
self.roster.clear() self.roster.clear()
self.squadron.claim_inventory(-self.count)
def __repr__(self) -> str: def __repr__(self) -> str:
if self.custom_name: if self.custom_name:

View File

@ -165,8 +165,7 @@ class PackageModel(QAbstractListModel):
self.beginRemoveRows(QModelIndex(), index, index) self.beginRemoveRows(QModelIndex(), index, index)
if flight.cargo is not None: if flight.cargo is not None:
flight.cargo.transport = None flight.cargo.transport = None
self.game_model.game.aircraft_inventory.return_from_flight(flight) flight.return_pilots_and_aircraft()
flight.clear_roster()
self.package.remove_flight(flight) self.package.remove_flight(flight)
self.endRemoveRows() self.endRemoveRows()
self.update_tot() self.update_tot()
@ -258,8 +257,7 @@ class AtoModel(QAbstractListModel):
self.beginRemoveRows(QModelIndex(), index, index) self.beginRemoveRows(QModelIndex(), index, index)
self.ato.remove_package(package) self.ato.remove_package(package)
for flight in package.flights: for flight in package.flights:
self.game.aircraft_inventory.return_from_flight(flight) flight.return_pilots_and_aircraft()
flight.clear_roster()
if flight.cargo is not None: if flight.cargo is not None:
flight.cargo.transport = None flight.cargo.transport = None
self.endRemoveRows() self.endRemoveRows()

View File

@ -1,69 +0,0 @@
"""Combo box for selecting a departure airfield."""
from typing import Iterable, Optional
from PySide2.QtCore import Signal
from PySide2.QtWidgets import QComboBox
from dcs.unittype import FlyingType
from game.dcs.aircrafttype import AircraftType
from game.inventory import GlobalAircraftInventory
from game.theater.controlpoint import ControlPoint
class QOriginAirfieldSelector(QComboBox):
"""A combo box for selecting a flight's departure airfield.
The combo box will automatically be populated with all departure airfields
that have unassigned inventory of the given aircraft type.
"""
availability_changed = Signal(int)
def __init__(
self,
global_inventory: GlobalAircraftInventory,
origins: Iterable[ControlPoint],
aircraft: Optional[AircraftType],
) -> None:
super().__init__()
self.global_inventory = global_inventory
self.origins = list(origins)
self.aircraft = aircraft
self.rebuild_selector()
self.currentIndexChanged.connect(self.index_changed)
self.setSizeAdjustPolicy(self.AdjustToContents)
def change_aircraft(self, aircraft: Optional[FlyingType]) -> None:
if self.aircraft == aircraft:
return
self.aircraft = aircraft
self.rebuild_selector()
def rebuild_selector(self) -> None:
self.clear()
if self.aircraft is None:
return
for origin in self.origins:
if not origin.can_operate(self.aircraft):
continue
inventory = self.global_inventory.for_control_point(origin)
available = inventory.available(self.aircraft)
if available:
self.addItem(f"{origin.name} ({available} available)", origin)
self.model().sort(0)
@property
def available(self) -> int:
origin = self.currentData()
if origin is None:
return 0
inventory = self.global_inventory.for_control_point(origin)
return inventory.available(self.aircraft)
def index_changed(self, index: int) -> None:
origin = self.itemData(index)
if origin is None:
return
inventory = self.global_inventory.for_control_point(origin)
self.availability_changed.emit(inventory.available(self.aircraft))

View File

@ -122,14 +122,6 @@ class SquadronBaseSelector(QComboBox):
self.model().sort(0) self.model().sort(0)
self.setCurrentText(self.squadron.location.name) self.setCurrentText(self.squadron.location.name)
@property
def available(self) -> int:
origin = self.currentData()
if origin is None:
return 0
inventory = self.global_inventory.for_control_point(origin)
return inventory.available(self.aircraft)
class SquadronConfigurationBox(QGroupBox): class SquadronConfigurationBox(QGroupBox):
def __init__(self, squadron: Squadron, theater: ConflictTheater) -> None: def __init__(self, squadron: Squadron, theater: ConflictTheater) -> None:

View File

@ -16,7 +16,6 @@ from PySide2.QtWidgets import (
QWidget, QWidget,
) )
from game.inventory import ControlPointAircraftInventory
from game.squadrons import Squadron from game.squadrons import Squadron
from gen.flights.flight import Flight from gen.flights.flight import Flight
from qt_ui.delegates import TwoColumnRowDelegate from qt_ui.delegates import TwoColumnRowDelegate
@ -127,19 +126,13 @@ class AircraftInventoryData:
) )
@classmethod @classmethod
def each_from_inventory( def each_untasked_from_squadron(
cls, inventory: ControlPointAircraftInventory cls, squadron: Squadron
) -> Iterator[AircraftInventoryData]: ) -> Iterator[AircraftInventoryData]:
for unit_type, num_units in inventory.all_aircraft: for _ in range(0, squadron.untasked_aircraft):
for _ in range(0, num_units): yield AircraftInventoryData(
yield AircraftInventoryData( squadron.name, squadron.aircraft.name, "Idle", "N/A", "N/A", "N/A"
inventory.control_point.name, )
unit_type.name,
"Idle",
"N/A",
"N/A",
"N/A",
)
class AirInventoryView(QWidget): class AirInventoryView(QWidget):
@ -188,9 +181,8 @@ class AirInventoryView(QWidget):
def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]: def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]:
game = self.game_model.game game = self.game_model.game
for control_point, inventory in game.aircraft_inventory.inventories.items(): for squadron in game.blue.air_wing.iter_squadrons():
if control_point.captured: yield from AircraftInventoryData.each_untasked_from_squadron(squadron)
yield from AircraftInventoryData.each_from_inventory(inventory)
def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]: def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]:
yield from self.iter_unallocated_aircraft() yield from self.iter_unallocated_aircraft()

View File

@ -24,7 +24,7 @@ from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog
from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs
from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour from qt_ui.windows.basemenu.UnitTransactionFrame import UnitTransactionFrame
class QBaseMenu2(QDialog): class QBaseMenu2(QDialog):
@ -108,7 +108,7 @@ class QBaseMenu2(QDialog):
capture_button.clicked.connect(self.cheat_capture) capture_button.clicked.connect(self.cheat_capture)
self.budget_display = QLabel( self.budget_display = QLabel(
QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.blue.budget) UnitTransactionFrame.BUDGET_FORMAT.format(self.game_model.game.blue.budget)
) )
self.budget_display.setAlignment(Qt.AlignRight | Qt.AlignBottom) self.budget_display.setAlignment(Qt.AlignRight | Qt.AlignBottom)
self.budget_display.setProperty("style", "budget-label") self.budget_display.setProperty("style", "budget-label")
@ -190,7 +190,7 @@ class QBaseMenu2(QDialog):
self.repair_button.setDisabled(True) self.repair_button.setDisabled(True)
def update_intel_summary(self) -> None: def update_intel_summary(self) -> None:
aircraft = self.cp.base.total_aircraft aircraft = self.cp.allocated_aircraft(self.game_model.game).total_present
parking = self.cp.total_aircraft_parking parking = self.cp.total_aircraft_parking
ground_unit_limit = self.cp.frontline_unit_count_limit ground_unit_limit = self.cp.frontline_unit_count_limit
deployable_unit_info = "" deployable_unit_info = ""
@ -258,5 +258,5 @@ class QBaseMenu2(QDialog):
def update_budget(self, game: Game) -> None: def update_budget(self, game: Game) -> None:
self.budget_display.setText( self.budget_display.setText(
QRecruitBehaviour.BUDGET_FORMAT.format(game.blue.budget) UnitTransactionFrame.BUDGET_FORMAT.format(game.blue.budget)
) )

View File

@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from enum import Enum
from typing import TypeVar, Generic
from PySide2.QtCore import Qt from PySide2.QtCore import Qt
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QGroupBox, QGroupBox,
@ -11,15 +14,15 @@ from PySide2.QtWidgets import (
QSpacerItem, QSpacerItem,
QGridLayout, QGridLayout,
QApplication, QApplication,
QFrame,
QMessageBox,
) )
from game.dcs.unittype import UnitType from game.dcs.unittype import UnitType
from game.theater import ControlPoint
from game.unitdelivery import PendingUnitDeliveries
from qt_ui.models import GameModel from qt_ui.models import GameModel
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.QUnitInfoWindow import QUnitInfoWindow from qt_ui.windows.QUnitInfoWindow import QUnitInfoWindow
from enum import Enum from game.purchaseadapter import PurchaseAdapter, TransactionError
class RecruitType(Enum): class RecruitType(Enum):
@ -27,21 +30,28 @@ class RecruitType(Enum):
SELL = 1 SELL = 1
class PurchaseGroup(QGroupBox): TransactionItemType = TypeVar("TransactionItemType")
def __init__(self, unit_type: UnitType, recruiter: QRecruitBehaviour) -> None:
class PurchaseGroup(QGroupBox, Generic[TransactionItemType]):
def __init__(
self,
item: TransactionItemType,
recruiter: UnitTransactionFrame[TransactionItemType],
) -> None:
super().__init__() super().__init__()
self.unit_type = unit_type self.item = item
self.recruiter = recruiter self.recruiter = recruiter
self.setProperty("style", "buy-box") self.setProperty("style", "buy-box")
self.setMaximumHeight(36) self.setMaximumHeight(72)
self.setMinimumHeight(36) self.setMinimumHeight(36)
layout = QHBoxLayout() layout = QHBoxLayout()
self.setLayout(layout) self.setLayout(layout)
self.sell_button = QPushButton("-") self.sell_button = QPushButton("-")
self.sell_button.setProperty("style", "btn-sell") self.sell_button.setProperty("style", "btn-sell")
self.sell_button.setDisabled(not recruiter.enable_sale(unit_type)) self.sell_button.setDisabled(not recruiter.enable_sale(item))
self.sell_button.setMinimumSize(16, 16) self.sell_button.setMinimumSize(16, 16)
self.sell_button.setMaximumSize(16, 16) self.sell_button.setMaximumSize(16, 16)
self.sell_button.setSizePolicy( self.sell_button.setSizePolicy(
@ -49,7 +59,7 @@ class PurchaseGroup(QGroupBox):
) )
self.sell_button.clicked.connect( self.sell_button.clicked.connect(
lambda: self.recruiter.recruit_handler(RecruitType.SELL, self.unit_type) lambda: self.recruiter.recruit_handler(RecruitType.SELL, self.item)
) )
self.amount_bought = QLabel() self.amount_bought = QLabel()
@ -59,12 +69,12 @@ class PurchaseGroup(QGroupBox):
self.buy_button = QPushButton("+") self.buy_button = QPushButton("+")
self.buy_button.setProperty("style", "btn-buy") self.buy_button.setProperty("style", "btn-buy")
self.buy_button.setDisabled(not recruiter.enable_purchase(unit_type)) self.buy_button.setDisabled(not recruiter.enable_purchase(item))
self.buy_button.setMinimumSize(16, 16) self.buy_button.setMinimumSize(16, 16)
self.buy_button.setMaximumSize(16, 16) self.buy_button.setMaximumSize(16, 16)
self.buy_button.clicked.connect( self.buy_button.clicked.connect(
lambda: self.recruiter.recruit_handler(RecruitType.BUY, self.unit_type) lambda: self.recruiter.recruit_handler(RecruitType.BUY, self.item)
) )
self.buy_button.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) self.buy_button.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
@ -76,36 +86,53 @@ class PurchaseGroup(QGroupBox):
@property @property
def pending_units(self) -> int: def pending_units(self) -> int:
return self.recruiter.pending_deliveries.units.get(self.unit_type, 0) return self.recruiter.pending_delivery_quantity(self.item)
def update_state(self) -> None: def update_state(self) -> None:
self.buy_button.setEnabled(self.recruiter.enable_purchase(self.unit_type)) self.buy_button.setEnabled(self.recruiter.enable_purchase(self.item))
self.buy_button.setToolTip( self.buy_button.setToolTip(
self.recruiter.purchase_tooltip(self.buy_button.isEnabled()) self.recruiter.purchase_tooltip(self.buy_button.isEnabled())
) )
self.sell_button.setEnabled(self.recruiter.enable_sale(self.unit_type)) self.sell_button.setEnabled(self.recruiter.enable_sale(self.item))
self.sell_button.setToolTip( self.sell_button.setToolTip(
self.recruiter.sell_tooltip(self.sell_button.isEnabled()) self.recruiter.sell_tooltip(self.sell_button.isEnabled())
) )
self.amount_bought.setText(f"<b>{self.pending_units}</b>") self.amount_bought.setText(f"<b>{self.pending_units}</b>")
class QRecruitBehaviour: class UnitTransactionFrame(QFrame, Generic[TransactionItemType]):
game_model: GameModel
cp: ControlPoint
purchase_groups: dict[UnitType, PurchaseGroup]
existing_units_labels = None
maximum_units = -1
BUDGET_FORMAT = "Available Budget: <b>${:.2f}M</b>" BUDGET_FORMAT = "Available Budget: <b>${:.2f}M</b>"
def __init__(self) -> None: def __init__(
self,
game_model: GameModel,
purchase_adapter: PurchaseAdapter[TransactionItemType],
) -> None:
super().__init__()
self.game_model = game_model
self.purchase_adapter = purchase_adapter
self.existing_units_labels = {} self.existing_units_labels = {}
self.purchase_groups = {} self.purchase_groups: dict[
TransactionItemType, PurchaseGroup[TransactionItemType]
] = {}
self.update_available_budget() self.update_available_budget()
@property def current_quantity_of(self, item: TransactionItemType) -> int:
def pending_deliveries(self) -> PendingUnitDeliveries: return self.purchase_adapter.current_quantity_of(item)
return self.cp.pending_unit_deliveries
def pending_delivery_quantity(self, item: TransactionItemType) -> int:
return self.purchase_adapter.pending_delivery_quantity(item)
def expected_quantity_next_turn(self, item: TransactionItemType) -> int:
return self.purchase_adapter.expected_quantity_next_turn(item)
def display_name_of(
self, item: TransactionItemType, multiline: bool = False
) -> str:
return self.purchase_adapter.name_of(item, multiline)
def price_of(self, item: TransactionItemType) -> int:
return self.purchase_adapter.price_of(item)
@property @property
def budget(self) -> float: def budget(self) -> float:
@ -117,20 +144,20 @@ class QRecruitBehaviour:
def add_purchase_row( def add_purchase_row(
self, self,
unit_type: UnitType, item: TransactionItemType,
layout: QGridLayout, layout: QGridLayout,
row: int, row: int,
) -> None: ) -> None:
exist = QGroupBox() exist = QGroupBox()
exist.setProperty("style", "buy-box") exist.setProperty("style", "buy-box")
exist.setMaximumHeight(36) exist.setMaximumHeight(72)
exist.setMinimumHeight(36) exist.setMinimumHeight(36)
existLayout = QHBoxLayout() existLayout = QHBoxLayout()
exist.setLayout(existLayout) exist.setLayout(existLayout)
existing_units = self.cp.base.total_units_of_type(unit_type) existing_units = self.current_quantity_of(item)
unitName = QLabel(f"<b>{unit_type.name}</b>") unitName = QLabel(f"<b>{self.display_name_of(item, multiline=True)}</b>")
unitName.setSizePolicy( unitName.setSizePolicy(
QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
) )
@ -138,17 +165,17 @@ class QRecruitBehaviour:
existing_units = QLabel(str(existing_units)) existing_units = QLabel(str(existing_units))
existing_units.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) existing_units.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
self.existing_units_labels[unit_type] = existing_units self.existing_units_labels[item] = existing_units
price = QLabel(f"<b>$ {unit_type.price}</b> M") price = QLabel(f"<b>$ {self.price_of(item)}</b> M")
price.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) price.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
purchase_group = PurchaseGroup(unit_type, self) purchase_group = PurchaseGroup(item, self)
self.purchase_groups[unit_type] = purchase_group self.purchase_groups[item] = purchase_group
info = QGroupBox() info = QGroupBox()
info.setProperty("style", "buy-box") info.setProperty("style", "buy-box")
info.setMaximumHeight(36) info.setMaximumHeight(72)
info.setMinimumHeight(36) info.setMinimumHeight(36)
infolayout = QHBoxLayout() infolayout = QHBoxLayout()
info.setLayout(infolayout) info.setLayout(infolayout)
@ -157,7 +184,7 @@ class QRecruitBehaviour:
unitInfo.setProperty("style", "btn-info") unitInfo.setProperty("style", "btn-info")
unitInfo.setMinimumSize(16, 16) unitInfo.setMinimumSize(16, 16)
unitInfo.setMaximumSize(16, 16) unitInfo.setMaximumSize(16, 16)
unitInfo.clicked.connect(lambda: self.info(unit_type)) unitInfo.clicked.connect(lambda: self.info(item))
unitInfo.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) unitInfo.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
existLayout.addWidget(unitName) existLayout.addWidget(unitName)
@ -179,7 +206,9 @@ class QRecruitBehaviour:
def update_available_budget(self) -> None: def update_available_budget(self) -> None:
GameUpdateSignal.get_instance().updateBudget(self.game_model.game) GameUpdateSignal.get_instance().updateBudget(self.game_model.game)
def recruit_handler(self, recruit_type: RecruitType, unit_type: UnitType) -> None: def recruit_handler(
self, recruit_type: RecruitType, item: TransactionItemType
) -> None:
# Lookup if Keyboard Modifiers were pressed # Lookup if Keyboard Modifiers were pressed
# Shift = 10 times # Shift = 10 times
# CTRL = 5 Times # CTRL = 5 Times
@ -191,51 +220,54 @@ class QRecruitBehaviour:
else: else:
amount = 1 amount = 1
for i in range(amount): if recruit_type == RecruitType.SELL:
if recruit_type == RecruitType.SELL: self.sell(item, amount)
if not self.sell(unit_type): elif recruit_type == RecruitType.BUY:
return self.buy(item, amount)
elif recruit_type == RecruitType.BUY:
if not self.buy(unit_type):
return
def buy(self, unit_type: UnitType) -> bool: def post_transaction_update(self) -> None:
if not self.enable_purchase(unit_type):
logging.error(f"Purchase of {unit_type} not allowed at {self.cp.name}")
return False
self.pending_deliveries.order({unit_type: 1})
self.budget -= unit_type.price
self.update_purchase_controls() self.update_purchase_controls()
self.update_available_budget() self.update_available_budget()
def buy(self, item: TransactionItemType, quantity: int) -> bool:
try:
self.purchase_adapter.buy(item, quantity)
except TransactionError as ex:
logging.exception(f"Purchase of {self.display_name_of(item)} failed")
QMessageBox.warning(self, "Purchase failed", str(ex), QMessageBox.Ok)
return False
self.post_transaction_update()
return True return True
def sell(self, unit_type: UnitType) -> bool: def sell(self, item: TransactionItemType, quantity: int) -> bool:
if self.pending_deliveries.available_next_turn(unit_type) > 0: try:
self.budget += unit_type.price self.purchase_adapter.sell(item, quantity)
self.pending_deliveries.sell({unit_type: 1}) except TransactionError as ex:
self.update_purchase_controls() logging.exception(f"Sale of {self.display_name_of(item)} failed")
self.update_available_budget() QMessageBox.warning(self, "Sale failed", str(ex), QMessageBox.Ok)
return False
self.post_transaction_update()
return True return True
def update_purchase_controls(self) -> None: def update_purchase_controls(self) -> None:
for group in self.purchase_groups.values(): for group in self.purchase_groups.values():
group.update_state() group.update_state()
def enable_purchase(self, unit_type: UnitType) -> bool: def enable_purchase(self, item: TransactionItemType) -> bool:
return self.budget >= unit_type.price return self.purchase_adapter.can_buy(item)
def enable_sale(self, unit_type: UnitType) -> bool: def enable_sale(self, item: TransactionItemType) -> bool:
return True return self.purchase_adapter.can_sell_or_cancel(item)
def purchase_tooltip(self, is_enabled: bool) -> str: @staticmethod
def purchase_tooltip(is_enabled: bool) -> str:
if is_enabled: if is_enabled:
return "Buy unit. Use Shift or Ctrl key to buy multiple units at once." return "Buy unit. Use Shift or Ctrl key to buy multiple units at once."
else: else:
return "Unit can not be bought." return "Unit can not be bought."
def sell_tooltip(self, is_enabled: bool) -> str: @staticmethod
def sell_tooltip(is_enabled: bool) -> str:
if is_enabled: if is_enabled:
return "Sell unit. Use Shift or Ctrl key to buy multiple units at once." return "Sell unit. Use Shift or Ctrl key to buy multiple units at once."
else: else:
@ -244,9 +276,3 @@ class QRecruitBehaviour:
def info(self, unit_type: UnitType) -> None: def info(self, unit_type: UnitType) -> None:
self.info_window = QUnitInfoWindow(self.game_model.game, unit_type) self.info_window = QUnitInfoWindow(self.game_model.game, unit_type)
self.info_window.show() self.info_window.show()
def set_maximum_units(self, maximum_units):
"""
Set the maximum number of units that can be bought
"""
self.maximum_units = maximum_units

View File

@ -1,38 +1,38 @@
import logging
from typing import Set from typing import Set
from PySide2.QtCore import Qt from PySide2.QtCore import Qt
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QFrame,
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QMessageBox,
QScrollArea, QScrollArea,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from dcs.helicopters import helicopter_map
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.theater import ControlPoint, ControlPointType from game.squadrons import Squadron
from game.theater import ControlPoint
from qt_ui.models import GameModel from qt_ui.models import GameModel
from qt_ui.uiconstants import ICONS from qt_ui.uiconstants import ICONS
from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour from qt_ui.windows.basemenu.UnitTransactionFrame import UnitTransactionFrame
from game.purchaseadapter import AircraftPurchaseAdapter
class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): class QAircraftRecruitmentMenu(UnitTransactionFrame[Squadron]):
def __init__(self, cp: ControlPoint, game_model: GameModel) -> None: def __init__(self, cp: ControlPoint, game_model: GameModel) -> None:
QFrame.__init__(self) super().__init__(
game_model,
AircraftPurchaseAdapter(
cp, game_model.game.coalition_for(cp.captured), game_model.game
),
)
self.cp = cp self.cp = cp
self.game_model = game_model self.game_model = game_model
self.purchase_groups = {} self.purchase_groups = {}
self.bought_amount_labels = {} self.bought_amount_labels = {}
self.existing_units_labels = {} self.existing_units_labels = {}
# Determine maximum number of aircrafts that can be bought
self.set_maximum_units(self.cp.total_aircraft_parking)
self.bought_amount_labels = {} self.bought_amount_labels = {}
self.existing_units_labels = {} self.existing_units_labels = {}
@ -48,9 +48,9 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
for squadron in cp.squadrons: for squadron in cp.squadrons:
unit_types.add(squadron.aircraft) unit_types.add(squadron.aircraft)
sorted_units = sorted(unit_types, key=lambda u: u.name) sorted_squadrons = sorted(cp.squadrons, key=lambda s: (s.aircraft.name, s.name))
for row, unit_type in enumerate(sorted_units): for row, squadron in enumerate(sorted_squadrons):
self.add_purchase_row(unit_type, task_box_layout, row) self.add_purchase_row(squadron, task_box_layout, row)
stretch = QVBoxLayout() stretch = QVBoxLayout()
stretch.addStretch() stretch.addStretch()
task_box_layout.addLayout(stretch, row, 0) task_box_layout.addLayout(stretch, row, 0)
@ -65,76 +65,19 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
main_layout.addWidget(scroll) main_layout.addWidget(scroll)
self.setLayout(main_layout) self.setLayout(main_layout)
def enable_purchase(self, unit_type: AircraftType) -> bool:
if not super().enable_purchase(unit_type):
return False
if not self.cp.can_operate(unit_type):
return False
return True
def enable_sale(self, unit_type: AircraftType) -> bool:
return self.can_be_sold(unit_type)
def sell_tooltip(self, is_enabled: bool) -> str: def sell_tooltip(self, is_enabled: bool) -> str:
if is_enabled: if is_enabled:
return "Sell unit. Use Shift or Ctrl key to sell multiple units at once." return "Sell unit. Use Shift or Ctrl key to sell multiple units at once."
else: else:
return "Can not be sold because either no aircraft are available or are already assigned to a mission." return (
"Can not be sold because either no aircraft are available or are "
def buy(self, unit_type: AircraftType) -> bool: "already assigned to a mission."
if self.maximum_units > 0:
if self.cp.unclaimed_parking(self.game_model.game) <= 0:
logging.debug(f"No space for additional aircraft at {self.cp}.")
QMessageBox.warning(
self,
"No space for additional aircraft",
f"There is no parking space left at {self.cp.name} to accommodate "
"another plane.",
QMessageBox.Ok,
)
return False
# If we change our mind about selling, we want the aircraft to be put
# back in the inventory immediately.
elif self.pending_deliveries.units.get(unit_type, 0) < 0:
global_inventory = self.game_model.game.aircraft_inventory
inventory = global_inventory.for_control_point(self.cp)
inventory.add_aircraft(unit_type, 1)
super().buy(unit_type)
self.hangar_status.update_label()
return True
def can_be_sold(self, unit_type: AircraftType) -> bool:
inventory = self.game_model.game.aircraft_inventory.for_control_point(self.cp)
pending_deliveries = self.pending_deliveries.units.get(unit_type, 0)
return self.cp.can_operate(unit_type) and (
pending_deliveries > 0 or inventory.available(unit_type) > 0
)
def sell(self, unit_type: AircraftType) -> bool:
# Don't need to remove aircraft from the inventory if we're canceling
# orders.
if not self.can_be_sold(unit_type):
QMessageBox.critical(
self,
"Could not sell aircraft",
f"Attempted to sell one {unit_type} at {self.cp.name} "
"but none are available. Are all aircraft currently "
"assigned to a mission?",
QMessageBox.Ok,
) )
return False
inventory = self.game_model.game.aircraft_inventory.for_control_point(self.cp) def post_transaction_update(self) -> None:
pending_deliveries = self.pending_deliveries.units.get(unit_type, 0) super().post_transaction_update()
if pending_deliveries <= 0 < inventory.available(unit_type):
inventory.remove_aircraft(unit_type, 1)
super().sell(unit_type)
self.hangar_status.update_label() self.hangar_status.update_label()
return True
class QHangarStatus(QHBoxLayout): class QHangarStatus(QHBoxLayout):
def __init__(self, game_model: GameModel, control_point: ControlPoint) -> None: def __init__(self, game_model: GameModel, control_point: ControlPoint) -> None:

View File

@ -1,30 +1,27 @@
from PySide2.QtCore import Qt from PySide2.QtCore import Qt
from PySide2.QtWidgets import ( from PySide2.QtWidgets import QGridLayout, QScrollArea, QVBoxLayout, QWidget
QFrame,
QGridLayout,
QScrollArea,
QVBoxLayout,
QWidget,
)
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.theater import ControlPoint from game.theater import ControlPoint
from qt_ui.models import GameModel from qt_ui.models import GameModel
from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour from qt_ui.windows.basemenu.UnitTransactionFrame import UnitTransactionFrame
from game.purchaseadapter import GroundUnitPurchaseAdapter
class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): class QArmorRecruitmentMenu(UnitTransactionFrame[GroundUnitType]):
def __init__(self, cp: ControlPoint, game_model: GameModel): def __init__(self, cp: ControlPoint, game_model: GameModel):
QFrame.__init__(self) super().__init__(
game_model,
GroundUnitPurchaseAdapter(
cp, game_model.game.coalition_for(cp.captured), game_model.game
),
)
self.cp = cp self.cp = cp
self.game_model = game_model self.game_model = game_model
self.purchase_groups = {} self.purchase_groups = {}
self.bought_amount_labels = {} self.bought_amount_labels = {}
self.existing_units_labels = {} self.existing_units_labels = {}
self.init_ui()
def init_ui(self):
main_layout = QVBoxLayout() main_layout = QVBoxLayout()
scroll_content = QWidget() scroll_content = QWidget()
@ -50,11 +47,3 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour):
scroll.setWidget(scroll_content) scroll.setWidget(scroll_content)
main_layout.addWidget(scroll) main_layout.addWidget(scroll)
self.setLayout(main_layout) self.setLayout(main_layout)
def enable_purchase(self, unit_type: GroundUnitType) -> bool:
if not super().enable_purchase(unit_type):
return False
return self.cp.has_ground_unit_source(self.game_model.game)
def enable_sale(self, unit_type: GroundUnitType) -> bool:
return self.pending_deliveries.pending_orders(unit_type) > 0

View File

@ -26,7 +26,7 @@ class QIntelInfo(QFrame):
intel_layout = QVBoxLayout() intel_layout = QVBoxLayout()
units_by_task: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) units_by_task: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for unit_type, count in self.cp.base.aircraft.items(): for unit_type, count in self.cp.allocated_aircraft(game).present.items():
if count: if count:
task_type = unit_type.dcs_unit_type.task_default.name task_type = unit_type.dcs_unit_type.task_default.name
units_by_task[task_type][unit_type.name] += count units_by_task[task_type][unit_type.name] += count

View File

@ -77,14 +77,15 @@ class AircraftIntelLayout(IntelTableLayout):
total = 0 total = 0
for control_point in game.theater.control_points_for(player): for control_point in game.theater.control_points_for(player):
base = control_point.base allocation = control_point.allocated_aircraft(game)
total += base.total_aircraft base_total = allocation.total_present
if not base.total_aircraft: total += base_total
if not base_total:
continue continue
self.add_header(f"{control_point.name} ({base.total_aircraft})") self.add_header(f"{control_point.name} ({base_total})")
for airframe in sorted(base.aircraft, key=lambda k: k.name): for airframe in sorted(allocation.present, key=lambda k: k.name):
count = base.aircraft[airframe] count = allocation.present[airframe]
if not count: if not count:
continue continue
self.add_row(f" {airframe.name}", count) self.add_row(f" {airframe.name}", count)

View File

@ -177,7 +177,6 @@ class QPackageDialog(QDialog):
def add_flight(self, flight: Flight) -> None: def add_flight(self, flight: Flight) -> None:
"""Adds the new flight to the package.""" """Adds the new flight to the package."""
self.game.aircraft_inventory.claim_for_flight(flight)
self.package_model.add_flight(flight) self.package_model.add_flight(flight)
planner = FlightPlanBuilder( planner = FlightPlanBuilder(
self.package_model.package, self.game.blue, self.game.theater self.package_model.package, self.game.blue, self.game.theater
@ -251,8 +250,7 @@ class QNewPackageDialog(QPackageDialog):
def on_cancel(self) -> None: def on_cancel(self) -> None:
super().on_cancel() super().on_cancel()
for flight in self.package_model.package.flights: for flight in self.package_model.package.flights:
self.game.aircraft_inventory.return_from_flight(flight) flight.return_pilots_and_aircraft()
flight.clear_roster()
class QEditPackageDialog(QPackageDialog): class QEditPackageDialog(QPackageDialog):

View File

@ -24,7 +24,6 @@ from qt_ui.widgets.QLabeledWidget import QLabeledWidget
from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector
from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelector from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelector
from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox
from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector
from qt_ui.windows.mission.flight.SquadronSelector import SquadronSelector from qt_ui.windows.mission.flight.SquadronSelector import SquadronSelector
from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import FlightRosterEditor from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import FlightRosterEditor
@ -34,6 +33,7 @@ class QFlightCreator(QDialog):
def __init__(self, game: Game, package: Package, parent=None) -> None: def __init__(self, game: Game, package: Package, parent=None) -> None:
super().__init__(parent=parent) super().__init__(parent=parent)
self.setMinimumWidth(400)
self.game = game self.game = game
self.package = package self.package = package
@ -51,7 +51,7 @@ class QFlightCreator(QDialog):
layout.addLayout(QLabeledWidget("Task:", self.task_selector)) layout.addLayout(QLabeledWidget("Task:", self.task_selector))
self.aircraft_selector = QAircraftTypeSelector( self.aircraft_selector = QAircraftTypeSelector(
self.game.aircraft_inventory.available_types_for_player, self.game.blue.air_wing.available_aircraft_types,
self.task_selector.currentData(), self.task_selector.currentData(),
) )
self.aircraft_selector.setCurrentIndex(0) self.aircraft_selector.setCurrentIndex(0)
@ -66,22 +66,6 @@ class QFlightCreator(QDialog):
self.squadron_selector.setCurrentIndex(0) self.squadron_selector.setCurrentIndex(0)
layout.addLayout(QLabeledWidget("Squadron:", self.squadron_selector)) layout.addLayout(QLabeledWidget("Squadron:", self.squadron_selector))
self.departure = QOriginAirfieldSelector(
self.game.aircraft_inventory,
[cp for cp in game.theater.controlpoints if cp.captured],
self.aircraft_selector.currentData(),
)
self.departure.availability_changed.connect(self.update_max_size)
self.departure.currentIndexChanged.connect(self.on_departure_changed)
layout.addLayout(QLabeledWidget("Departure:", self.departure))
self.arrival = QArrivalAirfieldSelector(
[cp for cp in game.theater.controlpoints if cp.captured],
self.aircraft_selector.currentData(),
"Same as departure",
)
layout.addLayout(QLabeledWidget("Arrival:", self.arrival))
self.divert = QArrivalAirfieldSelector( self.divert = QArrivalAirfieldSelector(
[cp for cp in game.theater.controlpoints if cp.captured], [cp for cp in game.theater.controlpoints if cp.captured],
self.aircraft_selector.currentData(), self.aircraft_selector.currentData(),
@ -90,7 +74,7 @@ class QFlightCreator(QDialog):
layout.addLayout(QLabeledWidget("Divert:", self.divert)) layout.addLayout(QLabeledWidget("Divert:", self.divert))
self.flight_size_spinner = QFlightSizeSpinner() self.flight_size_spinner = QFlightSizeSpinner()
self.update_max_size(self.departure.available) self.update_max_size(self.squadron_selector.aircraft_available)
layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner)) layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner))
squadron = self.squadron_selector.currentData() squadron = self.squadron_selector.currentData()
@ -144,8 +128,6 @@ class QFlightCreator(QDialog):
self.setLayout(layout) self.setLayout(layout)
self.on_departure_changed(self.departure.currentIndex())
def reject(self) -> None: def reject(self) -> None:
super().reject() super().reject()
# Clear the roster to return pilots to the pool. # Clear the roster to return pilots to the pool.
@ -161,25 +143,19 @@ class QFlightCreator(QDialog):
def verify_form(self) -> Optional[str]: def verify_form(self) -> Optional[str]:
aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData() aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData()
squadron: Optional[Squadron] = self.squadron_selector.currentData() squadron: Optional[Squadron] = self.squadron_selector.currentData()
origin: Optional[ControlPoint] = self.departure.currentData()
arrival: Optional[ControlPoint] = self.arrival.currentData()
divert: Optional[ControlPoint] = self.divert.currentData() divert: Optional[ControlPoint] = self.divert.currentData()
size: int = self.flight_size_spinner.value() size: int = self.flight_size_spinner.value()
if aircraft is None: if aircraft is None:
return "You must select an aircraft type." return "You must select an aircraft type."
if squadron is None: if squadron is None:
return "You must select a squadron." return "You must select a squadron."
if not origin.captured:
return f"{origin.name} is not owned by your coalition."
if arrival is not None and not arrival.captured:
return f"{arrival.name} is not owned by your coalition."
if divert is not None and not divert.captured: if divert is not None and not divert.captured:
return f"{divert.name} is not owned by your coalition." return f"{divert.name} is not owned by your coalition."
available = origin.base.aircraft.get(aircraft, 0) available = squadron.untasked_aircraft
if not available: if not available:
return f"{origin.name} has no {aircraft.id} available." return f"{squadron} has no aircraft available."
if size > available: if size > available:
return f"{origin.name} has only {available} {aircraft.id} available." return f"{squadron} has only {available} aircraft available."
if size <= 0: if size <= 0:
return f"Flight must have at least one aircraft." return f"Flight must have at least one aircraft."
if self.custom_name_text and "|" in self.custom_name_text: if self.custom_name_text and "|" in self.custom_name_text:
@ -194,14 +170,9 @@ class QFlightCreator(QDialog):
task = self.task_selector.currentData() task = self.task_selector.currentData()
squadron = self.squadron_selector.currentData() squadron = self.squadron_selector.currentData()
origin = self.departure.currentData()
arrival = self.arrival.currentData()
divert = self.divert.currentData() divert = self.divert.currentData()
roster = self.roster_editor.roster roster = self.roster_editor.roster
if arrival is None:
arrival = origin
flight = Flight( flight = Flight(
self.package, self.package,
self.country, self.country,
@ -211,8 +182,8 @@ class QFlightCreator(QDialog):
roster.max_size, roster.max_size,
task, task,
self.start_type.currentText(), self.start_type.currentText(),
origin, squadron.location,
arrival, squadron.location,
divert, divert,
custom_name=self.custom_name_text, custom_name=self.custom_name_text,
roster=roster, roster=roster,
@ -228,11 +199,9 @@ class QFlightCreator(QDialog):
self.task_selector.currentData(), new_aircraft self.task_selector.currentData(), new_aircraft
) )
self.departure.change_aircraft(new_aircraft) self.departure.change_aircraft(new_aircraft)
self.arrival.change_aircraft(new_aircraft)
self.divert.change_aircraft(new_aircraft) self.divert.change_aircraft(new_aircraft)
def on_departure_changed(self, index: int) -> None: def on_departure_changed(self, departure: ControlPoint) -> None:
departure = self.departure.itemData(index)
if isinstance(departure, OffMapSpawn): if isinstance(departure, OffMapSpawn):
previous_type = self.start_type.currentText() previous_type = self.start_type.currentText()
if previous_type != "In Flight": if previous_type != "In Flight":
@ -248,12 +217,12 @@ class QFlightCreator(QDialog):
def on_task_changed(self, index: int) -> None: def on_task_changed(self, index: int) -> None:
task = self.task_selector.itemData(index) task = self.task_selector.itemData(index)
self.aircraft_selector.update_items( self.aircraft_selector.update_items(
task, self.game.aircraft_inventory.available_types_for_player task, self.game.blue.air_wing.available_aircraft_types
) )
self.squadron_selector.update_items(task, self.aircraft_selector.currentData()) self.squadron_selector.update_items(task, self.aircraft_selector.currentData())
def on_squadron_changed(self, index: int) -> None: def on_squadron_changed(self, index: int) -> None:
squadron = self.squadron_selector.itemData(index) squadron: Optional[Squadron] = self.squadron_selector.itemData(index)
# Clear the roster first so we return the pilots to the pool. This way if we end # Clear the roster first so we return the pilots to the pool. This way if we end
# up repopulating from the same squadron we'll get the same pilots back. # up repopulating from the same squadron we'll get the same pilots back.
self.roster_editor.replace(None) self.roster_editor.replace(None)
@ -261,6 +230,7 @@ class QFlightCreator(QDialog):
self.roster_editor.replace( self.roster_editor.replace(
FlightRoster(squadron, self.flight_size_spinner.value()) FlightRoster(squadron, self.flight_size_spinner.value())
) )
self.on_departure_changed(squadron.location)
def update_max_size(self, available: int) -> None: def update_max_size(self, available: int) -> None:
aircraft = self.aircraft_selector.currentData() aircraft = self.aircraft_selector.currentData()

View File

@ -1,9 +1,9 @@
"""Combo box for selecting squadrons.""" """Combo box for selecting squadrons."""
from typing import Type, Optional from typing import Optional
from PySide2.QtWidgets import QComboBox from PySide2.QtWidgets import QComboBox
from dcs.unittype import FlyingType
from game.dcs.aircrafttype import AircraftType
from game.squadrons.airwing import AirWing from game.squadrons.airwing import AirWing
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
@ -15,7 +15,7 @@ class SquadronSelector(QComboBox):
self, self,
air_wing: AirWing, air_wing: AirWing,
task: Optional[FlightType], task: Optional[FlightType],
aircraft: Optional[Type[FlyingType]], aircraft: Optional[AircraftType],
) -> None: ) -> None:
super().__init__() super().__init__()
self.air_wing = air_wing self.air_wing = air_wing
@ -24,8 +24,15 @@ class SquadronSelector(QComboBox):
self.setSizeAdjustPolicy(self.AdjustToContents) self.setSizeAdjustPolicy(self.AdjustToContents)
self.update_items(task, aircraft) self.update_items(task, aircraft)
@property
def aircraft_available(self) -> int:
squadron = self.currentData()
if squadron is None:
return 0
return squadron.untasked_aircraft
def update_items( def update_items(
self, task: Optional[FlightType], aircraft: Optional[Type[FlyingType]] self, task: Optional[FlightType], aircraft: Optional[AircraftType]
) -> None: ) -> None:
current_squadron = self.currentData() current_squadron = self.currentData()
self.blockSignals(True) self.blockSignals(True)
@ -41,12 +48,12 @@ class SquadronSelector(QComboBox):
return return
for squadron in self.air_wing.squadrons_for(aircraft): for squadron in self.air_wing.squadrons_for(aircraft):
if task in squadron.mission_types: if task in squadron.mission_types and squadron.untasked_aircraft:
self.addItem(f"{squadron}", squadron) self.addItem(f"{squadron.location}: {squadron}", squadron)
if self.count() == 0: if self.count() == 0:
self.addItem("No capable aircraft available", None) self.addItem("No capable aircraft available", None)
return return
if current_squadron is not None: if current_squadron is not None:
self.setCurrentText(f"{current_squadron}") self.setCurrentText(f"{current_squadron.location}: {current_squadron}")

View File

@ -195,8 +195,7 @@ class QFlightSlotEditor(QGroupBox):
self.package_model = package_model self.package_model = package_model
self.flight = flight self.flight = flight
self.game = game self.game = game
self.inventory = self.game.aircraft_inventory.for_control_point(flight.from_cp) available = self.flight.squadron.untasked_aircraft
available = self.inventory.available(self.flight.unit_type)
max_count = self.flight.count + available max_count = self.flight.count + available
if max_count > 4: if max_count > 4:
max_count = 4 max_count = 4
@ -225,21 +224,18 @@ class QFlightSlotEditor(QGroupBox):
def _changed_aircraft_count(self): def _changed_aircraft_count(self):
old_count = self.flight.count old_count = self.flight.count
new_count = int(self.aircraft_count_spinner.value()) new_count = int(self.aircraft_count_spinner.value())
self.game.aircraft_inventory.return_from_flight(self.flight)
self.flight.resize(new_count)
try: try:
self.game.aircraft_inventory.claim_for_flight(self.flight) self.flight.resize(new_count)
except ValueError: except ValueError:
# The UI should have prevented this, but if we ran out of aircraft # The UI should have prevented this, but if we ran out of aircraft
# then roll back the inventory change. # then roll back the inventory change.
difference = new_count - self.flight.count difference = new_count - self.flight.count
available = self.inventory.available(self.flight.unit_type) available = self.flight.squadron.untasked_aircraft
logging.error( logging.error(
f"Could not add {difference} additional aircraft to " f"Could not add {difference} additional aircraft to "
f"{self.flight} because {self.flight.departure} has only " f"{self.flight} because {self.flight.departure} has only "
f"{available} {self.flight.unit_type} remaining" f"{available} {self.flight.unit_type} remaining"
) )
self.game.aircraft_inventory.claim_for_flight(self.flight)
self.flight.resize(old_count) self.flight.resize(old_count)
return return
self.roster_editor.resize(new_count) self.roster_editor.resize(new_count)