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)