refactor
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
venv3/
|
||||
*.log
|
||||
__pycache__/
|
||||
144
README.md
Normal file
144
README.md
Normal file
@ -0,0 +1,144 @@
|
||||
# QLPyCon - Quake Live Python Console
|
||||
|
||||
A modular, refactored terminal-based client for monitoring Quake Live servers via ZMQ.
|
||||
|
||||
## Version 0.8.0 - Modular Architecture
|
||||
|
||||
### Features
|
||||
|
||||
- **Real-time game monitoring** - Watch kills, deaths, team switches, medals, and more
|
||||
- **Server info display** - Shows hostname, map, gametype, limits, and player count
|
||||
- **Team-aware chat** - Color-coded messages with team prefixes
|
||||
- **Powerup tracking** - Formatted pickup and carrier kill messages
|
||||
- **JSON event logging** - Capture all game events to file for analysis
|
||||
- **Colorized output** - Quake color code support (^0-^7)
|
||||
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
qlpycon/
|
||||
├── __init__.py # Package initialization
|
||||
├── main.py # Entry point and main loop
|
||||
├── config.py # Constants and configuration
|
||||
├── state.py # Game state management (ServerInfo, PlayerTracker, etc)
|
||||
├── network.py # ZMQ connections (RCON and stats stream)
|
||||
├── parser.py # JSON event parsing
|
||||
├── formatter.py # Message formatting and colorization
|
||||
└── ui.py # Curses interface (windows, display, input)
|
||||
```
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Requirements
|
||||
pip install pyzmq
|
||||
|
||||
# Run directly
|
||||
python -m qlpycon.main --host tcp://127.0.0.1:27961 --password YOUR_PASSWORD
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Basic connection
|
||||
python main.py --host tcp://SERVER_IP:PORT --password RCON_PASSWORD
|
||||
|
||||
# Verbose mode (show all communications)
|
||||
python main.py --host tcp://SERVER_IP:PORT --password PASS -v
|
||||
|
||||
# Debug mode (detailed logging)
|
||||
python main.py --host tcp://SERVER_IP:PORT --password PASS -vv
|
||||
|
||||
# Capture all JSON events to file
|
||||
python main.py --host tcp://SERVER_IP:PORT --password PASS --json events.log
|
||||
|
||||
# Custom unknown events log
|
||||
python main.py --unknown-log my_unknown.log
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
|
||||
- `--host` - ZMQ URI (default: tcp://127.0.0.1:27961)
|
||||
- `--password` - RCON password
|
||||
- `--identity` - Socket identity (random UUID by default)
|
||||
- `-v, --verbose` - Increase verbosity (use -v for INFO, -vv for DEBUG)
|
||||
- `--json FILE` - Log all JSON events to FILE
|
||||
- `--unknown-log FILE` - Log unknown JSON events (default: unknown_events.log)
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
#### State Management (`state.py`)
|
||||
- **ServerInfo** - Tracks server configuration and metadata
|
||||
- **PlayerTracker** - Manages player teams and information
|
||||
- **EventDeduplicator** - Prevents duplicate kill/death events
|
||||
- **GameState** - Main container for all state
|
||||
|
||||
#### Network Layer (`network.py`)
|
||||
- **RconConnection** - Handles DEALER socket for RCON commands
|
||||
- **StatsConnection** - Handles SUB socket for game event stream
|
||||
|
||||
#### Event Parsing (`parser.py`)
|
||||
- **EventParser** - Parses JSON game events into formatted messages
|
||||
- Modular handlers for each event type (deaths, medals, team switches, etc)
|
||||
|
||||
#### Message Formatting (`formatter.py`)
|
||||
- Color code handling (Quake's ^N system)
|
||||
- Team prefix generation
|
||||
- Timestamp logic
|
||||
- Chat message formatting
|
||||
|
||||
#### UI Layer (`ui.py`)
|
||||
- **UIManager** - Manages all curses windows
|
||||
- Three-panel layout: server info, output, input
|
||||
- Threaded input queue for non-blocking commands
|
||||
- Color rendering with curses
|
||||
|
||||
### Key Improvements Over v0.7.0
|
||||
|
||||
1. **Modular Design** - Separated concerns into focused modules
|
||||
2. **Clean Classes** - OOP design for state and connections
|
||||
3. **Better Maintainability** - Each module has a single responsibility
|
||||
4. **Easier Testing** - Components can be tested independently
|
||||
5. **Clear Interfaces** - Well-defined APIs between modules
|
||||
6. **Improved Comments** - Every module, class, and function documented
|
||||
|
||||
### Event Types Supported
|
||||
|
||||
- `PLAYER_SWITCHTEAM` - Team changes
|
||||
- `PLAYER_DEATH` / `PLAYER_KILL` - Frag events (deduplicated)
|
||||
- `PLAYER_MEDAL` - Medal awards
|
||||
- `PLAYER_STATS` - End-game statistics with weapon accuracy
|
||||
- `MATCH_STARTED` - Match initialization
|
||||
- `MATCH_REPORT` - Final scores
|
||||
- `PLAYER_CONNECT` / `PLAYER_DISCONNECT` - Connection events
|
||||
- `ROUND_OVER` - Round completion
|
||||
|
||||
### Color Codes
|
||||
|
||||
Quake Live uses `^N` color codes where N is 0-7:
|
||||
- `^0` - Black
|
||||
- `^1` - Red
|
||||
- `^2` - Green
|
||||
- `^3` - Yellow
|
||||
- `^4` - Blue
|
||||
- `^5` - Cyan
|
||||
- `^6` - Magenta
|
||||
- `^7` - White (default)
|
||||
|
||||
### Development
|
||||
|
||||
To extend QLPyCon:
|
||||
|
||||
1. **Add new event types** - Edit `parser.py`, add handler method
|
||||
2. **Change UI layout** - Edit `ui.py`, modify window creation
|
||||
3. **Add new commands** - Edit `main.py`, handle in main loop
|
||||
4. **Modify formatting** - Edit `formatter.py`, adjust color/timestamp logic
|
||||
5. **Add configuration** - Edit `config.py`, add constants
|
||||
|
||||
### License
|
||||
|
||||
MIT License - See original qlpycon.py for details
|
||||
|
||||
### Credits
|
||||
|
||||
Refactored from qlpycon.py v0.7.0
|
||||
101
config.py
Normal file
101
config.py
Normal file
@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration and constants for QLPyCon
|
||||
"""
|
||||
|
||||
VERSION = "0.8.0"
|
||||
|
||||
# Network defaults
|
||||
DEFAULT_HOST = 'tcp://127.0.0.1:27961'
|
||||
POLL_TIMEOUT = 100
|
||||
|
||||
# UI dimensions
|
||||
INFO_WINDOW_HEIGHT = 10
|
||||
INFO_WINDOW_Y = 2
|
||||
OUTPUT_WINDOW_Y = 12
|
||||
INPUT_WINDOW_HEIGHT = 1
|
||||
|
||||
# Event deduplication
|
||||
MAX_RECENT_EVENTS = 10
|
||||
|
||||
# Team game modes
|
||||
TEAM_MODES = [
|
||||
"Team Deathmatch",
|
||||
"Clan Arena",
|
||||
"Capture The Flag",
|
||||
"One Flag CTF",
|
||||
"Overload",
|
||||
"Harvester",
|
||||
"Freeze Tag"
|
||||
]
|
||||
|
||||
# Team mappings
|
||||
TEAM_MAP = {
|
||||
0: 'FREE',
|
||||
1: 'RED',
|
||||
2: 'BLUE',
|
||||
3: 'SPECTATOR'
|
||||
}
|
||||
|
||||
TEAM_COLORS = {
|
||||
'RED': '^1(RED)^7',
|
||||
'BLUE': '^4(BLUE)^7',
|
||||
'FREE': '',
|
||||
'SPECTATOR': '^3(SPEC)^7'
|
||||
}
|
||||
|
||||
# Weapon names
|
||||
WEAPON_NAMES = {
|
||||
'ROCKET': 'Rocket Launcher',
|
||||
'LIGHTNING': 'Lightning Gun',
|
||||
'RAILGUN': 'Railgun',
|
||||
'SHOTGUN': 'Shotgun',
|
||||
'GAUNTLET': 'Gauntlet',
|
||||
'GRENADE': 'Grenade Launcher',
|
||||
'PLASMA': 'Plasma Gun',
|
||||
'MACHINEGUN': 'Machine Gun'
|
||||
}
|
||||
|
||||
# Weapon names for kill messages
|
||||
WEAPON_KILL_NAMES = {
|
||||
'ROCKET': 'the Rocket Launcher',
|
||||
'LIGHTNING': 'the Lightning Gun',
|
||||
'RAILGUN': 'the Railgun',
|
||||
'SHOTGUN': 'the Shotgun',
|
||||
'GAUNTLET': 'the Gauntlet',
|
||||
'GRENADE': 'the Grenade Launcher',
|
||||
'PLASMA': 'the Plasma Gun',
|
||||
'MACHINEGUN': 'the Machine Gun'
|
||||
}
|
||||
|
||||
# Death messages
|
||||
DEATH_MESSAGES = {
|
||||
'FALLING': "%s%s ^7cratered.",
|
||||
'HURT': "%s%s ^7was in the wrong place.",
|
||||
'LAVA': "%s%s ^7does a backflip into the lava.",
|
||||
'WATER': "%s%s ^7sank like a rock.",
|
||||
'SLIME': "%s%s ^7melted.",
|
||||
'CRUSH': "%s%s ^7was crushed."
|
||||
}
|
||||
|
||||
# Powerup names and colors
|
||||
POWERUP_COLORS = {
|
||||
'Quad Damage': '^5Quad Damage^7',
|
||||
'Battle Suit': '^3Battle Suit^7',
|
||||
'Regeneration': '^1Regeneration^7',
|
||||
'Haste': '^3Haste^7',
|
||||
'Invisibility': '^5Invisibility^7',
|
||||
'Flight': '^5Flight^7',
|
||||
'Medkit': '^1Medkit^7',
|
||||
'MegaHealth': '^4MegaHealth^7'
|
||||
}
|
||||
|
||||
# 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)
|
||||
}
|
||||
202
formatter.py
Normal file
202
formatter.py
Normal file
@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Message formatting and colorization for QLPyCon
|
||||
Handles Quake color codes and team prefixes
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from config import TEAM_COLORS
|
||||
|
||||
|
||||
def strip_color_codes(text):
|
||||
"""Remove Quake color codes (^N) from text"""
|
||||
return re.sub(r'\^\d', '', text)
|
||||
|
||||
|
||||
def get_team_prefix(player_name, player_tracker):
|
||||
"""Get color-coded team prefix for a player"""
|
||||
if not player_tracker.server_info.is_team_mode():
|
||||
return ''
|
||||
|
||||
team = player_tracker.get_team(player_name)
|
||||
if not team:
|
||||
return ''
|
||||
|
||||
return TEAM_COLORS.get(team, '')
|
||||
|
||||
|
||||
def should_add_timestamp(message):
|
||||
"""Determine if a message should get a timestamp"""
|
||||
# Skip status command output
|
||||
skip_keywords = ['map:', 'num score', '---', 'bot', 'status']
|
||||
if any(kw in message for kw in skip_keywords):
|
||||
# But allow "zmq RCON" lines (command echoes)
|
||||
if 'zmq RCON' not in message:
|
||||
return False
|
||||
|
||||
# Skip very short messages or fragments
|
||||
stripped = message.strip()
|
||||
if len(stripped) <= 2:
|
||||
return False
|
||||
|
||||
# Skip messages with leading spaces (status fragments)
|
||||
if message.startswith(' ') and len(stripped) < 50:
|
||||
return False
|
||||
|
||||
# Skip pure numbers
|
||||
if stripped.isdigit():
|
||||
return False
|
||||
|
||||
# Skip short single words
|
||||
if len(stripped) < 20 and ' ' not in stripped:
|
||||
return False
|
||||
|
||||
# Skip IP addresses (xxx.xxx.xxx.xxx or xxx.xxx.xxx.xxx:port)
|
||||
allowed_chars = set('0123456789.:')
|
||||
if stripped.count('.') == 3 and all(c in allowed_chars for c in stripped):
|
||||
return False
|
||||
|
||||
# Skip messages starting with ***
|
||||
if message.startswith('***'):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def format_message(message, add_timestamp=True):
|
||||
"""
|
||||
Format a message for display
|
||||
- Strips special characters
|
||||
- Adds timestamp if appropriate
|
||||
- Handles broadcast formatting
|
||||
"""
|
||||
# Clean up message
|
||||
message = message.replace("\\n", "")
|
||||
message = message.replace(chr(25), "")
|
||||
|
||||
# Handle broadcast messages
|
||||
attributes = 0
|
||||
if message[:10] == "broadcast:":
|
||||
message = message[11:]
|
||||
attributes = 1 # Bold
|
||||
|
||||
# Handle print messages
|
||||
if message[:7] == "print \"":
|
||||
message = message[7:-2] + "\n"
|
||||
|
||||
# Add timestamp if requested and appropriate
|
||||
if add_timestamp and should_add_timestamp(message):
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
message = f"^3[^7{timestamp}^3]^7 {message}"
|
||||
|
||||
return message, attributes
|
||||
|
||||
|
||||
def format_chat_message(message, player_tracker):
|
||||
"""
|
||||
Format chat messages with team prefixes and colors
|
||||
Handles both regular chat (Name: msg) and team chat ((Name): msg or (Name) (Location): msg)
|
||||
"""
|
||||
# Strip special character
|
||||
clean_msg = message.replace(chr(25), '')
|
||||
|
||||
# Team chat with location: (PlayerName) (Location): message
|
||||
# Location can have nested parens like (Lower Floor (Near Yellow Armour))
|
||||
if clean_msg.strip().startswith('(') and ')' in clean_msg:
|
||||
# Extract player name (first parenthetical)
|
||||
player_match = re.match(r'^(\([^)]+\))', clean_msg)
|
||||
if not player_match:
|
||||
return message
|
||||
|
||||
player_part = player_match.group(1)
|
||||
rest = clean_msg[len(player_part):].lstrip()
|
||||
|
||||
# Check for location (another parenthetical)
|
||||
if rest.startswith('('):
|
||||
# Count parens to handle nesting
|
||||
paren_count = 0
|
||||
location_end = -1
|
||||
for i, char in enumerate(rest):
|
||||
if char == '(':
|
||||
paren_count += 1
|
||||
elif char == ')':
|
||||
paren_count -= 1
|
||||
if paren_count == 0:
|
||||
location_end = i + 1
|
||||
break
|
||||
|
||||
# Check if location ends with colon
|
||||
if location_end > 0 and location_end < len(rest) and rest[location_end] == ':':
|
||||
location_part = rest[:location_end]
|
||||
message_part = rest[location_end + 1:]
|
||||
|
||||
# Get team prefix
|
||||
name_match = re.match(r'\(([^)]+)\)', player_part)
|
||||
if name_match:
|
||||
player_name = strip_color_codes(name_match.group(1).strip())
|
||||
team_prefix = get_team_prefix(player_name, player_tracker)
|
||||
location_clean = strip_color_codes(location_part)
|
||||
return f"{team_prefix}{player_part} ^3{location_clean}^7:^5{message_part}"
|
||||
|
||||
# Team chat without location: (PlayerName): message
|
||||
colon_match = re.match(r'^(\([^)]+\)):(\s*.*)', clean_msg)
|
||||
if colon_match:
|
||||
player_part = colon_match.group(1) + ':'
|
||||
message_part = colon_match.group(2)
|
||||
|
||||
name_match = re.match(r'\(([^)]+)\)', player_part)
|
||||
if name_match:
|
||||
player_name = strip_color_codes(name_match.group(1).strip())
|
||||
team_prefix = get_team_prefix(player_name, player_tracker)
|
||||
return f"{team_prefix}{player_part}^5{message_part}\n"
|
||||
|
||||
# Regular chat: PlayerName: message
|
||||
parts = clean_msg.split(':', 1)
|
||||
if len(parts) == 2:
|
||||
player_name = strip_color_codes(parts[0].strip())
|
||||
team_prefix = get_team_prefix(player_name, player_tracker)
|
||||
|
||||
# Preserve original color-coded name
|
||||
original_parts = message.replace(chr(25), '').split(':', 1)
|
||||
if len(original_parts) == 2:
|
||||
return f"{team_prefix}{original_parts[0]}:^2{original_parts[1]}"
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def format_powerup_message(message, player_tracker):
|
||||
"""
|
||||
Format powerup pickup and carrier kill messages
|
||||
Returns formatted message or None if not a powerup message
|
||||
"""
|
||||
from config import POWERUP_COLORS
|
||||
|
||||
if message.startswith("broadcast:"):
|
||||
message = message[11:].strip()
|
||||
|
||||
# Powerup pickup: "PlayerName got the PowerupName!"
|
||||
pickup_match = re.match(r'^(.+?)\s+got the\s+(.+?)!', message)
|
||||
if pickup_match:
|
||||
player_name = pickup_match.group(1).strip()
|
||||
powerup_name = pickup_match.group(2).strip()
|
||||
|
||||
player_clean = strip_color_codes(player_name)
|
||||
team_prefix = get_team_prefix(player_clean, player_tracker)
|
||||
|
||||
colored_powerup = POWERUP_COLORS.get(powerup_name, f'^6{powerup_name}^7')
|
||||
return f"{team_prefix}{player_name} ^7got the {colored_powerup}!\n"
|
||||
|
||||
# Powerup carrier kill: "PlayerName killed the PowerupName carrier!"
|
||||
carrier_match = re.match(r'^(.+?)\s+killed the\s+(.+?)\s+carrier!', message)
|
||||
if carrier_match:
|
||||
player_name = carrier_match.group(1).strip()
|
||||
powerup_name = carrier_match.group(2).strip()
|
||||
|
||||
player_clean = strip_color_codes(player_name)
|
||||
team_prefix = get_team_prefix(player_clean, player_tracker)
|
||||
|
||||
colored_powerup = POWERUP_COLORS.get(powerup_name, f'^6{powerup_name}^7')
|
||||
return f"{team_prefix}{player_name} ^7killed the {colored_powerup} ^7carrier!\n"
|
||||
|
||||
return None
|
||||
589
main.py
Normal file
589
main.py
Normal file
@ -0,0 +1,589 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QLPyCon - Quake Live Python Console
|
||||
Main entry point
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import uuid
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
import curses
|
||||
import zmq
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from config import VERSION, DEFAULT_HOST, POLL_TIMEOUT
|
||||
from state import GameState
|
||||
from network import RconConnection, StatsConnection
|
||||
from parser import EventParser
|
||||
from formatter import format_message, format_chat_message, format_powerup_message
|
||||
from ui import UIManager
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger('main')
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
all_json_logger = logging.getLogger('all_json')
|
||||
all_json_logger.setLevel(logging.DEBUG)
|
||||
|
||||
unknown_json_logger = logging.getLogger('unknown_json')
|
||||
unknown_json_logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""Handle Ctrl+C for immediate shutdown"""
|
||||
# Don't call curses.endwin() here - let curses.wrapper handle it
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def parse_cvar_response(message, game_state, ui):
|
||||
"""
|
||||
Parse server cvar responses and update state
|
||||
Returns True if message should be suppressed from display
|
||||
"""
|
||||
# Suppress command echo lines
|
||||
if message.startswith('zmq RCON command') and 'from' in message:
|
||||
suppress_cmds = ['status', 'roundlimit', 'qlx_serverBrandName', 'g_factoryTitle',
|
||||
'mapname', 'timelimit', 'fraglimit', 'capturelimit', 'sv_maxclients']
|
||||
if any(f': {cmd}' in message for cmd in suppress_cmds):
|
||||
return True
|
||||
|
||||
# Parse cvar responses (format: "cvar_name" is:"value" default:...)
|
||||
cvar_match = re.search(r'"([^"]+)"\s+is:"([^"]*)"', message)
|
||||
if cvar_match:
|
||||
cvar_name = cvar_match.group(1)
|
||||
value = cvar_match.group(2)
|
||||
|
||||
if game_state.server_info.update_from_cvar(cvar_name, value):
|
||||
ui.update_server_info(game_state)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def handle_stats_connection(message, rcon, ui, game_state):
|
||||
"""
|
||||
Handle stats connection info extraction
|
||||
Returns (stats_port, stats_password) or (None, None)
|
||||
"""
|
||||
stats_port = None
|
||||
stats_password = None
|
||||
|
||||
# Extract stats port
|
||||
if 'net_port' in message and ' is:' in message and '"net_port"' in message:
|
||||
match = re.search(r' is:"([^"]+)"', message)
|
||||
if match:
|
||||
port_str = match.group(1).strip()
|
||||
digit_match = re.search(r'(\d+)', port_str)
|
||||
if digit_match:
|
||||
stats_port = digit_match.group(1)
|
||||
logger.info(f'Got stats port: {stats_port}')
|
||||
|
||||
# Extract stats password
|
||||
if 'zmq_stats_password' in message and ' is:' in message and '"zmq_stats_password"' in message:
|
||||
match = re.search(r' is:"([^"]+)"', message)
|
||||
if match:
|
||||
password_str = match.group(1)
|
||||
password_str = re.sub(r'\^\d', '', password_str) # Strip color codes
|
||||
stats_password = password_str.strip()
|
||||
logger.info(f'Got stats password: {stats_password}')
|
||||
|
||||
return stats_port, stats_password
|
||||
|
||||
def parse_player_events(message, game_state, ui):
|
||||
"""
|
||||
Parse connect, disconnect, kick, and rename messages
|
||||
Returns True if message should be suppressed
|
||||
"""
|
||||
try:
|
||||
# Strip color codes for matching
|
||||
from formatter import strip_color_codes
|
||||
clean_msg = strip_color_codes(message)
|
||||
|
||||
# Match connects: "NAME connected"
|
||||
connect_match = re.match(r'^(.+?)\s+connected(?:\s+with Steam ID)?', clean_msg)
|
||||
if connect_match:
|
||||
player_name = message.split(' connected')[0].strip() # Keep color codes
|
||||
if game_state.server_info.is_team_mode():
|
||||
game_state.player_tracker.update_team(player_name, 'SPECTATOR')
|
||||
game_state.player_tracker.add_player(player_name)
|
||||
ui.update_server_info(game_state)
|
||||
logger.debug(f'Player connected: {player_name}')
|
||||
return False
|
||||
|
||||
# Match disconnects: "NAME disconnected", "NAME was kicked"
|
||||
disconnect_patterns = [
|
||||
r'^(.+?)\s+disconnected',
|
||||
r'^(.+?)\s+was kicked',
|
||||
]
|
||||
|
||||
for pattern in disconnect_patterns:
|
||||
match = re.match(pattern, clean_msg)
|
||||
if match:
|
||||
player_name_clean = match.group(1).strip()
|
||||
# Try to find the original name with color codes
|
||||
original_match = re.match(pattern, message)
|
||||
player_name = original_match.group(1).strip() if original_match else player_name_clean
|
||||
|
||||
game_state.player_tracker.remove_player(player_name)
|
||||
game_state.player_tracker.remove_player(player_name_clean) # Try both
|
||||
ui.update_server_info(game_state)
|
||||
logger.debug(f'Player disconnected: {player_name}')
|
||||
return False
|
||||
|
||||
# Match renames: "OldName renamed to NewName"
|
||||
rename_match = re.match(r'^(.+?)\s+renamed to\s+(.+?)$', clean_msg)
|
||||
if rename_match:
|
||||
old_name_clean = rename_match.group(1).strip()
|
||||
new_name_clean = rename_match.group(2).strip()
|
||||
|
||||
# Get names with color codes
|
||||
original_match = re.match(r'^(.+?)\s+renamed to\s+(.+?)$', message)
|
||||
old_name = original_match.group(1).strip() if original_match else old_name_clean
|
||||
new_name = original_match.group(2).strip() if original_match else new_name_clean
|
||||
|
||||
game_state.player_tracker.rename_player(old_name, new_name)
|
||||
ui.update_server_info(game_state)
|
||||
logger.debug(f'Player renamed: {old_name} -> {new_name}')
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error in parse_player_events: {e}')
|
||||
|
||||
return False
|
||||
|
||||
def main_loop(screen):
|
||||
return False
|
||||
"""Main application loop"""
|
||||
|
||||
# Setup signal handler for Ctrl+C
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Parse arguments
|
||||
parser = argparse.ArgumentParser(description='Verbose QuakeLive server statistics')
|
||||
parser.add_argument('--host', default=DEFAULT_HOST, help=f'ZMQ URI to connect to. Defaults to {DEFAULT_HOST}')
|
||||
parser.add_argument('--password', required=False, help='RCON password')
|
||||
parser.add_argument('--identity', default=uuid.uuid1().hex, help='Socket identity (random UUID by default)')
|
||||
parser.add_argument('-v', '--verbose', action='count', default=0, help='Increase verbosity (-v INFO, -vv DEBUG)')
|
||||
parser.add_argument('--unknown-log', default='unknown_events.log', help='File to log unknown JSON events')
|
||||
parser.add_argument('-j', '--json', dest='json_log', default=None, help='File to log all JSON events')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set logging level
|
||||
if args.verbose == 0:
|
||||
logger.setLevel(logging.WARNING)
|
||||
elif args.verbose == 1:
|
||||
logger.setLevel(logging.INFO)
|
||||
else:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Setup file logging for unknown events
|
||||
unknown_handler = logging.FileHandler(args.unknown_log, mode='a')
|
||||
unknown_formatter = logging.Formatter('%(asctime)s - %(message)s', '%Y-%m-%d %H:%M:%S')
|
||||
unknown_handler.setFormatter(unknown_formatter)
|
||||
unknown_json_logger.addHandler(unknown_handler)
|
||||
unknown_json_logger.propagate = False
|
||||
|
||||
# Initialize components
|
||||
ui = UIManager(screen, args.host)
|
||||
game_state = GameState()
|
||||
|
||||
# Setup logging to output window
|
||||
log_handler = ui.setup_logging()
|
||||
logger.addHandler(log_handler)
|
||||
|
||||
# Setup input queue
|
||||
input_queue = ui.setup_input_queue()
|
||||
|
||||
# Display startup messages
|
||||
ui.print_message(f"*** QL pyCon Version {VERSION} starting ***\n")
|
||||
ui.print_message(f"zmq python bindings {zmq.__version__}, libzmq version {zmq.zmq_version()}\n")
|
||||
|
||||
# Initialize network connections
|
||||
rcon = RconConnection(args.host, args.password, args.identity)
|
||||
rcon.connect()
|
||||
|
||||
stats_conn = None
|
||||
stats_port = None
|
||||
stats_password = None
|
||||
stats_check_counter = 0
|
||||
|
||||
# Shutdown flag
|
||||
shutdown = False
|
||||
|
||||
# Setup JSON logging if requested
|
||||
json_logger = None
|
||||
if args.json_log:
|
||||
json_handler = logging.FileHandler(args.json_log, mode='a')
|
||||
json_formatter = logging.Formatter('%(asctime)s - %(message)s', '%Y-%m-%d %H:%M:%S')
|
||||
json_handler.setFormatter(json_formatter)
|
||||
all_json_logger.addHandler(json_handler)
|
||||
all_json_logger.propagate = False
|
||||
json_logger = all_json_logger
|
||||
|
||||
# Create event parser
|
||||
event_parser = EventParser(game_state, json_logger, unknown_json_logger)
|
||||
|
||||
# Main event loop
|
||||
while not shutdown:
|
||||
# Poll RCON socket
|
||||
event = rcon.poll(POLL_TIMEOUT)
|
||||
|
||||
# Check monitor for connection events
|
||||
monitor_event = rcon.check_monitor()
|
||||
if monitor_event and monitor_event[0] == zmq.EVENT_CONNECTED:
|
||||
ui.print_message("Connected to server\n")
|
||||
rcon.send_command(b'register')
|
||||
logger.info('Registration message sent')
|
||||
|
||||
ui.print_message("Requesting connection info...\n")
|
||||
rcon.send_command(b'zmq_stats_password')
|
||||
rcon.send_command(b'net_port')
|
||||
|
||||
# Handle user input
|
||||
while not input_queue.empty():
|
||||
command = input_queue.get()
|
||||
logger.info(f'Sending command: {repr(command.strip())}')
|
||||
|
||||
# Display command with timestamp
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
ui.print_message(f"^5[^7{timestamp}^5] >>> {command.strip()}^7\n")
|
||||
|
||||
rcon.send_command(command)
|
||||
|
||||
# Poll stats stream if connected
|
||||
if stats_conn and stats_conn.connected:
|
||||
stats_check_counter += 1
|
||||
|
||||
if stats_check_counter % 100 == 0:
|
||||
logger.debug(f'Stats polling active (check #{stats_check_counter // 100})')
|
||||
|
||||
stats_msg = stats_conn.recv_message()
|
||||
if stats_msg:
|
||||
logger.info(f'Stats event received ({len(stats_msg)} bytes)')
|
||||
|
||||
# Parse game event
|
||||
parsed = event_parser.parse_event(stats_msg)
|
||||
if parsed:
|
||||
# Format with timestamp before displaying
|
||||
formatted_msg, attributes = format_message(parsed)
|
||||
ui.print_message(formatted_msg)
|
||||
ui.update_server_info(game_state)
|
||||
|
||||
# Process RCON messages
|
||||
if event > 0:
|
||||
logger.debug('Socket has data available')
|
||||
msg_count = 0
|
||||
|
||||
while True:
|
||||
message = rcon.recv_message()
|
||||
if message is None:
|
||||
if msg_count > 0:
|
||||
logger.debug(f'Read {msg_count} message(s)')
|
||||
break
|
||||
|
||||
msg_count += 1
|
||||
|
||||
if len(message) == 0:
|
||||
logger.debug('Received empty message (keepalive)')
|
||||
continue
|
||||
|
||||
logger.debug(f'Received message ({len(message)} bytes): {repr(message[:100])}')
|
||||
|
||||
# Try to parse as cvar response
|
||||
if parse_cvar_response(message, game_state, ui):
|
||||
logger.debug('Suppressed cvar response')
|
||||
continue
|
||||
|
||||
# Check for player connect/disconnect/rename events
|
||||
parse_player_events(message, game_state, ui)
|
||||
|
||||
# Check for stats connection info
|
||||
port, password = handle_stats_connection(message, rcon, ui, game_state)
|
||||
if port:
|
||||
stats_port = port
|
||||
if password:
|
||||
stats_password = password
|
||||
|
||||
# Connect to stats if we have both credentials
|
||||
if stats_port and stats_password and stats_conn is None:
|
||||
try:
|
||||
ui.print_message("Connecting to stats stream...\n")
|
||||
host_ip = args.host.split('//')[1].split(':')[0]
|
||||
|
||||
stats_conn = StatsConnection(host_ip, stats_port, stats_password)
|
||||
stats_conn.connect()
|
||||
|
||||
ui.print_message("Stats stream connected - ready for game events\n")
|
||||
|
||||
# Request initial server info
|
||||
logger.info('Sending initial server info queries')
|
||||
rcon.send_command(b'qlx_serverBrandName')
|
||||
rcon.send_command(b'g_factoryTitle')
|
||||
rcon.send_command(b'mapname')
|
||||
rcon.send_command(b'timelimit')
|
||||
rcon.send_command(b'fraglimit')
|
||||
rcon.send_command(b'roundlimit')
|
||||
rcon.send_command(b'capturelimit')
|
||||
rcon.send_command(b'sv_maxclients')
|
||||
|
||||
if args.json_log:
|
||||
ui.print_message(f"*** JSON capture enabled: {args.json_log} ***\n")
|
||||
|
||||
except Exception as e:
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
ui.print_message(f"^1[^7{timestamp}^1] Error: Stats connection failed: {e}^7\n")
|
||||
logger.error(f'Stats connection failed: {e}')
|
||||
|
||||
# Try to parse as game event
|
||||
parsed_event = event_parser.parse_event(message)
|
||||
if parsed_event:
|
||||
ui.print_message(parsed_event)
|
||||
continue
|
||||
|
||||
# Check if it looks like JSON but wasn't parsed
|
||||
stripped = message.strip()
|
||||
if stripped and stripped[0] in ('{', '['):
|
||||
logger.debug('Unparsed JSON event')
|
||||
continue
|
||||
|
||||
# Try powerup message formatting
|
||||
powerup_msg = format_powerup_message(message, game_state.player_tracker)
|
||||
if powerup_msg:
|
||||
ui.print_message(powerup_msg)
|
||||
continue
|
||||
|
||||
# Filter bot debug messages in default mode
|
||||
is_bot_debug = (' entered ' in message and
|
||||
any(x in message for x in [' seek ', ' battle ', ' chase', ' fight']))
|
||||
if is_bot_debug and args.verbose == 0:
|
||||
logger.debug(f'Filtered bot debug: {message[:50]}')
|
||||
continue
|
||||
|
||||
# Check if it's a chat message
|
||||
if ':' in message and not message.startswith(('print', 'broadcast', 'zmq')):
|
||||
message = format_chat_message(message, game_state.player_tracker)
|
||||
|
||||
# Format and display message
|
||||
formatted_msg, attributes = format_message(message)
|
||||
ui.print_message(formatted_msg)
|
||||
|
||||
def main_loop(screen):
|
||||
"""Main application loop"""
|
||||
|
||||
# Setup signal handler for Ctrl+C
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Parse arguments
|
||||
parser = argparse.ArgumentParser(description='Verbose QuakeLive server statistics')
|
||||
parser.add_argument('--host', default=DEFAULT_HOST, help=f'ZMQ URI to connect to. Defaults to {DEFAULT_HOST}')
|
||||
parser.add_argument('--password', required=False, help='RCON password')
|
||||
parser.add_argument('--identity', default=uuid.uuid1().hex, help='Socket identity (random UUID by default)')
|
||||
parser.add_argument('-v', '--verbose', action='count', default=0, help='Increase verbosity (-v INFO, -vv DEBUG)')
|
||||
parser.add_argument('--unknown-log', default='unknown_events.log', help='File to log unknown JSON events')
|
||||
parser.add_argument('-j', '--json', dest='json_log', default=None, help='File to log all JSON events')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set logging level
|
||||
if args.verbose == 0:
|
||||
logger.setLevel(logging.WARNING)
|
||||
elif args.verbose == 1:
|
||||
logger.setLevel(logging.INFO)
|
||||
else:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Setup file logging for unknown events
|
||||
unknown_handler = logging.FileHandler(args.unknown_log, mode='a')
|
||||
unknown_formatter = logging.Formatter('%(asctime)s - %(message)s', '%Y-%m-%d %H:%M:%S')
|
||||
unknown_handler.setFormatter(unknown_formatter)
|
||||
unknown_json_logger.addHandler(unknown_handler)
|
||||
unknown_json_logger.propagate = False
|
||||
|
||||
# Initialize components
|
||||
ui = UIManager(screen, args.host)
|
||||
game_state = GameState()
|
||||
|
||||
# Setup logging to output window
|
||||
log_handler = ui.setup_logging()
|
||||
logger.addHandler(log_handler)
|
||||
|
||||
# Setup input queue
|
||||
input_queue = ui.setup_input_queue()
|
||||
|
||||
# Display startup messages
|
||||
ui.print_message(f"*** QL pyCon Version {VERSION} starting ***\n")
|
||||
ui.print_message(f"zmq python bindings {zmq.__version__}, libzmq version {zmq.zmq_version()}\n")
|
||||
|
||||
# Initialize network connections
|
||||
rcon = RconConnection(args.host, args.password, args.identity)
|
||||
rcon.connect()
|
||||
|
||||
stats_conn = None
|
||||
stats_port = None
|
||||
stats_password = None
|
||||
stats_check_counter = 0
|
||||
|
||||
# Shutdown flag
|
||||
shutdown = False
|
||||
|
||||
# Setup JSON logging if requested
|
||||
json_logger = None
|
||||
if args.json_log:
|
||||
json_handler = logging.FileHandler(args.json_log, mode='a')
|
||||
json_formatter = logging.Formatter('%(asctime)s - %(message)s', '%Y-%m-%d %H:%M:%S')
|
||||
json_handler.setFormatter(json_formatter)
|
||||
all_json_logger.addHandler(json_handler)
|
||||
all_json_logger.propagate = False
|
||||
json_logger = all_json_logger
|
||||
|
||||
# Create event parser
|
||||
event_parser = EventParser(game_state, json_logger, unknown_json_logger)
|
||||
|
||||
# Main event loop
|
||||
while not shutdown:
|
||||
# Poll RCON socket
|
||||
event = rcon.poll(POLL_TIMEOUT)
|
||||
|
||||
# Check monitor for connection events
|
||||
monitor_event = rcon.check_monitor()
|
||||
if monitor_event and monitor_event[0] == zmq.EVENT_CONNECTED:
|
||||
ui.print_message("Connected to server\n")
|
||||
rcon.send_command(b'register')
|
||||
logger.info('Registration message sent')
|
||||
|
||||
ui.print_message("Requesting connection info...\n")
|
||||
rcon.send_command(b'zmq_stats_password')
|
||||
rcon.send_command(b'net_port')
|
||||
|
||||
# Handle user input
|
||||
while not input_queue.empty():
|
||||
command = input_queue.get()
|
||||
logger.info(f'Sending command: {repr(command.strip())}')
|
||||
|
||||
# Display command with timestamp
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
ui.print_message(f"^5[^7{timestamp}^5] >>> {command.strip()}^7\n")
|
||||
|
||||
rcon.send_command(command)
|
||||
|
||||
# Poll stats stream if connected
|
||||
if stats_conn and stats_conn.connected:
|
||||
stats_check_counter += 1
|
||||
|
||||
if stats_check_counter % 100 == 0:
|
||||
logger.debug(f'Stats polling active (check #{stats_check_counter // 100})')
|
||||
|
||||
stats_msg = stats_conn.recv_message()
|
||||
if stats_msg:
|
||||
logger.info(f'Stats event received ({len(stats_msg)} bytes)')
|
||||
|
||||
# Parse game event
|
||||
parsed = event_parser.parse_event(stats_msg)
|
||||
if parsed:
|
||||
# Format with timestamp before displaying
|
||||
formatted_msg, attributes = format_message(parsed)
|
||||
ui.print_message(formatted_msg)
|
||||
ui.update_server_info(game_state)
|
||||
|
||||
# Process RCON messages
|
||||
if event > 0:
|
||||
logger.debug('Socket has data available')
|
||||
msg_count = 0
|
||||
|
||||
while True:
|
||||
message = rcon.recv_message()
|
||||
if message is None:
|
||||
if msg_count > 0:
|
||||
logger.debug(f'Read {msg_count} message(s)')
|
||||
break
|
||||
|
||||
msg_count += 1
|
||||
|
||||
if len(message) == 0:
|
||||
logger.debug('Received empty message (keepalive)')
|
||||
continue
|
||||
|
||||
logger.debug(f'Received message ({len(message)} bytes): {repr(message[:100])}')
|
||||
|
||||
# Try to parse as cvar response
|
||||
if parse_cvar_response(message, game_state, ui):
|
||||
logger.debug('Suppressed cvar response')
|
||||
continue
|
||||
|
||||
# Check for player connect/disconnect/rename events
|
||||
parse_player_events(message, game_state, ui)
|
||||
|
||||
# Check for stats connection info
|
||||
port, password = handle_stats_connection(message, rcon, ui, game_state)
|
||||
if port:
|
||||
stats_port = port
|
||||
if password:
|
||||
stats_password = password
|
||||
|
||||
# Connect to stats if we have both credentials
|
||||
if stats_port and stats_password and stats_conn is None:
|
||||
try:
|
||||
ui.print_message("Connecting to stats stream...\n")
|
||||
host_ip = args.host.split('//')[1].split(':')[0]
|
||||
|
||||
stats_conn = StatsConnection(host_ip, stats_port, stats_password)
|
||||
stats_conn.connect()
|
||||
|
||||
ui.print_message("Stats stream connected - ready for game events\n")
|
||||
|
||||
# Request initial server info
|
||||
logger.info('Sending initial server info queries')
|
||||
rcon.send_command(b'qlx_serverBrandName')
|
||||
rcon.send_command(b'g_factoryTitle')
|
||||
rcon.send_command(b'mapname')
|
||||
rcon.send_command(b'timelimit')
|
||||
rcon.send_command(b'fraglimit')
|
||||
rcon.send_command(b'roundlimit')
|
||||
rcon.send_command(b'capturelimit')
|
||||
rcon.send_command(b'sv_maxclients')
|
||||
|
||||
if args.json_log:
|
||||
ui.print_message(f"*** JSON capture enabled: {args.json_log} ***\n")
|
||||
|
||||
except Exception as e:
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
ui.print_message(f"^1[^7{timestamp}^1] Error: Stats connection failed: {e}^7\n")
|
||||
logger.error(f'Stats connection failed: {e}')
|
||||
|
||||
# Try to parse as game event
|
||||
parsed_event = event_parser.parse_event(message)
|
||||
if parsed_event:
|
||||
ui.print_message(parsed_event)
|
||||
continue
|
||||
|
||||
# Check if it looks like JSON but wasn't parsed
|
||||
stripped = message.strip()
|
||||
if stripped and stripped[0] in ('{', '['):
|
||||
logger.debug('Unparsed JSON event')
|
||||
continue
|
||||
|
||||
# Try powerup message formatting
|
||||
powerup_msg = format_powerup_message(message, game_state.player_tracker)
|
||||
if powerup_msg:
|
||||
ui.print_message(powerup_msg)
|
||||
continue
|
||||
|
||||
# Filter bot debug messages in default mode
|
||||
is_bot_debug = (' entered ' in message and
|
||||
any(x in message for x in [' seek ', ' battle ', ' chase', ' fight']))
|
||||
if is_bot_debug and args.verbose == 0:
|
||||
logger.debug(f'Filtered bot debug: {message[:50]}')
|
||||
continue
|
||||
|
||||
# Check if it's a chat message
|
||||
if ':' in message and not message.startswith(('print', 'broadcast', 'zmq')):
|
||||
message = format_chat_message(message, game_state.player_tracker)
|
||||
|
||||
# Format and display message
|
||||
formatted_msg, attributes = format_message(message)
|
||||
ui.print_message(formatted_msg)
|
||||
|
||||
if __name__ == '__main__':
|
||||
curses.wrapper(main_loop)
|
||||
167
network.py
Normal file
167
network.py
Normal file
@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ZMQ network layer for QLPyCon
|
||||
Handles RCON and stats stream connections
|
||||
"""
|
||||
|
||||
import zmq
|
||||
import struct
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger('network')
|
||||
|
||||
|
||||
def read_socket_event(msg):
|
||||
"""Parse ZMQ socket monitor event"""
|
||||
event_id = struct.unpack('<H', msg[:2])[0]
|
||||
event_names = {
|
||||
zmq.EVENT_ACCEPTED: 'EVENT_ACCEPTED',
|
||||
zmq.EVENT_ACCEPT_FAILED: 'EVENT_ACCEPT_FAILED',
|
||||
zmq.EVENT_BIND_FAILED: 'EVENT_BIND_FAILED',
|
||||
zmq.EVENT_CLOSED: 'EVENT_CLOSED',
|
||||
zmq.EVENT_CLOSE_FAILED: 'EVENT_CLOSE_FAILED',
|
||||
zmq.EVENT_CONNECTED: 'EVENT_CONNECTED',
|
||||
zmq.EVENT_CONNECT_DELAYED: 'EVENT_CONNECT_DELAYED',
|
||||
zmq.EVENT_CONNECT_RETRIED: 'EVENT_CONNECT_RETRIED',
|
||||
zmq.EVENT_DISCONNECTED: 'EVENT_DISCONNECTED',
|
||||
zmq.EVENT_LISTENING: 'EVENT_LISTENING',
|
||||
zmq.EVENT_MONITOR_STOPPED: 'EVENT_MONITOR_STOPPED',
|
||||
}
|
||||
event_name = event_names.get(event_id, f'{event_id}')
|
||||
event_value = struct.unpack('<I', msg[2:])[0]
|
||||
return (event_id, event_name, event_value)
|
||||
|
||||
|
||||
def check_monitor(monitor):
|
||||
"""Check monitor socket for events"""
|
||||
try:
|
||||
event_monitor = monitor.recv(zmq.NOBLOCK)
|
||||
except zmq.Again:
|
||||
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}')
|
||||
return (event_id, event_value)
|
||||
|
||||
|
||||
class RconConnection:
|
||||
"""RCON connection to Quake Live server"""
|
||||
|
||||
def __init__(self, host, password, identity):
|
||||
self.host = host
|
||||
self.password = password
|
||||
self.identity = identity
|
||||
self.context = None
|
||||
self.socket = None
|
||||
self.monitor = None
|
||||
|
||||
def connect(self):
|
||||
"""Initialize connection"""
|
||||
logger.info('Initializing ZMQ context...')
|
||||
self.context = zmq.Context()
|
||||
|
||||
logger.info('Creating DEALER socket...')
|
||||
self.socket = self.context.socket(zmq.DEALER)
|
||||
|
||||
logger.info('Setting up socket monitor...')
|
||||
self.monitor = self.socket.get_monitor_socket(zmq.EVENT_ALL)
|
||||
|
||||
if self.password:
|
||||
logger.info('Setting password for access')
|
||||
self.socket.plain_username = b'rcon'
|
||||
self.socket.plain_password = self.password.encode('utf-8')
|
||||
self.socket.zap_domain = b'rcon'
|
||||
|
||||
logger.info(f'Setting socket identity: {self.identity}')
|
||||
self.socket.setsockopt(zmq.IDENTITY, self.identity.encode('utf-8'))
|
||||
|
||||
self.socket.connect(self.host)
|
||||
logger.info('Connection initiated, waiting for events...')
|
||||
|
||||
def send_command(self, command):
|
||||
"""Send RCON command"""
|
||||
if isinstance(command, str):
|
||||
command = command.encode('utf-8')
|
||||
self.socket.send(command)
|
||||
logger.info(f'Sent command: {command}')
|
||||
|
||||
def poll(self, timeout):
|
||||
"""Poll for messages"""
|
||||
return self.socket.poll(timeout)
|
||||
|
||||
def recv_message(self):
|
||||
"""Receive a message (non-blocking)"""
|
||||
try:
|
||||
return self.socket.recv(zmq.NOBLOCK).decode('utf-8', errors='replace')
|
||||
except zmq.error.Again:
|
||||
return None
|
||||
|
||||
def check_monitor(self):
|
||||
"""Check monitor for events"""
|
||||
return check_monitor(self.monitor)
|
||||
|
||||
def close(self):
|
||||
"""Close connection"""
|
||||
if self.socket:
|
||||
self.socket.setsockopt(zmq.LINGER, 0) # Don't wait for unsent messages
|
||||
self.socket.close()
|
||||
if self.context:
|
||||
self.context.term()
|
||||
|
||||
|
||||
class StatsConnection:
|
||||
"""Stats stream connection (ZMQ SUB socket)"""
|
||||
|
||||
def __init__(self, host, port, password):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.password = password
|
||||
self.context = None
|
||||
self.socket = None
|
||||
self.connected = False
|
||||
|
||||
def connect(self):
|
||||
"""Connect to stats stream"""
|
||||
stats_host = f'tcp://{self.host}:{self.port}'
|
||||
logger.info(f'Connecting to stats stream: {stats_host}')
|
||||
|
||||
self.context = zmq.Context()
|
||||
self.socket = self.context.socket(zmq.SUB)
|
||||
logger.debug('Stats socket created (SUB type)')
|
||||
|
||||
if self.password and self.password.strip():
|
||||
logger.debug('Setting PLAIN authentication')
|
||||
self.socket.setsockopt(zmq.PLAIN_USERNAME, b'stats')
|
||||
self.socket.setsockopt(zmq.PLAIN_PASSWORD, self.password.encode('utf-8'))
|
||||
self.socket.setsockopt_string(zmq.ZAP_DOMAIN, 'stats')
|
||||
|
||||
logger.debug(f'Connecting to {stats_host}')
|
||||
self.socket.connect(stats_host)
|
||||
|
||||
logger.debug('Setting ZMQ_SUBSCRIBE to empty (all messages)')
|
||||
self.socket.setsockopt(zmq.SUBSCRIBE, b'')
|
||||
|
||||
time.sleep(0.5)
|
||||
self.connected = True
|
||||
logger.info('Stats stream connected')
|
||||
|
||||
def recv_message(self):
|
||||
"""Receive stats message (non-blocking)"""
|
||||
if not self.connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
msg = self.socket.recv(zmq.NOBLOCK)
|
||||
return msg.decode('utf-8', errors='replace')
|
||||
except zmq.error.Again:
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""Close connection"""
|
||||
if self.socket:
|
||||
self.socket.setsockopt(zmq.LINGER, 0) # Don't wait for unsent messages
|
||||
self.socket.close()
|
||||
if self.context:
|
||||
self.context.term()
|
||||
247
parser.py
Normal file
247
parser.py
Normal file
@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JSON game event parsing for QLPyCon
|
||||
Parses events from Quake Live stats stream
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from config import WEAPON_NAMES, WEAPON_KILL_NAMES, DEATH_MESSAGES
|
||||
from formatter import get_team_prefix, strip_color_codes
|
||||
|
||||
logger = logging.getLogger('parser')
|
||||
|
||||
|
||||
def calculate_weapon_accuracies(weapon_data):
|
||||
"""Calculate accuracy percentages for all weapons"""
|
||||
accuracies = {}
|
||||
for weapon, stats in weapon_data.items():
|
||||
shots_fired = int(stats.get('S', 0))
|
||||
shots_hit = int(stats.get('H', 0))
|
||||
accuracy = shots_hit / shots_fired if shots_fired > 0 else 0
|
||||
accuracies[weapon] = accuracy
|
||||
return accuracies
|
||||
|
||||
|
||||
class EventParser:
|
||||
"""Parses JSON game events into formatted messages"""
|
||||
|
||||
def __init__(self, game_state, json_logger=None, unknown_logger=None):
|
||||
self.game_state = game_state
|
||||
self.json_logger = json_logger
|
||||
self.unknown_logger = unknown_logger
|
||||
|
||||
def parse_event(self, message):
|
||||
"""
|
||||
Parse JSON event and return formatted message string
|
||||
Returns None if event should not be displayed
|
||||
"""
|
||||
try:
|
||||
event = json.loads(message)
|
||||
|
||||
# Log all JSON if logger is configured
|
||||
if self.json_logger:
|
||||
self.json_logger.info('JSON Event received:')
|
||||
self.json_logger.info(json.dumps(event, indent=2))
|
||||
self.json_logger.info('---')
|
||||
|
||||
if 'TYPE' not in event or 'DATA' not in event:
|
||||
logger.debug('JSON missing TYPE or DATA')
|
||||
return None
|
||||
|
||||
event_type = event['TYPE']
|
||||
data = event['DATA']
|
||||
|
||||
# Route to appropriate handler
|
||||
handler_map = {
|
||||
'PLAYER_SWITCHTEAM': self._handle_switchteam,
|
||||
'PLAYER_DEATH': self._handle_death,
|
||||
'PLAYER_KILL': self._handle_death, # Same handler
|
||||
'PLAYER_MEDAL': self._handle_medal,
|
||||
'MATCH_STARTED': self._handle_match_started,
|
||||
'MATCH_REPORT': self._handle_match_report,
|
||||
'PLAYER_STATS': self._handle_player_stats,
|
||||
'PLAYER_CONNECT': lambda d: None,
|
||||
'PLAYER_DISCONNECT': lambda d: None,
|
||||
'ROUND_OVER': lambda d: None,
|
||||
}
|
||||
|
||||
handler = handler_map.get(event_type)
|
||||
if handler:
|
||||
return handler(data)
|
||||
else:
|
||||
# Unknown event
|
||||
logger.debug(f'Unknown event type: {event_type}')
|
||||
if self.unknown_logger:
|
||||
self.unknown_logger.info(f'Unknown event type: {event_type}')
|
||||
self.unknown_logger.info(f'Full JSON: {json.dumps(event, indent=2)}')
|
||||
return None
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug(f'JSON decode error: {e}')
|
||||
return None
|
||||
except (KeyError, TypeError) as e:
|
||||
logger.debug(f'Error parsing event: {e}')
|
||||
return None
|
||||
|
||||
def _handle_switchteam(self, data):
|
||||
"""Handle PLAYER_SWITCHTEAM event"""
|
||||
if 'KILLER' not in data:
|
||||
return None
|
||||
|
||||
killer = data['KILLER']
|
||||
name = killer.get('NAME', 'Unknown')
|
||||
team = killer.get('TEAM', '')
|
||||
old_team = killer.get('OLD_TEAM', '')
|
||||
|
||||
# Update player team
|
||||
self.game_state.player_tracker.update_team(name, team)
|
||||
self.game_state.player_tracker.add_player(name)
|
||||
|
||||
if team == old_team:
|
||||
return None
|
||||
|
||||
warmup = " ^3(warmup)" if data.get('WARMUP', False) else ""
|
||||
|
||||
team_messages = {
|
||||
'FREE': ' ^7joined the ^2Fight^7',
|
||||
'SPECTATOR': ' ^7joined the ^3Spectators^7',
|
||||
'RED': ' ^7joined the ^1Red ^7team',
|
||||
'BLUE': ' ^7joined the ^4Blue ^7team'
|
||||
}
|
||||
|
||||
old_team_messages = {
|
||||
'FREE': 'the ^2Fight^7',
|
||||
'SPECTATOR': 'the ^3Spectators^7',
|
||||
'RED': '^7the ^1Red ^7team',
|
||||
'BLUE': '^7the ^4Blue ^7team'
|
||||
}
|
||||
|
||||
team_msg = team_messages.get(team, f' ^7joined team {team}^7')
|
||||
old_team_msg = old_team_messages.get(old_team, f'team {old_team}')
|
||||
|
||||
team_prefix = get_team_prefix(name, self.game_state.player_tracker)
|
||||
return f"{team_prefix}{name}{team_msg} from {old_team_msg}{warmup}\n"
|
||||
|
||||
def _handle_death(self, data):
|
||||
"""Handle PLAYER_DEATH and PLAYER_KILL events"""
|
||||
if 'VICTIM' not in data:
|
||||
return None
|
||||
|
||||
victim = data['VICTIM']
|
||||
victim_name = victim.get('NAME', 'Unknown')
|
||||
|
||||
# Check for duplicate
|
||||
time_val = data.get('TIME', 0)
|
||||
killer_name = data.get('KILLER', {}).get('NAME', '') if data.get('KILLER') else ''
|
||||
|
||||
if self.game_state.event_deduplicator.is_duplicate('PLAYER_DEATH', time_val, killer_name, victim_name):
|
||||
return None
|
||||
|
||||
# Update victim team
|
||||
if 'TEAM' in victim:
|
||||
self.game_state.player_tracker.update_team(victim_name, victim['TEAM'])
|
||||
self.game_state.player_tracker.add_player(victim_name)
|
||||
|
||||
victim_prefix = get_team_prefix(victim_name, self.game_state.player_tracker)
|
||||
warmup = " ^3(warmup)" if data.get('WARMUP', False) else ""
|
||||
|
||||
# Environmental death (no killer)
|
||||
if 'KILLER' not in data or not data['KILLER']:
|
||||
mod = data.get('MOD', 'UNKNOWN')
|
||||
msg_template = DEATH_MESSAGES.get(mod, "%s%s ^1DIED FROM %s^7")
|
||||
|
||||
if mod in DEATH_MESSAGES:
|
||||
msg = msg_template % (victim_prefix, victim_name)
|
||||
else:
|
||||
msg = msg_template % (victim_prefix, victim_name, mod)
|
||||
|
||||
return f"{msg}{warmup}\n"
|
||||
|
||||
# Player killed by another player
|
||||
killer = data['KILLER']
|
||||
killer_name = killer.get('NAME', 'Unknown')
|
||||
|
||||
# Update killer team
|
||||
if 'TEAM' in killer:
|
||||
self.game_state.player_tracker.update_team(killer_name, killer['TEAM'])
|
||||
self.game_state.player_tracker.add_player(killer_name)
|
||||
|
||||
killer_prefix = get_team_prefix(killer_name, self.game_state.player_tracker)
|
||||
|
||||
# Suicide
|
||||
if killer_name == victim_name:
|
||||
weapon = killer.get('WEAPON', 'OTHER_WEAPON')
|
||||
if weapon != 'OTHER_WEAPON':
|
||||
weapon_name = WEAPON_NAMES.get(weapon, weapon)
|
||||
return f"{killer_prefix}{killer_name} ^7committed suicide with the ^7{weapon_name}{warmup}\n"
|
||||
return None
|
||||
|
||||
# Regular kill
|
||||
weapon = killer.get('WEAPON', 'UNKNOWN')
|
||||
weapon_name = WEAPON_KILL_NAMES.get(weapon, f'the {weapon}')
|
||||
|
||||
return f"{killer_prefix}{killer_name} ^7fragged^7 {victim_prefix}{victim_name} ^7with {weapon_name}{warmup}\n"
|
||||
|
||||
def _handle_medal(self, data):
|
||||
"""Handle PLAYER_MEDAL event"""
|
||||
name = data.get('NAME', 'Unknown')
|
||||
medal = data.get('MEDAL', 'UNKNOWN')
|
||||
warmup = " ^3(warmup)^7" if data.get('WARMUP', False) else ""
|
||||
|
||||
team_prefix = get_team_prefix(name, self.game_state.player_tracker)
|
||||
return f"{team_prefix}{name} ^7got a medal: ^6{medal}{warmup}\n"
|
||||
|
||||
def _handle_match_started(self, data):
|
||||
"""Handle MATCH_STARTED event"""
|
||||
if self.game_state.server_info.is_team_mode():
|
||||
return None
|
||||
|
||||
players = []
|
||||
for player in data.get('PLAYERS', []):
|
||||
name = player.get('NAME', 'Unknown')
|
||||
players.append(name)
|
||||
|
||||
if players:
|
||||
formatted = " vs. ".join(players)
|
||||
return f"Match has started - {formatted}\n"
|
||||
|
||||
return None
|
||||
|
||||
def _handle_match_report(self, data):
|
||||
"""Handle MATCH_REPORT event"""
|
||||
if not self.game_state.server_info.is_team_mode():
|
||||
return None
|
||||
|
||||
red_score = int(data.get('TSCORE0', '0'))
|
||||
blue_score = int(data.get('TSCORE1', '0'))
|
||||
|
||||
if red_score > blue_score:
|
||||
return f"^1RED TEAM ^7WINS by a score of {red_score} to {blue_score}\n"
|
||||
elif blue_score > red_score:
|
||||
return f"^4BLUE TEAM ^7WINS by a score of {blue_score} to {red_score}\n"
|
||||
else:
|
||||
return f"^7The match is a TIE with a score of {red_score} to {blue_score}\n"
|
||||
|
||||
def _handle_player_stats(self, data):
|
||||
"""Handle PLAYER_STATS event"""
|
||||
name = data.get('NAME', 'Unknown')
|
||||
team_prefix = get_team_prefix(name, self.game_state.player_tracker)
|
||||
|
||||
kills = int(data.get('KILLS', '0'))
|
||||
deaths = int(data.get('DEATHS', '0'))
|
||||
|
||||
weapon_data = data.get('WEAPONS', {})
|
||||
accuracies = calculate_weapon_accuracies(weapon_data)
|
||||
|
||||
if not accuracies:
|
||||
return None
|
||||
|
||||
best_weapon = max(accuracies, key=accuracies.get)
|
||||
best_accuracy = accuracies[best_weapon] * 100
|
||||
weapon_stats = weapon_data.get(best_weapon, {})
|
||||
best_weapon_kills = int(weapon_stats.get('K', 0))
|
||||
|
||||
weapon_name = WEAPON_NAMES.get(best_weapon, best_weapon)
|
||||
|
||||
return f"^7{team_prefix}{name} K/D: {kills}/{deaths} | Best Weapon: {weapon_name} - Acc: {best_accuracy:.2f}% - Kills: {best_weapon_kills}\n"
|
||||
180
state.py
Normal file
180
state.py
Normal file
@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Game state management for QLPyCon
|
||||
Tracks server info, players, and teams
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from config import TEAM_MODES, TEAM_MAP, MAX_RECENT_EVENTS
|
||||
|
||||
logger = logging.getLogger('state')
|
||||
|
||||
|
||||
class ServerInfo:
|
||||
"""Tracks current server information"""
|
||||
|
||||
def __init__(self):
|
||||
self.hostname = 'Unknown'
|
||||
self.map = 'Unknown'
|
||||
self.gametype = 'Unknown'
|
||||
self.timelimit = '0'
|
||||
self.fraglimit = '0'
|
||||
self.roundlimit = '0'
|
||||
self.capturelimit = '0'
|
||||
self.maxclients = '0'
|
||||
self.curclients = '0'
|
||||
self.red_score = 0
|
||||
self.blue_score = 0
|
||||
self.players = []
|
||||
self.last_update = 0
|
||||
|
||||
def is_team_mode(self):
|
||||
"""Check if current gametype is a team mode"""
|
||||
return self.gametype in TEAM_MODES
|
||||
|
||||
def update_from_cvar(self, cvar_name, value):
|
||||
"""Update server info from a cvar response"""
|
||||
# Normalize cvar name to lowercase for case-insensitive matching
|
||||
cvar_lower = cvar_name.lower()
|
||||
|
||||
mapping = {
|
||||
'qlx_serverbrandname': 'hostname',
|
||||
'g_factorytitle': 'gametype',
|
||||
'mapname': 'map',
|
||||
'timelimit': 'timelimit',
|
||||
'fraglimit': 'fraglimit',
|
||||
'roundlimit': 'roundlimit',
|
||||
'capturelimit': 'capturelimit',
|
||||
'sv_maxclients': 'maxclients'
|
||||
}
|
||||
|
||||
attr = mapping.get(cvar_lower)
|
||||
if attr:
|
||||
# Only strip color codes for non-hostname fields
|
||||
if attr != 'hostname':
|
||||
value = re.sub(r'\^\d', '', value)
|
||||
setattr(self, attr, value)
|
||||
logger.info(f'Updated {attr}: {value}')
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PlayerTracker:
|
||||
"""Tracks player teams and information"""
|
||||
|
||||
def __init__(self, server_info):
|
||||
self.server_info = server_info
|
||||
self.player_teams = {}
|
||||
|
||||
def update_team(self, name, team):
|
||||
"""Update player team. Team can be int or string"""
|
||||
if not self.server_info.is_team_mode():
|
||||
return
|
||||
|
||||
# Convert numeric team to string
|
||||
if isinstance(team, int):
|
||||
team = TEAM_MAP.get(team, 'FREE')
|
||||
|
||||
if team not in ['RED', 'BLUE', 'FREE', 'SPECTATOR']:
|
||||
team = 'FREE'
|
||||
|
||||
# Store both original name and color-stripped version
|
||||
self.player_teams[name] = team
|
||||
clean_name = re.sub(r'\^\d', '', name)
|
||||
if clean_name != name:
|
||||
self.player_teams[clean_name] = team
|
||||
|
||||
logger.debug(f'Updated team for {name} (clean: {clean_name}): {team}')
|
||||
|
||||
def get_team(self, name):
|
||||
"""Get player's team"""
|
||||
return self.player_teams.get(name)
|
||||
|
||||
def add_player(self, name, score='0', ping='0'):
|
||||
"""Add player to server's player list if not exists"""
|
||||
if not any(p['name'] == name for p in self.server_info.players):
|
||||
self.server_info.players.append({
|
||||
'name': name,
|
||||
'score': score,
|
||||
'ping': ping
|
||||
})
|
||||
|
||||
def get_players_by_team(self):
|
||||
"""Get players organized by team"""
|
||||
teams = {'RED': [], 'BLUE': [], 'SPECTATOR': [], 'FREE': []}
|
||||
for player in self.server_info.players:
|
||||
name = player['name']
|
||||
team = self.player_teams.get(name, 'FREE')
|
||||
if team not in teams:
|
||||
team = 'FREE'
|
||||
teams[team].append(name)
|
||||
return teams
|
||||
|
||||
def remove_player(self, name):
|
||||
"""Remove player from tracking"""
|
||||
clean_name = re.sub(r'\^\d', '', name)
|
||||
|
||||
# Remove from player list (check both name and clean name)
|
||||
self.server_info.players = [
|
||||
p for p in self.server_info.players
|
||||
if p['name'] != name and re.sub(r'\^\d', '', p['name']) != clean_name
|
||||
]
|
||||
|
||||
# Remove from team tracking
|
||||
if name in self.player_teams:
|
||||
del self.player_teams[name]
|
||||
if clean_name in self.player_teams and clean_name != name:
|
||||
del self.player_teams[clean_name]
|
||||
|
||||
logger.debug(f'Removed player: {name} (clean: {clean_name})')
|
||||
|
||||
def rename_player(self, old_name, new_name):
|
||||
"""Rename a player while maintaining their team"""
|
||||
# Get current team
|
||||
team = self.player_teams.get(old_name, 'SPECTATOR')
|
||||
|
||||
# Remove old entries
|
||||
self.remove_player(old_name)
|
||||
|
||||
# Add with new name and team
|
||||
if self.server_info.is_team_mode():
|
||||
self.update_team(new_name, team)
|
||||
self.add_player(new_name)
|
||||
|
||||
logger.debug(f'Renamed player: {old_name} -> {new_name}')
|
||||
|
||||
class EventDeduplicator:
|
||||
"""Prevents duplicate kill/death events"""
|
||||
|
||||
def __init__(self):
|
||||
self.recent_events = []
|
||||
|
||||
def is_duplicate(self, event_type, time_val, killer_name, victim_name):
|
||||
"""Check if this kill/death event is a duplicate"""
|
||||
if event_type not in ('PLAYER_DEATH', 'PLAYER_KILL'):
|
||||
return False
|
||||
|
||||
signature = f"KILL:{time_val}:{killer_name}:{victim_name}"
|
||||
|
||||
if signature in self.recent_events:
|
||||
logger.debug(f'Duplicate event: {signature}')
|
||||
return True
|
||||
|
||||
# Add to recent events
|
||||
self.recent_events.append(signature)
|
||||
if len(self.recent_events) > MAX_RECENT_EVENTS:
|
||||
self.recent_events.pop(0)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class GameState:
|
||||
"""Main game state container"""
|
||||
|
||||
def __init__(self):
|
||||
self.server_info = ServerInfo()
|
||||
self.player_tracker = PlayerTracker(self.server_info)
|
||||
self.event_deduplicator = EventDeduplicator()
|
||||
self.pending_background_commands = set()
|
||||
self.status_buffer = []
|
||||
258
ui.py
Normal file
258
ui.py
Normal file
@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Curses-based UI for QLPyCon
|
||||
Handles terminal display, windows, and color rendering
|
||||
"""
|
||||
|
||||
import curses
|
||||
import curses.textpad
|
||||
import threading
|
||||
import queue
|
||||
import logging
|
||||
from config import COLOR_PAIRS, INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, TEAM_MODES
|
||||
|
||||
logger = logging.getLogger('ui')
|
||||
|
||||
|
||||
class CursesHandler(logging.Handler):
|
||||
"""Logging handler that outputs to curses window"""
|
||||
|
||||
def __init__(self, window):
|
||||
logging.Handler.__init__(self)
|
||||
self.window = window
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
msg = self.format(record)
|
||||
fs = "%s\n"
|
||||
try:
|
||||
print_colored(self.window, fs % msg, 0)
|
||||
self.window.refresh()
|
||||
except UnicodeError:
|
||||
print_colored(self.window, fs % msg.encode("UTF-8"), 0)
|
||||
self.window.refresh()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
raise
|
||||
except:
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
def print_colored(window, message, attributes=0):
|
||||
"""
|
||||
Print message with Quake color codes (^N)
|
||||
^0 = black, ^1 = red, ^2 = green, ^3 = yellow, ^4 = blue, ^5 = cyan, ^6 = magenta, ^7 = white
|
||||
"""
|
||||
if not curses.has_colors:
|
||||
window.addstr(message)
|
||||
return
|
||||
|
||||
color = 0
|
||||
parse_color = False
|
||||
|
||||
for ch in message:
|
||||
val = ord(ch)
|
||||
if parse_color:
|
||||
if ord('0') <= val <= ord('7'):
|
||||
color = val - ord('0')
|
||||
if color == 7:
|
||||
color = 0
|
||||
else:
|
||||
window.addch('^', curses.color_pair(color) | attributes)
|
||||
window.addch(ch, curses.color_pair(color) | attributes)
|
||||
parse_color = False
|
||||
elif ch == '^':
|
||||
parse_color = True
|
||||
else:
|
||||
window.addch(ch, curses.color_pair(color) | attributes)
|
||||
|
||||
window.refresh()
|
||||
|
||||
|
||||
class UIManager:
|
||||
"""Manages curses windows and display"""
|
||||
|
||||
def __init__(self, screen, host):
|
||||
self.screen = screen
|
||||
self.host = host
|
||||
self.info_window = None
|
||||
self.output_window = None
|
||||
self.input_window = None
|
||||
self.divider_window = None
|
||||
self.input_queue = None
|
||||
|
||||
self._init_curses()
|
||||
self._create_windows()
|
||||
|
||||
def _init_curses(self):
|
||||
"""Initialize curses settings"""
|
||||
curses.endwin()
|
||||
curses.initscr()
|
||||
self.screen.nodelay(1)
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.cbreak()
|
||||
curses.setsyx(-1, -1)
|
||||
|
||||
self.screen.addstr(f"Quake Live PyCon: {self.host}")
|
||||
self.screen.refresh()
|
||||
|
||||
# Initialize color pairs
|
||||
for i in range(1, 7):
|
||||
curses.init_pair(i, i, 0)
|
||||
|
||||
# Swap cyan and magenta (5 and 6)
|
||||
curses.init_pair(5, 6, 0)
|
||||
curses.init_pair(6, 5, 0)
|
||||
|
||||
def _create_windows(self):
|
||||
"""Create all UI windows"""
|
||||
maxy, maxx = self.screen.getmaxyx()
|
||||
|
||||
# Server info window (top)
|
||||
self.info_window = curses.newwin(
|
||||
INFO_WINDOW_HEIGHT,
|
||||
maxx - 4,
|
||||
INFO_WINDOW_Y,
|
||||
2
|
||||
)
|
||||
self.info_window.scrollok(False)
|
||||
self.info_window.idlok(False)
|
||||
self.info_window.leaveok(True)
|
||||
self.info_window.refresh()
|
||||
|
||||
# Output window (middle - main display)
|
||||
self.output_window = curses.newwin(
|
||||
maxy - 15,
|
||||
maxx - 4,
|
||||
OUTPUT_WINDOW_Y,
|
||||
2
|
||||
)
|
||||
self.output_window.scrollok(True)
|
||||
self.output_window.idlok(True)
|
||||
self.output_window.leaveok(True)
|
||||
self.output_window.refresh()
|
||||
|
||||
# Input window (bottom)
|
||||
self.input_window = curses.newwin(
|
||||
INPUT_WINDOW_HEIGHT,
|
||||
maxx - 6,
|
||||
maxy - 2,
|
||||
4
|
||||
)
|
||||
self.screen.addstr(maxy - 2, 2, '$ ')
|
||||
self.input_window.idlok(True)
|
||||
self.input_window.leaveok(False)
|
||||
self.input_window.refresh()
|
||||
|
||||
# Divider line
|
||||
self.divider_window = curses.newwin(
|
||||
1,
|
||||
maxx - 4,
|
||||
maxy - 3,
|
||||
2
|
||||
)
|
||||
self.divider_window.hline(curses.ACS_HLINE, maxx - 4)
|
||||
self.divider_window.refresh()
|
||||
|
||||
self.screen.refresh()
|
||||
|
||||
def setup_input_queue(self):
|
||||
"""Setup threaded input queue"""
|
||||
def wait_stdin(q, window):
|
||||
while True:
|
||||
line = curses.textpad.Textbox(window).edit()
|
||||
if len(line) > 0:
|
||||
q.put(line)
|
||||
window.clear()
|
||||
window.refresh()
|
||||
|
||||
self.input_queue = queue.Queue()
|
||||
t = threading.Thread(target=wait_stdin, args=(self.input_queue, self.input_window))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
return self.input_queue
|
||||
|
||||
def setup_logging(self):
|
||||
"""Setup logging handler for output window"""
|
||||
handler = CursesHandler(self.output_window)
|
||||
formatter = logging.Formatter('%(asctime)-8s|%(name)-12s|%(levelname)-6s|%(message)-s', '%H:%M:%S')
|
||||
handler.setFormatter(formatter)
|
||||
return handler
|
||||
|
||||
def print_message(self, message, attributes=0):
|
||||
"""Print formatted message to output window"""
|
||||
y, x = curses.getsyx()
|
||||
print_colored(self.output_window, message, attributes)
|
||||
curses.setsyx(y, x)
|
||||
curses.doupdate()
|
||||
|
||||
def update_server_info(self, game_state):
|
||||
"""Update server info window"""
|
||||
self.info_window.clear()
|
||||
|
||||
max_y, max_x = self.info_window.getmaxyx()
|
||||
server_info = game_state.server_info
|
||||
|
||||
# Line 1: Hostname
|
||||
hostname = server_info.hostname
|
||||
print_colored(self.info_window, f"^6═══ {hostname} ^6═══^7\n", 0)
|
||||
|
||||
# Line 2: Game info
|
||||
gametype = server_info.gametype
|
||||
mapname = server_info.map
|
||||
timelimit = server_info.timelimit
|
||||
fraglimit = server_info.fraglimit
|
||||
roundlimit = server_info.roundlimit
|
||||
caplimit = server_info.capturelimit
|
||||
curclients = server_info.curclients
|
||||
maxclients = server_info.maxclients
|
||||
|
||||
print_colored(self.info_window,
|
||||
f"^3Type:^7 {gametype} ^3Map:^7 {mapname} ^3Players:^7 {curclients}/{maxclients} "
|
||||
f"^3Limits (T/F/R/C):^7 {timelimit}/{fraglimit}/{roundlimit}/{caplimit}\n", 0)
|
||||
|
||||
# Line 3: Team headers and player lists
|
||||
teams = game_state.player_tracker.get_players_by_team()
|
||||
|
||||
if server_info.gametype in TEAM_MODES:
|
||||
print_colored(self.info_window, f"^1(RED) ^4(BLUE) ^3(SPEC)\n", 0)
|
||||
|
||||
red_players = teams['RED'][:4]
|
||||
blue_players = teams['BLUE'][:4]
|
||||
spec_players = teams['SPECTATOR'][:4]
|
||||
|
||||
for i in range(4):
|
||||
red = red_players[i] if i < len(red_players) else ''
|
||||
blue = blue_players[i] if i < len(blue_players) else ''
|
||||
spec = spec_players[i] if i < len(spec_players) else ''
|
||||
|
||||
# Strip color codes for padding calculation
|
||||
from formatter import strip_color_codes
|
||||
red_clean = strip_color_codes(red)
|
||||
blue_clean = strip_color_codes(blue)
|
||||
spec_clean = strip_color_codes(spec)
|
||||
|
||||
# Calculate padding (24 chars per column)
|
||||
red_pad = 24 - len(red_clean)
|
||||
blue_pad = 24 - len(blue_clean)
|
||||
|
||||
line = f"{red}{' ' * red_pad}{blue}{' ' * blue_pad}{spec}\n"
|
||||
print_colored(self.info_window, line, 0)
|
||||
else:
|
||||
print_colored(self.info_window, f"^3(FREE)\n", 0)
|
||||
free_players = teams['FREE'][:4]
|
||||
for player in free_players:
|
||||
print_colored(self.info_window, f"{player}\n", 0)
|
||||
# Fill remaining lines
|
||||
for i in range(4 - len(free_players)):
|
||||
self.info_window.addstr("\n")
|
||||
|
||||
# Blank lines to fill
|
||||
self.info_window.addstr("\n\n")
|
||||
|
||||
# Separator
|
||||
separator = "^6" + "═" * (max_x - 1) + "^7"
|
||||
print_colored(self.info_window, separator, 0)
|
||||
|
||||
self.info_window.refresh()
|
||||
Reference in New Issue
Block a user