From b6da2d8e62d6b83f91c4d43f0820ca0aa6357255 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 30 Aug 2022 23:32:23 -0700 Subject: [PATCH] Turn the daytime map in theater into a real type. No (intended) user visible effects, but this is the groundwork that will support https://github.com/dcs-liberation/dcs_liberation/issues/2400. --- game/theater/conflicttheater.py | 97 ++++++++++++++--------------- game/theater/daytimemap.py | 87 ++++++++++++++++++++++++++ game/weather.py | 26 +++++--- tests/test_daytimemap.py | 107 ++++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 62 deletions(-) create mode 100644 game/theater/daytimemap.py create mode 100644 tests/test_daytimemap.py diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 1e291a23..f3861fad 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -4,7 +4,7 @@ import datetime import math from dataclasses import dataclass from pathlib import Path -from typing import Dict, Iterator, List, Optional, TYPE_CHECKING, Tuple +from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple from uuid import UUID from dcs.mapping import Point @@ -20,6 +20,7 @@ from dcs.terrain import ( from dcs.terrain.terrain import Terrain from shapely import geometry, ops +from .daytimemap import DaytimeMap from .frontline import FrontLine from .iadsnetwork.iadsnetwork import IadsNetwork from .landmap import Landmap, load_landmap, poly_contains @@ -42,19 +43,11 @@ class ConflictTheater: overview_image: str landmap: Optional[Landmap] - """ - land_poly = None # type: Polygon - """ - daytime_map: Dict[str, Tuple[int, int]] + daytime_map: DaytimeMap iads_network: IadsNetwork def __init__(self) -> None: self.controlpoints: List[ControlPoint] = [] - """ - self.land_poly = geometry.Polygon(self.landmap[0][0]) - for x in self.landmap[1]: - self.land_poly = self.land_poly.difference(geometry.Polygon(x)) - """ def add_controlpoint(self, point: ControlPoint) -> None: self.controlpoints.append(point) @@ -266,12 +259,12 @@ class CaucasusTheater(ConflictTheater): overview_image = "caumap.gif" landmap = load_landmap(Path("resources/caulandmap.p")) - daytime_map = { - "dawn": (6, 9), - "day": (9, 18), - "dusk": (18, 20), - "night": (0, 5), - } + daytime_map = DaytimeMap( + dawn=(datetime.time(hour=6), datetime.time(hour=9)), + day=(datetime.time(hour=9), datetime.time(hour=18)), + dusk=(datetime.time(hour=18), datetime.time(hour=20)), + night=(datetime.time(hour=0), datetime.time(hour=5)), + ) @property def timezone(self) -> datetime.timezone: @@ -288,12 +281,12 @@ class PersianGulfTheater(ConflictTheater): terrain = persiangulf.PersianGulf() overview_image = "persiangulf.gif" landmap = load_landmap(Path("resources/gulflandmap.p")) - daytime_map = { - "dawn": (6, 8), - "day": (8, 16), - "dusk": (16, 18), - "night": (0, 5), - } + daytime_map = DaytimeMap( + dawn=(datetime.time(hour=6), datetime.time(hour=8)), + day=(datetime.time(hour=8), datetime.time(hour=16)), + dusk=(datetime.time(hour=16), datetime.time(hour=18)), + night=(datetime.time(hour=0), datetime.time(hour=5)), + ) @property def timezone(self) -> datetime.timezone: @@ -310,12 +303,12 @@ class NevadaTheater(ConflictTheater): terrain = nevada.Nevada() overview_image = "nevada.gif" landmap = load_landmap(Path("resources/nevlandmap.p")) - daytime_map = { - "dawn": (4, 6), - "day": (6, 17), - "dusk": (17, 18), - "night": (0, 5), - } + daytime_map = DaytimeMap( + dawn=(datetime.time(hour=4), datetime.time(hour=6)), + day=(datetime.time(hour=6), datetime.time(hour=17)), + dusk=(datetime.time(hour=17), datetime.time(hour=18)), + night=(datetime.time(hour=0), datetime.time(hour=5)), + ) @property def timezone(self) -> datetime.timezone: @@ -332,12 +325,12 @@ class NormandyTheater(ConflictTheater): terrain = normandy.Normandy() overview_image = "normandy.gif" landmap = load_landmap(Path("resources/normandylandmap.p")) - daytime_map = { - "dawn": (6, 8), - "day": (10, 17), - "dusk": (17, 18), - "night": (0, 5), - } + daytime_map = DaytimeMap( + dawn=(datetime.time(hour=6), datetime.time(hour=8)), + day=(datetime.time(hour=10), datetime.time(hour=17)), + dusk=(datetime.time(hour=17), datetime.time(hour=18)), + night=(datetime.time(hour=0), datetime.time(hour=5)), + ) @property def timezone(self) -> datetime.timezone: @@ -354,12 +347,12 @@ class TheChannelTheater(ConflictTheater): terrain = thechannel.TheChannel() overview_image = "thechannel.gif" landmap = load_landmap(Path("resources/channellandmap.p")) - daytime_map = { - "dawn": (6, 8), - "day": (10, 17), - "dusk": (17, 18), - "night": (0, 5), - } + daytime_map = DaytimeMap( + dawn=(datetime.time(hour=6), datetime.time(hour=8)), + day=(datetime.time(hour=10), datetime.time(hour=17)), + dusk=(datetime.time(hour=17), datetime.time(hour=18)), + night=(datetime.time(hour=0), datetime.time(hour=5)), + ) @property def timezone(self) -> datetime.timezone: @@ -376,12 +369,12 @@ class SyriaTheater(ConflictTheater): terrain = syria.Syria() overview_image = "syria.gif" landmap = load_landmap(Path("resources/syrialandmap.p")) - daytime_map = { - "dawn": (6, 8), - "day": (8, 16), - "dusk": (16, 18), - "night": (0, 5), - } + daytime_map = DaytimeMap( + dawn=(datetime.time(hour=6), datetime.time(hour=8)), + day=(datetime.time(hour=8), datetime.time(hour=16)), + dusk=(datetime.time(hour=16), datetime.time(hour=18)), + night=(datetime.time(hour=0), datetime.time(hour=5)), + ) @property def timezone(self) -> datetime.timezone: @@ -399,12 +392,12 @@ class MarianaIslandsTheater(ConflictTheater): overview_image = "marianaislands.gif" landmap = load_landmap(Path("resources/marianaislandslandmap.p")) - daytime_map = { - "dawn": (6, 8), - "day": (8, 16), - "dusk": (16, 18), - "night": (0, 5), - } + daytime_map = DaytimeMap( + dawn=(datetime.time(hour=6), datetime.time(hour=8)), + day=(datetime.time(hour=8), datetime.time(hour=16)), + dusk=(datetime.time(hour=16), datetime.time(hour=18)), + night=(datetime.time(hour=0), datetime.time(hour=5)), + ) @property def timezone(self) -> datetime.timezone: diff --git a/game/theater/daytimemap.py b/game/theater/daytimemap.py new file mode 100644 index 00000000..c9c0a570 --- /dev/null +++ b/game/theater/daytimemap.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass +from datetime import time +from typing import TypeAlias + +from game.weather import TimeOfDay + +TimeRange: TypeAlias = tuple[time, time] + + +@dataclass(frozen=True) +class DaytimeMap: + dawn: TimeRange + day: TimeRange + dusk: TimeRange + night: TimeRange + + def __post_init__(self) -> None: + # Checks that we only are even given whole-hour intervals. There's no reason to + # not support this eventually, but for now the fact that missions always start + # on the hour is a nice gameplay property. That'll have to go as a part of the + # mid-mission starts and removal of turns, but for now we can keep it to + # preserve the old behavior. + # + # Mission start time generation (currently in Conditions.generate_start_time) + # will need to be updated if and when this changes. + def check_time_is_hours(descr: str, t: time) -> None: + if t.minute: + raise ValueError( + f"{descr} has non-zero minutes; only hour intervals are currently " + "supported" + ) + if t.second: + raise ValueError( + f"{descr} has non-zero seconds; only hour intervals are currently " + "supported" + ) + if t.microsecond: + raise ValueError( + f"{descr} has non-zero microseconds; only hour intervals are " + "currently supported" + ) + + check_time_is_hours("dawn start", self.dawn[0]) + check_time_is_hours("dawn end", self.dawn[1]) + check_time_is_hours("day start", self.day[0]) + check_time_is_hours("day end", self.day[1]) + check_time_is_hours("dusk start", self.dusk[0]) + check_time_is_hours("dusk end", self.dusk[1]) + check_time_is_hours("night start", self.night[0]) + check_time_is_hours("night end", self.night[1]) + + def range_of(self, item: TimeOfDay) -> TimeRange: + match item: + case TimeOfDay.Dawn: + return self.dawn + case TimeOfDay.Day: + return self.day + case TimeOfDay.Dusk: + return self.dusk + case TimeOfDay.Night: + return self.night + case _: + raise ValueError(f"Invalid value for TimeOfDay: {item}") + + def best_guess_time_of_day_at(self, at: time) -> TimeOfDay: + """Returns an approximation of the time of day at the given time. + + This is the best guess because time ranges need not cover the whole day. For the + Caucasus, for example, dusk ends at 20:00 but night does not begin until 24:00. + If a time between those hours is given, we call it dusk. + """ + if self.night[0] < self.dawn[0] and at < self.night[0]: + # Night happens at or before midnight, so there's a time before dawn but + # after midnight where it can still be dusk. + return TimeOfDay.Dusk + if at < self.dawn[0]: + return TimeOfDay.Night + if at < self.day[0]: + return TimeOfDay.Dawn + if at < self.dusk[0]: + return TimeOfDay.Day + if self.night[0] > self.dusk[0] and at >= self.night[0]: + # Night happens before midnight, so it might still be dusk or night. + return TimeOfDay.Night + # If night starts at or before midnight, and it's at least dusk, it's definitely + # dusk. + return TimeOfDay.Dusk diff --git a/game/weather.py b/game/weather.py index 5f862759..eeca1b13 100644 --- a/game/weather.py +++ b/game/weather.py @@ -9,13 +9,13 @@ from typing import Optional, TYPE_CHECKING from dcs.cloud_presets import Clouds as PydcsClouds from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind -from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg from game.theater.seasonalconditions import determine_season +from game.utils import Distance, Heading, Pressure, inches_hg, interpolate, meters if TYPE_CHECKING: from game.settings import Settings - from game.theater import ConflictTheater + from game.theater import ConflictTheater, DaytimeMap from game.theater.seasonalconditions import SeasonalConditions @@ -318,16 +318,22 @@ class Conditions: ) -> datetime.datetime: if night_disabled: logging.info("Skip Night mission due to user settings") - time_range = { - TimeOfDay.Dawn: (8, 9), - TimeOfDay.Day: (10, 12), - TimeOfDay.Dusk: (12, 14), - TimeOfDay.Night: (14, 17), - }[time_of_day] + time_range = DaytimeMap( + dawn=(datetime.time(hour=8), datetime.time(hour=9)), + day=(datetime.time(hour=10), datetime.time(hour=12)), + dusk=(datetime.time(hour=12), datetime.time(hour=14)), + night=(datetime.time(hour=14), datetime.time(hour=17)), + ).range_of(time_of_day) else: - time_range = theater.daytime_map[time_of_day.value] + time_range = theater.daytime_map.range_of(time_of_day) - time = datetime.time(hour=random.randint(*time_range)) + # Starting missions on the hour is a nice gameplay property, so keep the random + # time constrained to that. DaytimeMap enforces that we have only whole hour + # ranges for now, so we don't need to worry about accidentally changing the time + # of day by truncating sub-hours. + time = datetime.time( + hour=random.randint(time_range[0].hour, time_range[1].hour) + ) return datetime.datetime.combine(day, time) @classmethod diff --git a/tests/test_daytimemap.py b/tests/test_daytimemap.py new file mode 100644 index 00000000..c97ee8e5 --- /dev/null +++ b/tests/test_daytimemap.py @@ -0,0 +1,107 @@ +from datetime import time + +import pytest + +from game.theater.daytimemap import DaytimeMap +from game.weather import TimeOfDay + + +def test_range_of() -> None: + m = DaytimeMap( + dawn=(time(hour=6), time(hour=9)), + day=(time(hour=9), time(hour=18)), + dusk=(time(hour=18), time(hour=20)), + night=(time(hour=0), time(hour=5)), + ) + + assert m.range_of(TimeOfDay.Dawn) == (time(hour=6), time(hour=9)) + assert m.range_of(TimeOfDay.Day) == (time(hour=9), time(hour=18)) + assert m.range_of(TimeOfDay.Dusk) == (time(hour=18), time(hour=20)) + assert m.range_of(TimeOfDay.Night) == (time(hour=0), time(hour=5)) + + +def test_best_guess_time_of_day_at() -> None: + night_at_midnight = DaytimeMap( + dawn=(time(hour=6), time(hour=9)), + day=(time(hour=9), time(hour=18)), + dusk=(time(hour=18), time(hour=20)), + night=(time(hour=0), time(hour=5)), + ) + + assert night_at_midnight.best_guess_time_of_day_at(time(hour=0)) == TimeOfDay.Night + assert night_at_midnight.best_guess_time_of_day_at(time(hour=5)) == TimeOfDay.Night + assert night_at_midnight.best_guess_time_of_day_at(time(hour=6)) == TimeOfDay.Dawn + assert night_at_midnight.best_guess_time_of_day_at(time(hour=7)) == TimeOfDay.Dawn + assert night_at_midnight.best_guess_time_of_day_at(time(hour=9)) == TimeOfDay.Day + assert night_at_midnight.best_guess_time_of_day_at(time(hour=10)) == TimeOfDay.Day + assert night_at_midnight.best_guess_time_of_day_at(time(hour=18)) == TimeOfDay.Dusk + assert night_at_midnight.best_guess_time_of_day_at(time(hour=19)) == TimeOfDay.Dusk + + night_before_midnight = DaytimeMap( + dawn=(time(hour=6), time(hour=9)), + day=(time(hour=9), time(hour=18)), + dusk=(time(hour=18), time(hour=20)), + night=(time(hour=22), time(hour=5)), + ) + + assert ( + night_before_midnight.best_guess_time_of_day_at(time(hour=0)) == TimeOfDay.Night + ) + assert ( + night_before_midnight.best_guess_time_of_day_at(time(hour=1)) == TimeOfDay.Night + ) + assert ( + night_before_midnight.best_guess_time_of_day_at(time(hour=22)) + == TimeOfDay.Night + ) + assert ( + night_before_midnight.best_guess_time_of_day_at(time(hour=23)) + == TimeOfDay.Night + ) + assert ( + night_before_midnight.best_guess_time_of_day_at(time(hour=6)) == TimeOfDay.Dawn + ) + + night_after_midnight = DaytimeMap( + dawn=(time(hour=6), time(hour=9)), + day=(time(hour=9), time(hour=18)), + dusk=(time(hour=18), time(hour=20)), + night=(time(hour=2), time(hour=5)), + ) + + assert ( + night_after_midnight.best_guess_time_of_day_at(time(hour=0)) == TimeOfDay.Dusk + ) + assert ( + night_after_midnight.best_guess_time_of_day_at(time(hour=23)) == TimeOfDay.Dusk + ) + assert ( + night_after_midnight.best_guess_time_of_day_at(time(hour=2)) == TimeOfDay.Night + ) + assert ( + night_after_midnight.best_guess_time_of_day_at(time(hour=6)) == TimeOfDay.Dawn + ) + + +def test_whole_hours_only() -> None: + with pytest.raises(ValueError): + DaytimeMap( + dawn=(time(minute=6), time(hour=9)), + day=(time(hour=9), time(hour=18)), + dusk=(time(hour=18), time(hour=20)), + night=(time(hour=2), time(hour=5)), + ) + with pytest.raises(ValueError): + DaytimeMap( + dawn=(time(hour=6), time(hour=9)), + day=(time(second=9), time(hour=18)), + dusk=(time(hour=18), time(hour=20)), + night=(time(hour=2), time(hour=5)), + ) + with pytest.raises(ValueError): + DaytimeMap( + dawn=(time(hour=6), time(hour=9)), + day=(time(hour=9), time(hour=18)), + dusk=(time(hour=18), time(microsecond=20)), + night=(time(hour=2), time(hour=5)), + )