diff --git a/changelog.md b/changelog.md index de02723b..62a2e1df 100644 --- a/changelog.md +++ b/changelog.md @@ -4,12 +4,14 @@ Saves from 11.x are not compatible with 12.0.0. ## Features/Improvements +* **[Engine]** Support for DCS 2.9.8.1214. * **[Campaign]** Removed deprecated settings for generating persistent and invulnerable AWACs and tankers. -* **[Mods]** F/A-18 E/F/G Super Hornet mod version updated to 2.3. * **[Campaign]** Do not allow aircraft from a captured control point to retreat if the captured control point has a damaged runway. +* **[Mods]** F/A-18 E/F/G Super Hornet mod version updated to 2.3. ## Fixes +* **[Campaign]** Flights are assigned different callsigns appropriate to the faction. # 11.1.1 diff --git a/game/ato/flight.py b/game/ato/flight.py index 86942f4b..8ece14f2 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -21,6 +21,7 @@ from ..sidc import ( ) if TYPE_CHECKING: + from game.callsigns.callsigngenerator import Callsign from game.dcs.aircrafttype import AircraftType from game.sim.gameupdateevents import GameUpdateEvents from game.sim.simulationresults import SimulationResults @@ -49,6 +50,7 @@ class Flight(SidcDescribable): custom_name: Optional[str] = None, cargo: Optional[TransferOrder] = None, roster: Optional[FlightRoster] = None, + callsign: Optional[Callsign] = None, ) -> None: self.id = uuid.uuid4() self.package = package @@ -69,6 +71,8 @@ class Flight(SidcDescribable): # Only used by transport missions. self.cargo = cargo + self.callsign = callsign + # Flight properties that can be set in the mission editor. This is used for # things like HMD selection, ripple quantity, etc. Any values set here will take # the place of the defaults defined by DCS. diff --git a/game/callsigns.py b/game/callsigns/callsign.py similarity index 100% rename from game/callsigns.py rename to game/callsigns/callsign.py diff --git a/game/callsigns/callsigngenerator.py b/game/callsigns/callsigngenerator.py new file mode 100644 index 00000000..caa2fac9 --- /dev/null +++ b/game/callsigns/callsigngenerator.py @@ -0,0 +1,248 @@ +from __future__ import annotations +from abc import ABC +from dataclasses import dataclass +from enum import StrEnum + +from collections import deque +from typing import Any, List, Optional + +from dcs.country import Country +from dcs.countries import countries_by_name + +from game.ato.flight import Flight +from game.ato.flighttype import FlightType + + +MAX_GROUP_ID = 99 + + +class CallsignCategory(StrEnum): + AIR = "Air" + TANKERS = "Tankers" + AWACS = "AWACS" + GROUND_UNITS = "GroundUnits" + HELIPADS = "Helipad" + GRASS_AIRFIELDS = "GrassAirfield" + + +@dataclass(frozen=True) +class Callsign: + name: Optional[ + str + ] # Callsign name e.g. "Enfield" for western callsigns. None for eastern callsigns. + group_id: int # ID of the group e.g. 2 in Enfield-2-3 for western callsigns. First two digits of eastern callsigns. + unit_id: int # ID of the unit e.g. 3 in Enfield-2-3 for western callsigns. Last digit of eastern callsigns. + + def __post_init__(self) -> None: + if self.group_id < 1 or self.group_id > MAX_GROUP_ID: + raise ValueError( + f"Invalid group ID {self.group_id}. Group IDs have to be between 1 and {MAX_GROUP_ID}." + ) + if self.unit_id < 1 or self.unit_id > 9: + raise ValueError( + f"Invalid unit ID {self.unit_id}. Unit IDs have to be between 1 and 9." + ) + + def __str__(self) -> str: + if self.name is not None: + return f"{self.name}{self.group_id}{self.unit_id}" + else: + return str(self.group_id * 10 + self.unit_id) + + def lead_callsign(self) -> Callsign: + return Callsign(self.name, self.group_id, 1) + + def unit_callsign(self, unit_id: int) -> Callsign: + return Callsign(self.name, self.group_id, unit_id) + + def group_name(self) -> str: + if self.name is not None: + return f"{self.name}-{self.group_id}" + else: + return str(self.lead_callsign()) + + def pydcs_dict(self, country: str) -> dict[Any, Any]: + country_obj = countries_by_name[country]() + for category in CallsignCategory: + if category in country_obj.callsign: + for index, name in enumerate(country_obj.callsign[category]): + if name == self.name: + return { + "name": str(self), + 1: index + 1, + 2: self.group_id, + 3: self.unit_id, + } + raise ValueError(f"Could not find callsign {name} in {country}.") + + +class WesternGroupIdRegistry: + + def __init__(self, country: Country, max_group_id: int = MAX_GROUP_ID): + self._names: dict[str, deque[int]] = {} + for category in CallsignCategory: + if category in country.callsign: + for name in country.callsign[category]: + self._names[name] = deque() + self._max_group_id = max_group_id + self.reset() + + def reset(self) -> None: + for name in self._names: + self._names[name] = deque() + for i in range( + self._max_group_id, 0, -1 + ): # Put group IDs on FIFO queue so 1 gets popped first + self._names[name].appendleft(i) + + def alloc_group_id(self, name: str) -> int: + return self._names[name].popleft() + + def release_group_id(self, callsign: Callsign) -> None: + if callsign.name is None: + raise ValueError("Releasing eastern callsign") + self._names[callsign.name].appendleft(callsign.group_id) + + +class EasternGroupIdRegistry: + + def __init__(self, max_group_id: int = MAX_GROUP_ID): + self._max_group_id = max_group_id + self._queue: deque[int] = deque() + self.reset() + + def reset(self) -> None: + self._queue = deque() + for i in range( + self._max_group_id, 0, -1 + ): # Put group IDs on FIFO queue so 1 gets popped first + self._queue.appendleft(i) + + def alloc_group_id(self) -> int: + return self._queue.popleft() + + def release_group_id(self, callsign: Callsign) -> None: + self._queue.appendleft(callsign.group_id) + + +class RoundRobinNameAllocator: + + def __init__(self, names: List[str]): + self.names = names + self._index = 0 + + def allocate(self) -> str: + this_index = self._index + if this_index == len(self.names) - 1: + self._index = 0 + else: + self._index += 1 + return self.names[this_index] + + +class FlightTypeNameAllocator: + def __init__(self, names: List[str]): + self.names = names + + def allocate(self, flight: Flight) -> str: + index = self.FLIGHT_TYPE_LOOKUP.get(flight.flight_type, 0) + return self.names[index] + + FLIGHT_TYPE_LOOKUP: dict[FlightType, int] = { + FlightType.TARCAP: 1, + FlightType.BARCAP: 1, + FlightType.INTERCEPTION: 1, + FlightType.SWEEP: 1, + FlightType.CAS: 2, + FlightType.ANTISHIP: 2, + FlightType.BAI: 2, + FlightType.STRIKE: 3, + FlightType.OCA_RUNWAY: 3, + FlightType.OCA_AIRCRAFT: 3, + FlightType.SEAD: 4, + FlightType.DEAD: 4, + FlightType.ESCORT: 5, + FlightType.AIR_ASSAULT: 6, + FlightType.TRANSPORT: 7, + FlightType.FERRY: 7, + } + + +class WesternFlightCallsignGenerator: + """Generate western callsign for lead unit in a group""" + + def __init__(self, country: str) -> None: + self._country = countries_by_name[country]() + self._group_id_registry = WesternGroupIdRegistry(self._country) + self._awacs_name_allocator = None + self._tankers_name_allocator = None + + if CallsignCategory.AWACS in self._country.callsign: + self._awacs_name_allocator = RoundRobinNameAllocator( + self._country.callsign[CallsignCategory.AWACS] + ) + if CallsignCategory.TANKERS in self._country.callsign: + self._tankers_name_allocator = RoundRobinNameAllocator( + self._country.callsign[CallsignCategory.TANKERS] + ) + self._air_name_allocator = FlightTypeNameAllocator( + self._country.callsign[CallsignCategory.AIR] + ) + + def reset(self) -> None: + self._group_id_registry.reset() + + def alloc_callsign(self, flight: Flight) -> Callsign: + if flight.flight_type == FlightType.AEWC: + if self._awacs_name_allocator is None: + raise ValueError(f"{self._country.name} does not have AWACs callsigns") + name = self._awacs_name_allocator.allocate() + elif flight.flight_type == FlightType.REFUELING: + if self._tankers_name_allocator is None: + raise ValueError(f"{self._country.name} does not have tanker callsigns") + name = self._tankers_name_allocator.allocate() + else: + name = self._air_name_allocator.allocate(flight) + group_id = self._group_id_registry.alloc_group_id(name) + return Callsign(name, group_id, 1) + + def release_callsign(self, callsign: Callsign) -> None: + self._group_id_registry.release_group_id(callsign) + + +class EasternFlightCallsignGenerator: + """Generate eastern callsign for lead unit in a group""" + + def __init__(self) -> None: + self._group_id_registry = EasternGroupIdRegistry() + + def reset(self) -> None: + self._group_id_registry.reset() + + def alloc_callsign(self, flight: Flight) -> Callsign: + group_id = self._group_id_registry.alloc_group_id() + return Callsign(None, group_id, 1) + + def release_callsign(self, callsign: Callsign) -> None: + self._group_id_registry.release_group_id(callsign) + + +class FlightCallsignGenerator: + + def __init__(self, country: str): + self._generators: dict[ + bool, WesternFlightCallsignGenerator | EasternFlightCallsignGenerator + ] = { + True: WesternFlightCallsignGenerator(country), + False: EasternFlightCallsignGenerator(), + } + self._use_western_callsigns = countries_by_name[country]().use_western_callsigns + + def reset(self) -> None: + self._generators[self._use_western_callsigns].reset() + + def alloc_callsign(self, flight: Flight) -> Callsign: + return self._generators[self._use_western_callsigns].alloc_callsign(flight) + + def release_callsign(self, callsign: Callsign) -> None: + self._generators[self._use_western_callsigns].release_callsign(callsign) diff --git a/game/coalition.py b/game/coalition.py index f8abad92..f4eb2ae2 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -7,6 +7,7 @@ from faker import Faker from game.armedforces.armedforces import ArmedForces from game.ato.airtaaskingorder import AirTaskingOrder +from game.callsigns.callsigngenerator import FlightCallsignGenerator from game.campaignloader.defaultsquadronassigner import DefaultSquadronAssigner from game.commander import TheaterCommander from game.commander.missionscheduler import MissionScheduler @@ -46,6 +47,7 @@ class Coalition: self.air_wing = AirWing(player, game, self.faction) self.armed_forces = ArmedForces(self.faction) self.transfers = PendingTransfers(game, player) + self.callsign_generator = FlightCallsignGenerator(faction.country) # Late initialized because the two coalitions in the game are mutually # dependent, so must be both constructed before this property can be set. @@ -163,6 +165,8 @@ class Coalition: # is handled correctly. self.transfers.perform_transfers() + self.callsign_generator.reset() + def preinit_turn_0(self) -> None: """Runs final Coalition initialization. diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py index 2aea54ca..ad20eb72 100644 --- a/game/commander/packagebuilder.py +++ b/game/commander/packagebuilder.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Optional, TYPE_CHECKING +from game.callsigns.callsigngenerator import FlightCallsignGenerator from game.theater import ControlPoint, MissionTarget, OffMapSpawn from game.utils import nautical_miles from ..ato.flight import Flight @@ -26,6 +27,7 @@ class PackageBuilder: closest_airfields: ClosestAirfields, air_wing: AirWing, laser_code_registry: LaserCodeRegistry, + callsign_generator: FlightCallsignGenerator, flight_db: Database[Flight], is_player: bool, package_country: str, @@ -38,6 +40,7 @@ class PackageBuilder: self.package = Package(location, flight_db, auto_asap=asap) self.air_wing = air_wing self.laser_code_registry = laser_code_registry + self.callsign_generator = callsign_generator self.start_type = start_type def plan_flight(self, plan: ProposedFlight) -> bool: @@ -71,6 +74,7 @@ class PackageBuilder: member.assign_tgp_laser_code( self.laser_code_registry.alloc_laser_code() ) + flight.callsign = self.callsign_generator.alloc_callsign(flight) self.package.add_flight(flight) return True diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index 7e66eafa..b18f9dd8 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -142,6 +142,7 @@ class PackageFulfiller: ObjectiveDistanceCache.get_closest_airfields(mission.location), self.air_wing, self.coalition.laser_code_registry, + self.coalition.callsign_generator, self.flight_db, self.is_player, self.coalition.country_name, diff --git a/game/missiongenerator/aircraft/flightdata.py b/game/missiongenerator/aircraft/flightdata.py index 6d55813e..f80ae988 100644 --- a/game/missiongenerator/aircraft/flightdata.py +++ b/game/missiongenerator/aircraft/flightdata.py @@ -6,7 +6,7 @@ from typing import Optional, TYPE_CHECKING from dcs.flyingunit import FlyingUnit -from game.callsigns import create_group_callsign_from_unit +from game.callsigns.callsign import create_group_callsign_from_unit if TYPE_CHECKING: from game.ato import FlightType, FlightWaypoint, Package diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index 11bc26ad..951d3756 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -10,7 +10,8 @@ from dcs.unit import Skill from dcs.unitgroup import FlyingGroup from game.ato import Flight, FlightType -from game.callsigns import callsign_for_support_unit +from game.callsigns.callsign import callsign_for_support_unit +from game.callsigns.callsigngenerator import Callsign, FlightCallsignGenerator from game.data.weapons import Pylon from game.missiongenerator.logisticsgenerator import LogisticsGenerator from game.missiongenerator.missiondata import AwacsInfo, MissionData, TankerInfo @@ -115,6 +116,8 @@ class FlightGroupConfigurator: self.flight.flight_plan.waypoints, ) + self.set_callsigns() + return FlightData( package=self.flight.package, aircraft_type=self.flight.unit_type, @@ -269,3 +272,17 @@ class FlightGroupConfigurator: # our own tracking, so undo that. # https://github.com/pydcs/dcs/commit/303a81a8e0c778599fe136dd22cb2ae8123639a6 unit.fuel = self.flight.unit_type.dcs_unit_type.fuel_max + + def set_callsigns(self) -> None: + if self.flight.callsign is None: + return + for unit_index, unit in enumerate(self.group.units): + unit_callsign = self.flight.callsign.unit_callsign(unit_index + 1) + if ( + unit_callsign.name is None + ): # pydcs needs unit.callsign to be set for eastern callsigns + unit.callsign = str(unit_callsign) # type: ignore + else: # Use western callsign + unit.callsign_dict = unit_callsign.pydcs_dict( + country=self.flight.country + ) diff --git a/game/missiongenerator/flotgenerator.py b/game/missiongenerator/flotgenerator.py index 3be5fc4c..4c3d5c78 100644 --- a/game/missiongenerator/flotgenerator.py +++ b/game/missiongenerator/flotgenerator.py @@ -27,7 +27,7 @@ from dcs.triggers import Event, TriggerOnce from dcs.unit import Skill, Vehicle from dcs.unitgroup import VehicleGroup -from game.callsigns import callsign_for_support_unit +from game.callsigns.callsign import callsign_for_support_unit from game.data.units import UnitClass from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType diff --git a/qt_ui/models.py b/qt_ui/models.py index 683a6292..a609a290 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -177,6 +177,9 @@ class PackageModel(QAbstractListModel): """Removes the given flight from the package.""" with self.game_model.sim_controller.paused_sim(): index = self.package.flights.index(flight) + self.game_model.game.blue.callsign_generator.release_callsign( + flight.callsign + ) self.beginRemoveRows(QModelIndex(), index, index) self.package.remove_flight(flight) self.endRemoveRows() @@ -302,6 +305,10 @@ class AtoModel(QAbstractListModel): self.package_models.release(package) index = self.ato.packages.index(package) self.beginRemoveRows(QModelIndex(), index, index) + for flight in package.flights: + self.game_model.game.blue.callsign_generator.release_callsign( + flight.callsign + ) self.ato.remove_package(package) self.endRemoveRows() # noinspection PyUnresolvedReferences diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 641a65f8..20207b07 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -202,6 +202,8 @@ class QFlightCreator(QDialog): self.game.laser_code_registry.alloc_laser_code() ) + flight.callsign = self.game.blue.callsign_generator.alloc_callsign(flight) + # noinspection PyUnresolvedReferences self.created.emit(flight) self.accept() diff --git a/qt_ui/windows/mission/flight/settings/QFlightTypeTaskInfo.py b/qt_ui/windows/mission/flight/settings/QFlightTypeTaskInfo.py index 4aa1fbe9..56891786 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightTypeTaskInfo.py +++ b/qt_ui/windows/mission/flight/settings/QFlightTypeTaskInfo.py @@ -18,9 +18,19 @@ class QFlightTypeTaskInfo(QGroupBox): self.task_type = QLabel(str(flight.flight_type)) self.task_type.setProperty("style", flight.flight_type.name) + self.callsign_label = QLabel("Flight Lead Callsign:") + if flight.callsign is not None: + callsign = flight.callsign.group_name() + else: + callsign = "" + self.callsign = QLabel(callsign) + layout.addWidget(self.aircraft_icon, 0, 0) layout.addWidget(self.task, 1, 0) layout.addWidget(self.task_type, 1, 1) + layout.addWidget(self.callsign_label, 2, 0) + layout.addWidget(self.callsign, 2, 1) + self.setLayout(layout) diff --git a/requirements.txt b/requirements.txt index a07cd7bf..314fa55d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,7 +36,7 @@ pre-commit==3.5.0 pydantic==2.5.2 pydantic-settings==2.1.0 pydantic_core==2.14.5 -pydcs @ git+https://github.com/dcs-liberation/dcs@47b524e3cfaa945d1f42717ee00f949b3354125c +pydcs @ git+https://github.com/dcs-liberation/dcs@52b1a3510829dd46d52bbb79ba18e85e8608f25c pyinstaller==5.13.1 pyinstaller-hooks-contrib==2023.6 pyproj==3.6.1 diff --git a/tests/callsigns/test_callsign_generator.py b/tests/callsigns/test_callsign_generator.py new file mode 100644 index 00000000..d113d5aa --- /dev/null +++ b/tests/callsigns/test_callsign_generator.py @@ -0,0 +1,79 @@ +import pytest + +from dcs.countries import countries_by_name + +from game.callsigns.callsigngenerator import ( + Callsign, + EasternGroupIdRegistry, + WesternGroupIdRegistry, + RoundRobinNameAllocator, +) + + +def test_callsign() -> None: + + valid_callsign = Callsign("Enfield", 2, 3) + assert str(valid_callsign) == "Enfield23" + assert valid_callsign.group_name() == "Enfield-2" + assert valid_callsign.pydcs_dict("USA") == {"name": "Enfield23", 1: 1, 2: 2, 3: 3} + + # Invalid callsign, group ID too large. + with pytest.raises(ValueError): + Callsign("Enfield", 1000, 3) + + # Invalid callsign, group ID zero. + with pytest.raises(ValueError): + Callsign("Enfield", 0, 3) + + # Invalid callsign, unit ID zero. + with pytest.raises(ValueError): + Callsign("Enfield", 1, 0) + + # Invalid callsign, unit ID too large. + with pytest.raises(ValueError): + Callsign("Enfield", 1, 11) + + +def test_western_group_id_registry() -> None: + registry = WesternGroupIdRegistry(countries_by_name["USA"]()) + + # Check registry increments group IDs. + assert registry.alloc_group_id("Enfield") == 1 + assert registry.alloc_group_id("Enfield") == 2 + + # Check allocation on a new name Springfield. + assert registry.alloc_group_id("Springfield") == 1 + + # Check release of Enfield-1. + registry.release_group_id(Callsign("Enfield", 1, 1)) + assert registry.alloc_group_id("Enfield") == 1 + + # Reset and check allocation og Enfield-1 and Springfield-1. + registry.reset() + assert registry.alloc_group_id("Enfield") == 1 + assert registry.alloc_group_id("Springfield") == 1 + + +def test_eastern_group_id_registry() -> None: + registry = EasternGroupIdRegistry() + + # Check registry increments group IDs. + assert registry.alloc_group_id() == 1 + assert registry.alloc_group_id() == 2 + + # Check release. + registry.release_group_id(Callsign(None, 1, 1)) + assert registry.alloc_group_id() == 1 + + # Reset and check allocation. + registry.reset() + assert registry.alloc_group_id() == 1 + + +def test_round_robin_allocator() -> None: + allocator = RoundRobinNameAllocator(["A", "B", "C"]) + + assert allocator.allocate() == "A" + assert allocator.allocate() == "B" + assert allocator.allocate() == "C" + assert allocator.allocate() == "A"