dcs-retribution/gen/templates.py
RndName 60c8c80480 Decoupling and generalization of templates
Improvement for factions and templates which will allow decoupling of the templates from the actual units
- Implement UnitGroup class which matches unit_types and possible templates as the needed abstraction layer for decoupling.
- Refactor UnitType, Add ShipUnitType and all ships we currently use
- Remove serialized template.json and migrated to multiple yaml templates (one for each template) and multiple .miz
- Reorganized a lot of templates and started with generalization of many types (AAA, Flak, SHORAD, Navy)
- Fixed a lot of bugs from the previous reworks (group name generation, strike targets...)
- Reorganized the faction file completly. removed redundant lists, added presets for complex groups / families of units like sams
- Reworked the building template handling. Some templates are unused like "village"
- Reworked how groups from templates can be merged again for the dcs group creation (e.g. the skynet plugin requires them to be in the same group)
- Allow to define alternative tasks
2022-02-21 20:45:41 +01:00

717 lines
25 KiB
Python

from __future__ import annotations
import itertools
import logging
import random
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterator, Any, TYPE_CHECKING, Optional, Union
import dcs
import yaml
from dcs import Point
from dcs.unit import Unit
from dcs.unitgroup import StaticGroup
from dcs.unittype import VehicleType, ShipType
from game.data.radar_db import UNITS_WITH_RADAR
from game.data.units import UnitClass
from game.data.groups import GroupRole, GroupTask, ROLE_TASKINGS
from game.dcs.groundunittype import GroundUnitType
from game.dcs.shipunittype import ShipUnitType
from game.dcs.unittype import UnitType
from game.theater.theatergroundobject import (
SamGroundObject,
EwrGroundObject,
BuildingGroundObject,
GroundGroup,
MissileSiteGroundObject,
ShipGroundObject,
CarrierGroundObject,
LhaGroundObject,
CoastalSiteGroundObject,
VehicleGroupGroundObject,
IadsGroundObject,
GroundUnit,
)
from game.point_with_heading import PointWithHeading
from game.utils import Heading
from game import db
if TYPE_CHECKING:
from game import Game
from game.factions.faction import Faction
from game.theater import TheaterGroundObject, ControlPoint
@dataclass
class UnitTemplate:
name: str
position: Point
heading: int
@staticmethod
def from_unit(unit: Unit) -> UnitTemplate:
return UnitTemplate(
unit.name,
Point(int(unit.position.x), int(unit.position.y)),
int(unit.heading),
)
@dataclass
class GroupTemplate:
name: str
units: list[UnitTemplate]
# Is Static group
static: bool = False
# The group this template will be merged into
group: int = 1
# Define the amount of random units to be created by the randomizer.
# This can be a fixed int or a random value from a range of two ints as tuple
unit_count: list[int] = field(default_factory=list)
# defintion which unit types are supported
unit_types: list[str] = field(default_factory=list)
unit_classes: list[UnitClass] = field(default_factory=list)
alternative_classes: list[UnitClass] = field(default_factory=list)
# Defines if this groupTemplate is required or not
optional: bool = False
# Used to determine if the group should be generated or not
_possible_types: list[str] = field(default_factory=list)
_enabled: bool = True
_unit_type: Optional[str] = None
_unit_counter: Optional[int] = None
@property
def should_be_generated(self) -> bool:
if self.optional:
return self._enabled
return True
def enable(self) -> None:
self._enabled = True
def disable(self) -> None:
self._enabled = False
def set_enabled(self, enabled: bool) -> None:
self._enabled = enabled
def is_enabled(self) -> bool:
return self._enabled
def set_unit_type(self, unit_type: str) -> None:
self._unit_type = unit_type
def set_possible_types(self, unit_types: list[str]) -> None:
self._possible_types = unit_types
def can_use_unit(self, unit_type: UnitType[Any]) -> bool:
return (
self.can_use_unit_type(unit_type.dcs_id)
or unit_type.unit_class in self.unit_classes
)
def can_use_unit_type(self, unit_type: str) -> bool:
return unit_type in self.unit_types
def reset_unit_counter(self) -> None:
count = len(self.units)
if self.unit_count:
if len(self.unit_count) == 1:
count = self.unit_count[0]
else:
count = random.choice(range(min(self.unit_count), max(self.unit_count)))
self._unit_counter = count
@property
def unit_type(self) -> str:
if self._unit_type:
# Forced type
return self._unit_type
unit_types = self._possible_types or self.unit_types
if unit_types:
# Random type
return random.choice(unit_types)
raise RuntimeError("TemplateGroup has no unit_type")
@property
def size(self) -> int:
if not self._unit_counter:
self.reset_unit_counter()
assert self._unit_counter is not None
return self._unit_counter
@property
def max_size(self) -> int:
return len(self.units)
def use_unit(self) -> None:
if self._unit_counter is None:
self.reset_unit_counter()
if self._unit_counter and self._unit_counter > 0:
self._unit_counter -= 1
else:
raise IndexError
@property
def can_be_modified(self) -> bool:
return len(self._possible_types) > 1 or len(self.unit_count) > 1
@property
def statics(self) -> Iterator[str]:
for unit_type in self._possible_types or self.unit_types:
if db.static_type_from_name(unit_type):
yield unit_type
@property
def possible_units(self) -> Iterator[UnitType[Any]]:
for unit_type in self._possible_types or self.unit_types:
dcs_unit_type = db.unit_type_from_name(unit_type)
if dcs_unit_type is None:
raise RuntimeError(f"Unit Type {unit_type} not a valid dcs type")
try:
if issubclass(dcs_unit_type, VehicleType):
yield next(GroundUnitType.for_dcs_type(dcs_unit_type))
elif issubclass(dcs_unit_type, ShipType):
yield next(ShipUnitType.for_dcs_type(dcs_unit_type))
except StopIteration:
continue
def generate_units(self, go: TheaterGroundObject) -> list[GroundUnit]:
self.reset_unit_counter()
units = []
unit_type = self.unit_type
for u_id, unit in enumerate(self.units):
tgo_unit = GroundUnit.from_template(u_id, unit_type, unit, go)
try:
# Check if unit can be assigned
self.use_unit()
except IndexError:
# Do not generate the unit as no more units are available
continue
units.append(tgo_unit)
return units
class GroundObjectTemplate:
def __init__(self, name: str, description: str = "") -> None:
self.name = name
self.description = description
self.tasks: list[GroupTask] = [] # The supported tasks
self.groups: list[GroupTemplate] = []
self.category: str = "" # Only used for building templates
# If the template is generic it will be used the generate the general
# UnitGroups during faction initialization. Generic Groups allow to be mixed
self.generic: bool = False
def generate(
self,
name: str,
position: PointWithHeading,
control_point: ControlPoint,
game: Game,
merge_groups: bool = True,
) -> TheaterGroundObject:
# Create the ground_object based on the type
ground_object = self._create_ground_object(name, position, control_point)
# Generate all groups using the randomization if it defined
for g_id, group in enumerate(self.groups):
if not group.should_be_generated:
continue
# Static and non Static groups have to be separated
unit_count = 0
group_id = (group.group - 1) if merge_groups else g_id
if not merge_groups or len(ground_object.groups) <= group_id:
# Requested group was not yet created
ground_group = GroundGroup.from_template(
game.next_group_id(),
group,
ground_object,
)
# Set Group Name
ground_group.name = f"{self.name} {group_id}"
ground_object.groups.append(ground_group)
units = ground_group.units
else:
ground_group = ground_object.groups[group_id]
units = group.generate_units(ground_object)
unit_count = len(ground_group.units)
ground_group.units.extend(units)
# Assign UniqueID, name and align relative to ground_object
for u_id, unit in enumerate(units):
unit.id = game.next_unit_id()
unit.name = f"{self.name} {group_id}-{unit_count + u_id}"
unit.position = PointWithHeading.from_point(
Point(
ground_object.position.x + unit.position.x,
ground_object.position.y + unit.position.y,
),
# Align heading to GroundObject defined by the campaign designer
unit.position.heading + ground_object.heading,
)
if (
isinstance(self, AntiAirTemplate)
and unit.unit_type
and unit.unit_type.dcs_unit_type in UNITS_WITH_RADAR
):
# Head Radars towards the center of the conflict
unit.position.heading = (
game.theater.heading_to_conflict_from(unit.position)
or unit.position.heading
)
# Rotate unit around the center to align the orientation of the group
unit.position.rotate(ground_object.position, ground_object.heading)
return ground_object
def _create_ground_object(
self,
name: str,
position: PointWithHeading,
control_point: ControlPoint,
) -> TheaterGroundObject:
raise NotImplementedError
def add_group(self, new_group: GroupTemplate, index: int = 0) -> None:
"""Adds a group in the correct order to the template"""
if len(self.groups) > index:
self.groups.insert(index, new_group)
else:
self.groups.append(new_group)
def estimated_price_for(self, go: TheaterGroundObject) -> float:
# Price can only be estimated because of randomization
price = 0
for group in self.groups:
if group.should_be_generated:
for unit in group.generate_units(go):
if unit.unit_type:
price += unit.unit_type.price
return price
@property
def size(self) -> int:
return sum([len(group.units) for group in self.groups])
@property
def statics(self) -> Iterator[str]:
for group in self.groups:
yield from group.statics
@property
def units(self) -> Iterator[UnitType[Any]]:
for group in self.groups:
yield from group.possible_units
class AntiAirTemplate(GroundObjectTemplate):
def _create_ground_object(
self,
name: str,
position: PointWithHeading,
control_point: ControlPoint,
) -> IadsGroundObject:
if GroupTask.EWR in self.tasks:
return EwrGroundObject(name, position, position.heading, control_point)
elif any(tasking in self.tasks for tasking in ROLE_TASKINGS[GroupRole.AntiAir]):
return SamGroundObject(name, position, position.heading, control_point)
raise RuntimeError(
f" No Template for AntiAir tasking ({', '.join(task.value for task in self.tasks)})"
)
class BuildingTemplate(GroundObjectTemplate):
def _create_ground_object(
self,
name: str,
position: PointWithHeading,
control_point: ControlPoint,
) -> BuildingGroundObject:
return BuildingGroundObject(
name,
self.category,
position,
Heading.from_degrees(0),
control_point,
self.category == "fob",
)
class NavalTemplate(GroundObjectTemplate):
def _create_ground_object(
self,
name: str,
position: PointWithHeading,
control_point: ControlPoint,
) -> TheaterGroundObject:
if GroupTask.Navy in self.tasks:
return ShipGroundObject(name, position, control_point)
elif GroupTask.AircraftCarrier in self.tasks:
return CarrierGroundObject(name, control_point)
elif GroupTask.HelicopterCarrier in self.tasks:
return LhaGroundObject(name, control_point)
raise NotImplementedError
class DefensesTemplate(GroundObjectTemplate):
def _create_ground_object(
self,
name: str,
position: PointWithHeading,
control_point: ControlPoint,
) -> TheaterGroundObject:
if GroupTask.Missile in self.tasks:
return MissileSiteGroundObject(
name, position, position.heading, control_point
)
elif GroupTask.Coastal in self.tasks:
return CoastalSiteGroundObject(
name, position, control_point, position.heading
)
raise NotImplementedError
class GroundForceTemplate(GroundObjectTemplate):
def _create_ground_object(
self,
name: str,
position: PointWithHeading,
control_point: ControlPoint,
) -> TheaterGroundObject:
return VehicleGroupGroundObject(name, position, position.heading, control_point)
TEMPLATE_TYPES = {
GroupRole.AntiAir: AntiAirTemplate,
GroupRole.Building: BuildingTemplate,
GroupRole.Naval: NavalTemplate,
GroupRole.GroundForce: GroundForceTemplate,
GroupRole.Defenses: DefensesTemplate,
}
@dataclass
class GroupTemplateMapping:
# The group name used in the template.miz
name: str
# Defines if the group is required for the template or can be skipped
optional: bool = False
# All static units for the group
statics: list[str] = field(default_factory=list)
# Defines to which tgo group the groupTemplate will be added
# This allows to merge groups back together. Default: Merge all to group 1
group: int = field(default=1)
# Randomization settings. If left empty the template will be generated with the
# exact values (amount of units and unit_type) defined in the template.miz
# How many units the template should generate. Will be used for randomization
unit_count: list[int] = field(default_factory=list)
# All unit types the template supports.
unit_types: list[str] = field(default_factory=list)
# All unit classes the template supports.
unit_classes: list[UnitClass] = field(default_factory=list)
alternative_classes: list[UnitClass] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
d = self.__dict__
if not self.optional:
d.pop("optional")
if not self.statics:
d.pop("statics")
if not self.unit_types:
d.pop("unit_types")
if not self.unit_classes:
d.pop("unit_classes")
else:
d["unit_classes"] = [unit_class.value for unit_class in self.unit_classes]
if not self.alternative_classes:
d.pop("alternative_classes")
else:
d["alternative_classes"] = [
unit_class.value for unit_class in self.alternative_classes
]
if not self.unit_count:
d.pop("unit_count")
return d
@staticmethod
def from_dict(d: dict[str, Any]) -> GroupTemplateMapping:
optional = d["optional"] if "optional" in d else False
statics = d["statics"] if "statics" in d else []
unit_count = d["unit_count"] if "unit_count" in d else []
unit_types = d["unit_types"] if "unit_types" in d else []
group = d["group"] if "group" in d else 1
unit_classes = (
[UnitClass(u) for u in d["unit_classes"]] if "unit_classes" in d else []
)
alternative_classes = (
[UnitClass(u) for u in d["alternative_classes"]]
if "alternative_classes" in d
else []
)
return GroupTemplateMapping(
d["name"],
optional,
statics,
group,
unit_count,
unit_types,
unit_classes,
alternative_classes,
)
@dataclass
class TemplateMapping:
# The name of the Template
name: str
# An optional description to give more information about the template
description: str
# An optional description to give more information about the template
category: str
# Optional field to define if the template can be used to create generic groups
generic: bool
# The role the template can be used for
role: GroupRole
# All taskings the template can be used for
tasks: list[GroupTask]
# All Groups the template has
groups: list[GroupTemplateMapping] = field(default_factory=list)
# Define the miz file for the template. Optional. If empty use the mapping name
template_file: str = field(default="")
def to_dict(self) -> dict[str, Any]:
d = {
"name": self.name,
"description": self.description,
"category": self.category,
"generic": self.generic,
"role": self.role.value,
"tasks": [task.value for task in self.tasks],
"groups": [group.to_dict() for group in self.groups],
"template_file": self.template_file,
}
if not self.description:
d.pop("description")
if not self.category:
d.pop("category")
if not self.generic:
# Only save if true
d.pop("generic")
if not self.template_file:
d.pop("template_file")
return d
@staticmethod
def from_dict(d: dict[str, Any], file_name: str) -> TemplateMapping:
groups = [GroupTemplateMapping.from_dict(group) for group in d["groups"]]
description = d["description"] if "description" in d else ""
category = d["category"] if "category" in d else ""
generic = d["generic"] if "generic" in d else False
template_file = (
d["template_file"]
if "template_file" in d
else file_name.replace("yaml", "miz")
)
tasks = [GroupTask(task) for task in d["tasks"]]
return TemplateMapping(
d["name"],
description,
category,
generic,
GroupRole(d["role"]),
tasks,
groups,
template_file,
)
def export(self, mapping_folder: str) -> None:
file_name = self.name
for char in ["\\", "/", " ", "'", '"']:
file_name = file_name.replace(char, "_")
f = mapping_folder + file_name + ".yaml"
with open(f, "w", encoding="utf-8") as data_file:
yaml.dump(self.to_dict(), data_file, Dumper=MappingDumper, sort_keys=False)
# Custom Dumper to fix pyyaml indent https://github.com/yaml/pyyaml/issues/234
class MappingDumper(yaml.Dumper):
def increase_indent(self, flow: bool = False, *args: Any, **kwargs: Any) -> None:
return super().increase_indent(flow=flow, indentless=False)
class GroundObjectTemplates:
# list of templates per category. e.g. AA or similar
_templates: dict[GroupRole, list[GroundObjectTemplate]]
def __init__(self) -> None:
self._templates = {}
@property
def templates(self) -> Iterator[tuple[GroupRole, GroundObjectTemplate]]:
for category, templates in self._templates.items():
for template in templates:
yield category, template
@classmethod
def from_folder(cls, folder: str) -> GroundObjectTemplates:
templates = GroundObjectTemplates()
mappings: dict[str, list[TemplateMapping]] = {}
for file in Path(folder).rglob("*.yaml"):
if not file.is_file():
continue
with file.open("r", encoding="utf-8") as f:
mapping_dict = yaml.safe_load(f)
template_map = TemplateMapping.from_dict(mapping_dict, f.name)
if template_map.template_file in mappings:
mappings[template_map.template_file].append(template_map)
else:
mappings[template_map.template_file] = [template_map]
for miz, maps in mappings.items():
for role, template in cls.load_from_miz(miz, maps).templates:
templates.add_template(role, template)
return templates
@staticmethod
def mapping_for_group(
mappings: list[TemplateMapping], group_name: str
) -> tuple[TemplateMapping, int, GroupTemplateMapping]:
for mapping in mappings:
for g_id, group_mapping in enumerate(mapping.groups):
if (
group_mapping.name == group_name
or group_name in group_mapping.statics
):
return mapping, g_id, group_mapping
raise KeyError
@classmethod
def load_from_miz(
cls, miz: str, mappings: list[TemplateMapping]
) -> GroundObjectTemplates:
template_position: dict[str, Point] = {}
templates = GroundObjectTemplates()
temp_mis = dcs.Mission()
temp_mis.load_file(miz)
for country in itertools.chain(
temp_mis.coalition["red"].countries.values(),
temp_mis.coalition["blue"].countries.values(),
):
for dcs_group in itertools.chain(
temp_mis.country(country.name).vehicle_group,
temp_mis.country(country.name).ship_group,
temp_mis.country(country.name).static_group,
):
try:
mapping, group_id, group_mapping = cls.mapping_for_group(
mappings, dcs_group.name
)
except KeyError:
logging.error(f"No mapping for dcs group {dcs_group.name}")
continue
template = templates.by_name(mapping.name)
if not template:
template = TEMPLATE_TYPES[mapping.role](
mapping.name, mapping.description
)
template.category = mapping.category
template.generic = mapping.generic
template.tasks = mapping.tasks
templates.add_template(mapping.role, template)
for i, unit in enumerate(dcs_group.units):
group_template = None
for group in template.groups:
if group.name == dcs_group.name or (
isinstance(dcs_group, StaticGroup)
and dcs_group.units[0].type in group.unit_types
):
# MovingGroups are matched by name, statics by unit_type
group_template = group
if not group_template:
group_template = GroupTemplate(
dcs_group.name,
[],
True if isinstance(dcs_group, StaticGroup) else False,
group_mapping.group,
group_mapping.unit_count,
group_mapping.unit_types,
group_mapping.unit_classes,
group_mapping.alternative_classes,
)
group_template.optional = group_mapping.optional
# Add the group at the correct position
template.add_group(group_template, group_id)
unit_template = UnitTemplate.from_unit(unit)
if i == 0 and template.name not in template_position:
template_position[template.name] = unit.position
unit_template.position = (
unit_template.position - template_position[template.name]
)
group_template.units.append(unit_template)
return templates
@property
def all(self) -> Iterator[GroundObjectTemplate]:
for templates in self._templates.values():
yield from templates
def by_name(self, template_name: str) -> Optional[GroundObjectTemplate]:
for template in self.all:
if template.name == template_name:
return template
return None
def add_template(self, role: GroupRole, template: GroundObjectTemplate) -> None:
if role not in self._templates:
self._templates[role] = [template]
else:
self._templates[role].append(template)
def for_role_and_task(
self, group_role: GroupRole, group_task: Optional[GroupTask] = None
) -> Iterator[GroundObjectTemplate]:
if group_role not in self._templates:
return None
for template in self._templates[group_role]:
if not group_task or group_task in template.tasks:
yield template
def for_role_and_tasks(
self, group_role: GroupRole, group_tasks: list[GroupTask]
) -> Iterator[GroundObjectTemplate]:
unique_templates = []
for group_task in group_tasks:
for template in self.for_role_and_task(group_role, group_task):
if template not in unique_templates:
unique_templates.append(template)
yield from unique_templates