Add radar lock and melee sweep to DevControlBot

The bot now drives its radar every tick. It still never moves and never
fires: body, turret and gun stay white ("not programmed"), while the
radar paints itself black at init to mark itself as working.

- modules/radar.nim replaces the earlier multi-module radar design with a
  single module holding the whole of it: target memory, mode selection
  and both commands
- two commands, picked from the only fact the bot is sure of (the API's
  getEnemyCount()): one enemy plus a target in hand -> LOCK, servo onto
  its bearing; anything else -> SWEEP at 45 deg/tick, the radar's physics
  cap, a full revolution every 8 ticks
- the lock turns by 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.
  That crossing is what produces a fresh ScannedBotEvent every tick: a
  scan per tick, with no rescan to wait for. Turning by the bare error
  parks the radar on the bearing and the scans stop
- commandRadar() is called before go() on every path, so there is no idle
  branch to fall into and switching modes costs zero turns
- idea derived from ModularBot's radar (Apache-2.0) via common_libs/;
  common_libs/ is not edited in place
- AGENTS.md gains mermaid behaviour diagrams plus pointers to the physics
  and coordinate references
- version bumped to 1.1.0 in both DevControlBot.nimble and
  DevControlBot.json so the two agree

Compiled against robocode_tankroyale_botapi 1.0.7.
This commit is contained in:
2026-10-04 16:22:42 +02:00
parent 305c3977a6
commit caa096d6c9
6 changed files with 316 additions and 18 deletions
+87 -5
View File
@@ -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