Files

82 lines
9.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# How it works (and why)
Notes for anyone changing this setup: what each part does and which alternatives were tried.
## 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=''`.
## The training bot (`lgbot`): aim from the character file, movement from a plugin
Quake 3 bots take their personality from a character file. `home/baseq3/botfiles/bots/lgbot_c.c` (registered by `scripts/lgbot.bot`) sets aim accuracy, aim skill, reaction time and view speed per skill level, sets jumping, crouching and rocket jumping to 0, and weights the LG above everything else. QL reads skill blocks 1, 4 and 5 and interpolates 2 and 3 between 1 and 4, which is why the file has only three. The server loads these loose files from `fs_homepath` alongside the ones in `pak00.pk3`, so no pk3 is needed (bots run on the server only, so clients don't need the files).
The stock bot AI moves like a bot: it runs at you, hops around and goes for items. So the movement is overridden. The `lgbot` minqlx plugin sets the bot's horizontal velocity on every server frame: sideways to its target in the current strafe direction, plus an in/out part that holds the wanted distance (capped at 70% of the speed so it still strafes while closing in). The strafe direction comes from a small pattern generator: hold one side for a random time between the min and max, then switch. Sometimes it fakes instead: a 60–120 ms tap the other way, then a new hold on the same side. Strafing sideways to someone moves you around them, and fakes keep you going the same way, so the bot would slowly circle you. The pattern therefore tracks its net sideways drift and turns back once it is `lane` units off centre. Ground friction removes 15–25% of the set speed before the move happens, so on the ground it sets 1.2× (measured at sv_fps 40: the bot then moves at about the set speed. On ramps no boost is applied, so it's ~15% slower there). The velocity also changes at most 3200 u/s per second (QL's ground acceleration of 10 × 320). So a left-right reversal takes ~0.2 s like a player's, and a fake is a short wobble, not an instant flip. Its top speed and its turning are both what a player can do. An upward speed of 200–320 (a jump; QL jumps at ~275) is cancelled. Falling, slopes and jump pads are left alone. The AI still aims and shoots, so aim and movement don't interfere. The steering is straight-line, with no pathfinding. So it only starts when the target is within 900 units horizontally and 200 vertically, and keeps the same target out to 1000 / 250 so it doesn't flip modes at the edge. It switches targets only to someone 150 units closer. Farther away, the AI's own navigation walks the bot over. If the speed it makes along the steered direction stays under 60% for half a second (a wall, a ledge), steering pauses for 1.5 s so the AI can get around it, doubling to at most 6 s while that keeps happening. Whenever it stops steering (dead, nobody near, paused), the strafe pattern starts fresh. Otherwise the pause would count as sideways drift and the bot would strafe one way only after every respawn (found in review). Without this, on thunderstruck it pushed against a wall toward a target on another level for 12 s. Measured on a test server, the plugin costs no extra CPU you could measure.
minqlx gives bots Steam IDs from 90071996842377216 up. The plugin uses that to pick human targets only.
minqlx is started with only our plugins (`qlx_plugins "nowarmuptext,welcome,lgbot,healthcap"`). 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 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. (The stock `_training` factory uses 0.75.)
Vampire healing in QL goes up to twice the player's max health, and QL sets max health from `g_startingHealth`. With `g_startingHealth 200` players healed to 400 (seen in game 2026-10-16), and there is no cvar for a vampire cap (the game module's only vampire cvars are `g_vampiricDamage` and `g_debugVampiricDamage`). So the minqlx plugin `healthcap` sets any living player above the cap back to it on every server frame. The cap is `qlx_healthCap` if above 0, otherwise `g_startingHealth`. The 200 HP modes use `g_startingHealth 200` with `g_startingHealthBonus 0`: you spawn with 200 and vampire heals up to 200. Health above 100 decays 1 per second down to 100 (measured on a test server). The alternative, `g_startingHealth 100` plus `g_startingHealthBonus 100`, also spawns you at 200, but players noticed they never healed back above 100. That was this repo's setting until 2026-10 and is what the SEA preset uses; x76 heals above 100.
`lgvamp100` uses 100 and 0: `healthcap` stops vampire at 100, no decay.
## 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.
## Files
```
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
lgbot training bot setup (runs tools/lgbot_setup.py in the container)
home/baseq3/ server.cfg (shared), scripts/lgvamp.factories (game modes),
scripts/lgbot.bot + botfiles/bots/lgbot_*.c (training bot aim);
local.cfg, secrets.cfg, mappool_lg.txt are generated from .env on each start
home/minqlx-plugins/ nowarmuptext.py (hides warmup text), welcome.py, lgbot.py (training bot movement), healthcap.py (vampire cap)
tools/ mkcfg.sh (.env -> cfg), qlrcon.py (RCON client), qlq.py (health check),
lgbot_setup.py, lgbot_rec.py (recording analyzer), tests (python -m unittest in tools/)
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/`.
## 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.