Doctrine load from YAML (#3291)

This PR refactors the Doctrine class to load from YAML files in the
resources folder instead of being hardcoded as a step towards making
doctrines moddable (Issue #829).

I haven't added anything to the changelog as a couple of things should
get cleaned up first:
- As far as I can tell, the flags in the Doctrine class (cap, cas, sead
etc.) aren't used anywhere. Need to test further, and if they're truly
not used, will remove them.
- Probably need to update the Wiki
This commit is contained in:
zhexu14 2023-12-18 13:42:31 +11:00 committed by GitHub
parent a213215c3f
commit 4631ee0d74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 235 additions and 143 deletions

View File

@ -1,3 +1,9 @@
from __future__ import annotations
from pathlib import Path
import yaml
from typing import ClassVar
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
@ -15,6 +21,16 @@ class GroundUnitProcurementRatios:
except KeyError: except KeyError:
return 0.0 return 0.0
@staticmethod
def from_dict(data: dict[str, float]) -> GroundUnitProcurementRatios:
unit_class_enum_from_name = {unit.value: unit for unit in UnitClass}
r = {}
for unit_class in data:
if unit_class not in unit_class_enum_from_name:
raise ValueError(f"Could not find unit type {unit_class}")
r[unit_class_enum_from_name[unit_class]] = float(data[unit_class])
return GroundUnitProcurementRatios(r)
@dataclass(frozen=True) @dataclass(frozen=True)
class Doctrine: class Doctrine:
@ -79,122 +95,78 @@ class Doctrine:
ground_unit_procurement_ratios: GroundUnitProcurementRatios ground_unit_procurement_ratios: GroundUnitProcurementRatios
_by_name: ClassVar[dict[str, Doctrine]] = {}
_loaded: ClassVar[bool] = False
MODERN_DOCTRINE = Doctrine( @classmethod
"modern", def register(cls, doctrine: Doctrine) -> None:
cap=True, if doctrine.name in cls._by_name:
cas=True, duplicate = cls._by_name[doctrine.name]
sead=True, raise ValueError(f"Doctrine {doctrine.name} is already loaded")
strike=True, cls._by_name[doctrine.name] = doctrine
antiship=True,
rendezvous_altitude=feet(25000), @classmethod
hold_distance=nautical_miles(25), def named(cls, name: str) -> Doctrine:
push_distance=nautical_miles(20), if not cls._loaded:
join_distance=nautical_miles(20), cls.load_all()
max_ingress_distance=nautical_miles(45), return cls._by_name[name]
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(20000), @classmethod
min_patrol_altitude=feet(15000), def all_doctrines(cls) -> list[Doctrine]:
max_patrol_altitude=feet(33000), if not cls._loaded:
pattern_altitude=feet(5000), cls.load_all()
cap_duration=timedelta(minutes=30), return list(cls._by_name.values())
cap_min_track_length=nautical_miles(15),
cap_max_track_length=nautical_miles(40), @classmethod
cap_min_distance_from_cp=nautical_miles(10), def load_all(cls) -> None:
cap_max_distance_from_cp=nautical_miles(40), if cls._loaded:
cap_engagement_range=nautical_miles(50), return
cas_duration=timedelta(minutes=30), for doctrine_file_path in Path("resources/doctrines").glob("**/*.yaml"):
sweep_distance=nautical_miles(60), with doctrine_file_path.open(encoding="utf8") as doctrine_file:
ground_unit_procurement_ratios=GroundUnitProcurementRatios( data = yaml.safe_load(doctrine_file)
{ cls.register(
UnitClass.TANK: 3, Doctrine(
UnitClass.ATGM: 2, name=data["name"],
UnitClass.APC: 2, cap=data["cap"],
UnitClass.IFV: 3, cas=data["cas"],
UnitClass.ARTILLERY: 1, sead=data["sead"],
UnitClass.SHORAD: 2, strike=data["strike"],
UnitClass.RECON: 1, antiship=data["antiship"],
} rendezvous_altitude=feet(data["rendezvous_altitude_ft_msl"]),
hold_distance=nautical_miles(data["hold_distance_nm"]),
push_distance=nautical_miles(data["push_distance_nm"]),
join_distance=nautical_miles(data["join_distance_nm"]),
max_ingress_distance=nautical_miles(
data["max_ingress_distance_nm"]
), ),
) min_ingress_distance=nautical_miles(
data["min_ingress_distance_nm"]
COLDWAR_DOCTRINE = Doctrine(
name="coldwar",
cap=True,
cas=True,
sead=True,
strike=True,
antiship=True,
rendezvous_altitude=feet(22000),
hold_distance=nautical_miles(15),
push_distance=nautical_miles(10),
join_distance=nautical_miles(10),
max_ingress_distance=nautical_miles(30),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(18000),
min_patrol_altitude=feet(10000),
max_patrol_altitude=feet(24000),
pattern_altitude=feet(5000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(12),
cap_max_track_length=nautical_miles(24),
cap_min_distance_from_cp=nautical_miles(8),
cap_max_distance_from_cp=nautical_miles(25),
cap_engagement_range=nautical_miles(35),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(40),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
UnitClass.TANK: 4,
UnitClass.ATGM: 2,
UnitClass.APC: 3,
UnitClass.IFV: 2,
UnitClass.ARTILLERY: 1,
UnitClass.SHORAD: 2,
UnitClass.RECON: 1,
}
), ),
) ingress_altitude=feet(data["ingress_altitude_ft_msl"]),
min_patrol_altitude=feet(data["min_patrol_altitude_ft_msl"]),
WWII_DOCTRINE = Doctrine( max_patrol_altitude=feet(data["max_patrol_altitude_ft_msl"]),
name="ww2", pattern_altitude=feet(data["pattern_altitude_ft_msl"]),
cap=True, cap_duration=timedelta(minutes=data["cap_duration_minutes"]),
cas=True, cap_min_track_length=nautical_miles(
sead=False, data["cap_min_track_length_nm"]
strike=True,
antiship=True,
hold_distance=nautical_miles(10),
push_distance=nautical_miles(5),
join_distance=nautical_miles(5),
rendezvous_altitude=feet(10000),
max_ingress_distance=nautical_miles(7),
min_ingress_distance=nautical_miles(5),
ingress_altitude=feet(8000),
min_patrol_altitude=feet(4000),
max_patrol_altitude=feet(15000),
pattern_altitude=feet(5000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(8),
cap_max_track_length=nautical_miles(18),
cap_min_distance_from_cp=nautical_miles(0),
cap_max_distance_from_cp=nautical_miles(5),
cap_engagement_range=nautical_miles(20),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(10),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
UnitClass.TANK: 3,
UnitClass.ATGM: 3,
UnitClass.APC: 3,
UnitClass.ARTILLERY: 1,
UnitClass.SHORAD: 3,
UnitClass.RECON: 1,
}
), ),
) cap_max_track_length=nautical_miles(
data["cap_max_track_length_nm"]
ALL_DOCTRINES = [ ),
COLDWAR_DOCTRINE, cap_min_distance_from_cp=nautical_miles(
MODERN_DOCTRINE, data["cap_min_distance_from_cp_nm"]
WWII_DOCTRINE, ),
] cap_max_distance_from_cp=nautical_miles(
data["cap_max_distance_from_cp_nm"]
),
cap_engagement_range=nautical_miles(
data["cap_engagement_range_nm"]
),
cas_duration=timedelta(minutes=data["cas_duration_minutes"]),
sweep_distance=nautical_miles(data["sweep_distance_nm"]),
ground_unit_procurement_ratios=GroundUnitProcurementRatios.from_dict(
data["ground_unit_procurement_ratios"]
),
)
)
cls._loaded = True

