Add (very!) rough simulation of frozen combat.

There are some TODOs here but th behavior is flagged off by default. The
biggest TODO here is that the time spent frozen is not simulated, so
flights that are engaged by SAMs will unfreeze, move slightly, then re-
freeze.

https://github.com/dcs-liberation/dcs_liberation/issues/1680
This commit is contained in:
Dan Albert 2021-12-22 13:07:10 -08:00
parent bc41261009
commit 2585dcc130
19 changed files with 260 additions and 35 deletions

View File

@ -15,6 +15,7 @@ from .loadouts import Loadout
if TYPE_CHECKING:
from game.dcs.aircrafttype import AircraftType
from game.sim.gameupdateevents import GameUpdateEvents
from game.sim.simulationresults import SimulationResults
from game.squadrons import Squadron, Pilot
from game.theater import ControlPoint, MissionTarget
from game.transfers import TransferOrder
@ -180,3 +181,27 @@ class Flight:
def should_halt_sim(self) -> bool:
return self.state.should_halt_sim()
def kill(self, results: SimulationResults) -> None:
# This is a bit messy while we're in transition from turn-based to turnless
# because we want the simulation to have minimal impact on the save game while
# turns exist so that loading a game is essentially a way to reset the
# simulation to the start of the turn. As such, we don't actually want to mark
# pilots killed or reduce squadron aircraft availability, but we do still need
# the UI to reflect that aircraft were lost and avoid generating those flights
# when the mission is generated.
#
# For now we do this by removing the flight from the ATO and logging the kill in
# the SimulationResults, which is similar to the Debriefing. If a flight is
# killed and the player saves and reloads, those pilots/aircraft will be
# unusable until the next turn, but otherwise will survive.
#
# This is going to be extremely temporary since the solution for other killable
# game objects (killed SAMs, sinking carriers, bombed out runways) will not be
# so easily worked around.
# TODO: Support partial kills.
# TODO: Remove empty packages from the ATO?
self.package.remove_flight(self)
for pilot in self.roster.pilots:
if pilot is not None:
results.kill_pilot(self, pilot)

View File

@ -26,6 +26,18 @@ class FlightState(ABC):
) -> None:
...
@property
def in_flight(self) -> bool:
return False
@property
def is_at_ip(self) -> bool:
return False
@property
def in_combat(self) -> bool:
return False
@property
def vulnerable_to_intercept(self) -> bool:
return False

View File

@ -24,6 +24,14 @@ class InCombat(InFlight):
self.previous_state = previous_state
self.combat = combat
def exit_combat(self) -> None:
# TODO: Account for time passed while frozen.
self.flight.set_state(self.previous_state)
@property
def in_combat(self) -> bool:
return True
def estimate_position(self) -> Point:
return self.previous_state.estimate_position()
@ -36,7 +44,9 @@ class InCombat(InFlight):
def on_game_tick(
self, events: GameUpdateEvents, time: datetime, duration: timedelta
) -> None:
raise RuntimeError("Cannot simulate combat")
# Combat ticking is handled elsewhere because combat objects may be shared
# across multiple flights.
pass
@property
def is_at_ip(self) -> bool:
@ -46,9 +56,6 @@ class InCombat(InFlight):
def is_waiting_for_start(self) -> bool:
return False
def should_halt_sim(self) -> bool:
return True
@property
def vulnerable_to_intercept(self) -> bool:
# Interception results in the interceptor joining the existing combat rather

View File

@ -30,6 +30,10 @@ class InFlight(FlightState, ABC):
self.total_time_to_next_waypoint = self.travel_time_between_waypoints()
self.elapsed_time = timedelta()
@property
def in_flight(self) -> bool:
return True
def has_passed_waypoint(self, waypoint: FlightWaypoint) -> bool:
index = self.flight.flight_plan.waypoints.index(waypoint)
return index <= self.waypoint_index

View File

@ -20,6 +20,7 @@ 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,
@ -36,8 +37,8 @@ DEBRIEFING_LOG_EXTENSION = "log"
@dataclass(frozen=True)
class AirLosses:
player: List[FlyingUnit]
enemy: List[FlyingUnit]
player: list[FlyingUnit]
enemy: list[FlyingUnit]
@property
def losses(self) -> Iterator[FlyingUnit]:
@ -137,6 +138,13 @@ class Debriefing:
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

View File

@ -18,6 +18,7 @@ class GameUpdateEventsJs(BaseModel):
updated_flight_positions: dict[UUID, LeafletLatLon]
new_combats: list[FrozenCombatJs]
updated_combats: list[FrozenCombatJs]
ended_combats: list[UUID]
navmesh_updates: set[bool]
unculled_zones_updated: bool
threat_zones_updated: bool
@ -41,6 +42,7 @@ class GameUpdateEventsJs(BaseModel):
FrozenCombatJs.for_combat(c, game.theater)
for c in events.updated_combats
],
ended_combats=[c.id for c in events.ended_combats],
navmesh_updates=events.navmesh_updates,
unculled_zones_updated=events.unculled_zones_updated,
threat_zones_updated=events.threat_zones_updated,

