Add a new miz file based campaign generator.

Defining a campaign using a miz file instead of as JSON has a number of
advantages:

* Much easier for players to mod their campaigns.
* Easier to see the big picture of how objective locations will be laid
  out, since every control point can be seen at once.
* No need to associate objective locations to control points explicitly;
  the campaign generator can claim objectives for control points based
  on distance.
* Easier to create an IADS that performs well.
* Non-random campaigns are easier to make.

The downside is duplication across campaigns, and a less structured data
format for complex objects. The former is annoying if we have to fix a
bug that appears in a dozen campaigns. It's less an annoyance for
needing to start from scratch since the easiest way to create a campaign
will be to copy the "full" campaign for the given theater and prune it.

So far I've implemented control points, base defenses, and front lines.
Still need to add support for non-base defense TGOs.

This currently doesn't do anything for the `radials` property of the
`ControlPoint` because I'm not sure what those are.
This commit is contained in:
Dan Albert 2020-11-17 20:17:29 -08:00
parent 20f97e48a9
commit df80ec635f
6 changed files with 322 additions and 118 deletions

View File

@ -1,13 +1,22 @@
from __future__ import annotations
import logging
import itertools
import json
import logging
from dataclasses import dataclass
from functools import cached_property
from itertools import tee
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
from dcs import Mission
from dcs.countries import (
CombinedJointTaskForcesBlue,
CombinedJointTaskForcesRed,
)
from dcs.country import Country
from dcs.mapping import Point
from dcs.ships import CVN_74_John_C__Stennis, LHA_1_Tarawa
from dcs.terrain import (
caucasus,
nevada,
@ -16,11 +25,14 @@ from dcs.terrain import (
syria,
thechannel,
)
from dcs.terrain.terrain import Terrain
from dcs.terrain.terrain import Airport, Terrain
from dcs.unitgroup import MovingGroup, ShipGroup, VehicleGroup
from dcs.vehicles import AirDefence, Armor
from gen.flights.flight import FlightType
from .controlpoint import ControlPoint, MissionTarget
from .landmap import Landmap, load_landmap, poly_contains
from ..utils import nm_to_meter
Numeric = Union[int, float]
@ -73,6 +85,193 @@ def pairwise(iterable):
return zip(a, b)
class MizCampaignLoader:
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
RED_COUNTRY = CombinedJointTaskForcesRed()
CV_UNIT_TYPE = CVN_74_John_C__Stennis.id
LHA_UNIT_TYPE = LHA_1_Tarawa.id
FRONT_LINE_UNIT_TYPE = Armor.APC_M113.id
EWR_UNIT_TYPE = AirDefence.EWR_55G6.id
SAM_UNIT_TYPE = AirDefence.SAM_SA_10_S_300PS_SR_64H6E.id
GARRISON_UNIT_TYPE = AirDefence.SAM_SA_19_Tunguska_2S6.id
BASE_DEFENSE_RADIUS = nm_to_meter(2)
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
self.theater = theater
self.mission = Mission()
self.mission.load_file(str(miz))
self.control_point_id = itertools.count(1000)
# If there are no red carriers there usually aren't red units. Make sure
# both countries are initialized so we don't have to deal with None.
if self.mission.country(self.BLUE_COUNTRY.name) is None:
self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY)
if self.mission.country(self.RED_COUNTRY.name) is None:
self.mission.coalition["red"].add_country(self.RED_COUNTRY)
@staticmethod
def control_point_from_airport(airport: Airport) -> ControlPoint:
# TODO: Radials?
radials = LAND
# The wiki says this is a legacy property and to just use regular.
size = SIZE_REGULAR
# The importance is taken from the periodicity of the airport's
# warehouse divided by 10. 30 is the default, and out of range (valid
# values are between 1.0 and 1.4). If it is used, pick the default
# importance.
if airport.periodicity == 30:
importance = IMPORTANCE_MEDIUM
else:
importance = airport.periodicity / 10
cp = ControlPoint.from_airport(airport, radials, size, importance)
cp.captured = airport.is_blue()
# Use the unlimited aircraft option to determine if an airfield should
# be owned by the player when the campaign is "inverted".
cp.captured_invert = airport.unlimited_aircrafts
return cp
def country(self, blue: bool) -> Country:
country = self.mission.country(
self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name)
# Should be guaranteed because we initialized them.
assert country
return country
@property
def blue(self) -> Country:
return self.country(blue=True)
@property
def red(self) -> Country:
return self.country(blue=False)
def carriers(self, blue: bool) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.CV_UNIT_TYPE:
yield group
def lhas(self, blue: bool) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.LHA_UNIT_TYPE:
yield group
@property
def ewrs(self) -> Iterator[VehicleGroup]:
for group in self.blue.vehicle_group:
if group.units[0].type == self.EWR_UNIT_TYPE:
yield group
@property
def sams(self) -> Iterator[VehicleGroup]:
for group in self.blue.vehicle_group:
if group.units[0].type == self.SAM_UNIT_TYPE:
yield group
@property
def garrisons(self) -> Iterator[VehicleGroup]:
for group in self.blue.vehicle_group:
if group.units[0].type == self.GARRISON_UNIT_TYPE:
yield group
@cached_property
def control_points(self) -> Dict[int, ControlPoint]:
control_points = {}
for airport in self.mission.terrain.airport_list():
if airport.is_blue() or airport.is_red():
control_point = self.control_point_from_airport(airport)
control_points[control_point.id] = control_point
for blue in (False, True):
for group in self.carriers(blue):
control_point = ControlPoint.carrier(
"carrier", group.position, next(self.control_point_id))
control_point.captured = blue
control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point
for group in self.lhas(blue):
control_point = ControlPoint.lha(
"lha", group.position, next(self.control_point_id))
control_point.captured = blue
control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point
return control_points
@property
def front_line_path_groups(self) -> Iterator[VehicleGroup]:
for group in self.country(blue=True).vehicle_group:
if group.units[0].type == self.FRONT_LINE_UNIT_TYPE:
yield group
@cached_property
def front_lines(self) -> Dict[str, ComplexFrontLine]:
# Dict of front line ID to a front line.
front_lines = {}
for group in self.front_line_path_groups:
# The unit will have its first waypoint at the source CP and the
# final waypoint at the destination CP. Intermediate waypoints
# define the curve of the front line.
waypoints = [p.position for p in group.points]
origin = self.mission.terrain.nearest_airport(waypoints[0])
if origin is None:
raise RuntimeError(
f"No airport near the first waypoint of {group.name}")
destination = self.mission.terrain.nearest_airport(waypoints[-1])
if destination is None:
raise RuntimeError(
f"No airport near the final waypoint of {group.name}")
# Snap the begin and end points to the control points.
waypoints[0] = origin.position
waypoints[-1] = destination.position
front_line_id = f"{origin.id}|{destination.id}"
front_lines[front_line_id] = ComplexFrontLine(origin, waypoints)
self.control_points[origin.id].connect(
self.control_points[destination.id])
self.control_points[destination.id].connect(
self.control_points[origin.id])
return front_lines
def objective_info(self, group: MovingGroup) -> Tuple[ControlPoint, int]:
closest = self.theater.closest_control_point(group.position)
distance = closest.position.distance_to_point(group.position)
return closest, distance
def add_preset_locations(self) -> None:
for group in self.garrisons:
closest, distance = self.objective_info(group)
if distance < self.BASE_DEFENSE_RADIUS:
closest.preset_locations.base_garrisons.append(group.position)
else:
logging.warning(
f"Found garrison unit too far from base: {group.name}")
for group in self.sams:
closest, distance = self.objective_info(group)
if distance < self.BASE_DEFENSE_RADIUS:
closest.preset_locations.base_air_defense.append(group.position)
else:
closest.preset_locations.sams.append(group.position)
for group in self.ewrs:
closest, distance = self.objective_info(group)
closest.preset_locations.ewrs.append(group.position)
def populate_theater(self) -> None:
for control_point in self.control_points.values():
self.theater.add_controlpoint(control_point)
self.add_preset_locations()
self.theater.set_frontline_data(self.front_lines)
class ConflictTheater:
terrain: Terrain
@ -83,17 +282,35 @@ class ConflictTheater:
land_poly = None # type: Polygon
"""
daytime_map: Dict[str, Tuple[int, int]]
frontline_data: Optional[Dict[str, ComplexFrontLine]] = None
_frontline_data: Optional[Dict[str, ComplexFrontLine]] = None
def __init__(self):
self.controlpoints: List[ControlPoint] = []
self.frontline_data = FrontLine.load_json_frontlines(self)
self._frontline_data: Optional[Dict[str, ComplexFrontLine]] = None
"""
self.land_poly = geometry.Polygon(self.landmap[0][0])
for x in self.landmap[1]:
self.land_poly = self.land_poly.difference(geometry.Polygon(x))
"""
@property
def frontline_data(self) -> Optional[Dict[str, ComplexFrontLine]]:
if self._frontline_data is None:
self.load_frontline_data_from_file()
return self._frontline_data
def load_frontline_data_from_file(self) -> None:
if self._frontline_data is not None:
logging.warning("Replacing existing frontline data from file")
self._frontline_data = FrontLine.load_json_frontlines(self)
if self._frontline_data is None:
self._frontline_data = {}
def set_frontline_data(self, data: Dict[str, ComplexFrontLine]) -> None:
if self._frontline_data is not None:
logging.warning("Replacing existing frontline data")
self._frontline_data = data
def add_controlpoint(self, point: ControlPoint,
connected_to: Optional[List[ControlPoint]] = None):
if connected_to is None:
@ -153,11 +370,21 @@ class ConflictTheater:
def enemy_points(self) -> List[ControlPoint]:
return [point for point in self.controlpoints if not point.captured]
def closest_control_point(self, point: Point) -> ControlPoint:
closest = self.controlpoints[0]
closest_distance = point.distance_to_point(closest.position)
for control_point in self.controlpoints[1:]:
distance = point.distance_to_point(control_point.position)
if distance < closest_distance:
closest = control_point
closest_distance = distance
return closest
def add_json_cp(self, theater, p: dict) -> ControlPoint:
if p["type"] == "airbase":
airbase = theater.terrain.airports[p["id"]].__class__
airbase = theater.terrain.airports[p["id"]]
if "radials" in p.keys():
radials = p["radials"]
@ -188,7 +415,7 @@ class ConflictTheater:
return cp
@staticmethod
def from_json(data: Dict[str, Any]) -> ConflictTheater:
def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater:
theaters = {
"Caucasus": CaucasusTheater,
"Nevada": NevadaTheater,
@ -199,6 +426,12 @@ class ConflictTheater:
}
theater = theaters[data["theater"]]
t = theater()
miz = data.get("miz", None)
if miz is not None:
MizCampaignLoader(directory / miz, t).populate_theater()
return t
cps = {}
for p in data["player_points"]:
cp = t.add_json_cp(theater, p)
@ -376,10 +609,6 @@ class FrontLine(MissionTarget):
"""Returns a tuple of the two control points."""
return self.control_point_a, self.control_point_b
@property
def middle_point(self):
self.point_from_a(self.attack_distance / 2)
@property
def attack_distance(self):
"""The total distance of all segments"""

View File

@ -1,9 +1,11 @@
from __future__ import annotations
import itertools
import random
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, Iterator, List, TYPE_CHECKING
from typing import Dict, Iterator, List, Optional, TYPE_CHECKING
from dcs.mapping import Point
from dcs.ships import (
@ -36,6 +38,43 @@ class ControlPointType(Enum):
FOB = 5 # A FOB (ground units only)
@dataclass
class PresetLocations:
base_garrisons: List[Point] = field(default_factory=list)
base_air_defense: List[Point] = field(default_factory=list)
ewrs: List[Point] = field(default_factory=list)
sams: List[Point] = field(default_factory=list)
coastal_defenses: List[Point] = field(default_factory=list)
strike_locations: List[Point] = field(default_factory=list)
@staticmethod
def _random_from(points: List[Point]) -> Optional[Point]:
if not points:
return None
point = random.choice(points)
points.remove(point)
return point
def random_garrison(self) -> Optional[Point]:
return self._random_from(self.base_garrisons)
def random_base_sam(self) -> Optional[Point]:
return self._random_from(self.base_air_defense)
def random_ewr(self) -> Optional[Point]:
return self._random_from(self.ewrs)
def random_sam(self) -> Optional[Point]:
return self._random_from(self.sams)
def random_coastal_defense(self) -> Optional[Point]:
return self._random_from(self.coastal_defenses)
def random_strike_location(self) -> Optional[Point]:
return self._random_from(self.strike_locations)
class ControlPoint(MissionTarget):
position = None # type: Point
@ -57,6 +96,7 @@ class ControlPoint(MissionTarget):
self.at = at
self.connected_objectives: List[TheaterGroundObject] = []
self.base_defenses: List[BaseDefenseGroundObject] = []
self.preset_locations = PresetLocations()
self.size = size
self.importance = importance
@ -79,7 +119,7 @@ class ControlPoint(MissionTarget):
def from_airport(cls, airport: Airport, radials: List[int], size: int, importance: float, has_frontline=True):
assert airport
obj = cls(airport.id, airport.name, airport.position, airport, radials, size, importance, has_frontline, cptype=ControlPointType.AIRBASE)
obj.airport = airport()
obj.airport = airport
return obj
@classmethod
@ -157,7 +197,7 @@ class ControlPoint(MissionTarget):
else:
return 0
def connect(self, to):
def connect(self, to: ControlPoint) -> None:
self.connected_points.append(to)
self.stances[to.id] = CombatStance.DEFENSIVE

View File

@ -4,7 +4,7 @@ import logging
import math
import pickle
import random
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike
@ -13,6 +13,17 @@ from dcs.vehicles import AirDefence
from game import Game, db
from game.factions.faction import Faction
from game.settings import Settings
from game.theater.conflicttheater import IMPORTANCE_HIGH, IMPORTANCE_LOW
from game.theater.theatergroundobject import (
BuildingGroundObject,
CarrierGroundObject,
EwrGroundObject,
LhaGroundObject,
MissileSiteGroundObject,
SamGroundObject,
ShipGroundObject,
VehicleGroupGroundObject,
)
from game.version import VERSION
from gen import namegen
from gen.defenses.armor_group_generator import generate_armor_group
@ -34,17 +45,6 @@ from theater import (
ControlPointType,
TheaterGroundObject,
)
from game.theater.conflicttheater import IMPORTANCE_HIGH, IMPORTANCE_LOW
from game.theater.theatergroundobject import (
EwrGroundObject,
SamGroundObject,
BuildingGroundObject,
CarrierGroundObject,
LhaGroundObject,
MissileSiteGroundObject,
ShipGroundObject,
VehicleGroupGroundObject,
)
GroundObjectTemplates = Dict[str, Dict[str, Any]]
@ -317,10 +317,9 @@ class BaseDefenseGenerator:
self.generate_base_defenses()
def generate_ewr(self) -> None:
position = self._find_location()
position = self._find_location(
"EWR", self.control_point.preset_locations.random_ewr)
if position is None:
logging.error("Could not find position for "
f"{self.control_point} EWR")
return
group_id = self.game.next_group_id()
@ -350,10 +349,9 @@ class BaseDefenseGenerator:
self.generate_garrison()
def generate_garrison(self) -> None:
position = self._find_location()
position = self._find_location(
"garrison", self.control_point.preset_locations.random_garrison)
if position is None:
logging.error("Could not find position for "
f"{self.control_point} garrison")
return
group_id = self.game.next_group_id()
@ -368,10 +366,9 @@ class BaseDefenseGenerator:
self.control_point.base_defenses.append(g)
def generate_sam(self) -> None:
position = self._find_location()
position = self._find_location(
"SAM", self.control_point.preset_locations.random_base_sam)
if position is None:
logging.error("Could not find position for "
f"{self.control_point} SAM")
return
group_id = self.game.next_group_id()
@ -385,10 +382,9 @@ class BaseDefenseGenerator:
self.control_point.base_defenses.append(g)
def generate_shorad(self) -> None:
position = self._find_location()
position = self._find_location(
"SHORAD", self.control_point.preset_locations.random_garrison)
if position is None:
logging.error("Could not find position for "
f"{self.control_point} SHORAD")
return
group_id = self.game.next_group_id()
@ -401,7 +397,21 @@ class BaseDefenseGenerator:
g.groups.append(group)
self.control_point.base_defenses.append(g)
def _find_location(self) -> Optional[Point]:
def _find_location(self, position_type: str,
get_preset: Callable[[], None]) -> Optional[Point]:
position = get_preset()
if position is None:
logging.warning(
f"Found no preset location for {self.control_point} "
f"{position_type}. Falling back to random location."
)
position = self._find_random_location()
if position is None:
logging.error("Could not find position for "
f"{self.control_point} {position_type}.")
return position
def _find_random_location(self) -> Optional[Point]:
position = find_location(True, self.control_point.position,
self.game.theater, 400, 3200, [], True)

View File

@ -29,8 +29,10 @@ class Campaign:
data = json.load(campaign_file)
sanitized_theater = data["theater"].replace(" ", "")
return cls(data["name"], f"Terrain_{sanitized_theater}", data.get("authors", "???"),
data.get("description", ""), ConflictTheater.from_json(data))
return cls(data["name"], f"Terrain_{sanitized_theater}",
data.get("authors", "???"),
data.get("description", ""),
ConflictTheater.from_json(path.parent, data))
def load_campaigns() -> List[Campaign]:

View File

@ -3,82 +3,5 @@
"theater": "Syria",
"authors": "Khopa",
"description": "<p>In this scenario, you start from Jordan, and have to fight your way through eastern Syria.</p>",
"player_points": [
{
"type": "airbase",
"id": "King Hussein Air College",
"size": 1000,
"importance": 1.4
},
{
"type": "airbase",
"id": "Incirlik",
"size": 1000,
"importance": 1.4,
"captured_invert": true
},
{
"type": "carrier",
"id": 1001,
"x": -210000,
"y": -200000,
"captured_invert": true
},
{
"type": "lha",
"id": 1002,
"x": -131000,
"y": -161000,
"captured_invert": true
}
],
"enemy_points": [
{
"type": "airbase",
"id": "Khalkhalah",
"size": 1000,
"importance": 1.2
},
{
"type": "airbase",
"id": "Palmyra",
"size": 1000,
"importance": 1
},
{
"type": "airbase",
"id": "Tabqa",
"size": 1000,
"importance": 1
},
{
"type": "airbase",
"id": "Jirah",
"size": 1000,
"importance": 1,
"captured_invert": true
}
],
"links": [
[
"Khalkhalah",
"King Hussein Air College"
],
[
"Incirlik",
"Incirlik"
],
[
"Khalkhalah",
"Palmyra"
],
[
"Palmyra",
"Tabqa"
],
[
"Jirah",
"Tabqa"
]
]
"miz": "inherent_resolve.miz"
}

Binary file not shown.