fix(adversaries): repair four of the five sparring bots
The user suspected these were bugged. They were, and the verdicts are not uniform - three genuinely broken, one merely sloppy, one fine: - WaveSurfer: GENUINELY BUGGED, worst of the five. (a) The enemy velocity decomposition was sin/cos SWAPPED - enemyVx used sin and enemyVy used cos, while Tank Royale is 0 deg = East, CCW+, so it must be cos for X and sin for Y. Its linear-prediction gun was aiming at a reflected position. (b) The wall escape flipped strafeDir on EVERY tick the bot was inside the wall margin, so instead of turning away it flip-flopped in place: measured standing still (speed < 0.5) for 96.2% of ticks with a longest continuous standstill of 1398 ticks. Fixed with a hysteretic wall-escape selection plus a corner escape, dead enemyLastDir removed, and per-round state reset. AFTER, measured through the booter: rest 12.3%, mean speed 6.69, full speed 79.7%, longest zero run 18, perpendicular 0.845 / radial 0.012 - it now actually strafes. Gun sanity: lead error 1.0 px vs 106 px for head-on on a constant-velocity target; lead gun 45.8% hits vs 29.3% for head-on. - PatternMover: GENUINELY BUGGED. Real deadlock - it decremented its step counter by the REQUESTED amount while issuing setTargetSpeed(8), so against a wall the counter never reached 0, advanceStep never ran and it was stuck forever (309-tick standstill). Now counts down by ACTUAL distance/turn with a STALL_LIMIT watchdog and steers toward the arena centre. Standstill 309 -> 19 ticks; full-speed ticks 10.0% -> 28.4%. - OscillatorBot: GENUINELY BUGGED, milder. No wall handling at all, so it ground along walls 53.4% of ticks and could pin in a corner. Added wall steering that preserves the fixed 25-tick reversal cadence. Wall-band 53.4% -> 18.6%, mean wall distance 72 -> 119. - RandomMover: merely sloppy, not broken. Its turn intent saturated against the speed-dependent limit (18.4% of moving ticks clamped) and the fire gate was a very loose 10 deg. Now clamps to calcMaxTurnRate and fires within 3 deg. Saturation 18.4% -> 3.9%. - SittingDuck: FINE. Speed 0 for 100% of ticks, zero shots. Left untouched - it is a duck by design. Adds test_wavesurfer_velocity.nim, a direct assertion that the decomposition is cos/sin and explicitly NOT the swapped form (7 cases). KNOWN ISSUE, not fixed: RandomMover/PatternMover/WaveSurfer import tankroyale_botapi 1.0.1 and intermittently SIGSEGV in tankroyale_botapi/event_queue.nim:89 addEvent, freezing the bot for the rest of the battle. It reproduces on old and new code and never occurs for SittingDuck/ OscillatorBot, which import robocode_tankroyale_botapi 1.0.7. Migrating the three to 1.0.7 would likely fix it and is worth doing - it is a real reliability risk for these as sparring partners.
This commit is contained in:
Binary file not shown.
@@ -7,8 +7,10 @@ import tankroyale_botapi
|
||||
const botJsonPath = currentSourcePath().parentDir / "OscillatorBot.json"
|
||||
|
||||
const
|
||||
SPEED = 7.0 # forward/backward speed
|
||||
PERIOD = 25 # ticks between direction reversals
|
||||
SPEED = 7.0 # forward/backward speed
|
||||
PERIOD = 25 # ticks between direction reversals
|
||||
TURN_RATE = 4.0 # deg/tick zigzag turn
|
||||
WALL_MARGIN = 60.0
|
||||
|
||||
type OscillatorBot = ref object of Bot
|
||||
tickCount: int
|
||||
@@ -33,13 +35,32 @@ method onScannedBot*(bot: OscillatorBot, e: ScannedBotEvent) =
|
||||
|
||||
method run*(bot: OscillatorBot) =
|
||||
while isRunning():
|
||||
# The reversal cadence is fixed and independent of walls: the zigzag period
|
||||
# stays exactly PERIOD ticks.
|
||||
inc bot.tickCount
|
||||
if bot.tickCount mod PERIOD == 0:
|
||||
bot.moveSign *= -1.0
|
||||
bot.turnSign *= -1.0
|
||||
|
||||
setTargetSpeed(bot.moveSign * SPEED)
|
||||
setTurnRate(bot.turnSign * 4.0)
|
||||
|
||||
# Wall handling that preserves periodicity: when close to a wall, steer the
|
||||
# actual movement direction (forward or backward, per moveSign) towards the
|
||||
# arena centre instead of following the fixed zigzag turn.
|
||||
let x = getX()
|
||||
let y = getY()
|
||||
let arenaW = getArenaWidth().float
|
||||
let arenaH = getArenaHeight().float
|
||||
let nearest = min(min(x, arenaW - x), min(y, arenaH - y))
|
||||
if nearest < WALL_MARGIN:
|
||||
let toCenter = directionTo(x, y, arenaW * 0.5, arenaH * 0.5)
|
||||
let desired = if bot.moveSign >= 0.0: toCenter
|
||||
else: normalizeAbsoluteAngle(toCenter + 180.0)
|
||||
let delta = normalizeRelativeAngle(desired - getDirection())
|
||||
setTurnRate(delta.clamp(-MAX_TURN_RATE, MAX_TURN_RATE))
|
||||
else:
|
||||
setTurnRate(bot.turnSign * TURN_RATE)
|
||||
|
||||
setRadarTurnRate(45.0) # spin radar to keep scanning
|
||||
go()
|
||||
|
||||
|
||||
Binary file not shown.
@@ -17,18 +17,29 @@ const STEPS: array[6, Step] = [
|
||||
( 0.0, 30.0),
|
||||
]
|
||||
|
||||
const
|
||||
MOVE_SPEED = 8.0
|
||||
STALL_LIMIT = 20 # ticks without progress before the step is abandoned
|
||||
|
||||
type PatternMover = ref object of Bot
|
||||
stepIdx: int
|
||||
remaining: float # distance left in current move step, or turn degrees
|
||||
inTurn: bool # true while executing a turn sub-step
|
||||
lastX: float
|
||||
lastY: float
|
||||
lastDir: float
|
||||
haveLast: bool
|
||||
stallTicks: int
|
||||
|
||||
method onRoundStarted*(bot: PatternMover, e: RoundStartedEvent) =
|
||||
setAdjustGunForBodyTurn(true)
|
||||
setAdjustRadarForBodyTurn(true)
|
||||
setAdjustRadarForGunTurn(true)
|
||||
bot.stepIdx = 0
|
||||
bot.remaining = STEPS[0].dist
|
||||
bot.inTurn = false
|
||||
bot.stepIdx = 0
|
||||
bot.remaining = STEPS[0].dist
|
||||
bot.inTurn = false
|
||||
bot.haveLast = false
|
||||
bot.stallTicks = 0
|
||||
|
||||
method onScannedBot*(bot: PatternMover, e: ScannedBotEvent) =
|
||||
let bearing = directionTo(getX(), getY(), e.x, e.y)
|
||||
@@ -46,27 +57,52 @@ proc advanceStep(bot: PatternMover) =
|
||||
else:
|
||||
bot.inTurn = true
|
||||
bot.remaining = abs(s.turn)
|
||||
bot.stallTicks = 0
|
||||
|
||||
method run*(bot: PatternMover) =
|
||||
while isRunning():
|
||||
setRadarTurnRate(45.0)
|
||||
|
||||
# Progress is measured from real movement / real turning, never from the
|
||||
# requested intent, so a blocked bot cannot run its step counter down.
|
||||
var moved = 0.0
|
||||
var turned = 0.0
|
||||
if bot.haveLast:
|
||||
moved = distanceTo(bot.lastX, bot.lastY, getX(), getY())
|
||||
turned = abs(normalizeRelativeAngle(getDirection() - bot.lastDir))
|
||||
|
||||
if bot.inTurn:
|
||||
let s = STEPS[bot.stepIdx]
|
||||
let sign = if s.turn >= 0: 1.0 else: -1.0
|
||||
let rate = min(bot.remaining, MAX_TURN_RATE)
|
||||
setTurnRate(sign * rate)
|
||||
setTargetSpeed(0.0)
|
||||
bot.remaining -= rate
|
||||
if bot.remaining <= 0.0:
|
||||
advanceStep(bot)
|
||||
bot.remaining -= turned
|
||||
if turned < 0.05: inc bot.stallTicks else: bot.stallTicks = 0
|
||||
else:
|
||||
let rate = min(bot.remaining, 8.0)
|
||||
setTargetSpeed(8.0)
|
||||
setTurnRate(0.0)
|
||||
bot.remaining -= rate
|
||||
if bot.remaining <= 0.0:
|
||||
advanceStep(bot)
|
||||
# If the forward move is actually blocked (e.g. against a wall), steer
|
||||
# towards the arena centre until it can move again, so the pattern makes
|
||||
# real progress instead of deadlocking against the wall.
|
||||
var headingCorrection = 0.0
|
||||
if bot.stallTicks >= 2:
|
||||
let arenaW = getArenaWidth().float
|
||||
let arenaH = getArenaHeight().float
|
||||
let toCenter = directionTo(getX(), getY(), arenaW * 0.5, arenaH * 0.5)
|
||||
headingCorrection = normalizeRelativeAngle(toCenter - getDirection())
|
||||
setTurnRate(headingCorrection.clamp(-MAX_TURN_RATE, MAX_TURN_RATE))
|
||||
setTargetSpeed(MOVE_SPEED)
|
||||
bot.remaining -= moved
|
||||
if moved < 0.05: inc bot.stallTicks else: bot.stallTicks = 0
|
||||
|
||||
bot.lastX = getX()
|
||||
bot.lastY = getY()
|
||||
bot.lastDir = getDirection()
|
||||
bot.haveLast = true
|
||||
|
||||
# Advance on completion OR after a bounded stall (e.g. pinned against a
|
||||
# wall), so the repeating pattern can never deadlock.
|
||||
if bot.remaining <= 0.5 or bot.stallTicks > STALL_LIMIT:
|
||||
advanceStep(bot)
|
||||
|
||||
go()
|
||||
|
||||
|
||||
Binary file not shown.
@@ -13,7 +13,11 @@ type RandomMover = ref object of Bot
|
||||
|
||||
proc newDirection(bot: RandomMover) =
|
||||
bot.currentSpeed = rand(-8.0..8.0)
|
||||
bot.currentTurn = rand(-10.0..10.0)
|
||||
# The server caps body turn at calcMaxTurnRate(speed) = 10 - 0.75*|speed|
|
||||
# (~4 deg/tick at full speed); keep the random intent inside that limit so it
|
||||
# is not silently saturated by the server.
|
||||
let maxTurn = calcMaxTurnRate(bot.currentSpeed)
|
||||
bot.currentTurn = rand(-maxTurn..maxTurn)
|
||||
bot.ticksUntilChange = rand(10..20)
|
||||
|
||||
method onRoundStarted*(bot: RandomMover, e: RoundStartedEvent) =
|
||||
@@ -27,7 +31,7 @@ method onScannedBot*(bot: RandomMover, e: ScannedBotEvent) =
|
||||
let bearing = directionTo(getX(), getY(), e.x, e.y)
|
||||
let gunDelta = normalizeRelativeAngle(bearing - getGunDirection())
|
||||
setGunTurnRate(gunDelta.clamp(-MAX_GUN_TURN_RATE, MAX_GUN_TURN_RATE))
|
||||
if abs(gunDelta) < 10.0 and getGunHeat() <= 0.0:
|
||||
if abs(gunDelta) < 3.0 and getGunHeat() <= 0.0:
|
||||
discard setFire(1.0)
|
||||
|
||||
method run*(bot: RandomMover) =
|
||||
|
||||
Binary file not shown.
@@ -12,15 +12,24 @@ const
|
||||
WALL_MARGIN = 60.0
|
||||
STRAFE_SPEED = 8.0
|
||||
BULLET_SPEED = 20.0 - 3.0 * 2.0 # for fire power 2.0
|
||||
WALL_HYST = 0.05 # dot-product hysteresis for wall escape
|
||||
|
||||
type WaveSurfer = ref object of Bot
|
||||
strafeDir: float # +1 or -1
|
||||
strafeDir: float # +1 or -1
|
||||
lastEnemyEnergy: float
|
||||
enemyX, enemyY: float
|
||||
enemyX, enemyY: float
|
||||
enemyVx, enemyVy: float
|
||||
enemyDir: float
|
||||
enemySpeed: float
|
||||
enemyLastDir: float
|
||||
enemyDir: float
|
||||
enemySpeed: float
|
||||
stuckTicks: int # consecutive ticks stopped while touching a wall
|
||||
|
||||
proc enemyVelocity*(direction, speed: float): tuple[vx, vy: float] =
|
||||
## World velocity of a bot from its (heading, signed speed).
|
||||
## Tank Royale convention: 0 degrees is EAST and angles increase
|
||||
## counter-clockwise, so vx = cos(heading)*speed and vy = sin(heading)*speed
|
||||
## (the opposite of classic Robocode).
|
||||
(vx: cos(degToRad(direction)) * speed,
|
||||
vy: sin(degToRad(direction)) * speed)
|
||||
|
||||
method onRoundStarted*(bot: WaveSurfer, e: RoundStartedEvent) =
|
||||
setAdjustGunForBodyTurn(true)
|
||||
@@ -30,6 +39,11 @@ method onRoundStarted*(bot: WaveSurfer, e: RoundStartedEvent) =
|
||||
bot.lastEnemyEnergy = 100.0
|
||||
bot.enemyX = 0.0
|
||||
bot.enemyY = 0.0
|
||||
bot.enemyVx = 0.0
|
||||
bot.enemyVy = 0.0
|
||||
bot.enemyDir = 0.0
|
||||
bot.enemySpeed = 0.0
|
||||
bot.stuckTicks = 0
|
||||
|
||||
method onScannedBot*(bot: WaveSurfer, e: ScannedBotEvent) =
|
||||
# Detect bullet fire: enemy energy dropped by 0.1..3.0
|
||||
@@ -44,8 +58,9 @@ method onScannedBot*(bot: WaveSurfer, e: ScannedBotEvent) =
|
||||
bot.enemyY = e.y
|
||||
bot.enemyDir = e.direction
|
||||
bot.enemySpeed = e.speed
|
||||
bot.enemyVx = sin(degToRad(e.direction)) * e.speed
|
||||
bot.enemyVy = cos(degToRad(e.direction)) * e.speed
|
||||
let v = enemyVelocity(e.direction, e.speed)
|
||||
bot.enemyVx = v.vx
|
||||
bot.enemyVy = v.vy
|
||||
|
||||
# Linear prediction: estimate where enemy will be when bullet arrives
|
||||
let dx = e.x - getX()
|
||||
@@ -64,10 +79,37 @@ method run*(bot: WaveSurfer) =
|
||||
while isRunning():
|
||||
setRadarTurnRate(45.0)
|
||||
|
||||
# Strafe perpendicular to enemy
|
||||
if bot.enemyX != 0.0 or bot.enemyY != 0.0:
|
||||
let toEnemy = directionTo(getX(), getY(), bot.enemyX, bot.enemyY)
|
||||
let perpDir = toEnemy + 90.0 * bot.strafeDir
|
||||
let x = getX()
|
||||
let y = getY()
|
||||
let arenaW = getArenaWidth().float
|
||||
let arenaH = getArenaHeight().float
|
||||
let dl = x
|
||||
let dr = arenaW - x
|
||||
let db = y
|
||||
let dt = arenaH - y
|
||||
let nearest = min(min(dl, dr), min(db, dt))
|
||||
|
||||
# Detect a pin. Tank Royale stops a bot dead on wall contact (no sliding),
|
||||
# so in a corner both perpendicular options push into one of the two walls.
|
||||
if abs(getSpeed()) < 0.5 and nearest < WALL_MARGIN:
|
||||
inc bot.stuckTicks
|
||||
else:
|
||||
bot.stuckTicks = 0
|
||||
|
||||
var toEnemy = getDirection()
|
||||
let haveEnemy = bot.enemyX != 0.0 or bot.enemyY != 0.0
|
||||
if haveEnemy:
|
||||
toEnemy = directionTo(x, y, bot.enemyX, bot.enemyY)
|
||||
|
||||
if bot.stuckTicks > 3:
|
||||
# Pinned (usually a corner): head straight for the arena centre until
|
||||
# moving again, then resume the perpendicular strafe.
|
||||
let escapeDir = directionTo(x, y, arenaW * 0.5, arenaH * 0.5)
|
||||
let delta = normalizeRelativeAngle(escapeDir - getDirection())
|
||||
setTurnRate(delta.clamp(-MAX_TURN_RATE, MAX_TURN_RATE))
|
||||
elif haveEnemy:
|
||||
# Strafe perpendicular to enemy
|
||||
let perpDir = toEnemy + 90.0 * bot.strafeDir
|
||||
let bodyDelta = normalizeRelativeAngle(perpDir - getDirection())
|
||||
setTurnRate(bodyDelta.clamp(-MAX_TURN_RATE, MAX_TURN_RATE))
|
||||
else:
|
||||
@@ -75,14 +117,28 @@ method run*(bot: WaveSurfer) =
|
||||
|
||||
setTargetSpeed(STRAFE_SPEED)
|
||||
|
||||
# Reverse near walls
|
||||
let x = getX()
|
||||
let y = getY()
|
||||
let arenaW = getArenaWidth().float
|
||||
let arenaH = getArenaHeight().float
|
||||
if x < WALL_MARGIN or x > arenaW - WALL_MARGIN or
|
||||
y < WALL_MARGIN or y > arenaH - WALL_MARGIN:
|
||||
bot.strafeDir *= -1.0
|
||||
# Wall escape. Instead of a per-tick flip (which jitters in place) the
|
||||
# strafe sign is *selected* every tick to point the perpendicular heading
|
||||
# away from the nearest wall. It only changes when the other option is
|
||||
# clearly better (hysteresis), so it cannot chatter; and because it is
|
||||
# re-asserted each tick it also overrides a fire-triggered reversal that
|
||||
# would otherwise drive the bot back into the wall.
|
||||
if nearest < WALL_MARGIN and bot.stuckTicks <= 3:
|
||||
# Outward wall normal (points from the arena towards that wall).
|
||||
var nx = 0.0
|
||||
var ny = 0.0
|
||||
if nearest == dl: nx = -1.0
|
||||
elif nearest == dr: nx = 1.0
|
||||
elif nearest == db: ny = -1.0
|
||||
else: ny = 1.0
|
||||
# Pick the strafe sign whose perpendicular heading escapes the wall
|
||||
# (smallest component along the outward normal).
|
||||
let candA = toEnemy + 90.0 * bot.strafeDir
|
||||
let candB = toEnemy - 90.0 * bot.strafeDir
|
||||
let dotA = cos(degToRad(candA)) * nx + sin(degToRad(candA)) * ny
|
||||
let dotB = cos(degToRad(candB)) * nx + sin(degToRad(candB)) * ny
|
||||
if dotA > dotB + WALL_HYST:
|
||||
bot.strafeDir *= -1.0
|
||||
|
||||
go()
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
## Direct assertion that WaveSurfer decomposes an enemy's (heading, speed) into
|
||||
## a world velocity with the Tank Royale angle convention: 0 degrees = EAST,
|
||||
## angles increase counter-clockwise, so vx = cos(h)*s and vy = sin(h)*s.
|
||||
##
|
||||
## The classic-Robocode convention (0 = north, clockwise) would swap sin/cos;
|
||||
## this test fails if anyone reintroduces that bug.
|
||||
|
||||
import std/[math, unittest]
|
||||
import ../test_framework/adversaries/WaveSurfer/src/WaveSurfer
|
||||
|
||||
const eps = 1e-9
|
||||
|
||||
proc close(a, b: float): bool = abs(a - b) < eps
|
||||
|
||||
suite "WaveSurfer enemy velocity decomposition (TR: 0=east, CCW+)":
|
||||
test "east heading is +x":
|
||||
let v = enemyVelocity(0.0, 10.0)
|
||||
check close(v.vx, 10.0)
|
||||
check close(v.vy, 0.0)
|
||||
|
||||
test "north heading (90 deg) is +y":
|
||||
let v = enemyVelocity(90.0, 10.0)
|
||||
check close(v.vx, 0.0)
|
||||
check close(v.vy, 10.0)
|
||||
|
||||
test "west heading (180 deg) is -x":
|
||||
let v = enemyVelocity(180.0, 10.0)
|
||||
check close(v.vx, -10.0)
|
||||
check close(v.vy, 0.0)
|
||||
|
||||
test "south heading (270 deg) is -y":
|
||||
let v = enemyVelocity(270.0, 10.0)
|
||||
check close(v.vx, 0.0)
|
||||
check close(v.vy, -10.0)
|
||||
|
||||
test "diagonal 45 deg splits evenly":
|
||||
let v = enemyVelocity(45.0, 10.0)
|
||||
check close(v.vx, 10.0 / sqrt(2.0))
|
||||
check close(v.vy, 10.0 / sqrt(2.0))
|
||||
|
||||
test "negative speed reverses the velocity":
|
||||
let v = enemyVelocity(0.0, -5.0)
|
||||
check close(v.vx, -5.0)
|
||||
check close(v.vy, 0.0)
|
||||
|
||||
test "explicitly differs from the classic sin/cos swap":
|
||||
# The old (buggy) decomposition. If the implementation ever matches this
|
||||
# at a heading where cos != sin, the test fails.
|
||||
for heading in [30.0, 60.0, 90.0, 120.0]:
|
||||
let v = enemyVelocity(heading, 8.0)
|
||||
let swappedX = sin(degToRad(heading)) * 8.0
|
||||
let swappedY = cos(degToRad(heading)) * 8.0
|
||||
check not (close(v.vx, swappedX) and close(v.vy, swappedY))
|
||||
Reference in New Issue
Block a user