diff --git a/changelog.md b/changelog.md
index e1f3b9e0..8b01de88 100644
--- a/changelog.md
+++ b/changelog.md
@@ -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]** 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]** 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]** 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.
diff --git a/game/coalition.py b/game/coalition.py
index 9804d553..e15d916d 100644
--- a/game/coalition.py
+++ b/game/coalition.py
@@ -10,7 +10,6 @@ from game.campaignloader.defaultsquadronassigner import DefaultSquadronAssigner
from game.commander import TheaterCommander
from game.commander.missionscheduler import MissionScheduler
from game.income import Income
-from game.inventory import GlobalAircraftInventory
from game.navmesh import NavMesh
from game.orderedset import OrderedSet
from game.profiling import logged_duration, MultiEventTracer
@@ -88,10 +87,6 @@ class Coalition:
assert self._navmesh is not None
return self._navmesh
- @property
- def aircraft_inventory(self) -> GlobalAircraftInventory:
- return self.game.aircraft_inventory
-
def __getstate__(self) -> dict[str, Any]:
state = self.__dict__.copy()
# Avoid persisting any volatile types that can be deterministically
@@ -196,7 +191,9 @@ class Coalition:
return
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:
color = "Blue" if self.player else "Red"
diff --git a/game/commander/aircraftallocator.py b/game/commander/aircraftallocator.py
index b8bfa812..0339ff27 100644
--- a/game/commander/aircraftallocator.py
+++ b/game/commander/aircraftallocator.py
@@ -1,9 +1,8 @@
from typing import Optional, Tuple
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.squadron import Squadron
from game.theater import ControlPoint, MissionTarget
from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task
@@ -15,15 +14,10 @@ class AircraftAllocator:
"""Finds suitable aircraft for proposed missions."""
def __init__(
- self,
- air_wing: AirWing,
- closest_airfields: ClosestAirfields,
- global_inventory: GlobalAircraftInventory,
- is_player: bool,
+ self, air_wing: AirWing, closest_airfields: ClosestAirfields, is_player: bool
) -> None:
self.air_wing = air_wing
self.closest_airfields = closest_airfields
- self.global_inventory = global_inventory
self.is_player = is_player
def find_squadron_for_flight(
@@ -56,12 +50,9 @@ class AircraftAllocator:
for airfield in self.closest_airfields.operational_airfields:
if not airfield.is_friendly(self.is_player):
continue
- inventory = self.global_inventory.for_control_point(airfield)
for aircraft in types:
if not airfield.can_operate(aircraft):
continue
- if inventory.available(aircraft) < flight.num_aircraft:
- continue
distance_to_target = meters(target.distance_to(airfield))
if distance_to_target > aircraft.max_mission_range:
continue
@@ -71,9 +62,8 @@ class AircraftAllocator:
aircraft, task, airfield
)
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
):
- inventory.remove_aircraft(aircraft, flight.num_aircraft)
return airfield, squadron
return None
diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py
index cf5c6102..e684b98e 100644
--- a/game/commander/objectivefinder.py
+++ b/game/commander/objectivefinder.py
@@ -157,7 +157,10 @@ class ObjectiveFinder:
for control_point in self.enemy_control_points():
if not isinstance(control_point, Airfield):
continue
- if control_point.base.total_aircraft >= min_aircraft:
+ if (
+ control_point.allocated_aircraft(self.game).total_present
+ >= min_aircraft
+ ):
airfields.append(control_point)
return self._targets_by_range(airfields)
diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py
index f39c0e07..a4baf9c7 100644
--- a/game/commander/packagebuilder.py
+++ b/game/commander/packagebuilder.py
@@ -1,13 +1,12 @@
from typing import Optional
+from game.commander.aircraftallocator import AircraftAllocator
from game.commander.missionproposals import ProposedFlight
from game.dcs.aircrafttype import AircraftType
-from game.inventory import GlobalAircraftInventory
from game.squadrons.airwing import AirWing
from game.theater import MissionTarget, OffMapSpawn, ControlPoint
from game.utils import nautical_miles
from gen.ato import Package
-from game.commander.aircraftallocator import AircraftAllocator
from gen.flights.closestairfields import ClosestAirfields
from gen.flights.flight import Flight
@@ -19,7 +18,6 @@ class PackageBuilder:
self,
location: MissionTarget,
closest_airfields: ClosestAirfields,
- global_inventory: GlobalAircraftInventory,
air_wing: AirWing,
is_player: bool,
package_country: str,
@@ -30,10 +28,7 @@ class PackageBuilder:
self.is_player = is_player
self.package_country = package_country
self.package = Package(location, auto_asap=asap)
- self.allocator = AircraftAllocator(
- air_wing, closest_airfields, global_inventory, is_player
- )
- self.global_inventory = global_inventory
+ self.allocator = AircraftAllocator(air_wing, closest_airfields, is_player)
self.start_type = start_type
def plan_flight(self, plan: ProposedFlight) -> bool:
@@ -93,6 +88,5 @@ class PackageBuilder:
"""Returns any planned flights to the inventory."""
flights = list(self.package.flights)
for flight in flights:
- self.global_inventory.return_from_flight(flight)
- flight.clear_roster()
+ flight.return_pilots_and_aircraft()
self.package.remove_flight(flight)
diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py
index 87842a9e..a8cca58a 100644
--- a/game/commander/packagefulfiller.py
+++ b/game/commander/packagefulfiller.py
@@ -5,15 +5,14 @@ from collections import defaultdict
from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional
from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType
+from game.commander.packagebuilder import PackageBuilder
from game.data.doctrine import Doctrine
-from game.inventory import GlobalAircraftInventory
from game.procurement import AircraftProcurementRequest
from game.profiling import MultiEventTracer
from game.settings import Settings
from game.squadrons import AirWing
from game.theater import ConflictTheater
from game.threatzones import ThreatZones
-from game.commander.packagebuilder import PackageBuilder
from gen.ato import AirTaskingOrder, Package
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
@@ -27,15 +26,10 @@ class PackageFulfiller:
"""Responsible for package aircraft allocation and flight plan layout."""
def __init__(
- self,
- coalition: Coalition,
- theater: ConflictTheater,
- aircraft_inventory: GlobalAircraftInventory,
- settings: Settings,
+ self, coalition: Coalition, theater: ConflictTheater, settings: Settings
) -> None:
self.coalition = coalition
self.theater = theater
- self.aircraft_inventory = aircraft_inventory
self.player_missions_asap = settings.auto_ato_player_missions_asap
self.default_start_type = settings.default_start_type
@@ -137,7 +131,6 @@ class PackageFulfiller:
builder = PackageBuilder(
mission.location,
ObjectiveDistanceCache.get_closest_airfields(mission.location),
- self.aircraft_inventory,
self.air_wing,
self.is_player,
self.coalition.country_name,
diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py
index 65a7ffd8..a45774c3 100644
--- a/game/commander/tasks/packageplanningtask.py
+++ b/game/commander/tasks/packageplanningtask.py
@@ -53,8 +53,6 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
def execute(self, coalition: Coalition) -> None:
if self.package is None:
raise RuntimeError("Attempted to execute failed package planning task")
- for flight in self.package.flights:
- coalition.aircraft_inventory.claim_for_flight(flight)
coalition.ato.add_package(self.package)
@abstractmethod
@@ -99,7 +97,6 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
fulfiller = PackageFulfiller(
state.context.coalition,
state.context.theater,
- state.available_aircraft,
state.context.settings,
)
self.package = fulfiller.plan_mission(
diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py
index 4450c95b..8732206b 100644
--- a/game/commander/theaterstate.py
+++ b/game/commander/theaterstate.py
@@ -10,9 +10,9 @@ from typing import TYPE_CHECKING, Any, Union, Optional
from game.commander.garrisons import Garrisons
from game.commander.objectivefinder import ObjectiveFinder
from game.htn import WorldState
-from game.inventory import GlobalAircraftInventory
from game.profiling import MultiEventTracer
from game.settings import Settings
+from game.squadrons import AirWing
from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater
from game.theater.theatergroundobject import (
TheaterGroundObject,
@@ -58,7 +58,6 @@ class TheaterState(WorldState["TheaterState"]):
strike_targets: list[TheaterGroundObject[Any]]
enemy_barcaps: list[ControlPoint]
threat_zones: ThreatZones
- available_aircraft: GlobalAircraftInventory
def _rebuild_threat_zones(self) -> None:
"""Recreates the theater's threat zones based on the current planned state."""
@@ -122,7 +121,6 @@ class TheaterState(WorldState["TheaterState"]):
strike_targets=list(self.strike_targets),
enemy_barcaps=list(self.enemy_barcaps),
threat_zones=self.threat_zones,
- available_aircraft=self.available_aircraft.clone(),
# Persistent properties are not copied. These are a way for failed subtasks
# to communicate requirements to other tasks. For example, the task to
# attack enemy garrisons might fail because the target area has IADS
@@ -172,5 +170,4 @@ class TheaterState(WorldState["TheaterState"]):
strike_targets=list(finder.strike_targets()),
enemy_barcaps=list(game.theater.control_points_for(not player)),
threat_zones=game.threat_zone_for(not player),
- available_aircraft=game.aircraft_inventory.clone(),
)
diff --git a/game/event/event.py b/game/event/event.py
index caf68a38..e40cb76f 100644
--- a/game/event/event.py
+++ b/game/event/event.py
@@ -7,13 +7,12 @@ from dcs.mapping import Point
from dcs.task import Task
from game import persistency
-from game.debriefing import AirLosses, Debriefing
+from game.debriefing import Debriefing
from game.infos.information import Information
from game.operation.operation import Operation
from game.theater import ControlPoint
from gen.ato import AirTaskingOrder
from gen.ground_forces.combat_stance import CombatStance
-from ..dcs.groundunittype import GroundUnitType
from ..unitmap import UnitMap
if TYPE_CHECKING:
@@ -67,59 +66,6 @@ class Event:
)
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:
for loss in debriefing.air_losses.losses:
if loss.pilot is not None and (
@@ -127,18 +73,18 @@ class Event:
or not self.game.settings.invulnerable_player_pilots
):
loss.pilot.kill()
+ squadron = loss.flight.squadron
aircraft = loss.flight.unit_type
- cp = loss.flight.departure
- available = cp.base.total_units_of_type(aircraft)
+ available = squadron.owned_aircraft
if available <= 0:
logging.error(
- f"Found killed {aircraft} from {cp} but that airbase has "
+ f"Found killed {aircraft} from {squadron} but that airbase has "
"none available."
)
continue
- logging.info(f"{aircraft} destroyed from {cp}")
- cp.base.aircraft[aircraft] -= 1
+ logging.info(f"{aircraft} destroyed from {squadron}")
+ squadron.owned_aircraft -= 1
@staticmethod
def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
@@ -276,7 +222,6 @@ class Event:
self.commit_building_losses(debriefing)
self.commit_damaged_runways(debriefing)
self.commit_captures(debriefing)
- self.complete_aircraft_transfers(debriefing)
# Destroyed units carcass
# -------------------------
@@ -458,15 +403,10 @@ class Event:
source.base.commit_losses(moved_units)
# Also transfer pending deliveries.
- for unit_type, count in source.pending_unit_deliveries.units.items():
- if not isinstance(unit_type, GroundUnitType):
- continue
- if count <= 0:
- # Don't transfer *sales*...
- continue
+ for unit_type, count in source.ground_unit_orders.units.items():
move_count = int(count * move_factor)
- source.pending_unit_deliveries.sell({unit_type: move_count})
- destination.pending_unit_deliveries.order({unit_type: move_count})
+ source.ground_unit_orders.sell({unit_type: move_count})
+ destination.ground_unit_orders.order({unit_type: move_count})
total_units_redeployed += move_count
if total_units_redeployed > 0:
diff --git a/game/game.py b/game/game.py
index 8401b6ad..2da28c2a 100644
--- a/game/game.py
+++ b/game/game.py
@@ -13,7 +13,6 @@ from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence
from faker import Faker
-from game.inventory import GlobalAircraftInventory
from game.models.game_stats import GameStats
from game.plugins import LuaPluginManager
from gen import naming
@@ -127,8 +126,6 @@ class Game:
self.blue.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)
def __setstate__(self, state: dict[str, Any]) -> None:
@@ -392,9 +389,9 @@ class Game:
# Plan Coalition specific turn
if for_blue:
- self.initialize_turn_for(player=True)
+ self.blue.initialize_turn()
if for_red:
- self.initialize_turn_for(player=False)
+ self.red.initialize_turn()
# Plan GroundWar
self.ground_planners = {}
@@ -404,12 +401,6 @@ class Game:
gplanner.plan_groundwar()
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:
self.informations.append(Information(text, turn=self.turn))
diff --git a/game/unitdelivery.py b/game/groundunitorders.py
similarity index 72%
rename from game/unitdelivery.py
rename to game/groundunitorders.py
index 9bba6130..187e5373 100644
--- a/game/unitdelivery.py
+++ b/game/groundunitorders.py
@@ -2,13 +2,11 @@ from __future__ import annotations
import logging
from collections import defaultdict
-from dataclasses import dataclass
-from typing import Optional, TYPE_CHECKING, Any
+from typing import Optional, TYPE_CHECKING
from game.theater import ControlPoint
from .coalition import Coalition
from .dcs.groundunittype import GroundUnitType
-from .dcs.unittype import UnitType
from .theater.transitnetwork import (
NoPathError,
TransitNetwork,
@@ -19,58 +17,41 @@ if TYPE_CHECKING:
from .game import Game
-@dataclass(frozen=True)
-class GroundUnitSource:
- control_point: ControlPoint
-
-
-class PendingUnitDeliveries:
+class GroundUnitOrders:
def __init__(self, destination: ControlPoint) -> None:
self.destination = destination
# 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:
- 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():
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():
self.units[k] -= v
if self.units[k] == 0:
del self.units[k]
def refund_all(self, coalition: Coalition) -> None:
- self.refund(coalition, self.units)
+ self._refund(coalition, self.units)
self.units = defaultdict(int)
- def refund_ground_units(self, coalition: Coalition) -> 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:
+ def _refund(self, coalition: Coalition, units: dict[GroundUnitType, int]) -> None:
for unit_type, count in units.items():
logging.info(f"Refunding {count} {unit_type} at {self.destination.name}")
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)
if pending_units is None:
pending_units = 0
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:
coalition = game.coalition_for(self.destination.captured)
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 "
"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] = {}
- sold_units: dict[UnitType[Any], int] = {}
for unit_type, count in self.units.items():
allegiance = "Ally" if self.destination.captured else "Enemy"
- d: dict[Any, int]
- if (
- isinstance(unit_type, GroundUnitType)
- and self.destination != ground_unit_source
- ):
+ d: dict[GroundUnitType, int]
+ if self.destination != ground_unit_source:
source = ground_unit_source
d = units_needing_transfer
else:
source = self.destination
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
game.message(
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.destination.base.commission_units(bought_units)
- self.destination.base.commit_losses(sold_units)
if units_needing_transfer:
if ground_unit_source is None:
diff --git a/game/inventory.py b/game/inventory.py
deleted file mode 100644
index e4d40789..00000000
--- a/game/inventory.py
+++ /dev/null
@@ -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)
diff --git a/game/models/game_stats.py b/game/models/game_stats.py
index 7e828021..780565d4 100644
--- a/game/models/game_stats.py
+++ b/game/models/game_stats.py
@@ -56,10 +56,12 @@ class GameStats:
for cp in game.theater.controlpoints:
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())
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())
self.data_per_turn.append(turn_data)
diff --git a/game/procurement.py b/game/procurement.py
index 559f5891..46048170 100644
--- a/game/procurement.py
+++ b/game/procurement.py
@@ -7,7 +7,6 @@ from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
from game import db
from game.data.groundunitclass import GroundUnitClass
-from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction
from game.squadrons import Squadron
@@ -98,37 +97,10 @@ class ProcurementAi:
budget -= 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:
budget = self.purchase_aircraft(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:
for control_point in self.owned_points:
if budget < db.RUNWAY_REPAIR_COST:
@@ -181,7 +153,7 @@ class ProcurementAi:
break
budget -= unit.price
- cp.pending_unit_deliveries.order({unit: 1})
+ cp.ground_unit_orders.order({unit: 1})
return budget
@@ -211,64 +183,28 @@ class ProcurementAi:
return worst_balanced
@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(
- self, request: AircraftProcurementRequest, budget: float
+ squadrons: list[Squadron], quantity: int, budget: float
) -> Tuple[float, bool]:
- for airbase in self.best_airbases_for(request):
- unit = self.affordable_aircraft_for(request, airbase, budget)
- if unit is None:
- # 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.
+ for squadron in squadrons:
+ price = squadron.aircraft.price * quantity
+ if price > budget:
continue
- budget -= unit.price * request.number
- airbase.pending_unit_deliveries.order({unit: request.number})
+ squadron.pending_deliveries += quantity
+ budget -= price
return budget, True
return budget, False
def purchase_aircraft(self, budget: float) -> float:
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.
continue
- budget, fulfilled = self.fulfill_aircraft_request(request, budget)
+ budget, fulfilled = self.fulfill_aircraft_request(
+ squadrons, request.number, budget
+ )
if not fulfilled:
# The request was not fulfilled because we could not afford any suitable
# aircraft. Rather than continuing, which could proceed to buy tons of
@@ -285,9 +221,32 @@ class ProcurementAi:
else:
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
- ) -> Iterator[ControlPoint]:
+ ) -> Iterator[Squadron]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
threatened = []
for cp in distance_cache.operational_airfields:
@@ -297,8 +256,10 @@ class ProcurementAi:
continue
if self.threat_zones.threatened(cp.position):
threatened.append(cp)
- yield cp
- yield from threatened
+ continue
+ yield from self.compatible_squadrons_at_airbase(cp, request)
+ for threatened_base in threatened:
+ yield from self.compatible_squadrons_at_airbase(threatened_base, request)
def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
worst_supply = math.inf
diff --git a/game/purchaseadapter.py b/game/purchaseadapter.py
new file mode 100644
index 00000000..6376f15c
--- /dev/null
+++ b/game/purchaseadapter.py
@@ -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 = "
"
+ 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}"
diff --git a/game/squadrons/airwing.py b/game/squadrons/airwing.py
index a710f918..74ad5f83 100644
--- a/game/squadrons/airwing.py
+++ b/game/squadrons/airwing.py
@@ -5,12 +5,12 @@ from collections import defaultdict
from typing import Sequence, Iterator, TYPE_CHECKING
from game.dcs.aircrafttype import AircraftType
-from gen.flights.flight import FlightType
from .squadron import Squadron
from ..theater import ControlPoint
if TYPE_CHECKING:
from game import Game
+ from gen.flights.flight import FlightType
class AirWing:
@@ -32,11 +32,26 @@ class AirWing:
except StopIteration:
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]:
for squadron in self.iter_squadrons():
if squadron.can_auto_assign(task):
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(
self, aircraft: AircraftType, task: FlightType, base: ControlPoint
) -> Iterator[Squadron]:
@@ -67,7 +82,7 @@ class AirWing:
def reset(self) -> None:
for squadron in self.iter_squadrons():
- squadron.return_all_pilots()
+ squadron.return_all_pilots_and_aircraft()
@property
def size(self) -> int:
diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py
index 369514c5..b5169d26 100644
--- a/game/squadrons/squadron.py
+++ b/game/squadrons/squadron.py
@@ -54,6 +54,10 @@ class Squadron:
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:
self.auto_assignable_mission_types = set(self.mission_types)
@@ -62,6 +66,17 @@ class Squadron:
return self.name
return f'{self.name} "{self.nickname}"'
+ def __hash__(self) -> int:
+ return hash(
+ (
+ self.name,
+ self.nickname,
+ self.country,
+ self.role,
+ self.aircraft,
+ )
+ )
+
@property
def player(self) -> bool:
return self.coalition.player
@@ -165,8 +180,9 @@ class Squadron:
if replenish_count > 0:
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.untasked_aircraft = self.owned_aircraft
@staticmethod
def send_on_leave(pilot: Pilot) -> None:
@@ -238,6 +254,29 @@ class Squadron:
def pilot_at_index(self, index: int) -> Pilot:
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
def create_from(
cls,
diff --git a/game/theater/base.py b/game/theater/base.py
index 02839481..a4d7568b 100644
--- a/game/theater/base.py
+++ b/game/theater/base.py
@@ -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.unittype import UnitType
BASE_MAX_STRENGTH = 1.0
BASE_MIN_STRENGTH = 0.0
@@ -12,14 +6,9 @@ BASE_MIN_STRENGTH = 0.0
class Base:
def __init__(self) -> None:
- self.aircraft: dict[AircraftType, int] = {}
self.armor: dict[GroundUnitType, int] = {}
self.strength = 1.0
- @property
- def total_aircraft(self) -> int:
- return sum(self.aircraft.values())
-
@property
def total_armor(self) -> int:
return sum(self.armor.values())
@@ -31,49 +20,24 @@ class Base:
total += unit_type.price * count
return total
- def total_units_of_type(self, unit_type: UnitType[Any]) -> int:
- return sum(
- [
- c
- for t, c in itertools.chain(self.aircraft.items(), self.armor.items())
- if t == unit_type
- ]
- )
+ def total_units_of_type(self, unit_type: GroundUnitType) -> int:
+ return sum([c for t, c in 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():
if unit_count <= 0:
continue
+ self.armor[unit_type] = self.armor.get(unit_type, 0) + unit_count
- target_dict: dict[Any, int]
- 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:
+ def commit_losses(self, units_lost: dict[GroundUnitType, int]) -> None:
for unit_type, count in units_lost.items():
- target_dict: dict[Any, int]
- if unit_type in self.aircraft:
- 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))
+ if unit_type not in self.armor:
+ print("Base didn't find unit type {}".format(unit_type))
continue
- if unit_type not in target_dict:
- print("Base didn't find event type {}".format(unit_type))
- continue
-
- target_dict[unit_type] = max(target_dict[unit_type] - count, 0)
- if target_dict[unit_type] == 0:
- del target_dict[unit_type]
+ self.armor[unit_type] = max(self.armor[unit_type] - count, 0)
+ if self.armor[unit_type] == 0:
+ del self.armor[unit_type]
def affect_strength(self, amount: float) -> None:
self.strength += amount
diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py
index 2960bf77..8fe4dbb5 100644
--- a/game/theater/controlpoint.py
+++ b/game/theater/controlpoint.py
@@ -317,9 +317,9 @@ class ControlPoint(MissionTarget, ABC):
self.cptype = cptype
# TODO: Should be Airbase specific.
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
@@ -578,25 +578,14 @@ class ControlPoint(MissionTarget, ABC):
return airbase
return None
- def _retreat_air_units(
- self, game: Game, airframe: AircraftType, count: int
- ) -> None:
- 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
+ @staticmethod
+ def _retreat_squadron(squadron: Squadron) -> None:
+ logging.error("Air unit retreat not currently implemented")
def retreat_air_units(self, game: Game) -> None:
# TODO: Capture in order of price to retain maximum value?
- while self.base.aircraft:
- airframe, count = self.base.aircraft.popitem()
- self._retreat_air_units(game, airframe, count)
+ for squadron in self.squadrons:
+ self._retreat_squadron(squadron)
def depopulate_uncapturable_tgos(self) -> None:
for tgo in self.connected_objectives:
@@ -605,7 +594,10 @@ class ControlPoint(MissionTarget, ABC):
# TODO: Should be Airbase specific.
def capture(self, game: Game, for_player: bool) -> None:
- self.pending_unit_deliveries.refund_all(game.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_air_units(game)
self.depopulate_uncapturable_tgos()
@@ -621,19 +613,6 @@ class ControlPoint(MissionTarget, ABC):
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:
return self.total_aircraft_parking - self.allocated_aircraft(game).total
@@ -663,7 +642,9 @@ class ControlPoint(MissionTarget, ABC):
self.runway_status.begin_repair()
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
if runway_status is not None:
@@ -685,21 +666,22 @@ class ControlPoint(MissionTarget, ABC):
u.position.x = u.position.x + delta.x
u.position.y = u.position.y + delta.y
- def allocated_aircraft(self, game: Game) -> AircraftAllocations:
- on_order = {}
- for unit_bought, count in self.pending_unit_deliveries.units.items():
- if isinstance(unit_bought, AircraftType):
- on_order[unit_bought] = count
+ def allocated_aircraft(self, _game: Game) -> AircraftAllocations:
+ present: dict[AircraftType, int] = defaultdict(int)
+ on_order: dict[AircraftType, int] = defaultdict(int)
+ for squadron in self.squadrons:
+ present[squadron.aircraft] += squadron.owned_aircraft
+ # TODO: Only if this is the squadron destination, not location.
+ on_order[squadron.aircraft] += squadron.pending_deliveries
- return AircraftAllocations(
- self.base.aircraft, on_order, self.aircraft_transferring(game)
- )
+ # TODO: Implement squadron transfers.
+ return AircraftAllocations(present, on_order, transferring={})
def allocated_ground_units(
self, transfers: PendingTransfers
) -> GroundUnitAllocations:
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):
on_order[unit_bought] = count
diff --git a/game/transfers.py b/game/transfers.py
index 9146c5b8..3a8d62f6 100644
--- a/game/transfers.py
+++ b/game/transfers.py
@@ -66,7 +66,6 @@ from gen.naming import namegen
if TYPE_CHECKING:
from game import Game
- from game.inventory import ControlPointAircraftInventory
from game.squadrons import Squadron
@@ -315,29 +314,20 @@ class AirliftPlanner:
if cp.captured != self.for_player:
continue
- inventory = self.game.aircraft_inventory.for_control_point(cp)
- for unit_type, available in inventory.all_aircraft:
- squadrons = air_wing.auto_assignable_for_task_with_type(
- unit_type, FlightType.TRANSPORT, cp
- )
- for squadron in squadrons:
- if self.compatible_with_mission(unit_type, cp):
- while (
- available
- and squadron.has_available_pilots
- and self.transfer.transport is None
- ):
- flight_size = self.create_airlift_flight(
- squadron, inventory
- )
- available -= flight_size
+ squadrons = air_wing.auto_assignable_for_task_at(FlightType.TRANSPORT, cp)
+ for squadron in squadrons:
+ if self.compatible_with_mission(squadron.aircraft, cp):
+ while (
+ squadron.untasked_aircraft
+ and squadron.has_available_pilots
+ and self.transfer.transport is None
+ ):
+ self.create_airlift_flight(squadron)
if self.package.flights:
self.game.ato_for(self.for_player).add_package(self.package)
- def create_airlift_flight(
- self, squadron: Squadron, inventory: ControlPointAircraftInventory
- ) -> int:
- available_aircraft = inventory.available(squadron.aircraft)
+ def create_airlift_flight(self, squadron: Squadron) -> int:
+ available_aircraft = squadron.untasked_aircraft
capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2
required = math.ceil(self.transfer.size / capacity_each)
flight_size = min(
@@ -348,8 +338,8 @@ class AirliftPlanner:
# TODO: Use number_of_available_pilots directly once feature flag is gone.
# The number of currently available pilots is not relevant when pilot limits
# are disabled.
- if not squadron.can_provide_pilots(flight_size):
- flight_size = squadron.number_of_available_pilots
+ if not squadron.can_fulfill_flight(flight_size):
+ flight_size = squadron.max_fulfillable_aircraft
capacity = flight_size * capacity_each
if capacity < self.transfer.size:
@@ -359,16 +349,15 @@ class AirliftPlanner:
else:
transfer = self.transfer
- player = inventory.control_point.captured
flight = Flight(
self.package,
- self.game.country_for(player),
+ self.game.country_for(squadron.player),
squadron,
flight_size,
FlightType.TRANSPORT,
self.game.settings.default_start_type,
- departure=inventory.control_point,
- arrival=inventory.control_point,
+ departure=squadron.location,
+ arrival=squadron.location,
divert=None,
cargo=transfer,
)
@@ -381,7 +370,6 @@ class AirliftPlanner:
self.package, self.game.coalition_for(self.for_player), self.game.theater
)
planner.populate_flight_plan(flight)
- self.game.aircraft_inventory.claim_for_flight(flight)
return flight_size
@@ -652,8 +640,7 @@ class PendingTransfers:
flight.package.remove_flight(flight)
if not flight.package.flights:
self.game.ato_for(self.player).remove_package(flight.package)
- self.game.aircraft_inventory.return_from_flight(flight)
- flight.clear_roster()
+ flight.return_pilots_and_aircraft()
@cancel_transport.register
def _cancel_transport_convoy(
@@ -756,16 +743,12 @@ class PendingTransfers:
return 0
- def current_airlift_capacity(self, control_point: ControlPoint) -> int:
- inventory = self.game.aircraft_inventory.for_control_point(control_point)
- squadrons = self.game.air_wing_for(
- control_point.captured
- ).auto_assignable_for_task(FlightType.TRANSPORT)
- unit_types = {s.aircraft for s in squadrons}
+ @staticmethod
+ def current_airlift_capacity(control_point: ControlPoint) -> int:
return sum(
- count
- for unit_type, count in inventory.all_aircraft
- if unit_type in unit_types
+ s.owned_aircraft
+ for s in control_point.squadrons
+ if s.can_auto_assign(FlightType.TRANSPORT)
)
def order_airlift_assets_at(self, control_point: ControlPoint) -> None:
diff --git a/gen/aircraft.py b/gen/aircraft.py
index 3564d1ec..59fe1c86 100644
--- a/gen/aircraft.py
+++ b/gen/aircraft.py
@@ -108,7 +108,7 @@ from .naming import namegen
if TYPE_CHECKING:
from game import Game
- from game.squadrons import Pilot
+ from game.squadrons import Pilot, Squadron
WARM_START_HELI_ALT = meters(500)
WARM_START_ALTITUDE = meters(3000)
@@ -594,8 +594,7 @@ class AircraftConflictGenerator:
def spawn_unused_aircraft(
self, player_country: Country, enemy_country: Country
) -> None:
- inventories = self.game.aircraft_inventory.inventories
- for control_point, inventory in inventories.items():
+ for control_point in self.game.theater.controlpoints:
if not isinstance(control_point, Airfield):
continue
@@ -605,11 +604,9 @@ class AircraftConflictGenerator:
else:
country = enemy_country
- for aircraft, available in inventory.all_aircraft:
+ for squadron in control_point.squadrons:
try:
- self._spawn_unused_at(
- control_point, country, faction, aircraft, available
- )
+ self._spawn_unused_at(control_point, country, faction, squadron)
except NoParkingSlotError:
# If we run out of parking, stop spawning aircraft.
return
@@ -619,17 +616,16 @@ class AircraftConflictGenerator:
control_point: Airfield,
country: Country,
faction: Faction,
- aircraft: AircraftType,
- number: int,
+ squadron: Squadron,
) -> None:
- for _ in range(number):
+ for _ in range(squadron.untasked_aircraft):
# Creating a flight even those this isn't a fragged mission lets us
# reuse the existing debriefing code.
# TODO: Special flight type?
flight = Flight(
Package(control_point),
faction.country,
- self.game.air_wing_for(control_point.captured).squadron_for(aircraft),
+ squadron,
1,
FlightType.BARCAP,
"Cold",
@@ -641,16 +637,13 @@ class AircraftConflictGenerator:
group = self._generate_at_airport(
name=namegen.next_aircraft_name(country, control_point.id, flight),
side=country,
- unit_type=aircraft.dcs_unit_type,
+ unit_type=squadron.aircraft.dcs_unit_type,
count=1,
start_type="Cold",
airport=control_point.airport,
)
- if aircraft in faction.liveries_overrides:
- livery = random.choice(faction.liveries_overrides[aircraft])
- for unit in group.units:
- unit.livery_id = livery
+ self._setup_livery(flight, group)
group.uncontrolled = True
self.unit_map.add_aircraft(group, flight)
diff --git a/gen/flights/flight.py b/gen/flights/flight.py
index 6b60ef01..a4a2b427 100644
--- a/gen/flights/flight.py
+++ b/gen/flights/flight.py
@@ -290,6 +290,7 @@ class Flight:
self.package = package
self.country = country
self.squadron = squadron
+ self.squadron.claim_inventory(count)
if roster is None:
self.roster = FlightRoster(self.squadron, initial_size=count)
else:
@@ -338,6 +339,7 @@ class Flight:
return self.flight_plan.waypoints[1:]
def resize(self, new_size: int) -> None:
+ self.squadron.claim_inventory(new_size - self.count)
self.roster.resize(new_size)
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
@@ -347,8 +349,9 @@ class Flight:
def missing_pilots(self) -> int:
return self.roster.missing_pilots
- def clear_roster(self) -> None:
+ def return_pilots_and_aircraft(self) -> None:
self.roster.clear()
+ self.squadron.claim_inventory(-self.count)
def __repr__(self) -> str:
if self.custom_name:
diff --git a/qt_ui/models.py b/qt_ui/models.py
index 4ea93965..730e588c 100644
--- a/qt_ui/models.py
+++ b/qt_ui/models.py
@@ -165,8 +165,7 @@ class PackageModel(QAbstractListModel):
self.beginRemoveRows(QModelIndex(), index, index)
if flight.cargo is not None:
flight.cargo.transport = None
- self.game_model.game.aircraft_inventory.return_from_flight(flight)
- flight.clear_roster()
+ flight.return_pilots_and_aircraft()
self.package.remove_flight(flight)
self.endRemoveRows()
self.update_tot()
@@ -258,8 +257,7 @@ class AtoModel(QAbstractListModel):
self.beginRemoveRows(QModelIndex(), index, index)
self.ato.remove_package(package)
for flight in package.flights:
- self.game.aircraft_inventory.return_from_flight(flight)
- flight.clear_roster()
+ flight.return_pilots_and_aircraft()
if flight.cargo is not None:
flight.cargo.transport = None
self.endRemoveRows()
diff --git a/qt_ui/widgets/combos/QOriginAirfieldSelector.py b/qt_ui/widgets/combos/QOriginAirfieldSelector.py
deleted file mode 100644
index 9453a45c..00000000
--- a/qt_ui/widgets/combos/QOriginAirfieldSelector.py
+++ /dev/null
@@ -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))
diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py
index e97053cd..745cac5c 100644
--- a/qt_ui/windows/AirWingConfigurationDialog.py
+++ b/qt_ui/windows/AirWingConfigurationDialog.py
@@ -122,14 +122,6 @@ class SquadronBaseSelector(QComboBox):
self.model().sort(0)
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):
def __init__(self, squadron: Squadron, theater: ConflictTheater) -> None:
diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py
index f7da67da..a8f8ca3f 100644
--- a/qt_ui/windows/AirWingDialog.py
+++ b/qt_ui/windows/AirWingDialog.py
@@ -16,7 +16,6 @@ from PySide2.QtWidgets import (
QWidget,
)
-from game.inventory import ControlPointAircraftInventory
from game.squadrons import Squadron
from gen.flights.flight import Flight
from qt_ui.delegates import TwoColumnRowDelegate
@@ -127,19 +126,13 @@ class AircraftInventoryData:
)
@classmethod
- def each_from_inventory(
- cls, inventory: ControlPointAircraftInventory
+ def each_untasked_from_squadron(
+ cls, squadron: Squadron
) -> Iterator[AircraftInventoryData]:
- for unit_type, num_units in inventory.all_aircraft:
- for _ in range(0, num_units):
- yield AircraftInventoryData(
- inventory.control_point.name,
- unit_type.name,
- "Idle",
- "N/A",
- "N/A",
- "N/A",
- )
+ for _ in range(0, squadron.untasked_aircraft):
+ yield AircraftInventoryData(
+ squadron.name, squadron.aircraft.name, "Idle", "N/A", "N/A", "N/A"
+ )
class AirInventoryView(QWidget):
@@ -188,9 +181,8 @@ class AirInventoryView(QWidget):
def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]:
game = self.game_model.game
- for control_point, inventory in game.aircraft_inventory.inventories.items():
- if control_point.captured:
- yield from AircraftInventoryData.each_from_inventory(inventory)
+ for squadron in game.blue.air_wing.iter_squadrons():
+ yield from AircraftInventoryData.each_untasked_from_squadron(squadron)
def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]:
yield from self.iter_unallocated_aircraft()
diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py
index d10e5bc7..7c333e1b 100644
--- a/qt_ui/windows/basemenu/QBaseMenu2.py
+++ b/qt_ui/windows/basemenu/QBaseMenu2.py
@@ -24,7 +24,7 @@ from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog
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):
@@ -108,7 +108,7 @@ class QBaseMenu2(QDialog):
capture_button.clicked.connect(self.cheat_capture)
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.setProperty("style", "budget-label")
@@ -190,7 +190,7 @@ class QBaseMenu2(QDialog):
self.repair_button.setDisabled(True)
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
ground_unit_limit = self.cp.frontline_unit_count_limit
deployable_unit_info = ""
@@ -258,5 +258,5 @@ class QBaseMenu2(QDialog):
def update_budget(self, game: Game) -> None:
self.budget_display.setText(
- QRecruitBehaviour.BUDGET_FORMAT.format(game.blue.budget)
+ UnitTransactionFrame.BUDGET_FORMAT.format(game.blue.budget)
)
diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/UnitTransactionFrame.py
similarity index 59%
rename from qt_ui/windows/basemenu/QRecruitBehaviour.py
rename to qt_ui/windows/basemenu/UnitTransactionFrame.py
index 21684682..d9cbe57f 100644
--- a/qt_ui/windows/basemenu/QRecruitBehaviour.py
+++ b/qt_ui/windows/basemenu/UnitTransactionFrame.py
@@ -1,6 +1,9 @@
from __future__ import annotations
import logging
+from enum import Enum
+from typing import TypeVar, Generic
+
from PySide2.QtCore import Qt
from PySide2.QtWidgets import (
QGroupBox,
@@ -11,15 +14,15 @@ from PySide2.QtWidgets import (
QSpacerItem,
QGridLayout,
QApplication,
+ QFrame,
+ QMessageBox,
)
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.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.QUnitInfoWindow import QUnitInfoWindow
-from enum import Enum
+from game.purchaseadapter import PurchaseAdapter, TransactionError
class RecruitType(Enum):
@@ -27,21 +30,28 @@ class RecruitType(Enum):
SELL = 1
-class PurchaseGroup(QGroupBox):
- def __init__(self, unit_type: UnitType, recruiter: QRecruitBehaviour) -> None:
+TransactionItemType = TypeVar("TransactionItemType")
+
+
+class PurchaseGroup(QGroupBox, Generic[TransactionItemType]):
+ def __init__(
+ self,
+ item: TransactionItemType,
+ recruiter: UnitTransactionFrame[TransactionItemType],
+ ) -> None:
super().__init__()
- self.unit_type = unit_type
+ self.item = item
self.recruiter = recruiter
self.setProperty("style", "buy-box")
- self.setMaximumHeight(36)
+ self.setMaximumHeight(72)
self.setMinimumHeight(36)
layout = QHBoxLayout()
self.setLayout(layout)
self.sell_button = QPushButton("-")
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.setMaximumSize(16, 16)
self.sell_button.setSizePolicy(
@@ -49,7 +59,7 @@ class PurchaseGroup(QGroupBox):
)
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()
@@ -59,12 +69,12 @@ class PurchaseGroup(QGroupBox):
self.buy_button = QPushButton("+")
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.setMaximumSize(16, 16)
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))
@@ -76,36 +86,53 @@ class PurchaseGroup(QGroupBox):
@property
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:
- 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.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.recruiter.sell_tooltip(self.sell_button.isEnabled())
)
self.amount_bought.setText(f"{self.pending_units}")
-class QRecruitBehaviour:
- game_model: GameModel
- cp: ControlPoint
- purchase_groups: dict[UnitType, PurchaseGroup]
- existing_units_labels = None
- maximum_units = -1
+class UnitTransactionFrame(QFrame, Generic[TransactionItemType]):
BUDGET_FORMAT = "Available Budget: ${:.2f}M"
- 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.purchase_groups = {}
+ self.purchase_groups: dict[
+ TransactionItemType, PurchaseGroup[TransactionItemType]
+ ] = {}
self.update_available_budget()
- @property
- def pending_deliveries(self) -> PendingUnitDeliveries:
- return self.cp.pending_unit_deliveries
+ def current_quantity_of(self, item: TransactionItemType) -> int:
+ return self.purchase_adapter.current_quantity_of(item)
+
+ 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
def budget(self) -> float:
@@ -117,20 +144,20 @@ class QRecruitBehaviour:
def add_purchase_row(
self,
- unit_type: UnitType,
+ item: TransactionItemType,
layout: QGridLayout,
row: int,
) -> None:
exist = QGroupBox()
exist.setProperty("style", "buy-box")
- exist.setMaximumHeight(36)
+ exist.setMaximumHeight(72)
exist.setMinimumHeight(36)
existLayout = QHBoxLayout()
exist.setLayout(existLayout)
- existing_units = self.cp.base.total_units_of_type(unit_type)
+ existing_units = self.current_quantity_of(item)
- unitName = QLabel(f"{unit_type.name}")
+ unitName = QLabel(f"{self.display_name_of(item, multiline=True)}")
unitName.setSizePolicy(
QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
)
@@ -138,17 +165,17 @@ class QRecruitBehaviour:
existing_units = QLabel(str(existing_units))
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"$ {unit_type.price} M")
+ price = QLabel(f"$ {self.price_of(item)} M")
price.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
- purchase_group = PurchaseGroup(unit_type, self)
- self.purchase_groups[unit_type] = purchase_group
+ purchase_group = PurchaseGroup(item, self)
+ self.purchase_groups[item] = purchase_group
info = QGroupBox()
info.setProperty("style", "buy-box")
- info.setMaximumHeight(36)
+ info.setMaximumHeight(72)
info.setMinimumHeight(36)
infolayout = QHBoxLayout()
info.setLayout(infolayout)
@@ -157,7 +184,7 @@ class QRecruitBehaviour:
unitInfo.setProperty("style", "btn-info")
unitInfo.setMinimumSize(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))
existLayout.addWidget(unitName)
@@ -179,7 +206,9 @@ class QRecruitBehaviour:
def update_available_budget(self) -> None:
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
# Shift = 10 times
# CTRL = 5 Times
@@ -191,51 +220,54 @@ class QRecruitBehaviour:
else:
amount = 1
- for i in range(amount):
- if recruit_type == RecruitType.SELL:
- if not self.sell(unit_type):
- return
- elif recruit_type == RecruitType.BUY:
- if not self.buy(unit_type):
- return
+ if recruit_type == RecruitType.SELL:
+ self.sell(item, amount)
+ elif recruit_type == RecruitType.BUY:
+ self.buy(item, amount)
- def buy(self, unit_type: UnitType) -> bool:
-
- 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
+ def post_transaction_update(self) -> None:
self.update_purchase_controls()
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
- def sell(self, unit_type: UnitType) -> bool:
- if self.pending_deliveries.available_next_turn(unit_type) > 0:
- self.budget += unit_type.price
- self.pending_deliveries.sell({unit_type: 1})
- self.update_purchase_controls()
- self.update_available_budget()
+ def sell(self, item: TransactionItemType, quantity: int) -> bool:
+ try:
+ self.purchase_adapter.sell(item, quantity)
+ except TransactionError as ex:
+ logging.exception(f"Sale of {self.display_name_of(item)} failed")
+ QMessageBox.warning(self, "Sale failed", str(ex), QMessageBox.Ok)
+ return False
+ self.post_transaction_update()
return True
def update_purchase_controls(self) -> None:
for group in self.purchase_groups.values():
group.update_state()
- def enable_purchase(self, unit_type: UnitType) -> bool:
- return self.budget >= unit_type.price
+ def enable_purchase(self, item: TransactionItemType) -> bool:
+ return self.purchase_adapter.can_buy(item)
- def enable_sale(self, unit_type: UnitType) -> bool:
- return True
+ def enable_sale(self, item: TransactionItemType) -> bool:
+ 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:
return "Buy unit. Use Shift or Ctrl key to buy multiple units at once."
else:
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:
return "Sell unit. Use Shift or Ctrl key to buy multiple units at once."
else:
@@ -244,9 +276,3 @@ class QRecruitBehaviour:
def info(self, unit_type: UnitType) -> None:
self.info_window = QUnitInfoWindow(self.game_model.game, unit_type)
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
diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
index 803fb21a..99b18d5e 100644
--- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
+++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
@@ -1,38 +1,38 @@
-import logging
from typing import Set
from PySide2.QtCore import Qt
from PySide2.QtWidgets import (
- QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
- QMessageBox,
QScrollArea,
QVBoxLayout,
QWidget,
)
-from dcs.helicopters import helicopter_map
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.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:
- QFrame.__init__(self)
+ super().__init__(
+ game_model,
+ AircraftPurchaseAdapter(
+ cp, game_model.game.coalition_for(cp.captured), game_model.game
+ ),
+ )
self.cp = cp
self.game_model = game_model
self.purchase_groups = {}
self.bought_amount_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.existing_units_labels = {}
@@ -48,9 +48,9 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
for squadron in cp.squadrons:
unit_types.add(squadron.aircraft)
- sorted_units = sorted(unit_types, key=lambda u: u.name)
- for row, unit_type in enumerate(sorted_units):
- self.add_purchase_row(unit_type, task_box_layout, row)
+ sorted_squadrons = sorted(cp.squadrons, key=lambda s: (s.aircraft.name, s.name))
+ for row, squadron in enumerate(sorted_squadrons):
+ self.add_purchase_row(squadron, task_box_layout, row)
stretch = QVBoxLayout()
stretch.addStretch()
task_box_layout.addLayout(stretch, row, 0)
@@ -65,76 +65,19 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
main_layout.addWidget(scroll)
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:
if is_enabled:
return "Sell unit. Use Shift or Ctrl key to sell multiple units at once."
else:
- return "Can not be sold because either no aircraft are available or are already assigned to a mission."
-
- def buy(self, unit_type: AircraftType) -> bool:
- 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 (
+ "Can not be sold because either no aircraft are available or are "
+ "already assigned to a mission."
)
- return False
- inventory = self.game_model.game.aircraft_inventory.for_control_point(self.cp)
- pending_deliveries = self.pending_deliveries.units.get(unit_type, 0)
- if pending_deliveries <= 0 < inventory.available(unit_type):
- inventory.remove_aircraft(unit_type, 1)
-
- super().sell(unit_type)
+ def post_transaction_update(self) -> None:
+ super().post_transaction_update()
self.hangar_status.update_label()
- return True
-
class QHangarStatus(QHBoxLayout):
def __init__(self, game_model: GameModel, control_point: ControlPoint) -> None:
diff --git a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py
index 898d1cc4..77e2af8f 100644
--- a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py
+++ b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py
@@ -1,30 +1,27 @@
from PySide2.QtCore import Qt
-from PySide2.QtWidgets import (
- QFrame,
- QGridLayout,
- QScrollArea,
- QVBoxLayout,
- QWidget,
-)
+from PySide2.QtWidgets import QGridLayout, QScrollArea, QVBoxLayout, QWidget
from game.dcs.groundunittype import GroundUnitType
from game.theater import ControlPoint
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):
- QFrame.__init__(self)
+ super().__init__(
+ game_model,
+ GroundUnitPurchaseAdapter(
+ cp, game_model.game.coalition_for(cp.captured), game_model.game
+ ),
+ )
self.cp = cp
self.game_model = game_model
self.purchase_groups = {}
self.bought_amount_labels = {}
self.existing_units_labels = {}
- self.init_ui()
-
- def init_ui(self):
main_layout = QVBoxLayout()
scroll_content = QWidget()
@@ -50,11 +47,3 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour):
scroll.setWidget(scroll_content)
main_layout.addWidget(scroll)
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
diff --git a/qt_ui/windows/basemenu/intel/QIntelInfo.py b/qt_ui/windows/basemenu/intel/QIntelInfo.py
index c8bf03e8..d73682db 100644
--- a/qt_ui/windows/basemenu/intel/QIntelInfo.py
+++ b/qt_ui/windows/basemenu/intel/QIntelInfo.py
@@ -26,7 +26,7 @@ class QIntelInfo(QFrame):
intel_layout = QVBoxLayout()
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:
task_type = unit_type.dcs_unit_type.task_default.name
units_by_task[task_type][unit_type.name] += count
diff --git a/qt_ui/windows/intel.py b/qt_ui/windows/intel.py
index 64b539d0..288b87fe 100644
--- a/qt_ui/windows/intel.py
+++ b/qt_ui/windows/intel.py
@@ -77,14 +77,15 @@ class AircraftIntelLayout(IntelTableLayout):
total = 0
for control_point in game.theater.control_points_for(player):
- base = control_point.base
- total += base.total_aircraft
- if not base.total_aircraft:
+ allocation = control_point.allocated_aircraft(game)
+ base_total = allocation.total_present
+ total += base_total
+ if not base_total:
continue
- self.add_header(f"{control_point.name} ({base.total_aircraft})")
- for airframe in sorted(base.aircraft, key=lambda k: k.name):
- count = base.aircraft[airframe]
+ self.add_header(f"{control_point.name} ({base_total})")
+ for airframe in sorted(allocation.present, key=lambda k: k.name):
+ count = allocation.present[airframe]
if not count:
continue
self.add_row(f" {airframe.name}", count)
diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py
index c86987ae..2f83c85b 100644
--- a/qt_ui/windows/mission/QPackageDialog.py
+++ b/qt_ui/windows/mission/QPackageDialog.py
@@ -177,7 +177,6 @@ class QPackageDialog(QDialog):
def add_flight(self, flight: Flight) -> None:
"""Adds the new flight to the package."""
- self.game.aircraft_inventory.claim_for_flight(flight)
self.package_model.add_flight(flight)
planner = FlightPlanBuilder(
self.package_model.package, self.game.blue, self.game.theater
@@ -251,8 +250,7 @@ class QNewPackageDialog(QPackageDialog):
def on_cancel(self) -> None:
super().on_cancel()
for flight in self.package_model.package.flights:
- self.game.aircraft_inventory.return_from_flight(flight)
- flight.clear_roster()
+ flight.return_pilots_and_aircraft()
class QEditPackageDialog(QPackageDialog):
diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py
index eeeb54de..cc465422 100644
--- a/qt_ui/windows/mission/flight/QFlightCreator.py
+++ b/qt_ui/windows/mission/flight/QFlightCreator.py
@@ -24,7 +24,6 @@ from qt_ui.widgets.QLabeledWidget import QLabeledWidget
from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector
from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelector
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.settings.QFlightSlotEditor import FlightRosterEditor
@@ -34,6 +33,7 @@ class QFlightCreator(QDialog):
def __init__(self, game: Game, package: Package, parent=None) -> None:
super().__init__(parent=parent)
+ self.setMinimumWidth(400)
self.game = game
self.package = package
@@ -51,7 +51,7 @@ class QFlightCreator(QDialog):
layout.addLayout(QLabeledWidget("Task:", self.task_selector))
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.aircraft_selector.setCurrentIndex(0)
@@ -66,22 +66,6 @@ class QFlightCreator(QDialog):
self.squadron_selector.setCurrentIndex(0)
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(
[cp for cp in game.theater.controlpoints if cp.captured],
self.aircraft_selector.currentData(),
@@ -90,7 +74,7 @@ class QFlightCreator(QDialog):
layout.addLayout(QLabeledWidget("Divert:", self.divert))
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))
squadron = self.squadron_selector.currentData()
@@ -144,8 +128,6 @@ class QFlightCreator(QDialog):
self.setLayout(layout)
- self.on_departure_changed(self.departure.currentIndex())
-
def reject(self) -> None:
super().reject()
# Clear the roster to return pilots to the pool.
@@ -161,25 +143,19 @@ class QFlightCreator(QDialog):
def verify_form(self) -> Optional[str]:
aircraft: Optional[Type[FlyingType]] = self.aircraft_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()
size: int = self.flight_size_spinner.value()
if aircraft is None:
return "You must select an aircraft type."
if squadron is None:
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:
return f"{divert.name} is not owned by your coalition."
- available = origin.base.aircraft.get(aircraft, 0)
+ available = squadron.untasked_aircraft
if not available:
- return f"{origin.name} has no {aircraft.id} available."
+ return f"{squadron} has no aircraft 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:
return f"Flight must have at least one aircraft."
if self.custom_name_text and "|" in self.custom_name_text:
@@ -194,14 +170,9 @@ class QFlightCreator(QDialog):
task = self.task_selector.currentData()
squadron = self.squadron_selector.currentData()
- origin = self.departure.currentData()
- arrival = self.arrival.currentData()
divert = self.divert.currentData()
roster = self.roster_editor.roster
- if arrival is None:
- arrival = origin
-
flight = Flight(
self.package,
self.country,
@@ -211,8 +182,8 @@ class QFlightCreator(QDialog):
roster.max_size,
task,
self.start_type.currentText(),
- origin,
- arrival,
+ squadron.location,
+ squadron.location,
divert,
custom_name=self.custom_name_text,
roster=roster,
@@ -228,11 +199,9 @@ class QFlightCreator(QDialog):
self.task_selector.currentData(), new_aircraft
)
self.departure.change_aircraft(new_aircraft)
- self.arrival.change_aircraft(new_aircraft)
self.divert.change_aircraft(new_aircraft)
- def on_departure_changed(self, index: int) -> None:
- departure = self.departure.itemData(index)
+ def on_departure_changed(self, departure: ControlPoint) -> None:
if isinstance(departure, OffMapSpawn):
previous_type = self.start_type.currentText()
if previous_type != "In Flight":
@@ -248,12 +217,12 @@ class QFlightCreator(QDialog):
def on_task_changed(self, index: int) -> None:
task = self.task_selector.itemData(index)
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())
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
# up repopulating from the same squadron we'll get the same pilots back.
self.roster_editor.replace(None)
@@ -261,6 +230,7 @@ class QFlightCreator(QDialog):
self.roster_editor.replace(
FlightRoster(squadron, self.flight_size_spinner.value())
)
+ self.on_departure_changed(squadron.location)
def update_max_size(self, available: int) -> None:
aircraft = self.aircraft_selector.currentData()
diff --git a/qt_ui/windows/mission/flight/SquadronSelector.py b/qt_ui/windows/mission/flight/SquadronSelector.py
index a931f9aa..290e821b 100644
--- a/qt_ui/windows/mission/flight/SquadronSelector.py
+++ b/qt_ui/windows/mission/flight/SquadronSelector.py
@@ -1,9 +1,9 @@
"""Combo box for selecting squadrons."""
-from typing import Type, Optional
+from typing import Optional
from PySide2.QtWidgets import QComboBox
-from dcs.unittype import FlyingType
+from game.dcs.aircrafttype import AircraftType
from game.squadrons.airwing import AirWing
from gen.flights.flight import FlightType
@@ -15,7 +15,7 @@ class SquadronSelector(QComboBox):
self,
air_wing: AirWing,
task: Optional[FlightType],
- aircraft: Optional[Type[FlyingType]],
+ aircraft: Optional[AircraftType],
) -> None:
super().__init__()
self.air_wing = air_wing
@@ -24,8 +24,15 @@ class SquadronSelector(QComboBox):
self.setSizeAdjustPolicy(self.AdjustToContents)
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(
- self, task: Optional[FlightType], aircraft: Optional[Type[FlyingType]]
+ self, task: Optional[FlightType], aircraft: Optional[AircraftType]
) -> None:
current_squadron = self.currentData()
self.blockSignals(True)
@@ -41,12 +48,12 @@ class SquadronSelector(QComboBox):
return
for squadron in self.air_wing.squadrons_for(aircraft):
- if task in squadron.mission_types:
- self.addItem(f"{squadron}", squadron)
+ if task in squadron.mission_types and squadron.untasked_aircraft:
+ self.addItem(f"{squadron.location}: {squadron}", squadron)
if self.count() == 0:
self.addItem("No capable aircraft available", None)
return
if current_squadron is not None:
- self.setCurrentText(f"{current_squadron}")
+ self.setCurrentText(f"{current_squadron.location}: {current_squadron}")
diff --git a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py
index 3545ff1e..665e45ae 100644
--- a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py
+++ b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py
@@ -195,8 +195,7 @@ class QFlightSlotEditor(QGroupBox):
self.package_model = package_model
self.flight = flight
self.game = game
- self.inventory = self.game.aircraft_inventory.for_control_point(flight.from_cp)
- available = self.inventory.available(self.flight.unit_type)
+ available = self.flight.squadron.untasked_aircraft
max_count = self.flight.count + available
if max_count > 4:
max_count = 4
@@ -225,21 +224,18 @@ class QFlightSlotEditor(QGroupBox):
def _changed_aircraft_count(self):
old_count = self.flight.count
new_count = int(self.aircraft_count_spinner.value())
- self.game.aircraft_inventory.return_from_flight(self.flight)
- self.flight.resize(new_count)
try:
- self.game.aircraft_inventory.claim_for_flight(self.flight)
+ self.flight.resize(new_count)
except ValueError:
# The UI should have prevented this, but if we ran out of aircraft
# then roll back the inventory change.
difference = new_count - self.flight.count
- available = self.inventory.available(self.flight.unit_type)
+ available = self.flight.squadron.untasked_aircraft
logging.error(
f"Could not add {difference} additional aircraft to "
f"{self.flight} because {self.flight.departure} has only "
f"{available} {self.flight.unit_type} remaining"
)
- self.game.aircraft_inventory.claim_for_flight(self.flight)
self.flight.resize(old_count)
return
self.roster_editor.resize(new_count)