From 86754715f5afa18f230e97ce6a9eb7bc9c8ff0c6 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Thu, 15 Oct 2026 15:16:53 +0300 Subject: [PATCH] lgbot: review fixes: drift reset after pauses, player-like acceleration, sticky targets with hysteresis, wall backoff; plugin glue tests Co-Authored-By: Claude Opus 5.5 --- docs/ADMIN.md | 4 +- docs/HOW-IT-WORKS.md | 2 +- home/minqlx-plugins/lgbot.py | 92 ++++++++++++++++----- tools/lgbot_setup.py | 13 ++- tools/test_lgbot.py | 91 ++++++++++++++++++++- tools/test_lgbot_plugin.py | 149 +++++++++++++++++++++++++++++++++++ 6 files changed, 321 insertions(+), 30 deletions(-) create mode 100644 tools/test_lgbot_plugin.py diff --git a/docs/ADMIN.md b/docs/ADMIN.md index 4d2ba4f..ed48bc0 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -56,11 +56,11 @@ Movement levels (the details override one part, `-1` = use the level's value): | 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 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. +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 `, `./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. Its own movement only takes over when you're within LG reach (900 units) on about the same floor; 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. One lgbot at a time. +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` diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index c373ea2..70531d1 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -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). -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, never faster than a player can). 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 runs when the target is within 900 units horizontally and 200 vertically. 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. 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. +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. diff --git a/home/minqlx-plugins/lgbot.py b/home/minqlx-plugins/lgbot.py index 8e5cc71..0121a9a 100644 --- a/home/minqlx-plugins/lgbot.py +++ b/home/minqlx-plugins/lgbot.py @@ -14,6 +14,9 @@ RUN_SPEED = 320 # QL ground run speed, units/s # 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), @@ -27,10 +30,15 @@ PRESETS = { } PUSH_RANGE, BACKOFF_RANGE = 250, 700 BACKOFF_HEALTH, PUSH_LEAD = 60, 30 -# Steering is straight-line (no pathfinding), so only steer when the target is within LG reach and -# on about the same floor; otherwise the bot's own navigation walks it over. +# 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 @@ -81,9 +89,14 @@ class Strafer: 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 * (now_ms - self.last) + 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) @@ -132,40 +145,65 @@ def fix_z(vz): return 0.0 if 200 <= vz <= 320 else vz -def pick_target(bot_pos, candidates, max_dist=MAX_TARGET_DIST, max_dz=MAX_TARGET_DZ): - """Nearest candidate (pos = x, y, z) within max_dist horizontally and max_dz vertically.""" - best, best_d = None, max_dist - for pos, obj in candidates: - if abs(pos[2] - bot_pos[2]) > max_dz: - continue +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 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 d <= best_d: - best, best_d = obj, d - return best + 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.""" + 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): - self.ratio, self.after_ms, self.release_ms = ratio, after_ms, release_ms + 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 + self.release_ms + 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 @@ -188,7 +226,8 @@ if minqlx: self.debug = self.target_bots = False self.failed = False self.guard = StuckGuard() - self.last_cmd = None # last command (for the stuck guard) + 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.add_hook("frame", self.handle_frame) @@ -210,7 +249,10 @@ if minqlx: return self.settings def idle(self, now, why): - self.last_cmd = None # not steering this frame: nothing for the stuck guard to judge + # 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) @@ -231,11 +273,15 @@ if minqlx: if is_bot(p.steam_id) and not self.target_bots: continue pos = p.position() - cands.append(((pos.x, pos.y, pos.z), p)) + cands.append(((pos.x, pos.y, pos.z), p.id)) bpos = bot.position() - target = pick_target((bpos.x, bpos.y, bpos.z), cands) - if target is None: # nobody to duel close by: the bot AI walks over + 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 return self.idle(now, "no target within %d on this floor (%d candidates)" % (MAX_TARGET_DIST, len(cands))) + 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: @@ -248,6 +294,10 @@ if minqlx: 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) diff --git a/tools/lgbot_setup.py b/tools/lgbot_setup.py index afd8b75..cc9beb1 100644 --- a/tools/lgbot_setup.py +++ b/tools/lgbot_setup.py @@ -147,11 +147,14 @@ def refuse_impostor(status_text): return False +REFUSED = 3 + + def apply(st, old_aim): run = server() status = run("status") if refuse_impostor(status): - return 1 + 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) @@ -162,7 +165,7 @@ def apply(st, old_aim): def main(argv, input_fn=input): arg = argv[1] if len(argv) > 1 else "" - st = load_state() + st = load_state(STATE) if arg == "off": run = server() if refuse_impostor(run("status")): @@ -187,8 +190,10 @@ def main(argv, input_fn=input): except (EOFError, KeyboardInterrupt): print("\ncancelled, nothing changed") return 1 - save_state(STATE, new) - return apply(new, old_aim=st["aim"]) + 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__": diff --git a/tools/test_lgbot.py b/tools/test_lgbot.py index 9747e4d..5a76e1c 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, is_lgbot, StuckGuard) +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): @@ -83,6 +83,40 @@ class StraferTest(unittest.TestCase): 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) @@ -158,7 +192,7 @@ class Misc(unittest.TestCase): 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), [((1000, 0, 0), "x")])) # beyond LG reach: AI walks there + 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): @@ -166,6 +200,17 @@ class Misc(unittest.TestCase): 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): @@ -180,6 +225,29 @@ class Stuck(unittest.TestCase): 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)] @@ -267,6 +335,25 @@ class Parsing(unittest.TestCase): 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) diff --git a/tools/test_lgbot_plugin.py b/tools/test_lgbot_plugin.py new file mode 100644 index 0000000..dc35692 --- /dev/null +++ b/tools/test_lgbot_plugin.py @@ -0,0 +1,149 @@ +"""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 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_no_target_means_no_steering(self): + self.human.team = "spectator" + self.run_frames(40) + self.assertEqual(self.bot.sets, []) + + +if __name__ == "__main__": + unittest.main()