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,