From 2585dcc13071111b9d93a8c1cf1333cfbd76031c Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 22 Dec 2021 13:07:10 -0800 Subject: [PATCH] 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 --- game/ato/flight.py | 25 ++++++++++++++ game/ato/flightstate/flightstate.py | 12 +++++++ game/ato/flightstate/incombat.py | 15 +++++--- game/ato/flightstate/inflight.py | 4 +++ game/debriefing.py | 12 +++++-- game/server/eventstream/models.py | 2 ++ game/settings/settings.py | 12 +++++++ game/sim/aircraftsimulation.py | 24 ++++++++++++- game/sim/combat/aircombat.py | 53 ++++++++++++++++++++++++++--- game/sim/combat/atip.py | 13 +++++-- game/sim/combat/combatinitiator.py | 17 +++++---- game/sim/combat/defendingsam.py | 27 +++++++++++++-- game/sim/combat/frozencombat.py | 17 ++++++++- game/sim/combat/joinablecombat.py | 9 +++-- game/sim/gameupdateevents.py | 4 +++ game/sim/missionsimulation.py | 4 ++- game/sim/simulationresults.py | 23 +++++++++++++ qt_ui/simcontroller.py | 5 +-- resources/ui/map/map.js | 17 ++++++--- 19 files changed, 260 insertions(+), 35 deletions(-) create mode 100644 game/sim/simulationresults.py diff --git a/game/ato/flight.py b/game/ato/flight.py index cc0e9d0f..09cfda30 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -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) diff --git a/game/ato/flightstate/flightstate.py b/game/ato/flightstate/flightstate.py index 200ce688..5fe45c76 100644 --- a/game/ato/flightstate/flightstate.py +++ b/game/ato/flightstate/flightstate.py @@ -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 diff --git a/game/ato/flightstate/incombat.py b/game/ato/flightstate/incombat.py index 7aa15186..ccbf6347 100644 --- a/game/ato/flightstate/incombat.py +++ b/game/ato/flightstate/incombat.py @@ -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 diff --git a/game/ato/flightstate/inflight.py b/game/ato/flightstate/inflight.py index 40987246..0f97d635 100644 --- a/game/ato/flightstate/inflight.py +++ b/game/ato/flightstate/inflight.py @@ -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 diff --git a/game/debriefing.py b/game/debriefing.py index b365b3d3..817f0288 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -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 diff --git a/game/server/eventstream/models.py b/game/server/eventstream/models.py index 3f1d14c6..e25c48aa 100644 --- a/game/server/eventstream/models.py +++ b/game/server/eventstream/models.py @@ -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, diff --git a/game/settings/settings.py b/game/settings/settings.py index 2fba3762..2dd3934a 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -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, diff --git a/game/sim/aircraftsimulation.py b/game/sim/aircraftsimulation.py index 930ced8f..aa2416c6 100644 --- a/game/sim/aircraftsimulation.py +++ b/game/sim/aircraftsimulation.py @@ -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(): diff --git a/game/sim/combat/aircombat.py b/game/sim/combat/aircombat.py index a1a44f97..1b3800a1 100644 --- a/game/sim/combat/aircombat.py +++ b/game/sim/combat/aircombat.py @@ -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() diff --git a/game/sim/combat/atip.py b/game/sim/combat/atip.py index ca2c9b3a..fd34480e 100644 --- a/game/sim/combat/atip.py +++ b/game/sim/combat/atip.py @@ -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" + ) diff --git a/game/sim/combat/combatinitiator.py b/game/sim/combat/combatinitiator.py index a95c3126..17c5d2d3 100644 --- a/game/sim/combat/combatinitiator.py +++ b/game/sim/combat/combatinitiator.py @@ -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 diff --git a/game/sim/combat/defendingsam.py b/game/sim/combat/defendingsam.py index 108bc408..4cc2ad1a 100644 --- a/game/sim/combat/defendingsam.py +++ b/game/sim/combat/defendingsam.py @@ -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() diff --git a/game/sim/combat/frozencombat.py b/game/sim/combat/frozencombat.py index b2f0fc7b..09601191 100644 --- a/game/sim/combat/frozencombat.py +++ b/game/sim/combat/frozencombat.py @@ -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: diff --git a/game/sim/combat/joinablecombat.py b/game/sim/combat/joinablecombat.py index 63dfb876..84c19c40 100644 --- a/game/sim/combat/joinablecombat.py +++ b/game/sim/combat/joinablecombat.py @@ -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 diff --git a/game/sim/gameupdateevents.py b/game/sim/gameupdateevents.py index ec6498b8..83f4d372 100644 --- a/game/sim/gameupdateevents.py +++ b/game/sim/gameupdateevents.py @@ -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: diff --git a/game/sim/missionsimulation.py b/game/sim/missionsimulation.py index 044cb340..5868bc68 100644 --- a/game/sim/missionsimulation.py +++ b/game/sim/missionsimulation.py @@ -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: diff --git a/game/sim/simulationresults.py b/game/sim/simulationresults.py new file mode 100644 index 00000000..9c297319 --- /dev/null +++ b/game/sim/simulationresults.py @@ -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)) diff --git a/qt_ui/simcontroller.py b/qt_ui/simcontroller.py index 90ee7bda..f1cd8909 100644 --- a/qt_ui/simcontroller.py +++ b/qt_ui/simcontroller.py @@ -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 diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 017799b9..e76cf356 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -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,