372 lines
17 KiB
Python
372 lines
17 KiB
Python
# 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 os
|
|
import random
|
|
import time
|
|
|
|
RUN_SPEED = 320 # QL ground run speed, units/s
|
|
# Ground friction takes ~15% of the velocity we set before the frame's move happens at sv_fps 40
|
|
# (measured: 416 set -> 353 actual; up to ~25% while the bot AI pushes the other way), so on the
|
|
# 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),
|
|
"range": (150, 750), "range_play": (0, 1), "speed": (50, 100), "lane": (0, 2000)}
|
|
PRESETS = {
|
|
1: {"strafe_min": 600, "strafe_max": 1000, "fake": 0, "range": 450, "range_play": 0, "speed": 80, "lane": 600},
|
|
2: {"strafe_min": 400, "strafe_max": 800, "fake": 10, "range": 450, "range_play": 0, "speed": 90, "lane": 500},
|
|
3: {"strafe_min": 250, "strafe_max": 600, "fake": 25, "range": 400, "range_play": 1, "speed": 100, "lane": 400},
|
|
4: {"strafe_min": 180, "strafe_max": 450, "fake": 35, "range": 380, "range_play": 1, "speed": 100, "lane": 350},
|
|
5: {"strafe_min": 120, "strafe_max": 350, "fake": 50, "range": 350, "range_play": 1, "speed": 100, "lane": 300},
|
|
}
|
|
PUSH_RANGE, BACKOFF_RANGE = 250, 700
|
|
BACKOFF_HEALTH, PUSH_LEAD = 60, 30
|
|
# 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
|
|
# lgbot_record 1: every frame, the position/velocity of the bot and each human (as h<client number>,
|
|
# no names or Steam IDs) go to home/lgbot-rec.csv, for tools/lgbot_rec.py to compare how they move.
|
|
# Stops by itself after REC_MAX_S.
|
|
REC_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lgbot-rec.csv")
|
|
REC_MAX_S = 900
|
|
|
|
|
|
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:
|
|
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.
|
|
Lane: strafing sideways to someone moves you around them, and fakes keep you going the same
|
|
way, so the net sideways drift is tracked and the bot turns back once it is more than `lane`
|
|
units off centre (0 = no limit). That keeps it doing ADAD in front of you instead of circling."""
|
|
|
|
def __init__(self, rng=random):
|
|
self.rng = rng
|
|
self.direction = 1
|
|
self.until = 0
|
|
self.faking = False
|
|
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 * 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)
|
|
if self.direction * self.drift >= lane_ms:
|
|
self.direction = -self.direction
|
|
self.faking = False
|
|
self.until = now_ms + self.rng.randint(s["strafe_min"], s["strafe_max"])
|
|
return self.direction
|
|
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 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 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. 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, 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 + 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
|
|
|
|
|
|
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.set_cvar_once("lgbot_record", "0")
|
|
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.guard = StuckGuard()
|
|
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.state, self.frame_players = "", [] # for the recorder
|
|
self.record_on, self.rec, self.rec_start = False, None, 0.0
|
|
self.add_hook("frame", self.handle_frame)
|
|
|
|
def handle_frame(self):
|
|
try:
|
|
self.tick()
|
|
self.record(time.time())
|
|
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.record_on = bool(to_int(self.get_cvar("lgbot_record")))
|
|
self.read_at = now
|
|
return self.settings
|
|
|
|
def idle(self, now, code, why):
|
|
self.state = code
|
|
# 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)
|
|
|
|
def tick(self):
|
|
now = time.time()
|
|
s = self.read_cvars(now)
|
|
players = self.players()
|
|
self.frame_players = players
|
|
bot = next((p for p in players if is_lgbot(p.clean_name, p.steam_id)), None)
|
|
if bot is None:
|
|
return self.idle(now, "nobot", "no lgbot on the server")
|
|
if not bot.is_alive:
|
|
return self.idle(now, "dead", "lgbot is dead")
|
|
cands = []
|
|
for p in players:
|
|
if p.id == bot.id or p.team == "spectator" or not p.is_alive:
|
|
continue
|
|
if is_bot(p.steam_id) and not self.target_bots:
|
|
continue
|
|
pos = p.position()
|
|
cands.append(((pos.x, pos.y, pos.z), p.id))
|
|
bpos = bot.position()
|
|
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
|
|
near = min(((math.hypot(c[0][0] - bpos.x, c[0][1] - bpos.y), c[0][2] - bpos.z) for c in cands), default=None)
|
|
return self.idle(now, "notarget", "no target within %d on this floor (%d candidates, nearest %s)" % (
|
|
MAX_TARGET_DIST, len(cands), "dist=%d dz=%d bot=%d,%d" % (near + (bpos.x, bpos.y)) if near else "-"))
|
|
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:
|
|
cmd_speed = math.hypot(*self.last_cmd)
|
|
if cmd_speed > 1:
|
|
along = (vel.x * self.last_cmd[0] + vel.y * self.last_cmd[1]) / cmd_speed
|
|
if not self.guard.update(int(now * 1000), cmd_speed, along):
|
|
return self.idle(now, "blocked", "blocked, letting the bot AI walk around it")
|
|
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)
|
|
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)
|
|
self.state = "steer"
|
|
if self.debug:
|
|
self.debug_line(now, vel, (vx, vy), tpos, bpos, want, direction)
|
|
|
|
def record(self, now):
|
|
if not self.record_on:
|
|
if self.rec:
|
|
self.rec.close()
|
|
self.rec = None
|
|
return
|
|
if self.rec is None:
|
|
self.rec = open(REC_FILE, "a", buffering=1)
|
|
self.rec_start = now
|
|
self.rec.write("# start %d map %s\n" % (int(now), self.get_cvar("mapname")))
|
|
if now - self.rec_start > REC_MAX_S:
|
|
self.set_cvar("lgbot_record", "0")
|
|
self.record_on = False
|
|
self.rec.close()
|
|
self.rec = None
|
|
return
|
|
t = now - self.rec_start
|
|
for p in self.frame_players:
|
|
if p.team == "spectator":
|
|
continue
|
|
if is_lgbot(p.clean_name, p.steam_id):
|
|
who, state = "bot", self.state
|
|
elif is_bot(p.steam_id):
|
|
continue
|
|
else:
|
|
who, state = "h%d" % p.id, ""
|
|
pos, vel = p.position(), p.velocity()
|
|
self.rec.write("%.3f,%s,%d,%d,%.0f,%.0f,%.0f,%.0f,%.0f,%.0f,%s\n" % (
|
|
t, who, int(bool(p.is_alive)), p.health, pos.x, pos.y, pos.z, vel.x, vel.y, vel.z, state))
|
|
|
|
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; the
|
|
# StuckGuard is what acts on it, this is only the count)
|
|
actual = math.hypot(vel.x, vel.y)
|
|
cos = 0.0
|
|
if self.dbg_cmd:
|
|
lc = math.hypot(*self.dbg_cmd)
|
|
if lc > 1 and actual > 1:
|
|
cos = (vel.x * self.dbg_cmd[0] + vel.y * self.dbg_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.dbg_cmd = cmd
|
|
if now - self.log_at >= 1.0:
|
|
self.log_at = now
|
|
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)
|