diff --git a/game/coalition.py b/game/coalition.py index 01c1e2cb..b6e681f9 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -10,7 +10,9 @@ 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 +from game.savecompat import has_save_compat_for from game.threatzones import ThreatZones from game.transfers import PendingTransfers @@ -35,7 +37,7 @@ class Coalition: self.budget = budget self.ato = AirTaskingOrder() self.transit_network = TransitNetwork() - self.procurement_requests: list[AircraftProcurementRequest] = [] + self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet() self.bullseye = Bullseye(Point(0, 0)) self.faker = Faker(self.faction.locales) self.air_wing = AirWing(game, self) @@ -98,7 +100,14 @@ class Coalition: del state["faker"] return state + @has_save_compat_for(5) def __setstate__(self, state: dict[str, Any]) -> None: + # Begin save compat + old_procurement_requests = state["procurement_requests"] + if isinstance(old_procurement_requests, list): + state["procurement_requests"] = OrderedSet(old_procurement_requests) + # End save compat + self.__dict__.update(state) # Regenerate any state that was not persisted. self.on_load() @@ -225,3 +234,6 @@ class Coalition: manage_front_line, manage_aircraft, ).spend_budget(self.budget) + + def add_procurement_request(self, request: AircraftProcurementRequest) -> None: + self.procurement_requests.add(request) diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index 1005bfa9..83dbcf76 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -60,7 +60,7 @@ class PackageFulfiller: return self.coalition.opponent.threat_zone def add_procurement_request(self, request: AircraftProcurementRequest) -> None: - self.coalition.procurement_requests.append(request) + self.coalition.add_procurement_request(request) def air_wing_can_plan(self, mission_type: FlightType) -> bool: """Returns True if it is possible for the air wing to plan this mission type. @@ -78,13 +78,14 @@ class PackageFulfiller: flight: ProposedFlight, builder: PackageBuilder, missing_types: Set[FlightType], + purchase_multiplier: int, ) -> None: if not builder.plan_flight(flight): missing_types.add(flight.task) purchase_order = AircraftProcurementRequest( near=mission.location, task_capability=flight.task, - number=flight.num_aircraft, + number=flight.num_aircraft * purchase_multiplier, ) # Reserves are planned for critical missions, so prioritize those orders # over aircraft needed for non-critical missions. @@ -96,11 +97,14 @@ class PackageFulfiller: builder: PackageBuilder, missing_types: Set[FlightType], not_attempted: Iterable[ProposedFlight], + purchase_multiplier: int, ) -> None: # Try to plan the rest of the mission just so we can count the missing # types to buy. for flight in not_attempted: - self.plan_flight(mission, flight, builder, missing_types) + self.plan_flight( + mission, flight, builder, missing_types, purchase_multiplier + ) missing_types_str = ", ".join(sorted([t.name for t in missing_types])) builder.release_planned_aircraft() @@ -124,7 +128,10 @@ class PackageFulfiller: return threats def plan_mission( - self, mission: ProposedMission, tracer: MultiEventTracer + self, + mission: ProposedMission, + purchase_multiplier: int, + tracer: MultiEventTracer, ) -> Optional[Package]: """Allocates aircraft for a proposed mission and adds it to the ATO.""" builder = PackageBuilder( @@ -155,11 +162,17 @@ class PackageFulfiller: escorts.append(proposed_flight) continue with tracer.trace("Flight planning"): - self.plan_flight(mission, proposed_flight, builder, missing_types) + self.plan_flight( + mission, + proposed_flight, + builder, + missing_types, + purchase_multiplier, + ) if missing_types: self.scrub_mission_missing_aircraft( - mission, builder, missing_types, escorts + mission, builder, missing_types, escorts, purchase_multiplier ) return None @@ -189,13 +202,15 @@ class PackageFulfiller: assert escort.escort_type is not None if needed_escorts[escort.escort_type]: with tracer.trace("Flight planning"): - self.plan_flight(mission, escort, builder, missing_types) + self.plan_flight( + mission, escort, builder, missing_types, purchase_multiplier + ) # Check again for unavailable aircraft. If the escort was required and # none were found, scrub the mission. if missing_types: self.scrub_mission_missing_aircraft( - mission, builder, missing_types, escorts + mission, builder, missing_types, escorts, purchase_multiplier ) return None diff --git a/game/commander/tasks/compound/protectairspace.py b/game/commander/tasks/compound/protectairspace.py index 67407010..79306c65 100644 --- a/game/commander/tasks/compound/protectairspace.py +++ b/game/commander/tasks/compound/protectairspace.py @@ -9,4 +9,4 @@ class ProtectAirSpace(CompoundTask[TheaterState]): def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: for cp, needed in state.barcaps_needed.items(): if needed > 0: - yield [PlanBarcap(cp)] + yield [PlanBarcap(cp, needed)] diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index 8e2eb8a2..cf75eb1b 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -74,6 +74,27 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): def asap(self) -> bool: return False + @property + def purchase_multiplier(self) -> int: + """The multiplier for aircraft quantity when missions could not be fulfilled. + + For missions that do not schedule in rounds like BARCAPs do, this should be one + to ensure that the we only purchase enough aircraft to plan the mission once. + + For missions that repeat within the same turn, however, we may need to buy for + the same mission more than once. If three rounds of BARCAP still need to be + fulfilled, this would return 3, and we'd triplicate the purchase order. + + There is a small misbehavior here that's not symptomatic for our current mission + planning: multi-round, multi-flight packages will only purchase multiple sets of + aircraft for whatever is unavailable for the *first* failed package. For + example, if we extend this to CAS and have no CAS aircraft but enough TARCAP + aircraft for one round, we'll order CAS for every round but will not order any + TARCAP aircraft, since we can't know that TARCAP aircraft are needed until we + attempt to plan the second mission *without returning the first round aircraft*. + """ + return 1 + def fulfill_mission(self, state: TheaterState) -> bool: self.propose_flights() fulfiller = PackageFulfiller( @@ -83,7 +104,9 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): state.context.settings, ) self.package = fulfiller.plan_mission( - ProposedMission(self.target, self.flights), state.context.tracer + ProposedMission(self.target, self.flights), + self.purchase_multiplier, + state.context.tracer, ) return self.package is not None diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py index c2dafae7..b4e8455e 100644 --- a/game/commander/tasks/primitive/barcap.py +++ b/game/commander/tasks/primitive/barcap.py @@ -10,6 +10,8 @@ from gen.flights.flight import FlightType @dataclass class PlanBarcap(PackagePlanningTask[ControlPoint]): + max_orders: int + def preconditions_met(self, state: TheaterState) -> bool: if not state.barcaps_needed[self.target]: return False @@ -20,3 +22,7 @@ class PlanBarcap(PackagePlanningTask[ControlPoint]): def propose_flights(self) -> None: self.propose_flight(FlightType.BARCAP, 2) + + @property + def purchase_multiplier(self) -> int: + return self.max_orders diff --git a/game/game.py b/game/game.py index 7125cc24..1b9d1c7f 100644 --- a/game/game.py +++ b/game/game.py @@ -136,11 +136,6 @@ class Game: def ato_for(self, player: bool) -> AirTaskingOrder: return self.coalition_for(player).ato - def procurement_requests_for( - self, player: bool - ) -> list[AircraftProcurementRequest]: - return self.coalition_for(player).procurement_requests - def transit_network_for(self, player: bool) -> TransitNetwork: return self.coalition_for(player).transit_network diff --git a/game/orderedset.py b/game/orderedset.py new file mode 100644 index 00000000..07a5964d --- /dev/null +++ b/game/orderedset.py @@ -0,0 +1,23 @@ +from collections import Iterator, Iterable +from typing import Generic, TypeVar, Optional + +ValueT = TypeVar("ValueT") + + +class OrderedSet(Generic[ValueT]): + def __init__(self, initial_data: Optional[Iterable[ValueT]] = None) -> None: + if initial_data is None: + initial_data = [] + self._data: dict[ValueT, None] = {v: None for v in initial_data} + + def __iter__(self) -> Iterator[ValueT]: + yield from self._data + + def __contains__(self, item: ValueT) -> bool: + return item in self._data + + def add(self, item: ValueT) -> None: + self._data[item] = None + + def clear(self) -> None: + self._data.clear() diff --git a/game/procurement.py b/game/procurement.py index d1c254f0..950e19d0 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -2,7 +2,7 @@ from __future__ import annotations import math import random -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple from game import db @@ -262,7 +262,7 @@ class ProcurementAi: return budget, False def purchase_aircraft(self, budget: float) -> float: - for request in self.game.procurement_requests_for(self.is_player): + for request in self.game.coalition_for(self.is_player).procurement_requests: if not list(self.best_airbases_for(request)): # No airbases in range of this request. Skip it. continue diff --git a/game/transfers.py b/game/transfers.py index c5115d12..ffb879e4 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -751,6 +751,6 @@ class PendingTransfers: # aesthetic. gap += 1 - self.game.procurement_requests_for(self.player).append( + self.game.coalition_for(self.player).add_procurement_request( AircraftProcurementRequest(control_point, FlightType.TRANSPORT, gap) )