Skip to content

Commit

Permalink
Track damage (#3351)
Browse files Browse the repository at this point in the history
This PR partially addresses #3313 by:
- Tracking DCS hit events and storing the unit hit point updates in
state.json
- Adding logic to kill aircraft when the hit points is reduced to 1, as
opposed to the DCS logic of hit points to 0. This behavior allows
Liberation to track deaths to parked aircraft, which are uncontrolled
and seem to have different damage logic in DCS.
- Tracking damage to TheaterGroundObjects across turns and killing the
unit when the unit's hitpoints reduces to 1 or lower.

Intention is to build on this PR by also tracking front line objects and
statics (buildings). However, larger refactoring is required and so
splitting those into a separate PR.
  • Loading branch information
zhexu14 committed Mar 9, 2024
1 parent 1ee1113 commit 6550400
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 7 deletions.
5 changes: 4 additions & 1 deletion changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ Saves from 10.x are not compatible with 11.0.0.

## Features/Improvements

* **[Engine]** Support for DCS 2.9.3.51704 Open Beta.
* **[Engine]** Support for DCS 2.9.3.51704.
* **[Campaign]** Improved tracking of parked aircraft deaths. Parked aircraft are now considered dead once sufficient damage is done, meaning guns, rockets and AGMs are viable weapons for OCA/Aircraft missions. Previously Liberation relied on DCS death tracking which required parked aircraft to be hit with more powerful weapons e.g. 2000lb bombs as they were uncontrolled.
* **[Campaign]** Track damage to theater ground objects across turns. Damage can accumulate across turns leading to death of the unit. This behavior only applies to SAMs, ships and other units that appear on the Liberation map. Frontline units and buildings are not tracked (yet).


## Fixes

Expand Down
142 changes: 141 additions & 1 deletion game/debriefing.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from __future__ import annotations

from abc import ABC
import itertools
import logging
from collections import defaultdict
Expand All @@ -9,7 +9,9 @@
Dict,
Iterator,
List,
Optional,
TYPE_CHECKING,
TypeVar,
Union,
)
from uuid import UUID
Expand All @@ -21,8 +23,10 @@
if TYPE_CHECKING:
from game import Game
from game.ato.flight import Flight
from game.dcs.unittype import UnitType
from game.sim.simulationresults import SimulationResults
from game.transfers import CargoShip
from game.theater import TheaterUnit
from game.unitmap import (
AirliftUnits,
ConvoyUnit,
Expand Down Expand Up @@ -90,6 +94,103 @@ class BaseCaptureEvent:
captured_by_player: bool


@dataclass
class UnitHitpointUpdate(ABC):
unit: Any
hit_points: int

@classmethod
def from_json(
cls, data: dict[str, Any], unit_map: UnitMap
) -> Optional[UnitHitpointUpdate]:
raise NotImplementedError()

def is_dead(self) -> bool:
# Use hit_points > 1 to indicate unit is alive, rather than >=1 (DCS logic) to account for uncontrolled units which often have a
# health floor of 1
if self.hit_points > 1:
return False
return True

def is_friendly(self, to_player: bool) -> bool:
raise NotImplementedError()


@dataclass
class FlyingUnitHitPointUpdate(UnitHitpointUpdate):
unit: FlyingUnit

@classmethod
def from_json(
cls, data: dict[str, Any], unit_map: UnitMap
) -> Optional[FlyingUnitHitPointUpdate]:
unit = unit_map.flight(data["name"])
if unit is None:
return None
return cls(unit, int(float(data["hit_points"])))

def is_friendly(self, to_player: bool) -> bool:
if to_player:
return self.unit.flight.departure.captured
return not self.unit.flight.departure.captured


@dataclass
class TheaterUnitHitPointUpdate(UnitHitpointUpdate):
unit: TheaterUnitMapping

@classmethod
def from_json(
cls, data: dict[str, Any], unit_map: UnitMap
) -> Optional[TheaterUnitHitPointUpdate]:
unit = unit_map.theater_units(data["name"])
if unit is None:
return None

if unit.theater_unit.unit_type is None:
logging.debug(
f"Ground unit {data['name']} does not have a valid unit type."
)
return None

if unit.theater_unit.hit_points is None:
logging.debug(f"Ground unit {data['name']} does not have hit_points set.")
return None

sim_hit_points = int(
float(data["hit_points"])
) # Hit points out of the sim i.e. new unit hit points - damage in this turn
previous_turn_hit_points = (
unit.theater_unit.hit_points
) # Hit points at the end of the previous turn
full_health_hit_points = (
unit.theater_unit.unit_type.hit_points
) # Hit points of a new unit

# Hit points left after damage this turn is subtracted from hit points at the end of the previous turn
new_hit_points = previous_turn_hit_points - (
full_health_hit_points - sim_hit_points
)

return cls(unit, new_hit_points)

def is_dead(self) -> bool:
# Some TheaterUnits can start with low health of around 1, make sure we don't always kill them off.
if (
self.unit.theater_unit.unit_type is not None
and self.unit.theater_unit.unit_type.hit_points is not None
and self.unit.theater_unit.unit_type.hit_points <= 1
):
return False
return super().is_dead()

def is_friendly(self, to_player: bool) -> bool:
return self.unit.theater_unit.ground_object.is_friendly(to_player)

def commit(self) -> None:
self.unit.theater_unit.hit_points = self.hit_points


@dataclass(frozen=True)
class StateData:
#: True if the mission ended. If False, the mission exited abnormally.
Expand All @@ -108,6 +209,10 @@ class StateData:
#: Mangled names of bases that were captured during the mission.
base_capture_events: List[str]

# List of descriptions of damage done to units. Each list element is a dict like the following
# {"name": "<damaged unit name>", "hit_points": <hit points as float>}
unit_hit_point_updates: List[dict[str, Any]]

@classmethod
def from_json(cls, data: Dict[str, Any], unit_map: UnitMap) -> StateData:
def clean_unit_list(unit_list: List[Any]) -> List[str]:
Expand Down Expand Up @@ -147,6 +252,7 @@ def clean_unit_list(unit_list: List[Any]) -> List[str]:
killed_ground_units=killed_ground_units,
destroyed_statics=data["destroyed_objects_positions"],
base_capture_events=data["base_capture_events"],
unit_hit_point_updates=data["unit_hit_point_updates"],
)


