4 Commits

Author SHA1 Message Date
SirStone de0dd50d6e Purge AGENTS.md of code explanation; keep inventory and diagrams
AGENTS.md is now an inventory of what exists plus the mermaid behaviour
diagrams — not a narration of the code. The source and its doc comments are
the carrier of truth for how the bot works; a prose retelling in AGENTS.md
only goes stale against them.

Removed: blocks that duplicated what the source already says, a stale line
that contradicted DevControlBot.sh's actual no-argument behaviour, and the
prior bug-fix history (which is a record of the past, not guidance for the
next change). 384 -> 238 lines.

Kept byte-identical: the behaviour diagrams, the rules (git, version/tag,
colour convention), the build and run commands, and the physics and
coordinates references.

Two constraints stay as one-liners, because they are the two ways this bot
has actually been broken: command the radar BEFORE go(), and keep all radar
logic in modules/radar.nim.
2026-10-04 16:55:05 +02:00
SirStone caa096d6c9 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.
2026-10-04 16:22:42 +02:00
SirStone 305c3977a6 Add PLAN.md rule, garage .gitignore, remove hardcoded server port hint
- AGENTS.md: mark PLAN.md as user-owned (never modify; it stays tracked)
- move the .env ignore rule to the garage root so it covers any depth
- drop the hardcoded "port 7654" hint from the no-server message; the target
  is now reported from the resolved SERVER_URL. The 7654 fallback remains
  only as the upstream TR default.
2026-10-03 16:39:02 +02:00
SirStone 59fad00b5c Add DevControlBot garage: skeleton bot with release/debug build script
A minimal Tank Royale bot that boots and stands still. Body, gun and radar
are set to white once at init (team convention: white = not programmed yet);
the tick loop only calls go().

- DevControlBot/ holds the single bot module, its metadata JSON, the
  BotLauncher entry script and the nimble file
- DevControlBot.sh is a thin wrapper around the runBot nimble task:
  release build by default, --debug for the classic diagnostic build
- dependencies resolve at run time from the global nimble store via
  `nimble path`; optional .env (gitignored) supplies env vars such as
  SERVER_URL, with existing environment variables taking precedence