View File

@ -323,6 +323,18 @@ class Settings:
"mission reaches the set state or at first contact, whichever comes first."
),
)
auto_resolve_combat: bool = boolean_option(
"Auto-resolve combat during fast-forward (WIP)",
page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION,
default=False,
detail=(
"If enabled, aircraft entering combat during fast forward will have their "
"combat auto-resolved after a period of time. This allows the simulation "
"to advance further into the mission before requiring mission generation, "
"but simulation is currently very rudimentary so may result in huge losses."
),
)
supercarrier: bool = boolean_option(
"Use supercarrier module",
MISSION_GENERATOR_PAGE,

View File

@ -1,12 +1,12 @@
from __future__ import annotations
import itertools
import logging
from collections.abc import Iterator
from datetime import datetime, timedelta
from typing_extensions import TYPE_CHECKING
from game.ato import Flight
from game.ato.flightstate import (
Navigating,
StartUp,
@ -18,9 +18,11 @@ from game.ato.flightstate import (
from game.ato.starttype import StartType
from game.ato.traveltime import TotEstimator
from .combat import CombatInitiator, FrozenCombat
from .simulationresults import SimulationResults
if TYPE_CHECKING:
from game import Game
from game.ato import Flight
from .gameupdateevents import GameUpdateEvents
@ -28,6 +30,7 @@ class AircraftSimulation:
def __init__(self, game: Game) -> None:
self.game = game
self.combats: list[FrozenCombat] = []
self.results = SimulationResults()
def begin_simulation(self) -> None:
self.reset()
@ -36,6 +39,22 @@ class AircraftSimulation:
def on_game_tick(
self, events: GameUpdateEvents, time: datetime, duration: timedelta
) -> None:
if not self.game.settings.auto_resolve_combat and self.combats:
logging.error(
"Cannot resume simulation because aircraft are in combat and "
"auto-resolve is disabled"
)
events.complete_simulation()
return
still_active = []
for combat in self.combats:
if combat.on_game_tick(duration, self.results):
events.end_combat(combat)
else:
still_active.append(combat)
self.combats = still_active
for flight in self.iter_flights():
flight.on_game_tick(events, time, duration)
@ -49,6 +68,9 @@ class AircraftSimulation:
events.complete_simulation()
return
if not self.game.settings.auto_resolve_combat and self.combats:
events.complete_simulation()
def set_initial_flight_states(self) -> None:
now = self.game.conditions.start_time
for flight in self.iter_flights():

View File

@ -1,20 +1,24 @@
from __future__ import annotations
import logging
import random
from datetime import timedelta
from typing import TYPE_CHECKING
from shapely.ops import unary_union
from game.ato.flightstate import InFlight
from game.ato.flightstate import InCombat, InFlight
from game.utils import dcs_to_shapely_point
from .joinablecombat import JoinableCombat
if TYPE_CHECKING:
from game.ato import Flight
from ..simulationresults import SimulationResults
class AirCombat(JoinableCombat):
def __init__(self, flights: list[Flight]) -> None:
super().__init__(flights)
def __init__(self, freeze_duration: timedelta, flights: list[Flight]) -> None:
super().__init__(freeze_duration, flights)
footprints = []
for flight in self.flights:
if (region := flight.state.a2a_commit_region()) is not None:
@ -37,7 +41,7 @@ class AirCombat(JoinableCombat):
return True
return False
def because(self) -> str:
def __str__(self) -> str:
blue_flights = []
red_flights = []
for flight in self.flights:
@ -48,7 +52,46 @@ class AirCombat(JoinableCombat):
blue = ", ".join(blue_flights)
red = ", ".join(red_flights)
return f"of air combat {blue} vs {red}"
return f"air combat {blue} vs {red}"
def because(self) -> str:
return f"of {self}"
def describe(self) -> str:
return f"in air-to-air combat"
def resolve(self, results: SimulationResults) -> None:
blue = []
red = []
for flight in self.flights:
if flight.squadron.player:
blue.append(flight)
else:
red.append(flight)
if len(blue) > len(red):
winner = blue
loser = red
elif len(blue) < len(red):
winner = red
loser = blue
elif random.random() >= 0.5:
winner = blue
loser = red
else:
winner = red
loser = blue
if winner == blue:
logging.debug(f"{self} auto-resolved as blue victory")
else:
logging.debug(f"{self} auto-resolved as red victory")
for flight in loser:
flight.kill(results)
for flight in winner:
assert isinstance(flight.state, InCombat)
if random.random() / flight.count >= 0.5:
flight.kill(results)
else:
flight.state.exit_combat()

View File

@ -1,17 +1,20 @@
from __future__ import annotations
import logging
from collections.abc import Iterator
from datetime import timedelta
from typing import TYPE_CHECKING
from .frozencombat import FrozenCombat
if TYPE_CHECKING:
from game.ato import Flight
from ..simulationresults import SimulationResults
class AtIp(FrozenCombat):
def __init__(self, flight: Flight) -> None:
super().__init__()
def __init__(self, freeze_duration: timedelta, flight: Flight) -> None:
super().__init__(freeze_duration)
self.flight = flight
def because(self) -> str:
@ -22,3 +25,9 @@ class AtIp(FrozenCombat):
def iter_flights(self) -> Iterator[Flight]:
yield self.flight
def resolve(self, results: SimulationResults) -> None:
logging.debug(
f"{self.flight} attack on {self.flight.package.target} auto-resolved with "
"mission failure but no losses"
)

View File

@ -3,9 +3,9 @@ from __future__ import annotations
import itertools
import logging
from collections.abc import Iterator
from datetime import timedelta
from typing import Optional, TYPE_CHECKING
from game.ato.flightstate import InFlight
from .aircombat import AirCombat
from .aircraftengagementzones import AircraftEngagementZones
from .atip import AtIp
@ -43,6 +43,9 @@ class CombatInitiator:
# aircraft has entered combat it will not be rechecked later in the loop or on
# another tick.
for flight in self.iter_flights():
if flight.state.in_combat:
return
if flight.squadron.player:
a2a = red_a2a
own_a2a = blue_a2a
@ -66,7 +69,7 @@ class CombatInitiator:
own_a2a.remove_flight(flight)
self.events.update_combat(joined)
elif (combat := self.check_flight_for_new_combat(flight, a2a, sam)) is not None:
logging.info(f"Interrupting simulation because {combat.because()}")
logging.info(f"Creating new combat because {combat.because()}")
combat.update_flight_states()
# Remove any preoccupied flights from the list of potential air-to-air
# threats. This prevents BARCAPs (and other air-to-air types) from getting
@ -89,21 +92,23 @@ class CombatInitiator:
def check_flight_for_new_combat(
flight: Flight, a2a: AircraftEngagementZones, sam: SamEngagementZones
) -> Optional[FrozenCombat]:
if not isinstance(flight.state, InFlight):
if not flight.state.in_flight:
return None
if flight.state.is_at_ip:
return AtIp(flight)
return AtIp(timedelta(minutes=1), flight)
position = flight.state.estimate_position()
if flight.state.vulnerable_to_intercept and a2a.covers(position):
flights = [flight]
flights.extend(a2a.iter_intercepting_flights(position))
return AirCombat(flights)
return AirCombat(timedelta(minutes=1), flights)
if flight.state.vulnerable_to_sam and sam.covers(position):
return DefendingSam(flight, list(sam.iter_threatening_sams(position)))
return DefendingSam(
timedelta(minutes=1), flight, list(sam.iter_threatening_sams(position))
)
return None

View File

@ -1,18 +1,28 @@
from __future__ import annotations
import logging
import random
from collections.abc import Iterator
from typing import Any, TYPE_CHECKING
from datetime import timedelta
from typing import TYPE_CHECKING
from game.ato.flightstate import InCombat
from .frozencombat import FrozenCombat
if TYPE_CHECKING:
from game.ato import Flight
from game.theater import TheaterGroundObject
from ..simulationresults import SimulationResults
class DefendingSam(FrozenCombat):
def __init__(self, flight: Flight, air_defenses: list[TheaterGroundObject]) -> None:
super().__init__()
def __init__(
self,
freeze_duration: timedelta,
flight: Flight,
air_defenses: list[TheaterGroundObject],
) -> None:
super().__init__(freeze_duration)
self.flight = flight
self.air_defenses = air_defenses
@ -25,3 +35,14 @@ class DefendingSam(FrozenCombat):
def iter_flights(self) -> Iterator[Flight]:
yield self.flight
def resolve(self, results: SimulationResults) -> None:
assert isinstance(self.flight.state, InCombat)
if random.random() / self.flight.count >= 0.5:
logging.debug(f"Air defense combat auto-resolved with {self.flight} lost")
self.flight.kill(results)
else:
logging.debug(
f"Air defense combat auto-resolved with {self.flight} surviving"
)
self.flight.state.exit_combat()

View File

@ -3,17 +3,32 @@ from __future__ import annotations
import uuid
from abc import ABC, abstractmethod
from collections.abc import Iterator
from datetime import timedelta
from typing import TYPE_CHECKING
from game.ato.flightstate import InCombat, InFlight
if TYPE_CHECKING:
from game.ato import Flight
from ..simulationresults import SimulationResults
class FrozenCombat(ABC):
def __init__(self) -> None:
def __init__(self, freeze_duration: timedelta) -> None:
self.id = uuid.uuid4()
self.freeze_duration = freeze_duration
self.elapsed_time = timedelta()
def on_game_tick(self, duration: timedelta, results: SimulationResults) -> bool:
self.elapsed_time += duration
if self.elapsed_time >= self.freeze_duration:
self.resolve(results)
return True
return False
@abstractmethod
def resolve(self, results: SimulationResults) -> None:
...
@abstractmethod
def because(self) -> str:

View File

@ -2,8 +2,10 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Iterator
from datetime import timedelta
from typing import TYPE_CHECKING
from game.ato.flightstate import InCombat, InFlight
from .frozencombat import FrozenCombat
if TYPE_CHECKING:
@ -11,8 +13,8 @@ if TYPE_CHECKING:
class JoinableCombat(FrozenCombat, ABC):
def __init__(self, flights: list[Flight]) -> None:
super().__init__()
def __init__(self, freeze_duration: timedelta, flights: list[Flight]) -> None:
super().__init__(freeze_duration)
self.flights = flights
@abstractmethod
@ -20,7 +22,10 @@ class JoinableCombat(FrozenCombat, ABC):
...
def join(self, flight: Flight) -> None:
assert isinstance(flight.state, InFlight)
assert not isinstance(flight.state, InCombat)
self.flights.append(flight)
flight.set_state(InCombat(flight.state, self))
def iter_flights(self) -> Iterator[Flight]:
yield from self.flights

View File

@ -16,6 +16,7 @@ class GameUpdateEvents:
simulation_complete = False
new_combats: list[FrozenCombat] = field(default_factory=list)
updated_combats: list[FrozenCombat] = field(default_factory=list)
ended_combats: list[FrozenCombat] = field(default_factory=list)
updated_flight_positions: list[tuple[Flight, Point]] = field(default_factory=list)
navmesh_updates: set[bool] = field(default_factory=set)
unculled_zones_updated: bool = False
@ -43,6 +44,9 @@ class GameUpdateEvents:
self.updated_combats.append(combat)
return self
def end_combat(self, combat: FrozenCombat) -> None:
self.ended_combats.append(combat)
def update_flight_position(
self, flight: Flight, new_position: Point
) -> GameUpdateEvents:

View File

@ -62,7 +62,9 @@ class MissionSimulation:
data = json.load(state_file)
if force_end:
data["mission_ended"] = True
return Debriefing(data, self.game, self.unit_map)
debriefing = Debriefing(data, self.game, self.unit_map)
debriefing.merge_simulation_results(self.aircraft_simulation.results)
return debriefing
def process_results(self, debriefing: Debriefing) -> None:
if self.unit_map is None:

View File

@ -0,0 +1,23 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from game.unitmap import FlyingUnit
if TYPE_CHECKING:
from game.ato import Flight
from game.squadrons import Pilot
# TODO: Serialize for bug reproducibility.
# Any bugs filed that can only be reproduced with auto-resolved combat results will not
# be reproducible since we cannot replay the auto-resolution that the player saw. We
# need to be able to serialize this data so bug repro can include the auto-resolved
# results.
@dataclass
class SimulationResults:
air_losses: list[FlyingUnit] = field(default_factory=list)
def kill_pilot(self, flight: Flight, pilot: Pilot) -> None:
self.air_losses.append(FlyingUnit(flight, pilot))

View File

@ -56,10 +56,7 @@ class SimController(QObject):
if game is not None:
self.game_loop = GameLoop(
game,
GameUpdateCallbacks(
self.on_simulation_complete,
self.sim_update.emit,
),
GameUpdateCallbacks(self.on_simulation_complete, self.sim_update.emit),
)
self.started = False

View File

@ -400,6 +400,10 @@ function handleStreamedEvents(events) {
redrawCombat(combat);
}
for (const combatId of events.ended_combats) {
clearCombat(combatId);
}
for (const player of events.navmesh_updates) {
drawNavmesh(player);
}
@ -1355,18 +1359,23 @@ function drawHoldZones(id) {
var COMBATS = {};
function redrawCombat(combat) {
if (combat.id in COMBATS) {
for (layer in COMBATS[combat.id]) {
function clearCombat(id) {
if (id in COMBATS) {
for (const layer of COMBATS[id]) {
layer.removeFrom(combatLayer);
}
delete COMBATS[id];
}
}
function redrawCombat(combat) {
clearCombat(combat.id);
const layers = [];
if (combat.footprint) {
layers.push(
L.polygon(airCombat.footprint, {
L.polygon(combat.footprint, {
color: Colors.Red,
interactive: false,
fillOpacity: 0.2,