Send cvarlist and cmdlist on connect and merge the answer into the autocomplete candidates. Number the server menu from 0, keep suggesting sibling commands while the command name is typed, mask the password in the startup line, ignore venv/, fix README claims.
625 lines
15 KiB
Python
625 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Quake Live console variables (cvars) database
|
|
Common cvars for autocomplete and fuzzy search
|
|
"""
|
|
|
|
import re
|
|
|
|
# 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',
|
|
'g_password',
|
|
'g_needpass',
|
|
'g_overtime',
|
|
'g_startingHealth',
|
|
'g_startingArmor',
|
|
'g_infiniteAmmo',
|
|
'g_loadout',
|
|
'sv_privatePassword',
|
|
]
|
|
|
|
# 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',
|
|
'qlx_plugins',
|
|
'qlx_pluginsPath',
|
|
]
|
|
|
|
# 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',
|
|
'systeminfo',
|
|
'players',
|
|
'maplist',
|
|
'configstrings',
|
|
'cvarlist',
|
|
'cmdlist',
|
|
'dumpuser',
|
|
# Console
|
|
'exec',
|
|
'set',
|
|
'seta',
|
|
'sets',
|
|
'echo',
|
|
'clientkick',
|
|
'cp',
|
|
]
|
|
|
|
# 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>',
|
|
'g_password': '<password>',
|
|
'g_needpass': '<0|1>',
|
|
'sv_privatePassword': '<password>',
|
|
|
|
# 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': '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, learned_names=(), max_results=5):
|
|
"""
|
|
Autocomplete a partial cvar/command name.
|
|
Candidates are the built-in lists plus the names learned from the server
|
|
(see lib/namelist.py); a learned name that is already built in is skipped.
|
|
"""
|
|
candidates = list(ALL_CVARS)
|
|
builtin_lower = {name.lower() for name in ALL_CVARS}
|
|
for name in sorted(learned_names):
|
|
if name.lower() not in builtin_lower:
|
|
candidates.append(name)
|
|
return fuzzy_match(partial, candidates, 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]']
|
|
"""
|
|
# 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}")
|