Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0613d380a4 | ||
|
|
3213bb38b4 | ||
|
|
271637ee71 | ||
|
|
5b90d66276 | ||
|
|
e47badb7b3 | ||
|
|
15d461c234 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = []
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user