Compare commits

...
18 Commits
Author SHA1 Message Date
esefandClaude Opus 5.5 266cc20b76 vampire heals up to 200 (g_startingHealth 200, bonus 0); plain README rewrite
QL caps vampiric healing at max health, which comes from g_startingHealth.
With 100 + 100 bonus you spawn at 200 but can never heal back above 100.
200 + 0 spawns at 200, heals to 200, decays to 100 (measured on a test server).
README rewritten for Linux servers and home PCs; file layout moved to HOW-IT-WORKS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:52:21 +03:00
esefandClaude Opus 5.5 acf625d72b lgbot: auto recording while two humans fight (lgbot_record 2 / QL_RECORD), analyzer pairs any opponents
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:37:40 +03:00
esefandClaude Opus 5.5 3535940331 lgbot: movement recorder (lgbot_record) and tools/lgbot_rec.py to compare player vs bot movement
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:32:22 +03:00
esefandClaude Opus 5.5 e1c64bed4c lgbot: idle debug shows nearest player distance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:21:07 +03:00
esefandClaude Opus 5.5 86754715f5 lgbot: review fixes: drift reset after pauses, player-like acceleration, sticky targets with hysteresis, wall backoff; plugin glue tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:16:53 +03:00
esefandClaude Opus 5.5 a998614224 lgbot: steer only within LG reach on the same floor; let the AI walk around walls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:59:56 +03:00
esefandClaude Opus 5.5 f494223305 lgbot: debug log says why it isn't steering
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:57:28 +03:00
esefandClaude Opus 5.5 e7812902a1 lgbot: friction boost 1.2 (1.3 overshot to 353 u/s when the AI didn't push back)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:49:50 +03:00
esefandClaude Opus 5.5 aa457014e6 lgbot: lane setting stops it circling you (max sideways drift before turning back)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:48:30 +03:00
esefandClaude Opus 5.5 696d2f2dad lgbot: friction boost on the ground only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:40:14 +03:00
esefandClaude Opus 5.5 dc8409f872 lgbot: compensate ground friction so the bot moves at the set speed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:38:59 +03:00
esefandClaude Opus 5.5 aac104206b lgbot: never take over or kick a human named lgbot; live values in show; direction debug
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:37:30 +03:00
esefandClaude Opus 5.5 d2fb4be349 lgbot: docs (README, ADMIN, HOW-IT-WORKS, PLAYERS), back up lgbot files
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:34:10 +03:00
esefandClaude Opus 5.5 c6a495ce2c lgbot: bots have minqlx Steam IDs >= 90071996842377216; hunter chat; skill 1/4/5 only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:29:57 +03:00
esefandClaude Opus 5.5 8989780cd2 setup: make lgbot executable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:28:19 +03:00
esefandClaude Opus 5.5 fda3a795bb lgbot: interactive setup script
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:28:00 +03:00
esefandClaude Opus 5.5 062ea6468a lgbot: character file (aim per skill, no jump/crouch, LG only)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:26:39 +03:00
esefandClaude Opus 5.5 59be15ca2b lgbot: movement plugin (strafe pattern, range control, no jumps)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:26:11 +03:00
21 changed files with 1825 additions and 75 deletions

No files matched your search

+6 -2
View File
@@ -14,15 +14,19 @@ QL_PASSWORD=
QL_MAXCLIENTS=16 QL_MAXCLIENTS=16
# lgvamp LG only, 1:1 vampiric, no knockback, antilag (like x76 "Antilag LG Duel") # lgvamp LG only, 200 HP, 1:1 vampiric up to 200, no knockback, antilag (like x76 "Antilag LG Duel")
# lgvamp_nolag same without antilag (like x76 "LG Duel EU #3") # 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) # lgvamp100 100 HP, vampiric up to 100, so health never decays
QL_FACTORY=lgvamp QL_FACTORY=lgvamp
QL_MAP=thunderstruck QL_MAP=thunderstruck
# Shown to every player when they join. Empty = no message. # 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. QL_WELCOME=^7Welcome! LG only, vampiric, no knockback. Pair up and duel, there is no match to ready up for.
# Movement recording for the lgbot (home/lgbot-rec.csv, positions/speeds only, no names or IDs):
# 0 = off, 2 = automatically whenever two humans are fighting. See docs/ADMIN.md.
QL_RECORD=0
# Game port (UDP) and RCON port (TCP, bound to 127.0.0.1 only). Change both to run a second server. # 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_PORT=27960
QL_RCON_PORT=28960 QL_RCON_PORT=28960
+3
View File
@@ -13,3 +13,6 @@ home/baseq3/*
!home/baseq3/scripts/ !home/baseq3/scripts/
home/minqlx.log* home/minqlx.log*
__pycache__/ __pycache__/
!home/baseq3/botfiles/
home/lgbot.json
home/lgbot-rec.csv
+70 -61
View File
@@ -1,93 +1,102 @@
# ql-lg-server # ql-lg-server
A **Quake Live lightning gun (LG) vampiric practice server** in Docker, set up with one command. A Quake Live server for lightning gun practice, run with Docker on Linux.
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. Everyone spawns with the lightning gun and 200 HP. Damage you deal heals you by the same amount, up to 200, and health above 100 drops back by 1 per second. There is no knockback and there are no items. The server never leaves warmup, so there is no match, no ready-up and no score reset: people join, pair up and duel. This is the same format the x76 LG duel servers use.
- One command: `./setup.sh` downloads the game (SteamCMD), builds minqlx, generates an RCON password and starts the server. It also has an optional training bot (`lgbot`) that only uses the lightning gun and strafes on the ground instead of jumping around.
- 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 Linux only. Quake Live's dedicated server is only released for Linux, so there is no Windows version.
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. ## Requirements
- A Linux machine: a VPS, a home server or your own desktop. The server uses about 3% of one CPU core and 50 MB of RAM when idle.
- Docker with the compose plugin ([install guide](https://docs.docker.com/engine/install/)). Your user must be in the `docker` group (`sudo usermod -aG docker $USER`, then log out and back in).
- `git`, `python3`, about 2 GB of free disk space.
- Players need Quake Live on Steam.
## Install
```sh ```sh
git clone <this repo> ql-lg-server git clone https://git.userevolt.app/esef/ql-lg-server.git
cd ql-lg-server cd ql-lg-server
./setup.sh # asks: server name, listed or not, join password, port ./setup.sh
./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. Run it as your normal user, not root. Setup asks four questions (server name, listed or not, join password, port; Enter keeps the default), then downloads the game from Steam (about 1 GB), builds minqlx and starts the server. At the end it prints the `/connect` addresses.
**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. On a machine that runs all the time, also run `./scripts/install-cron.sh`. It adds a watchdog, a nightly backup and a daily restart (skipped while someone is playing).
## Settings ## Joining
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`. Players open the Quake Live console with `~` and type `/connect <address>:27960`.
| Setting | Default | | - Same machine: `/connect 127.0.0.1:27960`
|---|---|---| - Same network: the machine's LAN address, e.g. `/connect 192.168.1.20:27960`
| `QL_HOSTNAME` | `LG Vampiric FFA` | name in the browser (`^1`..`^7` colors work) | - Over the internet: either forward UDP port 27960 on your router to this machine and give out your public IP, or put everyone on a VPN such as Tailscale or ZeroTier and use the VPN address.
| `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) A listed server also shows up in the in-game server browser after a few minutes. That needs the port forward too.
Defined in [`home/baseq3/scripts/lgvamp.factories`](home/baseq3/scripts/lgvamp.factories): [docs/PLAYERS.md](docs/PLAYERS.md) is a page you can send to players.
| Factory | What | Seen on real servers | ## Everyday commands
|---|---|---|
| `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). Run these from the repo folder.
## Running it
```sh ```sh
./rcon status # who's on (./rcon alone = interactive console) ./rcon status # who is on
./rcon addbot hunter 5 # a target to shoot at; ./rcon kick allbots
./rcon say hello ./rcon say hello
python3 tools/qlq.py 127.0.0.1 27960 --status ./rcon addbot hunter 5 # a normal bot to shoot at; ./rcon kick allbots removes bots
docker compose restart # restart; docker compose stop / start ./lgbot # add the training bot (asks aim and movement level)
docker compose restart # restart the server
docker compose stop # stop it (docker compose start to start again)
./setup.sh --update # update the game from Steam ./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). `./rcon` with no arguments opens a console prompt. The RCON password is generated by setup and read from `secrets/` automatically, so you never type it.
## Layout ## Settings
``` All per-server settings live in `.env`. After changing it, run `docker compose up -d`.
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/`. | Setting | Default | |
|---|---|---|
| `QL_HOSTNAME` | `LG Vampiric FFA` | name in the server browser (`^1`..`^7` for colors) |
| `QL_LISTED` | `yes` | `no` = not in the browser, join by address only |
| `QL_PASSWORD` | empty | join password |
| `QL_MAXCLIENTS` | `16` | |
| `QL_FACTORY` | `lgvamp` | game mode, see below |
| `QL_MAP` | `thunderstruck` | stock maps only |
| `QL_WELCOME` | short rules text | message shown to each player on join; empty = none |
| `QL_RECORD` | `0` | movement recording for the training bot, see [docs/ADMIN.md](docs/ADMIN.md) |
| `QL_PORT` / `QL_RCON_PORT` | `27960` / `28960` | game port (UDP) / RCON port (TCP, local only) |
| `QL_NAME` | `ql-lg` | Docker container name |
Game modes (`QL_FACTORY`):
| Mode | |
|---|---|
| `lgvamp` | the default: 200 HP, vampire up to 200, antilag up to 240 ms ping |
| `lgvamp_nolag` | the same without antilag: you lead your shots by your ping |
| `lgvamp100` | 100 HP and vampire up to 100, so health never drops on its own |
The modes are defined in [`home/baseq3/scripts/lgvamp.factories`](home/baseq3/scripts/lgvamp.factories).
## When something goes wrong
- **Setup says a port is in use:** another program (or another copy of this server) uses it. Set a different `QL_PORT` / `QL_RCON_PORT` in `.env` and run `./setup.sh` again.
- **`permission denied` from Docker:** your user is not in the `docker` group yet, or you haven't logged out and back in since adding it.
- **Friends can't connect but you can:** the port forward is missing or points to the wrong machine, or a firewall on the machine blocks UDP 27960 (`sudo ufw allow 27960/udp` on Ubuntu).
- **The server doesn't come up:** `docker logs --tail 50 ql-lg` shows why. Treat logs as private.
- **After a game update the server won't start:** run `./setup.sh` again. It keeps your settings and restores the minqlx files.
Admins, bans, the training bot's settings, backups and running several servers: [docs/ADMIN.md](docs/ADMIN.md). Why each setting is the way it is: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
## Credits ## Credits
- [minqlx](https://github.com/MinoMino/minqlx) (GPL-3.0) by MinoMino: built from source by setup.sh, not included here. - [minqlx](https://github.com/MinoMino/minqlx) (GPL-3.0) by MinoMino. setup.sh builds it from source; it is not included here.
- [x76.gg](https://x76.gg/) for the LG duel server format this copies (FFA in permanent warmup, vampiric, no knockback). - [x76.gg](https://x76.gg/) for the LG duel server format.
- [SEA LG Duels server](https://github.com/minhlong124/quake-lg-sea-server): the vampiric LG preset this started from. - [SEA LG Duels server](https://github.com/minhlong124/quake-lg-sea-server), the 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. - Quake Live is © id Software / Bethesda. The game server is downloaded from Steam (app 349090); no game files are in this repo.
License: MIT (this repo's configs and scripts). License: MIT, for this repo's configs and scripts.
+44
View File
@@ -22,6 +22,50 @@ Everything below is run from the repo folder on the server machine.
**Careful:** `cvarlist` prints the RCON password into the server log. `./rcon` hides it from you, but avoid running it anyway. **Careful:** `cvarlist` prints the RCON password into the server log. `./rcon` hides it from you, but avoid running it anyway.
## LG training bot: `./lgbot`
A bot that only uses the LG, for practice: it strafes left and right on the ground with uneven timing and occasional fake direction changes, keeps LG range, can push in when it's ahead on health and back off when it's low, and never jumps or crouches. It shoots back. Aim and movement are set separately, so "poor aim, best movement" is possible. Its movement is rule-based and still looks mechanical next to a real player; work on that is ongoing.
```
$ ./lgbot
Aim level 1-5 [3]: 1
Movement level 1-5 [3]: 5
Customize movement details? (y/N): y
Strafe hold min ms 50-2000 [120]:
Strafe hold max ms 50-2000 [350]: 300
...
lgbot in: aim 1, movement 5 (custom: strafe_max 300)
```
Enter keeps the value in brackets; anything invalid asks again; Ctrl+C changes nothing. The setup is saved in `home/lgbot.json`.
| Command | |
|---|---|
| `./lgbot` | the questions above, then (re)adds the bot |
| `./lgbot --last` | put the saved setup back, e.g. after a restart (bots don't survive one) |
| `./lgbot show` | saved setup and whether the bot is in |
| `./lgbot off` | remove the bot (setup stays saved) |
Movement levels (the details override one part, `-1` = use the level's value):
| Level | Strafe hold (ms) | Fake chance | Range | Push in / back off | Speed | Lane |
|---|---|---|---|---|---|---|
| 1 | 600–1000 | 0% | 450 | no | 80% | 600 |
| 2 | 400–800 | 10% | 450 | no | 90% | 500 |
| 3 | 250–600 | 25% | 400 | yes | 100% | 400 |
| 4 | 180–450 | 35% | 380 | yes | 100% | 350 |
| 5 | 120–350 | 50% | 350 | yes | 100% | 300 |
Range is the distance it tries to keep (LG reaches 768). Lane is roughly how far it may drift sideways before it turns back, so it does ADAD in front of you instead of circling (0 = no limit; while it's also closing in or backing off, the real drift can be up to ~30% more). With push in / back off off, it always holds its range.
Underneath it's plain console settings, if you'd rather: `./rcon addbot lgbot <aim>`, `./rcon set lgbot_move <1-5>`, and `lgbot_strafe_min`, `lgbot_strafe_max`, `lgbot_fake`, `lgbot_range`, `lgbot_range_play`, `lgbot_speed`, `lgbot_lane`. The plugin rereads them every second. `set lgbot_debug 1` writes one line a second about its movement to `home/minqlx.log`.
To compare your movement with the bot's: `./rcon set lgbot_record 1`, fight it for a few minutes, `./rcon set lgbot_record 0`, then `python3 tools/lgbot_rec.py home/lgbot-rec.csv` prints both side by side (strafe hold times, speeds, jumps, distance). The recording has positions and speeds only, no names or Steam IDs, and stops by itself after 15 minutes.
`set lgbot_record 2` (or `QL_RECORD=2` in `.env`, which survives restarts) records automatically, but only while two humans are fighting each other (alive, within 1000 units, about the same floor), with no time limit. That's how to collect real duel movement to teach the bot. Recording stops at 200 MB; delete or move `home/lgbot-rec.csv` to start over. The same `tools/lgbot_rec.py` command reads it.
The bot fights the nearest human and sticks with them unless someone else is clearly closer. Its own movement takes over when you're within 900 units (a bit beyond LG reach) on about the same floor, and keeps going until you're past 1000. Farther away, the normal bot AI walks it over to you. If a wall blocks its strafing for half a second, it lets the AI walk around for 1.5 s, then longer (up to 6 s) if it keeps getting stuck. It speeds up and turns like a player (a full left-right reversal takes about 0.2 s), not instantly. One lgbot at a time.
## Admins and bans: `home/baseq3/access.txt` ## Admins and bans: `home/baseq3/access.txt`
One line per player: their 64-bit Steam ID, a `|`, and `admin`, `mod` or `ban`: One line per player: their 64-bit Steam ID, a `|`, and `admin`, `mod` or `ban`:
+34 -3
View File
@@ -1,6 +1,6 @@
# How it works (and why) # How it works (and why)
Notes for anyone changing this setup. Each point below was hit while building it. Notes for anyone changing this setup: what each part does and which alternatives were tried.
## Permanent warmup instead of a match ## Permanent warmup instead of a match
@@ -19,11 +19,23 @@ Approaches that don't work:
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=''`. 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. ## 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"`). 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 ## 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.) `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 stops at the player's max health, and QL sets max health from `g_startingHealth`. So 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 obvious alternative, `g_startingHealth 100` plus `g_startingHealthBonus 100`, also spawns you at 200, but caps vampire at 100: 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: vampire up to 100, no decay. There is no separate cvar for a vampire cap (the game module's only vampire cvars are `g_vampiricDamage` and `g_debugVampiricDamage`).
## Antilag ## Antilag
@@ -39,6 +51,25 @@ Two more places the password shows up: `home/baseq3/zmqpass.txt` (QL writes it o
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. 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)
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 ## Small things
- SteamCMD needs `+@sSteamCmdForcePlatformType linux`, or it fails with "Missing configuration". - SteamCMD needs `+@sSteamCmdForcePlatformType linux`, or it fails with "Missing configuration".
+2 -2
View File
@@ -13,7 +13,7 @@ Your server admin gives you the address. If the server is listed, it also shows
## How it works ## How it works
- You spawn with the **lightning gun** (and gauntlet), infinite ammo and 200 HP (100 HP on `lgvamp100` servers). - 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). - **Vampiric:** every point of damage you deal heals you by the same amount, up to your spawn health. Health above 100 drops by 1 per second.
- **No knockback, no items, no fall or self damage.** - **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. - 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. - **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.
@@ -38,4 +38,4 @@ These are client-side; type them in the console, or put them in `baseq3/autoexec
## Practice tips ## 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. - 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. - Practice alone: ask the admin for **lgbot** (`./lgbot` on the server). It only uses the LG and strafes on the ground instead of jumping around. Its aim and movement levels are set separately, so you can practise tracking against fast movement while it barely hits you, or the other way round.
+166
View File
@@ -0,0 +1,166 @@
//===========================================================================
// lgbot: LG training bot. Aim per skill level (the N in "addbot lgbot N"); never jumps or
// crouches, LG only. Movement is steered by the lgbot minqlx plugin.
// QL reads skill 1, 4 and 5 from this file and interpolates levels 2-3 between 1 and 4.
//===========================================================================
#include "chars.h"
skill 1
{
CHARACTERISTIC_NAME "lgbot"
CHARACTERISTIC_GENDER "female"
CHARACTERISTIC_ATTACK_SKILL 0.3
CHARACTERISTIC_WEAPONWEIGHTS "bots/lgbot_w.c"
CHARACTERISTIC_AIM_SKILL 0.2
CHARACTERISTIC_AIM_ACCURACY 0.25
CHARACTERISTIC_VIEW_FACTOR 0.4
CHARACTERISTIC_VIEW_MAXCHANGE 90
CHARACTERISTIC_REACTIONTIME 3.0
CHARACTERISTIC_CHAT_FILE "bots/hunter_t.c"
CHARACTERISTIC_CHAT_NAME "hunter"
CHARACTERISTIC_CHAT_CPM 400
CHARACTERISTIC_CHAT_INSULT 0
CHARACTERISTIC_CHAT_MISC 0
CHARACTERISTIC_CHAT_STARTENDLEVEL 0
CHARACTERISTIC_CHAT_ENTEREXITGAME 0
CHARACTERISTIC_CHAT_KILL 0
CHARACTERISTIC_CHAT_DEATH 0
CHARACTERISTIC_CHAT_ENEMYSUICIDE 0
CHARACTERISTIC_CHAT_HITTALKING 0
CHARACTERISTIC_CHAT_HITNODEATH 0
CHARACTERISTIC_CHAT_HITNOKILL 0
CHARACTERISTIC_CHAT_RANDOM 0
CHARACTERISTIC_CHAT_REPLY 0
CHARACTERISTIC_CROUCHER 0
CHARACTERISTIC_JUMPER 0
CHARACTERISTIC_WALKER 0
CHARACTERISTIC_WEAPONJUMPING 0
CHARACTERISTIC_GRAPPLE_USER 0
CHARACTERISTIC_ITEMWEIGHTS "bots/hunter_i.c"
CHARACTERISTIC_AGGRESSION 1.0
CHARACTERISTIC_SELFPRESERVATION 0
CHARACTERISTIC_VENGEFULNESS 1.0
CHARACTERISTIC_CAMPER 0
CHARACTERISTIC_EASY_FRAGGER 0.5
CHARACTERISTIC_ALERTNESS 1.0
CHARACTERISTIC_AIM_ACCURACY_MACHINEGUN 0.25
CHARACTERISTIC_AIM_ACCURACY_SHOTGUN 0.25
CHARACTERISTIC_AIM_ACCURACY_ROCKETLAUNCHER 0.25
CHARACTERISTIC_AIM_ACCURACY_GRENADELAUNCHER 0.25
CHARACTERISTIC_AIM_ACCURACY_LIGHTNING 0.25
CHARACTERISTIC_AIM_ACCURACY_PLASMAGUN 0.25
CHARACTERISTIC_AIM_ACCURACY_RAILGUN 0.25
CHARACTERISTIC_AIM_ACCURACY_BFG10K 0.25
CHARACTERISTIC_AIM_SKILL_ROCKETLAUNCHER 0.2
CHARACTERISTIC_AIM_SKILL_GRENADELAUNCHER 0.2
CHARACTERISTIC_AIM_SKILL_PLASMAGUN 0.2
CHARACTERISTIC_AIM_SKILL_BFG10K 0.2
CHARACTERISTIC_FIRETHROTTLE 1.0
}
skill 4
{
CHARACTERISTIC_NAME "lgbot"
CHARACTERISTIC_GENDER "female"
CHARACTERISTIC_ATTACK_SKILL 0.75
CHARACTERISTIC_WEAPONWEIGHTS "bots/lgbot_w.c"
CHARACTERISTIC_AIM_SKILL 0.75
CHARACTERISTIC_AIM_ACCURACY 0.8
CHARACTERISTIC_VIEW_FACTOR 0.85
CHARACTERISTIC_VIEW_MAXCHANGE 160
CHARACTERISTIC_REACTIONTIME 0.6
CHARACTERISTIC_CHAT_FILE "bots/hunter_t.c"
CHARACTERISTIC_CHAT_NAME "hunter"
CHARACTERISTIC_CHAT_CPM 400
CHARACTERISTIC_CHAT_INSULT 0
CHARACTERISTIC_CHAT_MISC 0
CHARACTERISTIC_CHAT_STARTENDLEVEL 0
CHARACTERISTIC_CHAT_ENTEREXITGAME 0
CHARACTERISTIC_CHAT_KILL 0
CHARACTERISTIC_CHAT_DEATH 0
CHARACTERISTIC_CHAT_ENEMYSUICIDE 0
CHARACTERISTIC_CHAT_HITTALKING 0
CHARACTERISTIC_CHAT_HITNODEATH 0
CHARACTERISTIC_CHAT_HITNOKILL 0
CHARACTERISTIC_CHAT_RANDOM 0
CHARACTERISTIC_CHAT_REPLY 0
CHARACTERISTIC_CROUCHER 0
CHARACTERISTIC_JUMPER 0
CHARACTERISTIC_WALKER 0
CHARACTERISTIC_WEAPONJUMPING 0
CHARACTERISTIC_GRAPPLE_USER 0
CHARACTERISTIC_ITEMWEIGHTS "bots/hunter_i.c"
CHARACTERISTIC_AGGRESSION 1.0
CHARACTERISTIC_SELFPRESERVATION 0
CHARACTERISTIC_VENGEFULNESS 1.0
CHARACTERISTIC_CAMPER 0
CHARACTERISTIC_EASY_FRAGGER 0.5
CHARACTERISTIC_ALERTNESS 1.0
CHARACTERISTIC_AIM_ACCURACY_MACHINEGUN 0.8
CHARACTERISTIC_AIM_ACCURACY_SHOTGUN 0.8
CHARACTERISTIC_AIM_ACCURACY_ROCKETLAUNCHER 0.8
CHARACTERISTIC_AIM_ACCURACY_GRENADELAUNCHER 0.8
CHARACTERISTIC_AIM_ACCURACY_LIGHTNING 0.8
CHARACTERISTIC_AIM_ACCURACY_PLASMAGUN 0.8
CHARACTERISTIC_AIM_ACCURACY_RAILGUN 0.8
CHARACTERISTIC_AIM_ACCURACY_BFG10K 0.8
CHARACTERISTIC_AIM_SKILL_ROCKETLAUNCHER 0.75
CHARACTERISTIC_AIM_SKILL_GRENADELAUNCHER 0.75
CHARACTERISTIC_AIM_SKILL_PLASMAGUN 0.75
CHARACTERISTIC_AIM_SKILL_BFG10K 0.75
CHARACTERISTIC_FIRETHROTTLE 1.0
}
skill 5
{
CHARACTERISTIC_NAME "lgbot"
CHARACTERISTIC_GENDER "female"
CHARACTERISTIC_ATTACK_SKILL 0.9
CHARACTERISTIC_WEAPONWEIGHTS "bots/lgbot_w.c"
CHARACTERISTIC_AIM_SKILL 1.0
CHARACTERISTIC_AIM_ACCURACY 1.0
CHARACTERISTIC_VIEW_FACTOR 1.0
CHARACTERISTIC_VIEW_MAXCHANGE 180
CHARACTERISTIC_REACTIONTIME 0.2
CHARACTERISTIC_CHAT_FILE "bots/hunter_t.c"
CHARACTERISTIC_CHAT_NAME "hunter"
CHARACTERISTIC_CHAT_CPM 400
CHARACTERISTIC_CHAT_INSULT 0
CHARACTERISTIC_CHAT_MISC 0
CHARACTERISTIC_CHAT_STARTENDLEVEL 0
CHARACTERISTIC_CHAT_ENTEREXITGAME 0
CHARACTERISTIC_CHAT_KILL 0
CHARACTERISTIC_CHAT_DEATH 0
CHARACTERISTIC_CHAT_ENEMYSUICIDE 0
CHARACTERISTIC_CHAT_HITTALKING 0
CHARACTERISTIC_CHAT_HITNODEATH 0
CHARACTERISTIC_CHAT_HITNOKILL 0
CHARACTERISTIC_CHAT_RANDOM 0
CHARACTERISTIC_CHAT_REPLY 0
CHARACTERISTIC_CROUCHER 0
CHARACTERISTIC_JUMPER 0
CHARACTERISTIC_WALKER 0
CHARACTERISTIC_WEAPONJUMPING 0
CHARACTERISTIC_GRAPPLE_USER 0
CHARACTERISTIC_ITEMWEIGHTS "bots/hunter_i.c"
CHARACTERISTIC_AGGRESSION 1.0
CHARACTERISTIC_SELFPRESERVATION 0
CHARACTERISTIC_VENGEFULNESS 1.0
CHARACTERISTIC_CAMPER 0
CHARACTERISTIC_EASY_FRAGGER 0.5
CHARACTERISTIC_ALERTNESS 1.0
CHARACTERISTIC_AIM_ACCURACY_MACHINEGUN 1.0
CHARACTERISTIC_AIM_ACCURACY_SHOTGUN 1.0
CHARACTERISTIC_AIM_ACCURACY_ROCKETLAUNCHER 1.0
CHARACTERISTIC_AIM_ACCURACY_GRENADELAUNCHER 1.0
CHARACTERISTIC_AIM_ACCURACY_LIGHTNING 1.0
CHARACTERISTIC_AIM_ACCURACY_PLASMAGUN 1.0
CHARACTERISTIC_AIM_ACCURACY_RAILGUN 1.0
CHARACTERISTIC_AIM_ACCURACY_BFG10K 1.0
CHARACTERISTIC_AIM_SKILL_ROCKETLAUNCHER 1.0
CHARACTERISTIC_AIM_SKILL_GRENADELAUNCHER 1.0
CHARACTERISTIC_AIM_SKILL_PLASMAGUN 1.0
CHARACTERISTIC_AIM_SKILL_BFG10K 1.0
CHARACTERISTIC_FIRETHROTTLE 1.0
}
+15
View File
@@ -0,0 +1,15 @@
// lgbot weapon weights: lightning gun only.
#include "inv.h"
#define W_GAUNTLET 1
#define W_SHOTGUN 1
#define W_MACHINEGUN 1
#define W_GRENADELAUNCHER 1
#define W_ROCKETLAUNCHER 1
#define W_RAILGUN 1
#define W_BFG10K 1
#define W_LIGHTNING 480
#define W_PLASMAGUN 1
#define W_GRAPPLE 1
#include "fw_weap.c"
+5
View File
@@ -0,0 +1,5 @@
{
name lgbot
model hunter
aifile bots/lgbot_c.c
}
+4 -4
View File
@@ -12,8 +12,8 @@
"cvars": { "cvars": {
"g_startingWeapons": "33", "g_startingWeapons": "33",
"g_infiniteAmmo": "1", "g_infiniteAmmo": "1",
"g_startingHealth": "100", "g_startingHealth": "200",
"g_startingHealthBonus": "100", "g_startingHealthBonus": "0",
"g_startingArmor": "0", "g_startingArmor": "0",
"g_vampiricDamage": "1", "g_vampiricDamage": "1",
"g_knockback": "0", "g_knockback": "0",
@@ -57,8 +57,8 @@
"cvars": { "cvars": {
"g_startingWeapons": "33", "g_startingWeapons": "33",
"g_infiniteAmmo": "1", "g_infiniteAmmo": "1",
"g_startingHealth": "100", "g_startingHealth": "200",
"g_startingHealthBonus": "100", "g_startingHealthBonus": "0",
"g_startingArmor": "0", "g_startingArmor": "0",
"g_vampiricDamage": "1", "g_vampiricDamage": "1",
"g_knockback": "0", "g_knockback": "0",
+1 -1
View File
@@ -23,7 +23,7 @@ set zmq_rcon_ip "127.0.0.1"
set zmq_stats_enable "0" set zmq_stats_enable "0"
// minqlx: only our plugins (the default minqlx plugin list needs a redis server) // minqlx: only our plugins (the default minqlx plugin list needs a redis server)
set qlx_plugins "nowarmuptext,welcome" set qlx_plugins "nowarmuptext,welcome,lgbot"
set qlx_pluginsPath "/ql/home/minqlx-plugins" set qlx_pluginsPath "/ql/home/minqlx-plugins"
exec secrets.cfg exec secrets.cfg
+393
View File
@@ -0,0 +1,393 @@
# LG training bot movement: steers the bot named "lgbot" like a human LG duelist (ground ADAD
# strafing with uneven holds and fakes, keeps LG range, never jumps). Aim comes from the bot's
# character file (botfiles/bots/lgbot_c.c, the number in "addbot lgbot N"); this plugin only moves it.
# Settings are cvars (the ./lgbot script sets them): lgbot_move 1-5 picks a preset, the detail cvars
# override one part of it, -1 = use the preset. The pattern code below has no minqlx dependency so
# tools/test_lgbot.py and tools/lgbot.py can import it.
import math
import os
import random
import time
RUN_SPEED = 320 # QL ground run speed, units/s
# Ground friction takes ~15% of the velocity we set before the frame's move happens at sv_fps 40
# (measured: 416 set -> 353 actual; up to ~25% while the bot AI pushes the other way), so on the
# ground we set this much more to end up at about the wanted speed.
# In the air there is no friction, so no boost (vertical speed 0 = on the ground, near enough).
FRICTION_COMP = 1.2
# How fast the bot's velocity may change, units/s per second: QL ground acceleration (10) x run
# speed, so a full left-right reversal takes ~200 ms like a player, not one frame.
ACCEL = 3200
KEYS = ["strafe_min", "strafe_max", "fake", "range", "range_play", "speed", "lane"]
LIMITS = {"strafe_min": (50, 2000), "strafe_max": (50, 2000), "fake": (0, 100),
"range": (150, 750), "range_play": (0, 1), "speed": (50, 100), "lane": (0, 2000)}
PRESETS = {
1: {"strafe_min": 600, "strafe_max": 1000, "fake": 0, "range": 450, "range_play": 0, "speed": 80, "lane": 600},
2: {"strafe_min": 400, "strafe_max": 800, "fake": 10, "range": 450, "range_play": 0, "speed": 90, "lane": 500},
3: {"strafe_min": 250, "strafe_max": 600, "fake": 25, "range": 400, "range_play": 1, "speed": 100, "lane": 400},
4: {"strafe_min": 180, "strafe_max": 450, "fake": 35, "range": 380, "range_play": 1, "speed": 100, "lane": 350},
5: {"strafe_min": 120, "strafe_max": 350, "fake": 50, "range": 350, "range_play": 1, "speed": 100, "lane": 300},
}
PUSH_RANGE, BACKOFF_RANGE = 250, 700
BACKOFF_HEALTH, PUSH_LEAD = 60, 30
# Steering is straight-line (no pathfinding), so only start steering when the target is at most a
# bit beyond LG reach (768) and on about the same floor; otherwise the bot's own navigation walks it
# over. Once steering, keep the target up to the STOP limits so it doesn't flip at the edge, and
# only switch to another player who is clearly closer.
MAX_TARGET_DIST = 900
MAX_TARGET_DZ = 200
STOP_TARGET_DIST = 1000
STOP_TARGET_DZ = 250
SWITCH_MARGIN = 150
BOT_STEAM_ID = 90071996842377216 # minqlx gives bots this + their client number
# lgbot_record 1: every frame, the position/velocity of the bot and each human (as h<client number>,
# no names or Steam IDs) go to home/lgbot-rec.csv, for tools/lgbot_rec.py to compare how they move.
# Stops by itself after REC_MAX_S. lgbot_record 2 (auto): only frames where two humans are fighting
# (alive, within REC_FIGHT_DIST on about the same floor), no time limit. Both stop at REC_MAX_BYTES.
REC_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lgbot-rec.csv")
REC_MAX_S = 900
REC_MAX_BYTES = 200 * 1024 * 1024
REC_FIGHT_DIST, REC_FIGHT_DZ = 1000, 250
def is_bot(steam_id):
return steam_id == 0 or steam_id >= BOT_STEAM_ID
def is_lgbot(name, steam_id):
"""The training bot: named lgbot AND a bot (a human can't take it over by renaming)."""
return name.lower() == "lgbot" and is_bot(steam_id)
def to_int(text):
"""Cvar text -> int, None when unset or junk (so a typo falls back to the preset)."""
try:
return int(float(text))
except (TypeError, ValueError):
return None
def resolve(move, overrides):
"""Preset for movement level `move` with the valid (>= 0) overrides applied, clamped."""
try:
move = min(5, max(1, int(move)))
except (TypeError, ValueError):
move = 3
s = dict(PRESETS[move])
for k, v in overrides.items():
if k in LIMITS and isinstance(v, int) and v >= 0:
lo, hi = LIMITS[k]
s[k] = min(hi, max(lo, v))
s["strafe_max"] = max(s["strafe_max"], s["strafe_min"])
return s
class Strafer:
"""Strafe direction over time: holds one side for strafe_min..strafe_max ms, then switches.
A fake is a 60-120 ms tap to the other side followed by a new hold on the original side.
Lane: strafing sideways to someone moves you around them, and fakes keep you going the same
way, so the net sideways drift is tracked and the bot turns back once it is more than `lane`
units off centre (0 = no limit). That keeps it doing ADAD in front of you instead of circling."""
def __init__(self, rng=random):
self.rng = rng
self.direction = 1
self.until = 0
self.faking = False
self.drift = 0 # signed ms of strafing, + = direction 1
self.last = None
def reset(self):
"""Not steering (dead, no target, walking): drift so far means nothing for the next fight."""
self.drift = 0
self.last = None
def step(self, now_ms, s):
if self.last is not None:
self.drift += self.direction * min(now_ms - self.last, 100) # a pause is not drift
self.last = now_ms
if s["lane"]:
lane_ms = s["lane"] * 1000.0 / (RUN_SPEED * s["speed"] / 100.0)
if self.direction * self.drift >= lane_ms:
self.direction = -self.direction
self.faking = False
self.until = now_ms + self.rng.randint(s["strafe_min"], s["strafe_max"])
return self.direction
if now_ms >= self.until:
self.direction = -self.direction
if self.faking:
self.faking = False
self.until = now_ms + self.rng.randint(s["strafe_min"], s["strafe_max"])
elif self.rng.random() * 100 < s["fake"]:
self.faking = True
self.until = now_ms + self.rng.randint(60, 120)
else:
self.until = now_ms + self.rng.randint(s["strafe_min"], s["strafe_max"])
return self.direction
def wanted_range(s, my_hp, target_hp):
if s["range_play"]:
if my_hp < BACKOFF_HEALTH:
return BACKOFF_RANGE
if my_hp >= target_hp + PUSH_LEAD:
return PUSH_RANGE
return s["range"]
def steer(bot_xy, target_xy, direction, s, want):
"""Horizontal velocity: strafe sideways to the target, plus in/out to hold `want` distance."""
cap = RUN_SPEED * s["speed"] / 100.0
dx, dy = target_xy[0] - bot_xy[0], target_xy[1] - bot_xy[1]
dist = math.hypot(dx, dy)
if dist < 1:
return (direction * cap, 0.0)
ux, uy = dx / dist, dy / dist
radial = max(-0.7 * cap, min(0.7 * cap, (dist - want) * 2.0))
side = math.sqrt(max(0.0, cap * cap - radial * radial))
return (-uy * direction * side + ux * radial, ux * direction * side + uy * radial)
def fix_z(vz):
"""Cancel a jump (QL jump velocity is ~275); leave slopes, falling and jump pads alone."""
return 0.0 if 200 <= vz <= 320 else vz
def approach(cur, want, max_delta):
"""Move velocity `cur` toward `want` by at most `max_delta` (acceleration limit)."""
dx, dy = want[0] - cur[0], want[1] - cur[1]
d = math.hypot(dx, dy)
if d <= max_delta:
return (want[0], want[1])
return (cur[0] + dx / d * max_delta, cur[1] + dy / d * max_delta)
def humans_fighting(positions):
"""True when two of these (x, y, z) are within duel range of each other on about the same floor."""
for i, a in enumerate(positions):
for b in positions[i + 1:]:
if math.hypot(a[0] - b[0], a[1] - b[1]) <= REC_FIGHT_DIST and abs(a[2] - b[2]) <= REC_FIGHT_DZ:
return True
return False
def pick_target(bot_pos, candidates, current=None):
"""Key of the player to fight, from candidates [((x, y, z), key)]. A new target must be within
MAX_TARGET_DIST / MAX_TARGET_DZ; the current one is kept up to the STOP limits unless another
is SWITCH_MARGIN closer. None = nobody (the bot AI walks)."""
near, cur_d = [], None
for pos, key in candidates:
dz = abs(pos[2] - bot_pos[2])
d = math.hypot(pos[0] - bot_pos[0], pos[1] - bot_pos[1])
if key == current and d <= STOP_TARGET_DIST and dz <= STOP_TARGET_DZ:
cur_d = d
if d <= MAX_TARGET_DIST and dz <= MAX_TARGET_DZ:
near.append((d, key))
best = min(near, key=lambda t: t[0]) if near else None
if cur_d is not None and (best is None or best[0] > cur_d - SWITCH_MARGIN):
return current
return best[1] if best else None
class StuckGuard:
"""Lets go of the bot when steering keeps getting blocked (a wall, a ledge): if the speed it
makes along the commanded direction stays under `ratio` of the command for `after_ms`, steering
is off for `release_ms` so the bot AI can walk around the obstacle. If it keeps failing, each
release is twice as long (up to `max_release_ms`); `clean_ms` of good steering resets that."""
def __init__(self, ratio=0.6, after_ms=500, release_ms=1500, max_release_ms=6000, clean_ms=3000):
self.ratio, self.after_ms = ratio, after_ms
self.release_ms, self.max_release_ms, self.clean_ms = release_ms, max_release_ms, clean_ms
self.slow_since = None
self.off_until = 0
self.streak = 0
self.clean_since = None
def update(self, now_ms, cmd_speed, along_speed):
if now_ms < self.off_until:
return False
if cmd_speed > 1 and along_speed < cmd_speed * self.ratio:
self.clean_since = None
if self.slow_since is None:
self.slow_since = now_ms
elif now_ms - self.slow_since >= self.after_ms:
self.slow_since = None
self.off_until = now_ms + min(self.max_release_ms, self.release_ms * 2 ** self.streak)
self.streak += 1
return False
else:
self.slow_since = None
if self.clean_since is None:
self.clean_since = now_ms
elif now_ms - self.clean_since >= self.clean_ms:
self.streak = 0
return True
try:
import minqlx
except ImportError: # imported by tests / the setup script outside the server
minqlx = None
if minqlx:
class lgbot(minqlx.Plugin):
def __init__(self):
super().__init__()
self.set_cvar_once("lgbot_move", "3")
for k in KEYS:
self.set_cvar_once("lgbot_" + k, "-1")
self.set_cvar_once("lgbot_debug", "0")
self.set_cvar_once("lgbot_target_bots", "0") # test aid: let it fight other bots
self.set_cvar_once("lgbot_record", "0")
self.strafer = Strafer()
self.settings, self.read_at, self.log_at = None, 0.0, 0.0
self.debug = self.target_bots = False
self.failed = False
self.guard = StuckGuard()
self.last_cmd = None # last command (for the stuck guard and the acceleration limit)
self.target_id, self.last_tick = None, None
self.dbg_cmd, self.slow_frames, self.stuck = None, 0, 0 # debug stats
self.state, self.frame_players = "", [] # for the recorder
self.record_mode, self.rec, self.rec_start, self.rec_bytes = 0, None, 0.0, 0
self.add_hook("frame", self.handle_frame)
def handle_frame(self):
try:
self.tick()
self.record(time.time())
except Exception:
if not self.failed: # log once, never spam 40 tracebacks a second
self.failed = True
minqlx.log_exception(self)
def read_cvars(self, now):
if self.settings is None or now - self.read_at >= 1.0:
over = {k: to_int(self.get_cvar("lgbot_" + k)) for k in KEYS}
self.settings = resolve(to_int(self.get_cvar("lgbot_move")), over)
self.debug = bool(to_int(self.get_cvar("lgbot_debug")))
self.target_bots = bool(to_int(self.get_cvar("lgbot_target_bots")))
self.record_mode = to_int(self.get_cvar("lgbot_record")) or 0
self.read_at = now
return self.settings
def idle(self, now, code, why):
self.state = code
# not steering this frame: nothing for the stuck guard to judge, and the strafe pattern
# starts fresh next time (otherwise the pause counts as sideways drift)
self.last_cmd = self.dbg_cmd = self.target_id = None
self.strafer.reset()
if self.debug and now - self.log_at >= 1.0:
self.log_at = now
self.logger.info("lgbot idle: %s", why)
def tick(self):
now = time.time()
s = self.read_cvars(now)
players = self.players()
self.frame_players = players
bot = next((p for p in players if is_lgbot(p.clean_name, p.steam_id)), None)
if bot is None:
return self.idle(now, "nobot", "no lgbot on the server")
if not bot.is_alive:
return self.idle(now, "dead", "lgbot is dead")
cands = []
for p in players:
if p.id == bot.id or p.team == "spectator" or not p.is_alive:
continue
if is_bot(p.steam_id) and not self.target_bots:
continue
pos = p.position()
cands.append(((pos.x, pos.y, pos.z), p.id))
bpos = bot.position()
tid = pick_target((bpos.x, bpos.y, bpos.z), cands, self.target_id)
if tid is None: # nobody to duel close by: the bot AI walks over
near = min(((math.hypot(c[0][0] - bpos.x, c[0][1] - bpos.y), c[0][2] - bpos.z) for c in cands), default=None)
return self.idle(now, "notarget", "no target within %d on this floor (%d candidates, nearest %s)" % (
MAX_TARGET_DIST, len(cands), "dist=%d dz=%d bot=%d,%d" % (near + (bpos.x, bpos.y)) if near else "-"))
if tid != self.target_id:
self.strafer.reset() # drift was measured around the previous target
self.target_id = tid
target = next(p for p in players if p.id == tid)
vel = bot.velocity()
cmd_speed, along = 0.0, 0.0
if self.last_cmd:
cmd_speed = math.hypot(*self.last_cmd)
if cmd_speed > 1:
along = (vel.x * self.last_cmd[0] + vel.y * self.last_cmd[1]) / cmd_speed
if not self.guard.update(int(now * 1000), cmd_speed, along):
return self.idle(now, "blocked", "blocked, letting the bot AI walk around it")
tpos = target.position()
direction = self.strafer.step(int(now * 1000), s)
want = wanted_range(s, bot.health, target.health)
vx, vy = steer((bpos.x, bpos.y), (tpos.x, tpos.y), direction, s, want)
dt = min(0.1, now - self.last_tick) if self.last_tick else 0.025
self.last_tick = now
start = self.last_cmd if self.last_cmd else (vel.x, vel.y)
vx, vy = approach(start, (vx, vy), ACCEL * dt) # turn like a player, not in one frame
comp = FRICTION_COMP if abs(vel.z) < 1 else 1.0
bot.velocity(x=vx * comp, y=vy * comp, z=fix_z(vel.z))
self.last_cmd = (vx, vy)
self.state = "steer"
if self.debug:
self.debug_line(now, vel, (vx, vy), tpos, bpos, want, direction)
def stop_recording(self):
if self.rec:
self.rec.close()
self.rec = None
def record(self, now):
mode = self.record_mode
if mode not in (1, 2):
return self.stop_recording()
rows, humans = [], []
for p in self.frame_players:
if p.team == "spectator":
continue
if is_lgbot(p.clean_name, p.steam_id):
who, state = "bot", self.state
elif is_bot(p.steam_id):
continue
else:
who, state = "h%d" % p.id, ""
pos, vel = p.position(), p.velocity()
if who != "bot" and p.is_alive:
humans.append((pos.x, pos.y, pos.z))
rows.append((who, int(bool(p.is_alive)), p.health, pos.x, pos.y, pos.z, vel.x, vel.y, vel.z, state))
if mode == 2 and not humans_fighting(humans):
return
if self.rec is None:
self.rec = open(REC_FILE, "a", buffering=1)
self.rec_bytes = os.path.getsize(REC_FILE)
self.rec_start = now
self.rec.write("# start %d map %s\n" % (int(now), self.get_cvar("mapname")))
if (mode == 1 and now - self.rec_start > REC_MAX_S) or self.rec_bytes > REC_MAX_BYTES:
self.set_cvar("lgbot_record", "0")
self.record_mode = 0
return self.stop_recording()
t = now - self.rec_start
for r in rows:
line = "%.3f,%s,%d,%d,%.0f,%.0f,%.0f,%.0f,%.0f,%.0f,%s\n" % ((t,) + r)
self.rec.write(line)
self.rec_bytes += len(line)
def debug_line(self, now, vel, cmd, tpos, bpos, want, direction):
# cos = how well last frame's command survived the frame (1 = exactly our direction);
# stuck = times actual speed stayed under half the command for > 0.5 s (walls; the
# StuckGuard is what acts on it, this is only the count)
actual = math.hypot(vel.x, vel.y)
cos = 0.0
if self.dbg_cmd:
lc = math.hypot(*self.dbg_cmd)
if lc > 1 and actual > 1:
cos = (vel.x * self.dbg_cmd[0] + vel.y * self.dbg_cmd[1]) / (lc * actual)
self.slow_frames = self.slow_frames + 1 if actual < lc / 2 else 0
if self.slow_frames == 20:
self.stuck += 1
self.dbg_cmd = cmd
if now - self.log_at >= 1.0:
self.log_at = now
self.logger.info("lgbot dist=%d want=%d dir=%d cmd=%d actual=%d cos=%.2f stuck=%d z=%d",
math.hypot(tpos.x - bpos.x, tpos.y - bpos.y), want, direction,
math.hypot(*cmd), actual, cos, self.stuck, vel.z)
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# LG training bot setup: ./lgbot asks for aim/movement and sets the bot up; ./lgbot --last, show, off.
# Runs inside the container like ./rcon, 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/lgbot_setup.py "$@"
+1 -1
View File
@@ -8,7 +8,7 @@ umask 077
mkdir -p backups mkdir -p backups
chmod 700 backups chmod 700 backups
tar czf backups/ql-$(date +%F).tar.gz .env secrets home/baseq3/access.txt \ 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 home/baseq3/server.cfg home/baseq3/scripts home/minqlx-plugins home/baseq3/botfiles home/lgbot.json 2>/dev/null || true
find backups -name "ql-*.tar.gz" -mtime +14 -delete find backups -name "ql-*.tar.gz" -mtime +14 -delete
for f in home/baseq3/*.log home/minqlx.log; do for f in home/baseq3/*.log home/minqlx.log; do
if [ -f "$f" ] && [ "$(stat -c %s "$f")" -gt 52428800 ]; then : > "$f"; fi if [ -f "$f" ] && [ "$(stat -c %s "$f")" -gt 52428800 ]; then : > "$f"; fi
+1 -1
View File
@@ -70,7 +70,7 @@ if [ ! -s secrets/rcon-password.txt ]; then
else else
echo "keeping secrets/rcon-password.txt" echo "keeping secrets/rcon-password.txt"
fi fi
chmod +x rcon tools/*.sh scripts/*.sh chmod +x rcon lgbot tools/*.sh scripts/*.sh
say "Building the Docker image" say "Building the Docker image"
docker compose build -q docker compose build -q
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Compare how you and lgbot move, from a recording (set lgbot_record 1, play, set lgbot_record 0).
python3 tools/lgbot_rec.py [home/lgbot-rec.csv]
Only fight time counts: both alive, within 1000 units and about the same floor. Sideways/toward are
relative to the nearest opponent (human or bot), so human-vs-human recordings (lgbot_record 2) work
too: "humans" is every human together. Prints numbers only.
"""
import math, sys
FIGHT_DIST, FIGHT_DZ = 1000, 250
SIDE_DEADBAND = 60 # u/s sideways before it counts as strafing to one side
GAP_S = 0.2 # a hole in the recording longer than this breaks a run
STOP_SPEED = 100
JUMP_VZ = 150
def parse(lines):
"""-> list of sessions, each {t: {who: row}}; row = (alive, hp, x, y, z, vx, vy, vz, state)."""
sessions, cur = [], None
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith("#"):
cur = {}
sessions.append(cur)
continue
f = line.split(",")
if len(f) < 11:
continue
if cur is None:
cur = {}
sessions.append(cur)
try:
t = float(f[0])
row = (int(f[2]), int(f[3])) + tuple(float(v) for v in f[4:10]) + (f[10],)
except ValueError:
continue
cur.setdefault(t, {})[f[1]] = row
return sessions
def fight_frames(session):
"""-> {who: [...]} ("bot", "h1", ...), frames (t, x, y, z, vx, vy, vz, ox, oy, state) in time order.
Each alive player is measured against the nearest alive player in fight range (human or bot)."""
out = {}
for t in sorted(session):
alive = [(who, r) for who, r in session[t].items() if r[0]]
for who, me in alive:
best = None
for other, r in alive:
if other == who or abs(r[4] - me[4]) > FIGHT_DZ:
continue
d = math.hypot(r[2] - me[2], r[3] - me[3])
if d <= FIGHT_DIST and (best is None or d < best[0]):
best = (d, r)
if best is not None:
out.setdefault(who, []).append(
(t,) + me[2:8] + (best[1][2], best[1][3], me[8]))
return out
def by_kind(per_player):
"""{who: frames} -> {"human": frames, "bot": frames}: one player after another, so time jumps
back at each join and the gap rule keeps players (and sessions) apart."""
out = {"human": [], "bot": []}
for who in sorted(per_player):
out["bot" if who == "bot" else "human"].extend(per_player[who])
return out
def pct(vals, q):
if not vals:
return None
v = sorted(vals)
return v[min(len(v) - 1, int(q / 100.0 * len(v)))]
def analyze(frames):
"""Movement stats for one player's fight frames (see fight_frames)."""
n = len(frames)
res = {"seconds": 0.0, "frames": n}
if n < 2:
return res
ground_speed, air_speed, dist, side_share = [], [], [], []
holds, side, side_since, prev = [], 0, None, None
air = stops = jumps = steer = 0
for fr in frames:
t, x, y, z, vx, vy, vz, ox, oy, state = fr
dx, dy = ox - x, oy - y
d = math.hypot(dx, dy) or 1.0
ux, uy = dx / d, dy / d
lat = -uy * vx + ux * vy
rad = ux * vx + uy * vy
speed = math.hypot(vx, vy)
dist.append(d)
on_ground = abs(vz) < 1
(ground_speed if on_ground else air_speed).append(speed)
if not on_ground:
air += 1
if speed < STOP_SPEED:
stops += 1
if abs(lat) + abs(rad) > 1:
side_share.append(abs(lat) / (abs(lat) + abs(rad)))
if state == "steer":
steer += 1
gap = prev is not None and not 0 <= t - prev[0] <= GAP_S
if prev is not None and not gap and abs(prev[6]) < 1 and vz > JUMP_VZ:
jumps += 1
if gap:
side, side_since = 0, None
s = 1 if lat > SIDE_DEADBAND else -1 if lat < -SIDE_DEADBAND else 0
if s and s != side:
if side and side_since is not None:
holds.append(int((t - side_since) * 1000))
side, side_since = s, t
prev = fr
secs = sum(min(max(b[0] - a[0], 0), GAP_S) for a, b in zip(frames, frames[1:]))
res.update({
"seconds": secs,
"hold_ms": (pct(holds, 10), pct(holds, 50), pct(holds, 90)),
"reversals_per_s": len(holds) / secs if secs else 0,
"ground_speed": (pct(ground_speed, 50), pct(ground_speed, 90)),
"air_speed": (pct(air_speed, 50), pct(air_speed, 90)),
"air_share": air / n,
"jumps_per_s": jumps / secs if secs else 0,
"sideways_share": sum(side_share) / len(side_share) if side_share else 0,
"stopped_share": stops / n,
"dist": (pct(dist, 10), pct(dist, 50), pct(dist, 90)),
"steer_share": steer / n,
})
return res
def fmt(v):
if v is None:
return "-"
if isinstance(v, tuple):
return " / ".join(fmt(x) for x in v)
if isinstance(v, float):
return "%.2f" % v if abs(v) < 10 else "%.0f" % v
return str(v)
ROWS = [("seconds", "fight time s"), ("hold_ms", "strafe hold ms p10/p50/p90"),
("reversals_per_s", "direction changes /s"), ("ground_speed", "ground speed p50/p90"),
("air_speed", "air speed p50/p90"), ("air_share", "time in air"), ("jumps_per_s", "jumps /s"),
("sideways_share", "sideways (vs toward/away)"), ("stopped_share", "time nearly stopped"),
("dist", "distance p10/p50/p90"), ("steer_share", "plugin steering (bot)")]
def report(human, bot):
lines = ["%-28s %-22s %s" % ("", "humans", "lgbot")]
for k, label in ROWS:
lines.append("%-28s %-22s %s" % (label, fmt(human.get(k)), fmt(bot.get(k))))
return "\n".join(lines)
def main(argv):
path = argv[1] if len(argv) > 1 else "home/lgbot-rec.csv"
with open(path) as f:
sessions = parse(f)
per_player = {}
for s in sessions:
for who, frames in fight_frames(s).items():
per_player.setdefault(who, []).extend(frames) # t restarts each session: a gap
both = by_kind(per_player)
print("%d session(s)" % len(sessions))
print(report(analyze(both["human"]), analyze(both["bot"])))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""LG training bot setup. Runs inside the container (through ./lgbot), like ./rcon.
./lgbot questions: aim 1-5, movement 1-5, optional movement details; Enter keeps [value]
./lgbot --last put the last setup back (e.g. after a server restart)
./lgbot show saved setup and whether the bot is in
./lgbot off remove the bot
Aim is the bot's skill level (addbot lgbot N). Movement is the lgbot_* cvars the lgbot plugin reads.
The last setup is saved in home/lgbot.json.
"""
import json, os, re, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "home", "minqlx-plugins"))
from lgbot import PRESETS, LIMITS, KEYS # noqa: E402 (the plugin's pattern settings)
from qlq import parse_rcon_status # noqa: E402
STATE = os.path.join(HERE, "..", "home", "lgbot.json")
LABELS = {"strafe_min": "Strafe hold min ms", "strafe_max": "Strafe hold max ms",
"fake": "Fake chance %", "range": "Preferred range (LG reach 768)",
"range_play": "Range play: push in when ahead, back off when low y/n",
"speed": "Strafe speed % of run speed",
"lane": "Max sideways drift before turning back (no circling), 0 = off"}
def default_state():
return {"aim": 3, "move": 3, "details": {}}
def load_state(path=STATE):
st = default_state()
try:
with open(path) as f:
raw = json.load(f)
except (OSError, ValueError):
return st
if not isinstance(raw, dict):
return st
for k in ("aim", "move"):
if isinstance(raw.get(k), int) and 1 <= raw[k] <= 5:
st[k] = raw[k]
details = raw.get("details")
if isinstance(details, dict):
for k, v in details.items():
if k in LIMITS and isinstance(v, int) and LIMITS[k][0] <= v <= LIMITS[k][1]:
st["details"][k] = v
return st
def save_state(path, st):
with open(path, "w") as f:
json.dump(st, f, indent=1)
def parse_int(text, lo, hi, default):
t = text.strip()
if not t:
return default
try:
v = int(t)
except ValueError:
return None
return v if lo <= v <= hi else None
def parse_yn(text, default):
t = text.strip().lower()
if not t:
return default
return {"y": True, "yes": True, "n": False, "no": False}.get(t)
def ask(prompt, parse, input_fn=input, print_fn=print):
while True:
v = parse(input_fn(prompt))
if v is not None:
return v
print_fn(" not valid, try again")
def prompt_setup(st, input_fn=input, print_fn=print):
"""Ask everything; Enter keeps the bracketed value. Ctrl-C/EOF propagate (caller cancels)."""
aim = ask("Aim level 1-5 [%d]: " % st["aim"], lambda t: parse_int(t, 1, 5, st["aim"]), input_fn, print_fn)
move = ask("Movement level 1-5 [%d]: " % st["move"], lambda t: parse_int(t, 1, 5, st["move"]), input_fn, print_fn)
had = bool(st["details"])
custom = ask("Customize movement details? %s: " % ("(Y/n)" if had else "(y/N)"),
lambda t: parse_yn(t, had), input_fn, print_fn)
details = {}
if custom:
for k in KEYS:
cur = st["details"].get(k, PRESETS[move][k])
lo, hi = LIMITS[k]
if k == "range_play":
v = int(ask(" %s [%s]: " % (LABELS[k], "y" if cur else "n"),
lambda t, c=cur: parse_yn(t, bool(c)), input_fn, print_fn))
else:
v = ask(" %s %d-%d [%d]: " % (LABELS[k], lo, hi, cur),
lambda t, c=cur, lo=lo, hi=hi: parse_int(t, lo, hi, c), input_fn, print_fn)
if v != PRESETS[move][k]:
details[k] = v
return {"aim": aim, "move": move, "details": details}
def _named_lgbot(status_text):
parsed = parse_rcon_status(status_text)
return [p for p in parsed[1] if p[2].lower() == "lgbot"] if parsed else []
def bot_present(status_text):
return any(p[3] == "bot" for p in _named_lgbot(status_text))
def impostor(status_text):
"""A human calling themselves lgbot: "kick lgbot" would hit them, so don't send it."""
return any(p[3] != "bot" for p in _named_lgbot(status_text))
def build_commands(st, readd, bot_in):
cmds = ["set lgbot_move %d" % st["move"]]
cmds += ["set lgbot_%s %d" % (k, st["details"].get(k, -1)) for k in KEYS]
if readd:
if bot_in:
cmds.append("kick lgbot")
cmds.append("addbot lgbot %d" % st["aim"])
return cmds
def cvar_value(reply, name):
m = re.search(r'"%s" is:"([^"]*)"' % re.escape(name), reply)
return m.group(1) if m else None
def describe(st):
d = ", ".join("%s %s" % (k, v) for k, v in sorted(st["details"].items()))
return "aim %d, movement %d%s" % (st["aim"], st["move"], " (custom: %s)" % d if d else "")
def server():
import qlrcon # needs zmq + the RCON password: only available inside the container
s = qlrcon.connect()
return lambda cmd: qlrcon.run(s, cmd)
def refuse_impostor(status_text):
if impostor(status_text):
print("a player (not a bot) is named lgbot: rename or kick them first, nothing changed")
return True
return False
REFUSED = 3
def apply(st, old_aim):
run = server()
status = run("status")
if refuse_impostor(status):
return REFUSED
bot_in = bot_present(status)
for cmd in build_commands(st, readd=(not bot_in or st["aim"] != old_aim), bot_in=bot_in):
run(cmd)
ok = bot_present(run("status"))
print("lgbot %s: %s" % ("in" if ok else "NOT in (is the server up? map loading?)", describe(st)))
return 0 if ok else 1
def main(argv, input_fn=input):
arg = argv[1] if len(argv) > 1 else ""
st = load_state(STATE)
if arg == "off":
run = server()
if refuse_impostor(run("status")):
return 1
run("kick lgbot")
print("lgbot removed (setup kept, ./lgbot --last brings it back)")
return 0
if arg == "show":
run = server()
print("saved setup: " + describe(st))
live = ["%s %s" % (k, cvar_value(run("lgbot_" + k), "lgbot_" + k)) for k in ["move"] + KEYS]
print("live (-1 = from the movement level): " + ", ".join(live))
print("bot is " + ("in" if bot_present(run("status")) else "not in"))
return 0
if arg == "--last":
return apply(st, old_aim=None) # old_aim None: always re-add so the aim level is right
if arg:
print(__doc__.strip())
return 2
try:
new = prompt_setup(st, input_fn)
except (EOFError, KeyboardInterrupt):
print("\ncancelled, nothing changed")
return 1
rc = apply(new, old_aim=st["aim"])
if rc != REFUSED: # saved even if the bot didn't show up (server restarting): --last retries
save_state(STATE, new)
return rc
if __name__ == "__main__":
sys.exit(main(sys.argv))
+1
View File
@@ -29,6 +29,7 @@ set g_password "$(clean "$QL_PASSWORD")"
set sv_maxClients "$(clean "${QL_MAXCLIENTS:-16}")" set sv_maxClients "$(clean "${QL_MAXCLIENTS:-16}")"
set zmq_rcon_port "$(clean "${QL_RCON_PORT:-28960}")" set zmq_rcon_port "$(clean "${QL_RCON_PORT:-28960}")"
set qlx_welcome "$(clean "$QL_WELCOME")" set qlx_welcome "$(clean "$QL_WELCOME")"
set lgbot_record "$(clean "${QL_RECORD:-0}")"
map $MAP $FACTORY map $MAP $FACTORY
EOF EOF
echo "$MAP|$FACTORY" > $B/mappool_lg.txt echo "$MAP|$FACTORY" > $B/mappool_lg.txt
+399
View File
@@ -0,0 +1,399 @@
import math, os, random, sys, unittest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "home", "minqlx-plugins"))
from lgbot import (PRESETS, KEYS, resolve, Strafer, wanted_range, steer, fix_z, pick_target, to_int, is_bot, is_lgbot, StuckGuard, approach, ACCEL)
def preset(n, **kw):
s = dict(PRESETS[n]); s.update(kw); return s
class Resolve(unittest.TestCase):
def test_preset_only(self):
self.assertEqual(resolve(3, {}), PRESETS[3])
def test_minus_one_and_none_keep_preset(self):
self.assertEqual(resolve(2, {k: -1 for k in KEYS}), PRESETS[2])
self.assertEqual(resolve(2, {"fake": None}), PRESETS[2])
def test_override_and_clamp(self):
s = resolve(1, {"fake": 50, "range": 5000, "speed": 10})
self.assertEqual((s["fake"], s["range"], s["speed"]), (50, 750, 50))
def test_bad_move_level(self):
self.assertEqual(resolve(9, {}), PRESETS[5])
self.assertEqual(resolve(0, {}), PRESETS[1])
self.assertEqual(resolve(None, {}), PRESETS[3])
def test_max_below_min(self):
s = resolve(3, {"strafe_min": 800, "strafe_max": 200})
self.assertEqual((s["strafe_min"], s["strafe_max"]), (800, 800))
class StraferTest(unittest.TestCase):
def changes(self, s, n=2000, seed=1):
st, now, out = Strafer(random.Random(seed)), 0, []
d = st.step(now, s)
while len(out) < n:
now += 5
nd = st.step(now, s)
if nd != d:
out.append((now, nd)); d = nd
return out
def test_holds_within_range_without_fakes(self):
s = preset(3, fake=0, strafe_min=250, strafe_max=600, lane=0)
ch = self.changes(s, 300)
gaps = [b[0] - a[0] for a, b in zip(ch, ch[1:])]
self.assertTrue(all(245 <= g <= 610 for g in gaps), (min(gaps), max(gaps)))
def test_fake_taps_are_short_and_return(self):
s = preset(5, fake=100, strafe_min=300, strafe_max=300, lane=0)
ch = self.changes(s, 200)
gaps = [b[0] - a[0] for a, b in zip(ch, ch[1:])]
# always faking: alternating short tap (60-120) and full hold (300), direction never really changes
short = [g for g in gaps if g <= 125]
self.assertTrue(len(short) >= 90)
self.assertTrue(all(60 <= g <= 125 for g in short))
def test_fake_fraction_matches_setting(self):
s = preset(3, fake=30, strafe_min=300, strafe_max=300, lane=0)
st, now, fakes, decisions = Strafer(random.Random(7)), 0, 0, 0
st.step(0, s)
while decisions < 2000:
now += 5
was = st.faking
before = st.until
st.step(now, s)
if st.until != before and not was:
decisions += 1; fakes += st.faking
self.assertAlmostEqual(fakes / decisions, 0.30, delta=0.04)
def test_lane_stops_circling(self):
# always faking = always drifting the same way; the lane must turn it back
s = preset(5, fake=100, strafe_min=300, strafe_max=300, lane=300, speed=100)
lane_ms = 300 / 320.0 * 1000
st, now, drift = Strafer(random.Random(3)), 0, 0
d = st.step(0, s)
for _ in range(20000):
now += 25
drift += d * 25
self.assertLessEqual(abs(drift), lane_ms + 50)
d = st.step(now, s)
def test_gap_does_not_count_as_drift(self):
# bot dead / out of range for 10 s: afterwards it must still strafe both ways (C1)
s = preset(5)
st, now = Strafer(random.Random(5)), 0
for _ in range(80):
now += 25; st.step(now, s)
now += 10000
dirs = []
for _ in range(400):
now += 25; dirs.append(st.step(now, s))
self.assertLess(max(dirs.count(1), dirs.count(-1)) / 400.0, 0.75)
def test_reset_clears_drift(self):
st = Strafer(random.Random(1))
st.drift, st.last = 5000, 123
st.reset()
self.assertEqual((st.drift, st.last), (0, None))
class Accel(unittest.TestCase):
def test_limits_change_per_frame(self):
v = approach((320, 0), (-320, 0), ACCEL * 0.025)
self.assertAlmostEqual(v[0], 320 - ACCEL * 0.025, places=6)
def test_reaches_target_when_close(self):
self.assertEqual(approach((300, 0), (320, 0), 80), (320, 0))
def test_reversal_takes_human_time(self):
v, frames = (320.0, 0.0), 0
while v[0] > -319:
v = approach(v, (-320, 0), ACCEL * 0.025); frames += 1
self.assertTrue(6 <= frames <= 10, frames) # ~150-250 ms, not one frame
class Range(unittest.TestCase):
def test_plain(self):
self.assertEqual(wanted_range(preset(1), 100, 100), 450)
def test_back_off_when_low(self):
self.assertEqual(wanted_range(preset(5), 50, 20), 700)
def test_push_when_ahead(self):
self.assertEqual(wanted_range(preset(5), 150, 100), 250)
def test_even_fight(self):
self.assertEqual(wanted_range(preset(5), 100, 90), 350)
def test_range_play_off(self):
self.assertEqual(wanted_range(preset(5, range_play=0), 50, 20), 350)
class Steer(unittest.TestCase):
def test_perpendicular_at_wanted_range(self):
vx, vy = steer((0, 0), (400, 0), 1, preset(3), 400)
self.assertAlmostEqual(vx, 0, places=3)
self.assertAlmostEqual(abs(vy), 320, places=3)
def test_direction_flips_side(self):
a = steer((0, 0), (400, 0), 1, preset(3), 400)
b = steer((0, 0), (400, 0), -1, preset(3), 400)
self.assertAlmostEqual(a[1], -b[1], places=3)
def test_speed_cap(self):
for d in (50, 400, 1200):
vx, vy = steer((0, 0), (d, 0), 1, preset(1), 450)
self.assertLessEqual(math.hypot(vx, vy), 320 * 0.8 + 1e-6)
def test_closes_in_when_far(self):
vx, _ = steer((0, 0), (1000, 0), 1, preset(3), 400)
self.assertGreater(vx, 100)
def test_backs_off_when_close(self):
vx, _ = steer((0, 0), (100, 0), 1, preset(3), 400)
self.assertLess(vx, -100)
def test_same_spot(self):
vx, vy = steer((5, 5), (5, 5), 1, preset(3), 400)
self.assertLessEqual(math.hypot(vx, vy), 320 + 1e-6)
class Misc(unittest.TestCase):
def test_fix_z(self):
self.assertEqual(fix_z(275), 0)
self.assertEqual(fix_z(-100), -100)
self.assertEqual(fix_z(40), 40) # slopes/stairs
self.assertEqual(fix_z(800), 800) # jump pads
def test_is_bot(self):
self.assertTrue(is_bot(90071996842377216)) # minqlx bot ids: base + client number
self.assertTrue(is_bot(90071996842377216 + 5))
self.assertTrue(is_bot(0))
self.assertFalse(is_bot(76561198000000000)) # real SteamID64
def test_is_lgbot_needs_bot_id(self):
self.assertTrue(is_lgbot("lgbot", 90071996842377216))
self.assertTrue(is_lgbot("LGBot", 90071996842377217))
self.assertFalse(is_lgbot("lgbot", 76561198000000000)) # a human calling themselves lgbot
self.assertFalse(is_lgbot("hunter", 90071996842377216))
def test_to_int(self):
self.assertEqual(to_int("5"), 5)
self.assertEqual(to_int(" -1 "), -1)
self.assertEqual(to_int("2.7"), 2)
self.assertIsNone(to_int("abc"))
self.assertIsNone(to_int(None))
def test_pick_target(self):
c = [((300, 0, 0), "near"), ((800, 0, 0), "far")]
self.assertEqual(pick_target((0, 0, 0), c), "near")
self.assertIsNone(pick_target((0, 0, 0), [((950, 0, 0), "x")])) # too far to start steering
self.assertIsNone(pick_target((0, 0, 0), []))
def test_pick_target_ignores_other_floors(self):
c = [((200, 0, 300), "above"), ((600, 0, 40), "same floor")]
self.assertEqual(pick_target((0, 0, 0), c), "same floor")
self.assertIsNone(pick_target((0, 0, 0), [((200, 0, -300), "below")]))
def test_keeps_current_target_a_bit_past_the_start_limit(self):
self.assertEqual(pick_target((0, 0, 0), [((950, 0, 0), "a")], current="a"), "a") # hysteresis
self.assertIsNone(pick_target((0, 0, 0), [((1050, 0, 0), "a")], current="a"))
def test_sticky_target(self):
c = [((500, 0, 0), "a"), ((420, 0, 0), "b")]
self.assertEqual(pick_target((0, 0, 0), c, current="a"), "a") # b only 80 closer: stay
c = [((500, 0, 0), "a"), ((300, 0, 0), "b")]
self.assertEqual(pick_target((0, 0, 0), c, current="a"), "b") # clearly closer: switch
self.assertEqual(pick_target((0, 0, 0), [((400, 0, 0), "b")], current="a"), "b") # a gone
class Stuck(unittest.TestCase):
def test_steers_while_moving(self):
g = StuckGuard()
self.assertTrue(all(g.update(t, 320, 300) for t in range(0, 3000, 25)))
def test_lets_go_when_blocked_then_retries(self):
g = StuckGuard()
results = [(t, g.update(t, 320, 50)) for t in range(0, 4000, 25)]
first_off = next(t for t, ok in results if not ok)
self.assertTrue(450 <= first_off <= 550)
back_on = next(t for t, ok in results if t > first_off and ok)
self.assertTrue(1900 <= back_on - first_off + 500 <= 2100) # off for 1.5 s
def test_backs_off_longer_when_it_keeps_failing(self):
g = StuckGuard()
offs, prev = [], True
for t in range(0, 20000, 25):
ok = g.update(t, 320, 50)
if prev and not ok:
offs.append(t)
prev = ok
gaps = [b - a for a, b in zip(offs, offs[1:])]
self.assertGreater(gaps[1], gaps[0]) # 1.5 s, 3 s, 6 s ... between retries
self.assertLessEqual(max(gaps), 6000 + 525)
def test_backoff_resets_after_clean_steering(self):
g = StuckGuard()
for t in range(0, 4000, 25):
g.update(t, 320, 50) # two releases: streak up
for t in range(4000, 12000, 25):
g.update(t, 320, 300) # clean for a while
res = [(t, g.update(t, 320, 50)) for t in range(12000, 16000, 25)]
off = next(t for t, ok in res if not ok)
back = next(t for t, ok in res if t > off and ok)
self.assertEqual(back - off, 1500) # back to a 1.5 s release
def test_short_bump_is_fine(self):
g = StuckGuard()
ok = [g.update(t, 320, 50 if 1000 <= t < 1300 else 300) for t in range(0, 3000, 25)]
self.assertTrue(all(ok))
import json, tempfile
import lgbot_setup as setup
STATUS_WITH_BOT = """map: thunderstruck
num score ping name lastmsg address qport rate steamid
--- ----- ---- --------------- ------- --------------------- ----- ----- -----------------
0 3 0 lgbot 0 bot 0 16384 0
1 7 48 player 0 100.64.1.2:27960 354 25000 76561198000000000
"""
def feeder(*answers):
it = iter(answers)
def inp(prompt=""):
a = next(it)
if isinstance(a, BaseException):
raise a
return a
return inp
class State(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.path = os.path.join(self.dir, "lgbot.json")
def test_missing_file_gives_defaults(self):
self.assertEqual(setup.load_state(self.path), {"aim": 3, "move": 3, "details": {}})
def test_corrupt_file_gives_defaults(self):
with open(self.path, "w") as f:
f.write("{not json")
self.assertEqual(setup.load_state(self.path), {"aim": 3, "move": 3, "details": {}})
def test_bad_values_are_dropped(self):
with open(self.path, "w") as f:
json.dump({"aim": 9, "move": "x", "details": {"fake": 50, "range": 99999, "bogus": 1}}, f)
self.assertEqual(setup.load_state(self.path), {"aim": 3, "move": 3, "details": {"fake": 50}})
def test_round_trip(self):
st = {"aim": 1, "move": 5, "details": {"fake": 60, "range_play": 0}}
setup.save_state(self.path, st)
self.assertEqual(setup.load_state(self.path), st)
class Parsing(unittest.TestCase):
def test_parse_int(self):
self.assertEqual(setup.parse_int("", 1, 5, 3), 3)
self.assertEqual(setup.parse_int(" 4 ", 1, 5, 3), 4)
self.assertIsNone(setup.parse_int("6", 1, 5, 3))
self.assertIsNone(setup.parse_int("abc", 1, 5, 3))
def test_parse_yn(self):
self.assertTrue(setup.parse_yn("", True))
self.assertTrue(setup.parse_yn("Y", False))
self.assertFalse(setup.parse_yn("no", True))
self.assertIsNone(setup.parse_yn("maybe", True))
def test_ask_reasks_until_valid(self):
said = []
v = setup.ask("Aim: ", lambda t: setup.parse_int(t, 1, 5, 3), feeder("x", "7", "2"), said.append)
self.assertEqual(v, 2)
self.assertEqual(len(said), 2)
def test_human_named_lgbot_is_not_the_bot(self):
status = STATUS_WITH_BOT.replace("lgbot 0 bot ", "lgbot 0 1.2.3.4:5")
self.assertFalse(setup.bot_present(status))
self.assertTrue(setup.impostor(status))
self.assertFalse(setup.impostor(STATUS_WITH_BOT))
def test_cvar_value(self):
reply = 'zmq RCON command from x: lgbot_fake' + chr(10) + '"lgbot_fake" is:"60" default:"-1"'
self.assertEqual(setup.cvar_value(reply, "lgbot_fake"), "60")
self.assertIsNone(setup.cvar_value("(no output: ok, or server down)", "lgbot_fake"))
def test_bot_present(self):
self.assertTrue(setup.bot_present(STATUS_WITH_BOT))
self.assertFalse(setup.bot_present(STATUS_WITH_BOT.replace("lgbot ", "Hunter")))
self.assertFalse(setup.bot_present("(no output)"))
class Main(unittest.TestCase):
def test_refused_run_keeps_saved_setup(self):
path = os.path.join(tempfile.mkdtemp(), "lgbot.json")
impostor_status = STATUS_WITH_BOT.replace("lgbot 0 bot ", "lgbot 0 1.2.3.4:5")
sent = []
def run(cmd):
sent.append(cmd)
return impostor_status if cmd == "status" else ""
old_state, old_server = setup.STATE, setup.server
setup.STATE, setup.server = path, (lambda: run)
try:
rc = setup.main(["lgbot"], feeder("1", "5", "n"))
finally:
setup.STATE, setup.server = old_state, old_server
self.assertNotEqual(rc, 0)
self.assertFalse(os.path.exists(path))
self.assertEqual(sent, ["status"]) # no kick, no set
class Commands(unittest.TestCase):
def test_no_details_resets_to_minus_one(self):
cmds = setup.build_commands({"aim": 2, "move": 4, "details": {}}, readd=False, bot_in=True)
self.assertEqual(cmds[0], "set lgbot_move 4")
self.assertIn("set lgbot_fake -1", cmds)
self.assertEqual(len(cmds), 1 + len(KEYS))
def test_details_and_readd(self):
cmds = setup.build_commands({"aim": 1, "move": 5, "details": {"fake": 50}}, readd=True, bot_in=True)
self.assertIn("set lgbot_fake 50", cmds)
self.assertEqual(cmds[-2:], ["kick lgbot", "addbot lgbot 1"])
def test_readd_without_bot_does_not_kick(self):
cmds = setup.build_commands({"aim": 4, "move": 3, "details": {}}, readd=True, bot_in=False)
self.assertNotIn("kick lgbot", cmds)
self.assertEqual(cmds[-1], "addbot lgbot 4")
class Prompts(unittest.TestCase):
def test_enter_keeps_everything(self):
old = {"aim": 2, "move": 4, "details": {"fake": 70}}
new = setup.prompt_setup(old, feeder("", "", "", "", "", "", "", "", "", ""), lambda *_: None)
self.assertEqual(new, old)
def test_poor_aim_best_movement_custom(self):
new = setup.prompt_setup({"aim": 3, "move": 3, "details": {}},
feeder("1", "5", "y", "", "300", "60", "", "n", "90", "250"), lambda *_: None)
self.assertEqual(new, {"aim": 1, "move": 5,
"details": {"strafe_max": 300, "fake": 60, "range_play": 0, "speed": 90, "lane": 250}})
# only values that differ from the movement preset are stored as overrides
def test_no_details_clears_them(self):
new = setup.prompt_setup({"aim": 3, "move": 3, "details": {"fake": 70}},
feeder("", "", "n"), lambda *_: None)
self.assertEqual(new["details"], {})
def test_ctrl_c_propagates(self):
with self.assertRaises(KeyboardInterrupt):
setup.prompt_setup({"aim": 3, "move": 3, "details": {}}, feeder("1", KeyboardInterrupt()), lambda *_: None)
if __name__ == "__main__":
unittest.main()
+208
View File
@@ -0,0 +1,208 @@
"""The lgbot plugin's frame logic (tick) against a fake minqlx: deaths, gaps, targets, acceleration."""
import importlib.util, logging, math, os, sys, types, unittest
PLUGIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "home", "minqlx-plugins", "lgbot.py")
BOT_ID = 90071996842377216
HUMAN_ID = 76561198000000000
class V:
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
class FakePlayer:
def __init__(self, cid, name, steam_id, pos):
self.id, self.clean_name, self.steam_id = cid, name, steam_id
self.team, self.is_alive, self.health = "free", True, 100
self.pos, self.vel = list(pos), [0.0, 0.0, 0.0]
self.sets = []
def position(self):
return V(*self.pos)
def velocity(self, **kw):
if kw:
self.vel = [kw.get("x", self.vel[0]), kw.get("y", self.vel[1]), kw.get("z", self.vel[2])]
self.sets.append(tuple(self.vel))
return True
return V(*self.vel)
class World:
"""Stands in for the server: players, cvars, a clock; moves players by their velocity."""
def __init__(self):
self.players, self.cvars, self.now = [], {}, 1000.0
def frame(self, plugin, dt=0.025):
self.now += dt
plugin.handle_frame()
for p in self.players:
p.pos[0] += p.vel[0] * 0.85 * dt # friction eats ~15%, like the measured server
p.pos[1] += p.vel[1] * 0.85 * dt
p.vel[0] *= 0.85
p.vel[1] *= 0.85
def load(world):
fake = types.ModuleType("minqlx")
class Plugin:
def __init__(self):
self.logger = logging.getLogger("lgbot-test")
def set_cvar_once(self, name, value):
world.cvars.setdefault(name, value)
def get_cvar(self, name):
return world.cvars.get(name)
def set_cvar(self, name, value):
world.cvars[name] = value
def add_hook(self, event, fn):
pass
def players(self):
return list(world.players)
def log_exception(plugin=None):
raise # surface plugin errors in the test
fake.Plugin, fake.log_exception = Plugin, log_exception
sys.modules["minqlx"] = fake
try:
spec = importlib.util.spec_from_file_location("lgbot_under_test", PLUGIN)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
finally:
del sys.modules["minqlx"]
mod.time = types.SimpleNamespace(time=lambda: world.now)
plugin = mod.lgbot()
plugin.strafer.rng.seed(11) if hasattr(plugin.strafer.rng, "seed") else None
return mod, plugin
def sideways_sign(bot, human):
# sign of the bot's velocity across the bot->human line
dx, dy = human.pos[0] - bot.pos[0], human.pos[1] - bot.pos[1]
return 1 if (-dy * bot.vel[0] + dx * bot.vel[1]) > 0 else -1
class Tick(unittest.TestCase):
def setUp(self):
self.w = World()
self.bot = FakePlayer(0, "lgbot", BOT_ID, (0, 0, 0))
self.human = FakePlayer(1, "player", HUMAN_ID, (450, 0, 0))
self.w.players = [self.bot, self.human]
self.w.cvars["lgbot_move"] = "5"
self.mod, self.plugin = load(self.w)
def run_frames(self, n):
for _ in range(n):
self.w.frame(self.plugin)
def test_steers_when_human_in_range(self):
self.run_frames(40)
self.assertTrue(self.bot.sets)
def test_human_named_lgbot_is_left_alone(self):
self.bot.steam_id = HUMAN_ID
self.run_frames(40)
self.assertEqual(self.bot.sets, [])
def test_strafes_both_ways_after_respawn(self):
self.run_frames(80)
self.bot.is_alive = False
self.run_frames(400) # 10 s dead
self.bot.is_alive = True
self.bot.pos = [0, 0, 0]
signs = []
for _ in range(400):
self.w.frame(self.plugin)
signs.append(sideways_sign(self.bot, self.human))
self.assertLess(max(signs.count(1), signs.count(-1)) / 400.0, 0.75)
def test_strafes_both_ways_after_walking_in(self):
self.human.pos = [3000, 0, 0] # far: AI walks
self.run_frames(400)
self.human.pos = [450, 0, 0]
signs = []
for _ in range(400):
self.w.frame(self.plugin)
signs.append(sideways_sign(self.bot, self.human))
self.assertLess(max(signs.count(1), signs.count(-1)) / 400.0, 0.75)
def test_velocity_changes_like_a_player_not_instantly(self):
self.run_frames(400)
sets = self.bot.sets
comp = self.mod.FRICTION_COMP
biggest = max(math.hypot(b[0] - a[0] * 0.85, b[1] - a[1] * 0.85) for a, b in zip(sets, sets[1:]))
# per frame: at most the acceleration step (plus friction compensation), never a 640 u/s flip
self.assertLess(biggest, self.mod.ACCEL * 0.025 * comp + 0.15 * 320 * comp + 1)
def test_recorder_writes_rows_without_names(self):
import tempfile
path = os.path.join(tempfile.mkdtemp(), "rec.csv")
self.mod.REC_FILE = path
self.w.cvars["lgbot_record"] = "1"
self.run_frames(80)
self.w.cvars["lgbot_record"] = "0"
self.w.now += 1.1 # cvars are reread once a second
self.run_frames(2)
with open(path) as f:
text = f.read()
rows = [l.split(",") for l in text.splitlines() if not l.startswith("#")]
self.assertGreater(len(rows), 100)
self.assertEqual({row[1] for row in rows}, {"bot", "h1"})
self.assertIn("steer", {row[10] for row in rows})
self.assertNotIn(str(HUMAN_ID), text)
self.assertIsNone(self.plugin.rec)
def test_recorder_stops_itself(self):
import tempfile
self.mod.REC_FILE = os.path.join(tempfile.mkdtemp(), "rec.csv")
self.mod.REC_MAX_S = 1
self.w.cvars["lgbot_record"] = "1"
self.run_frames(80)
self.assertEqual(self.w.cvars["lgbot_record"], "0")
self.assertIsNone(self.plugin.rec)
def test_auto_records_only_two_humans_fighting(self):
import tempfile
path = os.path.join(tempfile.mkdtemp(), "rec.csv")
self.mod.REC_FILE = path
self.w.cvars["lgbot_record"] = "2"
self.run_frames(40) # one human vs the bot: not recorded
self.assertFalse(os.path.exists(path))
h2 = FakePlayer(2, "other", HUMAN_ID + 1, (450, 300, 0))
self.w.players.append(h2)
self.run_frames(40)
h2.pos = [5000, 0, 0] # walks away: stops writing
self.run_frames(5)
with open(path) as f:
before = f.read()
self.run_frames(40)
with open(path) as f:
after = f.read()
self.assertEqual(before, after)
self.assertIn(",h2,", before)
self.assertNotIn(str(HUMAN_ID), before)
self.plugin.stop_recording()
def test_humans_fighting(self):
f = self.mod.humans_fighting
self.assertTrue(f([(0, 0, 0), (900, 0, 0)]))
self.assertFalse(f([(0, 0, 0), (1200, 0, 0)]))
self.assertFalse(f([(0, 0, 0), (100, 0, 400)]))
self.assertFalse(f([(0, 0, 0)]))
def test_no_target_means_no_steering(self):
self.human.team = "spectator"
self.run_frames(40)
self.assertEqual(self.bot.sets, [])
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -0,0 +1,91 @@
"""tools/lgbot_rec.py: parsing a recording and the movement stats."""
import os, sys, unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import lgbot_rec as r # noqa: E402
def rec(seconds, human_fn, bot_fn, dt=0.025):
"""Recording lines: human_fn/bot_fn(t) -> (x, y, z, vx, vy, vz)."""
lines = ["# start 0 map test"]
t = 0.0
while t < seconds:
for who, fn, state in (("h1", human_fn, ""), ("bot", bot_fn, "steer")):
x, y, z, vx, vy, vz = fn(t)
lines.append("%.3f,%s,1,100,%.0f,%.0f,%.0f,%.0f,%.0f,%.0f,%s" % (t, who, x, y, z, vx, vy, vz, state))
t += dt
return lines
def adad(hold_s):
# sideways (y) at 320 u/s, switching every hold_s, standing 450 in front of the other on x
def fn(t):
d = 1 if int(t / hold_s) % 2 == 0 else -1
return (0, 0, 0, 0, 320 * d, 0)
return fn
def still_at(x):
return lambda t: (x, 0, 0, 0, 0, 0)
class Stats(unittest.TestCase):
def test_strafe_holds_and_sideways(self):
s = r.parse(rec(10, adad(0.5), still_at(450)))
st = r.analyze(r.by_kind(r.fight_frames(s[0]))["human"])
self.assertAlmostEqual(st["hold_ms"][1], 500, delta=30)
self.assertGreater(st["sideways_share"], 0.95)
self.assertEqual(st["air_share"], 0)
self.assertAlmostEqual(st["ground_speed"][1], 320, delta=1)
self.assertAlmostEqual(st["seconds"], 10, delta=0.1)
def test_bot_steer_share_and_stopped(self):
s = r.parse(rec(5, adad(0.5), still_at(450)))
st = r.analyze(r.by_kind(r.fight_frames(s[0]))["bot"])
self.assertEqual(st["steer_share"], 1)
self.assertEqual(st["stopped_share"], 1)
def test_jumps_counted(self):
def hop(t): # one jump a second: 0.7 s in the air, then ground
ph = t % 1.0
return (0, 0, 0, 0, 320, 270 - 800 * ph if ph < 0.7 else 0)
st = r.analyze(r.by_kind(r.fight_frames(r.parse(rec(10, hop, still_at(450)))[0]))["human"])
self.assertAlmostEqual(st["jumps_per_s"], 1.0, delta=0.15)
self.assertAlmostEqual(st["air_share"], 0.7, delta=0.05)
def test_far_or_other_floor_is_not_fight_time(self):
far = r.fight_frames(r.parse(rec(2, adad(0.5), still_at(1500)))[0])
up = r.fight_frames(r.parse(rec(2, adad(0.5), lambda t: (450, 0, 400, 0, 0, 0)))[0])
self.assertEqual(far, {})
self.assertEqual(up, {})
def test_sessions_do_not_join(self):
lines = rec(2, adad(0.5), still_at(450)) + rec(2, adad(0.5), still_at(450))
sessions = r.parse(lines)
self.assertEqual(len(sessions), 2)
frames = r.fight_frames(sessions[0])["h1"] + r.fight_frames(sessions[1])["h1"]
self.assertAlmostEqual(r.analyze(frames)["seconds"], 4, delta=0.1)
def test_two_humans_kept_apart(self):
# h1 strafes with 0.5 s holds, h2 with 0.25 s holds, no bot: each is measured on its own
lines = ["# start 0 map test"]
for i in range(400):
t = i * 0.025
for who, hold, x in (("h1", 0.5, 0), ("h2", 0.25, 450)):
d = 1 if int(t / hold) % 2 == 0 else -1
lines.append("%.3f,%s,1,100,%d,0,0,0,%d,0," % (t, who, x, 320 * d))
per = r.fight_frames(r.parse(lines)[0])
self.assertEqual(sorted(per), ["h1", "h2"])
self.assertAlmostEqual(r.analyze(per["h1"])["hold_ms"][1], 500, delta=30)
self.assertAlmostEqual(r.analyze(per["h2"])["hold_ms"][1], 250, delta=30)
both = r.by_kind(per)["human"]
self.assertAlmostEqual(r.analyze(both)["seconds"], 20, delta=0.2)
self.assertEqual(r.analyze(r.by_kind(per)["bot"])["frames"], 0)
def test_report_prints(self):
st = r.analyze(r.fight_frames(r.parse(rec(2, adad(0.5), still_at(450)))[0])["h1"])
self.assertIn("strafe hold", r.report(st, r.analyze([])))
if __name__ == "__main__":
unittest.main()