Skip to content

Commit b2f5b6f

Browse files
committed
ENHANCEMENTS:
- MizEdit: date supports today, yesterday and tomorrow - MizEdit: start_time supports now, morning, "noon +02:00", etc. now (thanks to Zip for contributing!)
1 parent f0f5695 commit b2f5b6f

File tree

4 files changed

+174
-23
lines changed

4 files changed

+174
-23
lines changed

core/mizfile.py

Lines changed: 142 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,27 @@
55
import logging
66
import luadata
77
import os
8+
import re
89
import shutil
910
import tempfile
1011
import zipfile
1112

13+
from astral import LocationInfo
14+
from astral.sun import sun
1215
from core import utils
13-
from datetime import datetime
16+
from datetime import datetime, timedelta, date
1417
from packaging.version import parse, Version
18+
from timezonefinder import TimezoneFinder
1519
from typing import Union, Optional
20+
from zoneinfo import ZoneInfo
1621

1722
__all__ = [
1823
"MizFile",
1924
"UnsupportedMizFileException"
2025
]
2126

27+
THEATRES = {}
28+
2229

2330
class MizFile:
2431

@@ -34,6 +41,38 @@ def __init__(self, filename: str):
3441
self._load()
3542
self._files: list[dict] = []
3643
self.node = ServiceRegistry.get(ServiceBus).node
44+
if not THEATRES:
45+
self.read_theatres()
46+
47+
def read_theatres(self):
48+
maps_path = os.path.join(os.path.expandvars(self.node.locals['DCS']['installation']), "Mods", "terrains")
49+
if not os.path.exists(maps_path):
50+
self.log.error(f"Maps directory not found: {maps_path}, can't use timezone specific parameters!")
51+
return
52+
53+
for terrain in os.listdir(maps_path):
54+
terrain_path = os.path.join(maps_path, terrain)
55+
entry_lua = os.path.join(terrain_path, "entry.lua")
56+
pattern = r'local self_ID\s*=\s*"(.*?)";'
57+
with open(entry_lua, "r", encoding="utf-8") as file:
58+
match = re.search(pattern, file.read())
59+
terrain_id = match.group(1)
60+
towns_file = os.path.join(terrain_path, "Map", "towns.lua")
61+
if os.path.exists(towns_file):
62+
try:
63+
pattern = r"latitude\s*=\s*([\d.-]+),\s*longitude\s*=\s*([\d.-]+)"
64+
with open(towns_file, "r", encoding="utf-8") as file:
65+
for line in file:
66+
match = re.search(pattern, line)
67+
if match:
68+
THEATRES[terrain_id] = {float(match.group(1)), float(match.group(2))}
69+
break
70+
else:
71+
self.log.warning(f"No towns found in: {towns_file}")
72+
except Exception as ex:
73+
self.log.error(f"Error reading file {towns_file}: {ex}")
74+
else:
75+
self.log.info(f"No towns.lua found for terrain: {terrain}")
3776

3877
def _load(self):
3978
try:
@@ -68,7 +107,8 @@ def save(self, new_filename: Optional[str] = None):
68107
...
69108
else:
70109
filenames.extend([
71-
utils.make_unix_filename(item['target'], x) for x in utils.list_all_files(item['source'])
110+
utils.make_unix_filename(item['target'], x) for x in
111+
utils.list_all_files(item['source'])
72112
])
73113
for item in zin.infolist():
74114
if item.filename == 'mission':
@@ -78,8 +118,9 @@ def save(self, new_filename: Optional[str] = None):
78118
zout.writestr(item, "options = " + luadata.serialize(self.options, 'utf-8', indent='\t',
79119
indent_level=0))
80120
elif item.filename == 'warehouses':
81-
zout.writestr(item, "warehouses = " + luadata.serialize(self.warehouses, 'utf-8', indent='\t',
82-
indent_level=0))
121+
zout.writestr(item,
122+
"warehouses = " + luadata.serialize(self.warehouses, 'utf-8', indent='\t',
123+
indent_level=0))
83124
elif item.filename not in filenames:
84125
zout.writestr(item, zin.read(item.filename))
85126
for item in self._files:
@@ -118,9 +159,28 @@ def apply_preset(self, preset: Union[dict, list]):
118159
# handle special cases
119160
if key == 'date':
120161
if isinstance(value, str):
121-
self.date = datetime.strptime(value, '%Y-%m-%d')
162+
try:
163+
self.date = datetime.strptime(value, '%Y-%m-%d').date()
164+
except ValueError:
165+
if value in ['today', 'yesterday', 'tomorrow']:
166+
now = datetime.today().date()
167+
if value == 'today':
168+
self.date = now
169+
elif value == 'yesterday':
170+
self.date = now - timedelta(days=1)
171+
elif value == 'tomorrow':
172+
self.date = now + timedelta(days=1)
122173
else:
123174
self.date = value
175+
elif key == 'start_time':
176+
if isinstance(value, int):
177+
self.start_time = value
178+
else:
179+
try:
180+
self.start_time = int((datetime.strptime(value, "%H:%M") -
181+
datetime(1900, 1, 1)).total_seconds())
182+
except ValueError:
183+
self.start_time = self.parse_moment(value)
124184
elif key == 'clouds':
125185
if isinstance(value, str):
126186
self.clouds = {"preset": value}
@@ -146,20 +206,16 @@ def start_time(self) -> int:
146206
return self.mission['start_time']
147207

