Merge branch 'develop' into helipads

# Conflicts:
#	game/game.py
#	resources/campaigns/golan_heights_lite.miz
This commit is contained in:
Khopa
2021-06-26 18:00:36 +02:00
182 changed files with 1613 additions and 2146 deletions

View File

@@ -1,51 +0,0 @@
from dcs.planes import (
Bf_109K_4,
C_101CC,
FW_190A8,
FW_190D9,
F_5E_3,
F_86F_Sabre,
I_16,
L_39ZA,
MiG_15bis,
MiG_19P,
MiG_21Bis,
P_47D_30,
P_47D_30bl1,
P_47D_40,
P_51D,
P_51D_30_NA,
SpitfireLFMkIX,
SpitfireLFMkIXCW,
)
from pydcs_extensions.a4ec.a4ec import A_4E_C
"""
This list contains the aircraft that do not use the guns as the last resort weapons, but as a main weapon
They'll RTB when they don't have gun ammo left
"""
GUNFIGHTERS = [
# Cold War
MiG_15bis,
MiG_19P,
MiG_21Bis,
F_86F_Sabre,
A_4E_C,
F_5E_3,
# Trainers
C_101CC,
L_39ZA,
# WW2
P_51D_30_NA,
P_51D,
P_47D_30,
P_47D_30bl1,
P_47D_40,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
Bf_109K_4,
FW_190D9,
FW_190A8,
I_16,
]

View File

@@ -4,7 +4,7 @@ import datetime
import inspect
import logging
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Dict, Iterator, Optional, Set, Tuple, Union, cast
from dcs.unitgroup import FlyingGroup
@@ -21,8 +21,8 @@ class Weapon:
"""Wraps a pydcs weapon dict in a hashable type."""
cls_id: str
name: str
weight: int
name: str = field(compare=False)
weight: int = field(compare=False)
def available_on(self, date: datetime.date) -> bool:
introduction_year = WEAPON_INTRODUCTION_YEARS.get(self)

View File

@@ -44,12 +44,10 @@ from pydcs_extensions.a4ec.a4ec import A_4E_C
from pydcs_extensions.f22a.f22a import F_22A
from pydcs_extensions.hercules.hercules import Hercules
from pydcs_extensions.jas39.jas39 import JAS39Gripen, JAS39Gripen_AG
from pydcs_extensions.mb339.mb339 import MB_339PAN
from pydcs_extensions.su57.su57 import Su_57
plane_map["A-4E-C"] = A_4E_C
plane_map["F-22A"] = F_22A
plane_map["MB-339PAN"] = MB_339PAN
plane_map["Su-57"] = Su_57
plane_map["Hercules"] = Hercules
plane_map["JAS39Gripen"] = JAS39Gripen

View File