View File

@ -19,12 +19,7 @@ from game.data.building_data import (
WW2_FREE, WW2_FREE,
WW2_GERMANY_BUILDINGS, WW2_GERMANY_BUILDINGS,
) )
from game.data.doctrine import ( from game.data.doctrine import Doctrine
COLDWAR_DOCTRINE,
Doctrine,
MODERN_DOCTRINE,
WWII_DOCTRINE,
)
from game.data.groups import GroupRole from game.data.groups import GroupRole
from game.data.units import UnitClass from game.data.units import UnitClass
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
@ -106,7 +101,7 @@ class Faction:
jtac_unit: Optional[AircraftType] = field(default=None) jtac_unit: Optional[AircraftType] = field(default=None)
# doctrine # doctrine
doctrine: Doctrine = field(default=MODERN_DOCTRINE) doctrine: Doctrine = field(default=Doctrine.named("modern"))
# List of available building layouts for this faction # List of available building layouts for this faction
building_set: List[str] = field(default_factory=list) building_set: List[str] = field(default_factory=list)
@ -238,14 +233,7 @@ class Faction:
# Load doctrine # Load doctrine
doctrine = json.get("doctrine", "modern") doctrine = json.get("doctrine", "modern")
if doctrine == "modern": faction.doctrine = Doctrine.named(doctrine)
faction.doctrine = MODERN_DOCTRINE
elif doctrine == "coldwar":
faction.doctrine = COLDWAR_DOCTRINE
elif doctrine == "ww2":
faction.doctrine = WWII_DOCTRINE
else:
faction.doctrine = MODERN_DOCTRINE
# Load the building set # Load the building set
faction.building_set = [] faction.building_set = []

