Skip to content

Commit 7f71b90

Browse files
committed
Merge branch 'development'
2 parents 368dd60 + 3067e5b commit 7f71b90

15 files changed

Lines changed: 149 additions & 56 deletions

File tree

core/data/impl/serverimpl.py

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import shutil
1313
import sys
1414
import tempfile
15+
import time
1516
import traceback
1617

1718
if sys.platform == 'win32':
@@ -39,7 +40,8 @@
3940
from psycopg.errors import UndefinedTable
4041
from typing import TYPE_CHECKING, Any, Iterable
4142
from typing_extensions import override
42-
from watchdog.events import FileSystemEventHandler, FileSystemEvent, FileSystemMovedEvent
43+
from watchdog.events import (FileSystemEventHandler, DirCreatedEvent, FileCreatedEvent, DirDeletedEvent,
44+
FileDeletedEvent, DirMovedEvent, FileMovedEvent)
4345
from watchdog.observers import Observer, ObserverType
4446

4547
# ruamel YAML support
@@ -59,34 +61,74 @@
5961

6062

6163
class MissionFileSystemEventHandler(FileSystemEventHandler):
62-
def __init__(self, server: Server, loop: asyncio.AbstractEventLoop):
64+
DEBOUNCE_WINDOW = 2.0 # seconds
65+
TEMP_SUFFIXES = ('.tmp', '.part', '.swp', '~')
66+
IGNORE_PREFIX = '.' # hidden files
67+
68+
def __init__(self, server, loop):
69+
super().__init__()
6370
self.server = server
6471
self.log = server.log
6572
self.loop = loop
6673
self.deleted: dict[str, int] = {}
74+
self._seen: dict[str, float] = {}
6775

68-
@override
69-
def on_created(self, event: FileSystemEvent):
70-
path: str = os.path.normpath(event.src_path)
71-
# ignore non-mission files and such that are in the .dcssb folder
72-
if not (path.endswith('.miz') or path.endswith('.sav')) or '.dcssb' in path:
76+
def _is_debounced(self, path):
77+
now = time.monotonic()
78+
last = self._seen.get(path, 0)
79+
if now - last < self.DEBOUNCE_WINDOW:
80+
return True
81+
self._seen[path] = now
82+
return False
83+
84+
def _is_valid_mission_file(self, path):
85+
name = os.path.basename(path)
86+
if name.startswith(self.IGNORE_PREFIX):
87+
return False
88+
if any(name.endswith(suf) for suf in self.TEMP_SUFFIXES):
89+
return False
90+
return path.endswith('.miz') or path.endswith('.sav')
91+
92+
def _wait_for_stable_file(self, path, timeout=5.0):
93+
start = time.monotonic()
94+
while time.monotonic() - start < timeout:
95+
try:
96+
size1 = os.path.getsize(path)
97+
except FileNotFoundError:
98+
return False
99+
time.sleep(0.5)
100+
try:
101+
size2 = os.path.getsize(path)
102+
except FileNotFoundError:
103+
return False
104+
if size1 == size2:
105+
return True
106+
return False
107+
108+
# ----------------------------------------------------------------------
109+
# Core handlers
110+
# ----------------------------------------------------------------------
111+
def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
112+
path = os.path.normpath(event.src_path)
113+
if not self._is_valid_mission_file(path):
114+
return
115+
if self._is_debounced(path):
73116
return
117+
118+
if not self._wait_for_stable_file(path):
119+
self.log.warning(f"File {path} never stabilized – skipping.")
120+
return
121+
74122
if path in self.deleted:
75-
asyncio.run_coroutine_threadsafe(self.server.addMission(path, idx=self.deleted[path]), self.loop)
76-
del self.deleted[path]
123+
idx = self.deleted.pop(path)
124+
asyncio.run_coroutine_threadsafe(self.server.addMission(path, idx=idx), self.loop)
77125
else:
78126
asyncio.run_coroutine_threadsafe(self.server.addMission(path), self.loop)
79-
self.log.info(f"=> New mission {os.path.basename(path)[:-4]} added to server {self.server.name}.")
80127

81-
@override
82-
def on_moved(self, event: FileSystemMovedEvent):
83-
self.on_deleted(event)
84-
self.on_created(FileSystemEvent(event.dest_path))
128+
self.log.info(f"=> New mission {os.path.basename(path)[:-4]} added to server {self.server.name}.")
85129