@@ -29,7 +29,7 @@ from game.radio.channels import (
ViggenRadioChannelAllocator,
NoOpChannelAllocator,
)
from game.utils import Speed, kph
from game.utils import Distance, Speed, feet, kph, knots
if TYPE_CHECKING:
from gen.aircraft import FlightData
@@ -90,11 +90,34 @@ class RadioConfig:
}[config.get("namer", "default")]
@dataclass(frozen=True)
class PatrolConfig:
altitude: Optional[Distance]
speed: Optional[Speed]
@classmethod
def from_data(cls, data: dict[str, Any]) -> PatrolConfig:
altitude = data.get("altitude", None)
speed = data.get("altitude", None)
return PatrolConfig(
feet(altitude) if altitude is not None else None,
knots(speed) if speed is not None else None,
)
@dataclass(frozen=True)
class AircraftType(UnitType[FlyingType]):
carrier_capable: bool
lha_capable: bool
always_keeps_gun: bool
# If true, the aircraft does not use the guns as the last resort weapons, but as a main weapon.
# It'll RTB when it doesn't have gun ammo left.
gunfighter: bool
max_group_size: int
patrol_altitude: Optional[Distance]
patrol_speed: Optional[Speed]
intra_flight_radio: Optional[Radio]
channel_allocator: Optional[RadioChannelAllocator]
channel_namer: Type[ChannelNamer]
@@ -146,6 +169,12 @@ class AircraftType(UnitType[FlyingType]):
def channel_name(self, radio_id: int, channel_id: int) -> str:
return self.channel_namer.channel_name(radio_id, channel_id)
def __setstate__(self, state: dict[str, Any]) -> None:
# Update any existing models with new data on load.
updated = AircraftType.named(state["name"])
state.update(updated.__dict__)
self.__dict__.update(state)
@classmethod
def register(cls, aircraft_type: AircraftType) -> None:
cls._by_name[aircraft_type.name] = aircraft_type
@@ -159,6 +188,8 @@ class AircraftType(UnitType[FlyingType]):
@classmethod
def for_dcs_type(cls, dcs_unit_type: Type[FlyingType]) -> Iterator[AircraftType]:
if not cls._loaded:
cls._load_all()
yield from cls._by_unit_type[dcs_unit_type]
@staticmethod
@@ -189,6 +220,7 @@ class AircraftType(UnitType[FlyingType]):
raise KeyError(f"Missing required price field: {data_path}") from ex
radio_config = RadioConfig.from_data(data.get("radios", {}))
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
try:
introduction = data["introduced"]
@@ -210,6 +242,10 @@ class AircraftType(UnitType[FlyingType]):
carrier_capable=data.get("carrier_capable", False),
lha_capable=data.get("lha_capable", False),
always_keeps_gun=data.get("always_keeps_gun", False),
gunfighter=data.get("gunfighter", False),
max_group_size=data.get("max_group_size", aircraft.group_size_max),
patrol_altitude=patrol_config.altitude,
patrol_speed=patrol_config.speed,
intra_flight_radio=radio_config.intra_flight,
channel_allocator=radio_config.channel_allocator,
channel_namer=radio_config.channel_namer,

View File

@@ -45,6 +45,8 @@ class GroundUnitType(UnitType[VehicleType]):
@classmethod
def for_dcs_type(cls, dcs_unit_type: Type[VehicleType]) -> Iterator[GroundUnitType]:
if not cls._loaded:
cls._load_all()
yield from cls._by_unit_type[dcs_unit_type]
@staticmethod

View File

@@ -382,15 +382,21 @@ class PollDebriefingFileThread(threading.Thread):
else:
last_modified = 0
while not self.stopped():
if (
os.path.isfile("state.json")
and os.path.getmtime("state.json") > last_modified
):
with open("state.json", "r") as json_file:
json_data = json.load(json_file)
debriefing = Debriefing(json_data, self.game, self.unit_map)
self.callback(debriefing)
break
try:
if (
os.path.isfile("state.json")
and os.path.getmtime("state.json") > last_modified
):
with open("state.json", "r") as json_file:
json_data = json.load(json_file)
debriefing = Debriefing(json_data, self.game, self.unit_map)
self.callback(debriefing)
break
except json.JSONDecodeError:
logging.exception(
"Failed to decode state.json. Probably attempted read while DCS "
"was still writing the file. Will retry in 5 seconds."
)
time.sleep(5)

View File

@@ -54,7 +54,7 @@ class Event:
@property
def is_player_attacking(self) -> bool:
return self.attacker_name == self.game.player_name
return self.attacker_name == self.game.player_faction.name
@property
def tasks(self) -> List[Type[Task]]:

View File

