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.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]

View File

@ -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"],

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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"

View File

@ -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:

View File

@ -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()