From d09a15a7f3a1e29fb019def94ef6920f9e7bb3b0 Mon Sep 17 00:00:00 2001 From: zhexu14 <64713351+zhexu14@users.noreply.github.com> Date: Thu, 16 Oct 2025 23:51:27 +1100 Subject: [PATCH] Allow player to continue playing after the end of a turn. (#3526) This PR: Keeps track of time spent in mission Introduces a new "turnless mode" setting, which activates the following: - At the end of a mission, fast forwards through the time spent in the mission, skipping any combat (which has already been tracked through state.json) - Removes killed flights from the ATO - Does not start a new turn, instead allows the player to continue the current turn. --- game/ato/flightmembers.py | 9 +++++ game/debriefing.py | 4 +++ game/settings/settings.py | 10 ++++++ game/sim/aircraftsimulation.py | 42 ++++++++++++++++------- game/sim/gameloop.py | 6 +++- game/sim/missionresultsprocessor.py | 35 +++++++++++++++++-- game/sim/missionsimulation.py | 30 ++++++++++++++-- qt_ui/widgets/QTopPanel.py | 17 +++++---- qt_ui/windows/QLiberationWindow.py | 3 +- resources/plugins/base/dcs_liberation.lua | 5 +++ 10 files changed, 136 insertions(+), 25 deletions(-) diff --git a/game/ato/flightmembers.py b/game/ato/flightmembers.py index 8baaf01b..fd9aba5d 100644 --- a/game/ato/flightmembers.py +++ b/game/ato/flightmembers.py @@ -69,6 +69,15 @@ class FlightMembers(IFlightRoster): self.flight.squadron.return_pilot(current_pilot) self.members[index].pilot = pilot + def remove_pilot(self, pilot: Pilot) -> None: + for i, member in enumerate(self.members): + if member.pilot is not None and member.pilot.name == pilot.name: + self.members.pop(i) + if (code := member.tgp_laser_code) is not None: + code.release() + return + raise ValueError(f"Pilot {pilot.name} not a member") + def clear(self) -> None: self.flight.squadron.return_pilots( [p for p in self.iter_pilots() if p is not None] diff --git a/game/debriefing.py b/game/debriefing.py index 64c78385..20f45869 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -196,6 +196,9 @@ class StateData: #: True if the mission ended. If False, the mission exited abnormally. mission_ended: bool + #: Simulation time since mission start in seconds + simulation_time_seconds: float + #: Names of aircraft units that were killed during the mission. killed_aircraft: List[str] @@ -248,6 +251,7 @@ class StateData: return cls( mission_ended=data["mission_ended"], + simulation_time_seconds=data["simulation_time_seconds"], killed_aircraft=killed_aircraft, killed_ground_units=killed_ground_units, destroyed_statics=data["destroyed_objects_positions"], diff --git a/game/settings/settings.py b/game/settings/settings.py index 27c620b5..22f1e4bb 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -355,6 +355,16 @@ class Settings: "your game after aborting take off." ), ) + turnless_mode: bool = boolean_option( + "Enable turnless mode (WIP)", + page=MISSION_GENERATOR_PAGE, + section=GAMEPLAY_SECTION, + default=False, + detail=( + "If enabled, turns do not end after mission completion. A new mission " + "can be started picking up from where the previous mission ended." + ), + ) fast_forward_stop_condition: FastForwardStopCondition = choices_option( "Fast forward until", page=MISSION_GENERATOR_PAGE, diff --git a/game/sim/aircraftsimulation.py b/game/sim/aircraftsimulation.py index 8303fdaa..43c0ac87 100644 --- a/game/sim/aircraftsimulation.py +++ b/game/sim/aircraftsimulation.py @@ -28,9 +28,17 @@ class AircraftSimulation: self.set_initial_flight_states() def on_game_tick( - self, events: GameUpdateEvents, time: datetime, duration: timedelta + self, + events: GameUpdateEvents, + time: datetime, + duration: timedelta, + combat_resolution_method: CombatResolutionMethod, + force_continue: bool, ) -> None: - if not self._auto_resolve_combat() and self.combats: + if ( + not self._auto_resolve_combat(combat_resolution_method, force_continue) + and self.combats + ): logging.error( "Cannot resume simulation because aircraft are in combat and " "auto-resolve is disabled" @@ -45,7 +53,7 @@ class AircraftSimulation: duration, self.results, events, - self.game.settings.combat_resolution_method, + combat_resolution_method, ): events.end_combat(combat) else: @@ -61,11 +69,22 @@ class AircraftSimulation: # After updating all combat states, check for halts. for flight in self.iter_flights(): - if flight.should_halt_sim(): + if flight.should_halt_sim() and not force_continue: events.complete_simulation() return - if not self._auto_resolve_combat() and self.combats: + # Find completed flights, removing them from the ATO and returning aircraft + # and pilots back to the squadron. + for flight in self.iter_flights(): + if type(flight.state) == Completed: + flight.package.remove_flight(flight) + if len(flight.package.flights) == 0: + flight.squadron.coalition.ato.remove_package(flight.package) + + if ( + not self._auto_resolve_combat(combat_resolution_method, force_continue) + and self.combats + ): events.complete_simulation() def set_initial_flight_states(self) -> None: @@ -90,10 +109,9 @@ class AircraftSimulation: for package in packages: yield from package.flights - def _auto_resolve_combat(self) -> bool: - return ( - self.game.settings.fast_forward_stop_condition - != FastForwardStopCondition.DISABLED - and self.game.settings.combat_resolution_method - != CombatResolutionMethod.PAUSE - ) + def _auto_resolve_combat( + self, combat_resolution_method: CombatResolutionMethod, force_continue: bool + ) -> bool: + if force_continue: + return True + return combat_resolution_method != CombatResolutionMethod.PAUSE diff --git a/game/sim/gameloop.py b/game/sim/gameloop.py index 1b39b0b7..d1122989 100644 --- a/game/sim/gameloop.py +++ b/game/sim/gameloop.py @@ -100,7 +100,11 @@ class GameLoop: if not self.started: raise RuntimeError("Attempted to tick game loop before initialization") try: - self.sim.tick(self.events) + self.sim.tick( + self.events, + self.game.settings.combat_resolution_method, + force_continue=False, + ) self.completed = self.events.simulation_complete if not suppress_events: self.send_update(rate_limit=True) diff --git a/game/sim/missionresultsprocessor.py b/game/sim/missionresultsprocessor.py index 71a0fd24..b7f4e9f4 100644 --- a/game/sim/missionresultsprocessor.py +++ b/game/sim/missionresultsprocessor.py @@ -8,6 +8,7 @@ from game.ground_forces.combat_stance import CombatStance from game.theater import ControlPoint from .gameupdateevents import GameUpdateEvents from ..ato.airtaaskingorder import AirTaskingOrder +from ..ato.flightstate.atdeparture import AtDeparture if TYPE_CHECKING: from ..game import Game @@ -57,6 +58,25 @@ class MissionResultsProcessor: logging.info(f"{aircraft} destroyed from {squadron}") squadron.owned_aircraft -= 1 + # Remove air losses from the flight. Remove the flight if all aircraft are lost. + # Remove the package if the flight is the last flight in the package. + # This logic is redundant if we are going to a new turn, since the whole ATO is + # regenerated. However if we want to keep the ATO to continue a turn, this update + # is necessary to make sure lost aircraft are removed from the ATO. + if loss.pilot is not None: + loss.flight.roster.remove_pilot(loss.pilot) + if loss.flight.count == 0: # Last aircraft in the flight, remove the flight + # If no flights in package, generally indicates that the loss is an aircraft + # that is not assigned to a mission and is parked on the ground. There is no need + # to remove the aircraft from the ATO as it was never in the ATO in the first place. + if len(loss.flight.package.flights) == 0: + continue + loss.flight.package.remove_flight(loss.flight) + if len(loss.flight.package.flights) == 0: + loss.flight.squadron.coalition.ato.remove_package( + loss.flight.package + ) + @staticmethod def _commit_pilot_experience(ato: AirTaskingOrder) -> None: for package in ato.packages: @@ -148,10 +168,21 @@ class MissionResultsProcessor: iads_network.update_network(events) return - @staticmethod - def commit_damaged_runways(debriefing: Debriefing) -> None: + def commit_damaged_runways(self, debriefing: Debriefing) -> None: for damaged_runway in debriefing.damaged_runways: damaged_runway.damage_runway() + # Remove any flight in ATO scheduled to take off from the damaged runway. + for coalition in self.game.coalitions: + for package in coalition.ato.packages: + for flight in package.flights: + if flight.departure.name == damaged_runway.name and isinstance( + flight.state, AtDeparture + ): + flight.package.remove_flight(flight) + if len(flight.package.flights) == 0: + flight.squadron.coalition.ato.remove_package( + flight.package + ) def commit_captures(self, debriefing: Debriefing, events: GameUpdateEvents) -> None: for captured in debriefing.base_captures: diff --git a/game/sim/missionsimulation.py b/game/sim/missionsimulation.py index 90a562c9..e46ce7c0 100644 --- a/game/sim/missionsimulation.py +++ b/game/sim/missionsimulation.py @@ -7,6 +7,7 @@ from typing import Optional, TYPE_CHECKING from game.debriefing import Debriefing from game.missiongenerator import MissionGenerator +from game.settings.settings import FastForwardStopCondition, CombatResolutionMethod from game.unitmap import UnitMap from .aircraftsimulation import AircraftSimulation from .missionresultsprocessor import MissionResultsProcessor @@ -37,12 +38,25 @@ class MissionSimulation: self.time = self.game.simulation_time self.aircraft_simulation.begin_simulation() - def tick(self, events: GameUpdateEvents) -> GameUpdateEvents: + def tick( + self, + events: GameUpdateEvents, + combat_resolution_method: CombatResolutionMethod, + force_continue: bool, + ) -> GameUpdateEvents: self.time += TICK self.game.simulation_time = self.time if self.completed: raise RuntimeError("Simulation already completed") - self.aircraft_simulation.on_game_tick(events, self.time, TICK) + if ( + self.game.settings.fast_forward_stop_condition + == FastForwardStopCondition.DISABLED + ): + events.complete_simulation() + return events + self.aircraft_simulation.on_game_tick( + events, self.time, TICK, combat_resolution_method, force_continue + ) self.completed = events.simulation_complete return events @@ -76,6 +90,18 @@ class MissionSimulation: self.game.save_last_turn_state() MissionResultsProcessor(self.game).commit(debriefing, events) + if self.game.settings.turnless_mode: + # Set completed to False to clear completion of any previous simulation tick. + self.completed = False + # If running in turnless mode, run sim to calculate planned positions of flights + # for the duration of time the DCS mission ran. + start_time = copy.deepcopy(self.time) + while self.time < start_time + timedelta( + seconds=int(debriefing.state_data.simulation_time_seconds) + ): + # Always skip combat as we are processing results from DCS. Any combat has already + # been resolved in-game + self.tick(events, CombatResolutionMethod.SKIP, force_continue=True) def finish(self) -> None: self.unit_map = None diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 39958eff..72f115f6 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -50,10 +50,7 @@ class QTopPanel(QFrame): self.conditionsWidget = QConditionsWidget(sim_controller) self.budgetBox = QBudgetBox(self.game) - pass_turn_text = "Pass Turn" - if not self.game or self.game.turn == 0: - pass_turn_text = "Begin Campaign" - self.passTurnButton = QPushButton(pass_turn_text) + self.passTurnButton = QPushButton(self._pass_turn_button_text(self.game)) self.passTurnButton.setIcon(CONST.ICONS["PassTurn"]) self.passTurnButton.setProperty("style", "btn-primary") self.passTurnButton.clicked.connect(self.passTurn) @@ -120,11 +117,9 @@ class QTopPanel(QFrame): self.factionsInfos.setGame(game) self.passTurnButton.setEnabled(True) - if game and game.turn > 0: - self.passTurnButton.setText("Pass Turn") + self.passTurnButton.setText(self._pass_turn_button_text(game)) if game and game.turn == 0: - self.passTurnButton.setText("Begin Campaign") self.proceedButton.setEnabled(False) else: self.proceedButton.setEnabled(True) @@ -283,3 +278,11 @@ class QTopPanel(QFrame): def budget_update(self, game: Game): self.budgetBox.setGame(game) + + def _pass_turn_button_text(self, game: Game) -> None: + if game and game.turn > 0: + if game.settings.turnless_mode: + return "End Turn" + else: + return "Pass Turn" + return "Begin campaign" diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index de57dcd8..a1cbb6a6 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -571,7 +571,8 @@ class QLiberationWindow(QMainWindow): if state is not TurnState.CONTINUE: GameOverDialog(won=state is TurnState.WIN, parent=self).exec() else: - self.game.pass_turn() + if not self.game.settings.turnless_mode: + self.game.pass_turn() GameUpdateSignal.get_instance().updateGame(self.game) def open_tgo_info_dialog(self, tgo: TheaterGroundObject) -> None: diff --git a/resources/plugins/base/dcs_liberation.lua b/resources/plugins/base/dcs_liberation.lua index 6888b84b..d94d3e4e 100644 --- a/resources/plugins/base/dcs_liberation.lua +++ b/resources/plugins/base/dcs_liberation.lua @@ -4,6 +4,7 @@ local WRITESTATE_SCHEDULE_IN_SECONDS = 60 logger = mist.Logger:new("DCSLiberation", "info") logger:info("Check that json.lua is loaded : json = "..tostring(json)) +simulation_time_seconds = 0 crash_events = {} -- killed aircraft will be added via S_EVENT_CRASH event dead_events = {} -- killed units will be added via S_EVENT_DEAD event unit_lost_events = {} -- killed units will be added via S_EVENT_UNIT_LOST @@ -43,6 +44,7 @@ function write_state() ["destroyed_objects_positions"] = destroyed_objects_positions, ["killed_ground_units"] = killed_ground_units, ["unit_hit_point_updates"] = unit_hit_point_updates, + ["simulation_time_seconds"] = simulation_time_seconds } if not json then local message = string.format("Unable to save DCS Liberation state to %s, JSON library is not loaded !", _debriefing_file_location) @@ -160,6 +162,9 @@ end activeWeapons = {} local function onEvent(event) + + simulation_time_seconds = event.time + if event.id == world.event.S_EVENT_CRASH and event.initiator then crash_events[#crash_events + 1] = event.initiator.getName(event.initiator) write_state()