86-
@override
87-
def on_deleted(self, event: FileSystemEvent):
88-
path: str = os.path.normpath(event.src_path)
89-
# ignore non-mission files
130+
def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent) -> None:
131+
path = os.path.normpath(event.src_path)
90132
if not path.endswith('.miz'):
91133
return
92134
missions = self.server.settings['missionList']
@@ -97,13 +139,15 @@ def on_deleted(self, event: FileSystemEvent):
97139
if path in missions:
98140
idx = missions.index(path) + 1
99141
asyncio.run_coroutine_threadsafe(self.server.deleteMission(idx), self.loop)
100-
# cache the index of the line to re-add the file at the correct position afterward
101-
# if a cloud drive did a delete/add instead of a modification
102142
self.deleted[path] = idx
103143
self.log.info(f"=> Mission {os.path.basename(path)[:-4]} deleted from server {self.server.name}.")
104144
else:
105145
self.log.debug(f"Mission file {path} got deleted from disk.")
106146

147+
def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
148+
self.on_deleted(event)
149+
self.on_created(event)
150+
107151

108152
@dataclass
109153
@DataObjectFactory.register()
@@ -156,7 +200,7 @@ def settings(self) -> dict:
156200
# if someone managed to destroy the mission list, fix it...
157201
if 'missionList' not in self._settings:
158202
self._settings['missionList'] = []
159-
self._settings['listStartIndex'] = 0
203+
self._settings['listStartIndex'] = self.settings['current'] = 0
160204
elif isinstance(self._settings['missionList'], dict):
161205
self._settings['missionList'] = list(self._settings['missionList'].values())
162206
return self._settings
@@ -232,7 +276,7 @@ def _make_missions_unique(self):
232276
new_start = self._settings['missionList'].index(current_mission)
233277
except ValueError:
234278
new_start = 0
235-
self._settings['listStartIndex'] = new_start + 1
279+
self._settings['listStartIndex'] = self._settings['current'] = new_start + 1
236280

237281
async def _load_mission_list(self):
238282
try:
@@ -257,8 +301,7 @@ def set_status(self, status: Status | str):
257301
if self._status == Status.UNREGISTERED and status == Status.SHUTDOWN:
258302
if self.locals.get('autoscan', False):
259303
self._init_mission_list()
260-
else:
261-
self._make_missions_unique()
304+
self._make_missions_unique()
262305
elif self._status in [Status.UNREGISTERED, Status.LOADING] and new_status in [Status.RUNNING, Status.PAUSED]:
263306
# only check the mission list if we started that server
264307
if self._status == Status.LOADING:
@@ -551,7 +594,7 @@ def do_startup(self):
551594
idx = missions.index(start_mission) + 1
552595
except ValueError:
553596
idx = 1
554-
self.settings['listStartIndex'] = idx
597+
self.settings['listStartIndex'] = self.settings['current'] = idx
555598
self.log.warning('Removed non-existent missions from serverSettings.lua')
556599
self.log.debug(r'Launching DCS server with: "{}" --server --norender -w {}'.format(path, self.instance.name))
557600
try:
@@ -1059,8 +1102,7 @@ async def loadMission(self, mission: int | str, modify_mission: bool | None = Tr
10591102
if self.status == Status.STOPPED:
10601103
try:
10611104
idx = mission_list.index(filename) + 1
1062-
self.settings['listStartIndex'] = idx
1063-
self.settings['current'] = idx
1105+
self.settings['listStartIndex'] = self.settings['current'] = idx
10641106
return await self.start()
10651107
except ValueError:
10661108
return False

extensions/olympus/extension.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import sys
1313

1414
from core import utils, Server, get_translation, PortType, Port, ProcessManager, InstallableExtension
15-
from packaging.version import Version, InvalidVersion
1615
from threading import Thread
1716
from typing import cast
1817
from typing_extensions import override
@@ -81,6 +80,8 @@ def __init__(self, server: Server, config: dict):
8180
instance=server.instance.name
8281
)
8382
self.log.debug("- Running Olympus process found.")
83+
else:
84+
self.process = None
8485

8586
if self.version == '1.0.3':
8687
self.backend_tag = 'server'

plugins/admin/commands.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,13 @@ async def repair(self, interaction: discord.Interaction,
476476
slow: bool | None = False, check_extra_files: bool | None = False,
477477
warn_time: app_commands.Range[int, 0] = 60):
478478
ephemeral = utils.get_ephemeral(interaction)
479-
await interaction.response.defer(thinking=True, ephemeral=ephemeral)
479+
if not await utils.yn_question(
480+
interaction,
481+
_("Do you want to run a DCS World repair on node {name}?").format(name=node.name)
482+
):
483+
await interaction.followup.send(_("Aborted."))
484+
return
485+
480486
await self.bot.audit(f"started a repair of DCS World on node {node.name}.", user=interaction.user)
481487
msg = await interaction.followup.send(_("Repairing DCS World, please wait ..."), ephemeral=ephemeral)
482488
try:

plugins/discord/commands.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from core import Plugin, command, utils, get_translation, Group
44
from datetime import timedelta
55
from discord import app_commands, Permissions
6+
from discord.abc import GuildChannel
67
from discord.ext import commands
7-
8-
from plugins.discord.views import HealthcheckView
98
from services.bot import DCSServerBot
109
from services.cron.actions import purge_channel
10+
from typing import cast
11+
12+
from .views import HealthcheckView
1113

1214
_ = get_translation(__name__.split('.')[1])
1315

@@ -30,7 +32,10 @@ async def on_ready(self):
3032
if config['ping_everyone'].get('timeout', False) and not self.bot.member.guild_permissions.moderate_members:
3133
self.log.warning(f"{self.__class__.__name__}: Bot is missing permission to timeout members!")
3234
if 'reaction' in config:
33-
channel = self.bot.get_channel(config['reaction']['channel'])
35+
channel: GuildChannel | None = cast(GuildChannel, self.bot.get_channel(config['reaction']['channel']))
36+
if not channel:
37+
self.log.warning(f"{self.__class__.__name__}: Reaction channel {config['reaction']['channel']} not found!")
38+
return
3439
message = await self.bot.fetch_embed('reaction', channel)
3540
if not message:
3641
guild = self.bot.guilds[0]