@@ -257,6 +257,76 @@ class Faction:
if unit.unit_class is unit_class:
yield unit
def apply_mod_settings(self, mod_settings) -> Faction:
# aircraft
if not mod_settings.a4_skyhawk:
self.remove_aircraft("A-4E-C")
if not mod_settings.hercules:
self.remove_aircraft("Hercules")
if not mod_settings.f22_raptor:
self.remove_aircraft("F-22A")
if not mod_settings.jas39_gripen:
self.remove_aircraft("JAS39Gripen")
self.remove_aircraft("JAS39Gripen_AG")
if not mod_settings.su57_felon:
self.remove_aircraft("Su-57")
# frenchpack
if not mod_settings.frenchpack:
self.remove_vehicle("AMX10RCR")
self.remove_vehicle("SEPAR")
self.remove_vehicle("ERC")
self.remove_vehicle("M120")
self.remove_vehicle("AA20")
self.remove_vehicle("TRM2000")
self.remove_vehicle("TRM2000_Citerne")
self.remove_vehicle("TRM2000_AA20")
self.remove_vehicle("TRMMISTRAL")
self.remove_vehicle("VABH")
self.remove_vehicle("VAB_RADIO")
self.remove_vehicle("VAB_50")
self.remove_vehicle("VIB_VBR")
self.remove_vehicle("VAB_HOT")
self.remove_vehicle("VAB_MORTIER")
self.remove_vehicle("VBL50")
self.remove_vehicle("VBLANF1")
self.remove_vehicle("VBL-radio")
self.remove_vehicle("VBAE")
self.remove_vehicle("VBAE_MMP")
self.remove_vehicle("AMX-30B2")
self.remove_vehicle("Tracma")
self.remove_vehicle("JTACFP")
self.remove_vehicle("SHERIDAN")
self.remove_vehicle("Leclerc_XXI")
self.remove_vehicle("Toyota_bleu")
self.remove_vehicle("Toyota_vert")
self.remove_vehicle("Toyota_desert")
self.remove_vehicle("Kamikaze")
# high digit sams
if not mod_settings.high_digit_sams:
self.remove_air_defenses("SA10BGenerator")
self.remove_air_defenses("SA12Generator")
self.remove_air_defenses("SA20Generator")
self.remove_air_defenses("SA20BGenerator")
self.remove_air_defenses("SA23Generator")
self.remove_air_defenses("SA17Generator")
self.remove_air_defenses("KS19Generator")
return self
def remove_aircraft(self, name):
for i in self.aircrafts:
if i.dcs_unit_type.id == name:
self.aircrafts.remove(i)
def remove_air_defenses(self, name):
for i in self.air_defenses:
if i == name:
self.air_defenses.remove(i)
def remove_vehicle(self, name):
for i in self.frontline_units:
if i.dcs_unit_type.id == name:
self.frontline_units.remove(i)
def load_ship(name: str) -> Optional[Type[ShipType]]:
if (ship := getattr(dcs.ships, name, None)) is not None:

View File