View File

@ -11,17 +11,14 @@ from shapely import transform
from shapely.geometry import shape from shapely.geometry import shape
from shapely.geometry.base import BaseGeometry from shapely.geometry.base import BaseGeometry
from game.data.doctrine import Doctrine, ALL_DOCTRINES from game.data.doctrine import Doctrine
from .ipsolver import IpSolver from .ipsolver import IpSolver
from .waypointsolver import WaypointSolver from .waypointsolver import WaypointSolver
from ..theater.theaterloader import TERRAINS_BY_NAME from ..theater.theaterloader import TERRAINS_BY_NAME
def doctrine_from_name(name: str) -> Doctrine: def doctrine_from_name(name: str) -> Doctrine:
for doctrine in ALL_DOCTRINES: return Doctrine.named(name)
if doctrine.name == name:
return doctrine
raise KeyError
def geometry_ll_to_xy(geometry: BaseGeometry, terrain: Terrain) -> BaseGeometry: def geometry_ll_to_xy(geometry: BaseGeometry, terrain: Terrain) -> BaseGeometry:

View File

@ -0,0 +1,32 @@
name: coldwar
cap: true
cas: true
sead: true
strike: true
antiship: true
rendezvous_altitude_ft_msl: 22000
hold_distance_nm: 15
push_distance_nm: 10
join_distance_nm: 10
max_ingress_distance_nm: 30
min_ingress_distance_nm: 10
ingress_altitude_ft_msl: 18000
min_patrol_altitude_ft_msl: 10000
max_patrol_altitude_ft_msl: 24000
pattern_altitude_ft_msl: 5000
cap_duration_minutes: 30
cap_min_track_length_nm: 12
cap_max_track_length_nm: 24
cap_min_distance_from_cp_nm: 8
cap_max_distance_from_cp_nm: 25
cap_engagement_range_nm: 35
cas_duration_minutes: 30
sweep_distance_nm: 40
ground_unit_procurement_ratios:
Tank: 4
ATGM: 2
APC: 3
IFV: 2
Artillery: 1
SHORAD: 2
Recon: 1

View File

@ -0,0 +1,32 @@
name: modern
cap: true
cas: true
sead: true
strike: true
antiship: true
rendezvous_altitude_ft_msl: 25000
hold_distance_nm: 25
push_distance_nm: 20
join_distance_nm: 20
max_ingress_distance_nm: 45
min_ingress_distance_nm: 10
ingress_altitude_ft_msl: 20000
min_patrol_altitude_ft_msl: 15000
max_patrol_altitude_ft_msl: 33000
pattern_altitude_ft_msl: 5000
cap_duration_minutes: 30
cap_min_track_length_nm: 15
cap_max_track_length_nm: 40
cap_min_distance_from_cp_nm: 10
cap_max_distance_from_cp_nm: 40
cap_engagement_range_nm: 50
cas_duration_minutes: 30
sweep_distance_nm: 60
ground_unit_procurement_ratios:
Tank: 3
ATGM: 2
APC: 2
IFV: 3
Artillery: 1
SHORAD: 2
Recon: 1

View File

@ -0,0 +1,31 @@
name: ww2
cap: true
cas: true
sead: false
strike: true
antiship: true
hold_distance_nm: 10
push_distance_nm: 5
join_distance_nm: 5
rendezvous_altitude_ft_msl: 10000
max_ingress_distance_nm: 7
min_ingress_distance_nm: 5
ingress_altitude_ft_msl: 8000
min_patrol_altitude_ft_msl: 4000
max_patrol_altitude_ft_msl: 15000
pattern_altitude_ft_msl: 5000
cap_duration_minutes: 30
cap_min_track_length_nm: 8
cap_max_track_length_nm: 18
cap_min_distance_from_cp_nm: 0
cap_max_distance_from_cp_nm: 5
cap_engagement_range_nm: 20
cas_duration_minutes: 30
sweep_distance_nm: 10
ground_unit_procurement_ratios:
Tank: 3
ATGM: 3
APC: 3
Artillery: 1
SHORAD: 3
Recon: 1

View File

