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,6 +23,9 @@ 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",

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}")

235
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,22 +44,23 @@ 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:
if quit_confirm_time is None or (current_time - quit_confirm_time) > QUIT_CONFIRM_TIMEOUT:
# First Ctrl-C or timeout expired # First Ctrl-C or timeout expired
logger.warning("^1^8Press Ctrl-C again within 3 seconds to quit^0") logger.warning(f"^1^8Press Ctrl-C again within {QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0")
quit_confirm_time = current_time quit_confirm_time = current_time
else: else:
# Second Ctrl-C within 3 seconds # Second Ctrl-C within timeout
logger.warning("^1^8Quittin'^0") logger.warning("^1^8Quittin'^0")
sys.exit(0) sys.exit(0)
@ -63,7 +77,7 @@ def parse_cvar_response(message, game_state, ui):
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)
@ -85,25 +99,85 @@ def handle_stats_connection(message, rcon, ui, game_state):
# 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,15 +201,14 @@ 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')
@ -151,12 +223,11 @@ def parse_player_events(message, game_state, ui):
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)
@ -167,12 +238,11 @@ def parse_player_events(message, game_state, ui):
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)
@ -183,12 +253,11 @@ def parse_player_events(message, game_state, ui):
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)
@ -199,10 +268,10 @@ def parse_player_events(message, game_state, ui):
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
@ -235,14 +303,24 @@ 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
@ -300,7 +378,8 @@ def main_loop(screen):
# 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 # Main event loop with resource cleanup
try:
while not shutdown: while not shutdown:
# Poll RCON socket # Poll RCON socket
event = rcon.poll(POLL_TIMEOUT) event = rcon.poll(POLL_TIMEOUT)
@ -317,54 +396,13 @@ def main_loop(screen):
rcon.send_command(b'net_port') rcon.send_command(b'net_port')
# Handle user input # Handle user input
while not input_queue.empty(): handle_user_input(input_queue, rcon, ui)
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 # Poll stats stream if connected
if stats_conn and stats_conn.connected: stats_check_counter = handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_state)
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 # Check if we need to revive players
if game_state.server_info.gametype == 'Clan Arena': handle_player_respawns(game_state, ui)
# CA: revive all players 3s after round end
if game_state.server_info.round_end_time:
if time.time() - game_state.server_info.round_end_time >= 3.0:
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 # Process RCON messages
if event > 0: if event > 0:
@ -405,8 +443,8 @@ def main_loop(screen):
rcon.send_command(b'capturelimit') rcon.send_command(b'capturelimit')
rcon.send_command(b'sv_maxclients') rcon.send_command(b'sv_maxclients')
# Clear player list since map changed # Clear player dict since map changed
game_state.server_info.players = [] game_state.server_info.players = {}
game_state.player_tracker.player_teams = {} game_state.player_tracker.player_teams = {}
ui.update_server_info(game_state) ui.update_server_info(game_state)
@ -485,5 +523,16 @@ def main_loop(screen):
formatted_msg, attributes = format_message(message) formatted_msg, attributes = format_message(message)
ui.print_message(formatted_msg) 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

@ -223,7 +223,7 @@ class EventParser:
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"

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()

View File

@ -28,7 +28,7 @@ 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 = {}
@ -101,26 +101,19 @@ class PlayerTracker:
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:
existing_clean = re.sub(r'\^\d', '', existing['name'])
if existing['name'] == name or existing_clean == clean_name:
return # Already exists
self.server_info.players.append({
'name': name,
'score': score, 'score': score,
'ping': ping 'ping': ping
}) }
logger.debug(f'Added player: {name}')
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'
@ -131,39 +124,47 @@ class PlayerTracker:
"""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
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 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)
@ -175,14 +176,20 @@ class PlayerTracker:
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)"""
clean_name = re.sub(r'\^\d', '', name) # 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
for player in self.server_info.players: # Fallback: search by clean name (rare case)
player_clean = re.sub(r'\^\d', '', player['name']) clean_name = re.sub(r'\^\d', '', name)
if player['name'] == name or player_clean == clean_name: for player_name, player_data in self.server_info.players.items():
current_score = int(player.get('score', 0)) if re.sub(r'\^\d', '', player_name) == clean_name:
player['score'] = str(current_score + delta) current_score = int(player_data.get('score', 0))
logger.debug(f"Score update: {name} {delta:+d} -> {player['score']}") player_data['score'] = str(current_score + delta)
logger.debug(f"Score update: {player_name} {delta:+d} -> {player_data['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")

286
ui.py
View File

@ -9,7 +9,8 @@ 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')
@ -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):
@ -75,6 +76,116 @@ def print_colored(window, message, attributes=0):
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"""
@ -100,7 +211,7 @@ 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()
@ -174,7 +285,7 @@ class UIManager:
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()
@ -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()
@ -313,8 +535,7 @@ class UIManager:
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"
@ -365,10 +586,9 @@ 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
@ -386,19 +606,11 @@ class UIManager:
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
@ -414,18 +626,8 @@ class UIManager:
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
@ -449,11 +651,7 @@ 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
@ -469,18 +667,8 @@ class UIManager:
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