This commit is contained in:
xbl
2026-01-09 13:17:30 +01:00
parent 8d8a729490
commit 7d5210214a
12 changed files with 1790 additions and 620 deletions

240
AUTOCOMPLETE.md Normal file
View File

@ -0,0 +1,240 @@
# Autocomplete Feature
## Overview
QLPyCon includes intelligent autocomplete for console variables (cvars) and commands with fuzzy matching support.
## Features
- **Real-time Suggestions**: Fuzzy matches appear below the input line as you type
- **Tab Cycling**: Press Tab to cycle through suggestions
- **Smart Argument Highlighting**: Current argument position highlighted in reverse video
- **Argument Value Suggestions**: Intelligent suggestions for command arguments (bot names, maps, gametypes, etc.)
- **Command Signatures**: Automatic display when typing commands with arguments
- **Fuzzy Matching**: Finds matches even with partial or misspelled input
- **Smart Scoring**: Best matches appear first (exact > prefix > substring > fuzzy)
## Usage
Type a partial command (2+ characters) and fuzzy matches appear below the prompt:
```
$ sv_m
sv_maxclients sv_minrate sv_maxrate sv_timeout sv_floodprotect
```
Press **Tab** to cycle through the suggestions:
```
$ sv_m [Tab] → sv_maxclients
$ sv_maxclients [Tab] → sv_minrate
$ sv_minrate [Tab] → sv_maxrate
$ sv_maxrate [Tab] → sv_timeout
$ sv_timeout [Tab] → sv_floodprotect
$ sv_floodprotect [Tab] → sv_maxclients (cycles back)
```
**Command signatures with argument highlighting:**
When you type a command that has arguments, the signature appears automatically with the **current argument highlighted**:
```
$ addbot
[<botname>] [skill 1-5] [team] [msec delay] [altname]
↑ highlighted (currently typing this)
$ addbot sarge
<botname> [[skill 1-5]] [team] [msec delay] [altname]
↑ highlighted (now typing skill level)
$ addbot sarge 5
<botname> [skill 1-5] [[team]] [msec delay] [altname]
↑ highlighted (now typing team)
$ kick
[<player>]
↑ highlighted
$ g_gametype
[<0=FFA 1=Duel 2=TDM 3=CA 4=CTF...>]
↑ highlighted
```
The highlighted argument (shown with `[[ ]]` above, displayed in reverse video) shows you **exactly what to type next**.
Matches update in real-time as you type or delete characters.
**Argument value suggestions:**
After typing a command with a space, the system suggests valid values for each argument with fuzzy matching:
```
$ addbot
Anarki Angel Biker Bitterman Bones
↑ Shows bot names
$ addbot sar
Sarge
↑ Fuzzy matches 'sar' → 'Sarge'
$ addbot Sarge
1 2 3 4 5
↑ Shows skill levels (1-5)
$ addbot Sarge 4
red blue free spectator any
↑ Shows team values
$ map
aerowalk almostlost arenagate asylum battleforged
↑ Shows map names
$ map blood
bloodrun
↑ Fuzzy matches 'blood' → 'bloodrun'
$ g_gametype
0 1 2 3 4 FFA Duel TDM CA CTF
↑ Shows numeric and string gametype values
$ callvote
map kick shuffle teamsize
↑ Shows vote types
```
The system knows valid values for **25+ commands** including:
- **32 bot names**: Sarge, Ranger, Visor, Xaero, Anarki, etc.
- **40+ maps**: bloodrun, campgrounds, toxicity, aerowalk, etc.
- **12 game types**: FFA, Duel, TDM, CA, CTF, etc. (numeric and string forms)
- **Vote types**: map, kick, shuffle, teamsize, g_gametype, etc.
- **Team values**: red, blue, free, spectator, any
- **Skill levels**: 1-5 for bots
- **Boolean values**: 0, 1, true, false, enabled, disabled
- **Common settings**: timelimits, fraglimits, sv_fps values, etc.
Arguments with freetext (like player names or custom messages) fall back to showing the signature with highlighting.
## Supported Commands
**76 cvars and commands included:**
### Commands with Signatures
The following commands show usage help when selected:
**Bot commands:**
- `addbot` - <botname> [skill 1-5] [team] [msec delay] [altname]
- `removebot` - <botname>
**Player management:**
- `kick` - <player>
- `kickban` - <player>
- `ban` - <player>
- `tempban` - <player> <seconds>
- `tell` - <player> <message>
**Game settings:**
- `g_gametype` - <0=FFA 1=Duel 2=TDM 3=CA 4=CTF...>
- `timelimit` - <minutes>
- `fraglimit` - <frags>
- `capturelimit` - <captures>
**Map & voting:**
- `map` - <mapname>
- `callvote` - <vote type> [args...]
- `say` - <message>
### Additional Cvars
**Server Configuration:**
- sv_hostname, sv_maxclients, sv_fps, sv_pure, etc.
**Network & ZMQ:**
- net_port, zmq_rcon_enable, zmq_stats_enable, etc.
**QLX (minqlx):**
- qlx_serverBrandName, qlx_owner, qlx_redditAuth
## Technical Details
### Fuzzy Matching Algorithm
Scoring system (higher = better match):
- **1000**: Exact match
- **500+**: Prefix match (prioritizes shorter results)
- **100-**: Substring match (earlier = better)
- **50**: Contains all characters in order
### Examples:
```python
autocomplete('sv_')
['sv_fps', 'sv_pure', 'sv_maxrate', 'sv_minrate', 'sv_timeout']
autocomplete('time')
['timelimit', 'sv_timeout']
autocomplete('stat')
['status', 'zmq_stats_enable', 'zmq_stats_ip', 'zmq_stats_password', 'zmq_stats_port']
```
## Customization
### Add Your Own Cvars
Edit `cvars.py` and add to the appropriate list:
```python
# Custom cvars
CUSTOM_CVARS = [
'my_custom_cvar',
'another_cvar',
]
# Add to ALL_CVARS
ALL_CVARS = (
SERVER_CVARS +
GAME_CVARS +
# ...
CUSTOM_CVARS
)
```
### Adjust Behavior
In `ui.py`, modify:
```python
# Minimum characters before showing suggestions
if len(current_word) >= 2: # Change to 1 or 3
# Maximum suggestions displayed
suggestions = autocomplete(current_word, max_results=5) # Change limit
```
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| **Tab** | Cycle through autocomplete suggestions |
| **↑** | Previous command in history |
| **↓** | Next command in history |
| **←/→** | Move cursor |
| **Backspace** | Delete (updates suggestions) |
| **Enter** | Send command |
## Notes
- Autocomplete starts after typing 2+ characters
- Suggestions appear on the line below the prompt
- Up to 5 matches shown at once (best matches first)
- Suggestions update in real-time as you type
- History navigation (↑/↓) works normally
## Testing Autocomplete
```bash
# Run cvars module directly to test matching
python3 cvars.py
# Output shows test queries and results
```

146
README.md
View File

@ -1,119 +1,81 @@
# QLPyCon - Quake Live Python Console # QLPyCon - Quake Live Python Console
A modular, refactored terminal-based client for monitoring and remote controlling Quake Live servers via ZMQ. Terminal-based client for monitoring and controlling Quake Live servers via ZMQ.
### Features ## Features
- **Real-time game monitoring** - Watch kills, deaths, team switches, medals, and more - Real-time game monitoring (kills, deaths, medals, team switches)
- **Server info display** - Shows hostname, map, gametype, limits, and player count - Server info display (map, gametype, scores, player list)
- **Team-aware chat** - Color-coded messages with team prefixes - Team-aware colorized chat with location tracking
- **Powerup tracking** - Formatted pickup and carrier kill messages - Powerup pickup and carrier kill notifications
- **JSON event logging** - Capture all game events to file for analysis - JSON event capture for analysis
- **Colorized output** - Quake color code support (^0-^7) - Quake color code support (^0-^7)
- **Autocomplete for cvars/commands** with fuzzy matching
- **Intelligent argument suggestions** for 25+ commands (bot names, maps, gametypes)
### Module Structure ## Installation
```
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 ```bash
# Requirements
pip install pyzmq pip install pyzmq
python main.py --host tcp://SERVER_IP:PORT --password RCON_PASSWORD
# Run directly
python -m qlpycon.main --host tcp://127.0.0.1:27961 --password YOUR_PASSWORD
``` ```
### Usage ## Usage
```bash ```bash
# Basic connection # Basic
python main.py --host tcp://SERVER_IP:PORT --password RCON_PASSWORD python main.py --host tcp://SERVER_IP:PORT --password RCON_PASSWORD
# Verbose mode (show all communications) # Verbose logging
python main.py --host tcp://SERVER_IP:PORT --password PASS -v python main.py --host tcp://SERVER_IP:PORT --password RCON_PASSWORD -v
# Debug mode (detailed logging) # Capture JSON events
python main.py --host tcp://SERVER_IP:PORT --password PASS -vv python main.py --host tcp://SERVER_IP:PORT --password RCON_PASSWORD --json events.log
# 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 **Options:**
- `--host URI` - ZMQ RCON endpoint (default: tcp://127.0.0.1:27961)
- `--password PASS` - RCON password (required)
- `-v` / `-vv` - Verbose (INFO) or debug (DEBUG) logging
- `--json FILE` - Log all events as JSON
- `--unknown-log FILE` - Log unparsed events (default: unknown_events.log)
- `--host` - ZMQ URI (default: tcp://127.0.0.1:27961) **Configuration File (Optional):**
- `--password` - RCON password Create `~/.qlpycon.conf` or `./qlpycon.conf`:
- `--identity` - Socket identity (random UUID by default) ```ini
- `-v, --verbose` - Increase verbosity (use -v for INFO, -vv for DEBUG) [connection]
- `--json FILE` - Log all JSON events to FILE host = tcp://SERVER_IP:PORT
- `--unknown-log FILE` - Log unknown JSON events (default: unknown_events.log) password = your_password
### Architecture Overview [logging]
level = INFO
```
#### State Management (`state.py`) **Input Features:**
- **ServerInfo** - Tracks server configuration and metadata - **Smart autocomplete** - Type commands and see arguments highlighted in real-time
- **PlayerTracker** - Manages player teams and information - **Argument value suggestions** - Intelligent suggestions for bot names, maps, gametypes, teams, etc.
- **EventDeduplicator** - Prevents duplicate kill/death events - **Tab** - Cycle through autocomplete suggestions
- **GameState** - Main container for all state - **↑/↓** - Command history navigation
- **Argument highlighting** - Current argument position shown in reverse video
- **Command signatures** - Automatic display (e.g., `addbot <botname> [skill 1-5] [team]`)
#### Network Layer (`network.py`) See [AUTOCOMPLETE.md](AUTOCOMPLETE.md) for details.
- **RconConnection** - Handles DEALER socket for RCON commands
- **StatsConnection** - Handles SUB socket for game event stream
#### Event Parsing (`parser.py`) ## Architecture
- **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) main.py - Main loop, argument parsing, signal handling
- Team prefix generation config.py - Constants (weapons, teams, colors, limits)
- Timestamp logic state.py - Game state (ServerInfo, PlayerTracker, EventDeduplicator)
- Chat message formatting network.py - ZMQ connections (RCON DEALER, Stats SUB sockets)
parser.py - JSON event parsing (deaths, medals, switches, stats)
formatter.py - Message formatting, color codes, team prefixes
ui.py - Curses interface (3-panel: info, output, input)
```
#### UI Layer (`ui.py`) **Supported Events:**
- **UIManager** - Manages all curses windows PLAYER_SWITCHTEAM, PLAYER_DEATH/KILL, PLAYER_MEDAL, PLAYER_STATS, MATCH_STARTED/REPORT, PLAYER_CONNECT/DISCONNECT, ROUND_OVER
- Three-panel layout: server info, output, input
- Threaded input queue for non-blocking commands
- Color rendering with curses
### Event Types Supported ## License
- `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)
### License
WTFPL WTFPL

View File