Expand Down Expand Up @@ -284,6 +390,19 @@ def dead_aircraft(self) -> AirLosses:
player_losses.append(aircraft)
else:
enemy_losses.append(aircraft)

for unit_data in self.state_data.unit_hit_point_updates:
damaged_unit = FlyingUnitHitPointUpdate.from_json(unit_data, self.unit_map)
if damaged_unit is None:
continue
if damaged_unit.is_dead():
# If unit already killed, nothing to do.
if unit_data["name"] in self.state_data.killed_aircraft:
continue
if damaged_unit.is_friendly(to_player=True):
player_losses.append(damaged_unit.unit)
else:
enemy_losses.append(damaged_unit.unit)
return AirLosses(player_losses, enemy_losses)

def dead_ground_units(self) -> GroundLosses:
Expand Down Expand Up @@ -356,8 +475,29 @@ def dead_ground_units(self) -> GroundLosses:
losses.enemy_airlifts.append(airlift_unit)
continue

for unit_data in self.state_data.unit_hit_point_updates:
damaged_unit = TheaterUnitHitPointUpdate.from_json(unit_data, self.unit_map)
if damaged_unit is None:
continue
if damaged_unit.is_dead():
if unit_data["name"] in self.state_data.killed_ground_units:
continue
if damaged_unit.is_friendly(to_player=True):
losses.player_ground_objects.append(damaged_unit.unit)
else:
losses.enemy_ground_objects.append(damaged_unit.unit)

return losses

def unit_hit_point_update_events(self) -> List[TheaterUnitHitPointUpdate]:
damaged_units = []
for unit_data in self.state_data.unit_hit_point_updates:
unit = TheaterUnitHitPointUpdate.from_json(unit_data, self.unit_map)
if unit is None:
continue
damaged_units.append(unit)
return damaged_units

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
Expand Down
9 changes: 9 additions & 0 deletions game/sim/missionresultsprocessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def commit(self, debriefing: Debriefing, events: GameUpdateEvents) -> None:
self.commit_damaged_runways(debriefing)
self.commit_captures(debriefing, events)
self.commit_front_line_battle_impact(debriefing, events)
self.commit_unit_damage(debriefing)
self.record_carcasses(debriefing)

def commit_air_losses(self, debriefing: Debriefing) -> None:
Expand Down Expand Up @@ -307,6 +308,14 @@ def commit_front_line_battle_impact(
f"{enemy_cp.name}. {status_msg}",
)

@staticmethod
def commit_unit_damage(debriefing: Debriefing) -> None:
for damaged_unit in debriefing.unit_hit_point_update_events():
logging.info(
f"{damaged_unit.unit.theater_unit.name} damaged, setting hit points to {damaged_unit.hit_points}"
)
damaged_unit.commit()