@@ -1,23 +1,25 @@
from game.dcs.aircrafttype import AircraftType
import itertools
import logging
import random
import sys
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Any, Dict, List
from typing import Any, List
from dcs.action import Coalition
from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors
from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence
from pydcs_extensions.a4ec.a4ec import A_4E_C
from faker import Faker
from game import db
from game.inventory import GlobalAircraftInventory
from game.models.game_stats import GameStats
from game.plugins import LuaPluginManager
from gen import naming
from gen import aircraft, naming
from gen.ato import AirTaskingOrder
from gen.conflictgen import Conflict
from gen.flights.ai_flight_planner import CoalitionMissionPlanner
@@ -87,8 +89,8 @@ class TurnState(Enum):
class Game:
def __init__(
self,
player_name: str,
enemy_name: str,
player_faction: Faction,
enemy_faction: Faction,
theater: ConflictTheater,
start_date: datetime,
settings: Settings,
@@ -98,10 +100,10 @@ class Game:
self.settings = settings
self.events: List[Event] = []
self.theater = theater
self.player_name = player_name
self.player_country = db.FACTIONS[player_name].country
self.enemy_name = enemy_name
self.enemy_country = db.FACTIONS[enemy_name].country
self.player_faction = player_faction
self.player_country = player_faction.country
self.enemy_faction = enemy_faction
self.enemy_country = enemy_faction.country
# pass_turn() will be called when initialization is complete which will
# increment this to turn 0 before it reaches the player.
self.turn = -1
@@ -109,7 +111,7 @@ class Game:
self.date = date(start_date.year, start_date.month, start_date.day)
self.game_stats = GameStats()
self.game_stats.update(self)
self.ground_planners: Dict[int, GroundPlanner] = {}
self.ground_planners: dict[int, GroundPlanner] = {}
self.informations = []
self.informations.append(Information("Game Start", "-" * 40, 0))
# Culling Zones are for areas around points of interest that contain things we may not wish to cull.
@@ -150,7 +152,7 @@ class Game:
self.on_load(game_still_initializing=True)
def __getstate__(self) -> Dict[str, Any]:
def __getstate__(self) -> dict[str, Any]:
state = self.__dict__.copy()
# Avoid persisting any volatile types that can be deterministically
# recomputed on load for the sake of save compatibility.
@@ -162,7 +164,7 @@ class Game:
del state["red_faker"]
return state
def __setstate__(self, state: Dict[str, Any]) -> None:
def __setstate__(self, state: dict[str, Any]) -> None:
self.__dict__.update(state)
# Regenerate any state that was not persisted.
self.on_load()
@@ -202,13 +204,49 @@ class Game:
else:
self.enemy_country = "Russia"
@property
def player_faction(self) -> Faction:
return db.FACTIONS[self.player_name]
def faction_for(self, player: bool) -> Faction:
if player:
return self.player_faction
return self.enemy_faction
@property
def enemy_faction(self) -> Faction:
return db.FACTIONS[self.enemy_name]
def faker_for(self, player: bool) -> Faker:
if player:
return self.blue_faker
return self.red_faker
def air_wing_for(self, player: bool) -> AirWing:
if player:
return self.blue_air_wing
return self.red_air_wing
def country_for(self, player: bool) -> str:
if player:
return self.player_country
return self.enemy_country
def bullseye_for(self, player: bool) -> Bullseye:
if player:
return self.blue_bullseye
return self.red_bullseye
def _roll(self, prob, mult):
if self.settings.version == "dev":
# always generate all events for dev
return 100
else:
return random.randint(1, 100) <= prob * mult
def _generate_player_event(self, event_class, player_cp, enemy_cp):
self.events.append(
event_class(
self,
player_cp,
enemy_cp,
enemy_cp.position,
self.player_faction.name,
self.enemy_faction.name,
)
)
@property
def neutral_country(self):
@@ -260,8 +298,8 @@ class Game:
player_cp,
enemy_cp,
enemy_cp.position,
self.player_name,
self.enemy_name,
self.player_faction.name,
self.enemy_faction.name,
)
)
@@ -307,7 +345,7 @@ class Game:
return (
event
and event.attacker_name
and event.attacker_name == self.player_name
and event.attacker_name == self.player_faction.name
)
else:
raise RuntimeError(f"{event} was passed when an Event type was expected")
@@ -356,10 +394,10 @@ class Game:
self.blue_air_wing.replenish()
self.red_air_wing.replenish()
if not skipped and self.turn > 1:
if not skipped:
for cp in self.theater.player_points():
cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY)
else:
elif self.turn > 1:
for cp in self.theater.player_points():
if not cp.is_carrier and not cp.is_lha:
cp.base.affect_strength(-PLAYER_BASE_STRENGTH_RECOVERY)

View File