@ -9,6 +9,11 @@ VERSION = "0.8.1"
DEFAULT_HOST = 'tcp://127.0.0.1:27961' DEFAULT_HOST = 'tcp://127.0.0.1:27961'
POLL_TIMEOUT = 100 POLL_TIMEOUT = 100
# Timing constants
QUIT_CONFIRM_TIMEOUT = 3.0 # Seconds to confirm quit (Ctrl-C twice)
RESPAWN_DELAY = 3.0 # Seconds before players respawn after death
STATS_CONNECTION_DELAY = 0.5 # Initial stats connection delay
# UI dimensions # UI dimensions
INFO_WINDOW_HEIGHT = 12 INFO_WINDOW_HEIGHT = 12
INFO_WINDOW_Y = 2 INFO_WINDOW_Y = 2
@ -18,10 +23,13 @@ INPUT_WINDOW_HEIGHT = 2
# Event deduplication # Event deduplication
MAX_RECENT_EVENTS = 10 MAX_RECENT_EVENTS = 10
# UI settings
MAX_COMMAND_HISTORY = 10 # Number of commands to remember
# Team game modes # Team game modes
TEAM_MODES = [ TEAM_MODES = [
"Team Deathmatch", "Team Deathmatch",
"Clan Arena", "Clan Arena",
"Capture The Flag", "Capture The Flag",
"One Flag CTF", "One Flag CTF",
"Overload", "Overload",

559
cvars.py Normal file
View File

@ -0,0 +1,559 @@
#!/usr/bin/env python3
"""
Quake Live console variables (cvars) database
Common cvars for autocomplete and fuzzy search
"""
# Server configuration
SERVER_CVARS = [
'sv_hostname',
'sv_maxclients',
'sv_privateclients',
'sv_fps',
'sv_timeout',
'sv_minrate',
'sv_maxrate',
'sv_floodprotect',
'sv_pure',
'sv_allowdownload',
]
# Game rules
GAME_CVARS = [
'g_gametype',
'g_factoryTitle',
'g_motd',
'timelimit',
'fraglimit',
'capturelimit',
'roundlimit',
'scorelimit',
'g_allowKill',
'g_allowSpecVote',
'g_friendlyFire',
'g_teamAutoJoin',
'g_teamForceBalance',
'g_warmupDelay',
'g_inactivity',
'g_quadHog',
'g_training',
'g_instagib',
]
# Map and rotation
MAP_CVARS = [
'mapname',
'nextmap',
'g_nextmap',
]
# Voting
VOTE_CVARS = [
'g_voteDelay',
'g_voteLimit',
'g_allowVote',
]
# Items and weapons
ITEM_CVARS = [
'dmflags',
'weapon_reload_rg',
'weapon_reload_sg',
'weapon_reload_gl',
'weapon_reload_rl',
'weapon_reload_lg',
'weapon_reload_pg',
'weapon_reload_hmg',
]
# Network
NETWORK_CVARS = [
'net_port',
'net_ip',
'net_strict',
]
# ZMQ
ZMQ_CVARS = [
'zmq_rcon_enable',
'zmq_rcon_ip',
'zmq_rcon_port',
'zmq_rcon_password',
'zmq_stats_enable',
'zmq_stats_ip',
'zmq_stats_port',
'zmq_stats_password',
]
# QLX (minqlx) specific
QLX_CVARS = [
'qlx_serverBrandName',
'qlx_owner',
'qlx_redditAuth',
]
# Bot cvars
BOT_CVARS = [
'bot_enable',
'bot_nochat',
'bot_minplayers',
]
# Common commands (not cvars but useful for autocomplete)
COMMANDS = [
'status',
'map',
'map_restart',
'kick',
'kickban',
'ban',
'unban',
'tempban',
'tell',
'say',
'callvote',
'vote',
'rcon',
'addbot',
'removebot',
'killserver',
'quit',
'team',
]
# Bot names for addbot command (complete list)
BOT_NAMES = [
'Anarki', 'Angel', 'Biker', 'Bitterman', 'Bones', 'Cadaver',
'Crash', 'Daemia', 'Doom', 'Gorre', 'Grunt', 'Hossman',
'Hunter', 'Keel', 'Klesk', 'Lucy', 'Major', 'Mynx', 'Orbb',
'Patriot', 'Phobos', 'Ranger', 'Razor', 'Sarge', 'Slash',
'Sorlag', 'Stripe', 'TankJr', 'Uriel', 'Visor', 'Wrack', 'Xaero'
]
# Skill levels for bots
BOT_SKILL_LEVELS = ['1', '2', '3', '4', '5']
# Team values
TEAM_VALUES = ['red', 'blue', 'free', 'spectator', 'r', 'b', 'f', 's']
# Popular competitive Quake Live maps
MAP_NAMES = [
'aerowalk', 'almostlost', 'arenagate', 'asylum', 'battleforged',
'bloodrun', 'brimstoneabbey', 'campgrounds', 'cannedheat',
'citycrossings', 'cure', 'deepinside', 'dismemberment',
'elder', 'eviscerated', 'falloutbunker', 'finnegans',
'furiousheights', 'gospelcrossings', 'grimdungeons',
'hearth', 'hektik', 'lostworld', 'monsoon', 'reflux',
'repent', 'shiningforces', 'sinister', 'spacectf',
'spidercrossings', 'stonekeep', 'terminus', 'tornado',
'toxicity', 'trinity', 'verticalvengeance', 'warehouses', 'whisper'
]
# Game type names (string values)
GAMETYPE_NAMES = [
'ffa', 'duel', 'race', 'tdm', 'ca', 'ctf', 'oneflag',
'har', 'ft', 'dom', 'ad', 'rr'
]
# Game type numbers (legacy numeric values)
GAMETYPE_NUMBERS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']
# Vote types for callvote command
VOTE_TYPES = [
'map', 'map_restart', 'nextmap', 'gametype', 'kick',
'timelimit', 'fraglimit', 'shuffle', 'teamsize',
'cointoss', 'random', 'loadouts', 'ammo', 'timers'
]
# Vote values
VOTE_VALUES = ['yes', 'no', '1', '2']
# Boolean values (0/1)
BOOLEAN_VALUES = ['0', '1']
# sv_fps valid values
SV_FPS_VALUES = ['20', '30', '40', '60', '125']
# sv_maxclients common values
SV_MAXCLIENTS_VALUES = ['8', '12', '16', '24', '32']
# Common time limits (minutes)
TIMELIMIT_VALUES = ['10', '15', '20', '30']
# Common frag limits
FRAGLIMIT_VALUES = ['30', '50', '75', '100']
# Common capture limits
CAPTURELIMIT_VALUES = ['5', '8', '10']
# Common round limits
ROUNDLIMIT_VALUES = ['3', '5', '7', '10']
# Common teamsize values
TEAMSIZE_VALUES = ['2', '3', '4', '5', '6']
# Factory types for map command
FACTORY_TYPES = ['ffa', 'duel', 'tdm', 'ca', 'ctf', 'iffa', 'ictf', 'ift']
# Command signatures (usage help)
COMMAND_SIGNATURES = {
# Bot commands
'addbot': '<botname:Sarge|Ranger|Visor|etc> [skill 1-5] [team] [msec delay] [altname]',
'removebot': '<botname|altname>',
# Player management
'kick': '<player>',
'kickban': '<player>',
'ban': '<player>',
'unban': '<player>',
'tempban': '<player> <seconds>',
'tell': '<player> <message>',
# Chat & voting
'say': '<message>',
'callvote': '<vote type> [args...]',
'vote': '<yes|no>',
# Map commands
'map': '<mapname>',
'map_restart': '',
# Server
'rcon': '<command>',
'killserver': '',
'quit': '',
'status': '',
# Game cvars with common values
'g_gametype': '<0=FFA 1=Duel 2=TDM 3=CA 4=CTF 5=OCTF 6=Harv 7=FT 8=Dom>',
'timelimit': '<minutes>',
'fraglimit': '<frags>',
'capturelimit': '<captures>',
'roundlimit': '<rounds>',
'scorelimit': '<score>',
# Server settings
'sv_maxclients': '<1-64>',
'sv_hostname': '<name>',
'sv_fps': '<20|30|40|60|125>',
# Network
'net_port': '<port number>',
# ZMQ
'zmq_rcon_enable': '<0|1>',
'zmq_rcon_port': '<port>',
'zmq_rcon_password': '<password>',
'zmq_stats_enable': '<0|1>',
'zmq_stats_port': '<port>',
'zmq_stats_password': '<password>',
}
# Combine all cvars
ALL_CVARS = (
SERVER_CVARS +
GAME_CVARS +
MAP_CVARS +
VOTE_CVARS +
ITEM_CVARS +
NETWORK_CVARS +
ZMQ_CVARS +
QLX_CVARS +
BOT_CVARS +
COMMANDS
)
# Sort for binary search
ALL_CVARS.sort()
# Argument value mappings - maps argument type to list of valid values
ARGUMENT_VALUES = {
'botname': BOT_NAMES,
'skill': BOT_SKILL_LEVELS,
'team': TEAM_VALUES,
'mapname': MAP_NAMES,
'gametype': GAMETYPE_NAMES + GAMETYPE_NUMBERS,
'gametype_name': GAMETYPE_NAMES,
'vote_type': VOTE_TYPES,
'vote_value': VOTE_VALUES,
'boolean': BOOLEAN_VALUES,
'sv_fps': SV_FPS_VALUES,
'sv_maxclients': SV_MAXCLIENTS_VALUES,
'timelimit': TIMELIMIT_VALUES,
'fraglimit': FRAGLIMIT_VALUES,
'capturelimit': CAPTURELIMIT_VALUES,
'roundlimit': ROUNDLIMIT_VALUES,
'teamsize': TEAMSIZE_VALUES,
'factory': FACTORY_TYPES,
}
# Command argument definitions - maps command to list of argument types
# Each argument is a dict with: type (value list key), required (bool)
COMMAND_ARGUMENTS = {
'addbot': [
{'type': 'botname', 'required': True},
{'type': 'skill', 'required': False},
{'type': 'team', 'required': False},
{'type': 'msec delay number', 'required': False}, # msec delay
{'type': 'freetext', 'required': False}, # altname
],
'removebot': [
{'type': 'botname', 'required': True},
],
'kick': [
{'type': 'player', 'required': True},
],
'kickban': [
{'type': 'player', 'required': True},
],
'ban': [
{'type': 'player', 'required': True},
],
'tempban': [
{'type': 'player', 'required': True},
{'type': 'number', 'required': True}, # seconds
],
'tell': [
{'type': 'player', 'required': True},
{'type': 'freetext', 'required': True}, # message
],
'map': [
{'type': 'mapname', 'required': True},
{'type': 'factory', 'required': False},
],
'callvote': [
{'type': 'vote_type', 'required': True},
{'type': 'dynamic', 'required': False}, # Depends on vote type
],
'vote': [
{'type': 'vote_value', 'required': True},
],
'team': [
{'type': 'team', 'required': True},
],
'g_gametype': [
{'type': 'gametype', 'required': True},
],
'timelimit': [
{'type': 'timelimit', 'required': True},
],
'fraglimit': [
{'type': 'fraglimit', 'required': True},
],
'capturelimit': [
{'type': 'capturelimit', 'required': True},
],
'roundlimit': [
{'type': 'roundlimit', 'required': True},
],
'sv_fps': [
{'type': 'sv_fps', 'required': True},
],
'sv_maxclients': [
{'type': 'sv_maxclients', 'required': True},
],
'sv_hostname': [
{'type': 'freetext', 'required': True},
],
'sv_pure': [
{'type': 'boolean', 'required': True},
],
'zmq_rcon_enable': [
{'type': 'boolean', 'required': True},
],
'zmq_rcon_port': [
{'type': 'number', 'required': True},
],
'zmq_rcon_password': [
{'type': 'freetext', 'required': True},
],
'zmq_stats_enable': [
{'type': 'boolean', 'required': True},
],
'zmq_stats_port': [
{'type': 'number', 'required': True},
],
'zmq_stats_password': [
{'type': 'freetext', 'required': True},
],
}
def get_argument_suggestions(command, arg_position, current_value, player_list=None):
"""
Get autocomplete suggestions for a command argument
Args:
command: Command name (e.g., 'addbot')
arg_position: Argument index (0 = first argument after command)
current_value: What user has typed so far for this argument
player_list: List of player names (for dynamic 'player' type)
Returns:
List of suggestion strings matching current_value
"""
# Get command's argument definitions
if command not in COMMAND_ARGUMENTS:
return []
arg_defs = COMMAND_ARGUMENTS[command]
# Check if arg_position is valid
if arg_position >= len(arg_defs):
return []
arg_def = arg_defs[arg_position]
arg_type = arg_def['type']
# Handle special types
if arg_type == 'player':
# Dynamic: get from player_list parameter
if player_list:
return fuzzy_match(current_value, player_list, max_results=5)
return []
elif arg_type == 'dynamic':
# Special case for callvote second argument
# Would need first argument to determine suggestions
# For now, return empty (could be enhanced later)
return []
elif arg_type == 'number':
# Show common numeric values
return ['50', '100', '150', '200', '250']
elif arg_type == 'freetext':
# No suggestions for free text
return []
elif arg_type in ARGUMENT_VALUES:
# Static list from ARGUMENT_VALUES
values = ARGUMENT_VALUES[arg_type]
return fuzzy_match(current_value, values, max_results=5)
return []
def fuzzy_match(query, candidates, max_results=5):
"""
Fuzzy match query against candidates
Returns list of (match, score) tuples sorted by score
Scoring:
- Exact match: 1000
- Prefix match: 500 + remaining chars
- Substring match: 100
- Contains all chars in order: 50
- Levenshtein-like: based on edit distance
"""
if not query:
# Return first max_results candidates when query is empty
return candidates[:max_results]
query_lower = query.lower()
matches = []
for candidate in candidates:
candidate_lower = candidate.lower()
score = 0
# Exact match
if query_lower == candidate_lower:
score = 1000
# Prefix match (best after exact)
elif candidate_lower.startswith(query_lower):
score = 500 + (100 - len(candidate)) # Prefer shorter matches
# Substring match
elif query_lower in candidate_lower:
# Score higher if match is earlier in string
pos = candidate_lower.index(query_lower)
score = 100 - pos
# Contains all characters in order (fuzzy)
else:
query_idx = 0
for char in candidate_lower:
if query_idx < len(query_lower) and char == query_lower[query_idx]:
query_idx += 1
if query_idx == len(query_lower): # All chars found
score = 50
if score > 0:
matches.append((candidate, score))
# Sort by score (highest first), then alphabetically
matches.sort(key=lambda x: (-x[1], x[0]))
return [match for match, score in matches[:max_results]]
def autocomplete(partial, max_results=5):
"""
Autocomplete a partial cvar/command
Returns list of suggestions
"""
return fuzzy_match(partial, ALL_CVARS, max_results)
def parse_signature(signature):
"""
Parse command signature into individual arguments
Returns list of argument strings
Example:
'<botname> [skill 1-5] [team]' -> ['<botname>', '[skill 1-5]', '[team]']
"""
import re
# Match <arg> or [arg] patterns, including content with spaces
pattern = r'(<[^>]+>|\[[^\]]+\])'
args = re.findall(pattern, signature)
return args
def get_signature_with_highlight(command, arg_position):
"""
Get command signature with current argument highlighted
Args:
command: Command name (e.g., 'addbot')
arg_position: Current argument index (0-based, 0 = first arg after command)
Returns:
List of (text, is_highlighted) tuples
"""
if command not in COMMAND_SIGNATURES:
return []
signature = COMMAND_SIGNATURES[command]
if not signature:
return []
args = parse_signature(signature)
if not args:
return [(signature, False)]
# Build list of (arg_text, is_current) tuples
result = []
for i, arg in enumerate(args):
is_current = (i == arg_position)
result.append((arg, is_current))
return result
if __name__ == '__main__':
# Test autocomplete
print("Testing autocomplete:")
print(f"Total cvars/commands: {len(ALL_CVARS)}")
print()
test_queries = ['sv_', 'time', 'zmq', 'qlx', 'map', 'stat', 'g_team']
for query in test_queries:
results = autocomplete(query)
print(f"'{query}' -> {results}")

View File

@ -18,11 +18,11 @@ def get_team_prefix(player_name, player_tracker):
"""Get color-coded team prefix for a player""" """Get color-coded team prefix for a player"""
if not player_tracker.server_info.is_team_mode(): if not player_tracker.server_info.is_team_mode():
return '' return ''
team = player_tracker.get_team(player_name) team = player_tracker.get_team(player_name)
if not team: if not team:
return '' return ''
return TEAM_COLORS.get(team, '') return TEAM_COLORS.get(team, '')
@ -34,33 +34,33 @@ def should_add_timestamp(message):
# But allow "zmq RCON" lines (command echoes) # But allow "zmq RCON" lines (command echoes)
if 'zmq RCON' not in message: if 'zmq RCON' not in message:
return False return False
# Skip very short messages or fragments # Skip very short messages or fragments
stripped = message.strip() stripped = message.strip()
if len(stripped) <= 2: if len(stripped) <= 2:
return False return False
# Skip messages with leading spaces (status fragments) # Skip messages with leading spaces (status fragments)
if message.startswith(' ') and len(stripped) < 50: if message.startswith(' ') and len(stripped) < 50:
return False return False
# Skip pure numbers # Skip pure numbers
if stripped.isdigit(): if stripped.isdigit():
return False return False
# Skip short single words # Skip short single words
if len(stripped) < 20 and ' ' not in stripped: if len(stripped) < 20 and ' ' not in stripped:
return False return False
# Skip IP addresses (xxx.xxx.xxx.xxx or xxx.xxx.xxx.xxx:port) # Skip IP addresses (xxx.xxx.xxx.xxx or xxx.xxx.xxx.xxx:port)
allowed_chars = set('0123456789.:') allowed_chars = set('0123456789.:')
if stripped.count('.') == 3 and all(c in allowed_chars for c in stripped): if stripped.count('.') == 3 and all(c in allowed_chars for c in stripped):
return False return False
# Skip messages starting with *** # Skip messages starting with ***
if message.startswith('***'): if message.startswith('***'):
return False return False
return True return True
@ -74,13 +74,13 @@ def format_message(message, add_timestamp=True):
# Clean up message # Clean up message
message = message.replace("\\n", "") message = message.replace("\\n", "")
message = message.replace(chr(25), "") message = message.replace(chr(25), "")
# Handle broadcast messages # Handle broadcast messages
attributes = 0 attributes = 0
if message[:10] == "broadcast:": if message[:10] == "broadcast:":
message = message[11:] message = message[11:]
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:-2] + "\n"
@ -89,7 +89,7 @@ def format_message(message, add_timestamp=True):
if add_timestamp and should_add_timestamp(message): if add_timestamp and should_add_timestamp(message):
timestamp = time.strftime('%H:%M:%S') timestamp = time.strftime('%H:%M:%S')
message = f"^3[^7{timestamp}^3]^7 {message}" message = f"^3[^7{timestamp}^3]^7 {message}"
return message, attributes return message, attributes
@ -100,7 +100,7 @@ def format_chat_message(message, player_tracker):
""" """
# Strip special character # Strip special character
clean_msg = message.replace(chr(25), '') clean_msg = message.replace(chr(25), '')
# Team chat with location: (PlayerName) (Location): message # Team chat with location: (PlayerName) (Location): message
# Location can have nested parens like (Lower Floor (Near Yellow Armour)) # Location can have nested parens like (Lower Floor (Near Yellow Armour))
if clean_msg.strip().startswith('(') and ')' in clean_msg: if clean_msg.strip().startswith('(') and ')' in clean_msg:
@ -108,10 +108,10 @@ def format_chat_message(message, player_tracker):
player_match = re.match(r'^(\([^)]+\))', clean_msg) player_match = re.match(r'^(\([^)]+\))', clean_msg)
if not player_match: if not player_match:
return message return message
player_part = player_match.group(1) player_part = player_match.group(1)
rest = clean_msg[len(player_part):].lstrip() rest = clean_msg[len(player_part):].lstrip()
# Check for location (another parenthetical) # Check for location (another parenthetical)
if rest.startswith('('): if rest.startswith('('):
# Count parens to handle nesting # Count parens to handle nesting
@ -125,12 +125,12 @@ def format_chat_message(message, player_tracker):
if paren_count == 0: if paren_count == 0:
location_end = i + 1 location_end = i + 1
break break
# Check if location ends with colon # Check if location ends with colon
if location_end > 0 and location_end < len(rest) and rest[location_end] == ':': if location_end > 0 and location_end < len(rest) and rest[location_end] == ':':
location_part = rest[:location_end] location_part = rest[:location_end]
message_part = rest[location_end + 1:] message_part = rest[location_end + 1:]
# Get team prefix # Get team prefix
name_match = re.match(r'\(([^)]+)\)', player_part) name_match = re.match(r'\(([^)]+)\)', player_part)
if name_match: if name_match:
@ -138,30 +138,30 @@ def format_chat_message(message, player_tracker):
team_prefix = get_team_prefix(player_name, player_tracker) team_prefix = get_team_prefix(player_name, player_tracker)
location_clean = strip_color_codes(location_part) location_clean = strip_color_codes(location_part)
return f"^8^5[TEAMSAY]^7^0 {team_prefix}^8{player_part}^0^3{location_clean}^7:^5{message_part}" return f"^8^5[TEAMSAY]^7^0 {team_prefix}^8{player_part}^0^3{location_clean}^7:^5{message_part}"
# Team chat without location: (PlayerName): message # Team chat without location: (PlayerName): message
colon_match = re.match(r'^(\([^)]+\)):(\s*.*)', clean_msg) colon_match = re.match(r'^(\([^)]+\)):(\s*.*)', clean_msg)
if colon_match: if colon_match:
player_part = colon_match.group(1) player_part = colon_match.group(1)
message_part = colon_match.group(2) message_part = colon_match.group(2)
name_match = re.match(r'\(([^)]+)\)', player_part) name_match = re.match(r'\(([^)]+)\)', player_part)
if name_match: if name_match:
player_name = strip_color_codes(name_match.group(1).strip()) player_name = strip_color_codes(name_match.group(1).strip())
team_prefix = get_team_prefix(player_name, player_tracker) team_prefix = get_team_prefix(player_name, player_tracker)
return f"^8^5[TEAMSAY]^7^0 {team_prefix}^8{player_part}^7^0:^5{message_part}\n" return f"^8^5[TEAMSAY]^7^0 {team_prefix}^8{player_part}^7^0:^5{message_part}\n"
# Regular chat: PlayerName: message # Regular chat: PlayerName: message
parts = clean_msg.split(':', 1) parts = clean_msg.split(':', 1)
if len(parts) == 2: if len(parts) == 2:
player_name = strip_color_codes(parts[0].strip()) player_name = strip_color_codes(parts[0].strip())
team_prefix = get_team_prefix(player_name, player_tracker) team_prefix = get_team_prefix(player_name, player_tracker)
# Preserve original color-coded name # Preserve original color-coded name
original_parts = message.replace(chr(25), '').split(':', 1) original_parts = message.replace(chr(25), '').split(':', 1)
if len(original_parts) == 2: if len(original_parts) == 2:
return f"^8^2[SAY]^7^0 {team_prefix}^8{original_parts[0]}^0^7:^2{original_parts[1]}" return f"^8^2[SAY]^7^0 {team_prefix}^8{original_parts[0]}^0^7:^2{original_parts[1]}"
return message return message
def format_powerup_message(message, player_tracker): def format_powerup_message(message, player_tracker):

555
main.py
View File

@ -13,13 +13,26 @@ import curses
import zmq import zmq
import signal import signal
import sys import sys
import threading
from config import VERSION, DEFAULT_HOST, POLL_TIMEOUT from config import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY
from state import GameState from state import GameState
from network import RconConnection, StatsConnection from network import RconConnection, StatsConnection
from parser import EventParser from parser import EventParser
from formatter import format_message, format_chat_message, format_powerup_message from formatter import format_message, format_chat_message, format_powerup_message, strip_color_codes
from ui import UIManager from ui import UIManager
from qlpycon_config import ConfigLoader
# Pre-compiled regex patterns
CVAR_RESPONSE_PATTERN = re.compile(r'"([^"]+)"\s+is:"([^"]*)"')
PORT_PATTERN = re.compile(r'(\d+)')
BROADCAST_PATTERN = re.compile(r'^broadcast:\s*print\s*"(.+?)(?:\\n)?"\s*$')
TIMESTAMP_PATTERN = re.compile(r'\^\d\[\^\d[0-9:]+\^\d\]\^\d\s*|\[[0-9:]+\]\s*')
CONNECT_PATTERN = re.compile(r'^(.+?)\s+connected')
DISCONNECT_PATTERN = re.compile(r'^(.+?)\s+disconnected')
KICK_PATTERN = re.compile(r'^(.+?)\s+was kicked')
INACTIVITY_PATTERN = re.compile(r'^(.+?)\s+Dropped due to inactivity')
RENAME_PATTERN = re.compile(r'^(.+?)\s+renamed to\s+(.+?)$')
# Configure logging # Configure logging
logger = logging.getLogger('main') logger = logging.getLogger('main')
@ -31,24 +44,25 @@ all_json_logger.setLevel(logging.DEBUG)
unknown_json_logger = logging.getLogger('unknown_json') unknown_json_logger = logging.getLogger('unknown_json')
unknown_json_logger.setLevel(logging.DEBUG) unknown_json_logger.setLevel(logging.DEBUG)
# Global flag for quit confirmation # Global flag for quit confirmation (thread-safe)
quit_confirm_time = None quit_confirm_time = None
quit_confirm_lock = threading.Lock()
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
import time
current_time = time.time() current_time = time.time()
if quit_confirm_time is None or (current_time - quit_confirm_time) > 3: with quit_confirm_lock:
# First Ctrl-C or timeout expired if quit_confirm_time is None or (current_time - quit_confirm_time) > QUIT_CONFIRM_TIMEOUT:
logger.warning("^1^8Press Ctrl-C again within 3 seconds to quit^0") # First Ctrl-C or timeout expired
quit_confirm_time = current_time logger.warning(f"^1^8Press Ctrl-C again within {QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0")
else: quit_confirm_time = current_time
# Second Ctrl-C within 3 seconds else:
logger.warning("^1^8Quittin'^0") # Second Ctrl-C within timeout
sys.exit(0) logger.warning("^1^8Quittin'^0")
sys.exit(0)
def parse_cvar_response(message, game_state, ui): def parse_cvar_response(message, game_state, ui):
""" """
@ -61,17 +75,17 @@ def parse_cvar_response(message, game_state, ui):
'mapname', 'timelimit', 'fraglimit', 'capturelimit', 'sv_maxclients'] 'mapname', 'timelimit', 'fraglimit', 'capturelimit', 'sv_maxclients']
if any(f': {cmd}' in message for cmd in suppress_cmds): if any(f': {cmd}' in message for cmd in suppress_cmds):
return True return True
# Parse cvar responses (format: "cvar_name" is:"value" default:...) # Parse cvar responses (format: "cvar_name" is:"value" default:...)
cvar_match = re.search(r'"([^"]+)"\s+is:"([^"]*)"', message) cvar_match = CVAR_RESPONSE_PATTERN.search(message)
if cvar_match: if cvar_match:
cvar_name = cvar_match.group(1) cvar_name = cvar_match.group(1)
value = cvar_match.group(2) value = cvar_match.group(2)
if game_state.server_info.update_from_cvar(cvar_name, value): if game_state.server_info.update_from_cvar(cvar_name, value):
ui.update_server_info(game_state) ui.update_server_info(game_state)
return True return True
return False return False
@ -82,28 +96,88 @@ def handle_stats_connection(message, rcon, ui, game_state):
""" """
stats_port = None stats_port = None
stats_password = None stats_password = None
# Extract stats port # Extract stats port
if 'net_port' in message and ' is:' in message and '"net_port"' in message: if 'net_port' in message and ' is:' in message and '"net_port"' in message:
match = re.search(r' is:"([^"]+)"', message) match = CVAR_RESPONSE_PATTERN.search(message)
if match: if match:
port_str = match.group(1).strip() port_str = match.group(2).strip()
digit_match = re.search(r'(\d+)', port_str) digit_match = PORT_PATTERN.search(port_str)
if digit_match: if digit_match:
stats_port = digit_match.group(1) stats_port = digit_match.group(1)
logger.info(f'Got stats port: {stats_port}') logger.info(f'Got stats port: {stats_port}')
# Extract stats password # Extract stats password
if 'zmq_stats_password' in message and ' is:' in message and '"zmq_stats_password"' in message: if 'zmq_stats_password' in message and ' is:' in message and '"zmq_stats_password"' in message:
match = re.search(r' is:"([^"]+)"', message) match = CVAR_RESPONSE_PATTERN.search(message)
if match: if match:
password_str = match.group(1) password_str = match.group(2)
password_str = re.sub(r'\^\d', '', password_str) # Strip color codes password_str = strip_color_codes(password_str)
stats_password = password_str.strip() stats_password = password_str.strip()
logger.info(f'Got stats password: {stats_password}') logger.info(f'Got stats password: {stats_password}')
return stats_port, stats_password return stats_port, stats_password
def handle_user_input(input_queue, rcon, ui):
"""Process user command 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)
def handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_state):
"""Poll and process stats stream events"""
if not stats_conn or not stats_conn.connected:
return stats_check_counter
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)
return stats_check_counter
def handle_player_respawns(game_state, ui):
"""Check and revive dead players after respawn delay"""
if game_state.server_info.gametype == 'Clan Arena':
# CA: revive all players after round end
if game_state.server_info.round_end_time:
if time.time() - game_state.server_info.round_end_time >= RESPAWN_DELAY:
game_state.server_info.dead_players.clear()
game_state.server_info.round_end_time = None
ui.update_server_info(game_state)
else:
# Other modes: revive individual players after death
current_time = time.time()
players_to_revive = [
name for name, death_time in game_state.server_info.dead_players.items()
if current_time - death_time >= RESPAWN_DELAY
]
if players_to_revive:
for name in players_to_revive:
del game_state.server_info.dead_players[name]
ui.update_server_info(game_state)
def parse_player_events(message, game_state, ui): def parse_player_events(message, game_state, ui):
""" """
Parse connect, disconnect, kick, and rename messages Parse connect, disconnect, kick, and rename messages
@ -113,13 +187,12 @@ def parse_player_events(message, game_state, ui):
msg = message msg = message
# Strip broadcast: print "..." wrapper with regex # Strip broadcast: print "..." wrapper with regex
broadcast_match = re.match(r'^broadcast:\s*print\s*"(.+?)(?:\\n)?"\s*$', msg) broadcast_match = BROADCAST_PATTERN.match(msg)
if broadcast_match: if broadcast_match:
msg = broadcast_match.group(1) msg = broadcast_match.group(1)
# Strip timestamp: [HH:MM:SS] or ^3[^7HH:MM:SS^3]^7 # Strip timestamp: [HH:MM:SS] or ^3[^7HH:MM:SS^3]^7
msg = re.sub(r'\^\d\[\^\d[0-9:]+\^\d\]\^\d\s*', '', msg) msg = TIMESTAMP_PATTERN.sub('', msg)
msg = re.sub(r'\[[0-9:]+\]\s*', '', msg)
msg = msg.strip() msg = msg.strip()
if not msg: if not msg:
@ -128,81 +201,77 @@ def parse_player_events(message, game_state, ui):
logger.debug(f'parse_player_events: {repr(msg)}') logger.debug(f'parse_player_events: {repr(msg)}')
# Strip color codes for matching # Strip color codes for matching
from formatter import strip_color_codes
clean_msg = strip_color_codes(msg) clean_msg = strip_color_codes(msg)
# Match connects: "NAME connected" or "NAME connected with Steam ID" # Match connects: "NAME connected" or "NAME connected with Steam ID"
connect_match = re.match(r'^(.+?)\s+connected', clean_msg) connect_match = CONNECT_PATTERN.match(clean_msg)
if connect_match: if connect_match:
player_name_match = re.match(r'^(.+?)\s+connected', msg) player_name_match = CONNECT_PATTERN.match(msg)
player_name = player_name_match.group(1).strip() if player_name_match else connect_match.group(1).strip() player_name = player_name_match.group(1).strip() if player_name_match else connect_match.group(1).strip()
player_name = re.sub(r'\^\d+$', '', player_name) player_name = strip_color_codes(player_name)
logger.info(f'CONNECT: {repr(player_name)}') logger.info(f'CONNECT: {repr(player_name)}')
game_state.player_tracker.update_team(player_name, 'SPECTATOR') game_state.player_tracker.update_team(player_name, 'SPECTATOR')
game_state.player_tracker.add_player(player_name) game_state.player_tracker.add_player(player_name)
ui.update_server_info(game_state) ui.update_server_info(game_state)
# Only print if this is NOT the Steam ID line # Only print if this is NOT the Steam ID line
if 'Steam ID' not in message: if 'Steam ID' not in message:
timestamp = time.strftime('%H:%M:%S') timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3]^7 ^8{player_name} ^9^2connected\n") ui.print_message(f"^3[^7{timestamp}^3]^7 ^8{player_name} ^9^2connected\n")
return True return True
# Regular disconnect # Regular disconnect
disconnect_match = re.match(r'^(.+?)\s+disconnected', clean_msg) disconnect_match = DISCONNECT_PATTERN.match(clean_msg)
if disconnect_match: if disconnect_match:
original_match = re.match(r'^(.+?)\s+disconnected', msg) original_match = DISCONNECT_PATTERN.match(msg)
player_name = original_match.group(1).strip() if original_match else disconnect_match.group(1).strip() player_name = original_match.group(1).strip() if original_match else disconnect_match.group(1).strip()
player_name = re.sub(r'\^\d+$', '', player_name) player_name = strip_color_codes(player_name)
player_name = re.sub(r'^\^\d+', '', player_name)
logger.info(f'DISCONNECT: {repr(player_name)}') logger.info(f'DISCONNECT: {repr(player_name)}')
game_state.player_tracker.remove_player(player_name) game_state.player_tracker.remove_player(player_name)
ui.update_server_info(game_state) ui.update_server_info(game_state)
timestamp = time.strftime('%H:%M:%S') timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3]^7 ^8{player_name} ^9^1disconnected\n") ui.print_message(f"^3[^7{timestamp}^3]^7 ^8{player_name} ^9^1disconnected\n")
return True return True
# Kick # Kick
kick_match = re.match(r'^(.+?)\s+was kicked', clean_msg) kick_match = KICK_PATTERN.match(clean_msg)
if kick_match: if kick_match:
original_match = re.match(r'^(.+?)\s+was kicked', msg) original_match = KICK_PATTERN.match(msg)
player_name = original_match.group(1).strip() if original_match else kick_match.group(1).strip() player_name = original_match.group(1).strip() if original_match else kick_match.group(1).strip()
player_name = re.sub(r'\^\d+$', '', player_name) player_name = strip_color_codes(player_name)
player_name = re.sub(r'^\^\d+', '', player_name)
logger.info(f'KICK: {repr(player_name)}') logger.info(f'KICK: {repr(player_name)}')
game_state.player_tracker.remove_player(player_name) game_state.player_tracker.remove_player(player_name)
ui.update_server_info(game_state) ui.update_server_info(game_state)
timestamp = time.strftime('%H:%M:%S') timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3]^7 ^8{player_name} ^1was kicked\n") ui.print_message(f"^3[^7{timestamp}^3]^7 ^8{player_name} ^1was kicked\n")
return True return True
# Inactivity # Inactivity
inactivity_match = re.match(r'^(.+?)\s+Dropped due to inactivity', clean_msg) inactivity_match = INACTIVITY_PATTERN.match(clean_msg)
if inactivity_match: if inactivity_match:
original_match = re.match(r'^(.+?)\s+Dropped due to inactivity', msg) original_match = INACTIVITY_PATTERN.match(msg)
player_name = original_match.group(1).strip() if original_match else inactivity_match.group(1).strip() player_name = original_match.group(1).strip() if original_match else inactivity_match.group(1).strip()
player_name = re.sub(r'\^\d+$', '', player_name) player_name = strip_color_codes(player_name)
player_name = re.sub(r'^\^\d+', '', player_name)
logger.info(f'INACTIVITY DROP: {repr(player_name)}') logger.info(f'INACTIVITY DROP: {repr(player_name)}')
game_state.player_tracker.remove_player(player_name) game_state.player_tracker.remove_player(player_name)
ui.update_server_info(game_state) ui.update_server_info(game_state)
timestamp = time.strftime('%H:%M:%S') timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3]^7 ^8{player_name}^0 ^3dropped due to inactivity^7\n") ui.print_message(f"^3[^7{timestamp}^3]^7 ^8{player_name}^0 ^3dropped due to inactivity^7\n")
return True return True
# Match renames: "OldName renamed to NewName" # Match renames: "OldName renamed to NewName"
rename_match = re.match(r'^(.+?)\s+renamed to\s+(.+?)$', clean_msg) rename_match = RENAME_PATTERN.match(clean_msg)
if rename_match: if rename_match:
# Extract from original message # Extract from original message
original_match = re.match(r'^(.+?)\s+renamed to\s+(.+?)$', msg) original_match = RENAME_PATTERN.match(msg)
if original_match: if original_match:
old_name = original_match.group(1).strip() old_name = original_match.group(1).strip()
new_name = original_match.group(2).strip() new_name = original_match.group(2).strip()
@ -210,10 +279,9 @@ def parse_player_events(message, game_state, ui):
old_name = rename_match.group(1).strip() old_name = rename_match.group(1).strip()
new_name = rename_match.group(2).strip() new_name = rename_match.group(2).strip()
# Remove trailing color codes from both names # Remove color codes from both names
old_name = re.sub(r'\^\d+$', '', old_name) old_name = strip_color_codes(old_name)
new_name = re.sub(r'^\^\d+', '', new_name) # Remove leading ^7 from new name new_name = strip_color_codes(new_name)
new_name = re.sub(r'\^\d+$', '', new_name) # Remove trailing too
old_name = old_name.rstrip('\n\r') # Remove trailing newline old_name = old_name.rstrip('\n\r') # Remove trailing newline
new_name = new_name.rstrip('\n\r') # Remove trailing newline new_name = new_name.rstrip('\n\r') # Remove trailing newline
@ -234,17 +302,27 @@ def main_loop(screen):
# Setup signal handler for Ctrl+C with confirmation # Setup signal handler for Ctrl+C with confirmation
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
# Parse arguments # Load configuration file (optional)
config = ConfigLoader()
config.load()
# Parse arguments (command line overrides config file)
parser = argparse.ArgumentParser(description='Verbose QuakeLive server statistics') 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('--host', default=config.get_host() or DEFAULT_HOST,
parser.add_argument('--password', required=False, help='RCON password') help=f'ZMQ URI to connect to. Defaults to {DEFAULT_HOST}')
parser.add_argument('--identity', default=uuid.uuid1().hex, help='Socket identity (random UUID by default)') parser.add_argument('--password', default=config.get_password(), required=False,
parser.add_argument('-v', '--verbose', action='count', default=0, help='Increase verbosity (-v INFO, -vv DEBUG)') help='RCON password')
parser.add_argument('--unknown-log', default='unknown_events.log', help='File to log unknown JSON events') parser.add_argument('--identity', default=uuid.uuid1().hex,
parser.add_argument('-j', '--json', dest='json_log', default=None, help='File to log all JSON events') 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() args = parser.parse_args()
# Set logging level # Set logging level
if args.verbose == 0: if args.verbose == 0:
logger.setLevel(logging.WARNING) logger.setLevel(logging.WARNING)
@ -252,41 +330,41 @@ def main_loop(screen):
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
else: else:
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
# Setup file logging for unknown events # Setup file logging for unknown events
unknown_handler = logging.FileHandler(args.unknown_log, mode='a') unknown_handler = logging.FileHandler(args.unknown_log, mode='a')
unknown_formatter = logging.Formatter('%(asctime)s - %(message)s', '%Y-%m-%d %H:%M:%S') unknown_formatter = logging.Formatter('%(asctime)s - %(message)s', '%Y-%m-%d %H:%M:%S')
unknown_handler.setFormatter(unknown_formatter) unknown_handler.setFormatter(unknown_formatter)
unknown_json_logger.addHandler(unknown_handler) unknown_json_logger.addHandler(unknown_handler)
unknown_json_logger.propagate = False unknown_json_logger.propagate = False
# Initialize components # Initialize components
ui = UIManager(screen, args.host) ui = UIManager(screen, args.host)
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)
# Setup input queue # Setup input queue
input_queue = ui.setup_input_queue() input_queue = ui.setup_input_queue()
# 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")
ui.print_message(f"zmq python bindings {zmq.__version__}, libzmq version {zmq.zmq_version()}\n") ui.print_message(f"zmq python bindings {zmq.__version__}, libzmq version {zmq.zmq_version()}\n")
# Initialize network connections # Initialize network connections
rcon = RconConnection(args.host, args.password, args.identity) rcon = RconConnection(args.host, args.password, args.identity)
rcon.connect() rcon.connect()
stats_conn = None stats_conn = None
stats_port = None stats_port = None
stats_password = None stats_password = None
stats_check_counter = 0 stats_check_counter = 0
# Shutdown flag # Shutdown flag
shutdown = False shutdown = False
# Setup JSON logging if requested # Setup JSON logging if requested
json_logger = None json_logger = None
if args.json_log: if args.json_log:
@ -296,145 +374,66 @@ def main_loop(screen):
all_json_logger.addHandler(json_handler) all_json_logger.addHandler(json_handler)
all_json_logger.propagate = False all_json_logger.propagate = False
json_logger = all_json_logger json_logger = all_json_logger
# Create event parser # Create event parser
event_parser = EventParser(game_state, json_logger, unknown_json_logger) 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)
# Check if we need to revive players # Main event loop with resource cleanup
if game_state.server_info.gametype == 'Clan Arena': try:
# CA: revive all players 3s after round end while not shutdown:
if game_state.server_info.round_end_time: # Poll RCON socket
if time.time() - game_state.server_info.round_end_time >= 3.0: event = rcon.poll(POLL_TIMEOUT)
game_state.server_info.dead_players.clear()
game_state.server_info.round_end_time = None
ui.update_server_info(game_state)
else:
# Other modes: revive individual players 3s after death
current_time = time.time()
players_to_revive = [
name for name, death_time in game_state.server_info.dead_players.items()
if current_time - death_time >= 3.0
]
if players_to_revive:
for name in players_to_revive:
del game_state.server_info.dead_players[name]
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])}')
# Check for player connect/disconnect/rename events
if parse_player_events(message, game_state, ui):
continue
if '------- Game Initialization -------' in message or 'Game Initialization' in message:
logger.info('Game initialization detected - refreshing server info')
timestamp = time.strftime('%H:%M:%S') # Check monitor for connection events
ui.print_message(f"^3[^7{timestamp}^3] ^8^3Game initialized - Refreshing server info^0^7\n") 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')
rcon.send_command(b'qlx_serverBrandName') ui.print_message("Requesting connection info...\n")
rcon.send_command(b'g_factoryTitle') rcon.send_command(b'zmq_stats_password')
rcon.send_command(b'mapname') rcon.send_command(b'net_port')
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')
# Clear player list since map changed # Handle user input
game_state.server_info.players = [] handle_user_input(input_queue, rcon, ui)
game_state.player_tracker.player_teams = {}
ui.update_server_info(game_state) # Poll stats stream if connected
stats_check_counter = handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_state)
# Check if we need to revive players
handle_player_respawns(game_state, ui)
# 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])}')
# Check for player connect/disconnect/rename events
if parse_player_events(message, game_state, ui):
continue
if '------- Game Initialization -------' in message or 'Game Initialization' in message:
logger.info('Game initialization detected - refreshing server info')
timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3] ^8^3Game initialized - Refreshing server info^0^7\n")
# Try to parse as cvar response
if parse_cvar_response(message, game_state, ui):
logger.debug('Suppressed cvar response')
continue
# 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'qlx_serverBrandName')
rcon.send_command(b'g_factoryTitle') rcon.send_command(b'g_factoryTitle')
rcon.send_command(b'mapname') rcon.send_command(b'mapname')
@ -443,47 +442,97 @@ def main_loop(screen):
rcon.send_command(b'roundlimit') rcon.send_command(b'roundlimit')
rcon.send_command(b'capturelimit') rcon.send_command(b'capturelimit')
rcon.send_command(b'sv_maxclients') rcon.send_command(b'sv_maxclients')
if args.json_log: # Clear player dict since map changed
ui.print_message(f"*** JSON capture enabled: {args.json_log} ***\n") game_state.server_info.players = {}
game_state.player_tracker.player_teams = {}
except Exception as e: ui.update_server_info(game_state)
timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^1[^7{timestamp}^1] Error: Stats connection failed: {e}^7\n") # Try to parse as cvar response
logger.error(f'Stats connection failed: {e}') if parse_cvar_response(message, game_state, ui):
logger.debug('Suppressed cvar response')
# Try to parse as game event continue
parsed_event = event_parser.parse_event(message)
if parsed_event: # Check for stats connection info
ui.print_message(parsed_event) port, password = handle_stats_connection(message, rcon, ui, game_state)
continue if port:
stats_port = port
# Check if it looks like JSON but wasn't parsed if password:
stripped = message.strip() stats_password = password
if stripped and stripped[0] in ('{', '['):
logger.debug('Unparsed JSON event') # Connect to stats if we have both credentials
continue if stats_port and stats_password and stats_conn is None:
try:
# Try powerup message formatting ui.print_message("Connecting to stats stream...\n")
powerup_msg = format_powerup_message(message, game_state.player_tracker) host_ip = args.host.split('//')[1].split(':')[0]
if powerup_msg:
ui.print_message(powerup_msg) stats_conn = StatsConnection(host_ip, stats_port, stats_password)
continue stats_conn.connect()
# Filter bot debug messages in default mode ui.print_message("Stats stream connected - ready for game events\n")
is_bot_debug = (' entered ' in message and
any(x in message for x in [' seek ', ' battle ', ' chase', ' fight'])) # Request initial server info
if is_bot_debug and args.verbose == 0: logger.info('Sending initial server info queries')
logger.debug(f'Filtered bot debug: {message[:50]}') rcon.send_command(b'qlx_serverBrandName')
continue rcon.send_command(b'g_factoryTitle')
rcon.send_command(b'mapname')
# Check if it's a chat message rcon.send_command(b'timelimit')
if ':' in message and not message.startswith(('print', 'broadcast', 'zmq')): rcon.send_command(b'fraglimit')
message = format_chat_message(message, game_state.player_tracker) rcon.send_command(b'roundlimit')
rcon.send_command(b'capturelimit')
# Format and display message rcon.send_command(b'sv_maxclients')
formatted_msg, attributes = format_message(message)
ui.print_message(formatted_msg) 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)
finally:
# Clean up resources
logger.info("Shutting down...")
if rcon:
logger.debug("Closing RCON connection")
rcon.close()
if stats_conn:
logger.debug("Closing stats connection")
stats_conn.close()
logger.info("Shutdown complete")
if __name__ == '__main__': if __name__ == '__main__':
curses.wrapper(main_loop) curses.wrapper(main_loop)

View File

@ -39,7 +39,7 @@ def check_monitor(monitor):
event_monitor = monitor.recv(zmq.NOBLOCK) event_monitor = 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_id, event_name, event_value = read_socket_event(event_monitor)
event_endpoint = monitor.recv(zmq.NOBLOCK) 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}')
@ -48,7 +48,7 @@ def check_monitor(monitor):
class RconConnection: class RconConnection:
"""RCON connection to Quake Live server""" """RCON connection to Quake Live server"""
def __init__(self, host, password, identity): def __init__(self, host, password, identity):
self.host = host self.host = host
self.password = password self.password = password
@ -56,52 +56,52 @@ class RconConnection:
self.context = None self.context = None
self.socket = None self.socket = None
self.monitor = None self.monitor = None
def connect(self): def connect(self):
"""Initialize connection""" """Initialize connection"""
logger.info('Initializing ZMQ context...') logger.info('Initializing ZMQ context...')
self.context = zmq.Context() self.context = zmq.Context()
logger.info('Creating DEALER socket...') logger.info('Creating DEALER socket...')
self.socket = self.context.socket(zmq.DEALER) self.socket = self.context.socket(zmq.DEALER)
logger.info('Setting up socket monitor...') logger.info('Setting up socket monitor...')
self.monitor = self.socket.get_monitor_socket(zmq.EVENT_ALL) self.monitor = self.socket.get_monitor_socket(zmq.EVENT_ALL)
if self.password: if self.password:
logger.info('Setting password for access') logger.info('Setting password for access')
self.socket.plain_username = b'rcon' self.socket.plain_username = b'rcon'
self.socket.plain_password = self.password.encode('utf-8') self.socket.plain_password = self.password.encode('utf-8')
self.socket.zap_domain = b'rcon' self.socket.zap_domain = b'rcon'
logger.info(f'Setting socket identity: {self.identity}') logger.info(f'Setting socket identity: {self.identity}')
self.socket.setsockopt(zmq.IDENTITY, self.identity.encode('utf-8')) self.socket.setsockopt(zmq.IDENTITY, self.identity.encode('utf-8'))
self.socket.connect(self.host) self.socket.connect(self.host)
logger.info('Connection initiated, waiting for events...') logger.info('Connection initiated, waiting for events...')
def send_command(self, command): def send_command(self, command):
"""Send RCON command""" """Send RCON command"""
if isinstance(command, str): if isinstance(command, str):
command = command.encode('utf-8') command = command.encode('utf-8')
self.socket.send(command) self.socket.send(command)
logger.info(f'Sent command: {command}') logger.info(f'Sent command: {command}')
def poll(self, timeout): def poll(self, timeout):
"""Poll for messages""" """Poll for messages"""
return self.socket.poll(timeout) return self.socket.poll(timeout)
def recv_message(self): def recv_message(self):
"""Receive a message (non-blocking)""" """Receive a message (non-blocking)"""
try: try:
return self.socket.recv(zmq.NOBLOCK).decode('utf-8', errors='replace') return self.socket.recv(zmq.NOBLOCK).decode('utf-8', errors='replace')
except zmq.error.Again: except zmq.error.Again:
return None return None
def check_monitor(self): def check_monitor(self):
"""Check monitor for events""" """Check monitor for events"""
return check_monitor(self.monitor) return check_monitor(self.monitor)
def close(self): def close(self):
"""Close connection""" """Close connection"""
if self.socket: if self.socket:
@ -113,7 +113,7 @@ class RconConnection:
class StatsConnection: class StatsConnection:
"""Stats stream connection (ZMQ SUB socket)""" """Stats stream connection (ZMQ SUB socket)"""
def __init__(self, host, port, password): def __init__(self, host, port, password):
self.host = host self.host = host
self.port = port self.port = port
@ -121,43 +121,43 @@ class StatsConnection:
self.context = None self.context = None
self.socket = None self.socket = None
self.connected = False self.connected = False
def connect(self): def connect(self):
"""Connect to stats stream""" """Connect to stats stream"""
stats_host = f'tcp://{self.host}:{self.port}' stats_host = f'tcp://{self.host}:{self.port}'
logger.info(f'Connecting to stats stream: {stats_host}') logger.info(f'Connecting to stats stream: {stats_host}')
self.context = zmq.Context() self.context = zmq.Context()
self.socket = self.context.socket(zmq.SUB) self.socket = self.context.socket(zmq.SUB)
logger.debug('Stats socket created (SUB type)') logger.debug('Stats socket created (SUB type)')
if self.password and self.password.strip(): if self.password and self.password.strip():
logger.debug('Setting PLAIN authentication') logger.debug('Setting PLAIN authentication')
self.socket.setsockopt(zmq.PLAIN_USERNAME, b'stats') self.socket.setsockopt(zmq.PLAIN_USERNAME, b'stats')
self.socket.setsockopt(zmq.PLAIN_PASSWORD, self.password.encode('utf-8')) self.socket.setsockopt(zmq.PLAIN_PASSWORD, self.password.encode('utf-8'))
self.socket.setsockopt_string(zmq.ZAP_DOMAIN, 'stats') self.socket.setsockopt_string(zmq.ZAP_DOMAIN, 'stats')
logger.debug(f'Connecting to {stats_host}') logger.debug(f'Connecting to {stats_host}')
self.socket.connect(stats_host) self.socket.connect(stats_host)
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(0.5)
self.connected = True self.connected = True
logger.info('Stats stream connected') logger.info('Stats stream connected')
def recv_message(self): def recv_message(self):
"""Receive stats message (non-blocking)""" """Receive stats message (non-blocking)"""
if not self.connected: if not self.connected:
return None return None
try: try:
msg = self.socket.recv(zmq.NOBLOCK) msg = self.socket.recv(zmq.NOBLOCK)
return msg.decode('utf-8', errors='replace') return msg.decode('utf-8', errors='replace')
except zmq.error.Again: except zmq.error.Again:
return None return None
def close(self): def close(self):
"""Close connection""" """Close connection"""
if self.socket: if self.socket:

112
parser.py
View File

@ -25,12 +25,12 @@ def calculate_weapon_accuracies(weapon_data):
class EventParser: class EventParser:
"""Parses JSON game events into formatted messages""" """Parses JSON game events into formatted messages"""
def __init__(self, game_state, json_logger=None, unknown_logger=None): def __init__(self, game_state, json_logger=None, unknown_logger=None):
self.game_state = game_state self.game_state = game_state
self.json_logger = json_logger self.json_logger = json_logger
self.unknown_logger = unknown_logger self.unknown_logger = unknown_logger
def parse_event(self, message): def parse_event(self, message):
""" """
Parse JSON event and return formatted message string Parse JSON event and return formatted message string
@ -38,23 +38,23 @@ class EventParser:
""" """
try: try:
event = json.loads(message) event = json.loads(message)
# Log all JSON if logger is configured # Log all JSON if logger is configured
if self.json_logger: if self.json_logger:
self.json_logger.info('JSON Event received:') self.json_logger.info('JSON Event received:')
self.json_logger.info(json.dumps(event, indent=2)) self.json_logger.info(json.dumps(event, indent=2))
self.json_logger.info('---') self.json_logger.info('---')
if 'TYPE' not in event or 'DATA' not in event: if 'TYPE' not in event or 'DATA' not in event:
logger.debug('JSON missing TYPE or DATA') logger.debug('JSON missing TYPE or DATA')
return None return None
event_type = event['TYPE'] event_type = event['TYPE']
data = event['DATA'] data = event['DATA']
if 'WARMUP' in data: if 'WARMUP' in data:
self.game_state.server_info.warmup = data['WARMUP'] self.game_state.server_info.warmup = data['WARMUP']
# Route to appropriate handler # Route to appropriate handler
handler_map = { handler_map = {
'PLAYER_SWITCHTEAM': self._handle_switchteam, 'PLAYER_SWITCHTEAM': self._handle_switchteam,
@ -68,7 +68,7 @@ class EventParser:
'PLAYER_DISCONNECT': lambda d: None, 'PLAYER_DISCONNECT': lambda d: None,
'ROUND_OVER': self._handle_round_over, 'ROUND_OVER': self._handle_round_over,
} }
handler = handler_map.get(event_type) handler = handler_map.get(event_type)
if handler: if handler:
return handler(data) return handler(data)
@ -79,14 +79,14 @@ class EventParser:
self.unknown_logger.info(f'Unknown event type: {event_type}') self.unknown_logger.info(f'Unknown event type: {event_type}')
self.unknown_logger.info(f'Full JSON: {json.dumps(event, indent=2)}') self.unknown_logger.info(f'Full JSON: {json.dumps(event, indent=2)}')
return None return None
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) as e:
logger.debug(f'Error parsing event: {e}') logger.debug(f'Error parsing event: {e}')
return None return None
def _handle_switchteam(self, data): def _handle_switchteam(self, data):
"""Handle PLAYER_SWITCHTEAM event""" """Handle PLAYER_SWITCHTEAM event"""
@ -96,41 +96,41 @@ class EventParser:
if 'KILLER' not in data: if 'KILLER' not in data:
return None return None
killer = data['KILLER'] killer = data['KILLER']
name = killer.get('NAME', 'Unknown') name = killer.get('NAME', 'Unknown')
team = killer.get('TEAM', '') team = killer.get('TEAM', '')
old_team = killer.get('OLD_TEAM', '') old_team = killer.get('OLD_TEAM', '')
# Update player team # Update player team
self.game_state.player_tracker.update_team(name, team) self.game_state.player_tracker.update_team(name, team)
self.game_state.player_tracker.add_player(name) self.game_state.player_tracker.add_player(name)
if team == old_team: if team == old_team:
return None return None
warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else "" warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else ""
team_messages = { team_messages = {
'FREE': ' ^7joined the ^8fight^0', 'FREE': ' ^7joined the ^8fight^0',
'SPECTATOR': ' ^7joined the ^3Spectators^7', 'SPECTATOR': ' ^7joined the ^3Spectators^7',
'RED': ' ^7joined the ^1RED Team^7', 'RED': ' ^7joined the ^1RED Team^7',
'BLUE': ' ^7joined the ^4BLUE Team^7' 'BLUE': ' ^7joined the ^4BLUE Team^7'
} }
old_team_messages = { old_team_messages = {
'FREE': 'the ^8fight^0', 'FREE': 'the ^8fight^0',
'SPECTATOR': 'the ^3Spectators^7', 'SPECTATOR': 'the ^3Spectators^7',
'RED': '^7the ^1RED Team^7', 'RED': '^7the ^1RED Team^7',
'BLUE': '^7the ^4BLUE Team^7' 'BLUE': '^7the ^4BLUE Team^7'
} }
team_msg = team_messages.get(team, f' ^7joined team {team}^7') team_msg = team_messages.get(team, f' ^7joined team {team}^7')
old_team_msg = old_team_messages.get(old_team, f'team {old_team}') old_team_msg = old_team_messages.get(old_team, f'team {old_team}')
team_prefix = get_team_prefix(name, self.game_state.player_tracker) team_prefix = get_team_prefix(name, self.game_state.player_tracker)
return f"^8^5[SWITCH]^7 {team_prefix}^8{name}^0{team_msg} from {old_team_msg}{warmup}\n" return f"^8^5[SWITCH]^7 {team_prefix}^8{name}^0{team_msg} from {old_team_msg}{warmup}\n"
def _handle_death(self, data): def _handle_death(self, data):
"""Handle PLAYER_DEATH and PLAYER_KILL events""" """Handle PLAYER_DEATH and PLAYER_KILL events"""
@ -140,17 +140,17 @@ class EventParser:
if 'VICTIM' not in data: if 'VICTIM' not in data:
return None return None
victim = data['VICTIM'] victim = data['VICTIM']
victim_name = victim.get('NAME', 'Unknown') victim_name = victim.get('NAME', 'Unknown')
# Check for duplicate # Check for duplicate
time_val = data.get('TIME', 0) time_val = data.get('TIME', 0)
killer_name = data.get('KILLER', {}).get('NAME', '') if data.get('KILLER') else '' 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): if self.game_state.event_deduplicator.is_duplicate('PLAYER_DEATH', time_val, killer_name, victim_name):
return None return None
# Update victim team # Update victim team
if 'TEAM' in victim: if 'TEAM' in victim:
self.game_state.player_tracker.update_team(victim_name, victim['TEAM']) self.game_state.player_tracker.update_team(victim_name, victim['TEAM'])
@ -160,46 +160,46 @@ class EventParser:
if not data.get('WARMUP', False): if not data.get('WARMUP', False):
import time import time
self.game_state.server_info.dead_players[victim_name] = time.time() self.game_state.server_info.dead_players[victim_name] = time.time()
victim_prefix = get_team_prefix(victim_name, self.game_state.player_tracker) victim_prefix = get_team_prefix(victim_name, self.game_state.player_tracker)
warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else "" warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else ""
score_prefix = "" score_prefix = ""
# Environmental death (no killer) # Environmental death (no killer)
if 'KILLER' not in data or not data['KILLER']: if 'KILLER' not in data or not data['KILLER']:
# -1 for environmental death # -1 for environmental death
if not data.get('WARMUP', False): if not data.get('WARMUP', False):
self.game_state.player_tracker.update_score(victim_name, -1) self.game_state.player_tracker.update_score(victim_name, -1)
score_prefix = "^8^1[-1]^7^0 " score_prefix = "^8^1[-1]^7^0 "
mod = data.get('MOD', 'UNKNOWN') mod = data.get('MOD', 'UNKNOWN')
msg_template = DEATH_MESSAGES.get(mod, "%s^8%s^0 ^1DIED FROM %s^7") msg_template = DEATH_MESSAGES.get(mod, "%s^8%s^0 ^1DIED FROM %s^7")
if mod in DEATH_MESSAGES: if mod in DEATH_MESSAGES:
msg = msg_template % (victim_prefix, victim_name) msg = msg_template % (victim_prefix, victim_name)
else: else:
msg = msg_template % (victim_prefix, victim_name, mod) msg = msg_template % (victim_prefix, victim_name, mod)
return f"{score_prefix}{msg}{warmup}\n" return f"{score_prefix}{msg}{warmup}\n"
# Player killed by another player # Player killed by another player
killer = data['KILLER'] killer = data['KILLER']
killer_name = killer.get('NAME', 'Unknown') killer_name = killer.get('NAME', 'Unknown')
# Update killer team # Update killer team
if 'TEAM' in killer: if 'TEAM' in killer:
self.game_state.player_tracker.update_team(killer_name, killer['TEAM']) self.game_state.player_tracker.update_team(killer_name, killer['TEAM'])
self.game_state.player_tracker.add_player(killer_name) self.game_state.player_tracker.add_player(killer_name)
killer_prefix = get_team_prefix(killer_name, self.game_state.player_tracker) killer_prefix = get_team_prefix(killer_name, self.game_state.player_tracker)
# Suicide # Suicide
if killer_name == victim_name: if killer_name == victim_name:
# -1 for suicide # -1 for suicide
if not data.get('WARMUP', False): if not data.get('WARMUP', False):
self.game_state.player_tracker.update_score(victim_name, -1) self.game_state.player_tracker.update_score(victim_name, -1)
score_prefix = "^8^1[-1]^7^0 " score_prefix = "^8^1[-1]^7^0 "
weapon = killer.get('WEAPON', 'OTHER_WEAPON') weapon = killer.get('WEAPON', 'OTHER_WEAPON')
if weapon == 'ROCKET': if weapon == 'ROCKET':
return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7blew herself up.{warmup}\n" return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7blew herself up.{warmup}\n"
@ -211,7 +211,7 @@ class EventParser:
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 return None
# Regular kill: +1 for killer # Regular kill: +1 for killer
if not data.get('WARMUP', False): if not data.get('WARMUP', False):
self.game_state.player_tracker.update_score(killer_name, 1) self.game_state.player_tracker.update_score(killer_name, 1)
@ -219,13 +219,13 @@ class EventParser:
else: else:
score_prefix = "" score_prefix = ""
weapon = killer.get('WEAPON', 'UNKNOWN') weapon = killer.get('WEAPON', 'UNKNOWN')
weapon_name = WEAPON_KILL_NAMES.get(weapon, f'the {weapon}') weapon_name = WEAPON_KILL_NAMES.get(weapon, f'the {weapon}')
hp_left = killer.get('HEALTH', '0 HP') hp_left = int(killer.get('HEALTH', 0))
hp_left_colored = "" hp_left_colored = ""
if hp_left <= 0: # from the grave if hp_left <= 0: # from the grave
hp_left_colored = f"^8^5From the Grave^0" hp_left_colored = f"^8^5From the Grave^0"
elif hp_left < 25: # red elif hp_left < 25: # red
hp_left_colored = f"^8^1{hp_left}^0 ^7HP" hp_left_colored = f"^8^1{hp_left}^0 ^7HP"
@ -235,7 +235,7 @@ class EventParser:
hp_left_colored = f"^8^7{hp_left}^0 ^7HP" hp_left_colored = f"^8^7{hp_left}^0 ^7HP"
else: # green else: # green
hp_left_colored = f"^8^2{hp_left}^0 ^7HP" hp_left_colored = f"^8^2{hp_left}^0 ^7HP"
return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7fragged^7 {victim_prefix}^8{victim_name}^0 ^7with {weapon_name}^0 ^7({hp_left_colored}^7){warmup}\n" return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7fragged^7 {victim_prefix}^8{victim_name}^0 ^7with {weapon_name}^0 ^7({hp_left_colored}^7){warmup}\n"
def _handle_round_over(self, data): def _handle_round_over(self, data):
@ -247,19 +247,19 @@ class EventParser:
team_won = data.get('TEAM_WON') team_won = data.get('TEAM_WON')
round_num = data.get('ROUND', 0) round_num = data.get('ROUND', 0)
if team_won == 'RED': if team_won == 'RED':
self.game_state.server_info.red_rounds += 1 self.game_state.server_info.red_rounds += 1
logger.info(f"Round {round_num}: RED wins (RED: {self.game_state.server_info.red_rounds}, BLUE: {self.game_state.server_info.blue_rounds})") logger.info(f"Round {round_num}: RED wins (RED: {self.game_state.server_info.red_rounds}, BLUE: {self.game_state.server_info.blue_rounds})")
elif team_won == 'BLUE': elif team_won == 'BLUE':
self.game_state.server_info.blue_rounds += 1 self.game_state.server_info.blue_rounds += 1
logger.info(f"Round {round_num}: BLUE wins (RED: {self.game_state.server_info.red_rounds}, BLUE: {self.game_state.server_info.blue_rounds})") logger.info(f"Round {round_num}: BLUE wins (RED: {self.game_state.server_info.red_rounds}, BLUE: {self.game_state.server_info.blue_rounds})")
import time import time
self.game_state.server_info.round_end_time = time.time() self.game_state.server_info.round_end_time = time.time()
return None # Don't display in chat return None # Don't display in chat
def _handle_medal(self, data): def _handle_medal(self, data):
"""Handle PLAYER_MEDAL event""" """Handle PLAYER_MEDAL event"""
@ -271,7 +271,7 @@ class EventParser:
medal = data.get('MEDAL', 'UNKNOWN') medal = data.get('MEDAL', 'UNKNOWN')
warmup = " ^8^3(Warmup)^7^0" if data.get('WARMUP', False) else "" warmup = " ^8^3(Warmup)^7^0" if data.get('WARMUP', False) else ""
medal_prefix = "^8^6[MEDAL]^7^0 " medal_prefix = "^8^6[MEDAL]^7^0 "
team_prefix = get_team_prefix(name, self.game_state.player_tracker) team_prefix = get_team_prefix(name, self.game_state.player_tracker)
# RED Medals (^1) # RED Medals (^1)
if medal in ["FIRSTFRAG", "HUMILIATION", "REVENGE"]: if medal in ["FIRSTFRAG", "HUMILIATION", "REVENGE"]:
@ -293,7 +293,7 @@ class EventParser:
return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^6{medal}^0{warmup}\n" return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^6{medal}^0{warmup}\n"
else: else:
return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^7{medal}^0{warmup}\n" return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^7{medal}^0{warmup}\n"
def _handle_match_started(self, data): def _handle_match_started(self, data):
"""Handle MATCH_STARTED event""" """Handle MATCH_STARTED event"""
@ -303,18 +303,18 @@ class EventParser:
if self.game_state.server_info.is_team_mode(): if self.game_state.server_info.is_team_mode():
return f"^8^2[GAME ON]^0 ^7Match has started - ^1^8RED ^0^7vs. ^4^8BLUE\n" return f"^8^2[GAME ON]^0 ^7Match has started - ^1^8RED ^0^7vs. ^4^8BLUE\n"
players = [] players = []
for player in data.get('PLAYERS', []): for player in data.get('PLAYERS', []):
name = player.get('NAME', 'Unknown') name = player.get('NAME', 'Unknown')
players.append(name) players.append(name)
if players: if players:
formatted = "^0 vs. ^8".join(players) formatted = "^0 vs. ^8".join(players)
return f"^8^2[GAME ON]^0 ^7Match has started - ^8^7{formatted}\n" return f"^8^2[GAME ON]^0 ^7Match has started - ^8^7{formatted}\n"
return None return None
def _handle_match_report(self, data): def _handle_match_report(self, data):
"""Handle MATCH_REPORT event""" """Handle MATCH_REPORT event"""
@ -324,37 +324,37 @@ class EventParser:
if not self.game_state.server_info.is_team_mode(): if not self.game_state.server_info.is_team_mode():
return None return None
red_score = int(data.get('TSCORE0', '0')) red_score = int(data.get('TSCORE0', '0'))
blue_score = int(data.get('TSCORE1', '0')) blue_score = int(data.get('TSCORE1', '0'))
report_prefix = "^8^1[GAME OVER]" report_prefix = "^8^1[GAME OVER]"
if red_score > blue_score: if red_score > blue_score:
return f"{report_prefix} ^7The ^1RED TEAM ^7WINS^0 by a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n" return f"{report_prefix} ^7The ^1RED TEAM ^7WINS^0 by a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n"
elif blue_score > red_score: elif blue_score > red_score:
return f"{report_prefix} ^7The ^4BLUE TEAM ^7WINS^0 by a score of ^8^4{blue_score} ^0^7to ^8^1{red_score}\n" return f"{report_prefix} ^7The ^4BLUE TEAM ^7WINS^0 by a score of ^8^4{blue_score} ^0^7to ^8^1{red_score}\n"
else: else:
return f"{report_prefix} ^7The match is a TIE^0 with a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n" return f"{report_prefix} ^7The match is a TIE^0 with a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n"
def _handle_player_stats(self, data): def _handle_player_stats(self, data):
"""Handle PLAYER_STATS event""" """Handle PLAYER_STATS event"""
name = data.get('NAME', 'Unknown') name = data.get('NAME', 'Unknown')
team_prefix = get_team_prefix(name, self.game_state.player_tracker) team_prefix = get_team_prefix(name, self.game_state.player_tracker)
kills = int(data.get('KILLS', '0')) kills = int(data.get('KILLS', '0'))
deaths = int(data.get('DEATHS', '0')) deaths = int(data.get('DEATHS', '0'))
weapon_data = data.get('WEAPONS', {}) weapon_data = data.get('WEAPONS', {})
accuracies = calculate_weapon_accuracies(weapon_data) accuracies = calculate_weapon_accuracies(weapon_data)
if not accuracies: if not accuracies:
return None return None
best_weapon = max(accuracies, key=accuracies.get) best_weapon = max(accuracies, key=accuracies.get)
best_accuracy = accuracies[best_weapon] * 100 best_accuracy = accuracies[best_weapon] * 100
weapon_stats = weapon_data.get(best_weapon, {}) weapon_stats = weapon_data.get(best_weapon, {})
best_weapon_kills = int(weapon_stats.get('K', 0)) best_weapon_kills = int(weapon_stats.get('K', 0))
weapon_name = WEAPON_NAMES.get(best_weapon, best_weapon) weapon_name = WEAPON_NAMES.get(best_weapon, best_weapon)
return f"^8^5[PLAYER STATS]^7^0 {team_prefix}^8^7{name}^0^7 K/D: {kills}/{deaths} | Best Weapon: {weapon_name} - Acc: {best_accuracy:.2f}% - Kills: {best_weapon_kills}\n" return f"^8^5[PLAYER STATS]^7^0 {team_prefix}^8^7{name}^0^7 K/D: {kills}/{deaths} | Best Weapon: {weapon_name} - Acc: {best_accuracy:.2f}% - Kills: {best_weapon_kills}\n"

View File

@ -1,10 +1,20 @@
#/usr/bin/env bash #!/usr/bin/env bash
# #
# Helper script to connect to different Quake Live servers
# Set QLPYCON_PASSWORD environment variable or create ~/.qlpycon.conf
workdir="/home/xbl/gits/qlpycon" workdir="/home/xbl/gitz/qlpycon"
password="$(cat $workdir/rconpw.txt)" password="${QLPYCON_PASSWORD:-}"
serverip="10.13.12.93" serverip="10.13.12.93"
# Check if password is set
if [ -z "$password" ]; then
echo "Error: QLPYCON_PASSWORD environment variable not set"
echo "Set it with: export QLPYCON_PASSWORD='your_password'"
echo "Or create ~/.qlpycon.conf with [connection] section"
exit 1
fi
cd "$workdir" cd "$workdir"
source venv/bin/activate source venv/bin/activate

147
qlpycon_config.py Normal file
View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""
Configuration file handler for QLPyCon
Supports loading from ~/.qlpycon.conf or ./qlpycon.conf
"""
import os
import configparser
import logging
logger = logging.getLogger('config_loader')
class ConfigLoader:
"""Load configuration from INI file"""
def __init__(self):
self.config = configparser.ConfigParser()
self.config_loaded = False
def load(self):
"""
Try to load config from (in order):
1. ./qlpycon.conf (current directory)
2. ~/.qlpycon.conf (home directory)
"""
config_paths = [
'qlpycon.conf',
os.path.expanduser('~/.qlpycon.conf')
]
for path in config_paths:
if os.path.exists(path):
try:
self.config.read(path)
self.config_loaded = True
logger.info(f'Loaded configuration from: {path}')
return True
except Exception as e:
logger.warning(f'Failed to load config from {path}: {e}')
logger.debug('No configuration file found, using defaults')
return False
def get(self, section, key, fallback=None):
"""Get a configuration value"""
if not self.config_loaded:
return fallback
try:
return self.config.get(section, key, fallback=fallback)
except (configparser.NoSectionError, configparser.NoOptionError):
return fallback
def get_int(self, section, key, fallback=0):
"""Get an integer configuration value"""
value = self.get(section, key)
if value is None:
return fallback
try:
return int(value)
except ValueError:
logger.warning(f'Invalid integer value for [{section}] {key}: {value}')
return fallback
def get_bool(self, section, key, fallback=False):
"""Get a boolean configuration value"""
value = self.get(section, key)
if value is None:
return fallback
return value.lower() in ('true', 'yes', '1', 'on')
def get_host(self):
"""Get connection host"""
return self.get('connection', 'host')
def get_password(self):
"""Get connection password (supports ${ENV_VAR} syntax)"""
password = self.get('connection', 'password')
if not password:
return None
# Support environment variable substitution: ${VAR_NAME}
if password.startswith('${') and password.endswith('}'):
env_var = password[2:-1]
return os.environ.get(env_var)
return password
def get_log_level(self):
"""Get logging level"""
level_str = self.get('logging', 'level', 'INFO')
levels = {
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL
}
return levels.get(level_str.upper(), logging.INFO)
def create_example_config():
"""Create an example configuration file"""
config_content = """# QLPyCon Configuration File
# Place this file as ~/.qlpycon.conf or ./qlpycon.conf
[connection]
# Server connection settings
host = tcp://10.13.12.93:28969
# Use ${ENV_VAR} to read from environment
password = ${QLPYCON_PASSWORD}
[logging]
# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
level = INFO
[ui]
# Max command history entries
max_history = 10
# Color scheme (future feature)
color_scheme = quake
[behavior]
# Quit confirmation timeout (seconds)
quit_timeout = 3.0
# Player respawn delay (seconds)
respawn_delay = 3.0
"""
example_path = os.path.expanduser('~/.qlpycon.conf.example')
try:
with open(example_path, 'w') as f:
f.write(config_content)
print(f'Created example config: {example_path}')
print(f'Copy to ~/.qlpycon.conf and edit as needed')
return True
except Exception as e:
print(f'Failed to create example config: {e}')
return False
if __name__ == '__main__':
# Create example config when run directly
create_example_config()

