From 59673e791158e7b442851f1a2176cf50253c698c Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 2 Sep 2022 20:58:26 -0700 Subject: [PATCH] Convert TOTs to datetime. https://github.com/dcs-liberation/dcs_liberation/issues/1680 --- game/ato/flightplans/airassault.py | 14 +++--- game/ato/flightplans/airlift.py | 15 ++++--- game/ato/flightplans/custom.py | 12 +++-- game/ato/flightplans/ferry.py | 12 +++-- game/ato/flightplans/flightplan.py | 33 +++++++++----- game/ato/flightplans/formation.py | 19 +++++--- game/ato/flightplans/formationattack.py | 39 +++++++++++++--- game/ato/flightplans/loiter.py | 9 ++-- game/ato/flightplans/packagerefueling.py | 4 +- game/ato/flightplans/patrolling.py | 16 ++++--- game/ato/flightplans/rtb.py | 14 +++--- game/ato/flightplans/sweep.py | 20 +++++---- game/ato/flightplans/tarcap.py | 8 ++-- game/ato/flightstate/uninitialized.py | 3 +- game/ato/flightwaypoint.py | 6 +-- game/ato/package.py | 19 ++++---- game/ato/traveltime.py | 45 +++++-------------- game/coalition.py | 11 ++--- game/commander/missionscheduler.py | 12 ++--- game/commander/packagefulfiller.py | 4 +- game/commander/tasks/packageplanningtask.py | 1 + game/commander/theatercommander.py | 5 ++- game/commander/theaterstate.py | 12 ++++- game/groundunitorders.py | 12 +++-- .../aircraft/flightgroupconfigurator.py | 1 - .../aircraft/waypoints/holdpoint.py | 2 +- .../waypoints/pydcswaypointbuilder.py | 10 ++--- .../aircraft/waypoints/racetrack.py | 2 +- .../aircraft/waypoints/waypointgenerator.py | 23 ++++++++-- game/missiongenerator/briefinggenerator.py | 7 +-- game/missiongenerator/kneeboard.py | 12 ++--- game/missiongenerator/missiondata.py | 10 ++--- game/server/waypoints/models.py | 4 +- game/sim/aircraftsimulation.py | 4 +- game/squadrons/squadron.py | 17 +++---- game/theater/controlpoint.py | 6 ++- game/transfers.py | 17 +++---- qt_ui/models.py | 16 +++---- qt_ui/simcontroller.py | 9 +++- qt_ui/widgets/QConditionsWidget.py | 2 +- qt_ui/widgets/QTopPanel.py | 9 ++-- qt_ui/widgets/ato.py | 12 +---- qt_ui/windows/AirWingDialog.py | 13 ++++-- qt_ui/windows/SquadronDialog.py | 7 ++- .../windows/basemenu/NewUnitTransferDialog.py | 4 +- qt_ui/windows/mission/QFlightItem.py | 2 +- qt_ui/windows/mission/QPackageDialog.py | 18 ++++---- qt_ui/windows/mission/QPlannedFlightsView.py | 4 +- .../settings/FlightPlanPropertiesGroup.py | 4 +- .../flight/waypoints/QFlightWaypointList.py | 12 +---- 50 files changed, 333 insertions(+), 239 deletions(-) diff --git a/game/ato/flightplans/airassault.py b/game/ato/flightplans/airassault.py index 463bb747..3ea47c2b 100644 --- a/game/ato/flightplans/airassault.py +++ b/game/ato/flightplans/airassault.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime from typing import Iterator, TYPE_CHECKING, Type from game.theater.controlpoint import ControlPointType @@ -65,21 +65,21 @@ class AirAssaultFlightPlan(FormationAttackFlightPlan, UiZoneDisplay): return self.layout.targets[0] @property - def ingress_time(self) -> timedelta: + def ingress_time(self) -> datetime: tot = self.tot travel_time = self.travel_time_between_waypoints( self.layout.ingress, self.tot_waypoint ) return tot - travel_time - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: if waypoint is self.tot_waypoint: return self.tot elif waypoint is self.layout.ingress: return self.ingress_time return None - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: return None @property @@ -87,7 +87,11 @@ class AirAssaultFlightPlan(FormationAttackFlightPlan, UiZoneDisplay): return meters(2500) @property - def mission_departure_time(self) -> timedelta: + def mission_begin_on_station_time(self) -> datetime | None: + return None + + @property + def mission_departure_time(self) -> datetime: return self.package.time_over_target def ui_zone(self) -> UiZone: diff --git a/game/ato/flightplans/airlift.py b/game/ato/flightplans/airlift.py index 55969aa5..1cc15d84 100644 --- a/game/ato/flightplans/airlift.py +++ b/game/ato/flightplans/airlift.py @@ -2,8 +2,9 @@ from __future__ import annotations from collections.abc import Iterator from dataclasses import dataclass -from datetime import timedelta -from typing import TYPE_CHECKING, Type, Optional +from datetime import datetime +from typing import Optional +from typing import TYPE_CHECKING, Type from game.theater.missiontarget import MissionTarget from game.utils import feet @@ -89,16 +90,20 @@ class AirliftFlightPlan(StandardFlightPlan[AirliftLayout]): # drop-off waypoint. return self.layout.drop_off or self.layout.arrival - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: # TOT planning isn't really useful for transports. They're behind the front # lines so no need to wait for escorts or for other missions to complete. return None - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: return None @property - def mission_departure_time(self) -> timedelta: + def mission_begin_on_station_time(self) -> datetime | None: + return None + + @property + def mission_departure_time(self) -> datetime: return self.package.time_over_target diff --git a/game/ato/flightplans/custom.py b/game/ato/flightplans/custom.py index 1befb56c..3f89eee9 100644 --- a/game/ato/flightplans/custom.py +++ b/game/ato/flightplans/custom.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Iterator from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime from typing import TYPE_CHECKING, Type from .flightplan import FlightPlan, Layout @@ -46,16 +46,20 @@ class CustomFlightPlan(FlightPlan[CustomLayout]): return waypoint return self.layout.departure - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: if waypoint == self.tot_waypoint: return self.package.time_over_target + self.tot_offset return None - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: return None @property - def mission_departure_time(self) -> timedelta: + def mission_begin_on_station_time(self) -> datetime | None: + return None + + @property + def mission_departure_time(self) -> datetime: return self.package.time_over_target diff --git a/game/ato/flightplans/ferry.py b/game/ato/flightplans/ferry.py index 1520d3cc..331b9352 100644 --- a/game/ato/flightplans/ferry.py +++ b/game/ato/flightplans/ferry.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Iterator from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime from typing import TYPE_CHECKING, Type from game.utils import feet @@ -35,16 +35,20 @@ class FerryFlightPlan(StandardFlightPlan[FerryLayout]): def tot_waypoint(self) -> FlightWaypoint: return self.layout.arrival - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: # TOT planning isn't really useful for ferries. They're behind the front # lines so no need to wait for escorts or for other missions to complete. return None - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: return None @property - def mission_departure_time(self) -> timedelta: + def mission_begin_on_station_time(self) -> datetime | None: + return None + + @property + def mission_departure_time(self) -> datetime: return self.package.time_over_target diff --git a/game/ato/flightplans/flightplan.py b/game/ato/flightplans/flightplan.py index a3519937..d3865be5 100644 --- a/game/ato/flightplans/flightplan.py +++ b/game/ato/flightplans/flightplan.py @@ -8,10 +8,10 @@ generating the waypoints for the mission. from __future__ import annotations import math -from abc import ABC +from abc import ABC, abstractmethod from collections.abc import Iterator from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from functools import cached_property from typing import Any, Generic, TYPE_CHECKING, TypeGuard, TypeVar @@ -159,7 +159,7 @@ class FlightPlan(ABC, Generic[LayoutT]): raise NotImplementedError @property - def tot(self) -> timedelta: + def tot(self) -> datetime: return self.package.time_over_target + self.tot_offset @cached_property @@ -224,6 +224,8 @@ class FlightPlan(ABC, Generic[LayoutT]): for previous_waypoint, waypoint in self.edges(until=destination): total += self.total_time_between_waypoints(previous_waypoint, waypoint) + total += self.travel_time_between_waypoints(previous_waypoint, waypoint) + # Trim microseconds. Our simulation tick rate is 1 second, so anything that # takes 100.1 or 100.9 seconds will take 100 seconds. DCS doesn't handle # sub-second resolution for tasks anyway, nor are they interesting from a @@ -249,10 +251,10 @@ class FlightPlan(ABC, Generic[LayoutT]): distance = meters(a.position.distance_to_point(b.position)) return timedelta(hours=distance.nautical_miles / speed.knots * error_factor) - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: raise NotImplementedError - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: raise NotImplementedError def request_escort_at(self) -> FlightWaypoint | None: @@ -275,12 +277,14 @@ class FlightPlan(ABC, Generic[LayoutT]): if waypoint == end: return - def takeoff_time(self) -> timedelta: + def takeoff_time(self) -> datetime: return self.tot - self._travel_time_to_waypoint(self.tot_waypoint) - def startup_time(self) -> timedelta: - start_time = ( - self.takeoff_time() - self.estimate_startup() - self.estimate_ground_ops() + def minimum_duration_from_start_to_tot(self) -> timedelta: + return ( + self._travel_time_to_waypoint(self.tot_waypoint) + + self.estimate_startup() + + self.estimate_ground_ops() ) # In case FP math has given us some barely below zero time, round to @@ -304,6 +308,11 @@ class FlightPlan(ABC, Generic[LayoutT]): return start_time + def startup_time(self) -> datetime: + return ( + self.takeoff_time() - self.estimate_startup() - self.estimate_ground_ops() + ) + def estimate_startup(self) -> timedelta: if self.flight.start_type is StartType.COLD: if self.flight.client_count: @@ -325,12 +334,16 @@ class FlightPlan(ABC, Generic[LayoutT]): def is_airassault(self) -> bool: return False + @abstractmethod + def mission_begin_on_station_time(self) -> datetime | None: + """The time that the mission is first on-station.""" + @property def is_custom(self) -> bool: return False @property - def mission_departure_time(self) -> timedelta: + def mission_departure_time(self) -> datetime: """The time that the mission is complete and the flight RTBs.""" raise NotImplementedError diff --git a/game/ato/flightplans/formation.py b/game/ato/flightplans/formation.py index 52af636f..b1ca8c8c 100644 --- a/game/ato/flightplans/formation.py +++ b/game/ato/flightplans/formation.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from functools import cached_property from typing import Any, TYPE_CHECKING, TypeGuard, Optional @@ -75,15 +75,15 @@ class FormationFlightPlan(LoiterFlightPlan, ABC): @property @abstractmethod - def join_time(self) -> timedelta: + def join_time(self) -> datetime: ... @property @abstractmethod - def split_time(self) -> timedelta: + def split_time(self) -> datetime: ... - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: if waypoint == self.layout.join: return self.join_time + self.tot_offset elif waypoint == self.layout.split: @@ -91,13 +91,18 @@ class FormationFlightPlan(LoiterFlightPlan, ABC): return None @property - def push_time(self) -> timedelta: + def push_time(self) -> datetime: return self.join_time - self.travel_time_between_waypoints( - self.layout.hold, self.layout.join + self.layout.hold.position, + self.layout.join.position, ) @property - def mission_departure_time(self) -> timedelta: + def mission_begin_on_station_time(self) -> datetime | None: + return None + + @property + def mission_departure_time(self) -> datetime: return self.split_time @self_type_guard diff --git a/game/ato/flightplans/formationattack.py b/game/ato/flightplans/formationattack.py index 1be5669d..b8f85758 100644 --- a/game/ato/flightplans/formationattack.py +++ b/game/ato/flightplans/formationattack.py @@ -3,8 +3,9 @@ from __future__ import annotations from abc import ABC from collections.abc import Iterator from dataclasses import dataclass -from datetime import timedelta -from typing import TYPE_CHECKING, TypeVar, Optional +from datetime import datetime, timedelta +from typing import Optional +from typing import TYPE_CHECKING, TypeVar from dcs import Point @@ -14,6 +15,7 @@ from game.utils import Speed, meters, nautical_miles, feet from .flightplan import FlightPlan from .formation import FormationFlightPlan, FormationLayout from .ibuilder import IBuilder +from .planningerror import PlanningError from .waypointbuilder import StrikeTarget, WaypointBuilder from .. import FlightType from ..flightwaypoint import FlightWaypoint @@ -56,14 +58,37 @@ class FormationAttackFlightPlan(FormationFlightPlan, ABC): ) @property - def join_time(self) -> timedelta: + def travel_time_to_target(self) -> timedelta: + """The estimated time between the first waypoint and the target.""" + destination = self.tot_waypoint + total = timedelta() + for previous_waypoint, waypoint in self.edges(): + if waypoint == self.tot_waypoint: + # For anything strike-like the TOT waypoint is the *flight's* + # mission target, but to synchronize with the rest of the + # package we need to use the travel time to the same position as + # the others. + total += self.travel_time_between_waypoints( + previous_waypoint, self.target_area_waypoint + ) + break + total += self.travel_time_between_waypoints(previous_waypoint, waypoint) + else: + raise PlanningError( + f"Did not find destination waypoint {destination} in " + f"waypoints for {self.flight}" + ) + return total + + @property + def join_time(self) -> datetime: travel_time = self.total_time_between_waypoints( self.layout.join, self.layout.ingress ) return self.ingress_time - travel_time @property - def split_time(self) -> timedelta: + def split_time(self) -> datetime: travel_time_ingress = self.total_time_between_waypoints( self.layout.ingress, self.target_area_waypoint ) @@ -80,7 +105,7 @@ class FormationAttackFlightPlan(FormationFlightPlan, ABC): ) @property - def ingress_time(self) -> timedelta: + def ingress_time(self) -> datetime: tot = self.tot travel_time = self.total_time_between_waypoints( self.layout.ingress, self.target_area_waypoint @@ -88,14 +113,14 @@ class FormationAttackFlightPlan(FormationFlightPlan, ABC): return tot - travel_time @property - def initial_time(self) -> timedelta: + def initial_time(self) -> datetime: tot = self.tot travel_time = self.travel_time_between_waypoints( self.layout.initial, self.target_area_waypoint ) return tot - travel_time - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: if waypoint == self.layout.ingress: return self.ingress_time elif waypoint == self.layout.initial: diff --git a/game/ato/flightplans/loiter.py b/game/ato/flightplans/loiter.py index 803fa729..32d753c7 100644 --- a/game/ato/flightplans/loiter.py +++ b/game/ato/flightplans/loiter.py @@ -2,8 +2,9 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass -from datetime import timedelta -from typing import Any, TYPE_CHECKING, TypeGuard, Optional +from datetime import datetime, timedelta +from typing import Any, TYPE_CHECKING, TypeGuard +from typing import Optional from game.typeguard import self_type_guard from .flightplan import FlightPlan @@ -25,10 +26,10 @@ class LoiterFlightPlan(StandardFlightPlan[Any], ABC): @property @abstractmethod - def push_time(self) -> timedelta: + def push_time(self) -> datetime: ... - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: if waypoint == self.layout.hold: return self.push_time + self.tot_offset return None diff --git a/game/ato/flightplans/packagerefueling.py b/game/ato/flightplans/packagerefueling.py index dea4afbe..670d9b57 100644 --- a/game/ato/flightplans/packagerefueling.py +++ b/game/ato/flightplans/packagerefueling.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from typing import Type from dcs import Point @@ -39,7 +39,7 @@ class PackageRefuelingFlightPlan(RefuelingFlightPlan): ) @property - def patrol_start_time(self) -> timedelta: + def patrol_start_time(self) -> datetime: altitude = self.flight.unit_type.patrol_altitude if altitude is None: diff --git a/game/ato/flightplans/patrolling.py b/game/ato/flightplans/patrolling.py index 2ff48e2c..bebfa2df 100644 --- a/game/ato/flightplans/patrolling.py +++ b/game/ato/flightplans/patrolling.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Iterator from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any, TYPE_CHECKING, TypeGuard, TypeVar from game.ato.flightplans.standard import StandardFlightPlan, StandardLayout @@ -59,22 +59,22 @@ class PatrollingFlightPlan(StandardFlightPlan[LayoutT], UiZoneDisplay, ABC): """ @property - def patrol_start_time(self) -> timedelta: + def patrol_start_time(self) -> datetime: return self.tot @property - def patrol_end_time(self) -> timedelta: + def patrol_end_time(self) -> datetime: # TODO: This is currently wrong for CAS. # CAS missions end when they're winchester or bingo. We need to # configure push tasks for the escorts rather than relying on timing. return self.patrol_start_time + self.patrol_duration - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: if waypoint == self.layout.patrol_start: return self.patrol_start_time return None - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: if waypoint == self.layout.patrol_end: return self.patrol_end_time return None @@ -88,7 +88,11 @@ class PatrollingFlightPlan(StandardFlightPlan[LayoutT], UiZoneDisplay, ABC): return self.layout.patrol_start @property - def mission_departure_time(self) -> timedelta: + def mission_begin_on_station_time(self) -> datetime: + return self.patrol_start_time + + @property + def mission_departure_time(self) -> datetime: return self.patrol_end_time @self_type_guard diff --git a/game/ato/flightplans/rtb.py b/game/ato/flightplans/rtb.py index c8025a0a..3e1d48db 100644 --- a/game/ato/flightplans/rtb.py +++ b/game/ato/flightplans/rtb.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Iterator from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime from typing import TYPE_CHECKING, Type from game.utils import feet @@ -42,15 +42,19 @@ class RtbFlightPlan(StandardFlightPlan[RtbLayout]): def tot_waypoint(self) -> FlightWaypoint: return self.layout.abort_location - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: return None - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: return None @property - def mission_departure_time(self) -> timedelta: - return timedelta() + def mission_begin_on_station_time(self) -> datetime | None: + return None + + @property + def mission_departure_time(self) -> datetime: + return self.tot class Builder(IBuilder[RtbFlightPlan, RtbLayout]): diff --git a/game/ato/flightplans/sweep.py b/game/ato/flightplans/sweep.py index 9778ecb9..10cbee98 100644 --- a/game/ato/flightplans/sweep.py +++ b/game/ato/flightplans/sweep.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from typing import Iterator, TYPE_CHECKING, Type from dcs import Point @@ -54,36 +54,40 @@ class SweepFlightPlan(LoiterFlightPlan): return -timedelta(minutes=5) @property - def sweep_start_time(self) -> timedelta: + def sweep_start_time(self) -> datetime: travel_time = self.total_time_between_waypoints( self.layout.sweep_start, self.layout.sweep_end ) return self.sweep_end_time - travel_time @property - def sweep_end_time(self) -> timedelta: + def sweep_end_time(self) -> datetime: return self.tot - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: if waypoint == self.layout.sweep_start: return self.sweep_start_time + self.tot_offset if waypoint == self.layout.sweep_end: return self.sweep_end_time + self.tot_offset return None - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: if waypoint == self.layout.hold: return self.push_time + self.tot_offset return None @property - def push_time(self) -> timedelta: + def push_time(self) -> datetime: return self.sweep_end_time - self.travel_time_between_waypoints( - self.layout.hold, self.layout.sweep_end + self.layout.hold.position, + self.layout.sweep_end.position, ) @property - def mission_departure_time(self) -> timedelta: + def mission_begin_on_station_time(self) -> datetime | None: + return None + + def mission_departure_time(self) -> datetime: return self.sweep_end_time diff --git a/game/ato/flightplans/tarcap.py b/game/ato/flightplans/tarcap.py index 21ea3ef0..4b1c4cd0 100644 --- a/game/ato/flightplans/tarcap.py +++ b/game/ato/flightplans/tarcap.py @@ -3,7 +3,7 @@ from __future__ import annotations import random from collections.abc import Iterator from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Type from game.utils import Distance, Speed, feet @@ -71,20 +71,20 @@ class TarCapFlightPlan(PatrollingFlightPlan[TarCapLayout]): def default_tot_offset(self) -> timedelta: return -timedelta(minutes=2) - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None: if waypoint == self.layout.patrol_end: return self.patrol_end_time return super().depart_time_for_waypoint(waypoint) @property - def patrol_start_time(self) -> timedelta: + def patrol_start_time(self) -> datetime: start = self.package.escort_start_time if start is not None: return start + self.tot_offset return self.tot @property - def patrol_end_time(self) -> timedelta: + def patrol_end_time(self) -> datetime: end = self.package.escort_end_time if end is not None: return end diff --git a/game/ato/flightstate/uninitialized.py b/game/ato/flightstate/uninitialized.py index bc75490d..d19792f7 100644 --- a/game/ato/flightstate/uninitialized.py +++ b/game/ato/flightstate/uninitialized.py @@ -35,7 +35,6 @@ class Uninitialized(FlightState): @property def description(self) -> str: - delay = self.flight.flight_plan.startup_time() if self.flight.start_type is StartType.COLD: action = "Starting up" elif self.flight.start_type is StartType.WARM: @@ -46,4 +45,4 @@ class Uninitialized(FlightState): action = "In flight" else: raise ValueError(f"Unhandled StartType: {self.flight.start_type}") - return f"{action} in {delay}" + return f"{action} at {self.flight.flight_plan.startup_time():%H:%M:%S}" diff --git a/game/ato/flightwaypoint.py b/game/ato/flightwaypoint.py index 0efa17c1..ac625f57 100644 --- a/game/ato/flightwaypoint.py +++ b/game/ato/flightwaypoint.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import timedelta +from datetime import datetime from typing import Literal, TYPE_CHECKING from dcs import Point @@ -43,8 +43,8 @@ class FlightWaypoint: # generation). We do it late so that we don't need to propagate changes # to waypoint times whenever the player alters the package TOT or the # flight's offset in the UI. - tot: timedelta | None = None - departure_time: timedelta | None = None + tot: datetime | None = None + departure_time: datetime | None = None @property def x(self) -> float: diff --git a/game/ato/package.py b/game/ato/package.py index 6b031d2b..71278db3 100644 --- a/game/ato/package.py +++ b/game/ato/package.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from collections import defaultdict -from datetime import timedelta +from datetime import datetime from typing import Dict, Optional, TYPE_CHECKING from game.db import Database @@ -42,8 +42,11 @@ class Package(RadioFrequencyContainer): self.auto_asap = auto_asap self.flights: list[Flight] = [] - # Desired TOT as an offset from mission start. - self.time_over_target: timedelta = timedelta() + # Desired TOT as an offset from mission start. Obviously datetime.min is bogus, + # but it's going to be replaced by whatever is scheduling the package very soon. + # TODO: Constructor should maybe take the current time and use that to preserve + # the old behavior? + self.time_over_target: datetime = datetime.min self.waypoints: PackageWaypoints | None = None @property @@ -71,7 +74,7 @@ class Package(RadioFrequencyContainer): # TODO: Should depend on the type of escort. # SEAD might be able to leave before CAP. @property - def escort_start_time(self) -> Optional[timedelta]: + def escort_start_time(self) -> datetime | None: times = [] for flight in self.flights: waypoint = flight.flight_plan.request_escort_at() @@ -90,7 +93,7 @@ class Package(RadioFrequencyContainer): return None @property - def escort_end_time(self) -> Optional[timedelta]: + def escort_end_time(self) -> datetime | None: times = [] for flight in self.flights: waypoint = flight.flight_plan.dismiss_escort_at() @@ -112,7 +115,7 @@ class Package(RadioFrequencyContainer): return None @property - def mission_departure_time(self) -> Optional[timedelta]: + def mission_departure_time(self) -> datetime | None: times = [] for flight in self.flights: times.append(flight.flight_plan.mission_departure_time) @@ -120,8 +123,8 @@ class Package(RadioFrequencyContainer): return max(times) return None - def set_tot_asap(self) -> None: - self.time_over_target = TotEstimator(self).earliest_tot() + def set_tot_asap(self, now: datetime) -> None: + self.time_over_target = TotEstimator(self).earliest_tot(now) def add_flight(self, flight: Flight) -> None: """Adds a flight to the package.""" diff --git a/game/ato/traveltime.py b/game/ato/traveltime.py index 04476dea..d07da0ff 100644 --- a/game/ato/traveltime.py +++ b/game/ato/traveltime.py @@ -1,7 +1,6 @@ from __future__ import annotations -import math -from datetime import timedelta +from datetime import datetime, timedelta from typing import TYPE_CHECKING from game.utils import Distance, SPEED_OF_SOUND_AT_SEA_LEVEL, Speed, mach, meters @@ -40,44 +39,24 @@ class TotEstimator: def __init__(self, package: Package) -> None: self.package = package - def earliest_tot(self) -> timedelta: + def earliest_tot(self, now: datetime) -> datetime: if not self.package.flights: - return timedelta(0) + return now - earliest_tot = max( - (self.earliest_tot_for_flight(f) for f in self.package.flights) - ) - - # Trim microseconds. DCS doesn't handle sub-second resolution for tasks, - # and they're not interesting from a mission planning perspective so we - # don't want them in the UI. - # - # Round up so we don't get negative start times. - return timedelta(seconds=math.ceil(earliest_tot.total_seconds())) + return max(self.earliest_tot_for_flight(f, now) for f in self.package.flights) @staticmethod - def earliest_tot_for_flight(flight: Flight) -> timedelta: - """Estimate the fastest time from mission start to the target position. + def earliest_tot_for_flight(flight: Flight, now: datetime) -> datetime: + """Estimate the earliest time the flight can reach the target position. - For BARCAP flights, this is time to the racetrack start. This ensures that - they are on station at the same time any other package members reach - their ingress point. - - For other mission types this is the time to the mission target. + The interpretation of the TOT depends on the flight plan type. See the various + FlightPlan implementations for details. Args: - flight: The flight to get the earliest TOT time for. + flight: The flight to get the earliest TOT for. + now: The current mission time. Returns: - The earliest possible TOT for the given flight in seconds. Returns 0 - if an ingress point cannot be found. + The earliest possible TOT for the given flight. """ - # Clear the TOT, calculate the startup time. Negating the result gives - # the earliest possible start time. - orig_tot = flight.package.time_over_target - try: - flight.package.time_over_target = timedelta() - time = flight.flight_plan.startup_time() - finally: - flight.package.time_over_target = orig_tot - return -time + return now + flight.flight_plan.minimum_duration_from_start_to_tot() diff --git a/game/coalition.py b/game/coalition.py index 5ac2f0ed..bd1adfe6 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from typing import Any, Optional, TYPE_CHECKING from faker import Faker @@ -182,10 +183,10 @@ class Coalition: with logged_duration("Procurement of airlift assets"): self.transfers.order_airlift_assets() with logged_duration("Transport planning"): - self.transfers.plan_transports() + self.transfers.plan_transports(self.game.conditions.start_time) if not is_turn_0: - self.plan_missions() + self.plan_missions(self.game.conditions.start_time) self.plan_procurement() def refund_outstanding_orders(self) -> None: @@ -201,16 +202,16 @@ class Coalition: for squadron in self.air_wing.iter_squadrons(): squadron.refund_orders() - def plan_missions(self) -> None: + def plan_missions(self, now: datetime) -> None: color = "Blue" if self.player else "Red" with MultiEventTracer() as tracer: with tracer.trace(f"{color} mission planning"): with tracer.trace(f"{color} mission identification"): - TheaterCommander(self.game, self.player).plan_missions(tracer) + TheaterCommander(self.game, self.player).plan_missions(now, tracer) with tracer.trace(f"{color} mission scheduling"): MissionScheduler( self, self.game.settings.desired_player_mission_duration - ).schedule_missions() + ).schedule_missions(now) def plan_procurement(self) -> None: # The first turn needs to buy a *lot* of aircraft to fill CAPs, so it gets much diff --git a/game/commander/missionscheduler.py b/game/commander/missionscheduler.py index 5423d8d3..fe308219 100644 --- a/game/commander/missionscheduler.py +++ b/game/commander/missionscheduler.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging import random from collections import defaultdict -from datetime import timedelta -from typing import Iterator, Dict, TYPE_CHECKING +from datetime import datetime, timedelta +from typing import Iterator, TYPE_CHECKING from game.ato.flighttype import FlightType from game.ato.traveltime import TotEstimator @@ -19,7 +19,7 @@ class MissionScheduler: self.coalition = coalition self.desired_mission_length = desired_mission_length - def schedule_missions(self) -> None: + def schedule_missions(self, now: datetime) -> None: """Identifies and plans mission for the turn.""" def start_time_generator( @@ -35,7 +35,7 @@ class MissionScheduler: FlightType.TARCAP, } - previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta) + previous_cap_end_time: dict[MissionTarget, datetime] = defaultdict(now.replace) non_dca_packages = [ p for p in self.coalition.ato.packages if p.primary_task not in dca_types ] @@ -47,7 +47,7 @@ class MissionScheduler: margin=5 * 60, ) for package in self.coalition.ato.packages: - tot = TotEstimator(package).earliest_tot() + tot = TotEstimator(package).earliest_tot(now) if package.primary_task in dca_types: previous_end_time = previous_cap_end_time[package.target] if tot > previous_end_time: @@ -65,7 +65,7 @@ class MissionScheduler: continue previous_cap_end_time[package.target] = departure_time elif package.auto_asap: - package.set_tot_asap() + package.set_tot_asap(now) else: # But other packages should be spread out a bit. Note that take # times are delayed, but all aircraft will become active at diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index 622e1535..68225c20 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging from collections import defaultdict +from datetime import datetime from typing import Dict, Iterable, Optional, Set, TYPE_CHECKING from game.ato.airtaaskingorder import AirTaskingOrder @@ -135,6 +136,7 @@ class PackageFulfiller: self, mission: ProposedMission, purchase_multiplier: int, + now: datetime, tracer: MultiEventTracer, ) -> Optional[Package]: """Allocates aircraft for a proposed mission and adds it to the ATO.""" @@ -225,6 +227,6 @@ class PackageFulfiller: if package.has_players and self.player_missions_asap: package.auto_asap = True - package.set_tot_asap() + package.set_tot_asap(now) return package diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index 85676150..09ecca33 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -105,6 +105,7 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): self.package = fulfiller.plan_mission( ProposedMission(self.target, self.flights), self.purchase_multiplier, + state.context.now, state.context.tracer, ) return self.package is not None diff --git a/game/commander/theatercommander.py b/game/commander/theatercommander.py index a3c4a45b..0bc63828 100644 --- a/game/commander/theatercommander.py +++ b/game/commander/theatercommander.py @@ -54,6 +54,7 @@ https://en.wikipedia.org/wiki/Hierarchical_task_network """ from __future__ import annotations +from datetime import datetime from typing import TYPE_CHECKING from game.ato.starttype import StartType @@ -77,8 +78,8 @@ class TheaterCommander(Planner[TheaterState, TheaterCommanderTask]): self.game = game self.player = player - def plan_missions(self, tracer: MultiEventTracer) -> None: - state = TheaterState.from_game(self.game, self.player, tracer) + def plan_missions(self, now: datetime, tracer: MultiEventTracer) -> None: + state = TheaterState.from_game(self.game, self.player, now, tracer) while True: result = self.plan(state) if result is None: diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index 2c64b66a..36cc6262 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -5,6 +5,7 @@ import itertools import math from collections.abc import Iterator from dataclasses import dataclass +from datetime import datetime from typing import Optional, TYPE_CHECKING, Union from game.commander.battlepositions import BattlePositions @@ -36,6 +37,7 @@ class PersistentContext: coalition: Coalition theater: ConflictTheater turn: int + now: datetime settings: Settings tracer: MultiEventTracer @@ -139,14 +141,20 @@ class TheaterState(WorldState["TheaterState"]): @classmethod def from_game( - cls, game: Game, player: bool, tracer: MultiEventTracer + cls, game: Game, player: bool, now: datetime, tracer: MultiEventTracer ) -> TheaterState: coalition = game.coalition_for(player) finder = ObjectiveFinder(game, player) ordered_capturable_points = finder.prioritized_unisolated_points() context = PersistentContext( - game.db, coalition, game.theater, game.turn, game.settings, tracer + game.db, + coalition, + game.theater, + game.turn, + now, + game.settings, + tracer, ) # Plan enough rounds of CAP that the target has coverage over the expected diff --git a/game/groundunitorders.py b/game/groundunitorders.py index 187e5373..b6644527 100644 --- a/game/groundunitorders.py +++ b/game/groundunitorders.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging from collections import defaultdict +from datetime import datetime from typing import Optional, TYPE_CHECKING from game.theater import ControlPoint @@ -52,7 +53,7 @@ class GroundUnitOrders: pending_units = 0 return pending_units - def process(self, game: Game) -> None: + def process(self, game: Game, now: datetime) -> None: coalition = game.coalition_for(self.destination.captured) ground_unit_source = self.find_ground_unit_source(game) if ground_unit_source is None: @@ -95,15 +96,20 @@ class GroundUnitOrders: "still tried to transfer units to there" ) ground_unit_source.base.commission_units(units_needing_transfer) - self.create_transfer(coalition, ground_unit_source, units_needing_transfer) + self.create_transfer( + coalition, ground_unit_source, units_needing_transfer, now + ) def create_transfer( self, coalition: Coalition, source: ControlPoint, units: dict[GroundUnitType, int], + now: datetime, ) -> None: - coalition.transfers.new_transfer(TransferOrder(source, self.destination, units)) + coalition.transfers.new_transfer( + TransferOrder(source, self.destination, units), now + ) def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]: # This is running *after* the turn counter has been incremented, so this is the diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index effbd010..9fb18513 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -97,7 +97,6 @@ class FlightGroupConfigurator: self.flight, self.group, self.mission, - self.game.conditions.start_time, self.time, self.game.settings, self.mission_data, diff --git a/game/missiongenerator/aircraft/waypoints/holdpoint.py b/game/missiongenerator/aircraft/waypoints/holdpoint.py index bead0c1a..2b14bb16 100644 --- a/game/missiongenerator/aircraft/waypoints/holdpoint.py +++ b/game/missiongenerator/aircraft/waypoints/holdpoint.py @@ -31,7 +31,7 @@ class HoldPointBuilder(PydcsWaypointBuilder): return push_time = self.flight.flight_plan.push_time self.waypoint.departure_time = push_time - elapsed = int((push_time - self.elapsed_mission_time).total_seconds()) - 60 + elapsed = int((push_time - self.now).total_seconds()) - 60 loiter.stop_after_time(elapsed) # What follows is some code to cope with the broken 'stop after time' condition create_stop_orbit_trigger(loiter, self.package, self.mission, elapsed) diff --git a/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py b/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py index 598e22c8..4575ac69 100644 --- a/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py +++ b/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime from typing import Any, Iterable, Union from dcs import Mission @@ -28,7 +28,7 @@ class PydcsWaypointBuilder: group: FlyingGroup[Any], flight: Flight, mission: Mission, - elapsed_mission_time: timedelta, + now: datetime, mission_data: MissionData, ) -> None: self.waypoint = waypoint @@ -36,7 +36,7 @@ class PydcsWaypointBuilder: self.package = flight.package self.flight = flight self.mission = mission - self.elapsed_mission_time = elapsed_mission_time + self.now = now self.mission_data = mission_data def dcs_name_for_waypoint(self) -> str: @@ -83,10 +83,10 @@ class PydcsWaypointBuilder: def add_tasks(self, waypoint: MovingPoint) -> None: pass - def set_waypoint_tot(self, waypoint: MovingPoint, tot: timedelta) -> None: + def set_waypoint_tot(self, waypoint: MovingPoint, tot: datetime) -> None: self.waypoint.tot = tot if not self._viggen_client_tot(): - waypoint.ETA = int((tot - self.elapsed_mission_time).total_seconds()) + waypoint.ETA = int((tot - self.now).total_seconds()) waypoint.ETA_locked = True waypoint.speed_locked = False diff --git a/game/missiongenerator/aircraft/waypoints/racetrack.py b/game/missiongenerator/aircraft/waypoints/racetrack.py index 19a6962b..ef9237d4 100644 --- a/game/missiongenerator/aircraft/waypoints/racetrack.py +++ b/game/missiongenerator/aircraft/waypoints/racetrack.py @@ -57,7 +57,7 @@ class RaceTrackBuilder(PydcsWaypointBuilder): racetrack = ControlledTask(orbit) self.set_waypoint_tot(waypoint, flight_plan.patrol_start_time) - loiter_duration = flight_plan.patrol_end_time - self.elapsed_mission_time + loiter_duration = flight_plan.patrol_end_time - self.now elapsed = int(loiter_duration.total_seconds()) racetrack.stop_after_time(elapsed) # What follows is some code to cope with the broken 'stop after time' condition diff --git a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py index ff720287..424ab630 100644 --- a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py +++ b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py @@ -50,7 +50,6 @@ class WaypointGenerator: flight: Flight, group: FlyingGroup[Any], mission: Mission, - turn_start_time: datetime, time: datetime, settings: Settings, mission_data: MissionData, @@ -58,7 +57,6 @@ class WaypointGenerator: self.flight = flight self.group = group self.mission = mission - self.elapsed_mission_time = time - turn_start_time self.time = time self.settings = settings self.mission_data = mission_data @@ -152,7 +150,7 @@ class WaypointGenerator: self.group, self.flight, self.mission, - self.elapsed_mission_time, + self.time, self.mission_data, ) @@ -183,12 +181,29 @@ class WaypointGenerator: a.min_fuel = min_fuel def set_takeoff_time(self, waypoint: FlightWaypoint) -> timedelta: + force_delay = False if isinstance(self.flight.state, WaitingForStart): delay = self.flight.state.time_remaining(self.time) + elif ( + # The first two clauses capture the flight states that we want to adjust. We + # don't want to delay any flights that are already in flight or on the + # runway. + not self.flight.state.in_flight + and self.flight.state.spawn_type is not StartType.RUNWAY + and self.flight.departure.is_fleet + and not self.flight.client_count + ): + # https://github.com/dcs-liberation/dcs_liberation/issues/1309 + # Without a delay, AI aircraft will be spawned on the sixpack, which other + # AI planes of course want to taxi through, deadlocking the carrier deck. + # Delaying AI carrier deck spawns by one second for some reason causes DCS + # to spawn those aircraft elsewhere, avoiding the traffic jam. + delay = timedelta(seconds=1) + force_delay = True else: delay = timedelta() - if self.should_delay_flight(): + if force_delay or self.should_delay_flight(): if self.should_activate_late(): # Late activation causes the aircraft to not be spawned # until triggered. diff --git a/game/missiongenerator/briefinggenerator.py b/game/missiongenerator/briefinggenerator.py index 24836868..f8e5245d 100644 --- a/game/missiongenerator/briefinggenerator.py +++ b/game/missiongenerator/briefinggenerator.py @@ -5,7 +5,6 @@ from __future__ import annotations import os from dataclasses import dataclass -from datetime import timedelta from typing import Dict, List, TYPE_CHECKING from dcs.mission import Mission @@ -130,11 +129,9 @@ class MissionInfoGenerator: def format_waypoint_time(waypoint: FlightWaypoint, depart_prefix: str) -> str: if waypoint.tot is not None: - time = timedelta(seconds=int(waypoint.tot.total_seconds())) - return f"T+{time} " + return f"{waypoint.tot.time()} " elif waypoint.departure_time is not None: - time = timedelta(seconds=int(waypoint.departure_time.total_seconds())) - return f"{depart_prefix} T+{time} " + return f"{depart_prefix} {waypoint.departure_time.time()} " return "" diff --git a/game/missiongenerator/kneeboard.py b/game/missiongenerator/kneeboard.py index 61c9e0c1..d4068ced 100644 --- a/game/missiongenerator/kneeboard.py +++ b/game/missiongenerator/kneeboard.py @@ -262,11 +262,11 @@ class FlightPlanBuilder: ] ) - def _format_time(self, time: Optional[datetime.timedelta]) -> str: + @staticmethod + def _format_time(time: datetime.datetime | None) -> str: if time is None: return "" - local_time = self.start_time + time - return f"{local_time.strftime('%H:%M:%S')}{'Z' if local_time.tzinfo is not None else ''}" + return f"{time.strftime('%H:%M:%S')}{'Z' if time.tzinfo is not None else ''}" def _format_alt(self, alt: Distance) -> str: return f"{self.units.distance_short(alt):.0f}" @@ -643,11 +643,11 @@ class SupportPage(KneeboardPage): ) return f"{channel_name}\n{frequency}" - def _format_time(self, time: Optional[datetime.timedelta]) -> str: + @staticmethod + def _format_time(time: datetime.datetime | None) -> str: if time is None: return "" - local_time = self.start_time + time - return f"{local_time.strftime('%H:%M:%S')}{'Z' if local_time.tzinfo is not None else ''}" + return f"{time.strftime('%H:%M:%S')}{'Z' if time.tzinfo is not None else ''}" @staticmethod def _format_duration(time: Optional[datetime.timedelta]) -> str: diff --git a/game/missiongenerator/missiondata.py b/game/missiongenerator/missiondata.py index 82d00d8d..e65fe013 100644 --- a/game/missiongenerator/missiondata.py +++ b/game/missiongenerator/missiondata.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import timedelta +from datetime import datetime from typing import Optional, TYPE_CHECKING from game.dcs.aircrafttype import AircraftType @@ -33,8 +33,8 @@ class AwacsInfo(GroupInfo): """AWACS information for the kneeboard.""" depature_location: Optional[str] - start_time: timedelta - end_time: timedelta + start_time: datetime | None + end_time: datetime | None @dataclass @@ -43,8 +43,8 @@ class TankerInfo(GroupInfo): variant: str tacan: Optional[TacanChannel] - start_time: timedelta - end_time: timedelta + start_time: datetime | None + end_time: datetime | None @dataclass diff --git a/game/server/waypoints/models.py b/game/server/waypoints/models.py index f01e6016..1575ee79 100644 --- a/game/server/waypoints/models.py +++ b/game/server/waypoints/models.py @@ -1,7 +1,5 @@ from __future__ import annotations -from datetime import timedelta - from pydantic import BaseModel from game.ato import Flight, FlightWaypoint @@ -21,7 +19,7 @@ def timing_info(flight: Flight, waypoint_idx: int) -> str: time = flight.flight_plan.depart_time_for_waypoint(waypoint) if time is None: return "" - return f"{prefix} T+{timedelta(seconds=int(time.total_seconds()))}" + return f"{prefix} {time}" class FlightWaypointJs(BaseModel): diff --git a/game/sim/aircraftsimulation.py b/game/sim/aircraftsimulation.py index c0550e85..f56dce3f 100644 --- a/game/sim/aircraftsimulation.py +++ b/game/sim/aircraftsimulation.py @@ -74,11 +74,11 @@ class AircraftSimulation: now = self.game.conditions.start_time for flight in self.iter_flights(): start_time = flight.flight_plan.startup_time() - if start_time <= timedelta(): + if start_time <= now: self.set_active_flight_state(flight, now) else: flight.set_state( - WaitingForStart(flight, self.game.settings, now + start_time) + WaitingForStart(flight, self.game.settings, start_time) ) def set_active_flight_state(self, flight: Flight, now: datetime) -> None: diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py index c53e5bfa..c71b10e6 100644 --- a/game/squadrons/squadron.py +++ b/game/squadrons/squadron.py @@ -4,7 +4,9 @@ import logging import random from collections.abc import Iterable from dataclasses import dataclass, field -from typing import Optional, Sequence, TYPE_CHECKING, Any, Union +from datetime import datetime +from typing import Any, Union +from typing import Optional, Sequence, TYPE_CHECKING from uuid import uuid4, UUID from dcs.country import Country @@ -380,9 +382,8 @@ class Squadron: def arrival(self) -> ControlPoint: return self.location if self.destination is None else self.destination - def plan_relocation(self, destination: ControlPoint) -> None: + def plan_relocation(self, destination: ControlPoint, now: datetime) -> None: from game.theater import ParkingType - if destination == self.location: logging.warning( f"Attempted to plan relocation of {self} to current location " @@ -402,7 +403,7 @@ class Squadron: if not destination.can_operate(self.aircraft): raise RuntimeError(f"{self} cannot operate at {destination}.") self.destination = destination - self.replan_ferry_flights() + self.replan_ferry_flights(now) def cancel_relocation(self) -> None: from game.theater import ParkingType @@ -420,9 +421,9 @@ class Squadron: self.destination = None self.cancel_ferry_flights() - def replan_ferry_flights(self) -> None: + def replan_ferry_flights(self, now: datetime) -> None: self.cancel_ferry_flights() - self.plan_ferry_flights() + self.plan_ferry_flights(now) def cancel_ferry_flights(self) -> None: for package in self.coalition.ato.packages: @@ -433,7 +434,7 @@ class Squadron: if not package.flights: self.coalition.ato.remove_package(package) - def plan_ferry_flights(self) -> None: + def plan_ferry_flights(self, now: datetime) -> None: if self.destination is None: raise RuntimeError( f"Cannot plan ferry flights for {self} because there is no destination." @@ -447,7 +448,7 @@ class Squadron: size = min(remaining, self.aircraft.max_group_size) self.plan_ferry_flight(package, size) remaining -= size - package.set_tot_asap() + package.set_tot_asap(now) self.coalition.ato.add_package(package) def plan_ferry_flight(self, package: Package, size: int) -> None: diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 7dcc9834..65852d99 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -1011,7 +1011,11 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): self.runway_status.begin_repair() def process_turn(self, game: Game) -> None: - self.ground_unit_orders.process(game) + # We're running at the end of the turn, so the time right now is irrelevant, and + # we don't know what time the next turn will start yet. It doesn't actually + # matter though, because the first thing the start of turn action will do is + # clear the ATO and replan the airlifts with the correct time. + self.ground_unit_orders.process(game, game.conditions.start_time) runway_status = self.runway_status if runway_status is not None: diff --git a/game/transfers.py b/game/transfers.py index 571c64bc..6cd157d3 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -35,6 +35,7 @@ import logging import math from collections import defaultdict from dataclasses import dataclass, field +from datetime import datetime from functools import singledispatchmethod from typing import Generic, Iterator, List, Optional, Sequence, TYPE_CHECKING, TypeVar @@ -299,7 +300,7 @@ class AirliftPlanner: return True - def create_package_for_airlift(self) -> None: + def create_package_for_airlift(self, now: datetime) -> None: distance_cache = ObjectiveDistanceCache.get_closest_airfields( self.transfer.position ) @@ -318,7 +319,7 @@ class AirliftPlanner: ): self.create_airlift_flight(squadron) if self.package.flights: - self.package.set_tot_asap() + self.package.set_tot_asap(now) self.game.ato_for(self.for_player).add_package(self.package) def create_airlift_flight(self, squadron: Squadron) -> int: @@ -590,7 +591,7 @@ class PendingTransfers: def network_for(self, control_point: ControlPoint) -> TransitNetwork: return self.game.transit_network_for(control_point.captured) - def arrange_transport(self, transfer: TransferOrder) -> None: + def arrange_transport(self, transfer: TransferOrder, now: datetime) -> None: network = self.network_for(transfer.position) path = network.shortest_path_between(transfer.position, transfer.destination) next_stop = path[0] @@ -605,12 +606,12 @@ class PendingTransfers: == TransitConnection.Shipping ): return self.cargo_ships.add(transfer, next_stop) - AirliftPlanner(self.game, transfer, next_stop).create_package_for_airlift() + AirliftPlanner(self.game, transfer, next_stop).create_package_for_airlift(now) - def new_transfer(self, transfer: TransferOrder) -> None: + def new_transfer(self, transfer: TransferOrder, now: datetime) -> None: transfer.origin.base.commit_losses(transfer.units) self.pending_transfers.append(transfer) - self.arrange_transport(transfer) + self.arrange_transport(transfer, now) self._send_supply_route_event_stream_update() def split_transfer(self, transfer: TransferOrder, size: int) -> TransferOrder: @@ -692,7 +693,7 @@ class PendingTransfers: self.cargo_ships.disband_all() self._send_supply_route_event_stream_update() - def plan_transports(self) -> None: + def plan_transports(self, now: datetime) -> None: """ Plan transports for all pending and completable transfers which don't have a transport assigned already. This calculates the shortest path between current @@ -702,7 +703,7 @@ class PendingTransfers: self.disband_uncompletable_transfers() for transfer in self.pending_transfers: if transfer.transport is None: - self.arrange_transport(transfer) + self.arrange_transport(transfer, now) def disband_uncompletable_transfers(self) -> None: """ diff --git a/qt_ui/models.py b/qt_ui/models.py index 3f0dfd07..ce0e23ca 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -142,11 +142,9 @@ class PackageModel(QAbstractListModel): @staticmethod def text_for_flight(flight: Flight) -> str: """Returns the text that should be displayed for the flight.""" - delay = datetime.timedelta( - seconds=int(flight.flight_plan.startup_time().total_seconds()) - ) origin = flight.departure.name - return f"{flight} from {origin} in {delay}" + startup = flight.flight_plan.startup_time() + return f"{flight} from {origin} at {startup}" @staticmethod def icon_for_flight(flight: Flight) -> Optional[QIcon]: @@ -191,7 +189,7 @@ class PackageModel(QAbstractListModel): """Returns the flight located at the given index.""" return self.package.flights[index.row()] - def set_tot(self, tot: datetime.timedelta) -> None: + def set_tot(self, tot: datetime.datetime) -> None: self.package.time_over_target = tot self.update_tot() @@ -201,7 +199,9 @@ class PackageModel(QAbstractListModel): def update_tot(self) -> None: if self.package.auto_asap: - self.package.set_tot_asap() + self.package.set_tot_asap( + self.game_model.sim_controller.current_time_in_sim + ) self.tot_changed.emit() # For some reason this is needed to make the UI update quickly. self.layoutChanged.emit() @@ -395,11 +395,11 @@ class TransferModel(QAbstractListModel): """Returns the icon that should be displayed for the transfer.""" return None - def new_transfer(self, transfer: TransferOrder) -> None: + def new_transfer(self, transfer: TransferOrder, now: datetime) -> None: """Updates the game with the new unit transfer.""" self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) # TODO: Needs to regenerate base inventory tab. - self.transfers.new_transfer(transfer) + self.transfers.new_transfer(transfer, now) self.endInsertRows() def cancel_transfer_at_index(self, index: QModelIndex) -> None: diff --git a/qt_ui/simcontroller.py b/qt_ui/simcontroller.py index f1cd8909..98607330 100644 --- a/qt_ui/simcontroller.py +++ b/qt_ui/simcontroller.py @@ -34,11 +34,18 @@ class SimController(QObject): return self.game_loop.completed @property - def current_time_in_sim(self) -> Optional[datetime]: + def current_time_in_sim_if_game_loaded(self) -> datetime | None: if self.game_loop is None: return None return self.game_loop.current_time_in_sim + @property + def current_time_in_sim(self) -> datetime: + time = self.current_time_in_sim_if_game_loaded + if time is None: + raise RuntimeError("No game is loaded") + return time + @property def elapsed_time(self) -> timedelta: if self.game_loop is None: diff --git a/qt_ui/widgets/QConditionsWidget.py b/qt_ui/widgets/QConditionsWidget.py index d5ad9e6a..86803607 100644 --- a/qt_ui/widgets/QConditionsWidget.py +++ b/qt_ui/widgets/QConditionsWidget.py @@ -58,7 +58,7 @@ class QTimeTurnWidget(QGroupBox): sim_controller.sim_update.connect(self.on_sim_update) def on_sim_update(self, _events: GameUpdateEvents) -> None: - time = self.sim_controller.current_time_in_sim + time = self.sim_controller.current_time_in_sim_if_game_loaded if time is None: self.date_display.setText("") self.time_display.setText("") diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index c49b088b..cbfb65bd 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import List, Optional from PySide2.QtWidgets import ( @@ -166,7 +167,7 @@ class QTopPanel(QFrame): GameUpdateSignal.get_instance().updateGame(self.game) self.proceedButton.setEnabled(True) - def negative_start_packages(self) -> List[Package]: + def negative_start_packages(self, now: datetime) -> List[Package]: packages = [] for package in self.game_model.ato_model.ato.packages: if not package.flights: @@ -174,7 +175,7 @@ class QTopPanel(QFrame): for flight in package.flights: if flight.state.is_waiting_for_start: startup = flight.flight_plan.startup_time() - if startup < 0: + if startup < now: packages.append(package) break return packages @@ -290,7 +291,9 @@ class QTopPanel(QFrame): if self.check_no_missing_pilots(): return - negative_starts = self.negative_start_packages() + negative_starts = self.negative_start_packages( + self.sim_controller.current_time_in_sim + ) if negative_starts: if not self.confirm_negative_start_time(negative_starts): return diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index f765d591..36d2c9a7 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -1,6 +1,5 @@ """Widgets for displaying air tasking orders.""" import logging -from datetime import timedelta from typing import Optional from PySide2.QtCore import ( @@ -285,16 +284,7 @@ class PackageDelegate(TwoColumnRowDelegate): clients = self.num_clients(index) return f"Player Slots: {clients}" if clients else "" elif (row, column) == (1, 0): - tot_delay = ( - package.time_over_target - self.game_model.sim_controller.elapsed_time - ) - if tot_delay >= timedelta(): - return f"TOT in {tot_delay}" - game = self.game_model.game - if game is None: - raise RuntimeError("Package TOT has elapsed but no game is loaded") - tot_time = game.conditions.start_time + package.time_over_target - return f"TOT passed at {tot_time:%H:%M:%S}" + return f"TOT at {package.time_over_target:%H:%M:%S}" elif (row, column) == (1, 1): unassigned_pilots = self.missing_pilots(index) return f"Missing pilots: {unassigned_pilots}" if unassigned_pilots else "" diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index bb2725a3..7f7655aa 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -1,27 +1,28 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional, Iterator +from typing import Iterator, Optional from PySide2.QtCore import QItemSelectionModel, QModelIndex, QSize from PySide2.QtWidgets import ( QAbstractItemView, QCheckBox, QDialog, + QHBoxLayout, QListView, - QVBoxLayout, QTabWidget, QTableWidget, QTableWidgetItem, + QVBoxLayout, QWidget, - QHBoxLayout, ) from game.ato.flight import Flight from game.squadrons import Squadron from game.theater import ConflictTheater from qt_ui.delegates import TwoColumnRowDelegate -from qt_ui.models import GameModel, AirWingModel, SquadronModel, AtoModel +from qt_ui.models import AirWingModel, AtoModel, GameModel, SquadronModel +from qt_ui.simcontroller import SimController from qt_ui.windows.SquadronDialog import SquadronDialog @@ -63,11 +64,13 @@ class SquadronList(QListView): ato_model: AtoModel, air_wing_model: AirWingModel, theater: ConflictTheater, + sim_controller: SimController, ) -> None: super().__init__() self.ato_model = ato_model self.air_wing_model = air_wing_model self.theater = theater + self.sim_controller = sim_controller self.dialog: Optional[SquadronDialog] = None self.setIconSize(QSize(91, 24)) @@ -88,6 +91,7 @@ class SquadronList(QListView): self.ato_model, SquadronModel(self.air_wing_model.squadron_at_index(index)), self.theater, + self.sim_controller, self, ) self.dialog.show() @@ -234,6 +238,7 @@ class AirWingTabs(QTabWidget): game_model.ato_model, game_model.blue_air_wing_model, game_model.game.theater, + game_model.sim_controller, ), "Squadrons", ) diff --git a/qt_ui/windows/SquadronDialog.py b/qt_ui/windows/SquadronDialog.py index 0fcbd193..808aa626 100644 --- a/qt_ui/windows/SquadronDialog.py +++ b/qt_ui/windows/SquadronDialog.py @@ -30,6 +30,7 @@ from game.theater import ConflictTheater, ControlPoint, ParkingType from qt_ui.delegates import TwoColumnRowDelegate from qt_ui.errorreporter import report_errors from qt_ui.models import AtoModel, SquadronModel +from qt_ui.simcontroller import SimController from qt_ui.widgets.combos.QSquadronLiverySelector import SquadronLiverySelector from qt_ui.widgets.combos.primarytaskselector import PrimaryTaskSelector @@ -230,11 +231,13 @@ class SquadronDialog(QDialog): ato_model: AtoModel, squadron_model: SquadronModel, theater: ConflictTheater, + sim_controller: SimController, parent, ) -> None: super().__init__(parent) self.ato_model = ato_model self.squadron_model = squadron_model + self.sim_controller = sim_controller self.setMinimumSize(1000, 440) self.setWindowTitle(str(squadron_model.squadron)) @@ -327,7 +330,9 @@ class SquadronDialog(QDialog): elif self.ato_model.game.settings.enable_transfer_cheat: self._instant_relocate(destination) else: - self.squadron.plan_relocation(destination) + self.squadron.plan_relocation( + destination, self.sim_controller.current_time_in_sim + ) self.ato_model.replace_from_game(player=True) def check_disabled_button_states( diff --git a/qt_ui/windows/basemenu/NewUnitTransferDialog.py b/qt_ui/windows/basemenu/NewUnitTransferDialog.py index 43578cb1..c1187470 100644 --- a/qt_ui/windows/basemenu/NewUnitTransferDialog.py +++ b/qt_ui/windows/basemenu/NewUnitTransferDialog.py @@ -320,7 +320,9 @@ class NewUnitTransferDialog(QDialog): units=transfers, request_airflift=self.dest_panel.request_airlift, ) - self.game_model.transfer_model.new_transfer(transfer) + self.game_model.transfer_model.new_transfer( + transfer, self.game_model.sim_controller.current_time_in_sim + ) self.close() def on_transfer_quantity_changed(self) -> None: diff --git a/qt_ui/windows/mission/QFlightItem.py b/qt_ui/windows/mission/QFlightItem.py index c43a2aac..8b1c63f9 100644 --- a/qt_ui/windows/mission/QFlightItem.py +++ b/qt_ui/windows/mission/QFlightItem.py @@ -19,4 +19,4 @@ class QFlightItem(QStandardItem): ) self.setIcon(icon) self.setEditable(False) - self.setText(f"{flight} in {flight.flight_plan.startup_time()}") + self.setText(f"{flight} at {flight.flight_plan.startup_time()}") diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 6ef908bb..6f5d8422 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -1,6 +1,5 @@ """Dialogs for creating and editing ATO packages.""" import logging -from datetime import timedelta from typing import Optional from PySide2.QtCore import QItemSelection, QTime, Qt, Signal @@ -93,7 +92,7 @@ class QPackageDialog(QDialog): self.tot_spinner = QTimeEdit(self.tot_qtime()) self.tot_spinner.setMinimumTime(QTime(0, 0)) - self.tot_spinner.setDisplayFormat("T+hh:mm:ss") + self.tot_spinner.setDisplayFormat("hh:mm:ss") self.tot_spinner.timeChanged.connect(self.save_tot) self.tot_spinner.setToolTip("Package TOT relative to mission TOT") self.tot_spinner.setEnabled(not self.package_model.package.auto_asap) @@ -159,11 +158,8 @@ class QPackageDialog(QDialog): return self.game_model.game def tot_qtime(self) -> QTime: - delay = int(self.package_model.package.time_over_target.total_seconds()) - hours = delay // 3600 - minutes = delay // 60 % 60 - seconds = delay % 60 - return QTime(hours, minutes, seconds) + tot = self.package_model.package.time_over_target + return QTime(tot.hour, tot.minute, tot.second) def on_cancel(self) -> None: pass @@ -177,9 +173,13 @@ class QPackageDialog(QDialog): self.save_tot() def save_tot(self) -> None: + # TODO: This is going to break horribly around midnight. time = self.tot_spinner.time() - seconds = time.hour() * 3600 + time.minute() * 60 + time.second() - self.package_model.set_tot(timedelta(seconds=seconds)) + self.package_model.set_tot( + self.package_model.package.time_over_target.replace( + hour=time.hour(), minute=time.minute(), second=time.second() + ) + ) def set_asap(self, checked: bool) -> None: self.package_model.set_asap(checked) diff --git a/qt_ui/windows/mission/QPlannedFlightsView.py b/qt_ui/windows/mission/QPlannedFlightsView.py index 0ac0af85..fd875ac6 100644 --- a/qt_ui/windows/mission/QPlannedFlightsView.py +++ b/qt_ui/windows/mission/QPlannedFlightsView.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime from PySide2.QtCore import QItemSelectionModel, QSize from PySide2.QtGui import QStandardItemModel @@ -50,5 +50,5 @@ class QPlannedFlightsView(QListView): self.setup_content() @staticmethod - def mission_start_for_flight(flight_item: QFlightItem) -> timedelta: + def mission_start_for_flight(flight_item: QFlightItem) -> datetime: return flight_item.flight.flight_plan.startup_time() diff --git a/qt_ui/windows/mission/flight/settings/FlightPlanPropertiesGroup.py b/qt_ui/windows/mission/flight/settings/FlightPlanPropertiesGroup.py index f8736212..0f6e73c0 100644 --- a/qt_ui/windows/mission/flight/settings/FlightPlanPropertiesGroup.py +++ b/qt_ui/windows/mission/flight/settings/FlightPlanPropertiesGroup.py @@ -102,7 +102,9 @@ class FlightPlanPropertiesGroup(QGroupBox): # handler may be called for a flight whose package has been canceled, which # is an invalid state for calling anything in TotEstimator. return - self.departure_time.setText(f"At T+{self.flight.flight_plan.startup_time()}") + self.departure_time.setText( + f"At {self.flight.flight_plan.startup_time():%H:%M%S}" + ) self.flight_wpt_list.update_list() def set_divert(self, index: int) -> None: diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py index 4305554b..aee4e77d 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py @@ -1,5 +1,3 @@ -from datetime import timedelta - from PySide2.QtCore import QItemSelectionModel, QPoint, QModelIndex from PySide2.QtGui import QStandardItem, QStandardItemModel from PySide2.QtWidgets import ( @@ -120,14 +118,8 @@ class QFlightWaypointList(QTableView): time = flight.flight_plan.depart_time_for_waypoint(waypoint) if time is None: return "" - time = timedelta(seconds=int(time.total_seconds())) - return f"{prefix}T+{time}" + return f"{prefix}{time:%H:%M:%S}" @staticmethod def takeoff_text(flight: Flight) -> str: - takeoff_time = flight.flight_plan.takeoff_time() - # Handle custom flight plans where we can't estimate the takeoff time. - if takeoff_time is None: - takeoff_time = timedelta() - start_time = timedelta(seconds=int(takeoff_time.total_seconds())) - return f"T+{start_time}" + return f"{flight.flight_plan.takeoff_time():%H:%M:%S}"