def redeploy_units(self, cp: ControlPoint) -> None:
""" "
Auto redeploy units to newly captured base
Expand Down
26 changes: 21 additions & 5 deletions game/theater/theatergroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,26 @@ class TheaterUnit:
position: PointWithHeading
# The parent ground object
ground_object: TheaterGroundObject
# Number of hit points the unit has
hit_points: Optional[int] = None
# State of the unit, dead or alive
alive: bool = True

@staticmethod
def from_template(
id: int, dcs_type: Type[DcsUnitType], t: LayoutUnit, go: TheaterGroundObject
) -> TheaterUnit:
return TheaterUnit(
unit = TheaterUnit(
id,
t.name,
dcs_type,
PointWithHeading.from_point(t.position, Heading.from_degrees(t.heading)),
go,
)
# if the TheaterUnit represents a GroundUnitType or ShipUnitType, initialize health to full hit points
if unit.unit_type is not None:
unit.hit_points = unit.unit_type.hit_points
return unit

@property
def unit_type(self) -> Optional[UnitType[Any]]:
Expand All @@ -70,14 +76,12 @@ def unit_name(self) -> str:

@property
def display_name(self) -> str:
dead_label = " [DEAD]" if not self.alive else ""
unit_label = self.unit_type or self.type.name or self.name
return f"{str(self.id).zfill(4)} | {unit_label}{dead_label}"
return f"{str(self.id).zfill(4)} | {unit_label}{self._status_label()}"

@property
def short_name(self) -> str:
dead_label = " [DEAD]" if not self.alive else ""
return f"<b>{self.type.id[0:18]}</b> {dead_label}"
return f"<b>{self.type.id[0:18]}</b> {self._status_label()}"

@property
def is_static(self) -> bool:
Expand Down Expand Up @@ -117,6 +121,18 @@ def threat_range(self) -> Distance:
unit_range = getattr(self.type, "threat_range", None)
return meters(unit_range if unit_range is not None and self.alive else 0)

def _status_label(self) -> str:
if not self.alive:
return " [DEAD]"
if self.unit_type is None:
return ""
if self.hit_points is None:
return ""
if self.unit_type.hit_points == self.hit_points:
return ""
damage_percentage = 100 - int(100 * self.hit_points / self.unit_type.hit_points)
return f" [DAMAGED {damage_percentage}%]"


class SceneryUnit(TheaterUnit):
"""Special TheaterUnit for handling scenery ground objects"""
Expand Down
19 changes: 19 additions & 0 deletions resources/plugins/base/dcs_liberation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ kill_events = {} -- killed units will be added via S_EVENT_KILL
base_capture_events = {}
destroyed_objects_positions = {} -- will be added via S_EVENT_DEAD event
killed_ground_units = {} -- keep track of static ground object deaths
unit_hit_point_updates = {} -- stores updates to unit hit points, triggered by S_EVENT_HIT
mission_ended = false

local function ends_with(str, ending)
Expand Down Expand Up @@ -41,6 +42,7 @@ function write_state()
["mission_ended"] = mission_ended,
["destroyed_objects_positions"] = destroyed_objects_positions,
["killed_ground_units"] = killed_ground_units,
["unit_hit_point_updates"] = unit_hit_point_updates,
}
if not json then
local message = string.format("Unable to save DCS Liberation state to %s, JSON library is not loaded !", _debriefing_file_location)
Expand Down Expand Up @@ -146,6 +148,14 @@ write_state_error_handling = function()
mist.scheduleFunction(write_state_error_handling, {}, timer.getTime() + WRITESTATE_SCHEDULE_IN_SECONDS)
end

function update_hit_points(event)
local update = {}
update.name = event.target:getName()
update.hit_points = event.target:getLife()
unit_hit_point_updates[#unit_hit_point_updates + 1] = update
write_state()
end

activeWeapons = {}
local function onEvent(event)
if event.id == world.event.S_EVENT_CRASH and event.initiator then
Expand Down Expand Up @@ -175,6 +185,15 @@ local function onEvent(event)
destroyed_objects_positions[#destroyed_objects_positions + 1] = destruction
write_state()
end

if event.id == world.event.S_EVENT_HIT then
target_category = event.target:getCategory()
if target_category == Object.Category.UNIT then
-- check on the health of the target 1 second after as the life value is sometimes not updated
-- at the time of the event
timer.scheduleFunction(update_hit_points, event, timer.getTime() + 1)
end
end

if event.id == world.event.S_EVENT_MISSION_END then
mission_ended = true
Expand Down

0 comments on commit 6550400

Please sign in to comment.