lgbot: movement plugin (strafe pattern, range control, no jumps)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
bc5bd09c15
commit
59be15ca2b
3 files changed
+320
-1
No files matched your search
@@ -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,178 @@
|
||||
# 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 random
|
||||
import time
|
||||
|
||||
RUN_SPEED = 320 # QL ground run speed, units/s
|
||||
|
||||
KEYS = ["strafe_min", "strafe_max", "fake", "range", "range_play", "speed"]
|
||||
LIMITS = {"strafe_min": (50, 2000), "strafe_max": (50, 2000), "fake": (0, 100),
|
||||
"range": (150, 750), "range_play": (0, 1), "speed": (50, 100)}
|
||||
PRESETS = {
|
||||
1: {"strafe_min": 600, "strafe_max": 1000, "fake": 0, "range": 450, "range_play": 0, "speed": 80},
|
||||
2: {"strafe_min": 400, "strafe_max": 800, "fake": 10, "range": 450, "range_play": 0, "speed": 90},
|
||||
3: {"strafe_min": 250, "strafe_max": 600, "fake": 25, "range": 400, "range_play": 1, "speed": 100},
|
||||
4: {"strafe_min": 180, "strafe_max": 450, "fake": 35, "range": 380, "range_play": 1, "speed": 100},
|
||||
5: {"strafe_min": 120, "strafe_max": 350, "fake": 50, "range": 350, "range_play": 1, "speed": 100},
|
||||
}
|
||||
PUSH_RANGE, BACKOFF_RANGE = 250, 700
|
||||
BACKOFF_HEALTH, PUSH_LEAD = 60, 30
|
||||
MAX_TARGET_DIST = 1500
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
def __init__(self, rng=random):
|
||||
self.rng = rng
|
||||
self.direction = 1
|
||||
self.until = 0
|
||||
self.faking = False
|
||||
|
||||
def step(self, now_ms, s):
|
||||
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 pick_target(bot_xy, candidates, max_dist=MAX_TARGET_DIST):
|
||||
best, best_d = None, max_dist
|
||||
for xy, obj in candidates:
|
||||
d = math.hypot(xy[0] - bot_xy[0], xy[1] - bot_xy[1])
|
||||
if d <= best_d:
|
||||
best, best_d = obj, d
|
||||
return best
|
||||
|
||||
|
||||
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.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.add_hook("frame", self.handle_frame)
|
||||
|
||||
def handle_frame(self):
|
||||
try:
|
||||
self.tick()
|
||||
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.read_at = now
|
||||
return self.settings
|
||||
|
||||
def tick(self):
|
||||
players = self.players()
|
||||
bot = next((p for p in players if p.clean_name.lower() == "lgbot"), None)
|
||||
if bot is None or not bot.is_alive:
|
||||
return
|
||||
now = time.time()
|
||||
s = self.read_cvars(now)
|
||||
cands = []
|
||||
for p in players:
|
||||
if p.id == bot.id or p.team == "spectator" or not p.is_alive:
|
||||
continue
|
||||
if p.steam_id == 0 and not self.target_bots: # bots have Steam ID 0
|
||||
continue
|
||||
pos = p.position()
|
||||
cands.append(((pos.x, pos.y), p))
|
||||
bpos = bot.position()
|
||||
target = pick_target((bpos.x, bpos.y), cands)
|
||||
if target is None:
|
||||
return # nobody to duel: stock AI roams
|
||||
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)
|
||||
vel = bot.velocity()
|
||||
bot.velocity(x=vx, y=vy, z=fix_z(vel.z))
|
||||
if self.debug and now - self.log_at >= 1.0:
|
||||
self.log_at = now
|
||||
dist = math.hypot(tpos.x - bpos.x, tpos.y - bpos.y)
|
||||
self.logger.info("lgbot dist=%d want=%d dir=%d cmd=%d actual=%d z=%d",
|
||||
dist, want, direction, math.hypot(vx, vy),
|
||||
math.hypot(vel.x, vel.y), vel.z)
|
||||
@@ -0,0 +1,141 @@
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
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_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), "near"), ((900, 0), "far")]
|
||||
self.assertEqual(pick_target((0, 0), c), "near")
|
||||
self.assertIsNone(pick_target((0, 0), [((2000, 0), "x")]))
|
||||
self.assertIsNone(pick_target((0, 0), []))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user