148208
@start_time.setter
149-
def start_time(self, value: Union[int, str]) -> None:
150-
if isinstance(value, int):
151-
start_time = value
152-
else:
153-
start_time = int((datetime.strptime(value, "%H:%M") - datetime(1900, 1, 1)).total_seconds())
154-
self.mission['start_time'] = start_time
209+
def start_time(self, value: int) -> None:
210+
self.mission['start_time'] = value
155211

156212
@property
157-
def date(self) -> datetime:
158-
date = self.mission['date']
159-
return datetime(date['Year'], date['Month'], date['Day'])
213+
def date(self) -> date:
214+
value = self.mission['date']
215+
return date(value['Year'], value['Month'], value['Day'])
160216

161217
@date.setter
162-
def date(self, value: datetime) -> None:
218+
def date(self, value: date) -> None:
163219
self.mission['date'] = {"Day": value.day, "Year": value.year, "Month": value.month}
164220

165221
@property
@@ -387,6 +443,75 @@ def files(self, files: list[Union[str, dict]]):
387443
else:
388444
self._files.append(file)
389445

446+
def parse_moment(self, value: str = "morning") -> int:
447+
"""
448+
Calculate the "moment" for the MizFile's theatre coordinates and date,
449+
then return the time corresponding to the "moment" parameter in seconds since midnight
450+
in the local timezone.
451+
452+
Example: parse_moment(mizfile, "sunrise") returns the time of sunrise in seconds since midnight
453+
Example: parse_moment(mizfile, "sunrise + 01:00") returns the time of sunrise + 1 hour in seconds since midnight
454+
Example: parse_moment(mizfile, "morning") returns the time of morning in seconds since midnight
455+
456+
Parameters:
457+
self: MizFile object with date, theatreCoordinates, and start_time properties
458+
value: string representing the moment to calculate
459+
460+
Constants available for the "moment" parameter:
461+
- sunrise: The time of sunrise
462+
- dawn: The time of dawn
463+
- morning: Two hours after dawn
464+
- noon: The time of solar noon
465+
- evening: Two hours before sunset
466+
- sunset: The time of sunset
467+
- dusk: The time of dusk
468+
- night: Two hours after dusk
469+
"""
470+
471+
# Get the date from the MizFile object
472+
target_date = self.date
473+
474+
# Extract latitude and longitude from theatreCoordinates
475+
latitude, longitude = THEATRES[self.theatre]
476+
477+
# Determine the local timezone
478+
timezone = TimezoneFinder().timezone_at(lat=latitude, lng=longitude)
479+
if not timezone:
480+
raise ValueError("start_time: Could not determine timezone for the given coordinates!")
481+
482+
# Create a LocationInfo object for astral calculations
483+
location = LocationInfo("Custom", "Location", timezone, latitude, longitude)
484+
485+
# Calculate sun times
486+
solar_events = sun(location.observer, date=target_date, tzinfo=ZoneInfo(timezone)).copy()
487+
488+
# Alternate moments, calculated based on the solar events above
489+
solar_events |= {
490+
"now": datetime.now(tz=ZoneInfo(timezone)),
491+
"morning": solar_events["dawn"] + timedelta(hours=2),
492+
"evening": solar_events["sunset"] - timedelta(hours=2),
493+
"night": solar_events["dusk"] + timedelta(hours=2)
494+
}
495+
496+
match = re.match(r"(\w+)\s*([+-]\d{2}:\d{2})?", value.strip())
497+
if not match:
498+
raise ValueError("start_time: Invalid input format. Expected '<event> [+HH:MM|-HH:MM]'.")
499+
500+
event = match.group(1) # the event
501+
offset = match.group(2) # the offset time (+/- HH24:MM)
502+
503+
if not event or event.lower() not in solar_events:
504+
raise ValueError(f"start_time: Invalid solar event '{event}'. "
505+
f"Valid events are {list(solar_events.keys())}.")
506+
507+
base_time = solar_events[event.lower()]
508+
if offset:
509+
hours, minutes = map(int, offset.split(":"))
510+
delta = timedelta(hours=hours, minutes=minutes)
511+
base_time += delta
512+
513+
return (base_time.hour * 3600) + (base_time.minute * 60)
514+
390515
def modify(self, config: Union[list, dict]) -> None:
391516

392517
def sort_dict(d):
@@ -430,7 +555,8 @@ def process_elements(reference: dict, **kwargs):
430555
element[_what - 1] = utils.evaluate(_with, reference=reference, **kkwargs, **kwargs)
431556
except IndexError:
432557
element.append(utils.evaluate(_with, reference=reference, **kkwargs, **kwargs))
433-
elif isinstance(element, dict) and any(isinstance(key, (int, float)) for key in element.keys()):
558+
elif isinstance(element, dict) and any(
559+
isinstance(key, (int, float)) for key in element.keys()):
434560
element[_what] = utils.evaluate(_with, reference=reference, **kkwargs, **kwargs)
435561
sort = True
436562
elif isinstance(_with, dict) and isinstance(element[_what], (int, str, float, bool)):