- build happens in a throwaway mktemp dir removed on exit; no artifacts
2026-10-03 16:32:27 +02:00
9 changed files with 1026 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# User-authored environment variables (e.g. SERVER_URL) — never committed
.env
+239
View File
@@ -0,0 +1,239 @@
# DevControlBot — Garage Agent Guide
> ## ⚠️ READ THIS FIRST — THIS GARAGE IS *INTENTIONALLY* NOT THE ROOT TEMPLATE
>
> **The layout and format described in the repo-root [`../../AGENTS.md`](../../AGENTS.md)
> template do NOT apply to `DevControlBot_garage/`.** The root template documents a
> `src/<bot>.nim` / `tests/` / `out/` convention for garages. **This garage deviates
> from it on purpose and must stay that way.**
>
> **Do NOT, even to "fix" a perceived inconsistency:**
> - create `src/` or `out/`, or put anything inside `DevControlBot/` for testing
> (the one exception is the intended `DevControlBot_garage/tests/`, see
> "Layout of this folder" — it does not exist yet, create it only when the
> first test is actually written)
> - add a `config.nims`, `nimble.paths`, `--path` flags, or a vendor/ directory
> - restructure `DevControlBot/` to match the root template
>
> Builds go to a `mktemp -d` throwaway dir that is removed on exit — nothing is
> ever written here. The root `AGENTS.md` and its folder conventions are for the
> **other** garages; here it is context only (issue tracker, Nim conventions), and
> is **NOT a layout contract for this folder**. If the root template and this file
> disagree about layout, **this file wins.**
> ## ⚠️ DO NOT TOUCH `PLAN.md`
>
> **[`PLAN.md`](PLAN.md) is the user's personal working notes file.** The user
> maintains it and edits it exclusively.
>
> - Agents must **NEVER** modify, rewrite, reformat, move, delete or commit
> changes to `PLAN.md` — unless the user explicitly asks in that conversation.
> - Reading it is **not** assumed or expected; do not open it by default.
> - It **IS** tracked in git and is intended to be committed (deliberately added
> in commit `59fad00b`). Do **NOT** gitignore it and do **NOT**
> `git rm --cached` it.
## Start here
The code is the source of truth; this file is an inventory of what exists and how
the pieces interact — not a description of what the code does. If the two
disagree, the code wins and this file is stale.
The human-facing description lives in [README.md](README.md). Repo-wide rules live
in [`../../AGENTS.md`](../../AGENTS.md) (issue tracker, Nim conventions, test
framework) — read it for that context, but note again: **its folder-layout
template does not govern this garage.**
## Conventions
### Git rule for this garage
- **EVERYTHING** under `DevControlBot_garage/` gets committed — sources, modules,
docs, `PLAN.md` — **unless it is explicitly listed in a `.gitignore`**.
- This includes files that look like scratch or generated output, and files an agent
did not create: if not gitignored, they belong in the commit.
- Do not silently leave a file untracked, and do **not** add entries to
`.gitignore` to avoid committing something. The only things that stay out are
those the user has deliberately gitignored (e.g. `.env`).
### Version bumps and tags — the user's decision
Agents **MUST NOT** bump the version in `DevControlBot.nimble` or
`DevControlBot.json`, and **MUST NOT** create git tags. A version bump and its tag
are honoured **only** when the user explicitly asks in that conversation; a bump is
never a side effect of ordinary work or of a commit. Never amend, rewrite or retag
an existing commit or tag.
### Scope and colour
- This bot's code lives **only** inside `DevControlBot_garage/`. Do not edit other
garages to make this bot work, and do not reuse another bot's game logic
without explicit permission; shared, bot-agnostic code belongs in
`common_libs/`, and only with permission.
- Team colour convention: **white = not programmed, black = radar active**.
- Provenance: ModularBot / common_libs (Davide Cappellini, Apache-2.0).
### Constraints worth remembering
Not explanations — real invariants that broke things when violated:
- **Command the radar BEFORE `go()`** — a real bug the other way round.
- All radar logic is `modules/radar.nim`, in one piece. Do not split it back into
harness/lock/melee modules.
## Layout of this folder
```
DevControlBot_garage/
├── AGENTS.md # this file
├── README.md # human-facing description
├── PLAN.md # the user's own notes — never edit (see header)
├── .gitignore # ignores .env
├── DevControlBot/
│ ├── DevControlBot.nim # bot type + entry point (isMainModule)
│ ├── DevControlBot.json # bot metadata
│ ├── DevControlBot.nimble # single task: runBot
│ ├── DevControlBot.sh # BotLauncher entry point (thin wrapper)
│ ├── .env # OPTIONAL local config; secrets, never commit
│ └── modules/
│ └── radar.nim # all radar logic
└── tests/ # INTENDED, does not exist yet — create it on
# the first real test
```
- `DevControlBot/` holds only what the bot needs to run.
- `tests/` is a sibling of `DevControlBot/`, never inside it, and holds **test
code worth keeping**: drivers, harnesses, reusable battle/probe scripts.
- **Throwaway output does NOT go here or anywhere in the repo — it goes to
`/tmp`**: logs, temporary binaries, dumps, telemetry, scratch output.
- No `out/`, no binaries, no `nimbledeps/` here. Builds are throwaway by design.
## Build and run
Use the folder task, never raw `nim c`:
```sh
cd DevControlBot_garage/DevControlBot
nimble runBot # quiet release build + run
```
Or via the wrapper, which works from any cwd:
```sh
./DevControlBot/DevControlBot.sh # bundled metadata
./DevControlBot/DevControlBot.sh my.json # first positional arg, must end in .json
./DevControlBot/DevControlBot.sh --debug # classic debug build (hints + diagnostics)
./DevControlBot/DevControlBot.sh --help # usage, exits 0 without building
```
- **No arguments means RUN, not usage.** Usage prints only for `-h` / `--help`.
There is no `--json` flag; a first positional ending in `.json` overrides the
metadata, anything else is forwarded to the bot verbatim.
- **Gotcha:** `nimble runBot` must run from `DevControlBot/` (where the
`.nimble` lives); from `DevControlBot_garage/` it fails with
`Could not find a file with a .nimble extension...`. `compile`/`build` are
reserved Nimble builtin names — hence `runBot`.
- **Config is environment-only.** The bot API's `start()` reads `SERVER_URL` /
`SERVER_SECRET` itself; **never add env-reading code to `DevControlBot.nim`.**
The optional `.env` (loaded by the `runBot` task, `$DEVCONTROLBOT_ENV_FILE`
overrides the path) is local-runs-only and **never overrides a variable already
present in the environment** — the BotLauncher's values always win. Never print
its values, never `set -x`, never commit it.
- Dependency setup is `nimble install robocode_tankroyale_botapi`; dependencies
come from the global nimble store. Do not vendor, do not add
`nimble.paths` / `nimble develop`.
- Without a game server at `SERVER_URL` the run fails with
`[start] Cannot connect ... Connection refused`. That is expected locally, not a
bug.
- Exit codes are distinct because BotLauncher branches on them: `0` clean,
`1` no server (expected), `2` dependency missing, `3` compile failure,
anything else = the bot's own code.
## Tests
There are **no tests yet** — no `tests/` directory and no `test` task; `nimble test`
does not exist. When the first test is written, follow the repo-wide convention in
[`../../AGENTS.md`](../../AGENTS.md) and
`common_libs/test_framework/README.md`: `tests/config.nims` with
`--path:"../../common_libs"`, one `test<concern>.nim` per concern, a `test` task
in the `.nimble`, and a guard-first block so tests pass with no Java present.
## 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<br/># one command, every tick, no idle branch
Radar-->>Bot: setRadarTurnRate<br/>LOCK turn + 5deg overshoot,<br/>or +45deg sweep
Run->>Bot: go<br/># intent sent here, FIRST
Bot->>Srv: tick intent<br/>radar turn only,<br/>no move, no fire
Srv-->>Bot: tick result<br/>+ pending ScannedBotEvent
Bot->>Radar: onScannedBot -> onScan x, y<br/># AFTER go: remembers only
Radar-->>Radar: target position stored,<br/>tick counter reset
Note over Run,Radar: that remembered target is used<br/>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<br/>360 sweep at 45 deg/tick,<br/>full revolution every 8 ticks" as SWEEP
state "LOCKED<br/>servo onto target bearing<br/>PLUS 5 deg overshoot" as LOCKED
SWEEP --> LOCKED: "getEnemyCount is 1<br/>AND target seen within<br/>FreshScanTurns 1"
LOCKED --> SWEEP: "enemy count is not 1,<br/>or target stale beyond 10 ticks,<br/>or lost"
LOCKED --> LOCKED: "scan every tick<br/>because the radar crosses<br/>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.
@@ -0,0 +1,9 @@
{
"name": "DevControlBot",
"version": "1.1.0",
"authors": ["Davide Cappellini"],
"description": "Control skeleton bot — does nothing, bright colors",
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,52 @@
## 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". 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). 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 robocode_tankroyale_botapi
import modules/radar
type DevControlBot* = ref object of Bot
proc newDevControlBot*(): DevControlBot =
result = DevControlBot()
setBodyColor(WHITE)
setTurretColor(WHITE)
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"
var bot = newDevControlBot()
start(bot, jsonPath)
@@ -0,0 +1,238 @@
# Package
version = "1.1.0"
author = "Davide Cappellini"
description = "DevControlBot — control-skeleton bot that does nothing but show up bright"
license = "MIT"
srcDir = "."
bin = @[]
# Dependencies
# Installed globally with `nimble install`; nothing is vendored locally and
# no nimble.paths / vendor/ is used. `runBot` resolves each package's real
# location at run time with `nimble path` and passes explicit --path: flags,
# so the build does not rely on $HOME or on the compiler's bundled nimblepath.
requires "nim >= 2.0.0"
requires "robocode_tankroyale_botapi >= 1.0.7"
requires "jsony >= 1.1.5"
# The build-and-run logic lives here, not in DevControlBot.sh.
#
# NimScript cannot install signal handlers, so the temp build dir is owned by a
# small POSIX shell program (below) that Nimble executes via `exec` (which goes
# through the shell). That program traps EXIT/INT/TERM and always removes the
# throwaway build dir, including when compilation fails or the run is
# interrupted. Nothing is ever written to a persistent out/ directory.
#
# `exec` turns ANY non-zero exit into a NimScript exception, which prints a
# stack trace plus an escaped copy of the whole script. That is nonsense for the
# expected "no game server at SERVER_URL" outcome. So the shell always exits 0 and
# hands the real code back through the file named by $DEVCONTROLBOT_STATUS_FILE,
# which DevControlBot.sh exports and then re-exits with. Direct `nimble runBot`
# (no such variable) keeps the old behaviour: the shell exits with the real code
# and Nimble raises.
#
# DevControlBot.sh is a thin wrapper: it locates itself, chdirs here so Nimble
# finds this file, forwards caller args, calls `runBot` and forwards signals.
import std/os
const
runScript = """
# Resolve the globally-installed dependencies at run time. Plain `nim c` only
# finds them when $HOME is set (the Nim distribution's nim.cfg carries
# `nimblepath="$home/.nimble/pkgs2/"`), which is NOT true in bare environments
# (env -i, nix build sandboxes, CI). `nimble path` is the reliable,
# cwd-independent way to ask for their real location.
# Distinct exit codes, so a caller can tell the failure modes apart.
EXIT_NO_SERVER=1 # bot could not connect to the game server (expected)
EXIT_NO_DEPS=2 # a dependency is not installed
EXIT_COMPILE=3 # compilation failed
STATUS_FILE="${DEVCONTROLBOT_STATUS_FILE:-}"
# report <code>: write the real exit code to the status file. Returns 1 when
# there is no status file, i.e. the caller wants it as the shell's own code.
report() {
[ -n "$STATUS_FILE" ] || return 1
printf '%s\n' "$1" > "$STATUS_FILE"
}
# die <code>: finish with <code> as the run's real result — quietly (exit 0,
# the status file carries the code) or, with no listener, by exiting with it.
die() {
if report "$1"; then exit 0; else exit "$1"; fi
}
DEPS=""
for pkg in robocode_tankroyale_botapi jsony; do
pkgdir="$(nimble path "$pkg" 2>/dev/null | head -n 1)"
if [ -z "$pkgdir" ] || [ ! -d "$pkgdir" ]; then
echo "[devcontrolbot] COMPILE BLOCKED: dependency '$pkg' is not installed." >&2
echo "[devcontrolbot] fix with: nimble install $pkg" >&2
die $EXIT_NO_DEPS
fi
echo "[devcontrolbot] dependency $pkg -> $pkgdir"
DEPS="$DEPS '--path:$pkgdir'"
done
# Build mode: $DEVCONTROLBOT_DEBUG is set to "debug" by DevControlBot.sh when
# the caller passed --debug; anything else (including an unset variable, i.e. a
# direct `nimble runBot`) means release. Release is the quiet default.
BUILD_MODE="${DEVCONTROLBOT_DEBUG:-release}"
BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/devcontrolbot.XXXXXX")"
BINARY="$BUILD_DIR/DevControlBot"
# ---- optional .env ------------------------------------------------------------
# Configuration reaches the bot through the ENVIRONMENT, not through any code
# of ours: robocode_tankroyale_botapi's start() reads SERVER_URL and
# SERVER_SECRET (robocode_tankroyale_botapi.nim:424-425, documented at :396-397)
# and loadBotInfo() reads BOT_NAME / BOT_VERSION / ... from the environment when
# the JSON is absent (bot_info.nim:117-130). That is the documented, supported
# mechanism the official BotLauncher uses, so the only thing we do here is make
# those variables PRESENT in the bot's process environment for local runs.
#
# Loaded HERE, in the runBot task, and not in DevControlBot.sh, so it applies both
# to `./DevControlBot.sh` and to a direct `nimble runBot` (one implementation, and
# the wrapper stays a thin wrapper).
# - optional: a missing file is not an error, nothing is printed but a note,
# the bot just uses the API defaults.
# - CRLF-tolerant: CRs are stripped so a Windows-edited file does not end up
# with the value "bar\r".
# - both `FOO=bar` and `export FOO=bar` styles.
# - never echoed, never `set -x`: only the file NAME is reported.
# - $DEVCONTROLBOT_ENV_FILE overrides the default path (".env" next to the
# .nimble, which is the cwd because DevControlBot.sh chdirs there).
#
# PRECEDENCE — CRITICAL. The .env file must NEVER override a variable that is
# already present in the environment: the official BotLauncher's values (and the
# caller's explicit `FOO=bar ./DevControlBot.sh`) must always win, otherwise this
# convenience file could silently break a BotLauncher run. Sourcing the file
# directly would do the opposite, because an assignment in a sourced file
# overwrites an already-exported variable of the same name. So 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-maybe-empty,
# not non-empty), and only the remaining, still-unset names are sourced with
# `set -a`. The filtering is what implements the precedence, not the source order.
ENV_FILE="${DEVCONTROLBOT_ENV_FILE:-.env}"
if [ -f "$ENV_FILE" ]; then
ENV_RAW="$BUILD_DIR/env.raw"
ENV_KEEP="$BUILD_DIR/env.keep"
ENV_ADDED=""
# CRLF tolerance: drop the CRs, keep everything else verbatim.
tr -d '\r' < "$ENV_FILE" > "$ENV_RAW"
: > "$ENV_KEEP"
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|\#*) continue ;; # blank / comment
export\ *|export=*) line="${line#export }" ;;
esac
name="${line%%=*}"
# Only plain NAME=value assignments; anything else is ignored, so the file
# can never smuggle in commands. `name` is validated as an identifier
# BEFORE being used in the eval below.
printf '%s' "$name" | grep -q '^[A-Za-z_][A-Za-z_0-9]*$' || continue
# PRECEDENCE: skip if the variable already exists in the environment.
if eval "[ -n \"\${$name+x}\" ]"; then
continue
fi
printf '%s\n' "$line" >> "$ENV_KEEP"
ENV_ADDED="$ENV_ADDED $name"
done < "$ENV_RAW"
if [ -n "$ENV_ADDED" ]; then
set -a
. "$ENV_KEEP"
set +a
fi
rm -f "$ENV_RAW" "$ENV_KEEP"
# Variable NAMES only, never values.
echo "[devcontrolbot] env file loaded: $ENV_FILE (names added:${ENV_ADDED:- none}; values never printed)"
echo "[devcontrolbot] variables already in the environment win and were left untouched"
else
echo "[devcontrolbot] no env file at $ENV_FILE (optional, using API defaults)"
fi
cleanup() {
rc=$?
trap - EXIT INT TERM
rm -rf "$BUILD_DIR"
if [ -d "$BUILD_DIR" ]; then
echo "[devcontrolbot] cleanup failed: $BUILD_DIR still present" >&2
exit 1
fi
exit $rc
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# Release: hints and the progress/dot lines are noise, so the compiler output is
# captured to a log and only shown if the build fails (errors are ALWAYS shown).
# Debug: classic build, full compiler output (config-file hints, diagnostics).
LOG="$BUILD_DIR/build.log"
if [ "$BUILD_MODE" = "debug" ]; then
echo "[devcontrolbot] compiling DevControlBot.nim (DEBUG BUILD) -> $BINARY"
# `eval` re-parses $DEPS so the embedded quotes do the word splitting: the
# dependency paths survive spaces, without relying on glob or brace expansion.
# No pipe: POSIX sh has no PIPESTATUS, and `nim | tee` would report tee's
# status, not the compiler's. Buffer first, then replay the log verbatim.
eval nim c $DEPS --out:"$BINARY" DevControlBot.nim >"$LOG" 2>&1
compile_rc=$?
cat "$LOG"
else
echo "[devcontrolbot] compiling DevControlBot.nim -> $BINARY"
eval nim c -d:release --hints:off $DEPS --out:"$BINARY" DevControlBot.nim >"$LOG" 2>&1
compile_rc=$?
fi
if [ $compile_rc -ne 0 ]; then
if [ "$BUILD_MODE" != "debug" ]; then
echo "[devcontrolbot] --- compiler output ---" >&2
cat "$LOG" >&2
echo "[devcontrolbot] --- end compiler output ---" >&2
fi
echo "[devcontrolbot] COMPILE FAILED (nim c exited $compile_rc) — errors above." >&2
die $EXIT_COMPILE
fi
rm -f "$LOG"
if [ "$BUILD_MODE" = "debug" ]; then
echo "[devcontrolbot] build ok (debug)"
else
echo "[devcontrolbot] build ok (release)"
fi
# The bot reads argv[1] as its metadata JSON path. If the caller already
# supplied a JSON, use that instead of the bundled one.
if [ -n "${1:-}" ] && [ "${1##*.}" = "json" ]; then
echo "[devcontrolbot] using caller-supplied metadata: $1"
"$BINARY" "$@"
else
# NOTE: no `exec` here: the shell must survive so the EXIT trap can clean up.
echo "[devcontrolbot] running $BINARY DevControlBot.json $*"
"$BINARY" DevControlBot.json "$@"
fi
bot_rc=$?
if [ $bot_rc -ne 0 ]; then
echo "[devcontrolbot] bot exited with code $bot_rc." >&2
if [ $bot_rc -eq $EXIT_NO_SERVER ]; then
# The Tank Royale client answers a refused connection with this exact
# exit code, so it means "no server", not "broken bot".
echo "[devcontrolbot] No game server on ${SERVER_URL:-ws://localhost:7654} (SERVER_URL)." >&2
echo "[devcontrolbot] Start the Tank Royale server, or point SERVER_URL at a running one." >&2
else
echo "[devcontrolbot] The bot ran but failed at runtime (crash or bad metadata)." >&2
fi
fi
die $bot_rc
"""
task runBot, "Compile, run and clean up the bot":
# Nimble passes every CLI argument here (its own flags, then the task name,
# then the caller's arguments). Ours are the ones after the task name.
var args = ""
var seen = false
for a in commandLineParams():
if seen:
if args != "": args = args & " "
args = args & quoteShell(a)
elif a == "runBot":
seen = true
exec "sh -c " & quoteShell(runScript) & " devcontrolbot " & args
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# BotLauncher entry point for DevControlBot.
#
# Thin wrapper: all build/run/cleanup logic lives in the `runBot` nimble task
# of DevControlBot.nimble. This script only locates itself, moves next to the
# .nimble so Nimble finds it, forwards caller arguments, calls the task and
# propagates the real exit code. Signals are forwarded to Nimble so the task's
# own EXIT/INT/TERM cleanup always runs.
#
# Usage: DevControlBot.sh [--debug] [<metadata.json>] [extra args for the bot]
#
# (no args) RELEASE build + run with the bundled DevControlBot.json
# (no flag) RELEASE build (nim c -d:release), quiet output
# --debug classic DEBUG build (plain nim c): compiler hints + diagnostics
#
# --debug is a build-mode flag for this wrapper only: it is stripped here and
# never reaches the bot binary or nimble's task arguments. It travels to the
# runBot task as $DEVCONTROLBOT_DEBUG (exported), because an env var cannot be
# confused with a bot argument, cannot collide with the status file mechanism
# and keeps the task's argument parsing untouched. Repeated --debug is harmless.
#
# Configuration (SERVER_URL, SERVER_SECRET, ...) is NOT handled here at all: the
# API's start() reads those from the environment, and the optional .env file is
# loaded by the runBot task, so it also works with a direct `nimble runBot`.
set -uo pipefail
set -m # job control: the background job gets its own process group
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
usage() {
cat <<EOF
Usage: $(basename "$0") [--debug] [<metadata.json>] [extra args for the bot]
--debug classic debug build: shows compiler hints and diagnostics
(default is a quiet RELEASE build, nim c -d:release)
-h,--help show this help
Examples:
$(basename "$0") # release build + run, bundled DevControlBot.json
$(basename "$0") my.json # release build + run with custom metadata
$(basename "$0") -h # this help
$(basename "$0") --debug # debug build
$(basename "$0") --debug my.json # debug build with custom metadata
EOF
}
# Split caller args: everything except --debug is forwarded verbatim.
BUILD_MODE=release
FORWARDED=()
for a in "$@"; do
case "$a" in
--debug) BUILD_MODE=debug ;;
*) FORWARDED+=("$a") ;;
esac
done
set -- ${FORWARDED+"${FORWARDED[@]}"}
# No arguments is the normal case: build and run with the bundled metadata.
# Usage is printed ONLY for an explicit -h/--help.
case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac
export DEVCONTROLBOT_DEBUG="$BUILD_MODE"
# The runBot task's shell always exits 0 (otherwise Nimble raises a NimScript
# exception with a stack trace and a dump of the whole script) and writes the
# bot's real exit code here instead. We read it back and exit with it, so
# BotLauncher still sees the faithful status: 1 = no game server at SERVER_URL,
# 2 = a dependency is missing, 3 = compile failure, anything else = the bot's
# own code, 0 = clean run.
STATUS_FILE="$(mktemp "${TMPDIR:-/tmp}/devcontrolbot.status.XXXXXX")"
export DEVCONTROLBOT_STATUS_FILE="$STATUS_FILE"
trap 'rm -f "$STATUS_FILE"' EXIT
# Forward to the whole job process group (nimble + the shell it spawned) so the
# task's own EXIT/INT/TERM cleanup runs. No cleanup logic here.
forward() { kill -s "$1" -- "-$NIMBLE_PID" 2>/dev/null; }
trap 'forward TERM' TERM
trap 'forward INT' INT
nimble runBot ${1+"$@"} &
NIMBLE_PID=$!
wait "$NIMBLE_PID"
rc=$?
trap - TERM INT
# Prefer the code the run actually produced; fall back to Nimble's own status if
# the task died before it could report (e.g. Nimble itself failed).
real_rc="$(head -n 1 "$STATUS_FILE" 2>/dev/null | tr -d '[:space:]')"
[ -n "$real_rc" ] || real_rc=$rc
exit "$real_rc"
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
1. Create a tidy Bot garage folder
2. Make inside a folder ready to be exported and shared with others
3. Create a basic bot with all white colors and does nothing else
4. make a nice runnable in bash script fro the RT official launcher
5. add nice radars: 1vs1 and melee
5. decide the gun to develop
+296
View File
@@ -0,0 +1,296 @@
# 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.