mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
Catch a few more death signals. Still not perfect but a bit better. Fixes https://github.com/dcs-liberation/dcs_liberation/issues/2765.
389 lines
15 KiB
Python
389 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import itertools
|
|
import logging
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass, field
|
|
from typing import (
|
|
Any,
|
|
Dict,
|
|
Iterator,
|
|
List,
|
|
TYPE_CHECKING,
|
|
Union,
|
|
)
|
|
from uuid import UUID
|
|
|
|
from game.dcs.aircrafttype import AircraftType
|
|
from game.dcs.groundunittype import GroundUnitType
|
|
from game.theater import Airfield, ControlPoint
|
|
|
|
if TYPE_CHECKING:
|
|
from game import Game
|
|
from game.ato.flight import Flight
|
|
from game.sim.simulationresults import SimulationResults
|
|
from game.transfers import CargoShip
|
|
from game.unitmap import (
|
|
AirliftUnits,
|
|
ConvoyUnit,
|
|
FlyingUnit,
|
|
FrontLineUnit,
|
|
TheaterUnitMapping,
|
|
UnitMap,
|
|
SceneryObjectMapping,
|
|
)
|
|
|
|
DEBRIEFING_LOG_EXTENSION = "log"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AirLosses:
|
|
player: list[FlyingUnit]
|
|
enemy: list[FlyingUnit]
|
|
|
|
@property
|
|
def losses(self) -> Iterator[FlyingUnit]:
|
|
return itertools.chain(self.player, self.enemy)
|
|
|
|
def by_type(self, player: bool) -> Dict[AircraftType, int]:
|
|
losses_by_type: Dict[AircraftType, int] = defaultdict(int)
|
|
losses = self.player if player else self.enemy
|
|
for loss in losses:
|
|
losses_by_type[loss.flight.unit_type] += 1
|
|
return losses_by_type
|
|
|
|
def surviving_flight_members(self, flight: Flight) -> int:
|
|
losses = 0
|
|
for loss in self.losses:
|
|
if loss.flight == flight:
|
|
losses += 1
|
|
return flight.count - losses
|
|
|
|
|
|
@dataclass
|
|
class GroundLosses:
|
|
player_front_line: List[FrontLineUnit] = field(default_factory=list)
|
|
enemy_front_line: List[FrontLineUnit] = field(default_factory=list)
|
|
|
|
player_convoy: List[ConvoyUnit] = field(default_factory=list)
|
|
enemy_convoy: List[ConvoyUnit] = field(default_factory=list)
|
|
|
|
player_cargo_ships: List[CargoShip] = field(default_factory=list)
|
|
enemy_cargo_ships: List[CargoShip] = field(default_factory=list)
|
|
|
|
player_airlifts: List[AirliftUnits] = field(default_factory=list)
|
|
enemy_airlifts: List[AirliftUnits] = field(default_factory=list)
|
|
|
|
player_ground_objects: List[TheaterUnitMapping] = field(default_factory=list)
|
|
enemy_ground_objects: List[TheaterUnitMapping] = field(default_factory=list)
|
|
|
|
player_scenery: List[SceneryObjectMapping] = field(default_factory=list)
|
|
enemy_scenery: List[SceneryObjectMapping] = field(default_factory=list)
|
|
|
|
player_airfields: List[Airfield] = field(default_factory=list)
|
|
enemy_airfields: List[Airfield] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BaseCaptureEvent:
|
|
control_point: ControlPoint
|
|
captured_by_player: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StateData:
|
|
#: True if the mission ended. If False, the mission exited abnormally.
|
|
mission_ended: bool
|
|
|
|
#: Names of aircraft units that were killed during the mission.
|
|
killed_aircraft: List[str]
|
|
|
|
#: Names of vehicles, ships or buildings that were killed during the mission.
|
|
killed_ground_units: List[str]
|
|
|
|
#: List of descriptions of destroyed statics. Format of each element is a mapping of
|
|
#: the coordinate type ("x", "y", "z", "type", "orientation") to the value.
|
|
destroyed_statics: List[dict[str, Union[float, str]]]
|
|
|
|
#: Mangled names of bases that were captured during the mission.
|
|
base_capture_events: List[str]
|
|
|
|
@classmethod
|
|
def from_json(cls, data: Dict[str, Any], unit_map: UnitMap) -> StateData:
|
|
def clean_unit_list(unit_list: List[Any]) -> List[str]:
|
|
# Cleans list of units in state.json by
|
|
# - Removing duplicates. Airfields emit a new "dead" event every time a bomb
|
|
# is dropped on them when they've already dead.
|
|
# - Normalise dead map objects (which are ints) to strings. The unit map
|
|
# only stores strings
|
|
units = set()
|
|
for unit in unit_list:
|
|
units.add(str(unit))
|
|
return list(units)
|
|
|
|
killed_aircraft = []
|
|
killed_ground_units = []
|
|
|
|
# Process killed units from S_EVENT_UNIT_LOST, S_EVENT_CRASH, S_EVENT_DEAD & S_EVENT_KILL
|
|
# Try to process every event that could indicate a unit was killed, even if it is
|
|
# inefficient and results in duplication as the logic DCS uses to trigger the various
|
|
# event types is not clear and may change over time.
|
|
killed_units = clean_unit_list(
|
|
data["unit_lost_events"]
|
|
+ data["kill_events"]
|
|
+ data["crash_events"]
|
|
+ data["dead_events"]
|
|
)
|
|
for unit in killed_units: # organize killed units into aircraft vs ground
|
|
if unit_map.flight(unit) is not None:
|
|
killed_aircraft.append(unit)
|
|
else:
|
|
killed_ground_units.append(unit)
|
|
|
|
return cls(
|
|
mission_ended=data["mission_ended"],
|
|
killed_aircraft=killed_aircraft,
|
|
killed_ground_units=killed_ground_units,
|
|
destroyed_statics=data["destroyed_objects_positions"],
|
|
base_capture_events=data["base_capture_events"],
|
|
)
|
|
|
|
|
|
class Debriefing:
|
|
def __init__(
|
|
self, state_data: Dict[str, Any], game: Game, unit_map: UnitMap
|
|
) -> None:
|
|
self.state_data = StateData.from_json(state_data, unit_map)
|
|
self.game = game
|
|
self.unit_map = unit_map
|
|
|
|
self.player_country = game.blue.country_name
|
|
self.enemy_country = game.red.country_name
|
|
|
|
self.air_losses = self.dead_aircraft()
|
|
self.ground_losses = self.dead_ground_units()
|
|
self.base_captures = self.base_capture_events()
|
|
|
|
def merge_simulation_results(self, results: SimulationResults) -> None:
|
|
for air_loss in results.air_losses:
|
|
if air_loss.flight.squadron.player:
|
|
self.air_losses.player.append(air_loss)
|
|
else:
|
|
self.air_losses.enemy.append(air_loss)
|
|
|
|
@property
|
|
def front_line_losses(self) -> Iterator[FrontLineUnit]:
|
|
yield from self.ground_losses.player_front_line
|
|
yield from self.ground_losses.enemy_front_line
|
|
|
|
@property
|
|
def convoy_losses(self) -> Iterator[ConvoyUnit]:
|
|
yield from self.ground_losses.player_convoy
|
|
yield from self.ground_losses.enemy_convoy
|
|
|
|
@property
|
|
def cargo_ship_losses(self) -> Iterator[CargoShip]:
|
|
yield from self.ground_losses.player_cargo_ships
|
|
yield from self.ground_losses.enemy_cargo_ships
|
|
|
|
@property
|
|
def airlift_losses(self) -> Iterator[AirliftUnits]:
|
|
yield from self.ground_losses.player_airlifts
|
|
yield from self.ground_losses.enemy_airlifts
|
|
|
|
@property
|
|
def ground_object_losses(self) -> Iterator[TheaterUnitMapping]:
|
|
yield from self.ground_losses.player_ground_objects
|
|
yield from self.ground_losses.enemy_ground_objects
|
|
|
|
@property
|
|
def scenery_object_losses(self) -> Iterator[SceneryObjectMapping]:
|
|
yield from self.ground_losses.player_scenery
|
|
yield from self.ground_losses.enemy_scenery
|
|
|
|
@property
|
|
def damaged_runways(self) -> Iterator[Airfield]:
|
|
yield from self.ground_losses.player_airfields
|
|
yield from self.ground_losses.enemy_airfields
|
|
|
|
def casualty_count(self, control_point: ControlPoint) -> int:
|
|
return len([x for x in self.front_line_losses if x.origin == control_point])
|
|
|
|
def front_line_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
|
|
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
|
|
if player:
|
|
losses = self.ground_losses.player_front_line
|
|
else:
|
|
losses = self.ground_losses.enemy_front_line
|
|
for loss in losses:
|
|
losses_by_type[loss.unit_type] += 1
|
|
return losses_by_type
|
|
|
|
def convoy_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
|
|
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
|
|
if player:
|
|
losses = self.ground_losses.player_convoy
|
|
else:
|
|
losses = self.ground_losses.enemy_convoy
|
|
for loss in losses:
|
|
losses_by_type[loss.unit_type] += 1
|
|
return losses_by_type
|
|
|
|
def cargo_ship_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
|
|
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
|
|
if player:
|
|
ships = self.ground_losses.player_cargo_ships
|
|
else:
|
|
ships = self.ground_losses.enemy_cargo_ships
|
|
for ship in ships:
|
|
for unit_type, count in ship.units.items():
|
|
losses_by_type[unit_type] += count
|
|
return losses_by_type
|
|
|
|
def airlift_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
|
|
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
|
|
if player:
|
|
losses = self.ground_losses.player_airlifts
|
|
else:
|
|
losses = self.ground_losses.enemy_airlifts
|
|
for loss in losses:
|
|
for unit_type in loss.cargo:
|
|
losses_by_type[unit_type] += 1
|
|
return losses_by_type
|
|
|
|
def ground_object_losses_by_type(self, player: bool) -> Dict[str, int]:
|
|
losses_by_type: Dict[str, int] = defaultdict(int)
|
|
if player:
|
|
losses = self.ground_losses.player_ground_objects
|
|
else:
|
|
losses = self.ground_losses.enemy_ground_objects
|
|
for loss in losses:
|
|
losses_by_type[loss.theater_unit.type.id] += 1
|
|
return losses_by_type
|
|
|
|
def scenery_losses_by_type(self, player: bool) -> Dict[str, int]:
|
|
losses_by_type: Dict[str, int] = defaultdict(int)
|
|
if player:
|
|
losses = self.ground_losses.player_scenery
|
|
else:
|
|
losses = self.ground_losses.enemy_scenery
|
|
for loss in losses:
|
|
losses_by_type[loss.trigger_zone.name] += 1
|
|
return losses_by_type
|
|
|
|
def dead_aircraft(self) -> AirLosses:
|
|
player_losses = []
|
|
enemy_losses = []
|
|
for unit_name in self.state_data.killed_aircraft:
|
|
aircraft = self.unit_map.flight(unit_name)
|
|
if aircraft is None:
|
|
logging.error(f"Could not find Flight matching {unit_name}")
|
|
continue
|
|
if aircraft.flight.departure.captured:
|
|
player_losses.append(aircraft)
|
|
else:
|
|
enemy_losses.append(aircraft)
|
|
return AirLosses(player_losses, enemy_losses)
|
|
|
|
def dead_ground_units(self) -> GroundLosses:
|
|
losses = GroundLosses()
|
|
for unit_name in self.state_data.killed_ground_units:
|
|
front_line_unit = self.unit_map.front_line_unit(unit_name)
|
|
if front_line_unit is not None:
|
|
if front_line_unit.origin.captured:
|
|
losses.player_front_line.append(front_line_unit)
|
|
else:
|
|
losses.enemy_front_line.append(front_line_unit)
|
|
continue
|
|
|
|
convoy_unit = self.unit_map.convoy_unit(unit_name)
|
|
if convoy_unit is not None:
|
|
if convoy_unit.convoy.player_owned:
|
|
losses.player_convoy.append(convoy_unit)
|
|
else:
|
|
losses.enemy_convoy.append(convoy_unit)
|
|
continue
|
|
|
|
cargo_ship = self.unit_map.cargo_ship(unit_name)
|
|
if cargo_ship is not None:
|
|
if cargo_ship.player_owned:
|
|
losses.player_cargo_ships.append(cargo_ship)
|
|
else:
|
|
losses.enemy_cargo_ships.append(cargo_ship)
|
|
continue
|
|
|
|
ground_object = self.unit_map.theater_units(unit_name)
|
|
if ground_object is not None:
|
|
if ground_object.theater_unit.ground_object.is_friendly(to_player=True):
|
|
losses.player_ground_objects.append(ground_object)
|
|
else:
|
|
losses.enemy_ground_objects.append(ground_object)
|
|
continue
|
|
|
|
scenery_object = self.unit_map.scenery_object(unit_name)
|
|
# Try appending object to the name, because we do this for building statics.
|
|
if scenery_object is not None:
|
|
if scenery_object.ground_unit.ground_object.is_friendly(to_player=True):
|
|
losses.player_scenery.append(scenery_object)
|
|
else:
|
|
losses.enemy_scenery.append(scenery_object)
|
|
continue
|
|
|
|
airfield = self.unit_map.airfield(unit_name)
|
|
if airfield is not None:
|
|
if airfield.captured:
|
|
losses.player_airfields.append(airfield)
|
|
else:
|
|
losses.enemy_airfields.append(airfield)
|
|
continue
|
|
|
|
# Only logging as debug because we don't currently track infantry
|
|
# deaths, so we expect to see quite a few unclaimed dead ground
|
|
# units. We should start tracking those and covert this to a
|
|
# warning.
|
|
logging.debug(
|
|
f"Death of untracked ground unit {unit_name} will "
|
|
"have no effect. This may be normal behavior."
|
|
)
|
|
|
|
for unit_name in self.state_data.killed_aircraft:
|
|
airlift_unit = self.unit_map.airlift_unit(unit_name)
|
|
if airlift_unit is not None:
|
|
if airlift_unit.transfer.player:
|
|
losses.player_airlifts.append(airlift_unit)
|
|
else:
|
|
losses.enemy_airlifts.append(airlift_unit)
|
|
continue
|
|
|
|
return losses
|
|
|
|
def base_capture_events(self) -> List[BaseCaptureEvent]:
|
|
"""Keeps only the last instance of a base capture event for each base ID."""
|
|
blue_coalition_id = 2
|
|
seen = set()
|
|
captures = []
|
|
for capture in reversed(self.state_data.base_capture_events):
|
|
# The ID string in the JSON file will be the UUID generated from liberation
|
|
cp_id, new_owner_id_str, _name = capture.split("||")
|
|
|
|
# Only the most recent capture event matters.
|
|
if cp_id in seen:
|
|
continue
|
|
seen.add(cp_id)
|
|
|
|
try:
|
|
control_point = self.game.theater.find_control_point_by_id(UUID(cp_id))
|
|
except (KeyError, ValueError):
|
|
# Captured base is not a part of the campaign. This happens when neutral
|
|
# bases are near the conflict. Nothing to do.
|
|
continue
|
|
|
|
captured_by_player = int(new_owner_id_str) == blue_coalition_id
|
|
if control_point.is_friendly(to_player=captured_by_player):
|
|
# Base is currently friendly to the new owner. Was captured and
|
|
# recaptured in the same mission. Nothing to do.
|
|
continue
|
|
|
|
captures.append(BaseCaptureEvent(control_point, captured_by_player))
|
|
return captures
|