salidu
This commit is contained in:
240
AUTOCOMPLETE.md
Normal file
240
AUTOCOMPLETE.md
Normal 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
146
README.md
@ -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
|
||||||
|
|||||||
@ -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
559
cvars.py
Normal 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}")
|
||||||
459
main.py
459
main.py
@ -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):
|
||||||
"""
|
"""
|
||||||
@ -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,141 +378,62 @@ 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
|
||||||
while not shutdown:
|
try:
|
||||||
# Poll RCON socket
|
while not shutdown:
|
||||||
event = rcon.poll(POLL_TIMEOUT)
|
# Poll RCON socket
|
||||||
|
event = rcon.poll(POLL_TIMEOUT)
|
||||||
|
|
||||||
# Check monitor for connection events
|
# Check monitor for connection events
|
||||||
monitor_event = rcon.check_monitor()
|
monitor_event = rcon.check_monitor()
|
||||||
if monitor_event and monitor_event[0] == zmq.EVENT_CONNECTED:
|
if monitor_event and monitor_event[0] == zmq.EVENT_CONNECTED:
|
||||||
ui.print_message("Connected to server\n")
|
ui.print_message("Connected to server\n")
|
||||||
rcon.send_command(b'register')
|
rcon.send_command(b'register')
|
||||||
logger.info('Registration message sent')
|
logger.info('Registration message sent')
|
||||||
|
|
||||||
ui.print_message("Requesting connection info...\n")
|
ui.print_message("Requesting connection info...\n")
|
||||||
rcon.send_command(b'zmq_stats_password')
|
rcon.send_command(b'zmq_stats_password')
|
||||||
rcon.send_command(b'net_port')
|
rcon.send_command(b'net_port')
|
||||||
|
|
||||||
# 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
|
# Poll stats stream if connected
|
||||||
timestamp = time.strftime('%H:%M:%S')
|
stats_check_counter = handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_state)
|
||||||
ui.print_message(f"^5[^7{timestamp}^5] >>> {command.strip()}^7\n")
|
|
||||||
|
|
||||||
rcon.send_command(command)
|
# Check if we need to revive players
|
||||||
|
handle_player_respawns(game_state, ui)
|
||||||
|
|
||||||
# Poll stats stream if connected
|
# Process RCON messages
|
||||||
if stats_conn and stats_conn.connected:
|
if event > 0:
|
||||||
stats_check_counter += 1
|
logger.debug('Socket has data available')
|
||||||
|
msg_count = 0
|
||||||
|
|
||||||
if stats_check_counter % 100 == 0:
|
while True:
|
||||||
logger.debug(f'Stats polling active (check #{stats_check_counter // 100})')
|
message = rcon.recv_message()
|
||||||
|
if message is None:
|
||||||
|
if msg_count > 0:
|
||||||
|
logger.debug(f'Read {msg_count} message(s)')
|
||||||
|
break
|
||||||
|
|
||||||
stats_msg = stats_conn.recv_message()
|
msg_count += 1
|
||||||
if stats_msg:
|
|
||||||
logger.info(f'Stats event received ({len(stats_msg)} bytes)')
|
|
||||||
|
|
||||||
# Parse game event
|
if len(message) == 0:
|
||||||
parsed = event_parser.parse_event(stats_msg)
|
logger.debug('Received empty message (keepalive)')
|
||||||
if parsed:
|
continue
|
||||||
# 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
|
logger.debug(f'Received message ({len(message)} bytes): {repr(message[:100])}')
|
||||||
if game_state.server_info.gametype == 'Clan Arena':
|
|
||||||
# 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
|
# Check for player connect/disconnect/rename events
|
||||||
if event > 0:
|
if parse_player_events(message, game_state, ui):
|
||||||
logger.debug('Socket has data available')
|
continue
|
||||||
msg_count = 0
|
|
||||||
|
|
||||||
while True:
|
if '------- Game Initialization -------' in message or 'Game Initialization' in message:
|
||||||
message = rcon.recv_message()
|
logger.info('Game initialization detected - refreshing server info')
|
||||||
if message is None:
|
|
||||||
if msg_count > 0:
|
|
||||||
logger.debug(f'Read {msg_count} message(s)')
|
|
||||||
break
|
|
||||||
|
|
||||||
msg_count += 1
|
timestamp = time.strftime('%H:%M:%S')
|
||||||
|
ui.print_message(f"^3[^7{timestamp}^3] ^8^3Game initialized - Refreshing server info^0^7\n")
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
rcon.send_command(b'qlx_serverBrandName')
|
|
||||||
rcon.send_command(b'g_factoryTitle')
|
|
||||||
rcon.send_command(b'mapname')
|
|
||||||
rcon.send_command(b'timelimit')
|
|
||||||
rcon.send_command(b'fraglimit')
|
|
||||||
rcon.send_command(b'roundlimit')
|
|
||||||
rcon.send_command(b'capturelimit')
|
|
||||||
rcon.send_command(b'sv_maxclients')
|
|
||||||
|
|
||||||
# Clear player list since map changed
|
|
||||||
game_state.server_info.players = []
|
|
||||||
game_state.player_tracker.player_teams = {}
|
|
||||||
ui.update_server_info(game_state)
|
|
||||||
|
|
||||||
# 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')
|
||||||
@ -444,46 +443,96 @@ 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')
|
||||||
|
|
||||||
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 = {}
|
||||||
|
ui.update_server_info(game_state)
|
||||||
|
|
||||||
except Exception as e:
|
# Try to parse as cvar response
|
||||||
timestamp = time.strftime('%H:%M:%S')
|
if parse_cvar_response(message, game_state, ui):
|
||||||
ui.print_message(f"^1[^7{timestamp}^1] Error: Stats connection failed: {e}^7\n")
|
logger.debug('Suppressed cvar response')
|
||||||
logger.error(f'Stats connection failed: {e}')
|
continue
|
||||||
|
|
||||||
# Try to parse as game event
|
# Check for stats connection info
|
||||||
parsed_event = event_parser.parse_event(message)
|
port, password = handle_stats_connection(message, rcon, ui, game_state)
|
||||||
if parsed_event:
|
if port:
|
||||||
ui.print_message(parsed_event)
|
stats_port = port
|
||||||
continue
|
if password:
|
||||||
|
stats_password = password
|
||||||
|
|
||||||
# Check if it looks like JSON but wasn't parsed
|
# Connect to stats if we have both credentials
|
||||||
stripped = message.strip()
|
if stats_port and stats_password and stats_conn is None:
|
||||||
if stripped and stripped[0] in ('{', '['):
|
try:
|
||||||
logger.debug('Unparsed JSON event')
|
ui.print_message("Connecting to stats stream...\n")
|
||||||
continue
|
host_ip = args.host.split('//')[1].split(':')[0]
|
||||||
|
|
||||||
# Try powerup message formatting
|
stats_conn = StatsConnection(host_ip, stats_port, stats_password)
|
||||||
powerup_msg = format_powerup_message(message, game_state.player_tracker)
|
stats_conn.connect()
|
||||||
if powerup_msg:
|
|
||||||
ui.print_message(powerup_msg)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 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']))
|
|
||||||
if is_bot_debug and args.verbose == 0:
|
|
||||||
logger.debug(f'Filtered bot debug: {message[:50]}')
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if it's a chat message
|
# Request initial server info
|
||||||
if ':' in message and not message.startswith(('print', 'broadcast', 'zmq')):
|
logger.info('Sending initial server info queries')
|
||||||
message = format_chat_message(message, game_state.player_tracker)
|
rcon.send_command(b'qlx_serverBrandName')
|
||||||
|
rcon.send_command(b'g_factoryTitle')
|
||||||
|
rcon.send_command(b'mapname')
|
||||||
|
rcon.send_command(b'timelimit')
|
||||||
|
rcon.send_command(b'fraglimit')
|
||||||
|
rcon.send_command(b'roundlimit')
|
||||||
|
rcon.send_command(b'capturelimit')
|
||||||
|
rcon.send_command(b'sv_maxclients')
|
||||||
|
|
||||||
# Format and display message
|
if args.json_log:
|
||||||
formatted_msg, attributes = format_message(message)
|
ui.print_message(f"*** JSON capture enabled: {args.json_log} ***\n")
|
||||||
ui.print_message(formatted_msg)
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -223,9 +223,9 @@ 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"
|
||||||
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"
|
||||||
|
|||||||
16
qlpycon.bash
16
qlpycon.bash
@ -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
147
qlpycon_config.py
Normal 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()
|
||||||
93
state.py
93
state.py
@ -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:
|
'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'
|
||||||
@ -131,38 +124,46 @@ 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
|
||||||
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)
|
||||||
@ -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
286
ui.py
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user