extensions/mizedit/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ With this method, you can change the following values in your mission (to be ext
8686
8787
I highly recommend looking at a mission or options file inside your miz-file to see the structure of these settings.
8888
89+
date has different options:
90+
* date: '2022-05-31' # normal date
91+
* date: 'today' # one of today, yesterday, tomorrow
92+
93+
start_time has different options:
94+
* start_time: 28800 # seconds since midnight (here: 08:00h)
95+
* start_time: '08:00' # fixed time
96+
* start_time: 'noon' # one of now, dawn, sunrise, morning, noon, evening, sunset, dusk, night as a moment in time based on the current map / date
97+
* start_time: 'morning +02:00' # relative time to one of the above-mentioned moments
98+
99+
> [!NOTE]
100+
> The moments are calculated based on the current theatre and date. If you change the date through MizEdit, you need to
101+
> set that prior to the start_time!
102+
>
103+
> Thanks @davidp57 for contributing the moments part!
104+
89105
#### b) Attaching Files
90106
If you want to attach files to your mission (e.g. sounds but others like scripts, etc.), you can do it like this:
91107
```yaml

plugins/scheduler/views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import discord
2+
from discord import Interaction
3+
from discord._types import ClientT
24

35
from core import Server
46
from discord.ui import Modal, TextInput, View, Button
@@ -87,7 +89,8 @@ async def on_ok(self, interaction: discord.Interaction, _: Button):
8789
@discord.ui.button(label='Config', style=discord.ButtonStyle.secondary)
8890
async def on_config(self, interaction: discord.Interaction, _: Button):
8991
class ConfigModal(Modal, title="Server Configuration"):
90-
name = TextInput(label="Name", default=self.server.name, max_length=80, required=True)
92+
name = TextInput(label="Name", default=self.server.name if self.server.name != 'n/a' else '',
93+
min_length=3, max_length=80, required=True)
9194
description = TextInput(label="Description", style=discord.TextStyle.long,
9295
default=self.server.settings.get('description'), max_length=2000, required=False)
9396
password = TextInput(label="Password", placeholder="n/a", default=self.server.settings.get('password'),

requirements.txt

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
# AIOFiles: File support for python's asyncio
22
aiofiles==24.1.0
33

4+
# Astral: Get times for solar events (morning, noon, etc.)
5+
astral==3.2
6+
47
# For discord.py on Python 3.13
58
audioop-lts==0.2.1; python_version>='3.13'
69

710
# AIOHTTP: Uses asyncio for async/await native coroutines for establishing and handling HTTP connections
8-
aiohttp==3.11.12
11+
aiohttp==3.11.13
912

1013
# Certifi: Python package for providing Mozilla's CA Bundle
1114
certifi==2025.1.31
@@ -20,7 +23,7 @@ discord.py==2.4.0
2023
eyeD3==0.9.7
2124

2225
# FastAPI: A modern, fast (high-performance), web framework for building APIs with Python 3.6+ based on standard Python type hints
23-
fastapi==0.115.8
26+
fastapi==0.115.11
2427

2528
# FuzzyWuzzy: Python library that uses Levenshtein Distance to calculate the differences between strings
2629
fuzzywuzzy==0.18.0
@@ -56,22 +59,22 @@ packaging==24.2
5659
pid==3.0.4
5760

5861
# Psycopg: PostgreSQL database adapter for Python
59-
psycopg[binary]==3.2.4
62+
psycopg[binary]==3.2.5
6063

6164
# Psycopg Pool: PostgreSQL database adapter pool for Python
62-
psycopg-pool==3.2.4
65+
psycopg-pool==3.2.6
6366

6467
# PSUtil: Cross-platform library to access system details and process utilities
6568
psutil==7.0.0
6669

6770
# PyArrow: Python library providing high-performance tools for doing analytics on Arrow-based columnar data
68-
pyarrow==19.0.0
71+
pyarrow==19.0.1
6972

7073
# pyKwalify: A Python library for YAML/JSON schema validation
7174
pykwalify==1.8.0
7275

7376
# python-Levenshtein: Python extension for computing string edit distances and similarities
74-
python-Levenshtein==0.26.1
77+
python-Levenshtein==0.27.1
7578

7679
# python-multipart: A library for parsing multipart/form-data input requests
7780
python-multipart==0.0.20
@@ -94,6 +97,9 @@ seaborn==0.13.2
9497
# SQL parser, to allow multiline SQLs
9598
sqlparse==0.5.3
9699

100+
# TimetoneFinder: Find the right timezones for a given coordinate
101+
timezonefinder==6.5.8
102+
97103
# tomli: Python TOML parser
98104
tomli==2.2.1
99105
tomli_w==1.2.0

0 commit comments

Comments
 (0)