Compare commits

...
3 Commits
Author SHA1 Message Date
DebianandSisyphus b8864ca8dd Stop double-formatting stats stream events
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-16 21:27:28 +02:00
DebianandSisyphus b641eb4e45 Fix scroll-hold drift, ignore input while too small and redraw scrolls immediately
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-16 21:27:28 +02:00
DebianandSisyphus 71bd740d78 Bound listing capture and accept all server count-line formats
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-16 21:27:27 +02:00
3 changed files with 35 additions and 10 deletions
+17 -4
View File
@@ -22,6 +22,11 @@ logger = logging.getLogger('namelist')
# from the request: a large cvarlist (5000+ messages) takes seconds to process. # from the request: a large cvarlist (5000+ messages) takes seconds to process.
CAPTURE_TIMEOUT = 5.0 CAPTURE_TIMEOUT = 5.0
# Absolute cap on a capture, measured from the request. The timeout above is
# refreshed by every incoming message, so regular server output can keep a
# stuck capture (count line never matches) alive indefinitely without this bound.
CAPTURE_MAX_DURATION = 30.0
# Cvar line: the name is the word directly before the quoted value. # Cvar line: the name is the word directly before the quoted value.
# Example: 'S A sv_hostname "My Server"' -> sv_hostname # Example: 'S A sv_hostname "My Server"' -> sv_hostname
CVARLIST_LINE = re.compile(r'([A-Za-z_]\w*)\s+"') CVARLIST_LINE = re.compile(r'([A-Za-z_]\w*)\s+"')
@@ -31,8 +36,8 @@ CVARLIST_LINE = re.compile(r'([A-Za-z_]\w*)\s+"')
CMDLIST_LINE = re.compile(r'^\s*([A-Za-z_]\w*)\s*$') CMDLIST_LINE = re.compile(r'^\s*([A-Za-z_]\w*)\s*$')
# Count line that ends a listing. # Count line that ends a listing.
# Examples: '1234 total cvars', '321 commands' # Examples: '1234 total cvars', '321 commands', '321 total commands'
END_LINE = re.compile(r'^\s*\d+\s+(total cvars|commands)\s*$') END_LINE = re.compile(r'^\s*\d+\s+(total cvars|total commands|commands)\s*$')
def unwrap(message): def unwrap(message):
@@ -55,12 +60,14 @@ class NameCapture:
self.names = set() self.names = set()
self.listings_pending = 0 # count lines still expected; 0 = not capturing self.listings_pending = 0 # count lines still expected; 0 = not capturing
self.deadline = 0 self.deadline = 0
self.deadline_max = 0
self.partial_line = '' self.partial_line = ''
def request(self, rcon): def request(self, rcon):
"""Send cvarlist and cmdlist; their output is captured until both count lines arrived""" """Send cvarlist and cmdlist; their output is captured until both count lines arrived"""
self.listings_pending = 2 self.listings_pending = 2
self.deadline = time.time() + CAPTURE_TIMEOUT self.deadline = time.time() + CAPTURE_TIMEOUT
self.deadline_max = time.time() + CAPTURE_MAX_DURATION
self.partial_line = '' self.partial_line = ''
rcon.send_command(b'cvarlist') rcon.send_command(b'cvarlist')
rcon.send_command(b'cmdlist') rcon.send_command(b'cmdlist')
@@ -73,12 +80,18 @@ class NameCapture:
""" """
if self.listings_pending == 0: if self.listings_pending == 0:
return False return False
if time.time() > self.deadline: now = time.time()
if now > self.deadline_max:
logger.warning(f'Capture exceeded {CAPTURE_MAX_DURATION:.0f}s, '
f'stopping capture with {len(self.names)} names')
self.listings_pending = 0
return False
if now > self.deadline:
logger.warning(f'No listing output for {CAPTURE_TIMEOUT:.0f}s, ' logger.warning(f'No listing output for {CAPTURE_TIMEOUT:.0f}s, '
f'stopping capture with {len(self.names)} names') f'stopping capture with {len(self.names)} names')
self.listings_pending = 0 self.listings_pending = 0
return False return False
self.deadline = time.time() + CAPTURE_TIMEOUT self.deadline = now + CAPTURE_TIMEOUT
# A line can arrive in several messages (the server sends each flag column of # A line can arrive in several messages (the server sends each flag column of
# a cvarlist row as its own 1-byte message), so keep the unfinished tail # a cvarlist row as its own 1-byte message), so keep the unfinished tail
+15 -1
View File
@@ -586,6 +586,9 @@ class UIManager:
key_seen = True key_seen = True
if key == curses.KEY_RESIZE: if key == curses.KEY_RESIZE:
self.handle_resize() self.handle_resize()
elif self.too_small:
# Warning screen: ignore typing and scrolling, only resizes act
continue
elif key == curses.KEY_PPAGE: elif key == curses.KEY_PPAGE:
self.scroll_output(self._page_rows()) self.scroll_output(self._page_rows())
elif key == curses.KEY_NPAGE: elif key == curses.KEY_NPAGE:
@@ -625,6 +628,7 @@ class UIManager:
return return
self._draw_output() self._draw_output()
self._draw_divider() self._draw_divider()
self._flush()
# --- output --- # --- output ---
@@ -646,9 +650,18 @@ class UIManager:
pieces.pop() pieces.pop()
new_lines = [] new_lines = []
continued_delta = 0
for piece in pieces: for piece in pieces:
if self.line_open: if self.line_open:
self.output_lines[-1] += piece if not self.too_small and self.scroll_offset > 0:
# The continued line can gain wrapped rows; count them so a
# scrolled-back view stays still
_, width = self.output_window.getmaxyx()
rows_before = len(wrap_colored(self.output_lines[-1], width))
self.output_lines[-1] += piece
continued_delta += len(wrap_colored(self.output_lines[-1], width)) - rows_before
else:
self.output_lines[-1] += piece
self.line_open = False self.line_open = False
else: else:
self.output_lines.append(piece) self.output_lines.append(piece)
@@ -663,6 +676,7 @@ class UIManager:
_, width = self.output_window.getmaxyx() _, width = self.output_window.getmaxyx()
for line in new_lines: for line in new_lines:
self.scroll_offset += len(wrap_colored(line, width)) self.scroll_offset += len(wrap_colored(line, width))
self.scroll_offset += continued_delta
self._draw_output() self._draw_output()
self._draw_divider() self._draw_divider()
+3 -5
View File
@@ -162,12 +162,10 @@ def handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_
if stats_msg: if stats_msg:
logger.info(f'Stats event received ({len(stats_msg)} bytes)') logger.info(f'Stats event received ({len(stats_msg)} bytes)')
# Parse game event # Parse game event (parse_event returns a display-ready string)
parsed = event_parser.parse_event(stats_msg) parsed = event_parser.parse_event(stats_msg)
if parsed: if parsed:
# Format with timestamp before displaying ui.print_message(parsed)
formatted_msg, attributes = format_message(parsed)
ui.print_message(formatted_msg)
ui.update_server_info(game_state) ui.update_server_info(game_state)
return stats_check_counter return stats_check_counter
@@ -557,7 +555,7 @@ def main_loop(screen, args):
message = format_chat_message(message, game_state.player_tracker) message = format_chat_message(message, game_state.player_tracker)
# Format and display message # Format and display message
formatted_msg, attributes = format_message(message) formatted_msg, _ = format_message(message)
ui.print_message(formatted_msg) ui.print_message(formatted_msg)
# Update server info panel every second (for live timer display) # Update server info panel every second (for live timer display)