diff --git a/common_libs/test_framework/adversaries/OscillatorBot/out/OscillatorBot b/common_libs/test_framework/adversaries/OscillatorBot/out/OscillatorBot index 2970a80..ac8f39b 100755 Binary files a/common_libs/test_framework/adversaries/OscillatorBot/out/OscillatorBot and b/common_libs/test_framework/adversaries/OscillatorBot/out/OscillatorBot differ diff --git a/common_libs/test_framework/adversaries/OscillatorBot/src/OscillatorBot.nim b/common_libs/test_framework/adversaries/OscillatorBot/src/OscillatorBot.nim index f071468..a14dd13 100644 --- a/common_libs/test_framework/adversaries/OscillatorBot/src/OscillatorBot.nim +++ b/common_libs/test_framework/adversaries/OscillatorBot/src/OscillatorBot.nim @@ -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() diff --git a/common_libs/test_framework/adversaries/PatternMover/out/PatternMover b/common_libs/test_framework/adversaries/PatternMover/out/PatternMover index d7b7b35..7829457 100755 Binary files a/common_libs/test_framework/adversaries/PatternMover/out/PatternMover and b/common_libs/test_framework/adversaries/PatternMover/out/PatternMover differ diff --git a/common_libs/test_framework/adversaries/PatternMover/src/PatternMover.nim b/common_libs/test_framework/adversaries/PatternMover/src/PatternMover.nim index 94778a3..db48ad2 100644 --- a/common_libs/test_framework/adversaries/PatternMover/src/PatternMover.nim +++ b/common_libs/test_framework/adversaries/PatternMover/src/PatternMover.nim @@ -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() diff --git a/common_libs/test_framework/adversaries/RandomMover/out/RandomMover b/common_libs/test_framework/adversaries/RandomMover/out/RandomMover index ab3f2da..7f8ea65 100755 Binary files a/common_libs/test_framework/adversaries/RandomMover/out/RandomMover and b/common_libs/test_framework/adversaries/RandomMover/out/RandomMover differ diff --git a/common_libs/test_framework/adversaries/RandomMover/src/RandomMover.nim b/common_libs/test_framework/adversaries/RandomMover/src/RandomMover.nim index e3d094b..39bca23 100644 --- a/common_libs/test_framework/adversaries/RandomMover/src/RandomMover.nim +++ b/common_libs/test_framework/adversaries/RandomMover/src/RandomMover.nim @@ -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) = diff --git a/common_libs/test_framework/adversaries/WaveSurfer/out/WaveSurfer b/common_libs/test_framework/adversaries/WaveSurfer/out/WaveSurfer index a457a83..399ae10 100755 Binary files a/common_libs/test_framework/adversaries/WaveSurfer/out/WaveSurfer and b/common_libs/test_framework/adversaries/WaveSurfer/out/WaveSurfer differ diff --git a/common_libs/test_framework/adversaries/WaveSurfer/src/WaveSurfer.nim b/common_libs/test_framework/adversaries/WaveSurfer/src/WaveSurfer.nim index d1c2f1f..b4156ad 100644 --- a/common_libs/test_framework/adversaries/WaveSurfer/src/WaveSurfer.nim +++ b/common_libs/test_framework/adversaries/WaveSurfer/src/WaveSurfer.nim @@ -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() diff --git a/common_libs/tests/test_wavesurfer_velocity.nim b/common_libs/tests/test_wavesurfer_velocity.nim new file mode 100644 index 0000000..eed1547 --- /dev/null +++ b/common_libs/tests/test_wavesurfer_velocity.nim @@ -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))