@@ -77,8 +77,8 @@ class Operation:
yield Conflict(
cls.game.theater,
frontline,
cls.game.player_name,
cls.game.enemy_name,
cls.game.player_faction.name,
cls.game.enemy_faction.name,
cls.game.player_country,
cls.game.enemy_country,
frontline.position,
@@ -95,8 +95,8 @@ class Operation:
return Conflict(
cls.game.theater,
FrontLine(player_cp, enemy_cp),
cls.game.player_name,
cls.game.enemy_name,
cls.game.player_faction.name,
cls.game.enemy_faction.name,
cls.game.player_country,
cls.game.enemy_country,
mid_point,
@@ -403,8 +403,8 @@ class Operation:
player_cp = front_line.blue_cp
enemy_cp = front_line.red_cp
conflict = Conflict.frontline_cas_conflict(
cls.game.player_name,
cls.game.enemy_name,
cls.game.player_faction.name,
cls.game.enemy_faction.name,
cls.current_mission.country(cls.game.player_country),
cls.current_mission.country(cls.game.enemy_country),
front_line,

View File

@@ -2,16 +2,18 @@ import logging
import os
import pickle
import shutil
from pathlib import Path
from typing import Optional
_dcs_saved_game_folder: Optional[str] = None
_file_abs_path = None
def setup(user_folder: str):
global _dcs_saved_game_folder
_dcs_saved_game_folder = user_folder
_file_abs_path = os.path.join(base_path(), "default.liberation")
if not save_dir().exists():
save_dir().mkdir(parents=True)
def base_path() -> str:
@@ -20,16 +22,20 @@ def base_path() -> str:
return _dcs_saved_game_folder
def save_dir() -> Path:
return Path(base_path()) / "Liberation" / "Saves"
def _temporary_save_file() -> str:
return os.path.join(base_path(), "tmpsave.liberation")
return str(save_dir() / "tmpsave.liberation")
def _autosave_path() -> str:
return os.path.join(base_path(), "autosave.liberation")
return str(save_dir() / "autosave.liberation")
def mission_path_for(name: str) -> str:
return os.path.join(base_path(), "Missions", "{}".format(name))
return os.path.join(base_path(), "Missions", name)
def load_game(path):

View File

@@ -34,11 +34,14 @@ class Settings:
player_income_multiplier: float = 1.0
enemy_income_multiplier: float = 1.0
#: Feature flag for squadron limits.
enable_squadron_pilot_limits: bool = False
#: The maximum number of pilots a squadron can have at one time. Changing this after
#: the campaign has started will have no immediate effect; pilots already in the
#: squadron will not be removed if the limit is lowered and pilots will not be
#: immediately created if the limit is raised.
squadron_pilot_limit: int = 24
squadron_pilot_limit: int = 4
#: The number of pilots a squadron can replace per turn.
squadron_replenishment_rate: int = 4

View File

@@ -112,9 +112,19 @@ class Squadron:
return self.name
return f'{self.name} "{self.nickname}"'
@property
def pilot_limits_enabled(self) -> bool:
return self.game.settings.enable_squadron_pilot_limits
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
if self.pilot_limits_enabled:
return None
self._recruit_pilots(1)
return self.available_pilots.pop()
def claim_available_pilot(self) -> Optional[Pilot]:
if not self.available_pilots:
return None
return self.claim_new_pilot_if_allowed()
# For opfor, so player/AI option is irrelevant.
if not self.player:
@@ -140,7 +150,7 @@ class Squadron:
# If they only *prefer* players and we're out of players, just return an AI
# pilot.
if not prefer_players:
return None
return self.claim_new_pilot_if_allowed()
return self.available_pilots.pop()
def claim_pilot(self, pilot: Pilot) -> None:
@@ -169,9 +179,12 @@ class Squadron:
self.available_pilots.extend(new_pilots)
def replenish_lost_pilots(self) -> None:
if not self.pilot_limits_enabled:
return
replenish_count = min(
self.game.settings.squadron_replenishment_rate,
self.number_of_unfilled_pilot_slots,
self._number_of_unfilled_pilot_slots,
)
if replenish_count > 0:
self._recruit_pilots(replenish_count)
@@ -213,20 +226,23 @@ class Squadron:
return len(self.current_roster)
@property
def number_of_unfilled_pilot_slots(self) -> int:
def _number_of_unfilled_pilot_slots(self) -> int:
return self.game.settings.squadron_pilot_limit - len(self.active_pilots)
@property
def number_of_available_pilots(self) -> int:
return len(self.available_pilots)
def can_provide_pilots(self, count: int) -> bool:
return not self.pilot_limits_enabled or self.number_of_available_pilots >= count
@property
def has_available_pilots(self) -> bool:
return bool(self.available_pilots)
return not self.pilot_limits_enabled or bool(self.available_pilots)
@property
def has_unfilled_pilot_slots(self) -> bool:
return self.number_of_unfilled_pilot_slots > 0
return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
@@ -368,9 +384,16 @@ class AirWing:
def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]:
return self.squadrons[aircraft]
def squadrons_for_task(self, task: FlightType) -> Iterator[Squadron]:
def can_auto_plan(self, task: FlightType) -> bool:
try:
next(self.auto_assignable_for_task(task))
return True
except StopIteration:
return False
def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]:
for squadron in self.iter_squadrons():
if task in squadron.mission_types:
if squadron.can_auto_assign(task):
yield squadron
def auto_assignable_for_task_with_type(

View File

@@ -78,20 +78,33 @@ class GeneratorSettings:
no_enemy_navy: bool
@dataclass
class ModSettings:
a4_skyhawk: bool = False
f22_raptor: bool = False
hercules: bool = False
jas39_gripen: bool = False
su57_felon: bool = False
frenchpack: bool = False
high_digit_sams: bool = False
class GameGenerator:
def __init__(
self,
player: str,
enemy: str,
player: Faction,
enemy: Faction,
theater: ConflictTheater,
settings: Settings,
generator_settings: GeneratorSettings,
mod_settings: ModSettings,
) -> None:
self.player = player
self.enemy = enemy
self.theater = theater
self.settings = settings
self.generator_settings = generator_settings
self.mod_settings = mod_settings
def generate(self) -> Game:
with logged_duration("TGO population"):
@@ -99,8 +112,8 @@ class GameGenerator:
namegen.reset()
self.prepare_theater()
game = Game(
player_name=self.player,
enemy_name=self.enemy,
player_faction=self.player.apply_mod_settings(self.mod_settings),
enemy_faction=self.enemy.apply_mod_settings(self.mod_settings),
theater=self.theater,
start_date=self.generator_settings.start_date,
settings=self.settings,
@@ -159,9 +172,9 @@ class ControlPointGroundObjectGenerator:
@property
def faction_name(self) -> str:
if self.control_point.captured:
return self.game.player_name
return self.game.player_faction.name
else:
return self.game.enemy_name
return self.game.enemy_faction.name
@property
def faction(self) -> Faction:

View File

@@ -1,3 +1,34 @@
"""Implements support for ground unit transfers between bases.
Ground units can be transferred between bases via a number of transport methods, and
doing so can take multiple turns.
There are a few main concepts here:
* A TransferOrder is a request to move units from one base to another. It is described
by its origin, destination, current position, and contents. TransferOrders persist
across turns, and if no Transport is available to move the units in a given turn it
will have no Transport assigned.
* Transports: A Transport is the planned move of a group of units for a leg of the
journey *this turn*. A Transport has an assigned mode of transportation and has
vehicles assigned to move the units if needed. This might be a Convoy, a CargoShip, or
an Airlift.
The TransportMap (more accurately, it's subtypes) is responsible for managing the
transports moving from A to B for the turn. Transfers that are moving between A and B
this turn will be added to the TransportMap, which will create a new transport if needed
or add the units to an existing transport if one exists. This allows transfers from
A->B->C and D->B->C to share a transport between B and C.
AirLifts do not use TransportMap because no merging will take place between orders. It
instead uses AirLiftPlanner to create transport packages.
PendingTransfers manages all the incomplete transfer orders for the game. New transfer
orders are registered with PendingTransfers and it is responsible for allocating
transports and processing the turn's transit actions.
Routing is handled by TransitNetwork.
"""
from __future__ import annotations
import logging
@@ -6,7 +37,6 @@ from collections import defaultdict
from dataclasses import dataclass, field
from functools import singledispatchmethod
from typing import (
Dict,
Generic,
Iterator,
List,
@@ -29,7 +59,7 @@ from game.theater.transitnetwork import (
)
from game.utils import meters, nautical_miles
from gen.ato import Package
from gen.flights.ai_flight_planner_db import TRANSPORT_CAPABLE, aircraft_for_task
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import Flight, FlightType
from gen.flights.flightplan import FlightPlanBuilder
@@ -72,10 +102,18 @@ class TransferOrder:
player: bool = field(init=False)
#: The units being transferred.
units: Dict[GroundUnitType, int]
units: dict[GroundUnitType, int]
transport: Optional[Transport] = field(default=None)
def __str__(self) -> str:
"""Returns the text that should be displayed for the transfer."""
count = self.size
origin = self.origin.name
destination = self.destination.name
description = "Transfer" if self.player else "Enemy transfer"
return f"{description} of {count} units from {origin} to {destination}"
def __post_init__(self) -> None:
self.position = self.origin
self.player = self.origin.is_friendly(to_player=True)
@@ -91,12 +129,12 @@ class TransferOrder:
def kill_unit(self, unit_type: GroundUnitType) -> None:
if unit_type not in self.units or not self.units[unit_type]:
raise KeyError(f"{self.destination} has no {unit_type} remaining")
raise KeyError(f"{self} has no {unit_type} remaining")
self.units[unit_type] -= 1
@property
def size(self) -> int:
return sum(c for c in self.units.values())
return sum(self.units.values())
def iter_units(self) -> Iterator[GroundUnitType]:
for unit_type, count in self.units.items():
@@ -105,7 +143,7 @@ class TransferOrder:
@property
def completed(self) -> bool:
return self.destination == self.position or not self.units
return self.destination == self.position or not self.size
def disband_at(self, location: ControlPoint) -> None:
logging.info(f"Units halting at {location}.")
@@ -156,7 +194,7 @@ class Airlift(Transport):
self.flight = flight
@property
def units(self) -> Dict[GroundUnitType, int]:
def units(self) -> dict[GroundUnitType, int]:
return self.transfer.units
@property
@@ -261,8 +299,12 @@ class AirliftPlanner:
required,
available_aircraft,
squadron.aircraft.dcs_unit_type.group_size_max,
squadron.number_of_available_pilots,
)
# 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
capacity = flight_size * capacity_each
if capacity < self.transfer.size:
@@ -334,11 +376,11 @@ class MultiGroupTransport(MissionTarget, Transport):
@property
def size(self) -> int:
return sum(sum(t.units.values()) for t in self.transfers)
return sum(t.size for t in self.transfers)
@property
def units(self) -> dict[GroundUnitType, int]:
units: Dict[GroundUnitType, int] = defaultdict(int)
units: dict[GroundUnitType, int] = defaultdict(int)
for transfer in self.transfers:
for unit_type, count in transfer.units.items():
units[unit_type] += count
@@ -414,8 +456,8 @@ TransportType = TypeVar("TransportType", bound=MultiGroupTransport)
class TransportMap(Generic[TransportType]):
def __init__(self) -> None:
# Dict of origin -> destination -> transport.
self.transports: Dict[
ControlPoint, Dict[ControlPoint, TransportType]
self.transports: dict[
ControlPoint, dict[ControlPoint, TransportType]
] = defaultdict(dict)
def create_transport(
@@ -592,7 +634,10 @@ class PendingTransfers:
def order_airlift_assets(self) -> None:
for control_point in self.game.theater.controlpoints:
self.order_airlift_assets_at(control_point)
if self.game.air_wing_for(control_point.captured).can_auto_plan(
FlightType.TRANSPORT
):
self.order_airlift_assets_at(control_point)
@staticmethod
def desired_airlift_capacity(control_point: ControlPoint) -> int:
@@ -600,10 +645,10 @@ class PendingTransfers:
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).squadrons_for_task(
FlightType.TRANSPORT
)
unit_types = {s.aircraft for s in squadrons}.intersection(TRANSPORT_CAPABLE)
squadrons = self.game.air_wing_for(
control_point.captured
).auto_assignable_for_task(FlightType.TRANSPORT)
unit_types = {s.aircraft for s in squadrons}
return sum(
count
for unit_type, count in inventory.all_aircraft

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, Optional, TYPE_CHECKING, Any
from typing import Optional, TYPE_CHECKING, Any
from game.theater import ControlPoint
from .dcs.groundunittype import GroundUnitType
@@ -28,16 +28,16 @@ class PendingUnitDeliveries:
self.destination = destination
# Maps unit type to order quantity.
self.units: Dict[UnitType, int] = defaultdict(int)
self.units: dict[UnitType, int] = defaultdict(int)
def __str__(self) -> str:
return f"Pending delivery to {self.destination}"
def order(self, units: Dict[UnitType, int]) -> None:
def order(self, units: dict[UnitType, int]) -> None:
for k, v in units.items():
self.units[k] += v
def sell(self, units: Dict[UnitType, int]) -> None:
def sell(self, units: dict[UnitType, int]) -> None:
for k, v in units.items():
self.units[k] -= v
@@ -45,7 +45,15 @@ class PendingUnitDeliveries:
self.refund(game, self.units)
self.units = defaultdict(int)
def refund(self, game: Game, units: Dict[UnitType, int]) -> None:
def refund_ground_units(self, game: Game) -> None:
ground_units: dict[UnitType[Any], int] = {
u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType)
}
self.refund(game, ground_units)
for gu in ground_units.keys():
del self.units[gu]
def refund(self, game: Game, units: dict[UnitType, int]) -> None:
for unit_type, count in units.items():
logging.info(f"Refunding {count} {unit_type} at {self.destination.name}")
game.adjust_budget(
@@ -69,12 +77,11 @@ class PendingUnitDeliveries:
f"{self.destination.name} lost its source for ground unit "
"reinforcements. Refunding purchase price."
)
self.refund_all(game)
return
self.refund_ground_units(game)
bought_units: Dict[UnitType, int] = {}
units_needing_transfer: Dict[GroundUnitType, int] = {}
sold_units: Dict[UnitType, int] = {}
bought_units: dict[UnitType, int] = {}
units_needing_transfer: dict[GroundUnitType, int] = {}
sold_units: dict[UnitType, int] = {}
for unit_type, count in self.units.items():
coalition = "Ally" if self.destination.captured else "Enemy"
d: dict[Any, int]
@@ -102,11 +109,16 @@ class PendingUnitDeliveries:
self.destination.base.commit_losses(sold_units)
if units_needing_transfer:
if ground_unit_source is None:
raise RuntimeError(
f"ground unit source could not be found for {self.destination} but still tried to "
f"transfer units to there"
)
ground_unit_source.base.commission_units(units_needing_transfer)
self.create_transfer(game, ground_unit_source, units_needing_transfer)
def create_transfer(
self, game: Game, source: ControlPoint, units: Dict[GroundUnitType, int]
self, game: Game, source: ControlPoint, units: dict[GroundUnitType, int]
) -> None:
game.transfers.new_transfer(TransferOrder(source, self.destination, units))

View File

@@ -2,7 +2,7 @@ from pathlib import Path
def _build_version_string() -> str:
components = ["4.0.0"]
components = ["5.0.0"]
build_number_path = Path("resources/buildnumber")
if build_number_path.exists():
with build_number_path.open("r") as build_number_file:
@@ -90,4 +90,10 @@ VERSION = _build_version_string()
#:
#: Version 6.1
#: * Support for new Syrian airfields in DCS 2.7.2.7910.1 (Cyprus update).
CAMPAIGN_FORMAT_VERSION = (6, 1)
#:
#: Version 7.0
#: * DCS 2.7.2.7910.1 (Cyprus update) changed the IDs of scenery strike targets. Any
#: mission using map buildings as strike targets must check and potentially recreate
#: all those objectives. This definitely affects all Syria campaigns, other maps are
#: not yet verified.
CAMPAIGN_FORMAT_VERSION = (7, 0)