caa096d6c9
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.
296 lines
14 KiB
Markdown
296 lines
14 KiB
Markdown
# DevControlBot
|
||
|
||
Control skeleton bot: it boots, participates in the battle, and drives **no
|
||
movement and no gun**. The radar is the only driven part.
|
||
|
||
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
|
||
|
||
```
|
||
DevControlBot_garage/
|
||
├── AGENTS.md
|
||
├── README.md
|
||
└── DevControlBot/
|
||
├── DevControlBot.nim # bot type + entry point (isMainModule)
|
||
├── DevControlBot.json # bot metadata
|
||
├── DevControlBot.nimble # the single `runBot` task
|
||
├── DevControlBot.sh # BotLauncher entry point
|
||
└── .env # OPTIONAL local config (secrets; never commit)
|
||
```
|
||
|
||
There is no `src/`, no `tests/`, no `out/`, no `config.nim`, no `nimble.paths`
|
||
and no vendor directory. `DevControlBot.nim` is both the bot implementation and
|
||
the program entry point.
|
||
|
||
## Dependencies
|
||
|
||
Only two, both installed **globally** with Nimble and resolved from the global
|
||
package store — nothing vendored, no `nimble.paths`, no `nimble develop`:
|
||
|
||
- `robocode_tankroyale_botapi >= 1.0.7` (the renamed `tankroyale_botapi`)
|
||
- `jsony >= 1.1.5`
|
||
|
||
```sh
|
||
nimble install robocode_tankroyale_botapi
|
||
```
|
||
|
||
The `runBot` task asks Nimble where each of those packages actually lives
|
||
(`nimble path <pkg>`) and passes explicit `--path:` flags to `nim c`. This is
|
||
required because the compiler's own `nimblepath="$home/.nimble/pkgs2/"` only
|
||
works when `$HOME` is set — with an empty environment (`env -i`, Nix build
|
||
sandbox, CI) the packages are otherwise not on the search path. If a package is
|
||
not installed, the build stops with `run: nimble install <pkg>` instead of a
|
||
compiler error.
|
||
|
||
## Run
|
||
|
||
```sh
|
||
./DevControlBot/DevControlBot.sh
|
||
```
|
||
|
||
`DevControlBot.sh` is the **BotLauncher entry point**. It is a thin wrapper: it
|
||
locates itself, `chdir`s to the directory holding the `.nimble` file, forwards
|
||
arguments and signals to `nimble runBot`, and propagates the real exit code.
|
||
All build/run/cleanup logic lives in the `runBot` task of `DevControlBot.nimble`,
|
||
which creates a throwaway `mktemp -d` build dir, compiles there, runs, and always
|
||
removes the dir via an `EXIT`/`INT`/`TERM` trap. **Nothing is left behind** — this
|
||
bot never creates an `out/` directory.
|
||
|
||
`runBot` also accepts a metadata path: **as the first positional argument**. If it
|
||
ends in `.json` it is used instead of the bundled `DevControlBot.json`; there is
|
||
no `--json` flag (an argument that does not end in `.json` is forwarded to the bot
|
||
as-is).
|
||
|
||
```sh
|
||
./DevControlBot/DevControlBot.sh # bundled metadata (default)
|
||
./DevControlBot/DevControlBot.sh /path/to/my.json # alt metadata
|
||
./DevControlBot/DevControlBot.sh --debug /path/to/my.json # alt metadata, debug build
|
||
```
|
||
|
||
With **no arguments at all** the wrapper does *not* print usage: it performs a
|
||
quiet RELEASE build and runs the bot with the bundled `DevControlBot.json`,
|
||
exactly as if that path had been passed. Usage is printed **only** for an
|
||
explicit `-h` / `--help` (exit 0, nothing is built).
|
||
|
||
## Configuration comes from the environment — the API's own mechanism
|
||
|
||
No configuration code exists in `DevControlBot.nim`, and none is needed. The
|
||
bot API reads its own environment variables inside `start()`:
|
||
|
||
| variable | read at | default |
|
||
|---|---|---|
|
||
| `SERVER_URL` | `robocode_tankroyale_botapi.nim:424` (documented at `:396`) | `ws://localhost:7654` |
|
||
| `SERVER_SECRET` | `robocode_tankroyale_botapi.nim:425` | `""` |
|
||
| `BOT_NAME`, `BOT_VERSION`, `BOT_AUTHORS`, … | `bot_info.nim:117-130`, used only when no JSON is found | see that file |
|
||
|
||
This is the supported mechanism the official BotLauncher uses: it puts those
|
||
variables in the bot's process environment before starting the binary. So the
|
||
only thing the build/run wrapper does is make sure the variables are *present*
|
||
in the bot's environment for local runs.
|
||
|
||
## Optional `.env` (a convenience for local runs only)
|
||
|
||
`DevControlBot/.env` is **optional**. If it is absent nothing happens: no error,
|
||
no warning beyond one informational line, the run behaves exactly as before and
|
||
the API defaults apply. It is not required, not generated, and not committed.
|
||
|
||
When present it is loaded by the **`runBot` task**, not by `DevControlBot.sh`, so
|
||
it applies identically to `./DevControlBot.sh` and to a direct `nimble runBot`
|
||
and the wrapper stays a thin wrapper. Properties:
|
||
|
||
- **Format:** plain `NAME=value` and `export NAME=value` lines; blank lines and
|
||
`#` comments ignored; anything that is not a simple `NAME=value` identifier
|
||
assignment is ignored (a `.env` can never smuggle in a command).
|
||
- **CRLF tolerant:** `CR` characters are stripped, so a Windows-edited file does
|
||
not turn the value into `bar\r`.
|
||
- **Nothing is ever printed.** Only the *names* taken from the file are reported,
|
||
never a value, and `set -x` is not used.
|
||
- **Overridable path:** `$DEVCONTROLBOT_ENV_FILE` points the loader at a
|
||
different file (useful for testing).
|
||
|
||
### Precedence: the environment always wins
|
||
|
||
A variable **already present in the environment is never overridden** by the
|
||
`.env` file. This matters because the official BotLauncher's values must win:
|
||
if a `.env` sitting in the checkout could overwrite them, this convenience would
|
||
silently break a real BotLauncher run.
|
||
|
||
The `.env` file is therefore **not** simply sourced — sourcing it would do the
|
||
opposite, because an assignment in a sourced file overwrites an already-exported
|
||
variable of the same name. Instead the file is **filtered line by line**: a
|
||
`NAME=value` line is dropped when `NAME` is already set in the environment
|
||
(tested with `[ -n "${NAME+x}" ]`, i.e. *set but possibly empty*, not merely
|
||
non-empty), and only the still-unset names are then exported with `set -a` and
|
||
sourced. The filtering is what implements the precedence.
|
||
|
||
```sh
|
||
SERVER_URL=ws://host:1234 ./DevControlBot.sh # shell wins, .env ignored for SERVER_URL
|
||
./DevControlBot.sh # .env supplies SERVER_URL
|
||
```
|
||
|
||
`.env` contains secrets, so it must never be committed; see the note in
|
||
[AGENTS.md](AGENTS.md) about the root `.gitignore`.
|
||
|
||
## Build modes: release by default, `--debug` for the classic build
|
||
|
||
| invocation | build | output |
|
||
|---|---|---|
|
||
| `DevControlBot.sh …` (default) | `nim c -d:release --hints:off` | quiet: dependency lines, one `build ok (release)`, one `running` line |
|
||
| `DevControlBot.sh --debug …` | plain `nim c` | classic diagnostics: `Hint: used config file …`, dot-progress line, `DEBUG BUILD` hint |
|
||
|
||
`--debug` is stripped by the wrapper wherever it appears in the arguments (a
|
||
repeated `--debug` is harmless) and is handed to the `runBot` task via the
|
||
exported env var `$DEVCONTROLBOT_DEBUG` (`debug` / `release`) rather than as a
|
||
task argument: an env var can never be mistaken for a bot argument, cannot
|
||
collide with the `$DEVCONTROLBOT_STATUS_FILE` exit-code mechanism, and leaves the
|
||
task's argument parsing untouched. Every other argument is forwarded verbatim.
|
||
|
||
In release mode the compiler's stdout/stderr is captured to a log and replayed
|
||
**only if the build fails**, so a failed build is always loud in both modes while
|
||
a successful release build stays short (10 lines including the no-server report,
|
||
vs 16 with `--debug`). `--help` / `-h` prints usage and exits 0 **without
|
||
building**; no arguments does the opposite — it builds and runs.
|
||
|
||
The bot needs a running Tank Royale server at `SERVER_URL` (the API's documented
|
||
default is `ws://localhost:7654`); without one the run fails with
|
||
`[start] Cannot connect to <SERVER_URL>` and exits 1 (expected during local
|
||
verification).
|
||
|
||
## Failure reporting and exit codes
|
||
|
||
Nimble's `exec` raises a NimScript exception — stack trace, escaped copy of the
|
||
whole script — for *any* non-zero exit, which made "no game server" look like a
|
||
catastrophic build crash. The `runBot` shell therefore always exits 0 and writes
|
||
the real code to the status file named by `$DEVCONTROLBOT_STATUS_FILE`, which
|
||
`DevControlBot.sh` exports and then re-exits with. The real code is preserved,
|
||
not masked.
|
||
|
||
| exit | meaning |
|
||
|---|---|
|
||
| `0` | clean run |
|
||
| `1` | bot could not connect to the game server at `SERVER_URL` (expected locally) |
|
||
| `2` | a dependency is not installed — `nimble install <pkg>` |
|
||
| `3` | compilation failed — the compiler's own errors are printed above |
|
||
| other | the bot's own exit code (runtime crash, bad metadata) |
|
||
|
||
Each mode prints its own short line, e.g.
|
||
|
||
```
|
||
[start] Cannot connect to ws://localhost:4567: Connection refused
|
||
[devcontrolbot] bot exited with code 1.
|
||
[devcontrolbot] No game server on ws://localhost:4567 (SERVER_URL).
|
||
[devcontrolbot] Start the Tank Royale server, or point SERVER_URL at a running one.
|
||
```
|
||
|
||
No `Exception raised during nimble script execution`, no stack trace, no source
|
||
dump. `nimble runBot` run directly (no status file) keeps the old behaviour and
|
||
still raises.
|
||
|
||
Interrupt handling is unchanged: SIGINT/SIGTERM are forwarded, the build dir is
|
||
removed by the `EXIT` trap and the wrapper exits 143 (SIGTERM) / 130 (SIGINT).
|
||
|
||
## Gotcha: `nimble runBot` must be run from `DevControlBot/`
|
||
|
||
The `.nimble` file lives in `DevControlBot/`, so the task is only visible from
|
||
there:
|
||
|
||
```sh
|
||
cd DevControlBot && nimble runBot # works
|
||
cd DevControlBot_garage && nimble runBot
|
||
# Error: Could not find a file with a .nimble extension inside the specified
|
||
# directory: .../DevControlBot_garage
|
||
```
|
||
|
||
`DevControlBot.sh` handles this for you by `cd`-ing itself. Note also that
|
||
`compile`/`build` are reserved Nimble builtin names — hence the `runBot` name.
|
||
|
||
## Test
|
||
|
||
There are **no tests** in this garage. The `test` task and the `tests/` directory
|
||
were removed along with the old build layout; see
|
||
[AGENTS.md](AGENTS.md) for the convention to follow when tests are added. |