From 970110bc96e1ec9e29234e4de059cc8d44d95c81 Mon Sep 17 00:00:00 2001 From: Elliott Jackson Date: Sun, 31 Jul 2022 21:16:40 -0700 Subject: [PATCH 01/19] dlvax commit --- .gitignore | 5 + helpers/config.py | 48 +++++- nparse.py | 6 +- parsers/__init__.py | 1 + parsers/deathloopvaccine.py | 286 ++++++++++++++++++++++++++++++++++++ requirements.txt | 3 +- 6 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 parsers/deathloopvaccine.py diff --git a/.gitignore b/.gitignore index 2ce535a..58323fa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .idea/ .vscode/ +working/ +act.bat +deact.bat # Byte-compiled / optimized / DLL files __pycache__/ @@ -15,6 +18,7 @@ __pycache__/ # Distribution / packaging .Python +.venv/ env/ venv/ bin/ @@ -65,3 +69,4 @@ docs/_build/ wiki/ old/ nparse.config.json + diff --git a/helpers/config.py b/helpers/config.py index 2b539b1..b8c7817 100644 --- a/helpers/config.py +++ b/helpers/config.py @@ -287,7 +287,53 @@ def verify_settings(): False ) - + # deathloopvaccine + # note - many of these settings are completely unused, and are only here to satisfy the underlying + # assumption that they are to be manipulated on startup/shutdown + data['deathloopvaccine'] = data.get('deathloopvaccine', {}) + data['deathloopvaccine']['toggled'] = get_setting( + data['deathloopvaccine'].get('toggled', True), + True + ) + data['deathloopvaccine']['geometry'] = get_setting( + data['deathloopvaccine'].get('geometry', [0, 400, 200, 400]), + [0, 400, 200, 400], + lambda x: ( + len(x) == 4 and + isinstance(x[0], int) and + isinstance(x[1], int) and + isinstance(x[2], int) and + isinstance(x[3], int) + ) + ) + data['deathloopvaccine']['url'] = get_setting( + data['deathloopvaccine'].get('url', ''), + '' + ) + data['deathloopvaccine']['opacity'] = get_setting( + data['deathloopvaccine'].get('opacity', 0), + 0, + lambda x: (0 <= x <= 100) + ) + data['deathloopvaccine']['bg_opacity'] = get_setting( + data['deathloopvaccine'].get('bg_opacity', 0), + 0, + lambda x: (0 <= x <= 100) + ) + data['deathloopvaccine']['color'] = data['deathloopvaccine'].get('color', '#000000') + data['deathloopvaccine']['clickthrough'] = get_setting( + data['deathloopvaccine'].get('clickthrough', True), + True + ) + data['deathloopvaccine']['deaths'] = get_setting( + data['deathloopvaccine'].get('deaths', 4), + 4 + ) + data['deathloopvaccine']['seconds'] = get_setting( + data['deathloopvaccine'].get('seconds', 120), + 120 + ) + def get_setting(setting, default, func=None): try: assert(type(setting) == type(default)) diff --git a/nparse.py b/nparse.py index 2d17860..8a4f0d8 100644 --- a/nparse.py +++ b/nparse.py @@ -19,8 +19,8 @@ os.environ['QT_SCALE_FACTOR'] = str( config.data['general']['qt_scale_factor'] / 100) - -CURRENT_VERSION = '0.6.4' +# todo - set this to appropriate value +CURRENT_VERSION = '0.6.4-DLV' if config.data['general']['update_check']: ONLINE_VERSION = get_version() else: @@ -67,11 +67,13 @@ def _load_parsers(self): "maps": parsers.Maps(), "spells": parsers.Spells(), "discord": parsers.Discord(), + "deathloopvaccine" : parsers.DeathLoopVaccine(), } self._parsers = [ self._parsers_dict["maps"], self._parsers_dict["spells"], self._parsers_dict["discord"], + self._parsers_dict["deathloopvaccine"], ] for parser in self._parsers: if parser.name in config.data.keys() and 'geometry' in config.data[parser.name].keys(): diff --git a/parsers/__init__.py b/parsers/__init__.py index 427a74c..5a7b78e 100644 --- a/parsers/__init__.py +++ b/parsers/__init__.py @@ -2,3 +2,4 @@ from .maps import Maps # noqa: F401 from .spells import Spells # noqa: F401 from .discord import Discord # noqa: F401 +from .deathloopvaccine import DeathLoopVaccine # noqa: F401 diff --git a/parsers/deathloopvaccine.py b/parsers/deathloopvaccine.py new file mode 100644 index 0000000..5395f0d --- /dev/null +++ b/parsers/deathloopvaccine.py @@ -0,0 +1,286 @@ +import datetime +import re +import os +import signal + +import psutil + +from datetime import datetime +from helpers import ParserWindow, config + + +# +# simple utility to prevent Everquest Death Loop +# +# The utility functions by parsing the current (most recent) Everquest log file, and if it detects +# Death Loop symptoms, it will respond by initiating a system process kill of all "eqgame.exe" +# processes (there should usually only be one). +# +# We will define a death loop as any time a player experiences X deaths in Y seconds, and no player +# activity during that time. The values for X and Y are configurable, via the DeathLoopVaccine.ini file. +# +# For testing purposes, there is a back door feature, controlled by sending a tell to the following +# non-existent player: +# +# death_loop: Simulates a player death. +# +# Note however that this also sets a flag that disarms the conceptual +# "process-killer gun", which will allow every bit of the code to +# execute and be tested, but will stop short of actually killing any +# process +# +# The "process-killer gun" will then be armed again after the simulated +# player deaths trigger the simulated process kill, or after any simulated +# player death events "scroll off" the death loop monitoring window. +# +class DeathLoopVaccine(ParserWindow): + + """Tracks for DL symptoms""" + + def __init__(self): + super().__init__() + self.name = 'deathloopvaccine' + + # parameters that define a deathloop condition, i.e. D deaths in T seconds, + # with no player activity in the interim + # todo - make these configarable via the UI? + self.deathloop_deaths = config.data['deathloopvaccine']['deaths'] + self.deathloop_seconds = config.data['deathloopvaccine']['seconds'] + + # list of death messages + # this will function as a scrolling queue, with the oldest message at position 0, + # newest appended to the other end. Older messages scroll off the list when more + # than deathloop_seconds have elapsed. The list is also flushed any time + # player activity is detected (i.e. player is not AFK). + # + # if/when the length of this list meets or exceeds deathloop_deaths, then + # the deathloop response is triggered + self._death_list = list() + + # flag indicating whether the "process killer" gun is armed + self._kill_armed = True + + def reset(self) -> None: + """ + Utility function to clear the death_list and reset the armed flag + + Returns: + None: + """ + self._death_list.clear() + self._kill_armed = True + + # main parsing logic here + def parse(self, timestamp: datetime, text: str) -> None: + """ + Parse a single line from the logfile + + Args: + timestamp: A datetime.datetime object, created from the timestamp text of the raw logfile line + text: The text following the everquest timestamp + + Returns: + None: + """ + + self.check_for_death(timestamp, text) + self.check_not_afk(timestamp, text) + self.deathloop_response() + + def check_for_death(self, timestamp: datetime, text: str) -> None: + """ + check for indications the player just died, and if we find it, + save the message for later processing + + Args: + timestamp: A datetime.datetime object, created from the timestamp text of the raw logfile line + text: The text following the everquest timestamp + + Returns: + None: + """ + + # reconstruct the full logfile line + # this is a bit counter-intuitive, but the rest of the logic in this function was + # developed assuming the line was the full line, including the time stamp + line = f'[{timestamp.strftime("%a %b %d %H:%M:%S %Y")}] ' + text + + # cut off the leading date-time stamp info + trunc_line = line[27:] + + # does this line contain a death message + slain_regexp = r'^You have been slain' + m = re.match(slain_regexp, trunc_line) + if m: + # add this message to the list of death messages + self._death_list.append(line) + starprint(f'DeathLoopVaccine: Death count = {len(self._death_list)}') + + # a way to test - send a tell to death_loop + slain_regexp = r'^death_loop' + m = re.match(slain_regexp, trunc_line) + if m: + # add this message to the list of death messages + # since this is just for testing, disarm the kill-gun + self._death_list.append(line) + starprint(f'DeathLoopVaccine: Death count = {len(self._death_list)}') + self._kill_armed = False + + # only do the list-purging if there are already some death messages in the list, else skip this + if len(self._death_list) > 0: + + # create a datetime object for this line, using the very capable datetime.strptime() + now = datetime.strptime(line[0:26], '[%a %b %d %H:%M:%S %Y]') + + # now purge any death messages that are too old + done = False + while not done: + # if the list is empty, we're done + if len(self._death_list) == 0: + self.reset() + done = True + # if the list is not empty, check if we need to purge some old entries + else: + oldest_line = self._death_list[0] + oldest_time = datetime.strptime(oldest_line[0:26], '[%a %b %d %H:%M:%S %Y]') + elapsed_seconds = now - oldest_time + + if elapsed_seconds.total_seconds() > self.deathloop_seconds: + # that death message is too old, purge it + self._death_list.pop(0) + starprint(f'DeathLoopVaccine: Death count = {len(self._death_list)}') + else: + # the oldest death message is inside the window, so we're done purging + done = True + + def check_not_afk(self, timestamp: datetime, text: str) -> None: + """ + check for "proof of life" indications the player is really not AFK + + Args: + timestamp: A datetime.datetime object, created from the timestamp text of the raw logfile line + text: The text following the everquest timestamp + + Returns: + None: + """ + + # reconstruct the full logfile line + # this is a bit counter-intuitive, but the rest of the logic in this function was + # developed assuming the line was the full line, including the time stamp + line = f'[{timestamp.strftime("%a %b %d %H:%M:%S %Y")}] ' + text + + # only do the proof of life checks if there are already some death messages in the list, else skip this + if len(self._death_list) > 0: + + # check for proof of life, things that indicate the player is not actually AFK + # begin by assuming the player is AFK + afk = True + + # cut off the leading date-time stamp info + trunc_line = line[27:] + + # does this line contain a proof of life - casting + regexp = r'^You begin casting' + m = re.match(regexp, trunc_line) + if m: + # player is not AFK + afk = False + starprint(f'DeathLoopVaccine: Player Not AFK: {line}') + + # does this line contain a proof of life - communication + # this captures tells, say, group, auction, and shout channels + # todo - get character name for use in this regexp + charname = 'Unknown' + regexp = f'^(You told|You say|You tell|You auction|You shout|{charname} ->)' + m = re.match(regexp, trunc_line) + if m: + # player is not AFK + afk = False + starprint(f'DeathLoopVaccine: Player Not AFK: {line}') + + # does this line contain a proof of life - melee + regexp = r'^You( try to)? (hit|slash|pierce|crush|claw|bite|sting|maul|gore|punch|kick|backstab|bash)' + m = re.match(regexp, trunc_line) + if m: + # player is not AFK + afk = False + starprint(f'DeathLoopVaccine: Player Not AFK: {line}') + + # if they are not AFK, then go ahead and purge any death messages from the list + if not afk: + self.reset() + + def deathloop_response(self) -> None: + """ + are we death looping? if so, kill the process + + Returns: + None: + """ + + # if the death_list contains more deaths than the limit, then trigger the process kill + if len(self._death_list) >= self.deathloop_deaths: + + starprint('---------------------------------------------------') + starprint('DeathLoopVaccine - Killing all eqgame.exe processes') + starprint('---------------------------------------------------') + starprint('DeathLoopVaccine has detected deathloop symptoms:') + starprint(f' {self.deathloop_deaths} deaths in less than {self.deathloop_seconds} seconds, with no player activity') + + # show all the death messages + starprint('Death Messages:') + for line in self._death_list: + starprint(' ' + line) + + # get the list of eqgame.exe process ID's, and show them + pid_list = get_eqgame_pid_list() + starprint(f'eqgame.exe process id list = {pid_list}') + + # kill the eqgame.exe process / processes + for pid in pid_list: + starprint(f'Killing process [{pid}]') + + # for testing the actual kill process using simulated player deaths, uncomment the following line + # self._kill_armed = True + if self._kill_armed: + os.kill(pid, signal.SIGTERM) + else: + starprint('(Note: Process Kill only simulated, since death(s) were simulated)') + + # purge any death messages from the list + self.reset() + + +################################################################################################# +# +# standalone functions +# + + +def get_eqgame_pid_list() -> list[int]: + """ + get list of process ID's for eqgame.exe, using psutil module + + Returns: + object: list of process ID's (in case multiple versions of eqgame.exe are somehow running) + """ + + pid_list = list() + for p in psutil.process_iter(['name']): + if p.info['name'] == 'eqgame.exe': + pid_list.append(p.pid) + return pid_list + + +def starprint(line: str) -> None: + """ + utility function to print with leading and trailing ** indicators + + Args: + line: line to be printed + + Returns: + None: + """ + print(f'** {line.rstrip():<100} **') diff --git a/requirements.txt b/requirements.txt index 96ba78b..ae5856e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ websocket-client==0.58.0 requests colorhash pathvalidate -PyQtWebEngine \ No newline at end of file +PyQtWebEngine +psutil \ No newline at end of file From 807cb50d819b06506aa0c28518f578a189559c55 Mon Sep 17 00:00:00 2001 From: Elliott Jackson Date: Sun, 31 Jul 2022 21:54:54 -0700 Subject: [PATCH 02/19] small edits --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ae5856e..83ba159 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,4 @@ requests colorhash pathvalidate PyQtWebEngine -psutil \ No newline at end of file +psutil From 77ecb8fd1dc383320ff8ef6ecbc780076bb43319 Mon Sep 17 00:00:00 2001 From: Elliott Jackson Date: Mon, 1 Aug 2022 11:59:45 -0700 Subject: [PATCH 03/19] cleaned up Parser inheritance --- .gitignore | 2 -- helpers/__init__.py | 1 + helpers/config.py | 36 +++--------------------- helpers/logreader.py | 2 ++ helpers/parser.py | 56 ++++++++++++++++++++++++++++++------- nparse.py | 12 ++++---- parsers/deathloopvaccine.py | 11 ++++---- 7 files changed, 66 insertions(+), 54 deletions(-) diff --git a/.gitignore b/.gitignore index 58323fa..2be6b01 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,6 @@ .idea/ .vscode/ working/ -act.bat -deact.bat # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/helpers/__init__.py b/helpers/__init__.py index a0b67ca..6ac76eb 100644 --- a/helpers/__init__.py +++ b/helpers/__init__.py @@ -5,6 +5,7 @@ import json from datetime import datetime, timedelta +from .parser import Parser # noqa: F401 from .parser import ParserWindow # noqa: F401 diff --git a/helpers/config.py b/helpers/config.py index b8c7817..00335b7 100644 --- a/helpers/config.py +++ b/helpers/config.py @@ -49,6 +49,10 @@ def verify_settings(): data['general'].get('eq_log_dir', ''), '' ) + data['general']['char_name'] = get_setting( + data['general'].get('char_name', ''), + '' + ) data['general']['window_flush'] = get_setting( data['general'].get('window_flush', True), True @@ -288,43 +292,11 @@ def verify_settings(): ) # deathloopvaccine - # note - many of these settings are completely unused, and are only here to satisfy the underlying - # assumption that they are to be manipulated on startup/shutdown data['deathloopvaccine'] = data.get('deathloopvaccine', {}) data['deathloopvaccine']['toggled'] = get_setting( data['deathloopvaccine'].get('toggled', True), True ) - data['deathloopvaccine']['geometry'] = get_setting( - data['deathloopvaccine'].get('geometry', [0, 400, 200, 400]), - [0, 400, 200, 400], - lambda x: ( - len(x) == 4 and - isinstance(x[0], int) and - isinstance(x[1], int) and - isinstance(x[2], int) and - isinstance(x[3], int) - ) - ) - data['deathloopvaccine']['url'] = get_setting( - data['deathloopvaccine'].get('url', ''), - '' - ) - data['deathloopvaccine']['opacity'] = get_setting( - data['deathloopvaccine'].get('opacity', 0), - 0, - lambda x: (0 <= x <= 100) - ) - data['deathloopvaccine']['bg_opacity'] = get_setting( - data['deathloopvaccine'].get('bg_opacity', 0), - 0, - lambda x: (0 <= x <= 100) - ) - data['deathloopvaccine']['color'] = data['deathloopvaccine'].get('color', '#000000') - data['deathloopvaccine']['clickthrough'] = get_setting( - data['deathloopvaccine'].get('clickthrough', True), - True - ) data['deathloopvaccine']['deaths'] = get_setting( data['deathloopvaccine'].get('deaths', 4), 4 diff --git a/helpers/logreader.py b/helpers/logreader.py index a9ed677..9f43556 100644 --- a/helpers/logreader.py +++ b/helpers/logreader.py @@ -46,8 +46,10 @@ def _file_changed(self, changed_file): if changed_file != self._stats['log_file']: self._stats['log_file'] = changed_file char_name = os.path.basename(changed_file).split("_")[1] + config.data['general']['char_name'] = char_name if not config.data['sharing']['player_name_override']: config.data['sharing']['player_name'] = char_name + config.save() location_service.SIGNALS.config_updated.emit() with open(self._stats['log_file'], 'rb') as log: log.seek(0, os.SEEK_END) diff --git a/helpers/parser.py b/helpers/parser.py index 30cf48d..49f6158 100644 --- a/helpers/parser.py +++ b/helpers/parser.py @@ -3,9 +3,54 @@ QPushButton, QVBoxLayout, QWidget) from helpers import config +from datetime import datetime -class ParserWindow(QFrame): +class Parser(): + + def __init__(self): + super().__init__() + self.name = 'Parser' + self._visible = False + + def isVisible(self) -> bool: + return self._visible + + def hide(self): + self._visible = False + + def show(self): + self._visible = True + + # main parsing logic here - derived classed should override this to perform their particular parsing tasks + def parse(self, timestamp: datetime, text: str) -> None: + + # default behavior = simply print passed info + # this strftime mask will recreate the EQ log file timestamp format + line = f'[{timestamp.strftime("%a %b %d %H:%M:%S %Y")}] ' + text + print(f'[{self.name}]:{line}') + + def toggle(self, _=None): + if self.isVisible(): + self.hide() + config.data[self.name]['toggled'] = False + else: + self.set_flags() + self.show() + config.data[self.name]['toggled'] = True + config.save() + + def shutdown(self): + pass + + def set_flags(self): + pass + + def settings_updated(self): + pass + + +class ParserWindow(QFrame, Parser): def __init__(self): super().__init__() @@ -108,15 +153,6 @@ def _toggle_frame(self): def set_title(self, title): self._title.setText(title) - def toggle(self, _=None): - if self.isVisible(): - self.hide() - config.data[self.name]['toggled'] = False - else: - self.set_flags() - self.show() - config.data[self.name]['toggled'] = True - config.save() def closeEvent(self, _): config.data[self.name]['toggled'] = False diff --git a/nparse.py b/nparse.py index 8a4f0d8..ccc2938 100644 --- a/nparse.py +++ b/nparse.py @@ -91,7 +91,8 @@ def _toggle(self): error.args[0], error.args[1], msecs=3000) else: - self._log_reader = logreader.LogReader( + self._log_reader = \ + logreader.LogReader( config.data['general']['eq_log_dir']) self._log_reader.new_line.connect(self._parse) self._toggled = True @@ -186,10 +187,11 @@ def _menu(self, event): # save parser geometry for parser in self._parsers: - g = parser.geometry() - config.data[parser.name]['geometry'] = [ - g.x(), g.y(), g.width(), g.height() - ] + if parser.name in config.data.keys() and 'geometry' in config.data[parser.name].keys(): + g = parser.geometry() + config.data[parser.name]['geometry'] = [ + g.x(), g.y(), g.width(), g.height() + ] config.save() self._system_tray.setVisible(False) diff --git a/parsers/deathloopvaccine.py b/parsers/deathloopvaccine.py index 5395f0d..a2773c9 100644 --- a/parsers/deathloopvaccine.py +++ b/parsers/deathloopvaccine.py @@ -6,7 +6,7 @@ import psutil from datetime import datetime -from helpers import ParserWindow, config +from helpers import Parser, config # @@ -33,11 +33,12 @@ # player deaths trigger the simulated process kill, or after any simulated # player death events "scroll off" the death loop monitoring window. # -class DeathLoopVaccine(ParserWindow): +class DeathLoopVaccine(Parser): """Tracks for DL symptoms""" def __init__(self): + super().__init__() self.name = 'deathloopvaccine' @@ -60,6 +61,7 @@ def __init__(self): # flag indicating whether the "process killer" gun is armed self._kill_armed = True + def reset(self) -> None: """ Utility function to clear the death_list and reset the armed flag @@ -190,9 +192,8 @@ def check_not_afk(self, timestamp: datetime, text: str) -> None: # does this line contain a proof of life - communication # this captures tells, say, group, auction, and shout channels - # todo - get character name for use in this regexp - charname = 'Unknown' - regexp = f'^(You told|You say|You tell|You auction|You shout|{charname} ->)' + char_name = config.data['general']['char_name'] + regexp = f'^(You told|You say|You tell|You auction|You shout|{char_name} ->)' m = re.match(regexp, trunc_line) if m: # player is not AFK From 223b14eedf4e3e1cd08b93422a93c2127a7abf66 Mon Sep 17 00:00:00 2001 From: Elliott Jackson Date: Mon, 1 Aug 2022 12:04:56 -0700 Subject: [PATCH 04/19] small edit --- helpers/logreader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/logreader.py b/helpers/logreader.py index 9f43556..7423d86 100644 --- a/helpers/logreader.py +++ b/helpers/logreader.py @@ -47,9 +47,9 @@ def _file_changed(self, changed_file): self._stats['log_file'] = changed_file char_name = os.path.basename(changed_file).split("_")[1] config.data['general']['char_name'] = char_name + config.save() if not config.data['sharing']['player_name_override']: config.data['sharing']['player_name'] = char_name - config.save() location_service.SIGNALS.config_updated.emit() with open(self._stats['log_file'], 'rb') as log: log.seek(0, os.SEEK_END) From fa4fa89b91e87334a3d98e2bcf0dfeab8f37dcb0 Mon Sep 17 00:00:00 2001 From: Elliott Jackson Date: Mon, 1 Aug 2022 12:13:26 -0700 Subject: [PATCH 05/19] editorial --- nparse.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nparse.py b/nparse.py index ccc2938..c619aa1 100644 --- a/nparse.py +++ b/nparse.py @@ -91,8 +91,7 @@ def _toggle(self): error.args[0], error.args[1], msecs=3000) else: - self._log_reader = \ - logreader.LogReader( + self._log_reader = logreader.LogReader( config.data['general']['eq_log_dir']) self._log_reader.new_line.connect(self._parse) self._toggled = True From e84d05a3514a8bb264f1ca33d17243cc36247d92 Mon Sep 17 00:00:00 2001 From: Elliott Jackson Date: Mon, 1 Aug 2022 12:24:34 -0700 Subject: [PATCH 06/19] addressing some PEP warnings --- helpers/config.py | 1 + helpers/parser.py | 11 +++++------ nparse.py | 2 +- parsers/deathloopvaccine.py | 1 - 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/helpers/config.py b/helpers/config.py index 00335b7..9ed17d1 100644 --- a/helpers/config.py +++ b/helpers/config.py @@ -306,6 +306,7 @@ def verify_settings(): 120 ) + def get_setting(setting, default, func=None): try: assert(type(setting) == type(default)) diff --git a/helpers/parser.py b/helpers/parser.py index 49f6158..a24d632 100644 --- a/helpers/parser.py +++ b/helpers/parser.py @@ -6,7 +6,7 @@ from datetime import datetime -class Parser(): +class Parser: def __init__(self): super().__init__() @@ -30,7 +30,7 @@ def parse(self, timestamp: datetime, text: str) -> None: line = f'[{timestamp.strftime("%a %b %d %H:%M:%S %Y")}] ' + text print(f'[{self.name}]:{line}') - def toggle(self, _=None): + def toggle(self, _=None) -> None: if self.isVisible(): self.hide() config.data[self.name]['toggled'] = False @@ -40,13 +40,13 @@ def toggle(self, _=None): config.data[self.name]['toggled'] = True config.save() - def shutdown(self): + def shutdown(self) -> None: pass - def set_flags(self): + def set_flags(self) -> None: pass - def settings_updated(self): + def settings_updated(self) -> None: pass @@ -153,7 +153,6 @@ def _toggle_frame(self): def set_title(self, title): self._title.setText(title) - def closeEvent(self, _): config.data[self.name]['toggled'] = False config.save() diff --git a/nparse.py b/nparse.py index c619aa1..c71508c 100644 --- a/nparse.py +++ b/nparse.py @@ -67,7 +67,7 @@ def _load_parsers(self): "maps": parsers.Maps(), "spells": parsers.Spells(), "discord": parsers.Discord(), - "deathloopvaccine" : parsers.DeathLoopVaccine(), + "deathloopvaccine": parsers.DeathLoopVaccine(), } self._parsers = [ self._parsers_dict["maps"], diff --git a/parsers/deathloopvaccine.py b/parsers/deathloopvaccine.py index a2773c9..5242995 100644 --- a/parsers/deathloopvaccine.py +++ b/parsers/deathloopvaccine.py @@ -61,7 +61,6 @@ def __init__(self): # flag indicating whether the "process killer" gun is armed self._kill_armed = True - def reset(self) -> None: """ Utility function to clear the death_list and reset the armed flag From 7d319335bce5f081031f66bf28e4f66369468f10 Mon Sep 17 00:00:00 2001 From: Elliott Jackson Date: Mon, 1 Aug 2022 17:53:24 -0700 Subject: [PATCH 07/19] PR comments. 1. char_name moved out of .json and into a global variable. 2. standalone functions move into helpers --- helpers/__init__.py | 31 +++++++++++++++++++++++++++++ helpers/config.py | 5 +---- helpers/logreader.py | 3 +-- parsers/deathloopvaccine.py | 39 ++----------------------------------- 4 files changed, 35 insertions(+), 43 deletions(-) diff --git a/helpers/__init__.py b/helpers/__init__.py index 6ac76eb..36a94f8 100644 --- a/helpers/__init__.py +++ b/helpers/__init__.py @@ -3,6 +3,9 @@ import math import requests import json + +import psutil + from datetime import datetime, timedelta from .parser import Parser # noqa: F401 @@ -101,3 +104,31 @@ def text_time_to_seconds(text_time): pass return timedelta(hours=hours, minutes=minutes, seconds=seconds).total_seconds() + + +def get_eqgame_pid_list() -> list[int]: + """ + get list of process ID's for eqgame.exe, using psutil module + + Returns: + object: list of process ID's (in case multiple versions of eqgame.exe are somehow running) + """ + + pid_list = list() + for p in psutil.process_iter(['name']): + if p.info['name'] == 'eqgame.exe': + pid_list.append(p.pid) + return pid_list + + +def starprint(line: str) -> None: + """ + utility function to print with leading and trailing ** indicators + + Args: + line: line to be printed + + Returns: + None: + """ + print(f'** {line.rstrip():<100} **') diff --git a/helpers/config.py b/helpers/config.py index 9ed17d1..d067f72 100644 --- a/helpers/config.py +++ b/helpers/config.py @@ -7,6 +7,7 @@ data = {} _filename = '' +_char_name = '' def load(filename): @@ -49,10 +50,6 @@ def verify_settings(): data['general'].get('eq_log_dir', ''), '' ) - data['general']['char_name'] = get_setting( - data['general'].get('char_name', ''), - '' - ) data['general']['window_flush'] = get_setting( data['general'].get('window_flush', True), True diff --git a/helpers/logreader.py b/helpers/logreader.py index 7423d86..1c4d969 100644 --- a/helpers/logreader.py +++ b/helpers/logreader.py @@ -46,8 +46,7 @@ def _file_changed(self, changed_file): if changed_file != self._stats['log_file']: self._stats['log_file'] = changed_file char_name = os.path.basename(changed_file).split("_")[1] - config.data['general']['char_name'] = char_name - config.save() + config._char_name = char_name if not config.data['sharing']['player_name_override']: config.data['sharing']['player_name'] = char_name location_service.SIGNALS.config_updated.emit() diff --git a/parsers/deathloopvaccine.py b/parsers/deathloopvaccine.py index 5242995..700f4c9 100644 --- a/parsers/deathloopvaccine.py +++ b/parsers/deathloopvaccine.py @@ -3,11 +3,9 @@ import os import signal -import psutil from datetime import datetime -from helpers import Parser, config - +from helpers import Parser, config, get_eqgame_pid_list, starprint # # simple utility to prevent Everquest Death Loop @@ -191,7 +189,7 @@ def check_not_afk(self, timestamp: datetime, text: str) -> None: # does this line contain a proof of life - communication # this captures tells, say, group, auction, and shout channels - char_name = config.data['general']['char_name'] + char_name = config._char_name regexp = f'^(You told|You say|You tell|You auction|You shout|{char_name} ->)' m = re.match(regexp, trunc_line) if m: @@ -251,36 +249,3 @@ def deathloop_response(self) -> None: # purge any death messages from the list self.reset() - -################################################################################################# -# -# standalone functions -# - - -def get_eqgame_pid_list() -> list[int]: - """ - get list of process ID's for eqgame.exe, using psutil module - - Returns: - object: list of process ID's (in case multiple versions of eqgame.exe are somehow running) - """ - - pid_list = list() - for p in psutil.process_iter(['name']): - if p.info['name'] == 'eqgame.exe': - pid_list.append(p.pid) - return pid_list - - -def starprint(line: str) -> None: - """ - utility function to print with leading and trailing ** indicators - - Args: - line: line to be printed - - Returns: - None: - """ - print(f'** {line.rstrip():<100} **') From 25490a1fe496d10dde39f4cbadecfe2032a8286c Mon Sep 17 00:00:00 2001 From: Elliott Jackson Date: Tue, 2 Aug 2022 16:24:57 -0700 Subject: [PATCH 08/19] PR comments: 1. deathloop.deaths and deathloop.seconds are initialized at every use, not just at instantiation 2. cleaned up processing of (timestamp, text) parameters in parsing functions 3. reverted version number to 0.6.4 4. cleaned up some additional empty functions in ParserWindow 5. cleaned up global.char_name usage --- helpers/config.py | 3 ++- helpers/logreader.py | 5 ++--- helpers/parser.py | 6 ------ nparse.py | 3 +-- parsers/deathloopvaccine.py | 38 ++++++++++++++----------------------- 5 files changed, 19 insertions(+), 36 deletions(-) diff --git a/helpers/config.py b/helpers/config.py index d067f72..e885d1e 100644 --- a/helpers/config.py +++ b/helpers/config.py @@ -5,9 +5,10 @@ from glob import glob import json +# global data data = {} _filename = '' -_char_name = '' +char_name = '' def load(filename): diff --git a/helpers/logreader.py b/helpers/logreader.py index 1c4d969..d1b84e4 100644 --- a/helpers/logreader.py +++ b/helpers/logreader.py @@ -45,10 +45,9 @@ def _file_changed_safe_wrap(self, changed_file): def _file_changed(self, changed_file): if changed_file != self._stats['log_file']: self._stats['log_file'] = changed_file - char_name = os.path.basename(changed_file).split("_")[1] - config._char_name = char_name + config.char_name = os.path.basename(changed_file).split("_")[1] if not config.data['sharing']['player_name_override']: - config.data['sharing']['player_name'] = char_name + config.data['sharing']['player_name'] = config.char_name location_service.SIGNALS.config_updated.emit() with open(self._stats['log_file'], 'rb') as log: log.seek(0, os.SEEK_END) diff --git a/helpers/parser.py b/helpers/parser.py index a24d632..1709c7b 100644 --- a/helpers/parser.py +++ b/helpers/parser.py @@ -164,9 +164,3 @@ def enterEvent(self, event): def leaveEvent(self, event): self._menu.setVisible(False) QFrame.leaveEvent(self, event) - - def shutdown(self): - pass - - def settings_updated(self): - pass diff --git a/nparse.py b/nparse.py index c71508c..5aba849 100644 --- a/nparse.py +++ b/nparse.py @@ -19,8 +19,7 @@ os.environ['QT_SCALE_FACTOR'] = str( config.data['general']['qt_scale_factor'] / 100) -# todo - set this to appropriate value -CURRENT_VERSION = '0.6.4-DLV' +CURRENT_VERSION = '0.6.4' if config.data['general']['update_check']: ONLINE_VERSION = get_version() else: diff --git a/parsers/deathloopvaccine.py b/parsers/deathloopvaccine.py index 700f4c9..b3aa1f1 100644 --- a/parsers/deathloopvaccine.py +++ b/parsers/deathloopvaccine.py @@ -7,6 +7,7 @@ from datetime import datetime from helpers import Parser, config, get_eqgame_pid_list, starprint + # # simple utility to prevent Everquest Death Loop # @@ -42,9 +43,7 @@ def __init__(self): # parameters that define a deathloop condition, i.e. D deaths in T seconds, # with no player activity in the interim - # todo - make these configarable via the UI? - self.deathloop_deaths = config.data['deathloopvaccine']['deaths'] - self.deathloop_seconds = config.data['deathloopvaccine']['seconds'] + # todo - make the deathloop.deaths and deathloop.seconds values configarable via the UI? # list of death messages # this will function as a scrolling queue, with the oldest message at position 0, @@ -52,7 +51,7 @@ def __init__(self): # than deathloop_seconds have elapsed. The list is also flushed any time # player activity is detected (i.e. player is not AFK). # - # if/when the length of this list meets or exceeds deathloop_deaths, then + # if/when the length of this list meets or exceeds deathloop.deaths, then # the deathloop response is triggered self._death_list = list() @@ -99,14 +98,9 @@ def check_for_death(self, timestamp: datetime, text: str) -> None: None: """ - # reconstruct the full logfile line - # this is a bit counter-intuitive, but the rest of the logic in this function was - # developed assuming the line was the full line, including the time stamp + trunc_line = text line = f'[{timestamp.strftime("%a %b %d %H:%M:%S %Y")}] ' + text - # cut off the leading date-time stamp info - trunc_line = line[27:] - # does this line contain a death message slain_regexp = r'^You have been slain' m = re.match(slain_regexp, trunc_line) @@ -129,7 +123,7 @@ def check_for_death(self, timestamp: datetime, text: str) -> None: if len(self._death_list) > 0: # create a datetime object for this line, using the very capable datetime.strptime() - now = datetime.strptime(line[0:26], '[%a %b %d %H:%M:%S %Y]') + now = timestamp # now purge any death messages that are too old done = False @@ -144,7 +138,7 @@ def check_for_death(self, timestamp: datetime, text: str) -> None: oldest_time = datetime.strptime(oldest_line[0:26], '[%a %b %d %H:%M:%S %Y]') elapsed_seconds = now - oldest_time - if elapsed_seconds.total_seconds() > self.deathloop_seconds: + if elapsed_seconds.total_seconds() > config.data['deathloopvaccine']['seconds']: # that death message is too old, purge it self._death_list.pop(0) starprint(f'DeathLoopVaccine: Death count = {len(self._death_list)}') @@ -164,11 +158,6 @@ def check_not_afk(self, timestamp: datetime, text: str) -> None: None: """ - # reconstruct the full logfile line - # this is a bit counter-intuitive, but the rest of the logic in this function was - # developed assuming the line was the full line, including the time stamp - line = f'[{timestamp.strftime("%a %b %d %H:%M:%S %Y")}] ' + text - # only do the proof of life checks if there are already some death messages in the list, else skip this if len(self._death_list) > 0: @@ -176,8 +165,8 @@ def check_not_afk(self, timestamp: datetime, text: str) -> None: # begin by assuming the player is AFK afk = True - # cut off the leading date-time stamp info - trunc_line = line[27:] + trunc_line = text + line = f'[{timestamp.strftime("%a %b %d %H:%M:%S %Y")}] ' + text # does this line contain a proof of life - casting regexp = r'^You begin casting' @@ -189,8 +178,7 @@ def check_not_afk(self, timestamp: datetime, text: str) -> None: # does this line contain a proof of life - communication # this captures tells, say, group, auction, and shout channels - char_name = config._char_name - regexp = f'^(You told|You say|You tell|You auction|You shout|{char_name} ->)' + regexp = f'^(You told|You say|You tell|You auction|You shout|{config.char_name} ->)' m = re.match(regexp, trunc_line) if m: # player is not AFK @@ -217,14 +205,17 @@ def deathloop_response(self) -> None: None: """ + deaths = config.data['deathloopvaccine']['deaths'] + seconds = config.data['deathloopvaccine']['seconds'] + # if the death_list contains more deaths than the limit, then trigger the process kill - if len(self._death_list) >= self.deathloop_deaths: + if len(self._death_list) >= deaths: starprint('---------------------------------------------------') starprint('DeathLoopVaccine - Killing all eqgame.exe processes') starprint('---------------------------------------------------') starprint('DeathLoopVaccine has detected deathloop symptoms:') - starprint(f' {self.deathloop_deaths} deaths in less than {self.deathloop_seconds} seconds, with no player activity') + starprint(f' {deaths} deaths in less than {seconds} seconds, with no player activity') # show all the death messages starprint('Death Messages:') @@ -248,4 +239,3 @@ def deathloop_response(self) -> None: # purge any death messages from the list self.reset() - From 82587289fa2bac586d19272baece0440c4252181 Mon Sep 17 00:00:00 2001 From: Elliott Jackson Date: Wed, 3 Aug 2022 19:57:49 -0700 Subject: [PATCH 09/19] added 'slice' to melee regexp --- parsers/deathloopvaccine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsers/deathloopvaccine.py b/parsers/deathloopvaccine.py index b3aa1f1..b1c7e64 100644 --- a/parsers/deathloopvaccine.py +++ b/parsers/deathloopvaccine.py @@ -186,7 +186,7 @@ def check_not_afk(self, timestamp: datetime, text: str) -> None: starprint(f'DeathLoopVaccine: Player Not AFK: {line}') # does this line contain a proof of life - melee - regexp = r'^You( try to)? (hit|slash|pierce|crush|claw|bite|sting|maul|gore|punch|kick|backstab|bash)' + regexp = r'^You( try to)? (hit|slash|pierce|crush|claw|bite|sting|maul|gore|punch|kick|backstab|bash|slice)' m = re.match(regexp, trunc_line) if m: # player is not AFK From 9cd0cc063c209dc412f79f449ae42327c3fde6c4 Mon Sep 17 00:00:00 2001 From: Adam Harwell Date: Mon, 8 Aug 2022 22:00:09 -0700 Subject: [PATCH 10/19] Fix two discord color warnings --- parsers/discord.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parsers/discord.py b/parsers/discord.py index db5ed8e..34ade80 100644 --- a/parsers/discord.py +++ b/parsers/discord.py @@ -41,7 +41,7 @@ color: white; }} #ParserWindowMenu QPushButton {{ - color: rgb(255, 255, 255, {alpha}); + color: rgba(255, 255, 255, {alpha}); }} #ParserWindowMenuReal {{ background-color:rgba({red},{green},{blue},{alpha}) @@ -53,7 +53,7 @@ color: rgba(255,255,255,{alpha}) }} #ParserWindowTitle {{ - color: rgb(200, 200, 200, {alpha}) + color: rgba(200, 200, 200, {alpha}) }}""" HTML_NO_CONFIG = """ From fbaf78958bc0976acf59bd3c0f9a9896b4e9d58e Mon Sep 17 00:00:00 2001 From: Adam Harwell Date: Mon, 8 Aug 2022 22:01:58 -0700 Subject: [PATCH 11/19] Fix AA_EnableHighDpiScaling error Not totally sure what this being broken actually caused... --- nparse.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nparse.py b/nparse.py index 2d17860..20ca6dc 100644 --- a/nparse.py +++ b/nparse.py @@ -31,6 +31,7 @@ class NomnsParse(QApplication): """Application Control.""" def __init__(self, *args): + self.setAttribute(Qt.AA_EnableHighDpiScaling) super().__init__(*args) # Updates @@ -125,7 +126,6 @@ def _menu(self, event): menu = QMenu() menu.setAttribute(Qt.WA_DeleteOnClose) # check online for new version - new_version_text = "" if self.new_version_available(): new_version_text = "Update Available {}".format(ONLINE_VERSION) else: @@ -220,7 +220,6 @@ def new_version_available(self): APP.setStyleSheet(open(resource_path('data/ui/_.css')).read()) APP.setWindowIcon(QIcon(resource_path('data/ui/icon.png'))) APP.setQuitOnLastWindowClosed(False) - APP.setAttribute(Qt.AA_EnableHighDpiScaling) QFontDatabase.addApplicationFont( resource_path('data/fonts/NotoSans-Regular.ttf')) QFontDatabase.addApplicationFont( From cb21854fe5db5b83213d35f25b5e31aef5a7b3d4 Mon Sep 17 00:00:00 2001 From: Adam Harwell Date: Mon, 8 Aug 2022 22:03:03 -0700 Subject: [PATCH 12/19] Fix exit if logdir unconfigured Sharing will stop correctly in all cases now. --- nparse.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nparse.py b/nparse.py index 20ca6dc..7b7d220 100644 --- a/nparse.py +++ b/nparse.py @@ -181,6 +181,8 @@ def _menu(self, event): elif action == quit_action: if self._toggled: self._toggle() + else: + location_service.stop_location_service() # save parser geometry for parser in self._parsers: From 46a4a2a929a04d4cc7039f52b65a49e34a520c81 Mon Sep 17 00:00:00 2001 From: Adam Harwell Date: Mon, 8 Aug 2022 22:04:46 -0700 Subject: [PATCH 13/19] Close splash screen if one is open (pyInstaller) --- nparse.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nparse.py b/nparse.py index 7b7d220..d480718 100644 --- a/nparse.py +++ b/nparse.py @@ -12,6 +12,14 @@ from helpers import config, logreader, resource_path, get_version, location_service from helpers.settings import SettingsWindow +try: + import pyi_splash # noqa + + pyi_splash.update_text('Done!') + pyi_splash.close() +except: # noqa + pass + config.load('nparse.config.json') # validate settings file config.verify_settings() From 518c436025da278285646e71a6c26c47b23ac9ec Mon Sep 17 00:00:00 2001 From: Adam Harwell Date: Mon, 8 Aug 2022 22:06:15 -0700 Subject: [PATCH 14/19] Stop using custom qt.conf runtime hook This seems to be working correctly internally to PyInstaller as of the newer 5.x releases. --- nparse_py.spec | 3 +-- set_qt_conf.py | 10 ---------- 2 files changed, 1 insertion(+), 12 deletions(-) delete mode 100644 set_qt_conf.py diff --git a/nparse_py.spec b/nparse_py.spec index 6b71ba8..69f875a 100644 --- a/nparse_py.spec +++ b/nparse_py.spec @@ -4,12 +4,11 @@ block_cipher = None a = Analysis(['nparse.py'], - pathex=['D:\\nomns.github.com\\nparse'], + pathex=[], binaries=[], datas=[], hiddenimports=[], hookspath=[], - runtime_hooks=['set_qt_conf.py'], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, diff --git a/set_qt_conf.py b/set_qt_conf.py deleted file mode 100644 index 9766193..0000000 --- a/set_qt_conf.py +++ /dev/null @@ -1,10 +0,0 @@ -import os -import sys - -if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): - print('running in a PyInstaller bundle') - qt_prefix = os.path.join(sys._MEIPASS, 'PyQt5', 'Qt').replace("\\", "\\\\") - with open('qt.conf', 'w') as qtconf: - qtconf.writelines(['[Paths]\n', 'Prefix = %s\n' % qt_prefix]) -else: - print('running in a normal Python process') From d210839706793b9c0bd1564d76ab9d7cc98c0d03 Mon Sep 17 00:00:00 2001 From: Adam Harwell Date: Mon, 8 Aug 2022 22:19:44 -0700 Subject: [PATCH 15/19] Clean up specfile, add splash! --- nparse_py.spec | 75 ++++++++++++++++++++++++++++++++----------------- splash.png | Bin 0 -> 27161 bytes splash.xcf | Bin 0 -> 18007 bytes 3 files changed, 50 insertions(+), 25 deletions(-) create mode 100644 splash.png create mode 100644 splash.xcf diff --git a/nparse_py.spec b/nparse_py.spec index 69f875a..eefd8dd 100644 --- a/nparse_py.spec +++ b/nparse_py.spec @@ -3,33 +3,58 @@ block_cipher = None -a = Analysis(['nparse.py'], - pathex=[], - binaries=[], - datas=[], - hiddenimports=[], - hookspath=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher +a = Analysis( + ['nparse.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, ) + from glob import glob a.datas += [(filename, filename, '.') for filename in glob('data/fonts/*')] a.datas += [('data/ui/_.css', 'data/ui/_.css', '.')] a.datas += [('data/ui/icon.png', 'data/ui/icon.png', '.')] -pyz = PYZ(a.pure, a.zipped_data, - cipher=block_cipher) -exe = EXE(pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - name='nparse', - debug=False, - strip=False, - upx=False, - runtime_tmpdir=None, - console=False, - icon='data/ui/icon.ico' - ) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +splash = Splash( + 'splash.png', + binaries=a.binaries, + datas=a.datas, + text_pos=(5, 20), + text_size=12, + text_color='black', + minify_script=True, + always_on_top=True, +) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + splash, + splash.binaries, + name='nparse', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='data/ui/icon.ico' +) diff --git a/splash.png b/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..7b0de786f2601cb29990be618d214bd07f7df8df GIT binary patch literal 27161 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

