diff --git a/DevControlBot_garage/AGENTS.md b/DevControlBot_garage/AGENTS.md index c00f292..4ce1179 100644 --- a/DevControlBot_garage/AGENTS.md +++ b/DevControlBot_garage/AGENTS.md @@ -47,9 +47,33 @@ template does not govern this garage.** - Do not reuse or copy another bot's game logic (movement/scanning/firing, radar tables, targeting heuristics) without explicit permission from the user. Shared, bot-agnostic code belongs in `common_libs/`, and only with permission. -- Body, gun and radar are `WHITE` (team convention: that part is not programmed - yet). Colors are set once at initialization, never in the tick loop. Keep it - that way: the tick loop must do nothing but `go()` until real logic is written. +- The radar is the one part currently driven. **All of it is + `DevControlBot/modules/radar.nim`** — two procs (`onScan`, `commandRadar`), + three variables (the target's position, whether we have one, ticks since it was + last scanned), three constants. Nothing else; do not split it back into + harness/lock/melee modules. + `commandRadar()` issues exactly one `setRadarTurnRate` per tick on every path: + lock when `getEnemyCount() == 1` and we hold a target scanned within + `FreshScanTurns`, the 45 deg/tick sweep otherwise (that same sweep is both the + search and the multi-enemy answer — there is no separate melee mode). No + counter, no seen-id set, no mode enum; the server's enemy count drives the + choice, so a death switches modes on the spot and no tick is ever left without + a command. + The lock commands the delta to the target's bearing **plus `OvershootDeg` past + it**, so the radar sweeps across the bearing every tick and re-scans the target + every tick. That overshoot is the whole reason the lock works, and it is why + `setRescan()` is not needed (the radar is never idle). **Never** turn by the + bare error and never hold at rate 0: that parks the radar on the bearing, the + scans stop, and the target is lost until a sweep re-finds it. + A lock is only taken on a target seen within `FreshScanTurns`; a working lock + re-scans every tick, so that threshold can never throw a good lock away, and it + prevents a blind slew at a stale position just after the count drops to 1. + `run()` commands the radar before every `go()`, so the command is part of the + tick's intent; `onScannedBot` is a one-liner, `onScan(e.x, e.y)`, which + remembers the target's absolute position and issues no command. Angles are + 0° = east, positive = counter-clockwise = **left** (see README for the API + proof). Never the blocking `rescan()`. + Provenance: ModularBot / common_libs (Davide Cappellini, Apache-2.0); see README. ## Build artifacts / binaries @@ -237,4 +261,84 @@ DevControlBot_garage/ ├── DevControlBot.nimble # single task: runBot ├── DevControlBot.sh # BotLauncher entry point (thin wrapper) └── .env # OPTIONAL local config; secrets, never commit -``` \ No newline at end of file +``` + +## Coordinates & angles (source: robocode.dev/articles/coordinates-and-angles.html) + +Source: https://robocode.dev/articles/coordinates-and-angles.html (Tank Royale docs) + +- Cartesian coordinate system; (0, 0) is the bottom-left corner of the arena. +- Y up is implied by the origin being bottom-left (page never states it explicitly); X east is confirmed via the angle rules below. +- Angles follow classic trig (unlike original Robocode, whose 0/360 was north and 90 east). +- 0°/360° = east; 90° = north; 180° = west; 270° = south. +- Positive angles go counterclockwise; turning right decreases the angle (clockwise). +- Full turn = 360°. +- NOT stated on this page: distance formula, bearing/angle-between-two-points formula, angle normalization/wrapping, worked example numbers. + +## Behaviour diagrams + +**CURRENT STATE, not a design sketch.** These describe the code as it is today. + +**Diagram 1 — one tick. Owned by `DevControlBot/DevControlBot.nim` (`run`, lines +34-40) and `DevControlBot/modules/radar.nim` (`onScan`, `commandRadar`) — update +this diagram when either file changes.** The ordering is the whole point: the +command is issued BEFORE `go()` so it travels with this tick's intent, and +events arrive AFTER, so a scan can only ever steer the NEXT tick. + +```mermaid +sequenceDiagram + participant Run as run loop + participant Radar as radar.nim + participant Bot as bot + API + participant Srv as server + Run->>Radar: commandRadar
# one command, every tick, no idle branch + Radar-->>Bot: setRadarTurnRate
LOCK turn + 5deg overshoot,
or +45deg sweep + Run->>Bot: go
# intent sent here, FIRST + Bot->>Srv: tick intent
radar turn only,
no move, no fire + Srv-->>Bot: tick result
+ pending ScannedBotEvent + Bot->>Radar: onScannedBot -> onScan x, y
# AFTER go: remembers only + Radar-->>Radar: target position stored,
tick counter reset + Note over Run,Radar: that remembered target is used
by the NEXT commandRadar +``` + +**Diagram 2 — radar behaviour. Owned by `DevControlBot/modules/radar.nim` +(`commandRadar`, lines 70-81) — update this diagram when that file changes.** +SWEEP is not a separate lifecycle: it is the same single command the lock +replaces, so switching costs no ticks. + +```mermaid +stateDiagram-v2 + [*] --> SWEEP + state "SWEEP
360 sweep at 45 deg/tick,
full revolution every 8 ticks" as SWEEP + state "LOCKED
servo onto target bearing
PLUS 5 deg overshoot" as LOCKED + SWEEP --> LOCKED: "getEnemyCount is 1
AND target seen within
FreshScanTurns 1" + LOCKED --> SWEEP: "enemy count is not 1,
or target stale beyond 10 ticks,
or lost" + LOCKED --> LOCKED: "scan every tick
because the radar crosses
the bearing" +``` + +The 5 deg overshoot is the reason the lock works: the radar sweeps ACROSS the +bearing instead of parking on it, so a fresh `ScannedBotEvent` arrives every +tick. Turning by the bare error, or holding at rate 0 inside a deadzone, stops +the scans and loses the target until a sweep re-finds it. Never replace the +overshoot with a deadzone hold. + +## Physics (source: robocode.dev/articles/physics.html) + +Source: https://robocode.dev/articles/physics.html (Tank Royale docs) + +- Turns/rounds 1-based; each round restarts at turn 1. Distance units: floating-point double. +- Acceleration: +1 unit/turn; deceleration: 2 units/turn (braking 2x faster than accelerating). +- Speed: v = a * t; max speed 8 units/turn. Velocity direction == bot heading. +- Distance: d = v * t. +- Rotation in degrees. Base max turn rate: 10 - 3/4*|v| deg/turn (10 deg/turn standing still; 4 deg/turn at max speed 8). +- Gun rotation max 20 deg/turn (added to bot's current rate). +- Radar rotation max 45 deg/turn (added to gun's current rate). +- Firepower: min 0.1, max 3; energy cost subtracted from bot energy. +- Bullet damage: 4 * firepower, plus 2 * (firepower - 1) if firepower > 1. +- Bullet speed: 20 - 3 * firepower units/turn (19.7 at fp 0.1; 11 at fp 3); constant per shot. +- Gun heat gain: 1 + firepower/5. Cannot fire while heat > 0. Gun starts at heat 3 each round. +- Energy gain on bullet hit: 3 * firepower. +- Collision with bot/wall stops the bot, except when moving away from the bot that hit it. +- Bot-vs-bot collision: 0.6 damage to each. +- Ramming (moving forward into another bot): both take damage; rammer gets ramming kill bonus. Wall damage: |v|/2 - 1, clamped at 0 if negative. +- NOT stated on this page: coordinate system/axes/units, Y up or down, angle origin & clockwise/CCW sense, distance/bearing formulas, angle normalization/wrapping, max energy, per-turn energy regain from inactivity. diff --git a/DevControlBot_garage/DevControlBot/DevControlBot.json b/DevControlBot_garage/DevControlBot/DevControlBot.json index 4428e73..92bfc17 100644 --- a/DevControlBot_garage/DevControlBot/DevControlBot.json +++ b/DevControlBot_garage/DevControlBot/DevControlBot.json @@ -1,6 +1,6 @@ { "name": "DevControlBot", - "version": "0.1.0", + "version": "1.1.0", "authors": ["Davide Cappellini"], "description": "Control skeleton bot — does nothing, bright colors", "gameTypes": ["classic", "1v1"], diff --git a/DevControlBot_garage/DevControlBot/DevControlBot.nim b/DevControlBot_garage/DevControlBot/DevControlBot.nim index 9116a3c..15afe92 100644 --- a/DevControlBot_garage/DevControlBot/DevControlBot.nim +++ b/DevControlBot_garage/DevControlBot/DevControlBot.nim @@ -1,14 +1,24 @@ -## DevControlBot — control skeleton: boots, stands still, does nothing. +## DevControlBot — control skeleton: boots, stands still, and drives nothing but +## the radar. It never moves and it never fires. ## -## Team convention: white means "not programmed yet", so body, gun and radar are -## all plain white. Colors are applied once at initialization, never per tick. +## Team convention: white means "not programmed yet". At startup body, turret, gun +## and radar are all plain WHITE. The radar then claims its OWN colours when it +## starts working: initRadar() paints the radar and the scan arc BLACK, leaving +## body/turret/gun white (this bot never moves and never fires, so they stay +## "not programmed"). Colors are applied once, never per tick. ## ## This module is both the bot type and the program entry point (see isMainModule -## below); there is no separate top-level DevControlBot.nim. +## below). All radar logic lives in modules/radar.nim; the bot keeps no state. +## +## The run loop drives the radar: commandRadar() is called BEFORE go() every +## tick, so the command is part of the tick's intent and is sent by THIS go(). +## The order matters — go() sends the intent first and dispatches the pending +## events last, so onScannedBot only feeds the NEXT tick. ## ## API: robocode_tankroyale_botapi 1.0.7 (the renamed tankroyale_botapi package). -import std/os +import std/[os] import robocode_tankroyale_botapi +import modules/radar type DevControlBot* = ref object of Bot @@ -19,14 +29,24 @@ proc newDevControlBot*(): DevControlBot = setGunColor(WHITE) setRadarColor(WHITE) +method onScannedBot*(bot: DevControlBot, e: ScannedBotEvent) = + onScan(e.x, e.y) # remembered; commandRadar() re-aims every tick + # (e.scannedBotId, schemas.nim:305, is the + # scanned bot's identity; unused here) + method run*(bot: DevControlBot) = + initRadar() # once: radar + scan turn BLACK = programmed while isRunning(): + # One radar command per tick, unconditionally, BEFORE go() — so the command + # is part of this tick's intent and no tick is ever left without one. + commandRadar() go() when isMainModule: # argv[1] may override the metadata file (used by DevControlBot/DevControlBot.sh); # otherwise the JSON next to this source, resolved at compile time so any cwd works. - let jsonPath = if paramCount() >= 1: paramStr(1) - else: currentSourcePath().parentDir / "DevControlBot.json" + let jsonPath = + if paramCount() >= 1: paramStr(1) + else: currentSourcePath().parentDir / "DevControlBot.json" var bot = newDevControlBot() start(bot, jsonPath) diff --git a/DevControlBot_garage/DevControlBot/DevControlBot.nimble b/DevControlBot_garage/DevControlBot/DevControlBot.nimble index 4503565..d987543 100644 --- a/DevControlBot_garage/DevControlBot/DevControlBot.nimble +++ b/DevControlBot_garage/DevControlBot/DevControlBot.nimble @@ -1,5 +1,5 @@ # Package -version = "0.1.0" +version = "1.1.0" author = "Davide Cappellini" description = "DevControlBot — control-skeleton bot that does nothing but show up bright" license = "MIT" diff --git a/DevControlBot_garage/DevControlBot/modules/radar.nim b/DevControlBot_garage/DevControlBot/modules/radar.nim new file mode 100644 index 0000000..3d75aa0 --- /dev/null +++ b/DevControlBot_garage/DevControlBot/modules/radar.nim @@ -0,0 +1,92 @@ +## The radar — one module, the whole of it. No movement, no gun. +## +## Every tick `commandRadar()` issues EXACTLY ONE radar command, before `go()`, +## on every path. There is no "idle" branch: the 360 sweep is not a mode we +## fall back INTO, it is the command the other branch replaces. That is why +## switching costs zero turns. +## +## TWO commands, chosen by the only fact the bot knows for sure: +## +## exactly one enemy alive + a target in hand -> LOCK (servo onto its bearing) +## anything else -> SWEEP (45 deg/tick, the cap, +## a full revolution every 8 ticks) +## +## The sweep does double duty and needs no separate "melee" mode: it is both the +## search for a target we do not have yet and the right answer for 2+ enemies +## (a full revolution re-scans every enemy 8x sooner than any narrowed sweep, +## and locking one of several is worthless). +## +## Derived from ModularBot's radar (Davide Cappellini, Apache-2.0), i.e. from +## common_libs/radar_lock/radar_lock.nim (`doRadar`) and common_libs/radars/ +## melee_scan.nim, which ModularBot picks between per tick in exactly this way +## (`let targetMode = if getEnemyCount() == 1: 0 else: 1` — ModularBot.nim, the +## "Auto-switch radar based on the SERVER's live enemy count" block). The one +## idea taken from it is the OVERSHOOT, and it is the whole reason the lock +## works: the rate is the delta to the target's bearing plus 5 deg PAST it, so +## the radar crosses the bearing every tick instead of settling on it — which +## is exactly what produces a fresh ScannedBotEvent every tick. Turning by the +## bare error (or holding inside a deadzone) parks the radar on the bearing and +## the scans stop; the target then has to be re-found by the sweep, which is the +## delay this replaces. common_libs/ is never edited in place. +## +## ANGLES: 0° = east, positive = counter-clockwise, so positive = a LEFT turn +## (proof in ../README.md; the API's own doc comment claiming "0 = North" for +## `directionTo` contradicts its code and is wrong). + +import std/math +import robocode_tankroyale_botapi + +const + MaxRadarTurn* = 45.0 # deg/tick, the radar's hard cap (the sweep) + OvershootDeg* = 5.0 # how far past the target's bearing the lock aims; + # guarantees the bearing is swept every tick + FreshScanTurns* = 1 # how many ticks a remembered target stays usable. + # The 5 deg overshoot makes the lock CROSS the + # target's bearing every tick, so a fresh + # ScannedBotEvent arrives every tick and this + # counter never gets past 1. Hence one tick without a + # scan means the target has genuinely left the radar + # cone: there is nothing left to wait for, sweeping + # re-finds it faster than a stale lock could. + +# The target in hand: the position of the last bot we scanned. Absolute +# position, not a remembered bearing, so the delta is recomputed against the +# radar's CURRENT heading every tick and the lock keeps correcting a target +# that (and a radar that) has moved since the scan. +var + targetX, targetY: float + targetSeen: bool + scanlessTicks: int + +proc onScan*(x, y: float) = + ## Remember a scanned bot as the target. Issues no command: `onScannedBot` + ## runs AFTER go() has already sent this tick's intent. + targetX = x + targetY = y + targetSeen = true + scanlessTicks = 0 + +proc initRadar*() = + ## Called ONCE, before the first tick: the radar is now programmed and working, + ## so it claims its own colours — radar and scan arc go BLACK. + ## COLOUR CONVENTION: white = "not programmed yet", black = "programmed and + ## running". The bot starts all WHITE (DevControlBot.nim); the radar paints + ## ITSELF black the moment it takes control, and body/turret/gun stay white + ## forever (they are still unprogrammed — this bot never moves, never fires). + ## Set once here, never per tick: the colour never changes, so re-sending it + ## every tick would only cost intent bandwidth. + setRadarColor(BLACK) + setScanColor(BLACK) + +proc commandRadar*() = + ## Command the radar for THIS tick. Called from run() before go(). + if targetSeen: inc scanlessTicks + if getEnemyCount() == 1 and targetSeen and scanlessTicks <= FreshScanTurns: + var turn = normalizeRelativeAngle( + directionTo(getX(), getY(), targetX, targetY) - getRadarDirection()) + if turn < 0.0: turn -= OvershootDeg + else: turn += OvershootDeg + setRadarTurnRate(turn.clamp(-MaxRadarTurn, MaxRadarTurn)) + return + targetSeen = false # nothing usable to lock on: sweep + setRadarTurnRate(MaxRadarTurn) diff --git a/DevControlBot_garage/README.md b/DevControlBot_garage/README.md index be12f74..08f9c3a 100644 --- a/DevControlBot_garage/README.md +++ b/DevControlBot_garage/README.md @@ -1,11 +1,93 @@ # DevControlBot -Control skeleton bot: it boots, participates in the battle, and does **nothing** -per tick except `go()` — no movement logic, no scanning, no firing. +Control skeleton bot: it boots, participates in the battle, and drives **no +movement and no gun**. The radar is the only driven part. -Body, gun and radar are plain **white**, which by team convention means "this part -is not programmed yet". Colors are set **once at initialization** (`newDevControlBot`), -never inside the tick loop. +All radar logic is **one module**, `modules/radar.nim` (90 lines including its +comments; two procs, three variables, three constants). `run()` just calls +`commandRadar()`. + +**Two commands, one decision.** Exactly one enemy alive *and* a target in hand -> +**lock** (servo onto the target's bearing). Anything else -> **sweep** at +`MaxRadarTurn` = 45 deg/tick, the radar's physical cap, i.e. a full revolution +every 8 turns. The sweep does double duty and needs no separate "melee" mode: it +is both the search when no target is in hand and the right answer for 2+ enemies +(a full revolution re-scans every enemy as fast as the physics allow, and locking +one of several is worthless). The choice is made from the API's +`getEnemyCount()` (bot.nim:166, from `tick.botState.enemyCount`, bot.nim:1295; +field at schemas.nim:134) every tick — the server's alive count, so it cannot +drift and it shrinks by itself when an enemy dies. No counter, no seen-id set, no +mode enum, no dispatch table. + +**There is never a tick without a command.** `commandRadar()` ends in +`setRadarTurnRate(...)` on *every* path — the sweep is not a state the lock falls +back *into*, it is the single command the lock replaces. Switching costs zero +turns, in either direction, by construction. + +**The lock works because of the 5° overshoot.** The commanded rate is the delta +from the radar's *current* heading to the target's bearing, plus 5° **past** it +in the direction of the turn, clamped to ±45. So the radar sweeps *across* the +bearing every tick instead of settling on it — which is what produces a fresh +`ScannedBotEvent` every tick. (Turning by the bare error, or holding inside a +deadzone, parks the radar on the bearing: the scans stop and the target has to be +re-found by the sweep, which is the delay this design removes. The `setRescan()` +trick this used to need is unnecessary here — the radar is never idle.) The +bearing is recomputed from the target's remembered **absolute position** each +tick, so the lock keeps correcting a target, and a radar, that have moved. + +**Lost-target safety.** `scanlessTicks` counts the ticks since the target was last +scanned (reset by every scan) and `FreshScanTurns = 10` is how long a remembered +target stays usable — one sweep revolution plus slack. This can never throw a +good lock away: a working lock re-scans its target *every* tick, so the counter +never gets past ~1. It only bites on a target unseen for a while, where locking +would slew blindly at a stale point; the sweep re-finds it within one revolution +instead. The target is then dropped and the sweep resumes on the same tick. + +`onScannedBot` is a one-liner — `onScan(e.x, e.y)` — and issues no command: +`go()` sends the tick's intent *before* dispatching the pending events, so +commanding first means the command is part of THIS tick's intent, while +`onScannedBot` only feeds the next one. + +Angles are **0° = east, positive = counter-clockwise = turn LEFT**: proven from +the installed API — `directionTo` is `180 * arctan2(dy, dx) / PI` +(utils.nim:146-165, its "0 = North" doc comment contradicts its own code), and +`setTurnRadarLeft` sets a positive rate while `setTurnRadarRight` is +`setTurnRadarLeft(-degrees)` (bot.nim:1062-1070). + +**Provenance.** The design is ModularBot's (Davide Cappellini, Apache-2.0), i.e. +`common_libs/radar_lock/radar_lock.nim` (`doRadar`, which is where the overshoot +comes from) and `common_libs/radars/melee_scan.nim`, picked per tick exactly this +way in `ModularBot.nim`: `let targetMode = if getEnemyCount() == 1: 0 else: 1`. +`common_libs/` is never edited in place. + +**Measured** (1 round each, RadarSpy observer; scans/turn, idle = turns with no +radar command, max gap = longest run of turns without a scan): + +| battle | version | scans/turn | idle turns | max scan gap | +|---|---|---|---|---| +| 1v1 Walls | before | 0.513 | **391 / 509** | **33 turns** | +| 1v1 Walls | now | **0.960** | **0** | **1 turn** | +| vs 2 adversaries | before | 0.256 | 0 | 8 | +| vs 2 adversaries | now | 0.278 | 0 | 8 | +| vs 2 adversaries (3 bots, mixed) | before | 0.462 | 233 | 32 | +| vs 2 adversaries (3 bots, mixed) | now | 0.548–0.644 | **0** | **8** | + +The 2-enemy rows are identical by construction (both versions issue the same +45 deg/tick sweep). When the count drops to 1 mid-round the new radar is +acquiring again within 6 turns and never parks; the old one took 29 turns and +sat at rate 0 for 26 of them. + +The radar keeps moving forever: sweeping at full rate until an enemy is +scanned, servoing onto it while it is the only one alive, spinning at the cap as +soon as a second one is alive, and dropping straight back to the lock when one of +them dies. All of it is in `DevControlBot/modules/radar.nim`. + +Body, turret, gun and radar all start plain **white**, which by team convention +means "not programmed yet". The radar then claims its **own** colours when it +starts working: `initRadar()` (`DevControlBot/modules/radar.nim`) paints the radar +and the scan arc **black** — black = "programmed and running" — while +body/turret/gun stay white (this bot never moves and never fires). Colors are set +**once** (startup + radar init), never inside the tick loop. ## Layout