diff --git a/home/baseq3/server.cfg b/home/baseq3/server.cfg index a6acd42..a4c30f8 100644 --- a/home/baseq3/server.cfg +++ b/home/baseq3/server.cfg @@ -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 diff --git a/home/minqlx-plugins/lgbot.py b/home/minqlx-plugins/lgbot.py new file mode 100644 index 0000000..1396842 --- /dev/null +++ b/home/minqlx-plugins/lgbot.py @@ -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) diff --git a/tools/test_lgbot.py b/tools/test_lgbot.py new file mode 100644 index 0000000..a06f982 --- /dev/null +++ b/tools/test_lgbot.py @@ -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()