Skip to content

Commit a534da6

Browse files
committed
ENHANCEMENTS:
- MizEdit can call Python functions now. - Carrier relocation script added (Credits to Mags and PyDCS) BUGFIXES: - .orig files will be written on /mission modify and /realweather also now
1 parent 4e8b8f0 commit a534da6

File tree

12 files changed

+910
-210
lines changed

12 files changed

+910
-210
lines changed

core/data/impl/serverimpl.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -671,9 +671,6 @@ async def apply_mission_changes(self, filename: Optional[str] = None) -> str:
671671
dirty = False
672672
for ext in self.extensions.values():
673673
if type(ext).beforeMissionLoad != Extension.beforeMissionLoad:
674-
# make an initial backup, if there is none
675-
if '.dcssb' not in filename and not os.path.exists(filename + '.orig'):
676-
shutil.copy2(filename, filename + '.orig')
677674
new_filename, _dirty = await ext.beforeMissionLoad(new_filename)
678675
if _dirty:
679676
self.log.info(f' => {ext.name} applied on {new_filename}.')
@@ -741,6 +738,9 @@ async def uploadMission(self, filename: str, url: str, force: bool = False, miss
741738
async def modifyMission(self, filename: str, preset: Union[list, dict]) -> str:
742739
miz = await asyncio.to_thread(MizFile, filename)
743740
await asyncio.to_thread(miz.apply_preset, preset)
741+
# make an initial backup, if there is none
742+
if '.dcssb' not in filename and not os.path.exists(filename + '.orig'):
743+
shutil.copy2(filename, filename + '.orig')
744744
# write new mission
745745
new_filename = utils.create_writable_mission(filename)
746746
await asyncio.to_thread(miz.save, new_filename)

core/mizfile.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from __future__ import annotations
2+
3+
import importlib
24
import io
35
import logging
46
import luadata
@@ -461,6 +463,20 @@ def process_elements(reference: dict, **kwargs):
461463
for _what in element.copy():
462464
if utils.evaluate(config['delete'], **_what):
463465
element.remove(_what)
466+
elif 'run' in config:
467+
if debug:
468+
self.log.debug(f"Processing {config['run']}() ...")
469+
module_name, func_name = config['run'].rsplit(".", 1)
470+
try:
471+
module = importlib.import_module(module_name)
472+
func = getattr(module, func_name)
473+
func(element, reference, **kwargs)
474+
except AttributeError:
475+
self.log.error(f"Function {func_name} not found in module {module_name}.")
476+
raise
477+
except ModuleNotFoundError:
478+
self.log.error(f"Module {module_name} not found.")
479+
raise
464480

465481
def check_where(reference: dict, config: Union[list, str], debug: bool, **kwargs: dict) -> bool:
466482
if isinstance(config, str):
@@ -479,7 +495,11 @@ def check_where(reference: dict, config: Union[list, str], debug: bool, **kwargs
479495
for cfg in config:
480496
self.modify(cfg)
481497
return
498+
499+
# enable debug logging
482500
debug = config.get('debug', False)
501+
502+
# which file has to be changed?
483503
file = config.get('file', 'mission')
484504
if file == 'mission':
485505
source = self.mission
@@ -490,13 +510,25 @@ def check_where(reference: dict, config: Union[list, str], debug: bool, **kwargs
490510
else:
491511
self.log.error(f"File {file} can not be changed.")
492512
return
513+
493514
kwargs = {}
494-
if 'variables' in config:
495-
for name, value in config['variables'].items():
496-
if value.startswith('$'):
497-
kwargs[name] = utils.evaluate(value, **kwargs)
498-
else:
499-
kwargs[name] = next(utils.for_each(source, value.split('/'), debug=debug, **kwargs))
515+
# check if we need to import stuff
516+
for imp in config.get('imports', []):
517+
try:
518+
importlib.import_module(imp)
519+
except ModuleNotFoundError:
520+
self.log.error(f"Module '{imp}' could not be imported.")
521+
except Exception as ex:
522+
self.log.error(f"An error occurred while importing module '{imp}': {ex}")
523+
524+
# do we need to pre-set variables to work with?
525+
for name, value in config.get('variables', {}).items():
526+
if value.startswith('$'):
527+
kwargs[name] = utils.evaluate(value, **kwargs)
528+
else:
529+
kwargs[name] = next(utils.for_each(source, value.split('/'), debug=debug, **kwargs))
530+
531+
# run the processing
500532
try:
501533
for_each = config['for-each'].lstrip('/')
502534
except KeyError:

core/utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
from .dcs import *
55
from .discord import *
66
from .helper import *
7+
from .mizedit import *
78
from .os import *
89
from .performance import *

core/utils/helper.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@
7070
"hash_password",
7171
"evaluate",
7272
"for_each",
73-
"YAMLError"
73+
"YAMLError",
74+
"DictWrapper"
7475
]
7576

7677
logger = logging.getLogger(__name__)
@@ -923,3 +924,80 @@ class YAMLError(Exception):
923924
"""
924925
def __init__(self, file: str, ex: Union[MarkedYAMLError, ValueError, SchemaError]):
925926
super().__init__(f"Error in {file}, " + ex.__str__().replace('"<unicode string>"', file))
927+
928+
929+
class DictWrapper:
930+
"""A wrapper for dictionaries enabling both attribute and key-based access."""
931+
932+
def __init__(self, data):
933+
"""Initialize with a dictionary or a list."""
934+
if isinstance(data, dict):
935+
self._data = {k: self._wrap(v) for k, v in data.items()}
936+
elif isinstance(data, list):
937+
self._data = [self._wrap(v) for v in data]
938+
else:
939+
self._data = data # Handle non-dict types (e.g., primitive values)
940+
941+
@staticmethod
942+
def _wrap(value):
943+
"""Wrap nested dictionaries or lists inside DictWrapper."""
944+
if isinstance(value, dict):
945+
return DictWrapper(value)
946+
elif isinstance(value, list):
947+
return [DictWrapper._wrap(v) for v in value]
948+
return value
949+
950+
def __getattr__(self, name):
951+
"""Access dictionary keys as attributes."""
952+
try:
953+
return self._data[name]
954+
except KeyError:
955+
raise AttributeError(f"Attribute '{name}' not found")
956+
957+
def __setattr__(self, name, value):
958+
"""Set dictionary keys as attributes."""
959+
if name == "_data":
960+
super().__setattr__(name, value)
961+
else:
962+
self._data[name] = self._wrap(value)
963+
964+
def __delattr__(self, name):
965+
"""Delete keys like attributes."""
966+
try:
967+
del self._data[name]
968+
except KeyError:
969+
raise AttributeError(f"Attribute '{name}' not found")
970+
971+
def __getitem__(self, key):
972+
"""Support list-style or dictionary-style key/item access."""
973+
return self._data[key]
974+
975+
def __setitem__(self, key, value):
976+
"""Set items using key."""
977+
self._data[key] = self._wrap(value)
978+
979+
def __delitem__(self, key):
980+
"""Support item deletion."""
981+
del self._data[key]
982+
983+
def __iter__(self):
984+
"""Allow iteration."""
985+
return iter(self._data)
986+
987+
def __repr__(self):
988+
"""Pretty representation."""
989+
return repr(self._data)
990+
991+
def to_dict(self):
992+
def _unwrap_list(value):
993+
if isinstance(value, list):
994+
return [(v.to_dict() if isinstance(v, DictWrapper) else _unwrap_list(v)) for v in value]
995+
return value
996+
997+
if isinstance(self._data, dict):
998+
return {
999+
k: (v.to_dict() if isinstance(v, DictWrapper) else _unwrap_list(v)) for k, v in self._data.items()
1000+
}
1001+
elif isinstance(self._data, list):
1002+
return _unwrap_list(self._data)
1003+
return self._data

core/utils/mizedit/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .carrier_relocator import *
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Credits to magwo (Magnus Wolffelt)
2+
import math
3+
4+
from core.utils.helper import DictWrapper
5+
from .trigonometric_carrier_cruise import get_ship_course_and_speed
6+
from .me_utils import Speed, Distance, Heading, knots, HeadingAndSpeed
7+
8+
__all__ = ['relocate_carrier']
9+
10+
11+
#
12+
# TAKEN FROM PyDCS! >>> START
13+
#
14+
def point_from_heading(_x: float, _y: float, heading: float, distance: float) -> tuple[float, float]:
15+
"""Calculates a point from a given point, heading and distance.
16+
17+
:param _x: source point x
18+
:param _y: source point y
19+
:param heading: heading in degrees from source point
20+
:param distance: distance from source point
21+
:return: returns a tuple (x, y) of the calculated point
22+
"""
23+
while heading < 0:
24+
heading += 360
25+
heading %= 360
26+
rad_heading = math.radians(heading)
27+
x = _x + math.cos(rad_heading) * distance
28+
y = _y + math.sin(rad_heading) * distance
29+
30+
return x, y
31+
32+
33+
def heading_between_points(x1: float, y1: float, x2: float, y2: float) -> float:
34+
"""Returns the angle between 2 points in degrees.
35+
36+
:param x1: x coordinate of point 1
37+
:param y1: y coordinate of point 1
38+
:param x2: x coordinate of point 2
39+
:param y2: y coordinate of point 2
40+
:return: angle in degrees
41+
"""
42+
def angle_trunc(a):
43+
while a < 0.0:
44+
a += math.pi * 2
45+
return a
46+
deltax = x2 - x1
47+
deltay = y2 - y1
48+
return math.degrees(angle_trunc(math.atan2(deltay, deltax)))
49+
50+
51+
def distance_to_point(x1: float, y1: float, x2: float, y2: float) -> float:
52+
"""Returns the distance between 2 points
53+
54+
:param x1: x coordinate of point 1
55+
:param y1: y coordinate of point 1
56+
:param x2: x coordinate of point 2
57+
:param y2: y coordinate of point 2
58+
:return: distance in point units(m)
59+
"""
60+
return math.hypot(x2 - x1, y2 - y1)
61+
#
62+
# TAKEN FROM PyDCS <<<< END
63+
#
64+
65+
def get_carrier_cruise(wind: dict, deck_angle: float, desired_apparent_wind: Speed) -> HeadingAndSpeed:
66+
wind_speed = Speed.from_meters_per_second(wind.get('speed', 0))
67+
heading, speed, apparent_wind_angle = get_ship_course_and_speed(
68+
wind.get('dir', 0), wind_speed.knots, desired_apparent_wind.knots
69+
)
70+
# Quick hack for Tarawa
71+
if deck_angle == 0:
72+
wind_heading = Heading( wind.get('dir', 0))
73+
heading = wind_heading.opposite.degrees
74+
75+
solution = HeadingAndSpeed(
76+
Heading.from_degrees(heading), Speed.from_knots(speed)
77+
)
78+
return solution
79+
80+
81+
def rotate_group_around(group: DictWrapper, pivot: tuple[float, float], degrees_change: float):
82+
# My fear was that using sin/cos would result in incorrect
83+
# transforms when not near the equator. Polar coordinates (heading + distance)
84+
# should resolve that, if the Point functions are implemented correctly.
85+
# I think they're not, but this code at least doesn't prevent correct transform.
86+
# Maybe DCS doesn't even use mercator projection.
87+
for unit in group.units:
88+
distance = distance_to_point(pivot[0], pivot[1], unit.x, unit.y)
89+
heading = heading_between_points(pivot[0], pivot[1], unit.x, unit.y)
90+
new_heading = Heading.from_degrees(heading + degrees_change).degrees
91+
92+
unit.x, unit.y = point_from_heading(pivot[0], pivot[1], new_heading, distance)
93+
unit.heading = Heading.from_degrees(unit.heading + degrees_change).degrees
94+
95+
96+
def relocate_carrier(_: dict, reference: dict, **kwargs):
97+
# create a wrapper, to make it easier (and to mainly keep the old code)
98+
group = DictWrapper(reference)
99+
route = group.route
100+
101+
wind = kwargs.get('wind', {})
102+
carrier = group.units[0]
103+
deck_angle = 0 if carrier.type == 'LHA_Tarawa' else -9.12
104+
cruise = get_carrier_cruise(wind, deck_angle, Speed.from_knots(25))
105+
106+
radius = Distance.from_nautical_miles(50)
107+
carrier_start_pos = point_from_heading(
108+
group['x'], group['y'], cruise.heading.opposite.degrees, radius.meters
109+
)
110+
carrier_end_pos = point_from_heading(reference['x'], reference['y'], cruise.heading.degrees, radius.meters)
111+
112+
group_heading_before_change = heading_between_points(
113+
route.points[0].x, route.points[0].y, route.points[1].x, route.points[1].y
114+
)
115+
group_position_before_change = (route.points[0].x, route.points[0].y)
116+
117+
if len(route.points) < 4:
118+
print(f"Carrier group {reference['name']} missing waypoint")
119+
return
120+
# TODO
121+
122+
route.points[0].x = carrier_start_pos[0]
123+
route.points[0].y = carrier_start_pos[1]
124+
route.points[0].speed = cruise.speed.meters_per_second
125+
126+
route.points[1].x = carrier_end_pos[0]
127+
route.points[1].y = carrier_end_pos[1]
128+
route.points[1].ETA_locked = False
129+
route.points[1].speed = cruise.speed.meters_per_second
130+
route.points[1].speed_locked = True
131+
132+
route.points[2].x = carrier_start_pos[0]
133+
route.points[2].y = carrier_start_pos[1]
134+
route.points[2].speed = knots(50).meters_per_second
135+
route.points[2].ETA_locked = False
136+
route.points[2].speed_locked = True
137+
138+
route.points[3].x = carrier_end_pos[0]
139+
route.points[3].y = carrier_end_pos[1]
140+
route.points[3].ETA_locked = False
141+
route.points[3].speed = cruise.speed.meters_per_second
142+
route.points[3].speed_locked = True
143+
144+
position_change = (
145+
carrier_start_pos[0] - group_position_before_change[0],
146+
carrier_start_pos[1] - group_position_before_change[1]
147+
)
148+
for unit in group.units:
149+
unit.x = unit.x + position_change[0]
150+
unit.y = unit.y + position_change[1]
151+
152+
heading_change = cruise.heading.degrees - group_heading_before_change
153+
rotate_group_around(
154+
group, (route.points[0].x, route.points[0].y), heading_change
155+
)
156+
157+
# change the real thing
158+
reference['route'] = route.to_dict()
159+
reference['units'] = [unit.to_dict() for unit in group.units]

0 commit comments

Comments
 (0)