Dedup purchase requests.

Since the theater commander runs once per campaign action, missions that
do not have aircraft available may be checked more than once a turn.
Without deduping requests this can lead to cases where the AI buys
dozens of tankers on turn 0.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1470
This commit is contained in:
Dan Albert 2021-08-01 13:09:48 -07:00
parent a3e3e9046f
commit edf95ea9fb
9 changed files with 93 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

23
game/orderedset.py Normal file
View File

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

View File

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

View File

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