Compare commits
18
Commits
bc5bd09c15
...
266cc20b76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266cc20b76 | ||
|
|
acf625d72b | ||
|
|
3535940331 | ||
|
|
e1c64bed4c | ||
|
|
86754715f5 | ||
|
|
a998614224 | ||
|
|
f494223305 | ||
|
|
e7812902a1 | ||
|
|
aa457014e6 | ||
|
|
696d2f2dad | ||
|
|
dc8409f872 | ||
|
|
aac104206b | ||
|
|
d2fb4be349 | ||
|
|
c6a495ce2c | ||
|
|
8989780cd2 | ||
|
|
fda3a795bb | ||
|
|
062ea6468a | ||
|
|
59be15ca2b |
No files matched your search
+6
-2
@@ -14,15 +14,19 @@ QL_PASSWORD=
|
||||
|
||||
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")
|
||||
# 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_MAP=thunderstruck
|
||||
|
||||
# Shown to every player when they join. Empty = no message.
|
||||
QL_WELCOME=^7Welcome! LG only, vampiric, no knockback. Pair up and duel, there is no match to ready up for.
|
||||
|
||||
# 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.
|
||||
QL_PORT=27960
|
||||
QL_RCON_PORT=28960
|
||||
|
||||
@@ -13,3 +13,6 @@ home/baseq3/*
|
||||
!home/baseq3/scripts/
|
||||
home/minqlx.log*
|
||||
__pycache__/
|
||||
!home/baseq3/botfiles/
|
||||
home/lgbot.json
|
||||
home/lgbot-rec.csv
|
||||
@@ -1,93 +1,102 @@
|
||||
# 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.
|
||||
- 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)).
|
||||
It also has an optional training bot (`lgbot`) that only uses the lightning gun and strafes on the ground instead of jumping around.
|
||||
|
||||
## 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
|
||||
git clone <this repo> ql-lg-server
|
||||
git clone https://git.userevolt.app/esef/ql-lg-server.git
|
||||
cd ql-lg-server
|
||||
./setup.sh # asks: server name, listed or not, join password, port
|
||||
./scripts/install-cron.sh # optional: watchdog, nightly backup, daily restart
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
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 | |
|
||||
|---|---|---|
|
||||
| `QL_HOSTNAME` | `LG Vampiric FFA` | name in the browser (`^1`..`^7` colors work) |
|
||||
| `QL_LISTED` | `yes` | `no` = unlisted, join by address only |
|
||||
| `QL_PASSWORD` | empty | join password |
|
||||
| `QL_MAXCLIENTS` | `16` | |
|
||||
| `QL_FACTORY` | `lgvamp` | `lgvamp`, `lgvamp_nolag` or `lgvamp100`, see below |
|
||||
| `QL_MAP` | `thunderstruck` | stock map; workshop maps need extra setup |
|
||||
| `QL_WELCOME` | short rules text | sent to each player on join; empty = off |
|
||||
| `QL_PORT` / `QL_RCON_PORT` | `27960` / `28960` | game UDP / RCON TCP (127.0.0.1 only) |
|
||||
| `QL_NAME` | `ql-lg` | Docker container name |
|
||||
- Same machine: `/connect 127.0.0.1:27960`
|
||||
- Same network: the machine's LAN address, e.g. `/connect 192.168.1.20:27960`
|
||||
- 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.
|
||||
|
||||
### 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 |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
## Everyday commands
|
||||
|
||||
Shared values: `g_startingWeapons 33` (gauntlet + LG), infinite ammo, `g_knockback 0`, no items, no `/kill`, no votes, instant weapon switch, `dmflags 28` (no self/fall damage), permanent warmup (`sv_warmupReadyPercentage 2`). Every value and why: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
|
||||
|
||||
## Running it
|
||||
Run these from the repo folder.
|
||||
|
||||
```sh
|
||||
./rcon status # who's on (./rcon alone = interactive console)
|
||||
./rcon addbot hunter 5 # a target to shoot at; ./rcon kick allbots
|
||||
./rcon status # who is on
|
||||
./rcon say hello
|
||||
python3 tools/qlq.py 127.0.0.1 27960 --status
|
||||
docker compose restart # restart; docker compose stop / start
|
||||
./rcon addbot hunter 5 # a normal bot to shoot at; ./rcon kick allbots removes bots
|
||||
./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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
All per-server settings live in `.env`. After changing it, run `docker compose up -d`.
|
||||
|
||||
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
|
||||
|
||||
- [minqlx](https://github.com/MinoMino/minqlx) (GPL-3.0) by MinoMino: built from source by setup.sh, not included here.
|
||||
- [x76.gg](https://x76.gg/) for the LG duel server format this copies (FFA in permanent warmup, vampiric, no knockback).
|
||||
- [SEA LG Duels server](https://github.com/minhlong124/quake-lg-sea-server): the vampiric LG preset this started from.
|
||||
- Quake Live is © id Software / Bethesda. The dedicated server is downloaded from Steam (app 349090); no game files are in this repo.
|
||||
- [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.
|
||||
- [SEA LG Duels server](https://github.com/minhlong124/quake-lg-sea-server), the preset this started from.
|
||||
- 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.
|
||||
@@ -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.
|
||||
|
||||
## 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`
|
||||
|
||||
One line per player: their 64-bit Steam ID, a `|`, and `admin`, `mod` or `ban`:
|
||||
|
||||
+34
-3
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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=''`.
|
||||
|
||||
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
|
||||
|
||||
`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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- SteamCMD needs `+@sSteamCmdForcePlatformType linux`, or it fails with "Missing configuration".
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ Your server admin gives you the address. If the server is listed, it also shows
|
||||
## How it works
|
||||
|
||||
- You spawn with the **lightning gun** (and gauntlet), infinite ammo and 200 HP (100 HP on `lgvamp100` servers).
|
||||
- **Vampiric:** every point of damage you deal heals you by the same amount. Health above 100 slowly decays (~1 HP per second).
|
||||
- **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.**
|
||||
- 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.
|
||||
@@ -38,4 +38,4 @@ These are client-side; type them in the console, or put them in `baseq3/autoexec
|
||||
## Practice tips
|
||||
|
||||
- The [x76 strafe-aim guide](https://x76.gg/wiki/strafe-aim-guide/) covers the technique LG duels are about: keep the mouse steady, track by strafing with the keyboard and correct small amounts with the mouse.
|
||||
- Practice alone: ask an admin for a bot (`addbot hunter 5`). Bots jump around a lot, which makes them a fair tracking target, but they don't fight well in this mode.
|
||||
- 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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
name lgbot
|
||||
model hunter
|
||||
aifile bots/lgbot_c.c
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
"cvars": {
|
||||
"g_startingWeapons": "33",
|
||||
"g_infiniteAmmo": "1",
|
||||
"g_startingHealth": "100",
|
||||
"g_startingHealthBonus": "100",
|
||||
"g_startingHealth": "200",
|
||||
"g_startingHealthBonus": "0",
|
||||
"g_startingArmor": "0",
|
||||
"g_vampiricDamage": "1",
|
||||
"g_knockback": "0",
|
||||
@@ -57,8 +57,8 @@
|
||||
"cvars": {
|
||||
"g_startingWeapons": "33",
|
||||
"g_infiniteAmmo": "1",
|
||||
"g_startingHealth": "100",
|
||||
"g_startingHealthBonus": "100",
|
||||
"g_startingHealth": "200",
|
||||
"g_startingHealthBonus": "0",
|
||||
"g_startingArmor": "0",
|
||||
"g_vampiricDamage": "1",
|
||||
"g_knockback": "0",
|
||||
|
||||
@@ -23,7 +23,7 @@ set zmq_rcon_ip "127.0.0.1"
|
||||
set zmq_stats_enable "0"
|
||||
|
||||
// minqlx: only our plugins (the default minqlx plugin list needs a redis server)
|
||||
set qlx_plugins "nowarmuptext,welcome"
|
||||
set qlx_plugins "nowarmuptext,welcome,lgbot"
|
||||
set qlx_pluginsPath "/ql/home/minqlx-plugins"
|
||||
|
||||
exec secrets.cfg
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -8,7 +8,7 @@ umask 077
|
||||
mkdir -p backups
|
||||
chmod 700 backups
|
||||
tar czf backups/ql-$(date +%F).tar.gz .env secrets home/baseq3/access.txt \
|
||||
home/baseq3/server.cfg home/baseq3/scripts home/minqlx-plugins 2>/dev/null || true
|
||||
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
|
||||
for f in home/baseq3/*.log home/minqlx.log; do
|
||||
if [ -f "$f" ] && [ "$(stat -c %s "$f")" -gt 52428800 ]; then : > "$f"; fi
|
||||
|
||||
@@ -70,7 +70,7 @@ if [ ! -s secrets/rcon-password.txt ]; then
|
||||
else
|
||||
echo "keeping secrets/rcon-password.txt"
|
||||
fi
|
||||
chmod +x rcon tools/*.sh scripts/*.sh
|
||||
chmod +x rcon lgbot tools/*.sh scripts/*.sh
|
||||
|
||||
say "Building the Docker image"
|
||||
docker compose build -q
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -29,6 +29,7 @@ set g_password "$(clean "$QL_PASSWORD")"
|
||||
set sv_maxClients "$(clean "${QL_MAXCLIENTS:-16}")"
|
||||
set zmq_rcon_port "$(clean "${QL_RCON_PORT:-28960}")"
|
||||
set qlx_welcome "$(clean "$QL_WELCOME")"
|
||||
set lgbot_record "$(clean "${QL_RECORD:-0}")"
|
||||
map $MAP $FACTORY
|
||||
EOF
|
||||
echo "$MAP|$FACTORY" > $B/mappool_lg.txt
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user