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
This commit is contained in:
2026-10-03 16:32:27 +02:00
parent 7632aaba06
commit 59fad00b5c
8 changed files with 820 additions and 0 deletions
@@ -0,0 +1,2 @@
# User-authored environment variables (e.g. SERVER_URL) — never committed
.env
@@ -0,0 +1,9 @@
{
"name": "DevControlBot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "Control skeleton bot — does nothing, bright colors",
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,32 @@
## DevControlBot — control skeleton: boots, stands still, does nothing.
##
## 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.
##
## This module is both the bot type and the program entry point (see isMainModule
## below); there is no separate top-level DevControlBot.nim.
##
## API: robocode_tankroyale_botapi 1.0.7 (the renamed tankroyale_botapi package).
import std/os
import robocode_tankroyale_botapi
type DevControlBot* = ref object of Bot
proc newDevControlBot*(): DevControlBot =
result = DevControlBot()
setBodyColor(WHITE)
setTurretColor(WHITE)
setGunColor(WHITE)
setRadarColor(WHITE)
method run*(bot: DevControlBot) =
while isRunning():
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 = "0.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 on 7654" 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 check it is listening on port 7654." >&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 on 7654,
# 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"