# Copyright (C) 2026 Doomsday
# This is an extension plugin for minqlx to callvote factories
# Place the factories you want votable in factories.txt in your /baseq3 folder
# Originally created for Thunderdome tournament servers
# You can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation,
# either version 3 of the License, or (at your option) any later version.
# You should have received a copy of the GNU General Public License
# along with minqlx. If not, see .
# Created by Doomsday
# https://github.com/D00MSDAYDEVICE
# https://www.youtube.com/@HIT-CLIPS
# You are free to modify this plugin.
# This plugin comes with no warranty or guarantee.
# ──────────────────────────────────────────────────────────────────────────────
# PER-FACTORY CONFIG SUPPORT
# ──────────────────────────────────────────────────────────────────────────────
# When a factory vote passes, the plugin will look for a matching .cfg file in
# the minqlx-plugins folder (same directory as this script).
#
# Example: factory "ctf" → looks for /ctf.cfg
# factory "ca" → looks for /ca.cfg
#
# If the file exists, it is executed as a server console command before the map change.
#
# RESTORING DEFAULT SETTINGS
# Place a file named restore.cfg in the same plugins folder.
# The !restorecfg command executes it to reset cvars back to defaults.
# The restore is also offered automatically in the vote-failed message.
#
# Copy any custom settings your server has set in server.cfg into your restore.cfg
# This is extra protection to ensure when a factory is changed back, the defaults are set too.
# Good for when a server restart is not available
#
# Permission level is controlled by:
# ql_restorecfg_perm 2 (default: 2)
# 0 = everyone 1 = regular 2 = mod 3 = admin 4 = superadmin
# Set it in your server config, e.g.: set ql_restorecfg_perm 3
#
# EXAMPLE ctf.cfg
# ──────────────────
# // CTF-specific overrides
# set g_timelimit 15
# set g_scorelimit 8
# set g_friendlyfire 0
#
# EXAMPLE restore.cfg (defaults / restore)
# ──────────────────
# set g_timelimit 10
# set g_scorelimit 50
# set g_friendlyfire 0
# ──────────────────────────────────────────────────────────────────────────────
import minqlx
import os
class factoryvote(minqlx.Plugin):
def __init__(self):
self.version = "1.8"
# ql_restorecfg_perm - minimum permission level required to use !restorecfg
# 0 = everyone, 1 = regular, 2 = mod, 3 = admin, 4 = superadmin (default: 2)
self.set_cvar_once("ql_restorecfg_perm", "2")
restore_perm = int(self.get_cvar("ql_restorecfg_perm"))
self.add_command("factoryvote", self.cmd_factoryvote, 0)
self.add_command("fv", self.cmd_factoryvote, 0)
self.add_command("fvv", self.cmd_version, 0)
self.add_command("factory", self.cmd_factory, 0)
self.add_command("check", self.cmd_check, 0)
self.add_command("restorecfg", self.cmd_restorecfg, restore_perm)
self.plugins_dir = os.path.dirname(os.path.abspath(__file__))
self.factories = self.load_factories()
self.selected_factory = None
self.add_hook("game_countdown", self.handle_game_countdown)
# ──────────────────────────────────────────
# VERSION
# ──────────────────────────────────────────
def cmd_version(self, player, msg, channel):
player.tell("^3FactoryVote Plugin Version:^7 {}".format(self.version))
# ──────────────────────────────────────────
# LOAD FACTORIES LIST
# ──────────────────────────────────────────
def load_factories(self):
primary = os.path.join(self.get_cvar("fs_basepath"), "baseq3", "factories.txt")
fallback = os.path.join(self.plugins_dir, "factories.txt")
if os.path.isfile(primary):
factories_file = primary
self.logger.info("factories.txt found in baseq3.")
elif os.path.isfile(fallback):
factories_file = fallback
self.logger.info("factories.txt not found in baseq3, using minqlx-plugins folder.")
else:
self.logger.warning("factories.txt not found in baseq3 or minqlx-plugins folder.")
return None
with open(factories_file, "r") as f:
factories = [line.strip() for line in f if line.strip()]
if not factories:
self.logger.warning("factories.txt is empty.")
return factories
# ──────────────────────────────────────────
# CFG HELPERS
# ──────────────────────────────────────────
def cfg_path_for(self, name):
"""Return the full path to .cfg in the plugins directory."""
return os.path.join(self.plugins_dir, "{}.cfg".format(name))
def execute_cfg(self, cfg_path):
"""
Execute every non-blank, non-comment line in cfg_path as a server
console command. Returns (lines_run, skipped_lines).
"""
lines_run = 0
skipped = 0
with open(cfg_path, "r") as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("//") or line.startswith("#"):
skipped += 1
continue
self.console(line)
self.logger.info("factoryvote cfg exec: %s", line)
lines_run += 1
return lines_run, skipped
def try_load_factory_cfg(self, factory_name):
"""
If a matching .cfg exists for factory_name, execute it and announce
the result to the server. Returns True if a cfg was found and run.
"""
cfg = self.cfg_path_for(factory_name)
if not os.path.isfile(cfg):
self.logger.info("No cfg found for factory '%s' (looked for %s).", factory_name, cfg)
return False
self.msg("^3Loading settings for factory:^7 {} ^3...".format(factory_name))
lines_run, _ = self.execute_cfg(cfg)
self.msg("^2Settings applied ({} commands from {}.cfg).".format(lines_run, factory_name))
self.logger.info("factoryvote: applied %d commands from %s", lines_run, cfg)
return True
# ──────────────────────────────────────────
# RESTORE DEFAULT SETTINGS (!restorecfg)
# ──────────────────────────────────────────
def cmd_restorecfg(self, player, msg, channel):
"""
Execute restore.cfg from the plugins folder to restore default settings.
Access is gated by the ql_restorecfg_perm cvar (default: 2 / mod).
"""
cfg = self.cfg_path_for("restore")
if not os.path.isfile(cfg):
player.tell(
"^1restore.cfg not found in plugins folder. "
"Create ^7{} ^1to enable this feature.".format(cfg)
)
return
self.msg("^3{} ^7is restoring default server settings...".format(player.name))
lines_run, _ = self.execute_cfg(cfg)
self.msg("^2Default settings restored ({} commands from restore.cfg).".format(lines_run))
self.logger.info("factoryvote: restore.cfg restored by %s (%d commands)", player.name, lines_run)
# ──────────────────────────────────────────
# GAME COUNTDOWN HOOK
# ──────────────────────────────────────────
def handle_game_countdown(self):
if self.selected_factory:
self.msg("^3Game starting with factory:^7 {}".format(self.selected_factory))
else:
self.msg("^3Game starting. No factory selected. Using default settings.")
# ──────────────────────────────────────────
# !factory — show current selection
# ──────────────────────────────────────────
def cmd_factory(self, player, msg, channel):
if self.selected_factory:
player.tell("^3Current loaded factory:^7 {}".format(self.selected_factory))
else:
player.tell("^3No factory has been selected yet.")
# ──────────────────────────────────────────
# !check — compare plugin vs server state
# ──────────────────────────────────────────
def cmd_check(self, player, msg, channel):
try:
server_factory = self.game.factory
except Exception:
server_factory = None
if server_factory:
player.tell("^3Current Factory (server):^7 {}".format(server_factory))
else:
player.tell("^1Could not retrieve current factory from server.")
if self.selected_factory:
if self.selected_factory.lower() == (server_factory or "").lower():
player.tell("^2Plugin selected factory matches the server factory.")
else:
player.tell(
"^3Plugin selected factory:^7 {} ^3(differs from server)".format(self.selected_factory)
)
else:
player.tell("^3No factory selected via plugin yet.")
# Show whether a cfg exists for the current server factory
if server_factory:
cfg = self.cfg_path_for(server_factory)
if os.path.isfile(cfg):
player.tell("^2A .cfg file exists for this factory: ^7{}".format(cfg))
else:
player.tell("^3No .cfg file found for this factory (^7{}^3).".format(server_factory))
# ──────────────────────────────────────────
# !fv / !factoryvote — main vote command
# ──────────────────────────────────────────
def cmd_factoryvote(self, player, msg, channel):
if self.factories is None:
player.tell("^1Error:^7 factories.txt not found in baseq3.")
return
if not self.factories:
player.tell("^1Error:^7 factories.txt is empty.")
return
# No argument → list available factories and hint about cfg files
if len(msg) == 1:
player.tell("^3Available Factories:")
for idx, factory in enumerate(self.factories, start=1):
has_cfg = "^2[cfg]^7" if os.path.isfile(self.cfg_path_for(factory)) else ""
player.tell("^7{}: {} {}".format(idx, factory, has_cfg))
player.tell("^3Use ^7!fv ^3to start a vote.")
player.tell("^3^2[cfg] ^3= factory-specific settings will be applied on vote pass.")
return
try:
selection = int(msg[1])
except ValueError:
player.tell("^1Invalid selection. Use a number from the list.")
return
if selection < 1 or selection > len(self.factories):
player.tell("^1Invalid factory number.")
return
self.selected_factory = self.factories[selection - 1]
player.tell("^3You selected factory:^7 {}".format(self.selected_factory))
# Hint whether settings will be applied
if os.path.isfile(self.cfg_path_for(self.selected_factory)):
player.tell(
"^3A .cfg exists for this factory — settings will be applied if the vote passes."
)
vote_text = "Change map to {} factory?".format(self.selected_factory)
vote_command = "qlx !map {} {}".format(self.game.map, self.selected_factory)
self.callvote(vote_command, vote_text)
minqlx.delay(30)(self.check_vote_result)
# ──────────────────────────────────────────
# VOTE RESULT (called 30 s after callvote)
# ──────────────────────────────────────────
def check_vote_result(self):
if self.game.vote_passed:
self.msg("^2Vote passed! Loading factory:^7 {}".format(self.selected_factory))
# Apply factory-specific settings BEFORE the map change
cfg_applied = self.try_load_factory_cfg(self.selected_factory)
if not cfg_applied:
self.msg(
"^3No .cfg found for ^7{} ^3— using current server settings.".format(
self.selected_factory
)
)
# Trigger the actual map/factory change
self.console("map {} {}".format(self.game.map, self.selected_factory))
else:
self.msg("^1Vote failed for factory:^7 {}".format(self.selected_factory))
# Remind admins that settings can be restored if anything changed mid-vote
if os.path.isfile(self.cfg_path_for("restore")):
self.msg("^3Tip: Use ^7!restorecfg ^3to restore default server settings.")