plugins/greenieboard/listener.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,15 +158,15 @@ async def process_lso_event(self, config: dict, server: Server, player: Player,
158158
case = data.get('case', 1 if not night else 3)
159159
wire = data.get('wire')
160160
async with self.apool.connection() as conn:
161-
min_flight_time = self.get_config(server).get('min_flight_time')
161+
min_flight_time: int | None = self.get_config(server).get('min_flight_time')
162162
if min_flight_time:
163163
cursor = await conn.execute("""
164-
SELECT EXTRACT(EPOCH FROM (NOW() AT TIME ZONE 'utc' - MAX(time))) FROM missionstats
164+
SELECT COALESCE(EXTRACT(EPOCH FROM (NOW() AT TIME ZONE 'utc' - MAX(time))), 9999) FROM missionstats
165165
WHERE mission_id = %s
166166
AND init_id = %s
167167
AND event = 'S_EVENT_TAKEOFF'
168168
""", (server.mission_id, player.ucid))
169-
flight_time = await cursor.fetchone()
169+
flight_time: tuple[float] | None = await cursor.fetchone()
170170
if flight_time and flight_time[0] < min_flight_time:
171171
# ignore this event, as the player has not flown for the minimum flight time
172172
self.log.debug(f"Greenieboard: Player {player.name} has not flown for the minimum flight time.")

plugins/mission/commands.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,8 +1435,10 @@ async def wstypes(self, interaction: discord.Interaction,
14351435
async def _list(self, interaction: discord.Interaction,
14361436
server: app_commands.Transform[Server, utils.ServerTransformer(status=[Status.RUNNING])]):
14371437
if server.status != Status.RUNNING:
1438-
await interaction.response.send_message(_("Server {} is not running.").format(server.display_name),
1439-
ephemeral=True)
1438+
await interaction.response.send_message(
1439+
_("Server {} is {}.").format(server.display_name, server.status.value.lower()),
1440+
ephemeral=True
1441+
)
14401442
return
14411443
report = Report(self.bot, self.plugin_name, 'players.json')
14421444
env = await report.render(server=server, sides=utils.get_sides(interaction.client, interaction, server))

plugins/mission/listener.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -887,7 +887,7 @@ async def _stop_player(self, server: Server, player: Player):
887887

888888
@event(name="onPlayerStop")
889889
async def onPlayerStop(self, server: Server, data: dict) -> None:
890-
if data['id'] == 1:
890+
if data['id'] == 1 or 'ucid' not in data:
891891
return
892892
player = server.get_player(ucid=data['ucid'])
893893
if player:

plugins/missionstats/listener.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,21 @@ async def onMissionEvent(self, server: Server, data: dict) -> None:
264264
# TODO: rewrite that code, so the initiator is not needed
265265
win_coalition = self.COALITION[data['initiator']['coalition']]
266266
lose_coalition = self.COALITION[(data['initiator']['coalition'] % 2) + 1]
267-
name = data['place']['name']
267+
name = data.get('place', {}).get('name')
268+
# in the unlikely event that we do not have a name, return
269+
if not name:
270+
return
268271
# workaround for DCS base capture bug:
269-
if name in stats['coalitions'][win_coalition.name]['airbases'] or \
270-
name not in stats['coalitions'][lose_coalition.name]['airbases']:
272+
if (
273+
name in stats['coalitions'][win_coalition.name]['airbases'] or
274+
name not in stats['coalitions'][lose_coalition.name]['airbases']
275+
):
271276
return
272277

273278
wc = stats['coalitions'][win_coalition.name]
274-
wc.setdefault('airbases', []).append(name)
279+
if not wc.get('airbases'):
280+
wc['airbases'] = []
281+
wc['airbases'].append(name)
275282
wc['captures'] = wc.get('captures', 0) + 1
276283
lc = stats['coalitions'][lose_coalition.name]
277284
if name in lc['airbases']:

plugins/punishment/listener.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def missile_threat_score(self, distance_old: float, age_s: float, weapon: str |
8282

8383
v_avrg = params["v_avrg"]
8484
p_dstr = params["p_dstr"]
85+
d_max = params["d_max"]
8586

8687
if distance_old <= 0 or age_s < 0:
8788
return 0.0
@@ -90,7 +91,19 @@ def missile_threat_score(self, distance_old: float, age_s: float, weapon: str |
9091
if distance_new <= 0:
9192
return 0.0
9293

93-
return distance_new * p_dstr
94+
# Up to d_max, keep the configured endgame probability.
95+
if distance_old <= d_max:
96+
energy_factor = 1.0
97+
else:
98+
# Past d_max, missile energy gradually decays.
99+
# At roughly 3.5x d_max, consider the missile out of useful energy.
100+
max_energy_range = d_max * 3.5
101+
if distance_old >= max_energy_range:
102+
return 0.0
103+
104+
energy_factor = 1.0 - ((distance_old - d_max) / (max_energy_range - d_max))
105+
106+
return p_dstr * energy_factor
94107

95108
def is_missile_hot(self, distance_m: float, age_s: float, weapon: str | None) -> bool:
96109
return self.missile_threat_score(distance_m, age_s, weapon) >= 0.15
@@ -543,10 +556,11 @@ async def onMissionEvent(self, server: Server, data: dict) -> None:
543556
return
544557

545558
shot_time, s_event = self.pending_kill.get(target.ucid, (-1, None))
559+
now = int(time.time())
546560
if data['eventName'] == 'S_EVENT_SHOT':
547561
# if there is an older shot ...
548562
if shot_time > 0 and s_event:
549-
delta_time = int(time.time()) - shot_time
563+
delta_time = now - shot_time
550564
if s_event.get('eventName') == 'S_EVENT_HIT':
551565
# we do not overwrite hit events with shot events if they are still hot
552566
if delta_time < config.get('survival_window', 300):
@@ -576,7 +590,7 @@ async def onMissionEvent(self, server: Server, data: dict) -> None:
576590
f"where player {orig_name} was predicted.")
577591

578592
# store the shot with the highest PBK or the latest hit event
579-
self.pending_kill[target.ucid] = (int(time.time()), data)
593+
self.pending_kill[target.ucid] = (now, data)
580594

581595
elif data['eventName'] == 'S_EVENT_LAND':
582596
initiator = server.get_player(name=data.get('initiator', {}).get('name'))

plugins/scheduler/commands.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,10 +1229,12 @@ async def config(self, interaction: discord.Interaction,
12291229
ephemeral = utils.get_ephemeral(interaction)
12301230
await interaction.response.defer(ephemeral=ephemeral)
12311231

1232+
stopped = False
12321233
if server.status in [Status.RUNNING, Status.PAUSED]:
12331234
if await utils.yn_question(interaction, question='Server has to be stopped to change its configuration.\n'
12341235
'Do you want to stop it?'):
12351236
await server.stop()
1237+
stopped = True
12361238
else:
12371239
await interaction.followup.send('Aborted.')
12381240
return
@@ -1252,6 +1254,12 @@ async def config(self, interaction: discord.Interaction,
12521254
await server.update_channels(channels)
12531255
await interaction.followup.send(f'Server configuration for server "{server.display_name}" updated.',
12541256
ephemeral=ephemeral)
1257+
if stopped:
1258+
await server.start()
1259+
except (asyncio.TimeoutError, TimeoutError):
1260+
await interaction.followup.send(f'Timeout while starting server "{server.display_name}".\n'
1261+
f'Please check the server manually.',
1262+
ephemeral=ephemeral)
12551263
finally:
12561264
try:
12571265
await msg.delete()

0 commit comments

Comments
 (0)