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.
This commit is contained in:
Dan Albert 2022-08-30 23:32:23 -07:00
parent f49833646d
commit 82939a446b
4 changed files with 255 additions and 62 deletions

View File

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

View File

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

View File

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

107
tests/test_daytimemap.py Normal file
View File

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