Compare commits

..
6 Commits
Author SHA1 Message Date
DebianandSisyphus 0613d380a4 Add graceful shutdown, reconnect handling and config file integration
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-13 14:00:16 +02:00
DebianandSisyphus 3213bb38b4 Handle undersized terminals and wire autocomplete/history options
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-13 14:00:16 +02:00
DebianandSisyphus 271637ee71 Add config getters for logging, history and behavior settings
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-13 14:00:16 +02:00
DebianandSisyphus 5b90d66276 Remove dead code, fix cvar argument type and add player name helper
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-13 14:00:13 +02:00
DebianandSisyphus e47badb7b3 Harden JSON event parsing and broadcast message formatting
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-13 14:00:13 +02:00
DebianandSisyphus 15d461c234 Fix ZMQ socket monitor leak and guard monitor recv against races
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-13 14:00:13 +02:00
9 changed files with 199 additions and 70 deletions
-9
View File
@@ -104,12 +104,3 @@ POWERUP_COLORS = {
'MegaHealth': '^8^4MegaHealth^7^0' 'MegaHealth': '^8^4MegaHealth^7^0'
} }
# Curses color pairs
COLOR_PAIRS = {
1: 1, # Red
2: 2, # Green
3: 3, # Yellow
4: 4, # Blue
5: 6, # Cyan (swapped)
6: 5 # Magenta (swapped)
}
+3 -2
View File
@@ -4,6 +4,8 @@ Quake Live console variables (cvars) database
Common cvars for autocomplete and fuzzy search Common cvars for autocomplete and fuzzy search
""" """
import re
# Server configuration # Server configuration
SERVER_CVARS = [ SERVER_CVARS = [
'sv_hostname', 'sv_hostname',
@@ -329,7 +331,7 @@ COMMAND_ARGUMENTS = {
{'type': 'botname', 'required': True}, {'type': 'botname', 'required': True},
{'type': 'skill', 'required': False}, {'type': 'skill', 'required': False},
{'type': 'team', 'required': False}, {'type': 'team', 'required': False},
{'type': 'msec delay number', 'required': False}, # msec delay {'type': 'number', 'required': False}, # msec delay
{'type': 'freetext', 'required': False}, # altname {'type': 'freetext', 'required': False}, # altname
], ],
'removebot': [ 'removebot': [
@@ -541,7 +543,6 @@ def parse_signature(signature):
Example: Example:
'<botname> [skill 1-5] [team]' -> ['<botname>', '[skill 1-5]', '[team]'] '<botname> [skill 1-5] [team]' -> ['<botname>', '[skill 1-5]', '[team]']
""" """
import re
# Match <arg> or [arg] patterns, including content with spaces # Match <arg> or [arg] patterns, including content with spaces
pattern = r'(<[^>]+>|\[[^\]]+\])' pattern = r'(<[^>]+>|\[[^\]]+\])'
args = re.findall(pattern, signature) args = re.findall(pattern, signature)
+6 -3
View File
@@ -78,12 +78,15 @@ def format_message(message, add_timestamp=True):
# Handle broadcast messages # Handle broadcast messages
attributes = 0 attributes = 0
if message[:10] == "broadcast:": if message[:10] == "broadcast:":
message = message[11:] message = message[10:].lstrip()
attributes = 1 # Bold attributes = 1 # Bold
# Handle print messages # Handle print messages
if message[:7] == "print \"": if message[:7] == "print \"":
message = message[7:-2] + "\n" message = message[7:]
if message.endswith("\""):
message = message[:-1]
message = message.rstrip() + "\n"
# Add timestamp if requested and appropriate # Add timestamp if requested and appropriate
if add_timestamp and should_add_timestamp(message): if add_timestamp and should_add_timestamp(message):
@@ -173,7 +176,7 @@ def format_powerup_message(message, player_tracker):
import time import time
if message.startswith("broadcast:"): if message.startswith("broadcast:"):
message = message[11:].strip() message = message[10:].strip()
# Strip print " wrapper # Strip print " wrapper
if message.startswith('print "'): if message.startswith('print "'):
+8 -3
View File
@@ -9,6 +9,8 @@ import struct
import logging import logging
import time import time
from .constants import STATS_CONNECTION_DELAY
logger = logging.getLogger('network') logger = logging.getLogger('network')
@@ -37,11 +39,11 @@ def check_monitor(monitor):
"""Check monitor socket for events""" """Check monitor socket for events"""
try: try:
event_monitor = monitor.recv(zmq.NOBLOCK) event_monitor = monitor.recv(zmq.NOBLOCK)
event_id, event_name, event_value = read_socket_event(event_monitor)
event_endpoint = monitor.recv(zmq.NOBLOCK)
except zmq.Again: except zmq.Again:
return None return None
event_id, event_name, event_value = read_socket_event(event_monitor)
event_endpoint = monitor.recv(zmq.NOBLOCK)
logger.debug(f'Monitor: {event_name} {event_value} endpoint {event_endpoint}') logger.debug(f'Monitor: {event_name} {event_value} endpoint {event_endpoint}')
return (event_id, event_value) return (event_id, event_value)
@@ -104,6 +106,9 @@ class RconConnection:
def close(self): def close(self):
"""Close connection""" """Close connection"""
if self.monitor:
self.monitor.setsockopt(zmq.LINGER, 0) # Don't wait for unsent messages
self.monitor.close()
if self.socket: if self.socket:
self.socket.setsockopt(zmq.LINGER, 0) # Don't wait for unsent messages self.socket.setsockopt(zmq.LINGER, 0) # Don't wait for unsent messages
self.socket.close() self.socket.close()
@@ -143,7 +148,7 @@ class StatsConnection:
logger.debug('Setting ZMQ_SUBSCRIBE to empty (all messages)') logger.debug('Setting ZMQ_SUBSCRIBE to empty (all messages)')
self.socket.setsockopt(zmq.SUBSCRIBE, b'') self.socket.setsockopt(zmq.SUBSCRIBE, b'')
time.sleep(0.5) time.sleep(STATS_CONNECTION_DELAY)
self.connected = True self.connected = True
logger.info('Stats stream connected') logger.info('Stats stream connected')
+1 -2
View File
@@ -84,7 +84,7 @@ class EventParser:
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.debug(f'JSON decode error: {e}') logger.debug(f'JSON decode error: {e}')
return None return None
except (KeyError, TypeError) as e: except (KeyError, TypeError, ValueError) as e:
logger.debug(f'Error parsing event: {e}') logger.debug(f'Error parsing event: {e}')
return None return None
@@ -212,7 +212,6 @@ class EventParser:
else: else:
weapon_name = WEAPON_NAMES.get(weapon, weapon) weapon_name = WEAPON_NAMES.get(weapon, weapon)
return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7committed suicide with the ^7{weapon_name}{warmup}\n" return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7committed suicide with the ^7{weapon_name}{warmup}\n"
return None
# Regular kill: +1 for killer # Regular kill: +1 for killer
if not data.get('WARMUP', False): if not data.get('WARMUP', False):
+30 -3
View File
@@ -72,6 +72,18 @@ class ConfigLoader:
return value.lower() in ('true', 'yes', '1', 'on') return value.lower() in ('true', 'yes', '1', 'on')
def get_float(self, section, key, fallback=0.0):
"""Get a float configuration value"""
value = self.get(section, key)
if value is None:
return fallback
try:
return float(value)
except ValueError:
logger.warning(f'Invalid float value for [{section}] {key}: {value}')
return fallback
def get_host(self): def get_host(self):
"""Get connection host""" """Get connection host"""
return self.get('connection', 'host') return self.get('connection', 'host')
@@ -129,8 +141,11 @@ class ConfigLoader:
return password return password
def get_log_level(self): def get_log_level(self):
"""Get logging level""" """Get logging level (None if not configured or invalid)"""
level_str = self.get('logging', 'level', 'INFO') level_str = self.get('logging', 'level')
if not level_str:
return None
levels = { levels = {
'DEBUG': logging.DEBUG, 'DEBUG': logging.DEBUG,
'INFO': logging.INFO, 'INFO': logging.INFO,
@@ -138,7 +153,19 @@ class ConfigLoader:
'ERROR': logging.ERROR, 'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL 'CRITICAL': logging.CRITICAL
} }
return levels.get(level_str.upper(), logging.INFO) return levels.get(level_str.upper())
def get_max_history(self, default):
"""Get command history length from [ui] section"""
return self.get_int('ui', 'max_history', default)
def get_quit_timeout(self, default):
"""Get quit confirmation timeout from [behavior] section"""
return self.get_float('behavior', 'quit_timeout', default)
def get_respawn_delay(self, default):
"""Get respawn delay from [behavior] section"""
return self.get_float('behavior', 'respawn_delay', default)
def create_example_config(): def create_example_config():
+4 -3
View File
@@ -23,7 +23,6 @@ class ServerInfo:
self.roundlimit = '0' self.roundlimit = '0'
self.capturelimit = '0' self.capturelimit = '0'
self.maxclients = '0' self.maxclients = '0'
self.curclients = '0'
self.red_score = 0 self.red_score = 0
self.red_rounds = 0 self.red_rounds = 0
self.blue_score = 0 self.blue_score = 0
@@ -121,6 +120,10 @@ class PlayerTracker:
teams[team].append(name) teams[team].append(name)
return teams return teams
def get_player_names(self):
"""Get clean (color-code-stripped) names of all tracked players"""
return [strip_color_codes(name) for name in self.server_info.players]
def remove_player(self, name): def remove_player(self, name):
"""Remove player from tracking""" """Remove player from tracking"""
clean_name = strip_color_codes(name) clean_name = strip_color_codes(name)
@@ -227,5 +230,3 @@ class GameState:
self.server_info = ServerInfo() self.server_info = ServerInfo()
self.player_tracker = PlayerTracker(self.server_info) self.player_tracker = PlayerTracker(self.server_info)
self.event_deduplicator = EventDeduplicator() self.event_deduplicator = EventDeduplicator()
self.pending_background_commands = set()
self.status_buffer = []
+97 -27
View File
@@ -10,7 +10,7 @@ import threading
import queue import queue
import logging import logging
import time import time
from .constants import COLOR_PAIRS, INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, TEAM_MODES, MAX_COMMAND_HISTORY from .constants import INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, TEAM_MODES, MAX_COMMAND_HISTORY
from .cvars import autocomplete, COMMAND_SIGNATURES, get_signature_with_highlight, get_argument_suggestions, COMMAND_ARGUMENTS from .cvars import autocomplete, COMMAND_SIGNATURES, get_signature_with_highlight, get_argument_suggestions, COMMAND_ARGUMENTS
logger = logging.getLogger('ui') logger = logging.getLogger('ui')
@@ -19,12 +19,15 @@ logger = logging.getLogger('ui')
class CursesHandler(logging.Handler): class CursesHandler(logging.Handler):
"""Logging handler that outputs to curses window""" """Logging handler that outputs to curses window"""
def __init__(self, window): def __init__(self, window, manager=None):
logging.Handler.__init__(self) logging.Handler.__init__(self)
self.window = window self.window = window
self.manager = manager
def emit(self, record): def emit(self, record):
try: try:
if self.manager is not None and self.manager._too_small:
return
msg = self.format(record) msg = self.format(record)
fs = "%s\n" fs = "%s\n"
try: try:
@@ -86,7 +89,7 @@ def print_colored(window, message, attributes=0):
except curses.error: except curses.error:
return return
def update_autocomplete_display(window, current_input, first_word, words, ends_with_space): def update_autocomplete_display(window, first_word, words, ends_with_space, player_names_provider=None):
""" """
Update autocomplete display based on current input state. Update autocomplete display based on current input state.
Returns (suggestions, suggestion_index, original_word) tuple for Tab cycling. Returns (suggestions, suggestion_index, original_word) tuple for Tab cycling.
@@ -129,11 +132,12 @@ def update_autocomplete_display(window, current_input, first_word, words, ends_w
current_value = words[-1] current_value = words[-1]
# Get argument suggestions # Get argument suggestions
player_names = player_names_provider() if player_names_provider else None
arg_suggestions = get_argument_suggestions( arg_suggestions = get_argument_suggestions(
first_word, first_word,
arg_position, arg_position,
current_value, current_value,
player_list=None # TODO: pass player list from game_state player_list=player_names
) )
if arg_suggestions: if arg_suggestions:
@@ -199,7 +203,7 @@ def update_autocomplete_display(window, current_input, first_word, words, ends_w
class UIManager: class UIManager:
"""Manages curses windows and display""" """Manages curses windows and display"""
def __init__(self, screen, host): def __init__(self, screen, host, max_history=MAX_COMMAND_HISTORY):
self.screen = screen self.screen = screen
self.host = host self.host = host
self.info_window = None self.info_window = None
@@ -210,14 +214,15 @@ class UIManager:
self.command_history = [] self.command_history = []
self.history_index = -1 self.history_index = -1
self.cursor_pos = 0 # Track cursor position in input self.cursor_pos = 0 # Track cursor position in input
self.max_history = max_history
self._too_small = False
self._init_curses() self._init_curses()
self._create_windows() if not self._create_windows():
raise SystemExit('Terminal too small: minimum 20 rows x 80 columns required')
def _init_curses(self): def _init_curses(self):
"""Initialize curses settings""" """Initialize curses settings"""
curses.endwin()
curses.initscr()
self.screen.nodelay(1) self.screen.nodelay(1)
curses.start_color() curses.start_color()
curses.use_default_colors() curses.use_default_colors()
@@ -308,8 +313,20 @@ class UIManager:
# Minimum size check # Minimum size check
if maxy < 20 or maxx < 80: if maxy < 20 or maxx < 80:
if not self._too_small:
self._too_small = True
self._delete_windows()
self._draw_too_small_warning()
return False return False
# Rebuild windows when returning from a too-small terminal
if self._too_small:
self._too_small = False
curses.update_lines_cols()
self.screen.clear()
self.screen.addstr(0, 0, f"Quake Live PyCon: {self.host}")
return self._create_windows()
# Update screen # Update screen
curses.update_lines_cols() curses.update_lines_cols()
self.screen.clear() self.screen.clear()
@@ -346,8 +363,38 @@ class UIManager:
except curses.error: except curses.error:
return False return False
def setup_input_queue(self): def _delete_windows(self):
"""Delete all windows (terminal too small for the UI)"""
for window in (self.info_window, self.output_window, self.divider_window, self.input_window):
if window:
try:
window.erase()
window.noutrefresh()
except curses.error:
pass
curses.doupdate()
self.info_window = None
self.output_window = None
self.divider_window = None
self.input_window = None
def _draw_too_small_warning(self):
"""Draw centered warning on the bare screen"""
try:
maxy, maxx = self.screen.getmaxyx()
warning = 'Terminal too small - resize to at least 20x80'
self.screen.erase()
self.screen.addstr(maxy // 2, max(0, (maxx - len(warning)) // 2), warning)
self.screen.noutrefresh()
curses.doupdate()
except curses.error:
pass
def setup_input_queue(self, player_names_provider=None, max_history=None):
"""Setup threaded input queue with command history and autocomplete""" """Setup threaded input queue with command history and autocomplete"""
if max_history is None:
max_history = self.max_history
def wait_stdin(q, window, manager): def wait_stdin(q, window, manager):
current_input = "" current_input = ""
cursor_pos = 0 cursor_pos = 0
@@ -363,7 +410,12 @@ class UIManager:
while True: while True:
try: try:
key = window.getch() try:
key = window.getch()
except curses.error:
# Window may be deleted during a too-small resize
time.sleep(0.1)
continue
if key == -1: # No input if key == -1: # No input
continue continue
@@ -371,12 +423,14 @@ class UIManager:
# Handle terminal resize # Handle terminal resize
if key == curses.KEY_RESIZE: if key == curses.KEY_RESIZE:
manager.handle_resize() manager.handle_resize()
# Redraw input window = manager.input_window or window
window.erase() if not manager._too_small:
window.addstr(0, 0, current_input) # Redraw input
window.move(0, cursor_pos) window.erase()
window.noutrefresh() window.addstr(0, 0, current_input)
curses.doupdate() window.move(0, cursor_pos)
window.noutrefresh()
curses.doupdate()
continue continue
# Tab key - cycle through suggestions # Tab key - cycle through suggestions
@@ -455,7 +509,7 @@ class UIManager:
if len(current_input) > 0: if len(current_input) > 0:
# Add to history # Add to history
manager.command_history.append(current_input) manager.command_history.append(current_input)
if len(manager.command_history) > MAX_COMMAND_HISTORY: if len(manager.command_history) > max_history:
manager.command_history.pop(0) manager.command_history.pop(0)
q.put(current_input) q.put(current_input)
@@ -542,10 +596,10 @@ class UIManager:
words = current_input.split() words = current_input.split()
ends_with_space = current_input.endswith(' ') ends_with_space = current_input.endswith(' ')
if words: if words and not manager._too_small:
first_word = words[0].lower() first_word = words[0].lower()
suggestions, suggestion_index, original_word = update_autocomplete_display( suggestions, suggestion_index, original_word = update_autocomplete_display(
window, current_input, first_word, words, ends_with_space window, first_word, words, ends_with_space, player_names_provider
) )
window.move(0, cursor_pos) window.move(0, cursor_pos)
@@ -567,10 +621,10 @@ class UIManager:
words = current_input.split() words = current_input.split()
ends_with_space = current_input.endswith(' ') ends_with_space = current_input.endswith(' ')
if words: if words and not manager._too_small:
first_word = words[0].lower() first_word = words[0].lower()
suggestions, suggestion_index, original_word = update_autocomplete_display( suggestions, suggestion_index, original_word = update_autocomplete_display(
window, current_input, first_word, words, ends_with_space window, first_word, words, ends_with_space, player_names_provider
) )
window.move(0, cursor_pos) window.move(0, cursor_pos)
@@ -593,13 +647,15 @@ class UIManager:
def setup_logging(self): def setup_logging(self):
"""Setup logging handler for output window""" """Setup logging handler for output window"""
handler = CursesHandler(self.output_window) handler = CursesHandler(self.output_window, self)
formatter = logging.Formatter('%(asctime)-8s|%(name)-12s|%(levelname)-6s|%(message)-s', '%H:%M:%S') formatter = logging.Formatter('%(asctime)-8s|%(name)-12s|%(levelname)-6s|%(message)-s', '%H:%M:%S')
handler.setFormatter(formatter) handler.setFormatter(formatter)
return handler return handler
def print_message(self, message, attributes=0): def print_message(self, message, attributes=0):
"""Print formatted message to output window""" """Print formatted message to output window"""
if self._too_small:
return
print_colored(self.output_window, message, attributes) print_colored(self.output_window, message, attributes)
self.output_window.noutrefresh() self.output_window.noutrefresh()
# Restore cursor to input window at current position # Restore cursor to input window at current position
@@ -609,6 +665,8 @@ class UIManager:
def update_server_info(self, game_state): def update_server_info(self, game_state):
"""Update server info window""" """Update server info window"""
if self._too_small:
return
self.info_window.erase() self.info_window.erase()
max_y, max_x = self.info_window.getmaxyx() max_y, max_x = self.info_window.getmaxyx()
@@ -680,7 +738,10 @@ class UIManager:
blue_total = 0 blue_total = 0
for player_name, player_data in server_info.players.items(): for player_name, player_data in server_info.players.items():
team = game_state.player_tracker.get_team(player_name) team = game_state.player_tracker.get_team(player_name)
score = int(player_data.get('score', 0)) try:
score = int(player_data.get('score', 0))
except ValueError:
score = 0
if team == 'RED': if team == 'RED':
red_total += score red_total += score
@@ -698,11 +759,17 @@ class UIManager:
spec_players = [] spec_players = []
for player_name in teams['RED']: for player_name in teams['RED']:
score = int(server_info.players.get(player_name, {}).get('score', 0)) try:
score = int(server_info.players.get(player_name, {}).get('score', 0))
except ValueError:
score = 0
red_players_with_scores.append((player_name, score)) red_players_with_scores.append((player_name, score))
for player_name in teams['BLUE']: for player_name in teams['BLUE']:
score = int(server_info.players.get(player_name, {}).get('score', 0)) try:
score = int(server_info.players.get(player_name, {}).get('score', 0))
except ValueError:
score = 0
blue_players_with_scores.append((player_name, score)) blue_players_with_scores.append((player_name, score))
# Sort by score descending # Sort by score descending
@@ -743,7 +810,10 @@ class UIManager:
free_players = teams['FREE'] free_players = teams['FREE']
free_players_with_scores = [] free_players_with_scores = []
for player_name in free_players: for player_name in free_players:
score = int(server_info.players.get(player_name, {}).get('score', 0)) try:
score = int(server_info.players.get(player_name, {}).get('score', 0))
except ValueError:
score = 0
free_players_with_scores.append((player_name, score)) free_players_with_scores.append((player_name, score))
# Sort by score descending # Sort by score descending
@@ -797,7 +867,7 @@ class UIManager:
pass pass
# Separator # Separator
separator = "^7" + "═" * (max_x - 1) + "^7" separator = "^7" + "=" * (max_x - 1) + "^7"
print_colored(self.info_window, separator, 0) print_colored(self.info_window, separator, 0)
self.info_window.noutrefresh() self.info_window.noutrefresh()
+50 -18
View File
@@ -49,22 +49,32 @@ unknown_json_logger.setLevel(logging.DEBUG)
quit_confirm_time = None quit_confirm_time = None
quit_confirm_lock = threading.Lock() quit_confirm_lock = threading.Lock()
# Configurable timeouts (set from config in __main__)
_QUIT_CONFIRM_TIMEOUT = QUIT_CONFIRM_TIMEOUT
_RESPAWN_DELAY = RESPAWN_DELAY
# Global shutdown flag (set by signal_handler, checked by main_loop)
shutdown_requested = False
def signal_handler(sig, frame): def signal_handler(sig, frame):
"""Handle Ctrl+C with confirmation""" """Handle Ctrl+C with confirmation"""
global quit_confirm_time global quit_confirm_time, shutdown_requested
current_time = time.time() current_time = time.time()
with quit_confirm_lock: with quit_confirm_lock:
if quit_confirm_time is None or (current_time - quit_confirm_time) > QUIT_CONFIRM_TIMEOUT: if shutdown_requested:
# Third Ctrl-C: last-resort forced exit
curses.endwin()
os._exit(0)
elif quit_confirm_time is None or (current_time - quit_confirm_time) > _QUIT_CONFIRM_TIMEOUT:
# First Ctrl-C or timeout expired # First Ctrl-C or timeout expired
logger.warning(f"^1^8Press Ctrl-C again within {QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0") logger.warning(f"^1^8Press Ctrl-C again within {_QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0")
quit_confirm_time = current_time quit_confirm_time = current_time
else: else:
# Second Ctrl-C within timeout # Second Ctrl-C within timeout: request graceful shutdown
logger.warning("^1^8Quittin'^0") logger.warning("^1^8Quittin'^0")
curses.endwin() # Restore terminal before force exit shutdown_requested = True
os._exit(0)
def parse_cvar_response(message, game_state, ui): def parse_cvar_response(message, game_state, ui):
""" """
@@ -91,7 +101,7 @@ def parse_cvar_response(message, game_state, ui):
return False return False
def handle_stats_connection(message, rcon, ui, game_state): def handle_stats_connection(message):
""" """
Handle stats connection info extraction Handle stats connection info extraction
Returns (stats_port, stats_password) or (None, None) Returns (stats_port, stats_password) or (None, None)
@@ -163,7 +173,7 @@ def handle_player_respawns(game_state, ui):
if game_state.server_info.gametype == 'Clan Arena': if game_state.server_info.gametype == 'Clan Arena':
# CA: revive all players after round end # CA: revive all players after round end
if game_state.server_info.round_end_time: if game_state.server_info.round_end_time:
if time.time() - game_state.server_info.round_end_time >= RESPAWN_DELAY: if time.time() - game_state.server_info.round_end_time >= _RESPAWN_DELAY:
game_state.server_info.dead_players.clear() game_state.server_info.dead_players.clear()
game_state.server_info.round_end_time = None game_state.server_info.round_end_time = None
ui.update_server_info(game_state) ui.update_server_info(game_state)
@@ -172,7 +182,7 @@ def handle_player_respawns(game_state, ui):
current_time = time.time() current_time = time.time()
players_to_revive = [ players_to_revive = [
name for name, death_time in game_state.server_info.dead_players.items() name for name, death_time in game_state.server_info.dead_players.items()
if current_time - death_time >= RESPAWN_DELAY if current_time - death_time >= _RESPAWN_DELAY
] ]
if players_to_revive: if players_to_revive:
for name in players_to_revive: for name in players_to_revive:
@@ -307,7 +317,7 @@ def main_loop(screen, args):
# Set logging level # Set logging level
if args.verbose == 0: if args.verbose == 0:
logger.setLevel(logging.WARNING) logger.setLevel(args.config_log_level if args.config_log_level is not None else logging.WARNING)
elif args.verbose == 1: elif args.verbose == 1:
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
else: else:
@@ -321,15 +331,22 @@ def main_loop(screen, args):
unknown_json_logger.propagate = False unknown_json_logger.propagate = False
# Initialize components # Initialize components
ui = UIManager(screen, args.host) ui = UIManager(screen, args.host, args.max_history)
game_state = GameState() game_state = GameState()
# Setup logging to output window # Setup logging to output window
log_handler = ui.setup_logging() log_handler = ui.setup_logging()
logger.addHandler(log_handler) logger.addHandler(log_handler)
# Attach UI log handler to lib module loggers
for lib_name in ('network', 'parser', 'state'):
lib_logger = logging.getLogger(lib_name)
if log_handler not in lib_logger.handlers:
lib_logger.addHandler(log_handler)
lib_logger.setLevel(logger.level)
# Setup input queue # Setup input queue
input_queue = ui.setup_input_queue() input_queue = ui.setup_input_queue(player_names_provider=lambda: game_state.player_tracker.get_player_names())
# Display startup messages # Display startup messages
ui.print_message(f"*** QL pyCon Version {VERSION} starting ***\n") ui.print_message(f"*** QL pyCon Version {VERSION} starting ***\n")
@@ -345,9 +362,6 @@ def main_loop(screen, args):
stats_password = None stats_password = None
stats_check_counter = 0 stats_check_counter = 0
# Shutdown flag
shutdown = False
# Timer refresh tracking (update UI once per second) # Timer refresh tracking (update UI once per second)
last_ui_update = 0 last_ui_update = 0
@@ -366,7 +380,7 @@ def main_loop(screen, args):
# Main event loop with resource cleanup # Main event loop with resource cleanup
try: try:
while not shutdown: while not shutdown_requested:
# Poll RCON socket # Poll RCON socket
event = rcon.poll(POLL_TIMEOUT) event = rcon.poll(POLL_TIMEOUT)
@@ -380,6 +394,14 @@ def main_loop(screen, args):
ui.print_message("Requesting connection info...\n") ui.print_message("Requesting connection info...\n")
rcon.send_command(b'zmq_stats_password') rcon.send_command(b'zmq_stats_password')
rcon.send_command(b'net_port') rcon.send_command(b'net_port')
elif monitor_event and monitor_event[0] == zmq.EVENT_DISCONNECTED:
timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3] ^8^1Disconnected from server - waiting for reconnect...^0^7\n")
if stats_conn is not None:
stats_conn.close()
stats_conn = None
stats_port = None
stats_password = None
# Handle user input # Handle user input
handle_user_input(input_queue, rcon, ui) handle_user_input(input_queue, rcon, ui)
@@ -414,7 +436,7 @@ def main_loop(screen, args):
if parse_player_events(message, game_state, ui): if parse_player_events(message, game_state, ui):
continue continue
if '------- Game Initialization -------' in message or 'Game Initialization' in message: if 'Game Initialization' in message:
logger.info('Game initialization detected - refreshing server info') logger.info('Game initialization detected - refreshing server info')
timestamp = time.strftime('%H:%M:%S') timestamp = time.strftime('%H:%M:%S')
@@ -432,6 +454,8 @@ def main_loop(screen, args):
# Clear player dict since map changed # Clear player dict since map changed
game_state.server_info.players = {} game_state.server_info.players = {}
game_state.player_tracker.player_teams = {} game_state.player_tracker.player_teams = {}
game_state.server_info.reset_round_scores()
game_state.server_info.dead_players.clear()
ui.update_server_info(game_state) ui.update_server_info(game_state)
# Try to parse as cvar response # Try to parse as cvar response
@@ -440,7 +464,7 @@ def main_loop(screen, args):
continue continue
# Check for stats connection info # Check for stats connection info
port, password = handle_stats_connection(message, rcon, ui, game_state) port, password = handle_stats_connection(message)
if port: if port:
stats_port = port stats_port = port
if password: if password:
@@ -531,6 +555,10 @@ if __name__ == '__main__':
config = ConfigLoader() config = ConfigLoader()
config.load() config.load()
# Read settings from config ([logging]/[ui]/[behavior])
_QUIT_CONFIRM_TIMEOUT = config.get_quit_timeout(QUIT_CONFIRM_TIMEOUT)
_RESPAWN_DELAY = config.get_respawn_delay(RESPAWN_DELAY)
# Parse arguments # Parse arguments
parser = argparse.ArgumentParser(description='Quake Live Python Console') parser = argparse.ArgumentParser(description='Quake Live Python Console')
parser.add_argument('server', nargs='?', default=None, parser.add_argument('server', nargs='?', default=None,
@@ -551,6 +579,10 @@ if __name__ == '__main__':
help='File to log all JSON events') help='File to log all JSON events')
args = parser.parse_args() args = parser.parse_args()
# Apply config settings to args
args.config_log_level = config.get_log_level()
args.max_history = config.get_max_history(MAX_COMMAND_HISTORY)
# Handle --list # Handle --list
if args.list: if args.list:
servers = config.get_servers() servers = config.get_servers()