Files
SirRoboGarage/QBot_garage/src/QBot.nim
T
2026-08-27 18:42:03 +02:00

370 lines
14 KiB
Nim
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# QBot — gun-only Q-learning prototype, v2.
# Chained action decomposition: T1 (aim offset, 7 actions) + T2 (fire, 3 actions).
# Bullet tracking ring buffer: Q-updates happen ONLY on bullet resolution (hit/wall).
# 120 states × (7 + 3) actions = 1,200 Q-table entries total.
# All debug output goes to stderr. printToStdOut is never called.
#
# Bullet tracking protocol:
# 1. run(): setFire succeeds → push BulletRecord with valid=false, id=-1 ("pending")
# 2. onBulletFired(): stamp the oldest pending record with the real bulletId, set valid=true
# 3. onBulletHit / onBulletHitWall(): resolveBullet() finds by id, does Q-update, clears slot
import std/[random, math, os, streams, strutils]
import tankroyale_botapi
import radar_lock/radar_lock as radar_lock
# ── Constants ──────────────────────────────────────────────────────────────────
const botJsonPath = currentSourcePath().parentDir / "../QBot.json"
const qtablesDir = currentSourcePath().parentDir / "../qtables"
const
# State features
N_AIM_ERR = 5 # gun aim error: far-left / left / on-target / right / far-right
N_DIST = 3 # close / mid / far
N_LAT_SPD = 4 # enemy lateral speed: fast-left / slow-left / slow-right / fast-right
N_HEAT = 2 # gun ready / cooling
N_STATES = N_AIM_ERR * N_DIST * N_LAT_SPD * N_HEAT # 120
# Action tables
N_AIM_ACT = 7 # aim offset actions
N_FIRE_ACT = 3 # fire decision actions
ALPHA = 0.3
# No GAMMA: contextual bandit (γ=0). Each shot is independent; no future state.
# Q[s,a] converges to expected reward of action a in state s.
EPS_INIT = 0.5
EPS_DECAY = 0.995
EPS_FLOOR = 0.03
Q_INIT = 0.0 # neutral init; signal from +1 hit / -0.2 miss drives learning
# Bullet tracking ring buffer — max bullets in flight simultaneously
BULLET_BUF_CAP = 5
# Aim offset per action (degrees relative to direct bearing to enemy)
const AIM_OFFSETS: array[N_AIM_ACT, float] = [-12.0, -6.0, -3.0, 0.0, 3.0, 6.0, 12.0]
# Fire power per action (0.0 = don't fire)
const FIRE_POWERS: array[N_FIRE_ACT, float] = [0.0, 1.0, 3.0]
# ── Bullet tracking ───────────────────────────────────────────────────────────
type BulletRecord = object
bulletId: int # -1 = pending (waiting for onBulletFired to stamp real ID)
state: int
aimAct: int
fireAct: int
valid: bool # true = slot occupied (either pending or id-stamped)
# ── Bot type ───────────────────────────────────────────────────────────────────
type QBot = ref object of Bot
# Enemy tracking
enemyX, enemyY: float
enemyDir: float
enemySpeed: float
hasContact: bool
ticksSinceScan: int
# Q-tables (flat arrays)
# T1: aim offset — index [state * N_AIM_ACT + aimAction]
qt1: array[N_STATES * N_AIM_ACT, float64]
# T2: fire decision — index [state * N_FIRE_ACT + fireAction]
qt2: array[N_STATES * N_FIRE_ACT, float64]
# Bullet tracking ring buffer
bullets: array[BULLET_BUF_CAP, BulletRecord]
bulletHead: int # next write position (ring)
# Round stats
totalRounds: int
epsilon: float
roundHits: int
roundMisses: int
roundShots: int
# ── State discretization ──────────────────────────────────────────────────────
proc discretizeState(gunAimError, dist, lateralSpeed, gunHeat: float): int {.inline.} =
# gunAimError: signed angle from gun to direct enemy bearing (negative = gun left of enemy)
let aimBin =
if gunAimError < -20.0: 0
elif gunAimError < -5.0: 1
elif gunAimError <= 5.0: 2
elif gunAimError <= 20.0: 3
else: 4
let distBin =
if dist < 200.0: 0 elif dist <= 500.0: 1 else: 2
# lateralSpeed: enemy velocity component perpendicular to our line of sight
let latBin =
if lateralSpeed < -3.0: 0
elif lateralSpeed < 0.0: 1
elif lateralSpeed <= 3.0: 2
else: 3
# heat ready: threshold slightly above 0 since fire 3.0 generates heat 1.6
let heatBin = if gunHeat <= 0.2: 0 else: 1
aimBin * (N_DIST * N_LAT_SPD * N_HEAT) +
distBin * (N_LAT_SPD * N_HEAT) +
latBin * N_HEAT +
heatBin
# ── Q-learning helpers ────────────────────────────────────────────────────────
proc selectAimAction(bot: QBot, state: int): int =
if rand(1.0) < bot.epsilon:
return rand(N_AIM_ACT - 1)
var best = -1e30
var bestA = 0
for a in 0 ..< N_AIM_ACT:
let v = bot.qt1[state * N_AIM_ACT + a]
if v > best:
best = v
bestA = a
bestA
proc selectFireAction(bot: QBot, state: int): int =
if rand(1.0) < bot.epsilon:
return rand(N_FIRE_ACT - 1)
var best = -1e30
var bestA = 0
for a in 0 ..< N_FIRE_ACT:
let v = bot.qt2[state * N_FIRE_ACT + a]
if v > best:
best = v
bestA = a
bestA
proc updateQT1(bot: QBot, state, action: int, reward: float) {.inline.} =
# γ=0 bandit update: Q ← Q + α*(r - Q)
let idx = state * N_AIM_ACT + action
bot.qt1[idx] += ALPHA * (reward - bot.qt1[idx])
proc updateQT2(bot: QBot, state, action: int, reward: float) {.inline.} =
let idx = state * N_FIRE_ACT + action
bot.qt2[idx] += ALPHA * (reward - bot.qt2[idx])
# ── Bullet ring buffer ────────────────────────────────────────────────────────
proc pushPendingBullet(bot: QBot, state, aimAct, fireAct: int) =
# Push a pending record; onBulletFired will stamp the real ID.
bot.bullets[bot.bulletHead] = BulletRecord(
bulletId: -1, state: state, aimAct: aimAct, fireAct: fireAct, valid: true)
bot.bulletHead = (bot.bulletHead + 1) mod BULLET_BUF_CAP
proc stampBulletId(bot: QBot, realId: int) =
# Find the oldest pending (id=-1) record and stamp it with the real bullet ID.
# Search backwards from bulletHead (most recent push is just before head).
for i in 1 .. BULLET_BUF_CAP:
let idx = (bot.bulletHead - i + BULLET_BUF_CAP) mod BULLET_BUF_CAP
if bot.bullets[idx].valid and bot.bullets[idx].bulletId == -1:
bot.bullets[idx].bulletId = realId
return
proc resolveBullet(bot: QBot, bulletId: int, reward: float) =
for i in 0 ..< BULLET_BUF_CAP:
if bot.bullets[i].valid and bot.bullets[i].bulletId == bulletId:
let rec = bot.bullets[i]
bot.bullets[i].valid = false
updateQT1(bot, rec.state, rec.aimAct, reward)
updateQT2(bot, rec.state, rec.fireAct, reward)
return
# Not found — bullet from previous round or missed stamp; silently ignore.
# ── Persistence ───────────────────────────────────────────────────────────────
proc saveQTable(bot: QBot) =
createDir(qtablesDir)
let s = newFileStream(qtablesDir / "gun_q.bin", fmWrite)
if s == nil: return
for v in bot.qt1: s.write(v)
for v in bot.qt2: s.write(v)
s.close()
writeFile(qtablesDir / "gun_meta.txt",
"rounds=" & $bot.totalRounds & "\n" &
"epsilon=" & $bot.epsilon & "\n")
proc loadQTable(bot: QBot) =
let path = qtablesDir / "gun_q.bin"
if not fileExists(path): return
let s = newFileStream(path, fmRead)
if s == nil: return
var i = 0
while not s.atEnd and i < bot.qt1.len:
bot.qt1[i] = s.readFloat64()
inc i
i = 0
while not s.atEnd and i < bot.qt2.len:
bot.qt2[i] = s.readFloat64()
inc i
s.close()
let mp = qtablesDir / "gun_meta.txt"
if not fileExists(mp): return
for line in lines(mp):
if line.startsWith("rounds="):
bot.totalRounds = parseInt(line[7..^1])
elif line.startsWith("epsilon="):
bot.epsilon = parseFloat(line[8..^1])
# ── Logging ───────────────────────────────────────────────────────────────────
proc logRoundEnd(bot: QBot) =
let hitRate = if bot.roundShots > 0:
float(bot.roundHits) / float(bot.roundShots)
else: 0.0
stderr.writeLine("[QBot] round=" & $bot.totalRounds &
" shots=" & $bot.roundShots &
" hits=" & $bot.roundHits &
" hitRate=" & formatFloat(hitRate, ffDecimal, 3) &
" eps=" & formatFloat(bot.epsilon, ffDecimal, 3))
# Top 5 T1 Q-values
type QEntry = tuple[v: float; s, a: int]
var top: array[5, QEntry]
var topN = 0
for s in 0 ..< N_STATES:
for a in 0 ..< N_AIM_ACT:
let v = bot.qt1[s * N_AIM_ACT + a]
if topN < 5:
top[topN] = (v, s, a)
inc topN
else:
var minIdx = 0
for j in 1 ..< 5:
if top[j].v < top[minIdx].v: minIdx = j
if v > top[minIdx].v:
top[minIdx] = (v, s, a)
# Sort descending (simple insertion sort over 5 elements)
for i in 0 ..< topN - 1:
for j in i + 1 ..< topN:
if top[j].v > top[i].v:
let tmp = top[i]; top[i] = top[j]; top[j] = tmp
stderr.writeLine("[QBot] top T1 Q-values:")
for i in 0 ..< topN:
stderr.writeLine(" s=" & $top[i].s & " aimAct=" & $top[i].a &
" offset=" & formatFloat(AIM_OFFSETS[top[i].a], ffDecimal, 1) & "deg" &
" q=" & formatFloat(top[i].v, ffDecimal, 4))
# ── Event handlers ────────────────────────────────────────────────────────────
method onScannedBot*(bot: QBot, e: ScannedBotEvent) =
bot.enemyX = e.x
bot.enemyY = e.y
bot.enemyDir = e.direction
bot.enemySpeed = e.speed
bot.hasContact = true
bot.ticksSinceScan = 0
method onBulletFired*(bot: QBot, e: BulletFiredEvent) =
# Stamp the pending bullet record with the real ID assigned by the server.
bot.stampBulletId(e.bullet.bulletId)
method onBulletHit*(bot: QBot, e: BulletHitBotEvent) =
if e.victimId != getMyId():
inc bot.roundHits
bot.resolveBullet(e.bullet.bulletId, +1.0)
method onBulletHitWall*(bot: QBot, e: BulletHitWallEvent) =
if e.bullet.ownerId == getMyId():
inc bot.roundMisses
bot.resolveBullet(e.bullet.bulletId, -0.2)
method onRoundStarted*(bot: QBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
radar_lock.init()
bot.hasContact = false
bot.roundHits = 0
bot.roundMisses = 0
bot.roundShots = 0
bot.ticksSinceScan = 0
# Invalidate bullet buffer between rounds
for i in 0 ..< BULLET_BUF_CAP: bot.bullets[i].valid = false
bot.bulletHead = 0
setTargetSpeed(0.0)
setTurnRate(0.0)
bot.epsilon = max(EPS_FLOOR, bot.epsilon * EPS_DECAY)
method onRoundEnded*(bot: QBot, e: RoundEndedEventForBot) =
inc bot.totalRounds
# Bullets still in flight at round end → treat as misses
for i in 0 ..< BULLET_BUF_CAP:
if bot.bullets[i].valid:
updateQT1(bot, bot.bullets[i].state, bot.bullets[i].aimAct, -0.2)
updateQT2(bot, bot.bullets[i].state, bot.bullets[i].fireAct, -0.2)
bot.bullets[i].valid = false
logRoundEnd(bot)
method onGameStarted*(bot: QBot, e: GameStartedEventForBot) =
loadQTable(bot)
randomize()
method onGameEnded*(bot: QBot, e: GameEndedEventForBot) =
saveQTable(bot)
stderr.writeLine("[QBot] game ended rounds=" & $bot.totalRounds &
" eps=" & formatFloat(bot.epsilon, ffDecimal, 3))
# ── Main loop ─────────────────────────────────────────────────────────────────
method run*(bot: QBot) =
while isRunning():
setTargetSpeed(0.0)
setTurnRate(0.0)
inc bot.ticksSinceScan
if bot.ticksSinceScan > 5:
bot.hasContact = false
if not bot.hasContact:
setRadarTurnRate(45.0)
go()
continue
let myX = getX()
let myY = getY()
let gunDir = getGunDirection()
let dist = hypot(myX - bot.enemyX, myY - bot.enemyY)
# Absolute bearing from us to enemy
let bearingToEnemy = directionTo(myX, myY, bot.enemyX, bot.enemyY)
# Gun aim error: how far off our gun is from direct bearing to enemy
let gunAimError = normalizeRelativeAngle(bearingToEnemy - gunDir)
# Lateral speed: enemy velocity perpendicular to our line of sight
let lateralSpeed = bot.enemySpeed * sin(degToRad(bot.enemyDir - bearingToEnemy))
let state = discretizeState(gunAimError, dist, lateralSpeed, getGunHeat())
# Select actions (no per-tick Q-update — updates happen only on bullet resolution)
let aimAct = bot.selectAimAction(state)
let fireAct = bot.selectFireAction(state)
# Compute gun turn: aim at enemy bearing + selected offset, clamped to ±20 deg/tick
let aimOffset = AIM_OFFSETS[aimAct]
let desiredDelta = normalizeRelativeAngle(bearingToEnemy + aimOffset - gunDir)
setGunTurnRate(desiredDelta.clamp(-20.0, 20.0))
# Fire if the action calls for it and the gun is ready
let firePower = FIRE_POWERS[fireAct]
if firePower > 0.0 and getGunHeat() <= 0.0:
if setFire(firePower):
inc bot.roundShots
# Push a pending record; onBulletFired (fired next tick) stamps the real ID
bot.pushPendingBullet(state, aimAct, fireAct)
# Radar lock
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bearingToEnemy))
go()
# ── Entry point ───────────────────────────────────────────────────────────────
when isMainModule:
createDir(qtablesDir)
var bot = QBot(epsilon: EPS_INIT)
for i in 0 ..< bot.qt1.len: bot.qt1[i] = Q_INIT
for i in 0 ..< bot.qt2.len: bot.qt2[i] = Q_INIT
start(bot, botJsonPath)