Assign aircraft to squadrons rather than bases.

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

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

View File

@ -8,6 +8,7 @@ Saves from 4.x are not compatible with 5.0.
* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated.
* **[Campaign]** 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.

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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(

View File

@ -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(),
)

View File

@ -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:

View File

@ -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))

View File

@ -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:

View File

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

View File

@ -56,10 +56,12 @@ class GameStats:
for cp in game.theater.controlpoints:
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)

View File

@ -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

180
game/purchaseadapter.py Normal file
View File

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

View File

@ -5,12 +5,12 @@ from collections import defaultdict
from typing import Sequence, Iterator, TYPE_CHECKING
from 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:

View File

@ -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,

View File

@ -1,10 +1,4 @@
import itertools
import logging
from typing import Any
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.dcs.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

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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:

View File

@ -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()

View File

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

View File

@ -122,14 +122,6 @@ class SquadronBaseSelector(QComboBox):
self.model().sort(0)
self.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:

View File

@ -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()

View File

@ -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)
)

View File

@ -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"<b>{self.pending_units}</b>")
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: <b>${:.2f}M</b>"
def __init__(self) -> None:
def __init__(
self,
game_model: GameModel,
purchase_adapter: PurchaseAdapter[TransactionItemType],
) -> None:
super().__init__()
self.game_model = game_model
self.purchase_adapter = purchase_adapter
self.existing_units_labels = {}
self.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"<b>{unit_type.name}</b>")
unitName = QLabel(f"<b>{self.display_name_of(item, multiline=True)}</b>")
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"<b>$ {unit_type.price}</b> M")
price = QLabel(f"<b>$ {self.price_of(item)}</b> 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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -24,7 +24,6 @@ from qt_ui.widgets.QLabeledWidget import QLabeledWidget
from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector
from qt_ui.widgets.combos.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()

View File

@ -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}")

View File

@ -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)