149
state.py
View File

@ -13,7 +13,7 @@ logger = logging.getLogger('state')
class ServerInfo: class ServerInfo:
"""Tracks current server information""" """Tracks current server information"""
def __init__(self): def __init__(self):
self.hostname = 'Unknown' self.hostname = 'Unknown'
self.map = 'Unknown' self.map = 'Unknown'
@ -28,13 +28,13 @@ class ServerInfo:
self.red_rounds = 0 self.red_rounds = 0
self.blue_score = 0 self.blue_score = 0
self.blue_rounds = 0 self.blue_rounds = 0
self.players = [] self.players = {} # Changed to dict: {name: {'score': str, 'ping': str}}
self.last_update = 0 self.last_update = 0
self.warmup = False self.warmup = False
self.dead_players = {} self.dead_players = {}
self.round_end_time = None self.round_end_time = None
self.match_time = 0 self.match_time = 0
def is_team_mode(self): def is_team_mode(self):
"""Check if current gametype is a team mode""" """Check if current gametype is a team mode"""
return self.gametype in TEAM_MODES return self.gametype in TEAM_MODES
@ -44,12 +44,12 @@ class ServerInfo:
self.red_rounds = 0 self.red_rounds = 0
self.blue_rounds = 0 self.blue_rounds = 0
self.dead_players.clear() self.dead_players.clear()
def update_from_cvar(self, cvar_name, value): def update_from_cvar(self, cvar_name, value):
"""Update server info from a cvar response""" """Update server info from a cvar response"""
# Normalize cvar name to lowercase for case-insensitive matching # Normalize cvar name to lowercase for case-insensitive matching
cvar_lower = cvar_name.lower() cvar_lower = cvar_name.lower()
mapping = { mapping = {
'qlx_serverbrandname': 'hostname', 'qlx_serverbrandname': 'hostname',
'g_factorytitle': 'gametype', 'g_factorytitle': 'gametype',
@ -60,7 +60,7 @@ class ServerInfo:
'capturelimit': 'capturelimit', 'capturelimit': 'capturelimit',
'sv_maxclients': 'maxclients' 'sv_maxclients': 'maxclients'
} }
attr = mapping.get(cvar_lower) attr = mapping.get(cvar_lower)
if attr: if attr:
# Only strip color codes for non-hostname fields # Only strip color codes for non-hostname fields
@ -74,53 +74,46 @@ class ServerInfo:
class PlayerTracker: class PlayerTracker:
"""Tracks player teams and information""" """Tracks player teams and information"""
def __init__(self, server_info): def __init__(self, server_info):
self.server_info = server_info self.server_info = server_info
self.player_teams = {} self.player_teams = {}
def update_team(self, name, team): def update_team(self, name, team):
"""Update player team. Team can be int or string""" """Update player team. Team can be int or string"""
# Convert numeric team to string # Convert numeric team to string
if isinstance(team, int): if isinstance(team, int):
team = TEAM_MAP.get(team, 'FREE') team = TEAM_MAP.get(team, 'FREE')
if team not in ['RED', 'BLUE', 'FREE', 'SPECTATOR']: if team not in ['RED', 'BLUE', 'FREE', 'SPECTATOR']:
team = 'FREE' team = 'FREE'
# Store both original name and color-stripped version # Store both original name and color-stripped version
self.player_teams[name] = team self.player_teams[name] = team
clean_name = re.sub(r'\^\d', '', name) clean_name = re.sub(r'\^\d', '', name)
if clean_name != name: if clean_name != name:
self.player_teams[clean_name] = team self.player_teams[clean_name] = team
logger.debug(f'Updated team for {name} (clean: {clean_name}): {team}') logger.debug(f'Updated team for {name} (clean: {clean_name}): {team}')
def get_team(self, name): def get_team(self, name):
"""Get player's team""" """Get player's team"""
return self.player_teams.get(name) return self.player_teams.get(name)
def add_player(self, name, score='0', ping='0'): def add_player(self, name, score='0', ping='0'):
"""Add player to server's player list if not exists""" """Add player to server's player dict if not exists"""
clean_name = re.sub(r'\^\d', '', name) # Use original name with color codes as key
if name not in self.server_info.players:
# Check if player already exists (by either name or clean name) self.server_info.players[name] = {
for existing in self.server_info.players: 'score': score,
existing_clean = re.sub(r'\^\d', '', existing['name']) 'ping': ping
if existing['name'] == name or existing_clean == clean_name: }
return # Already exists logger.debug(f'Added player: {name}')
self.server_info.players.append({
'name': name,
'score': score,
'ping': ping
})
def get_players_by_team(self): def get_players_by_team(self):
"""Get players organized by team""" """Get players organized by team"""
teams = {'RED': [], 'BLUE': [], 'SPECTATOR': [], 'FREE': []} teams = {'RED': [], 'BLUE': [], 'SPECTATOR': [], 'FREE': []}
for player in self.server_info.players: for name in self.server_info.players.keys():
name = player['name']
team = self.player_teams.get(name, 'FREE') team = self.player_teams.get(name, 'FREE')
if team not in teams: if team not in teams:
team = 'FREE' team = 'FREE'
@ -130,91 +123,105 @@ class PlayerTracker:
def remove_player(self, name): def remove_player(self, name):
"""Remove player from tracking""" """Remove player from tracking"""
clean_name = re.sub(r'\^\d', '', name) clean_name = re.sub(r'\^\d', '', name)
# Count before removal # Try to remove by exact name first
before_count = len(self.server_info.players) removed = self.server_info.players.pop(name, None)
# Remove from player list - check both original and clean names # If not found, try to find by clean name
self.server_info.players = [ if not removed:
p for p in self.server_info.players for player_name in list(self.server_info.players.keys()):
if p['name'] != name and re.sub(r'\^\d', '', p['name']) != clean_name if re.sub(r'\^\d', '', player_name) == clean_name:
] removed = self.server_info.players.pop(player_name)
logger.info(f'Removed player: {player_name} (matched clean name: {clean_name})')
# Log if anything was actually removed break
after_count = len(self.server_info.players)
if before_count != after_count:
logger.info(f'Removed player: {name} (clean: {clean_name}) - {before_count} -> {after_count}')
else: else:
logger.info(f'Removed player: {name}')
if not removed:
logger.warning(f'Player not found for removal: {name} (clean: {clean_name})') logger.warning(f'Player not found for removal: {name} (clean: {clean_name})')
# Remove from team tracking - both versions # Remove from team tracking
self.player_teams.pop(name, None) self.player_teams.pop(name, None)
self.player_teams.pop(clean_name, None) self.player_teams.pop(clean_name, None)
def rename_player(self, old_name, new_name): def rename_player(self, old_name, new_name):
"""Rename a player while maintaining their team""" """Rename a player while maintaining their team and score"""
old_clean = re.sub(r'\^\d', '', old_name) old_clean = re.sub(r'\^\d', '', old_name)
# Get current team (try both names) # Get current team (try both names)
team = self.player_teams.get(old_name) or self.player_teams.get(old_clean, 'SPECTATOR') team = self.player_teams.get(old_name) or self.player_teams.get(old_clean, 'SPECTATOR')
# Find and update player in server list # Find player data by old name
for player in self.server_info.players: player_data = self.server_info.players.pop(old_name, None)
if player['name'] == old_name or re.sub(r'\^\d', '', player['name']) == old_clean:
player['name'] = new_name # If not found by exact name, try clean name
break if not player_data:
for player_name in list(self.server_info.players.keys()):
if re.sub(r'\^\d', '', player_name) == old_clean:
player_data = self.server_info.players.pop(player_name)
break
# Add player with new name
if player_data:
self.server_info.players[new_name] = player_data
# Remove old team entries # Remove old team entries
self.player_teams.pop(old_name, None) self.player_teams.pop(old_name, None)
self.player_teams.pop(old_clean, None) self.player_teams.pop(old_clean, None)
# Add new team entries with color codes preserved # Add new team entries with color codes preserved
self.update_team(new_name, team) self.update_team(new_name, team)
logger.debug(f'Renamed player: {old_name} -> {new_name} (team: {team})') logger.debug(f'Renamed player: {old_name} -> {new_name} (team: {team})')
def update_score(self, name, delta): def update_score(self, name, delta):
"""Update player's score by delta (+1 for kill, -1 for death/suicide)""" """Update player's score by delta (+1 for kill, -1 for death/suicide)"""
# Try exact name first (O(1) lookup)
if name in self.server_info.players:
current_score = int(self.server_info.players[name].get('score', 0))
self.server_info.players[name]['score'] = str(current_score + delta)
logger.debug(f"Score update: {name} {delta:+d} -> {self.server_info.players[name]['score']}")
return
# Fallback: search by clean name (rare case)
clean_name = re.sub(r'\^\d', '', name) clean_name = re.sub(r'\^\d', '', name)
for player_name, player_data in self.server_info.players.items():
for player in self.server_info.players: if re.sub(r'\^\d', '', player_name) == clean_name:
player_clean = re.sub(r'\^\d', '', player['name']) current_score = int(player_data.get('score', 0))
if player['name'] == name or player_clean == clean_name: player_data['score'] = str(current_score + delta)
current_score = int(player.get('score', 0)) logger.debug(f"Score update: {player_name} {delta:+d} -> {player_data['score']}")
player['score'] = str(current_score + delta)
logger.debug(f"Score update: {name} {delta:+d} -> {player['score']}")
return return
logger.warning(f"Could not update score for {name} - player not found") logger.warning(f"Could not update score for {name} - player not found")
class EventDeduplicator: class EventDeduplicator:
"""Prevents duplicate kill/death events""" """Prevents duplicate kill/death events"""
def __init__(self): def __init__(self):
self.recent_events = [] self.recent_events = []
def is_duplicate(self, event_type, time_val, killer_name, victim_name): def is_duplicate(self, event_type, time_val, killer_name, victim_name):
"""Check if this kill/death event is a duplicate""" """Check if this kill/death event is a duplicate"""
if event_type not in ('PLAYER_DEATH', 'PLAYER_KILL'): if event_type not in ('PLAYER_DEATH', 'PLAYER_KILL'):
return False return False
signature = f"KILL:{time_val}:{killer_name}:{victim_name}" signature = f"KILL:{time_val}:{killer_name}:{victim_name}"
if signature in self.recent_events: if signature in self.recent_events:
logger.debug(f'Duplicate event: {signature}') logger.debug(f'Duplicate event: {signature}')
return True return True
# Add to recent events # Add to recent events
self.recent_events.append(signature) self.recent_events.append(signature)
if len(self.recent_events) > MAX_RECENT_EVENTS: if len(self.recent_events) > MAX_RECENT_EVENTS:
self.recent_events.pop(0) self.recent_events.pop(0)
return False return False
class GameState: class GameState:
"""Main game state container""" """Main game state container"""
def __init__(self): def __init__(self):
self.server_info = ServerInfo() self.server_info = ServerInfo()
self.player_tracker = PlayerTracker(self.server_info) self.player_tracker = PlayerTracker(self.server_info)

