modified: .gitignore
modified: README.md new file: install.sh renamed: config.py -> lib/constants.py renamed: cvars.py -> lib/cvars.py renamed: formatter.py -> lib/formatter.py renamed: network.py -> lib/network.py renamed: parser.py -> lib/parser.py renamed: qlpycon_config.py -> lib/settings.py renamed: state.py -> lib/state.py renamed: ui.py -> lib/ui.py modified: main.py modified: qlpycon.bash new file: qlpycon.conf.example
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration and constants for QLPyCon
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
VERSION = "0.8.1"
|
||||
|
||||
# Pattern matching
|
||||
COLOR_CODE_PATTERN = re.compile(r'\^\d') # Quake color codes (^0-^9)
|
||||
SPECIAL_CHAR = chr(25) # ASCII EM (End of Medium) - used in Quake messages
|
||||
|
||||
# Network defaults
|
||||
DEFAULT_HOST = 'tcp://127.0.0.1:27961'
|
||||
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
|
||||
INFO_WINDOW_HEIGHT = 12
|
||||
INFO_WINDOW_Y = 2
|
||||
OUTPUT_WINDOW_Y = 14
|
||||
INPUT_WINDOW_HEIGHT = 2
|
||||
|
||||
# Event deduplication
|
||||
MAX_RECENT_EVENTS = 10
|
||||
|
||||
# UI settings
|
||||
MAX_COMMAND_HISTORY = 10 # Number of commands to remember
|
||||
|
||||
# Team game modes
|
||||
TEAM_MODES = [
|
||||
"Team Deathmatch",
|
||||
"Clan Arena",
|
||||
"Capture The Flag",
|
||||
"One Flag CTF",
|
||||
"Overload",
|
||||
"Harvester",
|
||||
"Freeze Tag"
|
||||
]
|
||||
|
||||
# Team mappings
|
||||
TEAM_MAP = {
|
||||
0: 'FREE',
|
||||
1: 'RED',
|
||||
2: 'BLUE',
|
||||
3: 'SPECTATOR'
|
||||
}
|
||||
|
||||
TEAM_COLORS = {
|
||||
'RED': '^1(RED)^7',
|
||||
'BLUE': '^4(BLUE)^7',
|
||||
'FREE': '',
|
||||
'SPECTATOR': '^3(SPEC)^7'
|
||||
}
|
||||
|
||||
# Weapon names
|
||||
WEAPON_NAMES = {
|
||||
'ROCKET': '^8^1Rocket Launcher^7^0',
|
||||
'LIGHTNING': '^8^3Lightning Gun^7^0',
|
||||
'RAILGUN': '^8^2Railgun^7^0',
|
||||
'SHOTGUN': '^8^3Shotgun^7^0',
|
||||
'GAUNTLET': '^8^1Gauntlet^7^0',
|
||||
'GRENADE': '^8^2Grenade Launcher^7^0',
|
||||
'PLASMA': '^8^6Plasma Gun^7^0',
|
||||
'MACHINEGUN': '^8^3Machine Gun^7^0'
|
||||
}
|
||||
|
||||
# Weapon names for kill messages
|
||||
WEAPON_KILL_NAMES = {
|
||||
'ROCKET': 'the ^8^1Rocket Launcher',
|
||||
'LIGHTNING': 'the ^8^3Lightning Gun',
|
||||
'RAILGUN': 'the ^8^2Railgun',
|
||||
'SHOTGUN': 'the ^8^3Shotgun',
|
||||
'GAUNTLET': 'the ^8^1Gauntlet',
|
||||
'GRENADE': 'the ^8^2Grenade Launcher',
|
||||
'PLASMA': 'the ^8^6Plasma Gun',
|
||||
'MACHINEGUN': 'the ^8^3Machine Gun'
|
||||
}
|
||||
|
||||
# Death messages
|
||||
DEATH_MESSAGES = {
|
||||
'FALLING': "%s^8%s^0 ^7cratered.",
|
||||
'HURT': "%s^8%s^0 ^7was in the wrong place.",
|
||||
'LAVA': "%s^8%s^0 ^7does a backflip into the lava.",
|
||||
'WATER': "%s^8%s^0 ^7sank like a rock.",
|
||||
'SLIME': "%s^8%s^0 ^7melted.",
|
||||
'CRUSH': "%s^8%s^0 ^7was crushed."
|
||||
}
|
||||
|
||||
# Powerup names and colors
|
||||
POWERUP_COLORS = {
|
||||
'Quad Damage': '^8^5Quad Damage^7^0',
|
||||
'Battle Suit': '^8^3Battle Suit^7^0',
|
||||
'Regeneration': '^8^1Regeneration^7^0',
|
||||
'Haste': '^8^3Haste^7^0',
|
||||
'Invisibility': '^8^5Invisibility^7^0',
|
||||
'Flight': '^8^5Flight^7^0',
|
||||
'Medkit': '^8^1Medkit^7^0',
|
||||
'MegaHealth': '^8^4MegaHealth^7^0'
|
||||
}
|
||||
|
||||
# Curses color pairs
|
||||
COLOR_PAIRS = {
|
||||
1: 1, # Red
|
||||
2: 2, # Green
|
||||
3: 3, # Yellow
|
||||
4: 4, # Blue
|
||||
5: 6, # Cyan (swapped)
|
||||
6: 5 # Magenta (swapped)
|
||||
}
|
||||
+592
@@ -0,0 +1,592 @@
|
||||
#!/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',
|
||||
# Match control
|
||||
'readyall',
|
||||
'allready',
|
||||
'abort',
|
||||
'pause',
|
||||
'unpause',
|
||||
'lock',
|
||||
'unlock',
|
||||
'timeout',
|
||||
'timein',
|
||||
# Player management
|
||||
'shuffle',
|
||||
'put',
|
||||
'mute',
|
||||
'unmute',
|
||||
'slap',
|
||||
'slay',
|
||||
# Server control
|
||||
'restart',
|
||||
'endgame',
|
||||
'nextmap',
|
||||
'forcemap',
|
||||
# QLX commands
|
||||
'qlx',
|
||||
'elo',
|
||||
'balance',
|
||||
'teams',
|
||||
'scores',
|
||||
# Info commands
|
||||
'serverinfo',
|
||||
'players',
|
||||
'maplist',
|
||||
'configstrings',
|
||||
]
|
||||
|
||||
# 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}")
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Message formatting and colorization for QLPyCon
|
||||
Handles Quake color codes and team prefixes
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from .constants import TEAM_COLORS, COLOR_CODE_PATTERN, SPECIAL_CHAR
|
||||
|
||||
|
||||
def strip_color_codes(text):
|
||||
"""Remove Quake color codes (^N) from text"""
|
||||
return COLOR_CODE_PATTERN.sub('', text)
|
||||
|
||||
|
||||
def get_team_prefix(player_name, player_tracker):
|
||||
"""Get color-coded team prefix for a player"""
|
||||
if not player_tracker.server_info.is_team_mode():
|
||||
return ''
|
||||
|
||||
team = player_tracker.get_team(player_name)
|
||||
if not team:
|
||||
return ''
|
||||
|
||||
return TEAM_COLORS.get(team, '')
|
||||
|
||||
|
||||
def should_add_timestamp(message):
|
||||
"""Determine if a message should get a timestamp"""
|
||||
# Skip status command output
|
||||
skip_keywords = ['map:', 'num score', '---', 'bot', 'status']
|
||||
if any(kw in message for kw in skip_keywords):
|
||||
# But allow "zmq RCON" lines (command echoes)
|
||||
if 'zmq RCON' not in message:
|
||||
return False
|
||||
|
||||
# Skip very short messages or fragments
|
||||
stripped = message.strip()
|
||||
if len(stripped) <= 2:
|
||||
return False
|
||||
|
||||
# Skip messages with leading spaces (status fragments)
|
||||
if message.startswith(' ') and len(stripped) < 50:
|
||||
return False
|
||||
|
||||
# Skip pure numbers
|
||||
if stripped.isdigit():
|
||||
return False
|
||||
|
||||
# Skip short single words
|
||||
if len(stripped) < 20 and ' ' not in stripped:
|
||||
return False
|
||||
|
||||
# Skip IP addresses (xxx.xxx.xxx.xxx or xxx.xxx.xxx.xxx:port)
|
||||
allowed_chars = set('0123456789.:')
|
||||
if stripped.count('.') == 3 and all(c in allowed_chars for c in stripped):
|
||||
return False
|
||||
|
||||
# Skip messages starting with ***
|
||||
if message.startswith('***'):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def format_message(message, add_timestamp=True):
|
||||
"""
|
||||
Format a message for display
|
||||
- Strips special characters
|
||||
- Adds timestamp if appropriate
|
||||
- Handles broadcast formatting
|
||||
"""
|
||||
# Clean up message
|
||||
message = message.replace("\\n", "")
|
||||
message = message.replace(SPECIAL_CHAR, "")
|
||||
|
||||
# Handle broadcast messages
|
||||
attributes = 0
|
||||
if message[:10] == "broadcast:":
|
||||
message = message[11:]
|
||||
attributes = 1 # Bold
|
||||
|
||||
# Handle print messages
|
||||
if message[:7] == "print \"":
|
||||
message = message[7:-2] + "\n"
|
||||
|
||||
# Add timestamp if requested and appropriate
|
||||
if add_timestamp and should_add_timestamp(message):
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
message = f"^3[^7{timestamp}^3]^7 {message}"
|
||||
|
||||
return message, attributes
|
||||
|
||||
|
||||
def format_chat_message(message, player_tracker):
|
||||
"""
|
||||
Format chat messages with team prefixes and colors
|
||||
Handles both regular chat (Name: msg) and team chat ((Name): msg or (Name) (Location): msg)
|
||||
"""
|
||||
# Strip special character
|
||||
clean_msg = message.replace(SPECIAL_CHAR, '')
|
||||
|
||||
# Team chat with location: (PlayerName) (Location): message
|
||||
# Location can have nested parens like (Lower Floor (Near Yellow Armour))
|
||||
if clean_msg.strip().startswith('(') and ')' in clean_msg:
|
||||
# Extract player name (first parenthetical)
|
||||
player_match = re.match(r'^(\([^)]+\))', clean_msg)
|
||||
if not player_match:
|
||||
return message
|
||||
|
||||
player_part = player_match.group(1)
|
||||
rest = clean_msg[len(player_part):].lstrip()
|
||||
|
||||
# Check for location (another parenthetical)
|
||||
if rest.startswith('('):
|
||||
# Count parens to handle nesting
|
||||
paren_count = 0
|
||||
location_end = -1
|
||||
for i, char in enumerate(rest):
|
||||
if char == '(':
|
||||
paren_count += 1
|
||||
elif char == ')':
|
||||
paren_count -= 1
|
||||
if paren_count == 0:
|
||||
location_end = i + 1
|
||||
break
|
||||
|
||||
# Check if location ends with colon
|
||||
if location_end > 0 and location_end < len(rest) and rest[location_end] == ':':
|
||||
location_part = rest[:location_end]
|
||||
message_part = rest[location_end + 1:]
|
||||
|
||||
# Get team prefix
|
||||
name_match = re.match(r'\(([^)]+)\)', player_part)
|
||||
if name_match:
|
||||
player_name = strip_color_codes(name_match.group(1).strip())
|
||||
team_prefix = get_team_prefix(player_name, player_tracker)
|
||||
location_clean = strip_color_codes(location_part)
|
||||
return f"^8^5[TEAMSAY]^7^0 {team_prefix}^8{player_part}^0^3{location_clean}^7:^5{message_part}"
|
||||
|
||||
# Team chat without location: (PlayerName): message
|
||||
colon_match = re.match(r'^(\([^)]+\)):(\s*.*)', clean_msg)
|
||||
if colon_match:
|
||||
player_part = colon_match.group(1)
|
||||
message_part = colon_match.group(2)
|
||||
|
||||
name_match = re.match(r'\(([^)]+)\)', player_part)
|
||||
if name_match:
|
||||
player_name = strip_color_codes(name_match.group(1).strip())
|
||||
team_prefix = get_team_prefix(player_name, player_tracker)
|
||||
return f"^8^5[TEAMSAY]^7^0 {team_prefix}^8{player_part}^7^0:^5{message_part}\n"
|
||||
|
||||
# Regular chat: PlayerName: message
|
||||
parts = clean_msg.split(':', 1)
|
||||
if len(parts) == 2:
|
||||
player_name = strip_color_codes(parts[0].strip())
|
||||
team_prefix = get_team_prefix(player_name, player_tracker)
|
||||
|
||||
# Preserve original color-coded name
|
||||
original_parts = message.replace(SPECIAL_CHAR, '').split(':', 1)
|
||||
if len(original_parts) == 2:
|
||||
return f"^8^2[SAY]^7^0 {team_prefix}^8{original_parts[0]}^0^7:^2{original_parts[1]}"
|
||||
|
||||
return message
|
||||
|
||||
def format_powerup_message(message, player_tracker):
|
||||
"""
|
||||
Format powerup pickup and carrier kill messages
|
||||
Returns formatted message or None if not a powerup message
|
||||
"""
|
||||
from .constants import POWERUP_COLORS
|
||||
import time
|
||||
|
||||
if message.startswith("broadcast:"):
|
||||
message = message[11:].strip()
|
||||
|
||||
# Strip print " wrapper
|
||||
if message.startswith('print "'):
|
||||
message = message[7:]
|
||||
if message.endswith('"'):
|
||||
message = message[:-1]
|
||||
message = message.strip()
|
||||
|
||||
# Powerup pickup: "PlayerName got the PowerupName!"
|
||||
pickup_match = re.match(r'^(.+?)\s+got the\s+(.+?)!', message)
|
||||
if pickup_match:
|
||||
player_name = pickup_match.group(1).strip()
|
||||
powerup_name = pickup_match.group(2).strip()
|
||||
|
||||
player_clean = strip_color_codes(player_name)
|
||||
team_prefix = get_team_prefix(player_clean, player_tracker)
|
||||
|
||||
colored_powerup = POWERUP_COLORS.get(powerup_name, f'^6{powerup_name}^7')
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
return f"^3[^7{timestamp}^3] ^8^5[POWERUP]^7^0 {team_prefix}^8{player_name}^0 ^7got the {colored_powerup}!\n"
|
||||
|
||||
# Powerup carrier kill: "PlayerName killed the PowerupName carrier!"
|
||||
carrier_match = re.match(r'^(.+?)\s+killed the\s+(.+?)\s+carrier!', message)
|
||||
if carrier_match:
|
||||
player_name = carrier_match.group(1).strip()
|
||||
powerup_name = carrier_match.group(2).strip()
|
||||
|
||||
player_clean = strip_color_codes(player_name)
|
||||
team_prefix = get_team_prefix(player_clean, player_tracker)
|
||||
|
||||
colored_powerup = POWERUP_COLORS.get(powerup_name, f'^6{powerup_name}^7')
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
return f"^3[^7{timestamp}^3] ^8^5[POWERUP]^7^0 {team_prefix}^8{player_name}^0 ^7killed the {colored_powerup} ^7carrier!\n"
|
||||
|
||||
return None
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ZMQ network layer for QLPyCon
|
||||
Handles RCON and stats stream connections
|
||||
"""
|
||||
|
||||
import zmq
|
||||
import struct
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger('network')
|
||||
|
||||
|
||||
def read_socket_event(msg):
|
||||
"""Parse ZMQ socket monitor event"""
|
||||
event_id = struct.unpack('<H', msg[:2])[0]
|
||||
event_names = {
|
||||
zmq.EVENT_ACCEPTED: 'EVENT_ACCEPTED',
|
||||
zmq.EVENT_ACCEPT_FAILED: 'EVENT_ACCEPT_FAILED',
|
||||
zmq.EVENT_BIND_FAILED: 'EVENT_BIND_FAILED',
|
||||
zmq.EVENT_CLOSED: 'EVENT_CLOSED',
|
||||
zmq.EVENT_CLOSE_FAILED: 'EVENT_CLOSE_FAILED',
|
||||
zmq.EVENT_CONNECTED: 'EVENT_CONNECTED',
|
||||
zmq.EVENT_CONNECT_DELAYED: 'EVENT_CONNECT_DELAYED',
|
||||
zmq.EVENT_CONNECT_RETRIED: 'EVENT_CONNECT_RETRIED',
|
||||
zmq.EVENT_DISCONNECTED: 'EVENT_DISCONNECTED',
|
||||
zmq.EVENT_LISTENING: 'EVENT_LISTENING',
|
||||
zmq.EVENT_MONITOR_STOPPED: 'EVENT_MONITOR_STOPPED',
|
||||
}
|
||||
event_name = event_names.get(event_id, f'{event_id}')
|
||||
event_value = struct.unpack('<I', msg[2:])[0]
|
||||
return (event_id, event_name, event_value)
|
||||
|
||||
|
||||
def check_monitor(monitor):
|
||||
"""Check monitor socket for events"""
|
||||
try:
|
||||
event_monitor = monitor.recv(zmq.NOBLOCK)
|
||||
except zmq.Again:
|
||||
return None
|
||||
|
||||
event_id, event_name, event_value = read_socket_event(event_monitor)
|
||||
event_endpoint = monitor.recv(zmq.NOBLOCK)
|
||||
logger.debug(f'Monitor: {event_name} {event_value} endpoint {event_endpoint}')
|
||||
return (event_id, event_value)
|
||||
|
||||
|
||||
class RconConnection:
|
||||
"""RCON connection to Quake Live server"""
|
||||
|
||||
def __init__(self, host, password, identity):
|
||||
self.host = host
|
||||
self.password = password
|
||||
self.identity = identity
|
||||
self.context = None
|
||||
self.socket = None
|
||||
self.monitor = None
|
||||
|
||||
def connect(self):
|
||||
"""Initialize connection"""
|
||||
logger.info('Initializing ZMQ context...')
|
||||
self.context = zmq.Context()
|
||||
|
||||
logger.info('Creating DEALER socket...')
|
||||
self.socket = self.context.socket(zmq.DEALER)
|
||||
|
||||
logger.info('Setting up socket monitor...')
|
||||
self.monitor = self.socket.get_monitor_socket(zmq.EVENT_ALL)
|
||||
|
||||
if self.password:
|
||||
logger.info('Setting password for access')
|
||||
self.socket.plain_username = b'rcon'
|
||||
self.socket.plain_password = self.password.encode('utf-8')
|
||||
self.socket.zap_domain = b'rcon'
|
||||
|
||||
logger.info(f'Setting socket identity: {self.identity}')
|
||||
self.socket.setsockopt(zmq.IDENTITY, self.identity.encode('utf-8'))
|
||||
|
||||
self.socket.connect(self.host)
|
||||
logger.info('Connection initiated, waiting for events...')
|
||||
|
||||
def send_command(self, command):
|
||||
"""Send RCON command"""
|
||||
if isinstance(command, str):
|
||||
command = command.encode('utf-8')
|
||||
self.socket.send(command)
|
||||
logger.info(f'Sent command: {command}')
|
||||
|
||||
def poll(self, timeout):
|
||||
"""Poll for messages"""
|
||||
return self.socket.poll(timeout)
|
||||
|
||||
def recv_message(self):
|
||||
"""Receive a message (non-blocking)"""
|
||||
try:
|
||||
return self.socket.recv(zmq.NOBLOCK).decode('utf-8', errors='replace')
|
||||
except zmq.error.Again:
|
||||
return None
|
||||
|
||||
def check_monitor(self):
|
||||
"""Check monitor for events"""
|
||||
return check_monitor(self.monitor)
|
||||
|
||||
def close(self):
|
||||
"""Close connection"""
|
||||
if self.socket:
|
||||
self.socket.setsockopt(zmq.LINGER, 0) # Don't wait for unsent messages
|
||||
self.socket.close()
|
||||
if self.context:
|
||||
self.context.term()
|
||||
|
||||
|
||||
class StatsConnection:
|
||||
"""Stats stream connection (ZMQ SUB socket)"""
|
||||
|
||||
def __init__(self, host, port, password):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.password = password
|
||||
self.context = None
|
||||
self.socket = None
|
||||
self.connected = False
|
||||
|
||||
def connect(self):
|
||||
"""Connect to stats stream"""
|
||||
stats_host = f'tcp://{self.host}:{self.port}'
|
||||
logger.info(f'Connecting to stats stream: {stats_host}')
|
||||
|
||||
self.context = zmq.Context()
|
||||
self.socket = self.context.socket(zmq.SUB)
|
||||
logger.debug('Stats socket created (SUB type)')
|
||||
|
||||
if self.password and self.password.strip():
|
||||
logger.debug('Setting PLAIN authentication')
|
||||
self.socket.setsockopt(zmq.PLAIN_USERNAME, b'stats')
|
||||
self.socket.setsockopt(zmq.PLAIN_PASSWORD, self.password.encode('utf-8'))
|
||||
self.socket.setsockopt_string(zmq.ZAP_DOMAIN, 'stats')
|
||||
|
||||
logger.debug(f'Connecting to {stats_host}')
|
||||
self.socket.connect(stats_host)
|
||||
|
||||
logger.debug('Setting ZMQ_SUBSCRIBE to empty (all messages)')
|
||||
self.socket.setsockopt(zmq.SUBSCRIBE, b'')
|
||||
|
||||
time.sleep(0.5)
|
||||
self.connected = True
|
||||
logger.info('Stats stream connected')
|
||||
|
||||
def recv_message(self):
|
||||
"""Receive stats message (non-blocking)"""
|
||||
if not self.connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
msg = self.socket.recv(zmq.NOBLOCK)
|
||||
return msg.decode('utf-8', errors='replace')
|
||||
except zmq.error.Again:
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""Close connection"""
|
||||
if self.socket:
|
||||
self.socket.setsockopt(zmq.LINGER, 0) # Don't wait for unsent messages
|
||||
self.socket.close()
|
||||
if self.context:
|
||||
self.context.term()
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JSON game event parsing for QLPyCon
|
||||
Parses events from Quake Live stats stream
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from .constants import WEAPON_NAMES, WEAPON_KILL_NAMES, DEATH_MESSAGES
|
||||
from .formatter import get_team_prefix, strip_color_codes
|
||||
|
||||
logger = logging.getLogger('parser')
|
||||
|
||||
|
||||
def calculate_weapon_accuracies(weapon_data):
|
||||
"""Calculate accuracy percentages for all weapons"""
|
||||
accuracies = {}
|
||||
for weapon, stats in weapon_data.items():
|
||||
shots_fired = int(stats.get('S', 0))
|
||||
shots_hit = int(stats.get('H', 0))
|
||||
accuracy = shots_hit / shots_fired if shots_fired > 0 else 0
|
||||
accuracies[weapon] = accuracy
|
||||
return accuracies
|
||||
|
||||
|
||||
class EventParser:
|
||||
"""Parses JSON game events into formatted messages"""
|
||||
|
||||
def __init__(self, game_state, json_logger=None, unknown_logger=None):
|
||||
self.game_state = game_state
|
||||
self.json_logger = json_logger
|
||||
self.unknown_logger = unknown_logger
|
||||
|
||||
def parse_event(self, message):
|
||||
"""
|
||||
Parse JSON event and return formatted message string
|
||||
Returns None if event should not be displayed
|
||||
"""
|
||||
try:
|
||||
event = json.loads(message)
|
||||
|
||||
# Log all JSON if logger is configured
|
||||
if self.json_logger:
|
||||
self.json_logger.info('JSON Event received:')
|
||||
self.json_logger.info(json.dumps(event, indent=2))
|
||||
self.json_logger.info('---')
|
||||
|
||||
if 'TYPE' not in event or 'DATA' not in event:
|
||||
logger.debug('JSON missing TYPE or DATA')
|
||||
return None
|
||||
|
||||
event_type = event['TYPE']
|
||||
data = event['DATA']
|
||||
|
||||
if 'WARMUP' in data:
|
||||
self.game_state.server_info.warmup = data['WARMUP']
|
||||
|
||||
# Route to appropriate handler
|
||||
handler_map = {
|
||||
'PLAYER_SWITCHTEAM': self._handle_switchteam,
|
||||
'PLAYER_DEATH': self._handle_death,
|
||||
'PLAYER_KILL': self._handle_death, # Same handler
|
||||
'PLAYER_MEDAL': self._handle_medal,
|
||||
'MATCH_STARTED': self._handle_match_started,
|
||||
'MATCH_REPORT': self._handle_match_report,
|
||||
'PLAYER_STATS': self._handle_player_stats,
|
||||
'PLAYER_CONNECT': lambda d: None,
|
||||
'PLAYER_DISCONNECT': lambda d: None,
|
||||
'ROUND_OVER': self._handle_round_over,
|
||||
}
|
||||
|
||||
handler = handler_map.get(event_type)
|
||||
if handler:
|
||||
return handler(data)
|
||||
else:
|
||||
# Unknown event
|
||||
logger.debug(f'Unknown event type: {event_type}')
|
||||
if self.unknown_logger:
|
||||
self.unknown_logger.info(f'Unknown event type: {event_type}')
|
||||
self.unknown_logger.info(f'Full JSON: {json.dumps(event, indent=2)}')
|
||||
return None
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug(f'JSON decode error: {e}')
|
||||
return None
|
||||
except (KeyError, TypeError) as e:
|
||||
logger.debug(f'Error parsing event: {e}')
|
||||
return None
|
||||
|
||||
def _handle_switchteam(self, data):
|
||||
"""Handle PLAYER_SWITCHTEAM event"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
|
||||
if 'KILLER' not in data:
|
||||
return None
|
||||
|
||||
killer = data['KILLER']
|
||||
name = killer.get('NAME', 'Unknown')
|
||||
team = killer.get('TEAM', '')
|
||||
old_team = killer.get('OLD_TEAM', '')
|
||||
|
||||
# Update player team
|
||||
self.game_state.player_tracker.update_team(name, team)
|
||||
self.game_state.player_tracker.add_player(name)
|
||||
|
||||
if team == old_team:
|
||||
return None
|
||||
|
||||
warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else ""
|
||||
|
||||
team_messages = {
|
||||
'FREE': ' ^7joined the ^8fight^0',
|
||||
'SPECTATOR': ' ^7joined the ^3Spectators^7',
|
||||
'RED': ' ^7joined the ^1RED Team^7',
|
||||
'BLUE': ' ^7joined the ^4BLUE Team^7'
|
||||
}
|
||||
|
||||
old_team_messages = {
|
||||
'FREE': 'the ^8fight^0',
|
||||
'SPECTATOR': 'the ^3Spectators^7',
|
||||
'RED': '^7the ^1RED Team^7',
|
||||
'BLUE': '^7the ^4BLUE Team^7'
|
||||
}
|
||||
|
||||
team_msg = team_messages.get(team, f' ^7joined team {team}^7')
|
||||
old_team_msg = old_team_messages.get(old_team, f'team {old_team}')
|
||||
|
||||
team_prefix = get_team_prefix(name, self.game_state.player_tracker)
|
||||
return f"^8^5[SWITCH]^7 {team_prefix}^8{name}^0{team_msg} from {old_team_msg}{warmup}\n"
|
||||
|
||||
def _handle_death(self, data):
|
||||
"""Handle PLAYER_DEATH and PLAYER_KILL events"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
|
||||
if 'VICTIM' not in data:
|
||||
return None
|
||||
|
||||
victim = data['VICTIM']
|
||||
victim_name = victim.get('NAME', 'Unknown')
|
||||
|
||||
# Check for duplicate
|
||||
time_val = data.get('TIME', 0)
|
||||
killer_name = data.get('KILLER', {}).get('NAME', '') if data.get('KILLER') else ''
|
||||
|
||||
if self.game_state.event_deduplicator.is_duplicate('PLAYER_DEATH', time_val, killer_name, victim_name):
|
||||
return None
|
||||
|
||||
# Update victim team
|
||||
if 'TEAM' in victim:
|
||||
self.game_state.player_tracker.update_team(victim_name, victim['TEAM'])
|
||||
self.game_state.player_tracker.add_player(victim_name)
|
||||
|
||||
# Mark as dead
|
||||
if not data.get('WARMUP', False):
|
||||
self.game_state.server_info.dead_players[victim_name] = time.time()
|
||||
|
||||
victim_prefix = get_team_prefix(victim_name, self.game_state.player_tracker)
|
||||
warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else ""
|
||||
score_prefix = ""
|
||||
|
||||
# Environmental death (no killer)
|
||||
if 'KILLER' not in data or not data['KILLER']:
|
||||
# -1 for environmental death
|
||||
if not data.get('WARMUP', False):
|
||||
self.game_state.player_tracker.update_score(victim_name, -1)
|
||||
score_prefix = "^8^1[-1]^7^0 "
|
||||
|
||||
mod = data.get('MOD', 'UNKNOWN')
|
||||
msg_template = DEATH_MESSAGES.get(mod, "%s^8%s^0 ^1DIED FROM %s^7")
|
||||
|
||||
if mod in DEATH_MESSAGES:
|
||||
msg = msg_template % (victim_prefix, victim_name)
|
||||
else:
|
||||
msg = msg_template % (victim_prefix, victim_name, mod)
|
||||
|
||||
return f"{score_prefix}{msg}{warmup}\n"
|
||||
|
||||
# Player killed by another player
|
||||
killer = data['KILLER']
|
||||
killer_name = killer.get('NAME', 'Unknown')
|
||||
|
||||
# Update killer team
|
||||
if 'TEAM' in killer:
|
||||
self.game_state.player_tracker.update_team(killer_name, killer['TEAM'])
|
||||
self.game_state.player_tracker.add_player(killer_name)
|
||||
|
||||
killer_prefix = get_team_prefix(killer_name, self.game_state.player_tracker)
|
||||
|
||||
# Suicide
|
||||
if killer_name == victim_name:
|
||||
# -1 for suicide
|
||||
if not data.get('WARMUP', False):
|
||||
self.game_state.player_tracker.update_score(victim_name, -1)
|
||||
score_prefix = "^8^1[-1]^7^0 "
|
||||
|
||||
weapon = killer.get('WEAPON', 'OTHER_WEAPON')
|
||||
if weapon == 'ROCKET':
|
||||
return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7blew herself up.{warmup}\n"
|
||||
elif weapon == 'GRENADE':
|
||||
return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7tripped on her own grenade.{warmup}\n"
|
||||
elif weapon == 'PLASMA':
|
||||
return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7melted herself.{warmup}\n"
|
||||
else:
|
||||
weapon_name = WEAPON_NAMES.get(weapon, weapon)
|
||||
return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7committed suicide with the ^7{weapon_name}{warmup}\n"
|
||||
return None
|
||||
|
||||
# Regular kill: +1 for killer
|
||||
if not data.get('WARMUP', False):
|
||||
self.game_state.player_tracker.update_score(killer_name, 1)
|
||||
score_prefix = "^8^2[+1]^7^0 "
|
||||
|
||||
else:
|
||||
score_prefix = ""
|
||||
|
||||
weapon = killer.get('WEAPON', 'UNKNOWN')
|
||||
weapon_name = WEAPON_KILL_NAMES.get(weapon, f'the {weapon}')
|
||||
|
||||
hp_left = int(killer.get('HEALTH', 0))
|
||||
hp_left_colored = ""
|
||||
if hp_left <= 0: # from the grave
|
||||
hp_left_colored = f"^8^5From the Grave^0"
|
||||
elif hp_left < 25: # red
|
||||
hp_left_colored = f"^8^1{hp_left}^0 ^7HP"
|
||||
elif hp_left < 80: # yellow
|
||||
hp_left_colored = f"^8^3{hp_left}^0 ^7HP"
|
||||
elif hp_left < 126: # white
|
||||
hp_left_colored = f"^8^7{hp_left}^0 ^7HP"
|
||||
else: # green
|
||||
hp_left_colored = f"^8^2{hp_left}^0 ^7HP"
|
||||
|
||||
return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7fragged^7 {victim_prefix}^8{victim_name}^0 ^7with {weapon_name}^0 ^7({hp_left_colored}^7){warmup}\n"
|
||||
|
||||
def _handle_round_over(self, data):
|
||||
"""Handle ROUND_OVER events for CA"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
|
||||
team_won = data.get('TEAM_WON')
|
||||
round_num = data.get('ROUND', 0)
|
||||
|
||||
if team_won == 'RED':
|
||||
self.game_state.server_info.red_rounds += 1
|
||||
logger.info(f"Round {round_num}: RED wins (RED: {self.game_state.server_info.red_rounds}, BLUE: {self.game_state.server_info.blue_rounds})")
|
||||
elif team_won == 'BLUE':
|
||||
self.game_state.server_info.blue_rounds += 1
|
||||
logger.info(f"Round {round_num}: BLUE wins (RED: {self.game_state.server_info.red_rounds}, BLUE: {self.game_state.server_info.blue_rounds})")
|
||||
|
||||
self.game_state.server_info.round_end_time = time.time()
|
||||
|
||||
return None # Don't display in chat
|
||||
|
||||
def _handle_medal(self, data):
|
||||
"""Handle PLAYER_MEDAL event"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
|
||||
name = data.get('NAME', 'Unknown')
|
||||
medal = data.get('MEDAL', 'UNKNOWN')
|
||||
warmup = " ^8^3(Warmup)^7^0" if data.get('WARMUP', False) else ""
|
||||
medal_prefix = "^8^6[MEDAL]^7^0 "
|
||||
|
||||
team_prefix = get_team_prefix(name, self.game_state.player_tracker)
|
||||
# RED Medals (^1)
|
||||
if medal in ["FIRSTFRAG", "HUMILIATION", "REVENGE"]:
|
||||
return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^1{medal}^0{warmup}\n"
|
||||
# GREEN Medals (^2)
|
||||
elif medal in ["MIDAIR", "PERFECT"]:
|
||||
return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^2{medal}^0{warmup}\n"
|
||||
# YELLOW Medals (^3)
|
||||
elif medal in ["EXCELLENT", "HEADSHOT", "RAMPAGE"]:
|
||||
return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^3{medal}^0{warmup}\n"
|
||||
# BLUE Medals (^4)
|
||||
elif medal in ["ASSIST", "DEFENSE", "QUADGOD"]:
|
||||
return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^4{medal}^0{warmup}\n"
|
||||
# CYAN Medals (^5)
|
||||
elif medal in ["CAPTURE", "COMBOKILL", "IMPRESSIVE"]:
|
||||
return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^5{medal}^0{warmup}\n"
|
||||
# PINK Medals (^6)
|
||||
elif medal in ["ACCURACY", "PERFORATED"]:
|
||||
return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^6{medal}^0{warmup}\n"
|
||||
else:
|
||||
return f"{medal_prefix}{team_prefix}^8{name}^0 ^7got ^8^7{medal}^0{warmup}\n"
|
||||
|
||||
def _handle_match_started(self, data):
|
||||
"""Handle MATCH_STARTED event"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
|
||||
if self.game_state.server_info.is_team_mode():
|
||||
return f"^8^2[GAME ON]^0 ^7Match has started - ^1^8RED ^0^7vs. ^4^8BLUE\n"
|
||||
|
||||
players = []
|
||||
for player in data.get('PLAYERS', []):
|
||||
name = player.get('NAME', 'Unknown')
|
||||
players.append(name)
|
||||
|
||||
if players:
|
||||
formatted = "^0 vs. ^8".join(players)
|
||||
return f"^8^2[GAME ON]^0 ^7Match has started - ^8^7{formatted}\n"
|
||||
|
||||
return None
|
||||
|
||||
def _handle_match_report(self, data):
|
||||
"""Handle MATCH_REPORT event"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
|
||||
if not self.game_state.server_info.is_team_mode():
|
||||
return None
|
||||
|
||||
red_score = int(data.get('TSCORE0', '0'))
|
||||
blue_score = int(data.get('TSCORE1', '0'))
|
||||
report_prefix = "^8^1[GAME OVER]"
|
||||
|
||||
if red_score > blue_score:
|
||||
return f"{report_prefix} ^7The ^1RED TEAM ^7WINS^0 by a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n"
|
||||
elif blue_score > red_score:
|
||||
return f"{report_prefix} ^7The ^4BLUE TEAM ^7WINS^0 by a score of ^8^4{blue_score} ^0^7to ^8^1{red_score}\n"
|
||||
else:
|
||||
return f"{report_prefix} ^7The match is a TIE^0 with a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n"
|
||||
|
||||
def _handle_player_stats(self, data):
|
||||
"""Handle PLAYER_STATS event"""
|
||||
name = data.get('NAME', 'Unknown')
|
||||
team_prefix = get_team_prefix(name, self.game_state.player_tracker)
|
||||
|
||||
kills = int(data.get('KILLS', '0'))
|
||||
deaths = int(data.get('DEATHS', '0'))
|
||||
|
||||
weapon_data = data.get('WEAPONS', {})
|
||||
accuracies = calculate_weapon_accuracies(weapon_data)
|
||||
|
||||
if not accuracies:
|
||||
return None
|
||||
|
||||
best_weapon = max(accuracies, key=accuracies.get)
|
||||
best_accuracy = accuracies[best_weapon] * 100
|
||||
weapon_stats = weapon_data.get(best_weapon, {})
|
||||
best_weapon_kills = int(weapon_stats.get('K', 0))
|
||||
|
||||
weapon_name = WEAPON_NAMES.get(best_weapon, best_weapon)
|
||||
|
||||
return f"^8^5[PLAYER STATS]^7^0 {team_prefix}^8^7{name}^0^7 K/D: {kills}/{deaths} | Best Weapon: {weapon_name} - Acc: {best_accuracy:.2f}% - Kills: {best_weapon_kills}\n"
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
#!/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
|
||||
|
||||
return self._resolve_password(password)
|
||||
|
||||
def get_servers(self):
|
||||
"""Return dict of name -> host:port from [servers]"""
|
||||
if not self.config.has_section('servers'):
|
||||
return {}
|
||||
return dict(self.config.items('servers'))
|
||||
|
||||
def get_server(self, name):
|
||||
"""
|
||||
Resolve a named server to (host, password).
|
||||
host comes from [servers], password from [server:name] or [connection].
|
||||
Returns (host, password) or (None, None) if name not found.
|
||||
"""
|
||||
servers = self.get_servers()
|
||||
if name not in servers:
|
||||
return None, None
|
||||
|
||||
host = servers[name]
|
||||
if not host.startswith('tcp://'):
|
||||
host = f'tcp://{host}'
|
||||
|
||||
# Per-server password override
|
||||
section = f'server:{name}'
|
||||
if self.config.has_section(section):
|
||||
password = self.config.get(section, 'password', fallback=None)
|
||||
if password:
|
||||
password = self._resolve_password(password)
|
||||
else:
|
||||
password = self.get_password()
|
||||
|
||||
return host, password
|
||||
|
||||
def _resolve_password(self, password):
|
||||
"""Resolve a password string, expanding ${VAR:-default} if needed"""
|
||||
if not password:
|
||||
return None
|
||||
if password.startswith('${') and password.endswith('}'):
|
||||
inner = password[2:-1]
|
||||
if ':-' in inner:
|
||||
env_var, default = inner.split(':-', 1)
|
||||
else:
|
||||
env_var, default = inner, None
|
||||
return os.environ.get(env_var, default)
|
||||
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.conf
|
||||
# Edit this file as needed.
|
||||
#
|
||||
# Connect by server name: qlpycon ffa
|
||||
# Connect directly: qlpycon --host tcp://1.2.3.4:28960 --password secret
|
||||
# List servers: qlpycon --list
|
||||
|
||||
# === Connection defaults ===>
|
||||
# Default host if no server name or --host is given
|
||||
[connection]
|
||||
host = tcp://127.0.0.1:28960
|
||||
|
||||
# Password for all servers unless overridden in [server:name]
|
||||
# Use ${ENV_VAR} to read from environment variable (recommended)
|
||||
# Or set directly (less secure):
|
||||
password = ${QLPYCON_PASSWORD:-secret}
|
||||
|
||||
# === Named servers ===>
|
||||
# Simple entries: name = host:port
|
||||
# These use the password from [connection] above.
|
||||
[servers]
|
||||
# Example:
|
||||
#ffa = 10.13.12.161:28960
|
||||
|
||||
# === Per-server overrides ===>
|
||||
# Use [server:name] to override any setting for a specific server.
|
||||
# The name must match an entry in [servers] above.
|
||||
# Example:
|
||||
#[server:ffa]
|
||||
#password = ${FFA_PASSWORD:-secret}
|
||||
|
||||
# === Logging ===>
|
||||
[logging]
|
||||
# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
level = WARNING
|
||||
|
||||
# === UI ===>
|
||||
[ui]
|
||||
# Number of commands to remember in history
|
||||
max_history = 10
|
||||
|
||||
# === Behaviour ===>
|
||||
[behavior]
|
||||
# Seconds to confirm quit (press Ctrl-C twice within this window)
|
||||
quit_timeout = 3.0
|
||||
# Seconds before players respawn after death
|
||||
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()
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Game state management for QLPyCon
|
||||
Tracks server info, players, and teams
|
||||
"""
|
||||
|
||||
import logging
|
||||
from .constants import TEAM_MODES, TEAM_MAP, MAX_RECENT_EVENTS
|
||||
from .formatter import strip_color_codes
|
||||
|
||||
logger = logging.getLogger('state')
|
||||
|
||||
|
||||
class ServerInfo:
|
||||
"""Tracks current server information"""
|
||||
|
||||
def __init__(self):
|
||||
self.hostname = 'Unknown'
|
||||
self.map = 'Unknown'
|
||||
self.gametype = 'Unknown'
|
||||
self.timelimit = '0'
|
||||
self.fraglimit = '0'
|
||||
self.roundlimit = '0'
|
||||
self.capturelimit = '0'
|
||||
self.maxclients = '0'
|
||||
self.curclients = '0'
|
||||
self.red_score = 0
|
||||
self.red_rounds = 0
|
||||
self.blue_score = 0
|
||||
self.blue_rounds = 0
|
||||
self.players = {} # Changed to dict: {name: {'score': str, 'ping': str}}
|
||||
self.last_update = 0
|
||||
self.warmup = False
|
||||
self.dead_players = {}
|
||||
self.round_end_time = None
|
||||
self.match_time = 0
|
||||
self.match_time_last_sync = 0 # Timestamp of last TIME update from server
|
||||
|
||||
def is_team_mode(self):
|
||||
"""Check if current gametype is a team mode"""
|
||||
return self.gametype in TEAM_MODES
|
||||
|
||||
def reset_round_scores(self):
|
||||
"""Reset round scores (for new matches)"""
|
||||
self.red_rounds = 0
|
||||
self.blue_rounds = 0
|
||||
self.dead_players.clear()
|
||||
|
||||
def update_from_cvar(self, cvar_name, value):
|
||||
"""Update server info from a cvar response"""
|
||||
# Normalize cvar name to lowercase for case-insensitive matching
|
||||
cvar_lower = cvar_name.lower()
|
||||
|
||||
mapping = {
|
||||
'qlx_serverbrandname': 'hostname',
|
||||
'g_factorytitle': 'gametype',
|
||||
'mapname': 'map',
|
||||
'timelimit': 'timelimit',
|
||||
'fraglimit': 'fraglimit',
|
||||
'roundlimit': 'roundlimit',
|
||||
'capturelimit': 'capturelimit',
|
||||
'sv_maxclients': 'maxclients'
|
||||
}
|
||||
|
||||
attr = mapping.get(cvar_lower)
|
||||
if attr:
|
||||
# Only strip color codes for non-hostname fields
|
||||
if attr != 'hostname':
|
||||
value = strip_color_codes(value)
|
||||
setattr(self, attr, value)
|
||||
logger.info(f'Updated {attr}: {value}')
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PlayerTracker:
|
||||
"""Tracks player teams and information"""
|
||||
|
||||
def __init__(self, server_info):
|
||||
self.server_info = server_info
|
||||
self.player_teams = {}
|
||||
|
||||
def update_team(self, name, team):
|
||||
"""Update player team. Team can be int or string"""
|
||||
# Convert numeric team to string
|
||||
if isinstance(team, int):
|
||||
team = TEAM_MAP.get(team, 'FREE')
|
||||
|
||||
if team not in ['RED', 'BLUE', 'FREE', 'SPECTATOR']:
|
||||
team = 'FREE'
|
||||
|
||||
# Store both original name and color-stripped version
|
||||
self.player_teams[name] = team
|
||||
clean_name = strip_color_codes(name)
|
||||
if clean_name != name:
|
||||
self.player_teams[clean_name] = team
|
||||
|
||||
logger.debug(f'Updated team for {name} (clean: {clean_name}): {team}')
|
||||
|
||||
def get_team(self, name):
|
||||
"""Get player's team"""
|
||||
return self.player_teams.get(name)
|
||||
|
||||
def add_player(self, name, score='0', ping='0'):
|
||||
"""Add player to server's player dict if not exists"""
|
||||
# Use original name with color codes as key
|
||||
if name not in self.server_info.players:
|
||||
self.server_info.players[name] = {
|
||||
'score': score,
|
||||
'ping': ping
|
||||
}
|
||||
logger.debug(f'Added player: {name}')
|
||||
|
||||
def get_players_by_team(self):
|
||||
"""Get players organized by team"""
|
||||
teams = {'RED': [], 'BLUE': [], 'SPECTATOR': [], 'FREE': []}
|
||||
for name in self.server_info.players.keys():
|
||||
team = self.player_teams.get(name, 'FREE')
|
||||
if team not in teams:
|
||||
team = 'FREE'
|
||||
teams[team].append(name)
|
||||
return teams
|
||||
|
||||
def remove_player(self, name):
|
||||
"""Remove player from tracking"""
|
||||
clean_name = strip_color_codes(name)
|
||||
|
||||
# Try to remove by exact name first
|
||||
removed = self.server_info.players.pop(name, None)
|
||||
|
||||
# If not found, try to find by clean name
|
||||
if not removed:
|
||||
for player_name in list(self.server_info.players.keys()):
|
||||
if strip_color_codes(player_name) == clean_name:
|
||||
removed = self.server_info.players.pop(player_name)
|
||||
logger.info(f'Removed player: {player_name} (matched clean name: {clean_name})')
|
||||
break
|
||||
else:
|
||||
logger.info(f'Removed player: {name}')
|
||||
|
||||
if not removed:
|
||||
logger.warning(f'Player not found for removal: {name} (clean: {clean_name})')
|
||||
|
||||
# Remove from team tracking
|
||||
self.player_teams.pop(name, None)
|
||||
self.player_teams.pop(clean_name, None)
|
||||
|
||||
def rename_player(self, old_name, new_name):
|
||||
"""Rename a player while maintaining their team and score"""
|
||||
old_clean = strip_color_codes(old_name)
|
||||
|
||||
# Get current team (try both names)
|
||||
team = self.player_teams.get(old_name) or self.player_teams.get(old_clean, 'SPECTATOR')
|
||||
|
||||
# Find player data by old name
|
||||
player_data = self.server_info.players.pop(old_name, None)
|
||||
|
||||
# If not found by exact name, try clean name
|
||||
if not player_data:
|
||||
for player_name in list(self.server_info.players.keys()):
|
||||
if strip_color_codes(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
|
||||
self.player_teams.pop(old_name, None)
|
||||
self.player_teams.pop(old_clean, None)
|
||||
|
||||
# Add new team entries with color codes preserved
|
||||
self.update_team(new_name, team)
|
||||
|
||||
logger.debug(f'Renamed player: {old_name} -> {new_name} (team: {team})')
|
||||
|
||||
def update_score(self, name, delta):
|
||||
"""Update player's score by delta (+1 for kill, -1 for death/suicide)"""
|
||||
# Try exact name first (O(1) lookup)
|
||||
if name in self.server_info.players:
|
||||
current_score = int(self.server_info.players[name].get('score', 0))
|
||||
self.server_info.players[name]['score'] = str(current_score + delta)
|
||||
logger.debug(f"Score update: {name} {delta:+d} -> {self.server_info.players[name]['score']}")
|
||||
return
|
||||
|
||||
# Fallback: search by clean name (rare case)
|
||||
clean_name = strip_color_codes(name)
|
||||
for player_name, player_data in self.server_info.players.items():
|
||||
if strip_color_codes(player_name) == clean_name:
|
||||
current_score = int(player_data.get('score', 0))
|
||||
player_data['score'] = str(current_score + delta)
|
||||
logger.debug(f"Score update: {player_name} {delta:+d} -> {player_data['score']}")
|
||||
return
|
||||
|
||||
logger.warning(f"Could not update score for {name} - player not found")
|
||||
|
||||
class EventDeduplicator:
|
||||
"""Prevents duplicate kill/death events"""
|
||||
|
||||
def __init__(self):
|
||||
self.recent_events = []
|
||||
|
||||
def is_duplicate(self, event_type, time_val, killer_name, victim_name):
|
||||
"""Check if this kill/death event is a duplicate"""
|
||||
if event_type not in ('PLAYER_DEATH', 'PLAYER_KILL'):
|
||||
return False
|
||||
|
||||
signature = f"KILL:{time_val}:{killer_name}:{victim_name}"
|
||||
|
||||
if signature in self.recent_events:
|
||||
logger.debug(f'Duplicate event: {signature}')
|
||||
return True
|
||||
|
||||
# Add to recent events
|
||||
self.recent_events.append(signature)
|
||||
if len(self.recent_events) > MAX_RECENT_EVENTS:
|
||||
self.recent_events.pop(0)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class GameState:
|
||||
"""Main game state container"""
|
||||
|
||||
def __init__(self):
|
||||
self.server_info = ServerInfo()
|
||||
self.player_tracker = PlayerTracker(self.server_info)
|
||||
self.event_deduplicator = EventDeduplicator()
|
||||
self.pending_background_commands = set()
|
||||
self.status_buffer = []
|
||||
@@ -0,0 +1,807 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Curses-based UI for QLPyCon
|
||||
Handles terminal display, windows, and color rendering
|
||||
"""
|
||||
|
||||
import curses
|
||||
import curses.textpad
|
||||
import threading
|
||||
import queue
|
||||
import logging
|
||||
import time
|
||||
from .constants 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')
|
||||
|
||||
|
||||
class CursesHandler(logging.Handler):
|
||||
"""Logging handler that outputs to curses window"""
|
||||
|
||||
def __init__(self, window):
|
||||
logging.Handler.__init__(self)
|
||||
self.window = window
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
msg = self.format(record)
|
||||
fs = "%s\n"
|
||||
try:
|
||||
print_colored(self.window, fs % msg, 0)
|
||||
self.window.noutrefresh()
|
||||
curses.doupdate()
|
||||
except UnicodeError:
|
||||
print_colored(self.window, fs % msg.encode("UTF-8"), 0)
|
||||
self.window.noutrefresh()
|
||||
curses.doupdate()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
raise
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
def print_colored(window, message, attributes=0):
|
||||
"""
|
||||
Print message with Quake color codes (^N)
|
||||
^0 = reset, ^1 = red, ^2 = green, ^3 = yellow, ^4 = blue, ^5 = cyan, ^6 = magenta, ^7 = white, ^8 = bold, ^9 = underline
|
||||
"""
|
||||
if not curses.has_colors:
|
||||
try:
|
||||
window.addstr(message)
|
||||
except curses.error:
|
||||
pass
|
||||
return
|
||||
|
||||
color = 0
|
||||
bold = False
|
||||
underline = False
|
||||
parse_color = False
|
||||
|
||||
for ch in message:
|
||||
val = ord(ch)
|
||||
if parse_color:
|
||||
if ch == '8':
|
||||
bold = True
|
||||
elif ch == '9':
|
||||
underline = True
|
||||
elif ch == '0':
|
||||
bold = False
|
||||
underline = False
|
||||
elif ch == '7':
|
||||
color = 0
|
||||
elif ord('1') <= val <= ord('6'):
|
||||
color = val - ord('0')
|
||||
else:
|
||||
try:
|
||||
window.addch('^', 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)
|
||||
except curses.error:
|
||||
return
|
||||
parse_color = False
|
||||
elif ch == '^':
|
||||
parse_color = True
|
||||
else:
|
||||
try:
|
||||
window.addch(ch, curses.color_pair(color) | (curses.A_BOLD if bold else 0) | (curses.A_UNDERLINE if underline else 0) | attributes)
|
||||
except curses.error:
|
||||
return
|
||||
|
||||
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 curses.error:
|
||||
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 curses.error:
|
||||
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 curses.error:
|
||||
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 curses.error:
|
||||
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 curses.error:
|
||||
pass
|
||||
|
||||
return suggestions, suggestion_index, original_word
|
||||
|
||||
|
||||
class UIManager:
|
||||
"""Manages curses windows and display"""
|
||||
|
||||
def __init__(self, screen, host):
|
||||
self.screen = screen
|
||||
self.host = host
|
||||
self.info_window = None
|
||||
self.output_window = None
|
||||
self.input_window = None
|
||||
self.divider_window = None
|
||||
self.input_queue = None
|
||||
self.command_history = []
|
||||
self.history_index = -1
|
||||
self.cursor_pos = 0 # Track cursor position in input
|
||||
|
||||
self._init_curses()
|
||||
self._create_windows()
|
||||
|
||||
def _init_curses(self):
|
||||
"""Initialize curses settings"""
|
||||
curses.endwin()
|
||||
curses.initscr()
|
||||
self.screen.nodelay(1)
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.cbreak()
|
||||
curses.curs_set(1) # Show cursor in input window
|
||||
|
||||
self.screen.addstr(f"Quake Live PyCon: {self.host}")
|
||||
self.screen.noutrefresh()
|
||||
|
||||
# Initialize color pairs
|
||||
for i in range(1, 7):
|
||||
curses.init_pair(i, i, 0)
|
||||
|
||||
# Swap cyan and magenta (5 and 6)
|
||||
curses.init_pair(5, 6, 0)
|
||||
curses.init_pair(6, 5, 0)
|
||||
|
||||
def _create_windows(self):
|
||||
"""Create all UI windows"""
|
||||
maxy, maxx = self.screen.getmaxyx()
|
||||
|
||||
# Minimum terminal size check
|
||||
if maxy < 20 or maxx < 80:
|
||||
return False
|
||||
|
||||
# Server info window (top)
|
||||
self.info_window = curses.newwin(
|
||||
INFO_WINDOW_HEIGHT,
|
||||
maxx - 4,
|
||||
INFO_WINDOW_Y,
|
||||
2
|
||||
)
|
||||
self.info_window.scrollok(False)
|
||||
self.info_window.idlok(False)
|
||||
self.info_window.leaveok(True)
|
||||
self.info_window.noutrefresh()
|
||||
|
||||
# Output window (middle - main display)
|
||||
self.output_window = curses.newwin(
|
||||
maxy - 17,
|
||||
maxx - 4,
|
||||
OUTPUT_WINDOW_Y,
|
||||
2
|
||||
)
|
||||
self.output_window.scrollok(True)
|
||||
self.output_window.idlok(False)
|
||||
self.output_window.idcok(False)
|
||||
self.output_window.leaveok(True)
|
||||
self.output_window.noutrefresh()
|
||||
|
||||
# Divider line
|
||||
self.divider_window = curses.newwin(
|
||||
1,
|
||||
maxx - 4,
|
||||
maxy - 3,
|
||||
2
|
||||
)
|
||||
self.divider_window.hline(curses.ACS_HLINE, maxx - 4)
|
||||
self.divider_window.scrollok(False)
|
||||
self.divider_window.idlok(False)
|
||||
self.divider_window.leaveok(True)
|
||||
self.divider_window.noutrefresh()
|
||||
|
||||
# Input window (bottom)
|
||||
self.input_window = curses.newwin(
|
||||
INPUT_WINDOW_HEIGHT,
|
||||
maxx - 6,
|
||||
maxy - 2,
|
||||
4
|
||||
)
|
||||
self.input_window.keypad(True)
|
||||
self.input_window.nodelay(False)
|
||||
self.screen.addstr(maxy - 2, 2, '$ ')
|
||||
self.input_window.idlok(True)
|
||||
self.input_window.idcok(True)
|
||||
self.input_window.leaveok(False)
|
||||
self.input_window.noutrefresh()
|
||||
|
||||
self.screen.noutrefresh()
|
||||
curses.doupdate()
|
||||
return True
|
||||
|
||||
def handle_resize(self):
|
||||
"""Handle terminal resize event"""
|
||||
try:
|
||||
# Get new terminal dimensions
|
||||
maxy, maxx = self.screen.getmaxyx()
|
||||
|
||||
# Minimum size check
|
||||
if maxy < 20 or maxx < 80:
|
||||
return False
|
||||
|
||||
# Update screen
|
||||
curses.update_lines_cols()
|
||||
self.screen.clear()
|
||||
self.screen.addstr(0, 0, f"Quake Live PyCon: {self.host}")
|
||||
self.screen.noutrefresh()
|
||||
|
||||
# Recreate windows with new dimensions
|
||||
self.info_window.resize(INFO_WINDOW_HEIGHT, maxx - 4)
|
||||
self.info_window.mvwin(INFO_WINDOW_Y, 2)
|
||||
|
||||
self.output_window.resize(maxy - 17, maxx - 4)
|
||||
self.output_window.mvwin(OUTPUT_WINDOW_Y, 2)
|
||||
|
||||
self.divider_window.resize(1, maxx - 4)
|
||||
self.divider_window.mvwin(maxy - 3, 2)
|
||||
self.divider_window.clear()
|
||||
self.divider_window.hline(curses.ACS_HLINE, maxx - 4)
|
||||
|
||||
self.input_window.resize(INPUT_WINDOW_HEIGHT, maxx - 6)
|
||||
self.input_window.mvwin(maxy - 2, 4)
|
||||
|
||||
self.screen.addstr(maxy - 2, 2, '$ ')
|
||||
|
||||
# Refresh all windows
|
||||
self.info_window.noutrefresh()
|
||||
self.output_window.noutrefresh()
|
||||
self.divider_window.noutrefresh()
|
||||
self.input_window.noutrefresh()
|
||||
self.screen.noutrefresh()
|
||||
curses.doupdate()
|
||||
|
||||
return True
|
||||
|
||||
except curses.error:
|
||||
return False
|
||||
|
||||
def setup_input_queue(self):
|
||||
"""Setup threaded input queue with command history and autocomplete"""
|
||||
def wait_stdin(q, window, manager):
|
||||
current_input = ""
|
||||
cursor_pos = 0
|
||||
manager.cursor_pos = 0 # Keep manager in sync
|
||||
temp_history_index = -1
|
||||
temp_input = "" # Temp storage when navigating history
|
||||
quit_confirm = False
|
||||
|
||||
# Autocomplete state
|
||||
suggestions = []
|
||||
suggestion_index = -1
|
||||
original_word = "" # Store original word before cycling
|
||||
|
||||
while True:
|
||||
try:
|
||||
key = window.getch()
|
||||
|
||||
if key == -1: # No input
|
||||
continue
|
||||
|
||||
# Handle terminal resize
|
||||
if key == curses.KEY_RESIZE:
|
||||
manager.handle_resize()
|
||||
# Redraw input
|
||||
window.erase()
|
||||
window.addstr(0, 0, current_input)
|
||||
window.move(0, cursor_pos)
|
||||
window.noutrefresh()
|
||||
curses.doupdate()
|
||||
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)
|
||||
manager.cursor_pos = cursor_pos
|
||||
|
||||
# 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 curses.error:
|
||||
pass
|
||||
|
||||
window.move(0, cursor_pos)
|
||||
window.noutrefresh()
|
||||
curses.doupdate() # Actually push the refresh to screen
|
||||
continue
|
||||
|
||||
# Enter key
|
||||
if key in (curses.KEY_ENTER, 10, 13):
|
||||
if len(current_input) > 0:
|
||||
# Add to history
|
||||
manager.command_history.append(current_input)
|
||||
if len(manager.command_history) > MAX_COMMAND_HISTORY:
|
||||
manager.command_history.pop(0)
|
||||
|
||||
q.put(current_input)
|
||||
current_input = ""
|
||||
cursor_pos = 0
|
||||
manager.cursor_pos = cursor_pos
|
||||
temp_history_index = -1
|
||||
temp_input = ""
|
||||
suggestions = []
|
||||
suggestion_index = -1
|
||||
original_word = ""
|
||||
window.erase()
|
||||
window.noutrefresh()
|
||||
|
||||
# Arrow UP - previous command
|
||||
elif key == curses.KEY_UP:
|
||||
if len(manager.command_history) > 0:
|
||||
# Save current input when first entering history
|
||||
if temp_history_index == -1:
|
||||
temp_input = current_input
|
||||
temp_history_index = len(manager.command_history)
|
||||
|
||||
if temp_history_index > 0:
|
||||
temp_history_index -= 1
|
||||
current_input = manager.command_history[temp_history_index]
|
||||
cursor_pos = len(current_input)
|
||||
manager.cursor_pos = cursor_pos
|
||||
suggestions = []
|
||||
suggestion_index = -1
|
||||
original_word = ""
|
||||
window.erase()
|
||||
window.addstr(0, 0, current_input)
|
||||
window.noutrefresh()
|
||||
|
||||
# Arrow DOWN - next command
|
||||
elif key == curses.KEY_DOWN:
|
||||
if temp_history_index != -1:
|
||||
temp_history_index += 1
|
||||
if temp_history_index >= len(manager.command_history):
|
||||
# Restore temp input
|
||||
current_input = temp_input
|
||||
temp_history_index = -1
|
||||
temp_input = ""
|
||||
else:
|
||||
current_input = manager.command_history[temp_history_index]
|
||||
|
||||
cursor_pos = len(current_input)
|
||||
manager.cursor_pos = cursor_pos
|
||||
suggestions = []
|
||||
suggestion_index = -1
|
||||
original_word = ""
|
||||
window.erase()
|
||||
window.addstr(0, 0, current_input)
|
||||
window.noutrefresh()
|
||||
|
||||
# Arrow LEFT - move cursor left
|
||||
elif key == curses.KEY_LEFT:
|
||||
if cursor_pos > 0:
|
||||
cursor_pos -= 1
|
||||
manager.cursor_pos = cursor_pos
|
||||
window.move(0, cursor_pos)
|
||||
window.noutrefresh()
|
||||
|
||||
# Arrow RIGHT - move cursor right
|
||||
elif key == curses.KEY_RIGHT:
|
||||
if cursor_pos < len(current_input):
|
||||
cursor_pos += 1
|
||||
manager.cursor_pos = cursor_pos
|
||||
window.move(0, cursor_pos)
|
||||
window.noutrefresh()
|
||||
|
||||
# Backspace
|
||||
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
||||
if cursor_pos > 0:
|
||||
current_input = current_input[:cursor_pos-1] + current_input[cursor_pos:]
|
||||
cursor_pos -= 1
|
||||
manager.cursor_pos = cursor_pos
|
||||
temp_history_index = -1 # Exit history mode
|
||||
|
||||
window.erase()
|
||||
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.noutrefresh()
|
||||
curses.doupdate() # Immediate screen update
|
||||
|
||||
# Regular character
|
||||
elif 32 <= key <= 126:
|
||||
char = chr(key)
|
||||
current_input = current_input[:cursor_pos] + char + current_input[cursor_pos:]
|
||||
cursor_pos += 1
|
||||
manager.cursor_pos = cursor_pos
|
||||
temp_history_index = -1 # Exit history mode
|
||||
|
||||
window.erase()
|
||||
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.noutrefresh()
|
||||
curses.doupdate() # Immediate screen update
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Input error: {e}')
|
||||
# Log but continue - input thread should stay alive
|
||||
|
||||
window.move(0, cursor_pos)
|
||||
curses.doupdate()
|
||||
|
||||
self.input_queue = queue.Queue()
|
||||
t = threading.Thread(target=wait_stdin, args=(self.input_queue, self.input_window, self))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
return self.input_queue
|
||||
|
||||
def setup_logging(self):
|
||||
"""Setup logging handler for output window"""
|
||||
handler = CursesHandler(self.output_window)
|
||||
formatter = logging.Formatter('%(asctime)-8s|%(name)-12s|%(levelname)-6s|%(message)-s', '%H:%M:%S')
|
||||
handler.setFormatter(formatter)
|
||||
return handler
|
||||
|
||||
def print_message(self, message, attributes=0):
|
||||
"""Print formatted message to output window"""
|
||||
print_colored(self.output_window, message, attributes)
|
||||
self.output_window.noutrefresh()
|
||||
# Restore cursor to input window at current position
|
||||
self.input_window.move(0, self.cursor_pos)
|
||||
self.input_window.noutrefresh()
|
||||
curses.doupdate()
|
||||
|
||||
def update_server_info(self, game_state):
|
||||
"""Update server info window"""
|
||||
self.info_window.erase()
|
||||
|
||||
max_y, max_x = self.info_window.getmaxyx()
|
||||
server_info = game_state.server_info
|
||||
|
||||
# Line 1: Hostname with Timer and Warmup Indicator
|
||||
hostname = server_info.hostname
|
||||
|
||||
timer_display = ""
|
||||
if server_info.match_time > 0 and not server_info.warmup:
|
||||
# Calculate live time: add elapsed seconds since last server update
|
||||
current_time = server_info.match_time
|
||||
if server_info.match_time_last_sync > 0:
|
||||
elapsed = int(time.time() - server_info.match_time_last_sync)
|
||||
current_time += elapsed
|
||||
|
||||
mins = current_time // 60
|
||||
secs = current_time % 60
|
||||
timer_display = f"^3^0Time:^8^7 {mins}:{secs:02d}^0"
|
||||
else:
|
||||
timer_display = "^3^0Time:^8^7 0:00^0"
|
||||
|
||||
warmup_display = "^3^0Warmup:^8 ^2YES^0" if server_info.warmup else "^3^0Warmup: ^8^1NO^0"
|
||||
|
||||
print_colored(self.info_window, f"^3Name:^8 {hostname} {warmup_display} {timer_display}\n", 0)
|
||||
|
||||
# Line 2: Game info
|
||||
gametype = server_info.gametype
|
||||
mapname = server_info.map
|
||||
timelimit = server_info.timelimit
|
||||
fraglimit = server_info.fraglimit
|
||||
roundlimit = server_info.roundlimit
|
||||
caplimit = server_info.capturelimit
|
||||
curclients = len(server_info.players)
|
||||
maxclients = server_info.maxclients
|
||||
|
||||
# Context-sensitive limit display based on gametype
|
||||
if gametype == 'Capture the Flag':
|
||||
limit_display = f"^3^0| Capturelimit:^7^8 {caplimit}"
|
||||
elif gametype == 'Clan Arena':
|
||||
limit_display = f"^3^0| Roundlimit:^7^8 {roundlimit}"
|
||||
elif gametype == 'Duel':
|
||||
limit_display = f"^3^0| Timelimit:^7^8 {timelimit}"
|
||||
elif gametype == 'Race':
|
||||
limit_display = f"^3^0| Timelimit:^7^8 {timelimit}"
|
||||
else:
|
||||
limit_display = f"^3^0| Timelimit:^7^8 {timelimit} ^0^3| Fraglimit:^7^8 {fraglimit}"
|
||||
|
||||
print_colored(self.info_window,
|
||||
f"^3^0Type:^7^8 {gametype} ^0^3| Map:^7^8 {mapname} ^0^3| Players:^7^8 {curclients}/{maxclients} "
|
||||
f"{limit_display}^0\n", 0)
|
||||
|
||||
# Blank lines to fill
|
||||
try:
|
||||
self.info_window.addstr("\n")
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Line 3: Team headers and player lists
|
||||
teams = game_state.player_tracker.get_players_by_team()
|
||||
|
||||
if server_info.gametype in TEAM_MODES:
|
||||
if server_info.gametype == 'Clan Arena':
|
||||
red_score = f"{server_info.red_rounds:>3} "
|
||||
blue_score = f"{server_info.blue_rounds:>3} "
|
||||
|
||||
else:
|
||||
red_total = 0
|
||||
blue_total = 0
|
||||
for player_name, player_data in server_info.players.items():
|
||||
team = game_state.player_tracker.get_team(player_name)
|
||||
score = int(player_data.get('score', 0))
|
||||
|
||||
if team == 'RED':
|
||||
red_total += score
|
||||
elif team == 'BLUE':
|
||||
blue_total += score
|
||||
|
||||
red_score = f"{red_total:>3} "
|
||||
blue_score = f"{blue_total:>3} "
|
||||
|
||||
print_colored(self.info_window, f"^8^7{red_score}^9^1RED TEAM^0 ^7^8{blue_score}^9^4BLUE TEAM\n", 0)
|
||||
|
||||
# Sort players by score within each team
|
||||
red_players_with_scores = []
|
||||
blue_players_with_scores = []
|
||||
spec_players = []
|
||||
|
||||
for player_name in teams['RED']:
|
||||
score = int(server_info.players.get(player_name, {}).get('score', 0))
|
||||
red_players_with_scores.append((player_name, score))
|
||||
|
||||
for player_name in teams['BLUE']:
|
||||
score = int(server_info.players.get(player_name, {}).get('score', 0))
|
||||
blue_players_with_scores.append((player_name, score))
|
||||
|
||||
# Sort by score descending
|
||||
red_players_with_scores.sort(key=lambda x: x[1], reverse=True)
|
||||
blue_players_with_scores.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
red_players = [name for name, score in red_players_with_scores[:4]]
|
||||
blue_players = [name for name, score in blue_players_with_scores[:4]]
|
||||
spec_players = teams['SPECTATOR'][:4]
|
||||
|
||||
for i in range(4):
|
||||
red_name = red_players[i] if i < len(red_players) else ''
|
||||
blue_name = blue_players[i] if i < len(blue_players) else ''
|
||||
|
||||
# Get scores for team players
|
||||
red_score = server_info.players.get(red_name, {}).get('score', '0') if red_name else ''
|
||||
blue_score = server_info.players.get(blue_name, {}).get('score', '0') if blue_name else ''
|
||||
|
||||
# Check if players are dead
|
||||
red_dead = red_name in server_info.dead_players
|
||||
blue_dead = blue_name in server_info.dead_players
|
||||
|
||||
# Format with strikethrough for dead players (using dim text)
|
||||
red = f"{red_score:>3} {'^8^1X^7^0 ' if red_dead else ''}{red_name}" if red_name else ''
|
||||
blue = f"{blue_score:>3} {'^8^1X^7^0 ' if blue_dead else ''}{blue_name}" if blue_name else ''
|
||||
|
||||
from .formatter import strip_color_codes
|
||||
red_clean = strip_color_codes(red)
|
||||
blue_clean = strip_color_codes(blue)
|
||||
|
||||
red_pad = 24 - len(red_clean)
|
||||
|
||||
line = f"^8{red}^0{' ' * red_pad}^8{blue}^0\n"
|
||||
print_colored(self.info_window, line, 0)
|
||||
else:
|
||||
print_colored(self.info_window, f" ^8^9^5FREE\n", 0)
|
||||
# Sort FREE players by score (highest first)
|
||||
free_players = teams['FREE']
|
||||
free_players_with_scores = []
|
||||
for player_name in free_players:
|
||||
score = int(server_info.players.get(player_name, {}).get('score', 0))
|
||||
free_players_with_scores.append((player_name, score))
|
||||
|
||||
# Sort by score descending
|
||||
free_players_with_scores.sort(key=lambda x: x[1], reverse=True)
|
||||
sorted_free_players = [name for name, score in free_players_with_scores]
|
||||
|
||||
spec_players = teams['SPECTATOR'][:4]
|
||||
free_col1 = sorted_free_players[:4]
|
||||
free_col2 = sorted_free_players[4:8]
|
||||
|
||||
for i in range(4):
|
||||
col1_name = free_col1[i] if i < len(free_col1) else ''
|
||||
col2_name = free_col2[i] if i < len(free_col2) else ''
|
||||
|
||||
# Get scores for FREE players
|
||||
col1_score = server_info.players.get(col1_name, {}).get('score', '0') if col1_name else ''
|
||||
col2_score = server_info.players.get(col2_name, {}).get('score', '0') if col2_name else ''
|
||||
|
||||
# Check if players are dead
|
||||
col1_dead = col1_name in server_info.dead_players
|
||||
col2_dead = col2_name in server_info.dead_players
|
||||
|
||||
# Format: " 9 PlayerName" with right-aligned score and dead marker
|
||||
col1 = f"{col1_score:>3} {'^8^1X^7^0 ' if col1_dead else ''}{col1_name}" if col1_name else ''
|
||||
col2 = f"{col2_score:>3} {'^8^1X^7^0 ' if col2_dead else ''}{col2_name}" if col2_name else ''
|
||||
|
||||
from .formatter import strip_color_codes
|
||||
col1_clean = strip_color_codes(col1)
|
||||
col2_clean = strip_color_codes(col2)
|
||||
|
||||
col1_pad = 24 - len(col1_clean)
|
||||
|
||||
line = f"^8{col1}^0{' ' * col1_pad}^8{col2}^0\n"
|
||||
print_colored(self.info_window, line, 0)
|
||||
|
||||
# Blank lines to fill
|
||||
try:
|
||||
self.info_window.addstr("\n")
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# List spectators on one line
|
||||
spec_list = " ".join(spec_players)
|
||||
line = f"^8^3Spectators:^7 {spec_list}\n"
|
||||
print_colored(self.info_window, line, 0)
|
||||
|
||||
# Blank lines to fill
|
||||
try:
|
||||
self.info_window.addstr("\n")
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Separator
|
||||
separator = "^7" + "═" * (max_x - 1) + "^7"
|
||||
print_colored(self.info_window, separator, 0)
|
||||
|
||||
self.info_window.noutrefresh()
|
||||
# Restore cursor to input window at current position
|
||||
self.input_window.move(0, self.cursor_pos)
|
||||
self.input_window.noutrefresh()
|
||||
curses.doupdate()
|
||||
Reference in New Issue
Block a user