From aac104206b376dde8466aa868bdee71748cd42d9 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Thu, 15 Oct 2026 14:37:30 +0300 Subject: [PATCH] lgbot: never take over or kick a human named lgbot; live values in show; direction debug Co-Authored-By: Claude Opus 5.5 --- home/minqlx-plugins/lgbot.py | 33 ++++++++++++++++++++++------ tools/lgbot_setup.py | 42 ++++++++++++++++++++++++++++++------ tools/test_lgbot.py | 19 +++++++++++++++- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/home/minqlx-plugins/lgbot.py b/home/minqlx-plugins/lgbot.py index b6c4e23..11b0e43 100644 --- a/home/minqlx-plugins/lgbot.py +++ b/home/minqlx-plugins/lgbot.py @@ -30,6 +30,11 @@ 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: @@ -131,6 +136,7 @@ if minqlx: self.settings, self.read_at, self.log_at = None, 0.0, 0.0 self.debug = self.target_bots = False self.failed = False + self.last_cmd, self.slow_frames, self.stuck = None, 0, 0 # debug stats self.add_hook("frame", self.handle_frame) def handle_frame(self): @@ -152,7 +158,7 @@ if minqlx: def tick(self): players = self.players() - bot = next((p for p in players if p.clean_name.lower() == "lgbot"), None) + bot = next((p for p in players if is_lgbot(p.clean_name, p.steam_id)), None) if bot is None or not bot.is_alive: return now = time.time() @@ -175,9 +181,24 @@ if minqlx: 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: + if self.debug: + self.debug_line(now, vel, (vx, vy), tpos, bpos, want, direction) + + 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) + actual = math.hypot(vel.x, vel.y) + cos = 0.0 + if self.last_cmd: + lc = math.hypot(*self.last_cmd) + if lc > 1 and actual > 1: + cos = (vel.x * self.last_cmd[0] + vel.y * self.last_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.last_cmd = cmd + if 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) + 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) diff --git a/tools/lgbot_setup.py b/tools/lgbot_setup.py index c7ac1f0..0a7fd40 100644 --- a/tools/lgbot_setup.py +++ b/tools/lgbot_setup.py @@ -7,7 +7,7 @@ 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, sys +import json, os, re, sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "home", "minqlx-plugins")) @@ -99,9 +99,18 @@ def prompt_setup(st, input_fn=input, print_fn=print): return {"aim": aim, "move": move, "details": details} -def bot_present(status_text): +def _named_lgbot(status_text): parsed = parse_rcon_status(status_text) - return bool(parsed) and any(p[2].lower() == "lgbot" for p in parsed[1]) + 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): @@ -114,6 +123,11 @@ def build_commands(st, readd, bot_in): 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 "") @@ -125,9 +139,19 @@ def server(): 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 + + def apply(st, old_aim): run = server() - bot_in = bot_present(run("status")) + status = run("status") + if refuse_impostor(status): + return 1 + 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")) @@ -139,12 +163,18 @@ def main(argv, input_fn=input): arg = argv[1] if len(argv) > 1 else "" st = load_state() if arg == "off": - server()("kick lgbot") + 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)) - print("bot is " + ("in" if bot_present(server()("status")) else "not in")) + 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 diff --git a/tools/test_lgbot.py b/tools/test_lgbot.py index 84d8385..ea6d68a 100644 --- a/tools/test_lgbot.py +++ b/tools/test_lgbot.py @@ -1,7 +1,7 @@ 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) +from lgbot import (PRESETS, KEYS, resolve, Strafer, wanted_range, steer, fix_z, pick_target, to_int, is_bot, is_lgbot) def preset(n, **kw): @@ -129,6 +129,12 @@ class Misc(unittest.TestCase): 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) @@ -207,6 +213,17 @@ class Parsing(unittest.TestCase): 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")))