Use unique callsigns for each flight (#3445)

This PR partially addresses #1561 by automatically generating unique
callsigns for each flight.
This commit is contained in:
zhexu14 2024-10-06 15:19:16 +11:00 committed by GitHub
parent 0e9a8ac1a1
commit 25b93b5d6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 383 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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