5xzmpWIDvr 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 Date: Mon, 8 Aug 2022 22:36:44 -0700 Subject: [PATCH 16/19] Be threadsafe for location service RUN flag Honestly this doesn't matter, as it isn't threaded, but it looks cleaner than using "global" statements. --- helpers/location_service.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/helpers/location_service.py b/helpers/location_service.py index 7a3612b..bb87e56 100644 --- a/helpers/location_service.py +++ b/helpers/location_service.py @@ -1,6 +1,7 @@ import json import logging import ssl +import threading import time from PyQt5.QtCore import QObject, QRunnable, pyqtSignal, pyqtSlot @@ -24,7 +25,8 @@ class LocationSignals(QObject): config_updated = pyqtSignal() -RUN = True +RUN = threading.Event() +RUN.set() SIGNALS = LocationSignals() THREADPOOL = QThreadPool() _LSC = None @@ -50,8 +52,8 @@ def start_location_service(update_func): def stop_location_service(): - global RUN - RUN = False + RUN.clear() + print("Stopping location service.") lsc = get_location_service_connection() lsc.enabled = False lsc.configure_socket() @@ -103,7 +105,7 @@ def configure_socket(self): @pyqtSlot() def run(self): - while RUN: + while RUN.is_set(): try: self.configure_socket() except: @@ -113,8 +115,8 @@ def run(self): try: self._socket.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE}) except: - print("Socket connection brokem continuing...") - if RUN: + print("Socket connection broken, continuing...") + if RUN.is_set(): time.sleep(self.reconnect_delay) @property From 23c9a5c5a4db41f2c7251b3f9c1b275768e3cf65 Mon Sep 17 00:00:00 2001 From: Adam Harwell Date: Mon, 8 Aug 2022 22:38:41 -0700 Subject: [PATCH 17/19] Bump version to 0.6.5 --- nparse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nparse.py b/nparse.py index d480718..afe6637 100644 --- a/nparse.py +++ b/nparse.py @@ -28,7 +28,7 @@ config.data['general']['qt_scale_factor'] / 100) -CURRENT_VERSION = '0.6.4' +CURRENT_VERSION = '0.6.5' if config.data['general']['update_check']: ONLINE_VERSION = get_version() else: From dcbe69ada64b6335f1137c17868b531cf1ab04af Mon Sep 17 00:00:00 2001 From: Adam Harwell Date: Mon, 8 Aug 2022 23:21:02 -0700 Subject: [PATCH 18/19] Update README for new pyInstaller info --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 155023a..be186a2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,6 @@ Building ======== Currently only python 3.8.x is supported. Python 3.10.x has known issues that must be resolved. -Install `pyinstaller==4.3` and `pyinstaller-hooks-contrib==2020.7` +Install `pyinstaller==5.3` -Run: `pyinstaller --onefile nparse_py.spec` \ No newline at end of file +Run: `pyinstaller nparse_py.spec` \ No newline at end of file From c046203be84930fe2c81bed3a003a5537e9c4af9 Mon Sep 17 00:00:00 2001 From: Elliott Jackson Date: Thu, 6 Oct 2022 20:14:42 -0700 Subject: [PATCH 19/19] updated coordinates of bank in Crystal caverns --- data/maps/map_files/Crystal_1.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/maps/map_files/Crystal_1.txt b/data/maps/map_files/Crystal_1.txt index b16d2de..070355c 100644 --- a/data/maps/map_files/Crystal_1.txt +++ b/data/maps/map_files/Crystal_1.txt @@ -1,4 +1,4 @@ -P -298.0000, 184.0000, 0.0000, 127, 64, 0, 2, Bank +P -298.0000, 192.0000, -384, 127, 64, 0, 2, Bank P -758.0000, 265.0000, 0.0000, 127, 64, 0, 2, Broken_Bridge P 939.1472, 589.2308, -538.4664, 127, 0, 0, 2, Queen P -692.0000, 176.0000, 0.0000, 127, 64, 0, 2, Waterfall