From c5b50ceeae1168ec362c0614987dfa0d218aff6f Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 31 Aug 2022 00:05:55 -0700 Subject: [PATCH] Add campaign property for campaign start time. This field is optional. Omitting the field (or using only a date instead of a full timestamp) will use the old behavior of picking a random daylight hour to start the campaign. This doesn't include any UI in the new game wizard yet. This is only a campaign yaml option. https://github.com/dcs-liberation/dcs_liberation/issues/2400 --- game/campaignloader/campaign.py | 25 +++++++++++------------ game/game.py | 27 ++++++++++++++++--------- game/theater/start_generator.py | 10 ++++----- game/version.py | 7 ++++++- game/weather.py | 12 ++++++++--- qt_ui/main.py | 1 + qt_ui/windows/newgame/QNewGameWizard.py | 1 + 7 files changed, 51 insertions(+), 32 deletions(-) diff --git a/game/campaignloader/campaign.py b/game/campaignloader/campaign.py index d080d9a3..bac9abdf 100644 --- a/game/campaignloader/campaign.py +++ b/game/campaignloader/campaign.py @@ -1,12 +1,11 @@ from __future__ import annotations import datetime -import json import logging from collections.abc import Iterator from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Tuple import yaml from packaging.version import Version @@ -49,7 +48,8 @@ class Campaign: recommended_player_faction: str recommended_enemy_faction: str - recommended_start_date: Optional[datetime.date] + recommended_start_date: datetime.date | None + recommended_start_time: datetime.time | None recommended_player_money: int recommended_enemy_money: int @@ -64,10 +64,7 @@ class Campaign: @classmethod def from_file(cls, path: Path) -> Campaign: with path.open() as campaign_file: - if path.suffix.lower() == ".yaml": - data = yaml.safe_load(campaign_file) - else: - data = json.load(campaign_file) + data = yaml.safe_load(campaign_file) sanitized_theater = data["theater"].replace(" ", "") version_field = data.get("version", "0") @@ -80,14 +77,15 @@ class Campaign: version = Version(str(version_field)) start_date_raw = data.get("recommended_start_date") - - # YAML automatically parses dates, but while we still support JSON campaigns we - # need to be able to handle parsing dates from strings ourselves as well. - start_date: Optional[datetime.date] - if isinstance(start_date_raw, str): - start_date = datetime.date.fromisoformat(start_date_raw) + # YAML automatically parses dates. + start_date: datetime.date | None + start_time: datetime.time | None = None + if isinstance(start_date_raw, datetime.datetime): + start_date = start_date_raw.date() + start_time = start_date_raw.time() elif isinstance(start_date_raw, datetime.date): start_date = start_date_raw + start_time = None elif start_date_raw is None: start_date = None else: @@ -104,6 +102,7 @@ class Campaign: data.get("recommended_player_faction", "USA 2005"), data.get("recommended_enemy_faction", "Russia 1990"), start_date, + start_time, data.get("recommended_player_money", DEFAULT_BUDGET), data.get("recommended_enemy_money", DEFAULT_BUDGET), data.get("recommended_player_income_multiplier", 1.0), diff --git a/game/game.py b/game/game.py index f5eb0efb..d010e162 100644 --- a/game/game.py +++ b/game/game.py @@ -4,7 +4,7 @@ import itertools import logging import math from collections.abc import Iterator -from datetime import date, datetime, timedelta +from datetime import date, datetime, time, timedelta from enum import Enum from typing import Any, List, TYPE_CHECKING, Type, Union, cast from uuid import UUID @@ -95,6 +95,7 @@ class Game: theater: ConflictTheater, air_wing_config: CampaignAirWingConfig, start_date: datetime, + start_time: time | None, settings: Settings, player_budget: float, enemy_budget: float, @@ -119,7 +120,15 @@ class Game: self.db = GameDb() - self.conditions = self.generate_conditions() + if start_time is None: + self.time_of_day_offset_for_start_time = list(TimeOfDay).index( + TimeOfDay.Day + ) + else: + self.time_of_day_offset_for_start_time = list(TimeOfDay).index( + self.theater.daytime_map.best_guess_time_of_day_at(start_time) + ) + self.conditions = self.generate_conditions(forced_time=start_time) self.sanitize_sides(player_faction, enemy_faction) self.blue = Coalition(self, player_faction, player_budget, player=True) @@ -154,9 +163,13 @@ class Game: def transit_network_for(self, player: bool) -> TransitNetwork: return self.coalition_for(player).transit_network - def generate_conditions(self) -> Conditions: + def generate_conditions(self, forced_time: time | None = None) -> Conditions: return Conditions.generate( - self.theater, self.current_day, self.current_turn_time_of_day, self.settings + self.theater, + self.current_day, + self.current_turn_time_of_day, + self.settings, + forced_time=forced_time, ) @staticmethod @@ -428,11 +441,7 @@ class Game: @property def current_turn_time_of_day(self) -> TimeOfDay: - # We don't actually advance time between turn 0 and turn 1. Clamp the turn value - # to 1 so we get the same answer for 0 and 1. We clamp to 1 rather than 0 - # because historically we've started campaigns in day rather than in dawn. We - # can either start at 1, or we could re-order the enum. - tod_turn = max(1, self.turn) + tod_turn = max(0, self.turn - 1) + self.time_of_day_offset_for_start_time return list(TimeOfDay)[tod_turn % 4] @property diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index cf973fa1..5b35f0a7 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import random from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, time from typing import List import dcs.statics @@ -17,7 +17,6 @@ from game.theater.theatergroundobject import ( BuildingGroundObject, IadsBuildingGroundObject, ) -from .theatergroup import SceneryUnit, TheaterGroup, IadsGroundGroup, IadsRole from game.utils import Heading, escape_string_for_lua from game.version import VERSION from . import ( @@ -27,10 +26,7 @@ from . import ( Fob, OffMapSpawn, ) -from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig -from ..data.building_data import IADS_BUILDINGS -from ..data.groups import GroupTask -from ..armedforces.forcegroup import ForceGroup +from .theatergroup import IadsGroundGroup, IadsRole, SceneryUnit, TheaterGroup from ..armedforces.armedforces import ArmedForces from ..armedforces.forcegroup import ForceGroup from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig @@ -42,6 +38,7 @@ from ..settings import Settings @dataclass(frozen=True) class GeneratorSettings: start_date: datetime + start_time: time | None player_budget: int enemy_budget: int inverted: bool @@ -98,6 +95,7 @@ class GameGenerator: theater=self.theater, air_wing_config=self.air_wing_config, start_date=self.generator_settings.start_date, + start_time=self.generator_settings.start_time, settings=self.settings, player_budget=self.generator_settings.player_budget, enemy_budget=self.generator_settings.enemy_budget, diff --git a/game/version.py b/game/version.py index 86052769..d81081e7 100644 --- a/game/version.py +++ b/game/version.py @@ -150,4 +150,9 @@ VERSION = _build_version_string() #: * Campaign files can optionally define the iads configuration #: It is possible to define if the campaign supports advanced iads #: -CAMPAIGN_FORMAT_VERSION = (10, 2) +#: Version 10.3 +#: * Campaign files can optionally include a start time in their recommended_start_date +#: field. For example, `recommended_start_data: 2022-08-31 13:30:00` will have the +#: first turn start at 13:30. If omitted, or if only a date is given, the mission will +#: start at a random hour in the middle of the day as before. +CAMPAIGN_FORMAT_VERSION = (10, 3) diff --git a/game/weather.py b/game/weather.py index eeca1b13..24361cd6 100644 --- a/game/weather.py +++ b/game/weather.py @@ -298,10 +298,16 @@ class Conditions: day: datetime.date, time_of_day: TimeOfDay, settings: Settings, + forced_time: datetime.time | None = None, ) -> Conditions: - _start_time = cls.generate_start_time( - theater, day, time_of_day, settings.night_disabled - ) + # The time might be forced by the campaign for the first turn. + if forced_time is not None: + _start_time = datetime.datetime.combine(day, forced_time) + else: + _start_time = cls.generate_start_time( + theater, day, time_of_day, settings.night_disabled + ) + return cls( time_of_day=time_of_day, start_time=_start_time, diff --git a/qt_ui/main.py b/qt_ui/main.py index e61c24df..44feb726 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -286,6 +286,7 @@ def create_game( ), GeneratorSettings( start_date=start_date, + start_time=campaign.recommended_start_time, player_budget=DEFAULT_BUDGET, enemy_budget=DEFAULT_BUDGET, inverted=inverted, diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 882f471a..5ad63f3b 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -145,6 +145,7 @@ class NewGameWizard(QtWidgets.QWizard): ) generator_settings = GeneratorSettings( start_date=start_date, + start_time=campaign.recommended_start_time, player_budget=int(self.field("starting_money")), enemy_budget=int(self.field("enemy_starting_money")), # QSlider forces integers, so we use 1 to 50 and divide by 10 to