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 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-15 15:16:53 +03:00
1 parent a998614224
commit 86754715f5
6 files changed
+321 -30

No files matched your search

+71 -21
View File
@@ -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)