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.
This commit is contained in:
zhexu14 2025-10-16 23:51:27 +11:00 committed by GitHub
parent 5676c40788
commit d09a15a7f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 136 additions and 25 deletions

View File

@ -69,6 +69,15 @@ class FlightMembers(IFlightRoster):
self.flight.squadron.return_pilot(current_pilot) self.flight.squadron.return_pilot(current_pilot)
self.members[index].pilot = 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: def clear(self) -> None:
self.flight.squadron.return_pilots( self.flight.squadron.return_pilots(
[p for p in self.iter_pilots() if p is not None] [p for p in self.iter_pilots() if p is not None]

View File

@ -196,6 +196,9 @@ class StateData:
#: True if the mission ended. If False, the mission exited abnormally. #: True if the mission ended. If False, the mission exited abnormally.
mission_ended: bool mission_ended: bool
#: Simulation time since mission start in seconds
simulation_time_seconds: float
#: Names of aircraft units that were killed during the mission. #: Names of aircraft units that were killed during the mission.
killed_aircraft: List[str] killed_aircraft: List[str]
@ -248,6 +251,7 @@ class StateData:
return cls( return cls(
mission_ended=data["mission_ended"], mission_ended=data["mission_ended"],
simulation_time_seconds=data["simulation_time_seconds"],
killed_aircraft=killed_aircraft, killed_aircraft=killed_aircraft,
killed_ground_units=killed_ground_units, killed_ground_units=killed_ground_units,
destroyed_statics=data["destroyed_objects_positions"], destroyed_statics=data["destroyed_objects_positions"],

View File

@ -355,6 +355,16 @@ class Settings:
"your game after aborting take off." "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_stop_condition: FastForwardStopCondition = choices_option(
"Fast forward until", "Fast forward until",
page=MISSION_GENERATOR_PAGE, page=MISSION_GENERATOR_PAGE,

View File

@ -28,9 +28,17 @@ class AircraftSimulation:
self.set_initial_flight_states() self.set_initial_flight_states()
def on_game_tick( 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: ) -> 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( logging.error(
"Cannot resume simulation because aircraft are in combat and " "Cannot resume simulation because aircraft are in combat and "
"auto-resolve is disabled" "auto-resolve is disabled"
@ -45,7 +53,7 @@ class AircraftSimulation:
duration, duration,
self.results, self.results,
events, events,
self.game.settings.combat_resolution_method, combat_resolution_method,
): ):
events.end_combat(combat) events.end_combat(combat)
else: else:
@ -61,11 +69,22 @@ class AircraftSimulation:
# After updating all combat states, check for halts. # After updating all combat states, check for halts.
for flight in self.iter_flights(): for flight in self.iter_flights():
if flight.should_halt_sim(): if flight.should_halt_sim() and not force_continue:
events.complete_simulation() events.complete_simulation()
return 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() events.complete_simulation()
def set_initial_flight_states(self) -> None: def set_initial_flight_states(self) -> None:
@ -90,10 +109,9 @@ class AircraftSimulation:
for package in packages: for package in packages:
yield from package.flights yield from package.flights
def _auto_resolve_combat(self) -> bool: def _auto_resolve_combat(
return ( self, combat_resolution_method: CombatResolutionMethod, force_continue: bool
self.game.settings.fast_forward_stop_condition ) -> bool:
!= FastForwardStopCondition.DISABLED if force_continue:
and self.game.settings.combat_resolution_method return True
!= CombatResolutionMethod.PAUSE return combat_resolution_method != CombatResolutionMethod.PAUSE
)

View File

@ -100,7 +100,11 @@ class GameLoop:
if not self.started: if not self.started:
raise RuntimeError("Attempted to tick game loop before initialization") raise RuntimeError("Attempted to tick game loop before initialization")
try: 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 self.completed = self.events.simulation_complete
if not suppress_events: if not suppress_events:
self.send_update(rate_limit=True) self.send_update(rate_limit=True)

View File

@ -8,6 +8,7 @@ from game.ground_forces.combat_stance import CombatStance
from game.theater import ControlPoint from game.theater import ControlPoint
from .gameupdateevents import GameUpdateEvents from .gameupdateevents import GameUpdateEvents
from ..ato.airtaaskingorder import AirTaskingOrder from ..ato.airtaaskingorder import AirTaskingOrder
from ..ato.flightstate.atdeparture import AtDeparture
if TYPE_CHECKING: if TYPE_CHECKING:
from ..game import Game from ..game import Game
@ -57,6 +58,25 @@ class MissionResultsProcessor:
logging.info(f"{aircraft} destroyed from {squadron}") logging.info(f"{aircraft} destroyed from {squadron}")
squadron.owned_aircraft -= 1 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 @staticmethod
def _commit_pilot_experience(ato: AirTaskingOrder) -> None: def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
for package in ato.packages: for package in ato.packages:
@ -148,10 +168,21 @@ class MissionResultsProcessor:
iads_network.update_network(events) iads_network.update_network(events)
return return
@staticmethod def commit_damaged_runways(self, debriefing: Debriefing) -> None:
def commit_damaged_runways(debriefing: Debriefing) -> None:
for damaged_runway in debriefing.damaged_runways: for damaged_runway in debriefing.damaged_runways:
damaged_runway.damage_runway() 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: def commit_captures(self, debriefing: Debriefing, events: GameUpdateEvents) -> None:
for captured in debriefing.base_captures: for captured in debriefing.base_captures:

View File

@ -7,6 +7,7 @@ from typing import Optional, TYPE_CHECKING
from game.debriefing import Debriefing from game.debriefing import Debriefing
from game.missiongenerator import MissionGenerator from game.missiongenerator import MissionGenerator
from game.settings.settings import FastForwardStopCondition, CombatResolutionMethod
from game.unitmap import UnitMap from game.unitmap import UnitMap
from .aircraftsimulation import AircraftSimulation from .aircraftsimulation import AircraftSimulation
from .missionresultsprocessor import MissionResultsProcessor from .missionresultsprocessor import MissionResultsProcessor
@ -37,12 +38,25 @@ class MissionSimulation:
self.time = self.game.simulation_time self.time = self.game.simulation_time
self.aircraft_simulation.begin_simulation() 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.time += TICK
self.game.simulation_time = self.time self.game.simulation_time = self.time
if self.completed: if self.completed:
raise RuntimeError("Simulation already 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 self.completed = events.simulation_complete
return events return events
@ -76,6 +90,18 @@ class MissionSimulation:
self.game.save_last_turn_state() self.game.save_last_turn_state()
MissionResultsProcessor(self.game).commit(debriefing, events) 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: def finish(self) -> None:
self.unit_map = None self.unit_map = None

View File

@ -50,10 +50,7 @@ class QTopPanel(QFrame):
self.conditionsWidget = QConditionsWidget(sim_controller) self.conditionsWidget = QConditionsWidget(sim_controller)
self.budgetBox = QBudgetBox(self.game) self.budgetBox = QBudgetBox(self.game)
pass_turn_text = "Pass Turn" self.passTurnButton = QPushButton(self._pass_turn_button_text(self.game))
if not self.game or self.game.turn == 0:
pass_turn_text = "Begin Campaign"
self.passTurnButton = QPushButton(pass_turn_text)
self.passTurnButton.setIcon(CONST.ICONS["PassTurn"]) self.passTurnButton.setIcon(CONST.ICONS["PassTurn"])
self.passTurnButton.setProperty("style", "btn-primary") self.passTurnButton.setProperty("style", "btn-primary")
self.passTurnButton.clicked.connect(self.passTurn) self.passTurnButton.clicked.connect(self.passTurn)
@ -120,11 +117,9 @@ class QTopPanel(QFrame):
self.factionsInfos.setGame(game) self.factionsInfos.setGame(game)
self.passTurnButton.setEnabled(True) self.passTurnButton.setEnabled(True)
if game and game.turn > 0: self.passTurnButton.setText(self._pass_turn_button_text(game))
self.passTurnButton.setText("Pass Turn")
if game and game.turn == 0: if game and game.turn == 0:
self.passTurnButton.setText("Begin Campaign")
self.proceedButton.setEnabled(False) self.proceedButton.setEnabled(False)
else: else:
self.proceedButton.setEnabled(True) self.proceedButton.setEnabled(True)
@ -283,3 +278,11 @@ class QTopPanel(QFrame):
def budget_update(self, game: Game): def budget_update(self, game: Game):
self.budgetBox.setGame(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"

View File

@ -571,7 +571,8 @@ class QLiberationWindow(QMainWindow):
if state is not TurnState.CONTINUE: if state is not TurnState.CONTINUE:
GameOverDialog(won=state is TurnState.WIN, parent=self).exec() GameOverDialog(won=state is TurnState.WIN, parent=self).exec()
else: else:
self.game.pass_turn() if not self.game.settings.turnless_mode:
self.game.pass_turn()
GameUpdateSignal.get_instance().updateGame(self.game) GameUpdateSignal.get_instance().updateGame(self.game)
def open_tgo_info_dialog(self, tgo: TheaterGroundObject) -> None: def open_tgo_info_dialog(self, tgo: TheaterGroundObject) -> None:

View File

@ -4,6 +4,7 @@ local WRITESTATE_SCHEDULE_IN_SECONDS = 60
logger = mist.Logger:new("DCSLiberation", "info") logger = mist.Logger:new("DCSLiberation", "info")
logger:info("Check that json.lua is loaded : json = "..tostring(json)) 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 crash_events = {} -- killed aircraft will be added via S_EVENT_CRASH event
dead_events = {} -- killed units will be added via S_EVENT_DEAD 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 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, ["destroyed_objects_positions"] = destroyed_objects_positions,
["killed_ground_units"] = killed_ground_units, ["killed_ground_units"] = killed_ground_units,
["unit_hit_point_updates"] = unit_hit_point_updates, ["unit_hit_point_updates"] = unit_hit_point_updates,
["simulation_time_seconds"] = simulation_time_seconds
} }
if not json then if not json then
local message = string.format("Unable to save DCS Liberation state to %s, JSON library is not loaded !", _debriefing_file_location) 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 = {} activeWeapons = {}
local function onEvent(event) local function onEvent(event)
simulation_time_seconds = event.time
if event.id == world.event.S_EVENT_CRASH and event.initiator then if event.id == world.event.S_EVENT_CRASH and event.initiator then
crash_events[#crash_events + 1] = event.initiator.getName(event.initiator) crash_events[#crash_events + 1] = event.initiator.getName(event.initiator)
write_state() write_state()