386
ui.py
View File

@ -9,18 +9,19 @@ import curses.textpad
import threading import threading
import queue import queue
import logging import logging
from config import COLOR_PAIRS, INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, TEAM_MODES from config import COLOR_PAIRS, 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
logger = logging.getLogger('ui') 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):
logging.Handler.__init__(self) logging.Handler.__init__(self)
self.window = window self.window = window
def emit(self, record): def emit(self, record):
try: try:
msg = self.format(record) msg = self.format(record)
@ -35,7 +36,7 @@ class CursesHandler(logging.Handler):
curses.doupdate() curses.doupdate()
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
raise raise
except: except Exception:
self.handleError(record) self.handleError(record)
def print_colored(window, message, attributes=0): def print_colored(window, message, attributes=0):
@ -46,12 +47,12 @@ def print_colored(window, message, attributes=0):
if not curses.has_colors: if not curses.has_colors:
window.addstr(message) window.addstr(message)
return return
color = 0 color = 0
bold = False bold = False
underline = False underline = False
parse_color = False parse_color = False
for ch in message: for ch in message:
val = ord(ch) val = ord(ch)
if parse_color: if parse_color:
@ -74,10 +75,120 @@ def print_colored(window, message, attributes=0):
parse_color = True parse_color = True
else: else:
window.addch(ch, curses.color_pair(color) | (curses.A_BOLD if bold else 0) | (curses.A_UNDERLINE if underline else 0) | attributes) window.addch(ch, curses.color_pair(color) | (curses.A_BOLD if bold else 0) | (curses.A_UNDERLINE if underline else 0) | attributes)
def update_autocomplete_display(window, current_input, first_word, words, ends_with_space):
"""
Update autocomplete display based on current input state.
Returns (suggestions, suggestion_index, original_word) tuple for Tab cycling.
Handles three display modes:
1. Command autocomplete (typing partial command)
2. Signature display (command recognized, showing arguments)
3. Argument value suggestions (typing argument values)
"""
suggestions = []
suggestion_index = -1
original_word = ""
# Check if this is a command with argument definitions
if first_word in COMMAND_ARGUMENTS:
# Determine if user is typing arguments (not just the command)
if len(words) == 1 and not ends_with_space:
# Just command, no space yet → show signature with first arg highlighted
sig_parts = get_signature_with_highlight(first_word, 0)
if sig_parts:
x_pos = 0
for arg_text, is_current in sig_parts:
try:
if is_current:
window.addstr(1, x_pos, arg_text, curses.A_REVERSE)
else:
window.addstr(1, x_pos, arg_text, curses.A_DIM)
x_pos += len(arg_text) + 1
except:
pass
else:
# User is typing arguments
if ends_with_space:
# Starting new argument (empty so far)
arg_position = len(words) - 1 # -1 for command
current_value = ''
else:
# Typing current argument
arg_position = len(words) - 2 # -1 for command, -1 for 0-indexed
current_value = words[-1]
# Get argument suggestions
arg_suggestions = get_argument_suggestions(
first_word,
arg_position,
current_value,
player_list=None # TODO: pass player list from game_state
)
if arg_suggestions:
# Show argument value suggestions with label (limit to 10 for performance)
arg_type = COMMAND_ARGUMENTS[first_word][arg_position]['type']
display_suggestions = arg_suggestions[:10]
more_indicator = f' (+{len(arg_suggestions)-10} more)' if len(arg_suggestions) > 10 else ''
match_line = f'<{arg_type}>: {" ".join(display_suggestions)}{more_indicator}'
try:
window.addstr(1, 0, match_line, curses.A_DIM)
except:
pass
suggestions = arg_suggestions # Store for Tab cycling
suggestion_index = -1
original_word = current_value
else:
# No suggestions (freetext, player without list, etc.) → show signature
sig_parts = get_signature_with_highlight(first_word, arg_position)
if sig_parts:
x_pos = 0
for arg_text, is_current in sig_parts:
try:
if is_current:
window.addstr(1, x_pos, arg_text, curses.A_REVERSE)
else:
window.addstr(1, x_pos, arg_text, curses.A_DIM)
x_pos += len(arg_text) + 1
except:
pass
elif first_word in COMMAND_SIGNATURES and COMMAND_SIGNATURES[first_word]:
# Command with signature but no argument definitions
sig_parts = get_signature_with_highlight(first_word, 0)
if sig_parts:
x_pos = 0
for arg_text, is_current in sig_parts:
try:
if is_current:
window.addstr(1, x_pos, arg_text, curses.A_REVERSE)
else:
window.addstr(1, x_pos, arg_text, curses.A_DIM)
x_pos += len(arg_text) + 1
except:
pass
else:
# Not a recognized command → show command autocomplete
current_word = words[-1]
if len(current_word) >= 2:
suggestions = autocomplete(current_word, max_results=5)
suggestion_index = -1
original_word = current_word
if suggestions:
match_line = ' '.join(suggestions)
try:
window.addstr(1, 0, match_line, curses.A_DIM)
except:
pass
return suggestions, suggestion_index, original_word
class UIManager: class UIManager:
"""Manages curses windows and display""" """Manages curses windows and display"""
def __init__(self, screen, host): def __init__(self, screen, host):
self.screen = screen self.screen = screen
self.host = host self.host = host
@ -88,7 +199,7 @@ class UIManager:
self.input_queue = None self.input_queue = None
self.command_history = [] self.command_history = []
self.history_index = -1 self.history_index = -1
self._init_curses() self._init_curses()
self._create_windows() self._create_windows()
@ -100,23 +211,23 @@ class UIManager:
curses.start_color() curses.start_color()
curses.use_default_colors() curses.use_default_colors()
curses.cbreak() curses.cbreak()
curses.curs_set(0) curses.curs_set(1) # Show cursor in input window
self.screen.addstr(f"Quake Live PyCon: {self.host}") self.screen.addstr(f"Quake Live PyCon: {self.host}")
self.screen.noutrefresh() self.screen.noutrefresh()
# Initialize color pairs # Initialize color pairs
for i in range(1, 7): for i in range(1, 7):
curses.init_pair(i, i, 0) curses.init_pair(i, i, 0)
# Swap cyan and magenta (5 and 6) # Swap cyan and magenta (5 and 6)
curses.init_pair(5, 6, 0) curses.init_pair(5, 6, 0)
curses.init_pair(6, 5, 0) curses.init_pair(6, 5, 0)
def _create_windows(self): def _create_windows(self):
"""Create all UI windows""" """Create all UI windows"""
maxy, maxx = self.screen.getmaxyx() maxy, maxx = self.screen.getmaxyx()
# Server info window (top) # Server info window (top)
self.info_window = curses.newwin( self.info_window = curses.newwin(
INFO_WINDOW_HEIGHT, INFO_WINDOW_HEIGHT,
@ -128,7 +239,7 @@ class UIManager:
self.info_window.idlok(False) self.info_window.idlok(False)
self.info_window.leaveok(True) self.info_window.leaveok(True)
self.info_window.noutrefresh() self.info_window.noutrefresh()
# Output window (middle - main display) # Output window (middle - main display)
self.output_window = curses.newwin( self.output_window = curses.newwin(
maxy - 17, maxy - 17,
@ -154,7 +265,7 @@ class UIManager:
self.divider_window.idlok(False) self.divider_window.idlok(False)
self.divider_window.leaveok(True) self.divider_window.leaveok(True)
self.divider_window.noutrefresh() self.divider_window.noutrefresh()
# Input window (bottom) # Input window (bottom)
self.input_window = curses.newwin( self.input_window = curses.newwin(
INPUT_WINDOW_HEIGHT, INPUT_WINDOW_HEIGHT,
@ -169,12 +280,12 @@ class UIManager:
self.input_window.idcok(True) self.input_window.idcok(True)
self.input_window.leaveok(False) self.input_window.leaveok(False)
self.input_window.noutrefresh() self.input_window.noutrefresh()
self.screen.noutrefresh() self.screen.noutrefresh()
curses.doupdate() curses.doupdate()
def setup_input_queue(self): def setup_input_queue(self):
"""Setup threaded input queue with command history""" """Setup threaded input queue with command history and autocomplete"""
def wait_stdin(q, window, manager): def wait_stdin(q, window, manager):
current_input = "" current_input = ""
cursor_pos = 0 cursor_pos = 0
@ -182,6 +293,11 @@ class UIManager:
temp_input = "" # Temp storage when navigating history temp_input = "" # Temp storage when navigating history
quit_confirm = False quit_confirm = False
# Autocomplete state
suggestions = []
suggestion_index = -1
original_word = "" # Store original word before cycling
while True: while True:
try: try:
key = window.getch() key = window.getch()
@ -189,12 +305,82 @@ class UIManager:
if key == -1: # No input if key == -1: # No input
continue continue
# Tab key - cycle through suggestions
if key == ord('\t') or key == 9:
if suggestions:
# Cycle to next suggestion
suggestion_index = (suggestion_index + 1) % len(suggestions)
# Replace or append suggestion
words = current_input.split()
if words:
# If original_word is empty, we had trailing space - append new word
# Otherwise, replace current word
if original_word == '':
words.append(suggestions[suggestion_index])
# Update original_word so next Tab replaces instead of appending
original_word = suggestions[suggestion_index]
else:
words[-1] = suggestions[suggestion_index]
current_input = ' '.join(words)
cursor_pos = len(current_input)
# Update display
window.erase()
window.addstr(0, 0, current_input)
# Determine display format
first_word = words[0].lower()
selected_value = suggestions[suggestion_index]
# Check if we're cycling argument values or commands
if first_word in COMMAND_ARGUMENTS and len(words) > 1:
# Cycling argument values - show with label
ends_with_space = current_input.endswith(' ')
if ends_with_space:
arg_position = len(words) - 1
else:
arg_position = len(words) - 2
# Bounds check to prevent index out of range
if arg_position < len(COMMAND_ARGUMENTS[first_word]):
arg_type = COMMAND_ARGUMENTS[first_word][arg_position]['type']
# Show only first 10 suggestions for performance
display_suggestions = suggestions[:10]
more_indicator = f' (+{len(suggestions)-10} more)' if len(suggestions) > 10 else ''
display_line = f'<{arg_type}>: {" ".join(display_suggestions)}{more_indicator}'
else:
# Fallback if position out of bounds
display_line = ' '.join(suggestions[:10])
else:
# Cycling commands - show with signature if available
display_suggestions = suggestions[:10]
match_line = ' '.join(display_suggestions)
if selected_value in COMMAND_SIGNATURES:
signature = COMMAND_SIGNATURES[selected_value]
if signature:
display_line = f"{match_line}{signature}"
else:
display_line = match_line
else:
display_line = match_line
try:
window.addstr(1, 0, display_line, curses.A_DIM)
except:
pass
window.move(0, cursor_pos)
window.noutrefresh()
curses.doupdate() # Actually push the refresh to screen
continue
# Enter key # Enter key
if key in (curses.KEY_ENTER, 10, 13): if key in (curses.KEY_ENTER, 10, 13):
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) > 10: if len(manager.command_history) > MAX_COMMAND_HISTORY:
manager.command_history.pop(0) manager.command_history.pop(0)
q.put(current_input) q.put(current_input)
@ -202,6 +388,9 @@ class UIManager:
cursor_pos = 0 cursor_pos = 0
temp_history_index = -1 temp_history_index = -1
temp_input = "" temp_input = ""
suggestions = []
suggestion_index = -1
original_word = ""
window.erase() window.erase()
window.noutrefresh() window.noutrefresh()
@ -217,6 +406,9 @@ class UIManager:
temp_history_index -= 1 temp_history_index -= 1
current_input = manager.command_history[temp_history_index] current_input = manager.command_history[temp_history_index]
cursor_pos = len(current_input) cursor_pos = len(current_input)
suggestions = []
suggestion_index = -1
original_word = ""
window.erase() window.erase()
window.addstr(0, 0, current_input) window.addstr(0, 0, current_input)
window.noutrefresh() window.noutrefresh()
@ -234,6 +426,9 @@ class UIManager:
current_input = manager.command_history[temp_history_index] current_input = manager.command_history[temp_history_index]
cursor_pos = len(current_input) cursor_pos = len(current_input)
suggestions = []
suggestion_index = -1
original_word = ""
window.erase() window.erase()
window.addstr(0, 0, current_input) window.addstr(0, 0, current_input)
window.noutrefresh() window.noutrefresh()
@ -244,7 +439,7 @@ class UIManager:
cursor_pos -= 1 cursor_pos -= 1
window.move(0, cursor_pos) window.move(0, cursor_pos)
window.noutrefresh() window.noutrefresh()
# Arrow RIGHT - move cursor right # Arrow RIGHT - move cursor right
elif key == curses.KEY_RIGHT: elif key == curses.KEY_RIGHT:
if cursor_pos < len(current_input): if cursor_pos < len(current_input):
@ -258,10 +453,23 @@ class UIManager:
current_input = current_input[:cursor_pos-1] + current_input[cursor_pos:] current_input = current_input[:cursor_pos-1] + current_input[cursor_pos:]
cursor_pos -= 1 cursor_pos -= 1
temp_history_index = -1 # Exit history mode temp_history_index = -1 # Exit history mode
window.erase() window.erase()
window.addstr(0, 0, current_input) window.addstr(0, 0, current_input)
# Parse input and update autocomplete display
words = current_input.split()
ends_with_space = current_input.endswith(' ')
if words:
first_word = words[0].lower()
suggestions, suggestion_index, original_word = update_autocomplete_display(
window, current_input, first_word, words, ends_with_space
)
window.move(0, cursor_pos) window.move(0, cursor_pos)
window.noutrefresh() window.noutrefresh()
curses.doupdate() # Immediate screen update
# Regular character # Regular character
elif 32 <= key <= 126: elif 32 <= key <= 126:
@ -269,14 +477,28 @@ class UIManager:
current_input = current_input[:cursor_pos] + char + current_input[cursor_pos:] current_input = current_input[:cursor_pos] + char + current_input[cursor_pos:]
cursor_pos += 1 cursor_pos += 1
temp_history_index = -1 # Exit history mode temp_history_index = -1 # Exit history mode
window.erase() window.erase()
window.addstr(0, 0, current_input) window.addstr(0, 0, current_input)
# Parse input and update autocomplete display
words = current_input.split()
ends_with_space = current_input.endswith(' ')
if words:
first_word = words[0].lower()
suggestions, suggestion_index, original_word = update_autocomplete_display(
window, current_input, first_word, words, ends_with_space
)
window.move(0, cursor_pos) window.move(0, cursor_pos)
window.noutrefresh() window.noutrefresh()
curses.doupdate() # Immediate screen update
except Exception as e: except Exception as e:
import logging import logging
logging.getLogger('ui').error(f'Input error: {e}') logging.getLogger('ui').error(f'Input error: {e}')
# Log but continue - input thread should stay alive
window.move(0, cursor_pos) window.move(0, cursor_pos)
curses.doupdate() curses.doupdate()
@ -287,14 +509,14 @@ class UIManager:
t.start() t.start()
return self.input_queue return self.input_queue
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)
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"""
print_colored(self.output_window, message, attributes) print_colored(self.output_window, message, attributes)
@ -305,16 +527,15 @@ class UIManager:
def update_server_info(self, game_state): def update_server_info(self, game_state):
"""Update server info window""" """Update server info window"""
self.info_window.erase() self.info_window.erase()
max_y, max_x = self.info_window.getmaxyx() max_y, max_x = self.info_window.getmaxyx()
server_info = game_state.server_info server_info = game_state.server_info
# Line 1: Hostname with Timer and Warmup Indicator # Line 1: Hostname with Timer and Warmup Indicator
hostname = server_info.hostname hostname = server_info.hostname
timer_display = "" timer_display = ""
if not server_info.warmup: if server_info.match_time > 0 and not server_info.warmup:
#if server_info.match_time > 0 and not server_info.warmup:
mins = server_info.match_time // 60 mins = server_info.match_time // 60
secs = server_info.match_time % 60 secs = server_info.match_time % 60
timer_display = f"^3^0Time:^8^7 {mins}:{secs:02d}^0" timer_display = f"^3^0Time:^8^7 {mins}:{secs:02d}^0"
@ -324,7 +545,7 @@ class UIManager:
warmup_display = "^3^0Warmup:^8 ^2YES^0" if server_info.warmup else "^3^0Warmup: ^8^1NO^0" warmup_display = "^3^0Warmup:^8 ^2YES^0" if server_info.warmup else "^3^0Warmup: ^8^1NO^0"
print_colored(self.info_window, f"^3Name:^8 {hostname} {warmup_display} {timer_display}\n", 0) print_colored(self.info_window, f"^3Name:^8 {hostname} {warmup_display} {timer_display}\n", 0)
# Line 2: Game info # Line 2: Game info
gametype = server_info.gametype gametype = server_info.gametype
mapname = server_info.map mapname = server_info.map
@ -346,17 +567,17 @@ class UIManager:
limit_display = f"^3^0| Timelimit:^7^8 {timelimit}" limit_display = f"^3^0| Timelimit:^7^8 {timelimit}"
else: else:
limit_display = f"^3^0| Timelimit:^7^8 {timelimit} ^0^3| Fraglimit:^7^8 {fraglimit}" limit_display = f"^3^0| Timelimit:^7^8 {timelimit} ^0^3| Fraglimit:^7^8 {fraglimit}"
print_colored(self.info_window, print_colored(self.info_window,
f"^3^0Type:^7^8 {gametype} ^0^3| Map:^7^8 {mapname} ^0^3| Players:^7^8 {curclients}/{maxclients} " f"^3^0Type:^7^8 {gametype} ^0^3| Map:^7^8 {mapname} ^0^3| Players:^7^8 {curclients}/{maxclients} "
f"{limit_display}^0\n", 0) f"{limit_display}^0\n", 0)
# Blank lines to fill # Blank lines to fill
self.info_window.addstr("\n") self.info_window.addstr("\n")
# Line 3: Team headers and player lists # Line 3: Team headers and player lists
teams = game_state.player_tracker.get_players_by_team() teams = game_state.player_tracker.get_players_by_team()
if server_info.gametype in TEAM_MODES: if server_info.gametype in TEAM_MODES:
if server_info.gametype == 'Clan Arena': if server_info.gametype == 'Clan Arena':
red_score = f"{server_info.red_rounds:>3} " red_score = f"{server_info.red_rounds:>3} "
@ -365,82 +586,63 @@ class UIManager:
else: else:
red_total = 0 red_total = 0
blue_total = 0 blue_total = 0
for player in server_info.players: for player_name, player_data in server_info.players.items():
player_name = player['name']
team = game_state.player_tracker.get_team(player_name) team = game_state.player_tracker.get_team(player_name)
score = int(player.get('score', 0)) score = int(player_data.get('score', 0))
if team == 'RED': if team == 'RED':
red_total += score red_total += score
elif team == 'BLUE': elif team == 'BLUE':
blue_total += score blue_total += score
red_score = f"{red_total:>3} " red_score = f"{red_total:>3} "
blue_score = f"{blue_total:>3} " blue_score = f"{blue_total:>3} "
print_colored(self.info_window, f"^8^7{red_score}^9^1RED TEAM^0 ^7^8{blue_score}^9^4BLUE TEAM\n", 0) print_colored(self.info_window, f"^8^7{red_score}^9^1RED TEAM^0 ^7^8{blue_score}^9^4BLUE TEAM\n", 0)
# Sort players by score within each team # Sort players by score within each team
red_players_with_scores = [] red_players_with_scores = []
blue_players_with_scores = [] blue_players_with_scores = []
spec_players = [] spec_players = []
for player_name in teams['RED']: for player_name in teams['RED']:
score = 0 score = int(server_info.players.get(player_name, {}).get('score', 0))
for player in server_info.players:
if player['name'] == player_name:
score = int(player.get('score', 0))
break
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 = 0 score = int(server_info.players.get(player_name, {}).get('score', 0))
for player in server_info.players:
if player['name'] == player_name:
score = int(player.get('score', 0))
break
blue_players_with_scores.append((player_name, score)) blue_players_with_scores.append((player_name, score))
# Sort by score descending # Sort by score descending
red_players_with_scores.sort(key=lambda x: x[1], reverse=True) red_players_with_scores.sort(key=lambda x: x[1], reverse=True)
blue_players_with_scores.sort(key=lambda x: x[1], reverse=True) blue_players_with_scores.sort(key=lambda x: x[1], reverse=True)
red_players = [name for name, score in red_players_with_scores[:4]] red_players = [name for name, score in red_players_with_scores[:4]]
blue_players = [name for name, score in blue_players_with_scores[:4]] blue_players = [name for name, score in blue_players_with_scores[:4]]
spec_players = teams['SPECTATOR'][:4] spec_players = teams['SPECTATOR'][:4]
for i in range(4): for i in range(4):
red_name = red_players[i] if i < len(red_players) else '' red_name = red_players[i] if i < len(red_players) else ''
blue_name = blue_players[i] if i < len(blue_players) else '' blue_name = blue_players[i] if i < len(blue_players) else ''
# Get scores for team players # Get scores for team players
red_score = '' red_score = server_info.players.get(red_name, {}).get('score', '0') if red_name else ''
blue_score = '' blue_score = server_info.players.get(blue_name, {}).get('score', '0') if blue_name else ''
if red_name:
for player in server_info.players:
if player['name'] == red_name:
red_score = player.get('score', '0')
break
if blue_name:
for player in server_info.players:
if player['name'] == blue_name:
blue_score = player.get('score', '0')
break
# Check if players are dead # Check if players are dead
red_dead = red_name in server_info.dead_players red_dead = red_name in server_info.dead_players
blue_dead = blue_name in server_info.dead_players blue_dead = blue_name in server_info.dead_players
# Format with strikethrough for dead players (using dim text) # Format with strikethrough for dead players (using dim text)
red = f"{red_score:>3} {'^8^1X^7^0 ' if red_dead else ''}{red_name}" if red_name else '' red = f"{red_score:>3} {'^8^1X^7^0 ' if red_dead else ''}{red_name}" if red_name else ''
blue = f"{blue_score:>3} {'^8^1X^7^0 ' if blue_dead else ''}{blue_name}" if blue_name else '' blue = f"{blue_score:>3} {'^8^1X^7^0 ' if blue_dead else ''}{blue_name}" if blue_name else ''
from formatter import strip_color_codes from formatter import strip_color_codes
red_clean = strip_color_codes(red) red_clean = strip_color_codes(red)
blue_clean = strip_color_codes(blue) blue_clean = strip_color_codes(blue)
red_pad = 24 - len(red_clean) red_pad = 24 - len(red_clean)
line = f"^8{red}^0{' ' * red_pad}^8{blue}^0\n" line = f"^8{red}^0{' ' * red_pad}^8{blue}^0\n"
print_colored(self.info_window, line, 0) print_colored(self.info_window, line, 0)
else: else:
@ -449,43 +651,29 @@ 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 = 0 score = int(server_info.players.get(player_name, {}).get('score', 0))
for player in server_info.players:
if player['name'] == player_name:
score = int(player.get('score', 0))
break
free_players_with_scores.append((player_name, score)) free_players_with_scores.append((player_name, score))
# Sort by score descending # Sort by score descending
free_players_with_scores.sort(key=lambda x: x[1], reverse=True) free_players_with_scores.sort(key=lambda x: x[1], reverse=True)
sorted_free_players = [name for name, score in free_players_with_scores] sorted_free_players = [name for name, score in free_players_with_scores]
spec_players = teams['SPECTATOR'][:4] spec_players = teams['SPECTATOR'][:4]
free_col1 = sorted_free_players[:4] free_col1 = sorted_free_players[:4]
free_col2 = sorted_free_players[4:8] free_col2 = sorted_free_players[4:8]
for i in range(4): for i in range(4):
col1_name = free_col1[i] if i < len(free_col1) else '' col1_name = free_col1[i] if i < len(free_col1) else ''
col2_name = free_col2[i] if i < len(free_col2) else '' col2_name = free_col2[i] if i < len(free_col2) else ''
# Get scores for FREE players # Get scores for FREE players
col1_score = '' col1_score = server_info.players.get(col1_name, {}).get('score', '0') if col1_name else ''
col2_score = '' col2_score = server_info.players.get(col2_name, {}).get('score', '0') if col2_name else ''
if col1_name:
for player in server_info.players:
if player['name'] == col1_name:
col1_score = player.get('score', '0')
break
if col2_name:
for player in server_info.players:
if player['name'] == col2_name:
col2_score = player.get('score', '0')
break
# Check if players are dead # Check if players are dead
col1_dead = col1_name in server_info.dead_players col1_dead = col1_name in server_info.dead_players
col2_dead = col2_name in server_info.dead_players col2_dead = col2_name in server_info.dead_players
# Format: " 9 PlayerName" with right-aligned score and dead marker # Format: " 9 PlayerName" with right-aligned score and dead marker
col1 = f"{col1_score:>3} {'^8^1X^7^0 ' if col1_dead else ''}{col1_name}" if col1_name else '' col1 = f"{col1_score:>3} {'^8^1X^7^0 ' if col1_dead else ''}{col1_name}" if col1_name else ''
col2 = f"{col2_score:>3} {'^8^1X^7^0 ' if col2_dead else ''}{col2_name}" if col2_name else '' col2 = f"{col2_score:>3} {'^8^1X^7^0 ' if col2_dead else ''}{col2_name}" if col2_name else ''
@ -493,12 +681,12 @@ class UIManager:
from formatter import strip_color_codes from formatter import strip_color_codes
col1_clean = strip_color_codes(col1) col1_clean = strip_color_codes(col1)
col2_clean = strip_color_codes(col2) col2_clean = strip_color_codes(col2)
col1_pad = 24 - len(col1_clean) col1_pad = 24 - len(col1_clean)
line = f"^8{col1}^0{' ' * col1_pad}^8{col2}^0\n" line = f"^8{col1}^0{' ' * col1_pad}^8{col2}^0\n"
print_colored(self.info_window, line, 0) print_colored(self.info_window, line, 0)
# Blank lines to fill # Blank lines to fill
self.info_window.addstr("\n") self.info_window.addstr("\n")
@ -506,14 +694,14 @@ class UIManager:
spec_list = " ".join(spec_players) spec_list = " ".join(spec_players)
line = f"^8^3Spectators:^7 {spec_list}\n" line = f"^8^3Spectators:^7 {spec_list}\n"
print_colored(self.info_window, line, 0) print_colored(self.info_window, line, 0)
# Blank lines to fill # Blank lines to fill
self.info_window.addstr("\n") self.info_window.addstr("\n")
# 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()
curses.doupdate() curses.doupdate()