ql-lg-server: Quake Live LG vampiric practice server in Docker

One-command setup (SteamCMD + minqlx build), .env settings with a
listed/unlisted switch, three factories (lgvamp, lgvamp_nolag, lgvamp100),
warmup-text and welcome minqlx plugins, watchdog/backup/daily-restart,
docs for admins and players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-08 01:39:43 +03:00
commit bc5bd09c15
25 files changed
+1009

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
# ql-lg-server settings. setup.sh copies this to .env (and asks for the important ones).
# After editing .env: docker compose up -d (recreates the container with the new values)
# No double quotes inside values.
# Name shown in the server browser and the scoreboard (^1..^7 = Quake colors)
QL_HOSTNAME=LG Vampiric FFA
# yes = listed in the in-game server browser (forward UDP QL_PORT on your router/firewall)
# no = unlisted, people join by address (LAN, VPN like Tailscale, or a known IP)
QL_LISTED=yes
# Join password (empty = open server)
QL_PASSWORD=
QL_MAXCLIENTS=16
# lgvamp LG only, 1:1 vampiric, no knockback, antilag (like x76 "Antilag LG Duel")
# lgvamp_nolag same without antilag (like x76 "LG Duel EU #3")
# lgvamp100 spawn with flat 100 HP instead of 200 (no health decay above 100)
QL_FACTORY=lgvamp
QL_MAP=thunderstruck
# Shown to every player when they join. Empty = no message.
QL_WELCOME=^7Welcome! LG only, vampiric, no knockback. Pair up and duel, there is no match to ready up for.
# Game port (UDP) and RCON port (TCP, bound to 127.0.0.1 only). Change both to run a second server.
QL_PORT=27960
QL_RCON_PORT=28960
# Docker container name (unique per server on this machine)
QL_NAME=ql-lg
# User/group the server runs as (owner of this folder). setup.sh fills these in.
QL_UID=1000
QL_GID=1000
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+15
View File
@@ -0,0 +1,15 @@
# per-machine settings and secrets
.env
secrets/
# downloaded / built (setup.sh recreates them)
server/
steamcmd-home/
minqlx-build/
# runtime
logs/
backups/
home/baseq3/*
!home/baseq3/server.cfg
!home/baseq3/scripts/
home/minqlx.log*
__pycache__/
+10
View File
@@ -0,0 +1,10 @@
# Quake Live dedicated server runtime + steamcmd. Game files live in ./server (bind mount).
# libpython3.11 + python3-redis: minqlx (built by setup.sh into ./server) embeds Python 3.11.
FROM debian:bookworm-slim
RUN dpkg --add-architecture i386 && apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl lib32gcc-s1 lib32stdc++6 libc6-i386 \
libstdc++6 python3 python3-zmq libpython3.11 python3-redis && \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /opt/steamcmd && curl -sSL https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz | tar xz -C /opt/steamcmd && \
chmod -R a+rwX /opt/steamcmd
WORKDIR /ql/server
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ql-lg-server contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
# ql-lg-server
A **Quake Live lightning gun (LG) vampiric practice server** in Docker, set up with one command.
Players spawn with the LG only. Every hit heals you by the damage you deal (1:1 vampiric), there's no knockback and no items, and the server stays in warmup forever. Nobody has to ready up, there's no countdown, and you never get kicked back to a scoreboard. People join, pair off and duel on thunderstruck. This is how the popular LG duel servers (x76) run, rebuilt from scratch and documented so you can host your own.
- One command: `./setup.sh` downloads the game (SteamCMD), builds minqlx, generates an RCON password and starts the server.
- Public (listed in the in-game browser) or private (join by address, LAN/VPN): one setting.
- Three variants: antilag, no antilag, flat 100 HP.
- No warmup text on screen (small minqlx plugin), plus a welcome message.
- Recovers on its own: Docker restarts it after a crash, a watchdog restarts it if it hangs, and it restarts daily while nobody is playing. It also takes nightly backups.
- The RCON password never shows up in logs (Quake Live normally prints it there, see [How it works](docs/HOW-IT-WORKS.md)).
## Quick start
You need a Linux machine (a small VPS is fine: the server idles at ~3% of one core and ~50 MB RAM), [Docker with the compose plugin](https://docs.docker.com/engine/install/), `python3`, git, and ~2 GB of disk.
```sh
git clone <this repo> ql-lg-server
cd ql-lg-server
./setup.sh # asks: server name, listed or not, join password, port
./scripts/install-cron.sh # optional: watchdog, nightly backup, daily restart
```
Run it as a normal user in the `docker` group, not as root. Setup prints the `/connect` addresses when it's done. Players need Quake Live on Steam.
**Public server:** forward **UDP 27960** (your `QL_PORT`) on your router or firewall to this machine. Listed servers appear in the in-game browser after a few minutes.
## Settings
Everything per-server is in `.env` (created from [`.env.example`](.env.example) by setup). After editing it, apply the change with `docker compose up -d`.
| Setting | Default | |
|---|---|---|
| `QL_HOSTNAME` | `LG Vampiric FFA` | name in the browser (`^1`..`^7` colors work) |
| `QL_LISTED` | `yes` | `no` = unlisted, join by address only |
| `QL_PASSWORD` | empty | join password |
| `QL_MAXCLIENTS` | `16` | |
| `QL_FACTORY` | `lgvamp` | `lgvamp`, `lgvamp_nolag` or `lgvamp100`, see below |
| `QL_MAP` | `thunderstruck` | stock map; workshop maps need extra setup |
| `QL_WELCOME` | short rules text | sent to each player on join; empty = off |
| `QL_PORT` / `QL_RCON_PORT` | `27960` / `28960` | game UDP / RCON TCP (127.0.0.1 only) |
| `QL_NAME` | `ql-lg` | Docker container name |
### Game modes (factories)
Defined in [`home/baseq3/scripts/lgvamp.factories`](home/baseq3/scripts/lgvamp.factories):
| Factory | What | Seen on real servers |
|---|---|---|
| `lgvamp` | LG only, 1:1 vampiric, no knockback, antilag up to 240 ms, spawn 200 HP | x76 "Antilag LG Duel" |
| `lgvamp_nolag` | same, antilag off (you have to lead your shots by your ping) | x76 "LG Duel EU #3" |
| `lgvamp100` | same as `lgvamp` but spawn with 100 HP | no, our variant: health above 100 decays ~1 HP/s, so with 200 HP spawns you slowly lose health even when you aren't taking damage |
Shared values: `g_startingWeapons 33` (gauntlet + LG), infinite ammo, `g_knockback 0`, no items, no `/kill`, no votes, instant weapon switch, `dmflags 28` (no self/fall damage), permanent warmup (`sv_warmupReadyPercentage 2`). Every value and why: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
## Running it
```sh
./rcon status # who's on (./rcon alone = interactive console)
./rcon addbot hunter 5 # a target to shoot at; ./rcon kick allbots
./rcon say hello
python3 tools/qlq.py 127.0.0.1 27960 --status
docker compose restart # restart; docker compose stop / start
./setup.sh --update # update the game from Steam
```
Admins, bans, mutes, multiple servers, backups: [docs/ADMIN.md](docs/ADMIN.md). Share [docs/PLAYERS.md](docs/PLAYERS.md) with your players (joining, LG client settings).
## Layout
```
setup.sh install / re-run / --update
.env.example settings template (.env is yours, not in git)
docker-compose.yml the container; Dockerfile = Debian + 32-bit libs + Python 3.11 + SteamCMD
rcon server console
home/baseq3/ server.cfg (shared), scripts/lgvamp.factories (game modes);
local.cfg, secrets.cfg, mappool_lg.txt are generated from .env on each start
home/minqlx-plugins/ nowarmuptext.py (hides warmup text), welcome.py
tools/ mkcfg.sh (.env -> cfg), qlrcon.py (RCON client), qlq.py (health check), tests
scripts/ watchdog.sh, daily-restart.sh, backup.sh, install-cron.sh
```
Not in git (setup creates them): `server/` (game files, ~1.1 GB), `minqlx-build/`, `steamcmd-home/`, `secrets/`, `logs/`, `backups/`.
## Credits
- [minqlx](https://github.com/MinoMino/minqlx) (GPL-3.0) by MinoMino: built from source by setup.sh, not included here.
- [x76.gg](https://x76.gg/) for the LG duel server format this copies (FFA in permanent warmup, vampiric, no knockback).
- [SEA LG Duels server](https://github.com/minhlong124/quake-lg-sea-server): the vampiric LG preset this started from.
- Quake Live is © id Software / Bethesda. The dedicated server is downloaded from Steam (app 349090); no game files are in this repo.
License: MIT (this repo's configs and scripts).
+37
View File
@@ -0,0 +1,37 @@
# Quake Live LG vampiric server. Settings: .env (see .env.example). Setup: ./setup.sh
# Game: UDP ${QL_PORT} on all interfaces. RCON: ZMQ TCP 127.0.0.1:${QL_RCON_PORT} only.
# Started through minqlx (run_server_x64_minqlx.sh) for the plugins in home/minqlx-plugins.
services:
ql:
build: .
image: ql-lg-server:local
container_name: ${QL_NAME:-ql-lg}
user: "${QL_UID:-1000}:${QL_GID:-1000}"
network_mode: host
restart: unless-stopped
init: true # tini as PID 1: if the server dies the container exits and Docker restarts it
ulimits:
core: 0
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
stdin_open: true
tty: true
env_file: .env
environment:
- HOME=/home/steam
- TINI_KILL_PROCESS_GROUP=1 # stop/restart signals reach qzeroded through the sh pipe below
volumes:
- ./server:/ql/server
- ./home:/ql/home
- ./secrets:/ql/secrets:ro
- ./tools:/ql/tools:ro
- ./steamcmd-home:/home/steam
# 1. mkcfg.sh turns .env into home/baseq3/local.cfg (+ secrets.cfg, mappool_lg.txt).
# 2. The minqlx start script turns zmq stats on (TCP, all interfaces): +set zmq_stats_enable 0 turns it off.
# 3. qzeroded's CZMQ auth logger prints the RCON password in plain text on every rcon login
# ("zauth: ... password=..."): grep drops those lines before they reach docker logs.
# If qzeroded dies, grep sees EOF, the container exits and Docker restarts it.
command: ["/bin/sh", "-c", "/ql/tools/mkcfg.sh && /ql/server/run_server_x64_minqlx.sh +set zmq_stats_enable 0 +set fs_homepath /ql/home +set net_port $$QL_PORT +exec server.cfg 2>&1 | grep --line-buffered -v 'zauth:'"]
+68
View File
@@ -0,0 +1,68 @@
# Running the server
Everything below is run from the repo folder on the server machine.
## Console (RCON)
`./rcon <command>` runs one server command; `./rcon` alone opens a `ql>` prompt (Ctrl+D quits). It runs inside the container and reads `secrets/rcon-password.txt` itself, so you never type the password. RCON listens on 127.0.0.1 only, so nobody can reach it from outside the machine.
| Command | |
|---|---|
| `status` | players with their slot number (`num`), ping, address (`bot` = bot) |
| `say <text>` | message to everyone |
| `addbot hunter 5` | add a bot (names: hunter, anarki, hossman, …; skill 1–5) |
| `kick allbots` | remove all bots (prints a harmless "Player allbots is not on the server") |
| `clientkick <num>` | kick a player |
| `mute <num>` / `unmute <num>` | chat mute |
| `tempban <num>` | kick + ban until the map changes |
| `ban <num>` / `unban <num>` | permanent ban (stored in `access.txt`) |
| `addadmin <num>` / `addmod <num>` | give a connected player admin / mod rights |
| `reload_access` | re-read `access.txt` |
| `map thunderstruck lgvamp` | reload the map (or switch factory: `lgvamp_nolag`, `lgvamp100`) |
**Careful:** `cvarlist` prints the RCON password into the server log. `./rcon` hides it from you, but avoid running it anyway.
## Admins and bans: `home/baseq3/access.txt`
One line per player: their 64-bit Steam ID, a `|`, and `admin`, `mod` or `ban`:
```
76561198000000000|admin
76561198000000001|ban
```
Quake Live writes this file back from memory on every map load and on shutdown, so a hand edit can be overwritten. Either use the console commands above (`addadmin`, `ban`, `unban`), or edit the file and run `./rcon reload_access` right away. To unban someone who isn't connected, delete their line and run `reload_access`.
## Settings changes
- `.env` (name, password, listed, factory, welcome text, ports): edit it, then run `docker compose up -d`.
- Gameplay: edit `home/baseq3/scripts/lgvamp.factories` (must stay valid JSON: `python3 -m json.tool home/baseq3/scripts/lgvamp.factories`), then run `docker compose restart`.
- A cvar changed over RCON (e.g. `./rcon timelimit 1`) lasts until the next restart, map changes included.
## Self-healing (`./scripts/install-cron.sh`)
| What | When | Log |
|---|---|---|
| crash restart | Docker (`restart: unless-stopped`) | `docker inspect -f '{{.RestartCount}}' ql-lg` |
| hang watchdog: 3 failed checks in a row → restart | every 5 min | `logs/watchdog.log` |
| backup of `.env`, secrets, access.txt, cfgs, plugins (14 days) | 04:40 | `backups/` |
| daily restart, skipped while a human is on | 05:00 | `logs/watchdog.log` |
`./scripts/install-cron.sh --remove` removes those cron lines. A `docker compose stop` is respected: the watchdog leaves a stopped server alone.
## Updates
- Game: `./setup.sh --update`, then `docker compose restart`. If a game update ever removes `server/run_server_x64_minqlx.sh`, plain `./setup.sh` copies it back.
- This repo: `git pull`, then `./setup.sh` (re-running it is safe: it keeps your `.env`, password and game files).
## More than one server
Clone the repo into a second folder. Before running `./setup.sh`, create `.env` there from `.env.example` with a different `QL_PORT`, `QL_RCON_PORT` and `QL_NAME` (e.g. `27961`, `28961`, `ql-lg-2`). Each folder downloads its own game files (~1.1 GB). Each server costs about 3% of a CPU core while idle.
## Logs
`docker logs --tail 50 ql-lg` (your `QL_NAME`). minqlx log: `home/minqlx.log`. Lines with the RCON password are filtered out before they reach `docker logs`, but treat logs as private anyway.
## Without minqlx
If minqlx ever breaks after a game update, change `run_server_x64_minqlx.sh` to `run_server_x64.sh` in `docker-compose.yml` and run `docker compose up -d`. Everything still works except that the warmup text ("The match will begin when more players join") comes back and there's no welcome message.
+50
View File
@@ -0,0 +1,50 @@
# How it works (and why)
Notes for anyone changing this setup. Each point below was hit while building it.
## Permanent warmup instead of a match
Real LG duel servers (x76) report `PRE_GAME` (warmup) even with 9 players on: everyone plays FFA in warmup, which never ends. That's exactly what LG practice needs: no countdown, no score resets, no map reloads.
- `sv_warmupReadyPercentage 2`: a match would need 200% of players ready, so F3 / `/readyup` can never start one. This cvar is locked, so it can only be set in the factory, not in server.cfg.
- `g_warmupReadyDelay 0`: no auto-start timer.
Approaches that don't work:
- `g_doWarmup 0` (always in a match): QL **forfeits a running match when fewer than 2 players are on, even with `g_allowForfeit 0`**. With one player you get reload → countdown → a few seconds of play → forfeit, every 15–20 s. A permanent bot (`bot_minplayers 2`) hides that, but players find it weird.
- Auto-start (`sv_warmupReadyPercentage 0` + a delay): a countdown and score reset every time a second player joins.
`fraglimit 50`, `timelimit 15`, `g_overtime 0` are x76's values; they only matter if a match ever starts.
## The warmup text (minqlx plugin `nowarmuptext`)
In warmup the client draws "The match will begin when more players join" / "Press F3 to ready yourself". No cvar hides it. The client draws it only while configstring `CS_WARMUP` (index 5) holds a time (warmup sets `\time\-1`). The plugin hooks `set_configstring` and sends an empty value for index 5 instead. The server itself stays in warmup. The first map is set up before plugins load, so the plugin also clears the value once when it loads. Check: `docker logs ql-lg | grep nowarmuptext` shows `CS_WARMUP=''`.
minqlx is started with only our plugins (`qlx_plugins "nowarmuptext,welcome"`). Its default plugin list needs a Redis server, which this setup doesn't run. QL's built-in `access.txt` covers admins and bans without it.
## Vampiric, measured
`g_vampiricDamage 1` heals exactly 1:1. Measured with `g_debugVampiricDamage 1` and two bots: an LG hit does 6 damage and the attacker gains 6. When both players hit each other at the same time, neither health changes. Spawning with 200 HP (`g_startingHealthBonus 100`) means health above 100 decays ~1/s, which is where the "I'm losing health" feeling comes from. `lgvamp100` removes it. (The stock `_training` factory uses 0.75.)
## Antilag
`g_lagHax 1`, `g_lagHaxMs 240`, `g_lagHaxMsec 10`, `g_lagHaxHistory 12`: hits are checked against where the target was on the shooter's screen, for pings up to 240 ms. `lgvamp_nolag` sets `g_lagHax 0`.
## The RCON password leak
qzeroded's built-in ZMQ auth logger (CZMQ zauth) prints `zauth: ... password=<your rcon password>` in plain text on every RCON login. The compose command pipes the server's output through `grep -v zauth:` so those lines never reach `docker logs`. `TINI_KILL_PROCESS_GROUP=1` makes stop and restart signals reach the server through that pipe. If the server dies, grep sees end-of-file, the container exits and Docker restarts it.
Two more places the password shows up: `home/baseq3/zmqpass.txt` (QL writes it on every start) and the output of `cvarlist`. `tools/qlrcon.py` filters it out of anything it prints.
## Unlisted servers answer no queries
With `sv_master 0`, QL ignores `getstatus`, `getinfo` and Steam A2S queries (`sv_serverType` doesn't change that). So the health check (`tools/qlq.py`) uses the connect handshake: send `getchallenge` and the reply is `print "No Steam auth token."`. That works for listed and unlisted servers. Player info comes from RCON `status`, where bots have the address `bot`; that's how the daily restart can tell whether a human is on.
## Small things
- SteamCMD needs `+@sSteamCmdForcePlatformType linux`, or it fails with "Missing configuration".
- minqlx must be built against the same Python as the image (Debian bookworm, 3.11). setup.sh builds it in a bookworm container, pinned to a tested commit.
- The minqlx start script turns on `zmq_stats_enable` (TCP, all interfaces). The compose command turns it off again.
- A tied FFA never ends on the timelimit (it goes to overtime); irrelevant in permanent warmup.
- Cvars changed over RCON survive map changes until the container restarts.
- `home/baseq3/access.txt` is rewritten by QL on map load/shutdown (see ADMIN.md).
- Polling RCON from several clients at once while the server restarts once coincided with a crash. Poll from one client, slowly.
+41
View File
@@ -0,0 +1,41 @@
# Playing on an LG vampiric server
## Joining
You need Quake Live (Steam). Open the console with the `~` key and type:
```
/connect <server address>:27960
```
Your server admin gives you the address. If the server is listed, it also shows up in the in-game server browser; search for "lg" or "vampiric".
## How it works
- You spawn with the **lightning gun** (and gauntlet), infinite ammo and 200 HP (100 HP on `lgvamp100` servers).
- **Vampiric:** every point of damage you deal heals you by the same amount. Health above 100 slowly decays (~1 HP per second).
- **No knockback, no items, no fall or self damage.**
- The server **stays in warmup on purpose.** There's no match to ready up for (F3 does nothing) and no scoreboard reset. Pair up with someone and duel, and switch partners whenever you like.
- **Antilag** (most servers): your hits register where you saw the enemy, up to about 240 ms ping. On `lgvamp_nolag` servers you have to lead your shots by your ping.
## Client settings people use for LG
These are client-side; type them in the console, or put them in `baseq3/autoexec.cfg` so they stick (Quake Live rewrites `qzconfig.cfg`). Ranges are from the [x76 config guide](https://x76.gg/wiki/ql-startup/); the values are preferences, not rules.
| Cvar | What it does |
|---|---|
| `cg_lightningStyle 1`–`5` | beam look (Settings → Weapons → Lightning Style) |
| `cg_lightningImpact 0` / `1` | sparks where the beam hits |
| `cg_impactSparks 0` / `1` | hit sparks on players |
| `cg_drawGun 0`–`2`, `cg_gunX/Y/Z` | gun model visibility/position (less of the screen covered) |
| `cg_fov` | field of view |
| `cg_drawInputCmds 1` | shows your key presses, useful for checking your strafing |
| `cg_enemyHeadColor` etc. | bright enemy colors, e.g. `0xFF0000FF` |
| `r_fullbright 1` | flat bright walls (easier to track the enemy) |
**No-beam LG:** some players use a "No LG Beam" workshop add-on with `cg_lightningStyle 3`. That Steam workshop item has been removed, and servers here run `sv_pure 1`, so don't count on it working.
## Practice tips
- The [x76 strafe-aim guide](https://x76.gg/wiki/strafe-aim-guide/) covers the technique LG duels are about: keep the mouse steady, track by strafing with the keyboard and correct small amounts with the mouse.
- Practice alone: ask an admin for a bot (`addbot hunter 5`). Bots jump around a lot, which makes them a fair tracking target, but they don't fight well in this mode.
+137
View File
@@ -0,0 +1,137 @@
[
{
"id": "lgvamp",
"title": "LG Vampiric FFA",
"author": "ql-lg-server",
"description": "LG only, 1:1 vampiric, no knockback, no items, antilag. Permanent warmup like the x76 LG duel servers: pair up and duel, ready-up can't start a match.",
"basegt": "ffa",
"tags": [
"lg",
"aimtraining"
],
"cvars": {
"g_startingWeapons": "33",
"g_infiniteAmmo": "1",
"g_startingHealth": "100",
"g_startingHealthBonus": "100",
"g_startingArmor": "0",
"g_vampiricDamage": "1",
"g_knockback": "0",
"g_weaponrespawn": "0",
"g_spawnItemAmmo": "0",
"g_spawnItemArmor": "0",
"g_spawnItemHealth": "0",
"g_spawnItemHoldable": "0",
"g_spawnItemPowerup": "0",
"g_spawnItemRune": "0",
"g_spawnItemWeapons": "0",
"g_allowKill": "0",
"g_allowVote": "0",
"g_allowVoteMidGame": "0",
"g_allowSpecVote": "0",
"g_voteFlags": "4095",
"pmove_WeaponRaiseTime": "0",
"pmove_WeaponDropTime": "0",
"sv_warmupReadyPercentage": "2",
"g_warmupReadyDelay": "0",
"fraglimit": "50",
"timelimit": "15",
"g_lagHax": "1",
"g_lagHaxMs": "240",
"g_lagHaxMsec": "10",
"g_lagHaxHistory": "12",
"dmflags": "28",
"g_overtime": "0"
}
},
{
"id": "lgvamp_nolag",
"title": "LG Vampiric FFA (no antilag)",
"author": "ql-lg-server",
"description": "LG only, 1:1 vampiric, no knockback, no items, no antilag. Permanent warmup like the x76 LG duel servers: pair up and duel, ready-up can't start a match.",
"basegt": "ffa",
"tags": [
"lg",
"aimtraining"
],
"cvars": {
"g_startingWeapons": "33",
"g_infiniteAmmo": "1",
"g_startingHealth": "100",
"g_startingHealthBonus": "100",
"g_startingArmor": "0",
"g_vampiricDamage": "1",
"g_knockback": "0",
"g_weaponrespawn": "0",
"g_spawnItemAmmo": "0",
"g_spawnItemArmor": "0",
"g_spawnItemHealth": "0",
"g_spawnItemHoldable": "0",
"g_spawnItemPowerup": "0",
"g_spawnItemRune": "0",
"g_spawnItemWeapons": "0",
"g_allowKill": "0",
"g_allowVote": "0",
"g_allowVoteMidGame": "0",
"g_allowSpecVote": "0",
"g_voteFlags": "4095",
"pmove_WeaponRaiseTime": "0",
"pmove_WeaponDropTime": "0",
"sv_warmupReadyPercentage": "2",
"g_warmupReadyDelay": "0",
"fraglimit": "50",
"timelimit": "15",
"g_lagHax": "0",
"g_lagHaxMs": "240",
"g_lagHaxMsec": "10",
"g_lagHaxHistory": "12",
"dmflags": "28",
"g_overtime": "0"
}
},
{
"id": "lgvamp100",
"title": "LG Vampiric FFA (100 HP)",
"author": "ql-lg-server",
"description": "LG only, 1:1 vampiric, no knockback, spawn with 100 HP (no decay), no items, antilag. Permanent warmup like the x76 LG duel servers: pair up and duel, ready-up can't start a match.",
"basegt": "ffa",
"tags": [
"lg",
"aimtraining"
],
"cvars": {
"g_startingWeapons": "33",
"g_infiniteAmmo": "1",
"g_startingHealth": "100",
"g_startingHealthBonus": "0",
"g_startingArmor": "0",
"g_vampiricDamage": "1",
"g_knockback": "0",
"g_weaponrespawn": "0",
"g_spawnItemAmmo": "0",
"g_spawnItemArmor": "0",
"g_spawnItemHealth": "0",
"g_spawnItemHoldable": "0",
"g_spawnItemPowerup": "0",
"g_spawnItemRune": "0",
"g_spawnItemWeapons": "0",
"g_allowKill": "0",
"g_allowVote": "0",
"g_allowVoteMidGame": "0",
"g_allowSpecVote": "0",
"g_voteFlags": "4095",
"pmove_WeaponRaiseTime": "0",
"pmove_WeaponDropTime": "0",
"sv_warmupReadyPercentage": "2",
"g_warmupReadyDelay": "0",
"fraglimit": "50",
"timelimit": "15",
"g_lagHax": "1",
"g_lagHaxMs": "240",
"g_lagHaxMsec": "10",
"g_lagHaxHistory": "12",
"dmflags": "28",
"g_overtime": "0"
}
}
]
+30
View File
@@ -0,0 +1,30 @@
// ql-lg-server. Per-server settings come from .env (local.cfg, generated on start).
// Gameplay lives in scripts/lgvamp.factories. See README.md and docs/HOW-IT-WORKS.md.
set sv_serverType "2" // internet server (also right for VPN players)
set sv_privateClients "0"
set sv_fps "40"
set sv_idleExit "0"
set net_strict "1"
set sv_floodprotect "10"
set g_floodprot_maxcount "10"
set g_floodprot_decay "1000"
set g_accessFile "access.txt" // admins, mods, bans (docs/ADMIN.md)
set sv_mapPoolFile "mappool_lg.txt"
set g_allowVote "0"
// bots: none by default (permanent warmup needs none); rcon "addbot hunter 5", remove with "kick allbots"
set bot_enable "1"
set bot_minplayers "0"
set bot_nochat "1"
// RCON over ZMQ, localhost only (port from .env, password from secrets/)
set zmq_rcon_enable "1"
set zmq_rcon_ip "127.0.0.1"
set zmq_stats_enable "0"
// minqlx: only our plugins (the default minqlx plugin list needs a redis server)
set qlx_plugins "nowarmuptext,welcome"
set qlx_pluginsPath "/ql/home/minqlx-plugins"
exec secrets.cfg
exec local.cfg // hostname, password, listing, max players, then: map <map> <factory>
+32
View File
@@ -0,0 +1,32 @@
# hide the client's warmup text ("The match will begin when more players join",
# "Press F3 to ready yourself") while the server stays in permanent warmup (PRE_GAME).
# The client draws that text only while configstring CS_WARMUP (5) carries a nonzero
# "time" (warmup sets \time\-1), so we send clients an empty CS_WARMUP instead.
import minqlx
CS_WARMUP = 5
class nowarmuptext(minqlx.Plugin):
def __init__(self):
super().__init__()
self.add_hook("set_configstring", self.handle_set_configstring, priority=minqlx.PRI_HIGHEST)
self.add_hook("map", self.handle_map)
# The first map was set up before this plugin loaded: clear the value it stored.
minqlx.set_configstring(CS_WARMUP, "")
self.log_warmup("loaded")
def handle_set_configstring(self, index, value):
if index == CS_WARMUP and value:
return ""
def handle_map(self, mapname, factory):
self.log_warmup_later()
@minqlx.delay(2)
def log_warmup_later(self):
self.log_warmup("map loaded")
def log_warmup(self, when):
self.logger.info("nowarmuptext %s: CS_WARMUP=%r g_gameState=%s", when,
minqlx.get_configstring(CS_WARMUP), minqlx.get_cvar("g_gameState"))
+23
View File
@@ -0,0 +1,23 @@
# Welcome message: tells each player the text of qlx_welcome (QL_WELCOME in .env) a few seconds
# after they finish loading the map. Empty = nothing is sent.
import minqlx
class welcome(minqlx.Plugin):
def __init__(self):
super().__init__()
self.set_cvar_once("qlx_welcome", "")
self.add_hook("player_loaded", self.handle_player_loaded)
def handle_player_loaded(self, player):
self.greet(player)
@minqlx.delay(3)
def greet(self, player):
text = self.get_cvar("qlx_welcome")
if text:
try:
player.tell(text)
player.center_print(text)
except minqlx.NonexistentPlayerError:
pass # left before the message went out
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Server console from the terminal: ./rcon status, ./rcon addbot hunter 5, or ./rcon for a prompt.
# Runs the client inside the container, so the RCON password never leaves this machine.
D=$(cd "$(dirname "$0")" && pwd)
NAME=$(sed -n 's/^QL_NAME=//p' "$D/.env" 2>/dev/null | tr -d '\r')
if [ -t 0 ]; then T=-it; else T=-i; fi
exec docker exec $T "${NAME:-ql-lg}" python3 /ql/tools/qlrcon.py "$@"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Nightly backup of settings, secrets, admins/bans and plugins (not the game files: setup.sh
# re-downloads them). backups/ql-YYYY-MM-DD.tar.gz, 14 days kept. Also empties QL's log files
# once they pass 50 MB (QL appends to them forever).
. "$(dirname "$0")/env.sh"
set -e
umask 077
mkdir -p backups
chmod 700 backups
tar czf backups/ql-$(date +%F).tar.gz .env secrets home/baseq3/access.txt \
home/baseq3/server.cfg home/baseq3/scripts home/minqlx-plugins 2>/dev/null || true
find backups -name "ql-*.tar.gz" -mtime +14 -delete
for f in home/baseq3/*.log home/minqlx.log; do
if [ -f "$f" ] && [ "$(stat -c %s "$f")" -gt 52428800 ]; then : > "$f"; fi
done
exit 0
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Daily clean restart (cron). Skipped while a human is playing (bots have address 'bot'), and also
# skipped if the player count can't be read: better a missed restart than kicking someone.
. "$(dirname "$0")/env.sh"
LOG=logs/watchdog.log
[ "$(docker inspect -f '{{.State.Status}}' "$NAME" 2>/dev/null)" = running ] || exit 0
H=$(python3 tools/qlq.py 127.0.0.1 "$QPORT" --humans 2>/dev/null)
case "$H" in
''|*[!0-9]*) echo "$(date '+%F %T') daily restart skipped: player count unknown" >> $LOG; exit 0 ;;
esac
if [ "$H" -gt 0 ]; then
echo "$(date '+%F %T') daily restart skipped: $H player(s) on" >> $LOG
exit 0
fi
echo "$(date '+%F %T') daily restart" >> $LOG
[ -n "$DRYRUN" ] || docker restart "$NAME" >/dev/null 2>&1
Executable
+5
View File
@@ -0,0 +1,5 @@
# Sourced by the scripts in this folder: cd to the repo and read QL_NAME / QL_PORT from .env.
cd "$(dirname "$0")/.." || exit 1
NAME=$(sed -n 's/^QL_NAME=//p' .env 2>/dev/null | tr -d '\r'); NAME=${NAME:-ql-lg}
QPORT=$(sed -n 's/^QL_PORT=//p' .env 2>/dev/null | tr -d '\r'); QPORT=${QPORT:-27960}
mkdir -p logs
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Adds the watchdog (every 5 min), backup (04:40) and daily restart (05:00) to your crontab.
# Safe to run again: replaces its own lines (tagged with this folder's path), leaves others alone.
# ./scripts/install-cron.sh --remove takes them out again.
D=$(cd "$(dirname "$0")/.." && pwd)
TAG="# ql-lg-server $D"
OLD=$(crontab -l 2>/dev/null | grep -vF "$TAG")
if [ "$1" = "--remove" ]; then
printf '%s\n' "$OLD" | crontab - && echo "removed cron lines for $D"; exit 0
fi
{
[ -n "$OLD" ] && printf '%s\n' "$OLD"
echo "*/5 * * * * $D/scripts/watchdog.sh $TAG"
echo "40 4 * * * $D/scripts/backup.sh $TAG"
echo "0 5 * * * $D/scripts/daily-restart.sh $TAG"
} | crontab -
echo "cron installed for $D:"; crontab -l | grep -F "$TAG"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Hang watchdog (cron every 5 min). Docker already restarts the server when it crashes; this covers
# the other case: container "running" but the server not answering. Restarts it after 3 failed
# checks in a row (~15 min). Does nothing if the container was stopped on purpose.
# DRYRUN=1 logs without restarting; PORT=... overrides the port (testing).
. "$(dirname "$0")/env.sh"
STATE=logs/.watchdog-fails
LOG=logs/watchdog.log
PORT=${PORT:-$QPORT}
[ "$(docker inspect -f '{{.State.Status}}' "$NAME" 2>/dev/null)" = running ] || { rm -f $STATE; exit 0; }
if python3 tools/qlq.py 127.0.0.1 "$PORT" >/dev/null 2>&1; then
rm -f $STATE
else
n=$(( $(cat $STATE 2>/dev/null || echo 0) + 1 ))
echo $n > $STATE
echo "$(date '+%F %T') no answer ($n/3)" >> $LOG
if [ $n -ge 3 ]; then
echo "$(date '+%F %T') restarting $NAME" >> $LOG
[ -n "$DRYRUN" ] || docker restart "$NAME" >/dev/null 2>&1
rm -f $STATE
fi
fi
Executable
+122
View File
@@ -0,0 +1,122 @@
#!/bin/sh
# ql-lg-server setup: run from the repo folder on a Linux machine with Docker.
# ./setup.sh first install (asks a few questions), or re-run safely any time
# ./setup.sh --yes no questions: use .env as it is (or the defaults)
# ./setup.sh --update also update the Quake Live game files from Steam
# Steps: check tools -> .env -> RCON password -> build image -> download QL (SteamCMD, ~1 GB)
# -> build minqlx -> start -> check the server answers.
set -e
cd "$(dirname "$0")"
YES=; UPDATE=
for a in "$@"; do
case "$a" in
--yes|-y) YES=1 ;;
--update) UPDATE=1 ;;
*) echo "unknown option: $a"; exit 1 ;;
esac
done
MINQLX_REPO=https://github.com/MinoMino/minqlx.git
MINQLX_COMMIT=fbdd915 # v0.5.3-15, the version this repo was tested with
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
setenv() { # setenv KEY VALUE: replace the KEY= line in .env
v=$(printf '%s' "$2" | sed 's/[\\&|]/\\&/g')
sed -i "s|^$1=.*|$1=$v|" .env
}
getenv() { sed -n "s/^$1=//p" .env | tr -d '\r'; }
ask() { # ask "question" default -> answer (default when --yes or no terminal)
if [ -n "$YES" ] || [ ! -t 0 ]; then printf '%s' "$2"; return; fi
printf '%s [%s]: ' "$1" "$2" >&2; read -r r; printf '%s' "${r:-$2}"
}
say "Checking tools"
[ "$(id -u)" != 0 ] || die "don't run this as root: use a normal user in the 'docker' group"
command -v docker >/dev/null || die "docker is not installed (https://docs.docker.com/engine/install/)"
docker info >/dev/null 2>&1 || die "can't talk to docker: add yourself to the docker group (sudo usermod -aG docker \$USER, then log in again)"
docker compose version >/dev/null 2>&1 || die "'docker compose' (v2 plugin) is missing"
command -v python3 >/dev/null || die "python3 is missing (used by the health checks)"
echo "ok"
say "Settings (.env)"
if [ ! -f .env ]; then
cp .env.example .env
setenv QL_HOSTNAME "$(ask 'Server name' "$(getenv QL_HOSTNAME)")"
l=$(ask 'List it in the in-game server browser? (yes/no)' yes)
case "$l" in y|Y|yes|YES) setenv QL_LISTED yes ;; *) setenv QL_LISTED no ;; esac
setenv QL_PASSWORD "$(ask 'Join password (empty = open server)' '')"
setenv QL_PORT "$(ask 'Game port (UDP)' "$(getenv QL_PORT)")"
echo "created .env (edit it any time, then: docker compose up -d)"
else
echo "using existing .env"
fi
setenv QL_UID "$(id -u)"
setenv QL_GID "$(id -g)"
PORT=$(getenv QL_PORT); RPORT=$(getenv QL_RCON_PORT); NAME=$(getenv QL_NAME)
[ "$PORT" != "$RPORT" ] || die "QL_PORT and QL_RCON_PORT must differ"
RUNNING=$(docker inspect -f '{{.State.Status}}' "$NAME" 2>/dev/null || true)
if [ "$RUNNING" != running ] && command -v ss >/dev/null; then
! ss -lun | grep -q ":$PORT " || die "UDP port $PORT is already in use: set another QL_PORT in .env"
! ss -ltn | grep -q ":$RPORT " || die "TCP port $RPORT is already in use: set another QL_RCON_PORT in .env"
fi
echo "server '$(getenv QL_HOSTNAME)', container $NAME, UDP $PORT, listed: $(getenv QL_LISTED)"
say "RCON password"
mkdir -p secrets server steamcmd-home logs minqlx-build
chmod 700 secrets
if [ ! -s secrets/rcon-password.txt ]; then
(umask 077; head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24 > secrets/rcon-password.txt)
echo "generated secrets/rcon-password.txt (you never need to type it: ./rcon uses it)"
else
echo "keeping secrets/rcon-password.txt"
fi
chmod +x rcon tools/*.sh scripts/*.sh
say "Building the Docker image"
docker compose build -q
if [ ! -x server/qzeroded.x64 ] || [ -n "$UPDATE" ]; then
say "Downloading Quake Live dedicated server (SteamCMD app 349090, ~1 GB)"
docker compose run --rm --no-deps --entrypoint /opt/steamcmd/steamcmd.sh ql \
+@sSteamCmdForcePlatformType linux +force_install_dir /ql/server \
+login anonymous +app_update 349090 validate +quit
[ -x server/qzeroded.x64 ] || die "download failed (server/qzeroded.x64 missing): run ./setup.sh again"
else
echo "game files present (./setup.sh --update to update them)"
fi
if [ ! -f minqlx-build/minqlx.x64.so ]; then
say "Building minqlx ($MINQLX_COMMIT)"
docker run --rm -v "$PWD/minqlx-build:/out" debian:bookworm-slim sh -c "
apt-get update -qq && apt-get install -y -qq build-essential python3-dev git ca-certificates >/dev/null &&
git clone -q $MINQLX_REPO /src && cd /src && git checkout -q $MINQLX_COMMIT && make >/dev/null &&
cp bin/minqlx.x64.so bin/minqlx.zip bin/run_server_x64_minqlx.sh /out/ && chown -R $(id -u):$(id -g) /out"
[ -f minqlx-build/minqlx.x64.so ] || die "minqlx build failed"
fi
cp minqlx-build/minqlx.x64.so minqlx-build/minqlx.zip minqlx-build/run_server_x64_minqlx.sh server/
chmod +x server/run_server_x64_minqlx.sh
echo "minqlx installed into server/"
say "Starting"
docker compose up -d
printf 'waiting for the server to answer'
i=0
until python3 tools/qlq.py 127.0.0.1 "$PORT" >/dev/null 2>&1; do
i=$((i+1)); [ $i -lt 30 ] || { echo; die "no answer after 90 s: look at 'docker logs --tail 50 $NAME'"; }
printf '.'; sleep 3
done
echo " up"
python3 tools/qlq.py 127.0.0.1 "$PORT" --status || true
say "Done"
IPS=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -v ':' | grep -v '^172\.' | head -3 | tr '\n' ' ')
echo "Join from Quake Live (console, ~ key):"
for ip in $IPS; do echo " /connect $ip:$PORT"; done
if [ "$(getenv QL_LISTED)" = yes ]; then
echo "From the internet: forward UDP $PORT to this machine, then /connect <your public IP>:$PORT"
echo "(listed servers show up in the in-game browser after a few minutes)"
fi
echo
echo "Console: ./rcon status ./rcon addbot hunter 5 ./rcon kick allbots"
echo "Auto-restart: ./scripts/install-cron.sh (watchdog, nightly backup, daily restart)"
echo "Docs: README.md, docs/ADMIN.md, docs/PLAYERS.md"
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Runs inside the container before the server starts: turns the .env settings (passed in as
# environment variables) into home/baseq3/local.cfg, secrets.cfg and mappool_lg.txt.
# server.cfg execs secrets.cfg and local.cfg; local.cfg ends with the map command.
set -e
B=/ql/home/baseq3
clean() { printf '%s' "$1" | tr -d '"\r'; }
PW=$(tr -d '\r\n' < /ql/secrets/rcon-password.txt)
[ -n "$PW" ] || { echo "mkcfg: secrets/rcon-password.txt is empty (run setup.sh)" >&2; exit 1; }
case "$QL_LISTED" in
yes|1|true) MASTER=1 ;;
*) MASTER=0 ;;
esac
FACTORY=$(clean "${QL_FACTORY:-lgvamp}")
MAP=$(clean "${QL_MAP:-thunderstruck}")
case "$FACTORY" in lgvamp_nolag) TAGS="lg,vampiric,ffa" ;; *) TAGS="lg,vampiric,ffa,antilag" ;; esac
umask 077
printf 'set zmq_rcon_password "%s"\n' "$(clean "$PW")" > $B/secrets.cfg
umask 022
cat > $B/local.cfg <<EOF
// Generated from .env by tools/mkcfg.sh on every start. Edit .env, not this file.
set sv_hostname "$(clean "${QL_HOSTNAME:-LG Vampiric FFA}")"
set sv_tags "$TAGS"
set sv_master "$MASTER"
set g_password "$(clean "$QL_PASSWORD")"
set sv_maxClients "$(clean "${QL_MAXCLIENTS:-16}")"
set zmq_rcon_port "$(clean "${QL_RCON_PORT:-28960}")"
set qlx_welcome "$(clean "$QL_WELCOME")"
map $MAP $FACTORY
EOF
echo "$MAP|$FACTORY" > $B/mappool_lg.txt
echo "mkcfg: $MAP $FACTORY, listed=$MASTER, port ${QL_PORT}"
Executable
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Quake Live server checks. With sv_master 0 (not browser-listed) QL ignores getstatus/getinfo/A2S,
so liveness uses the connect handshake ('getchallenge' -> 'No Steam auth token.') and player info
comes from RCON 'status' (only on the server machine, through ../rcon).
python tools/qlq.py <host> <port> alive check from anywhere (exit 0 = answered, 1 = no answer)
python3 tools/qlq.py <host> <port> --humans (server) number of human players (bots have address 'bot')
python3 tools/qlq.py <host> <port> --status (server) alive + map, factory, players
"""
import os, re, socket, subprocess, sys
H = b"\xff\xff\xff\xff"
RCON = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "rcon")
# num score ping name lastmsg address qport rate steamid (ping may be CNCT/ZMBI while connecting)
ROW = re.compile(r"^\s*\d+\s+(-?\d+)\s+(\d+|CNCT|ZMBI)\s+(.*?)\s+\d+\s+(\S+)\s+\d+\s+\d+\s+\d+\s*$")
def is_alive_reply(data):
return data.startswith(H) and len(data) > 4
def alive(host, port, timeout=3, tries=3):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(timeout)
for _ in range(tries):
try:
s.sendto(H + b"getchallenge\n", (host, port))
if is_alive_reply(s.recv(65535)):
return True
except OSError:
continue
return False
def parse_rcon_status(text):
"""-> (mapname, [(score, ping, name, address)]) or None if this is not a status reply."""
m = re.search(r"^map: (\S+)", text, re.M)
if not m:
return None
players = []
for l in text.splitlines():
r = ROW.match(l)
if r:
players.append((int(r.group(1)), r.group(2), r.group(3), r.group(4)))
return m.group(1), players
def humans(players):
return sum(1 for p in players if p[3] != "bot")
def rcon(cmd):
return subprocess.run([RCON, cmd], capture_output=True, text=True, timeout=20).stdout
def main(argv):
host, port = argv[1], int(argv[2])
if "--humans" in argv:
st = parse_rcon_status(rcon("status"))
if st is None:
print("?"); return 1
print(humans(st[1])); return 0
if not alive(host, port):
print(f"no answer from {host}:{port}"); return 1
if "--status" in argv:
st = parse_rcon_status(rcon("status"))
fac = re.search(r'"g_factory" is:"([^"]*)"', rcon("g_factory"))
if st is None:
print(f"alive {host}:{port} | rcon: no status"); return 0
print(f"alive {host}:{port} | map {st[0]} | factory {fac.group(1) if fac else '?'}"
f" | players {len(st[1])} (humans {humans(st[1])})")
for p in st[1]:
print(f" {p[2]} score {p[0]} ping {p[1]} {'bot' if p[3] == 'bot' else 'human'}")
return 0
print(f"alive {host}:{port}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Quake Live RCON over ZMQ. Runs inside the container (through ./rcon), so the password never
leaves the server machine.
./rcon status one command
./rcon interactive prompt (Ctrl+D or 'exit' to quit)
Protocol (id's zmq_rcon.py): DEALER socket, ZMQ PLAIN auth user 'rcon', zap_domain 'rcon',
first message 'register', then each message is a console command; console output comes back.
"""
import os, re, sys, uuid
import zmq
PW = open("/ql/secrets/rcon-password.txt").read().strip()
HOST = "tcp://127.0.0.1:" + os.environ.get("QL_RCON_PORT", "28960")
COLOR = re.compile(r"\^\d")
def connect():
s = zmq.Context.instance().socket(zmq.DEALER)
s.plain_username = b"rcon"
s.plain_password = PW.encode()
s.zap_domain = b"rcon"
s.setsockopt(zmq.IDENTITY, uuid.uuid4().hex.encode())
s.setsockopt(zmq.LINGER, 0)
s.connect(HOST)
s.send(b"register")
return s
def drain(s, first_wait, quiet=0.6):
out, wait = [], first_wait
while s.poll(int(wait * 1000)):
out.append(s.recv().decode(errors="replace"))
wait = quiet
return "".join(out)
def clean(text):
# never show the password, whatever the server echoes
lines = [COLOR.sub("", l) for l in text.splitlines()
if "zmq_rcon_password" not in l and PW not in l]
return "\n".join(lines).strip()
def run(s, cmd):
drain(s, first_wait=0.3, quiet=0.2) # drop console chatter that arrived in between
s.send(cmd.encode())
out = clean(drain(s, first_wait=3.0))
return out or "(no output: ok, or server down / map loading / wrong password)"
def main(argv):
s = connect()
if len(argv) > 1:
print(run(s, " ".join(argv[1:]))); return 0
print("Quake Live rcon - server commands (status, addbot hunter 5, kick allbots, map thunderstruck lgvamp, say hi). Ctrl+D to quit.")
while True:
try:
line = input("ql> ").strip()
except (EOFError, KeyboardInterrupt):
print(); return 0
if line in ("exit", "quit"):
return 0
if line:
print(run(s, line))
if __name__ == "__main__":
sys.exit(main(sys.argv))
+43
View File
@@ -0,0 +1,43 @@
import unittest
from qlq import is_alive_reply, parse_rcon_status, humans
# QL with sv_master 0 ignores getstatus/getinfo/A2S; it does answer getchallenge (real reply below).
ALIVE = b"\xff\xff\xff\xffprint\nNo Steam auth token.\n"
STATUS = """zmq RCON command from 3163ba8dd0fa4fab8164165fc9194153: status
map: thunderstruck
num score ping name lastmsg address qport rate steamid
--- ----- ---- --------------- ------- --------------------- ----- ----- -----------------
0 12 0 Hunter 0 bot 0 16384 0
1 7 48 player 0 100.64.1.2:27960 354 25000 76561198000000000
"""
class Alive(unittest.TestCase):
def test_challenge_reply_is_alive(self):
self.assertTrue(is_alive_reply(ALIVE))
def test_garbage_is_not_alive(self):
self.assertFalse(is_alive_reply(b"hello"))
self.assertFalse(is_alive_reply(b""))
class RconStatus(unittest.TestCase):
def test_map_and_players(self):
mapname, players = parse_rcon_status(STATUS)
self.assertEqual(mapname, "thunderstruck")
self.assertEqual([p[2] for p in players], ["Hunter", "player"])
def test_bots_are_not_humans(self):
_, players = parse_rcon_status(STATUS)
self.assertEqual(humans(players), 1)
def test_empty_server(self):
mapname, players = parse_rcon_status(STATUS.split(" 0 12")[0])
self.assertEqual(mapname, "thunderstruck")
self.assertEqual(players, [])
self.assertEqual(humans(players), 0)
def test_no_rcon_output_is_none(self):
self.assertIsNone(parse_rcon_status("(no output: ok, or server down / map loading / wrong password)"))
if __name__ == "__main__":
unittest.main()