@ -0,0 +1,43 @@
import pytest
from game.data.doctrine import Doctrine, GroundUnitProcurementRatios
from game.data.units import UnitClass
def test_ground_unit_procurement_ratios_empty() -> None:
r = GroundUnitProcurementRatios({})
for unit_class in UnitClass:
assert r.for_unit_class(unit_class) == 0.0
def test_ground_unit_procurement_ratios_single_item() -> None:
r = GroundUnitProcurementRatios({UnitClass.TANK: 1})
for unit_class in UnitClass:
if unit_class == UnitClass.TANK:
assert r.for_unit_class(unit_class) == 1.0
else:
assert r.for_unit_class(unit_class) == 0.0
def test_ground_unit_procurement_ratios_multiple_items() -> None:
r = GroundUnitProcurementRatios({UnitClass.TANK: 1, UnitClass.ATGM: 1})
for unit_class in UnitClass:
if unit_class in [UnitClass.TANK, UnitClass.ATGM]:
assert r.for_unit_class(unit_class) == 0.5
else:
assert r.for_unit_class(unit_class) == 0.0
def test_ground_unit_procurement_ratios_from_dict() -> None:
r = GroundUnitProcurementRatios.from_dict({"Tank": 1, "ATGM": 1})
for unit_class in UnitClass:
if unit_class in [UnitClass.TANK, UnitClass.ATGM]:
assert r.for_unit_class(unit_class) == 0.5
else:
assert r.for_unit_class(unit_class) == 0.0
def test_doctrine() -> None:
# This test checks for the presence of a doctrine named "modern" as this doctrine is used as a default
modern_doctrine = Doctrine.named("modern")
assert modern_doctrine.name == "modern"

View File

@ -8,7 +8,7 @@ import pytest
from dcs.terrain import Caucasus from dcs.terrain import Caucasus
from shapely import Point, MultiPolygon, Polygon, unary_union from shapely import Point, MultiPolygon, Polygon, unary_union
from game.data.doctrine import ALL_DOCTRINES from game.data.doctrine import Doctrine
from game.flightplan.ipsolver import IpSolver from game.flightplan.ipsolver import IpSolver
from game.flightplan.waypointsolver import NoSolutionsError from game.flightplan.waypointsolver import NoSolutionsError
from game.flightplan.waypointstrategy import point_at_heading from game.flightplan.waypointstrategy import point_at_heading
@ -70,7 +70,7 @@ def fuzzed_solver_fixture(
departure = Point(0, 0) departure = Point(0, 0)
target = point_at_heading(departure, target_heading, fuzzed_target_distance) target = point_at_heading(departure, target_heading, fuzzed_target_distance)
solver = IpSolver( solver = IpSolver(
departure, target, random.choice(ALL_DOCTRINES), fuzzed_threat_poly departure, target, random.choice(Doctrine.all_doctrines()), fuzzed_threat_poly
) )
solver.set_debug_properties(tmp_path, Caucasus()) solver.set_debug_properties(tmp_path, Caucasus())
return solver return solver
@ -98,4 +98,4 @@ def test_fuzz_ipsolver(fuzzed_solver: IpSolver, run_number: int) -> None:
def test_can_construct_solver_with_empty_threat() -> None: def test_can_construct_solver_with_empty_threat() -> None:
IpSolver(Point(0, 0), Point(0, 0), ALL_DOCTRINES[0], MultiPolygon([])) IpSolver(Point(0, 0), Point(0, 0), Doctrine.named("coldwar"), MultiPolygon([]))

View File

@ -8,7 +8,7 @@ from typing import Any, TypeVar, Generic
from shapely import Point, MultiPolygon from shapely import Point, MultiPolygon
from shapely.geometry import shape from shapely.geometry import shape
from game.data.doctrine import Doctrine, ALL_DOCTRINES from game.data.doctrine import Doctrine
from game.flightplan.ipsolver import IpSolver from game.flightplan.ipsolver import IpSolver
from game.flightplan.waypointsolver import WaypointSolver, NoSolutionsError from game.flightplan.waypointsolver import WaypointSolver, NoSolutionsError
from game.flightplan.waypointsolverloader import WaypointSolverLoader from game.flightplan.waypointsolverloader import WaypointSolverLoader
@ -18,10 +18,7 @@ ReducerT = TypeVar("ReducerT")
def doctrine_from_name(name: str) -> Doctrine: def doctrine_from_name(name: str) -> Doctrine:
for doctrine in ALL_DOCTRINES: return Doctrine.named(name)
if doctrine.name == name:
return doctrine
raise KeyError
class Reducer(Generic[ReducerT], Iterator[ReducerT], ABC): class Reducer(Generic[ReducerT], Iterator[ReducerT], ABC):