lgbot: lane setting stops it circling you (max sideways drift before turning back)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-15 14:48:30 +03:00
1 parent 696d2f2dad
commit aa457014e6
5 files changed
+55 -24

No files matched your search

+10 -8
View File
@@ -48,15 +48,17 @@ Enter keeps the value in brackets; anything invalid asks again; Ctrl+C changes n
Movement levels (the details override one part, `-1` = use the level's value): 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 | | Level | Strafe hold (ms) | Fake chance | Range | Push in / back off | Speed | Lane |
|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| 1 | 600–1000 | 0% | 450 | no | 80% | | 1 | 600–1000 | 0% | 450 | no | 80% | 600 |
| 2 | 400–800 | 10% | 450 | no | 90% | | 2 | 400–800 | 10% | 450 | no | 90% | 500 |
| 3 | 250–600 | 25% | 400 | yes | 100% | | 3 | 250–600 | 25% | 400 | yes | 100% | 400 |
| 4 | 180–450 | 35% | 380 | yes | 100% | | 4 | 180–450 | 35% | 380 | yes | 100% | 350 |
| 5 | 120–350 | 50% | 350 | yes | 100% | | 5 | 120–350 | 50% | 350 | yes | 100% | 300 |
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`. The plugin rereads them every second. `set lgbot_debug 1` writes one line a second about its movement to `home/minqlx.log`. Range is the distance it tries to keep (LG reaches 768). Lane is how far it may drift sideways before it turns back, so it does ADAD in front of you instead of circling (0 = no limit). 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`.
The bot fights the nearest human; with nobody nearby it roams like a normal bot. One lgbot at a time. The bot fights the nearest human; with nobody nearby it roams like a normal bot. One lgbot at a time.
+1 -1
View File
@@ -23,7 +23,7 @@ In warmup the client draws "The match will begin when more players join" / "Pres
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). 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. Ground friction removes about a quarter of the set speed before the move happens, so on the ground it sets 1.3× (measured: the bot then really moves at the set speed). 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. Measured on a test server, the plugin costs no extra CPU you could measure. 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 about a quarter of the set speed before the move happens, so on the ground it sets 1.3× (measured: the bot then really moves at the set speed). 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. 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 gives bots Steam IDs from 90071996842377216 up. The plugin uses that to pick human targets only.
+23 -8
View File
@@ -14,15 +14,15 @@ RUN_SPEED = 320 # QL ground run speed, units/s
# In the air there is no friction, so no boost (vertical speed 0 = on the ground, near enough). # In the air there is no friction, so no boost (vertical speed 0 = on the ground, near enough).
FRICTION_COMP = 1.3 FRICTION_COMP = 1.3
KEYS = ["strafe_min", "strafe_max", "fake", "range", "range_play", "speed"] KEYS = ["strafe_min", "strafe_max", "fake", "range", "range_play", "speed", "lane"]
LIMITS = {"strafe_min": (50, 2000), "strafe_max": (50, 2000), "fake": (0, 100), LIMITS = {"strafe_min": (50, 2000), "strafe_max": (50, 2000), "fake": (0, 100),
"range": (150, 750), "range_play": (0, 1), "speed": (50, 100)} "range": (150, 750), "range_play": (0, 1), "speed": (50, 100), "lane": (0, 2000)}
PRESETS = { PRESETS = {
1: {"strafe_min": 600, "strafe_max": 1000, "fake": 0, "range": 450, "range_play": 0, "speed": 80}, 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}, 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}, 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}, 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}, 5: {"strafe_min": 120, "strafe_max": 350, "fake": 50, "range": 350, "range_play": 1, "speed": 100, "lane": 300},
} }
PUSH_RANGE, BACKOFF_RANGE = 250, 700 PUSH_RANGE, BACKOFF_RANGE = 250, 700
BACKOFF_HEALTH, PUSH_LEAD = 60, 30 BACKOFF_HEALTH, PUSH_LEAD = 60, 30
@@ -64,15 +64,30 @@ def resolve(move, overrides):
class Strafer: class Strafer:
"""Strafe direction over time: holds one side for strafe_min..strafe_max ms, then switches. """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.""" 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): def __init__(self, rng=random):
self.rng = rng self.rng = rng
self.direction = 1 self.direction = 1
self.until = 0 self.until = 0
self.faking = False self.faking = False
self.drift = 0 # signed ms of strafing, + = direction 1
self.last = None
def step(self, now_ms, s): def step(self, now_ms, s):
if self.last is not None:
self.drift += self.direction * (now_ms - self.last)
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: if now_ms >= self.until:
self.direction = -self.direction self.direction = -self.direction
if self.faking: if self.faking:
+2 -1
View File
@@ -18,7 +18,8 @@ STATE = os.path.join(HERE, "..", "home", "lgbot.json")
LABELS = {"strafe_min": "Strafe hold min ms", "strafe_max": "Strafe hold max ms", LABELS = {"strafe_min": "Strafe hold min ms", "strafe_max": "Strafe hold max ms",
"fake": "Fake chance %", "range": "Preferred range (LG reach 768)", "fake": "Fake chance %", "range": "Preferred range (LG reach 768)",
"range_play": "Range play: push in when ahead, back off when low y/n", "range_play": "Range play: push in when ahead, back off when low y/n",
"speed": "Strafe speed % of run speed"} "speed": "Strafe speed % of run speed",
"lane": "Max sideways drift before turning back (no circling), 0 = off"}
def default_state(): def default_state():
+19 -6
View File
@@ -42,13 +42,13 @@ class StraferTest(unittest.TestCase):
return out return out
def test_holds_within_range_without_fakes(self): def test_holds_within_range_without_fakes(self):
s = preset(3, fake=0, strafe_min=250, strafe_max=600) s = preset(3, fake=0, strafe_min=250, strafe_max=600, lane=0)
ch = self.changes(s, 300) ch = self.changes(s, 300)
gaps = [b[0] - a[0] for a, b in zip(ch, ch[1:])] 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))) self.assertTrue(all(245 <= g <= 610 for g in gaps), (min(gaps), max(gaps)))
def test_fake_taps_are_short_and_return(self): def test_fake_taps_are_short_and_return(self):
s = preset(5, fake=100, strafe_min=300, strafe_max=300) s = preset(5, fake=100, strafe_min=300, strafe_max=300, lane=0)
ch = self.changes(s, 200) ch = self.changes(s, 200)
gaps = [b[0] - a[0] for a, b in zip(ch, ch[1:])] 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 # always faking: alternating short tap (60-120) and full hold (300), direction never really changes
@@ -57,7 +57,7 @@ class StraferTest(unittest.TestCase):
self.assertTrue(all(60 <= g <= 125 for g in short)) self.assertTrue(all(60 <= g <= 125 for g in short))
def test_fake_fraction_matches_setting(self): def test_fake_fraction_matches_setting(self):
s = preset(3, fake=30, strafe_min=300, strafe_max=300) 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, now, fakes, decisions = Strafer(random.Random(7)), 0, 0, 0
st.step(0, s) st.step(0, s)
while decisions < 2000: while decisions < 2000:
@@ -70,6 +70,19 @@ class StraferTest(unittest.TestCase):
self.assertAlmostEqual(fakes / decisions, 0.30, delta=0.04) 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)
class Range(unittest.TestCase): class Range(unittest.TestCase):
def test_plain(self): def test_plain(self):
self.assertEqual(wanted_range(preset(1), 100, 100), 450) self.assertEqual(wanted_range(preset(1), 100, 100), 450)
@@ -251,14 +264,14 @@ class Commands(unittest.TestCase):
class Prompts(unittest.TestCase): class Prompts(unittest.TestCase):
def test_enter_keeps_everything(self): def test_enter_keeps_everything(self):
old = {"aim": 2, "move": 4, "details": {"fake": 70}} old = {"aim": 2, "move": 4, "details": {"fake": 70}}
new = setup.prompt_setup(old, feeder("", "", "", "", "", "", "", "", ""), lambda *_: None) new = setup.prompt_setup(old, feeder("", "", "", "", "", "", "", "", "", ""), lambda *_: None)
self.assertEqual(new, old) self.assertEqual(new, old)
def test_poor_aim_best_movement_custom(self): def test_poor_aim_best_movement_custom(self):
new = setup.prompt_setup({"aim": 3, "move": 3, "details": {}}, new = setup.prompt_setup({"aim": 3, "move": 3, "details": {}},
feeder("1", "5", "y", "", "300", "60", "", "n", "90"), lambda *_: None) feeder("1", "5", "y", "", "300", "60", "", "n", "90", "250"), lambda *_: None)
self.assertEqual(new, {"aim": 1, "move": 5, self.assertEqual(new, {"aim": 1, "move": 5,
"details": {"strafe_max": 300, "fake": 60, "range_play": 0, "speed": 90}}) "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 # only values that differ from the movement preset are stored as overrides
def test_no_details_clears_them(self): def test_no_details_clears_them(self):