Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de0dd50d6e | |||
| caa096d6c9 | |||
| 305c3977a6 | |||
| 59fad00b5c | |||
| 7632aaba06 | |||
| 208f4a9092 | |||
| 55e92bc5e0 |
@@ -0,0 +1,2 @@
|
||||
# User-authored environment variables (e.g. SERVER_URL) — never committed
|
||||
.env
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -153,6 +153,9 @@
|
||||
# TR_SURF_LOG one line per wave-surfing decision
|
||||
# TR_FIRE_DIAG per-reading fire-detection tick/raw/correction
|
||||
# TR_RECORD_WORLDSTATE dump every observed world state to JSONL
|
||||
# TR_CAPTURE_AIM aim_scan/aim_fire records (gun id + bot belief).
|
||||
# KNOWN LIMIT: only shots that PASSED setFire are
|
||||
# recorded, so a gap is ambiguous - see docs/env_reference.md
|
||||
# TR_RADAR_SCANLOG log every radar scan tick
|
||||
# TR_RADAR_FORCE_SPIN force the old full-360 spin radar
|
||||
# TR_TRACKER_PROBE dump the enemy-tracker internals
|
||||
@@ -737,6 +740,24 @@ GUN_STATS_PATH=/tmp/gun_stats.jsonl # where the per-round gun stats are writte
|
||||
GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl # where the per-shot log is written
|
||||
|
||||
# ── measurement helpers (leave off unless you are measuring) ─────────────────
|
||||
# WHAT: aim capture. Adds TWO record kinds to the TR_RECORD_WORLDSTATE file:
|
||||
# aim_scan — one per radar scan: the raw reading, our own state, the gun
|
||||
# that fired, the PREVIOUS tracker belief, and the scan parity
|
||||
# (age = tick - previous lastSeenTick);
|
||||
# aim_fire — one per real shot: gun, power, the aim angle, the turret error,
|
||||
# gun heat, the predicted intercept/TOF, and the exact WorldState the
|
||||
# predictor consumed (with the tick it came from). It also adds the `gun`
|
||||
# id to the per-tick world-state rows.
|
||||
# VALUES: presence-only, like the other keys in this block. Unset = off.
|
||||
# STATUS: default-off, diagnostic only, never live-tested. j176 could not
|
||||
# attribute the 11.9 deg aim error because the corpus had no gun id and no
|
||||
# bot-side belief; this knob makes both a lookup instead of an inverse
|
||||
# problem. No aim model changed with it.
|
||||
# GOTCHA: it only writes when TR_RECORD_WORLDSTATE is on as well, and it makes
|
||||
# the capture file bigger, not different: the extra lines are annotations and
|
||||
# the offline replay skips them.
|
||||
# TRY: TR_RECORD_WORLDSTATE=1 TR_CAPTURE_AIM=1 ./out/ModularBot
|
||||
#TR_CAPTURE_AIM=1 # PRESENCE-only: aim_scan / aim_fire records (needs TR_RECORD_WORLDSTATE)
|
||||
#TR_RECORD_WORLDSTATE=1 # PRESENCE-only: dump every observed world state
|
||||
#TR_RADAR_SCANLOG=1 # PRESENCE-only: log every radar scan tick
|
||||
#TR_TRACKER_PROBE=1 # PRESENCE-only: dump the enemy-tracker's internal state
|
||||
|
||||
@@ -44,6 +44,7 @@ import movement_harness/bullet_shadows
|
||||
import targeting/enemy_tracker
|
||||
import targeting/target_selector
|
||||
import env_report
|
||||
import aim_capture
|
||||
import vbullet_draw
|
||||
import geo_overlay
|
||||
|
||||
@@ -69,6 +70,13 @@ const ShotLog = true
|
||||
## can enable recording for just the battle it spawns by exporting the env var.
|
||||
let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE")
|
||||
const WorldStateRecordPath = "/tmp/worldstate_record.jsonl"
|
||||
## j177 aim capture: with TR_CAPTURE_AIM set, append two extra record kinds to
|
||||
## the SAME world-state file — `aim_scan` (one per radar scan: the raw reading,
|
||||
## our own state, the GUN, the previous belief and the scan parity) and
|
||||
## `aim_fire` (one per firing decision: the gun, the power, the aim angle, the
|
||||
## turret error, and the exact WorldState the predictor consumed). Off by
|
||||
## default, so a normal run writes byte-for-byte what it wrote before.
|
||||
let CaptureAim* = existsEnv("TR_CAPTURE_AIM")
|
||||
## Radar measurement switches (all RUNTIME, read once at process start):
|
||||
## TR_RADAR_FORCE_SPIN=1 force the melee radar to the old stateless full
|
||||
## spin (always 45 deg/tick). This reproduces the
|
||||
@@ -477,6 +485,9 @@ proc recordWorldState(bot: ModularBot, ws: WorldState) =
|
||||
"eid": tid,
|
||||
}
|
||||
if lst >= 0: row["lst"] = %lst
|
||||
# j177: the gun in force when this state was built. Absent before j177,
|
||||
# which made a per-gun decomposition of the aim error impossible.
|
||||
if CaptureAim: row["gun"] = %bot.currentGun
|
||||
try:
|
||||
let f = open(WorldStateRecordPath, fmAppend)
|
||||
f.writeLine($row)
|
||||
@@ -616,6 +627,23 @@ proc recordRadarStats(bot: ModularBot) =
|
||||
inc bot.arcWidthHist[min(11, int(width / 30.0))]
|
||||
|
||||
method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
|
||||
# j177 aim capture: the belief we are about to REPLACE, and the fire site's
|
||||
# state, recorded BEFORE the update. Written first so the record is the
|
||||
# pre-update belief by construction, not by argument.
|
||||
if CaptureAim:
|
||||
var rec = AimScan(tick: bot.tick, eid: e.scannedBotId,
|
||||
ex: e.x, ey: e.y, eh: e.direction, es: e.speed, ee: e.energy,
|
||||
sx: getX(), sy: getY(), sh: getDirection(), ss: getSpeed(),
|
||||
gun: bot.currentGun,
|
||||
rlock: bot.radarMode == 0, rdir: getRadarDirection(),
|
||||
bx: 0.0, by: 0.0, blst: -1)
|
||||
if bot.enemyTracker.enemies.contains(e.scannedBotId):
|
||||
let prev = bot.enemyTracker.enemies[e.scannedBotId]
|
||||
rec.bx = prev.x; rec.by = prev.y; rec.bh = prev.heading
|
||||
rec.bs = prev.speed; rec.blst = prev.lastSeenTick
|
||||
rec.lbear = bearing(rec.bx, rec.by, rec.sx, rec.sy)
|
||||
rec.boff = (bearing(rec.ex, rec.ey, rec.sx, rec.sy) - rec.rdir) mod 360.0
|
||||
appendLine(WorldStateRecordPath, scanRow(rec))
|
||||
bot.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick)
|
||||
bot.hasContact = true
|
||||
if RadarScanLog and bot.radarMeleeActive:
|
||||
@@ -1467,6 +1495,22 @@ method run*(bot: ModularBot) =
|
||||
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
|
||||
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
|
||||
if not floorBlocks and setFire(power) and getEnergy() > power:
|
||||
# j177 aim capture: one `aim_fire` line per REAL shot, with the gun
|
||||
# that fired and the exact WorldState the predictor consumed. This
|
||||
# is the only place where `lst` is knowable, so it is the only place
|
||||
# the scan parity of a firing decision can be recorded.
|
||||
if CaptureAim:
|
||||
let bspd = bulletSpeed(power)
|
||||
appendLine(WorldStateRecordPath, fireRow(AimFire(
|
||||
tick: bot.tick, eid: tid, gun: selectedGun, power: power,
|
||||
aim: aimTarget, turret: gunDir, terr: normDelta, heat: gunHeat,
|
||||
ax: pred.x, ay: pred.y, tof: (if bspd > 0: distPx / bspd else: 0.0),
|
||||
ex: bot.lastState.enemyX, ey: bot.lastState.enemyY,
|
||||
eh: bot.lastState.enemyHeading, es: bot.lastState.enemySpeed,
|
||||
ee: bot.lastState.enemyEnergy,
|
||||
sx: bot.lastState.selfX, sy: bot.lastState.selfY,
|
||||
lst: (if tid >= 0 and bot.enemyTracker.enemies.contains(tid):
|
||||
bot.enemyTracker.enemies[tid].lastSeenTick else: -1))))
|
||||
bot.pendingFires.add(PendingShot(
|
||||
gunId: selectedGun,
|
||||
angleErr: abs(normDelta),
|
||||
@@ -1558,6 +1602,7 @@ when isMainModule:
|
||||
vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""),
|
||||
vBulletDebugMax: VBulletDebugMax,
|
||||
recordWorldState: RecordWorldState,
|
||||
captureAim: CaptureAim,
|
||||
geoDebug: GeoDebugOn,
|
||||
debugDraw: DebugDrawOn,
|
||||
radarForceSpin: RadarForceSpin,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
## j177 aim capture — the two records that make the aim error ATTRIBUTABLE.
|
||||
##
|
||||
## j176 could not answer "why is the aim 11.9 deg off at 450+ px" because the
|
||||
## corpus has neither the bot's own belief (staleness was an INVERSE problem,
|
||||
## unidentifiable) nor the gun id (a good gun's contribution was
|
||||
## indistinguishable from a bad one's). Both are cheap to log and impossible
|
||||
## to recover later. This module builds the two JSON records; ModularBot.nim
|
||||
## calls it and the lines go into the EXISTING world-state capture
|
||||
## (`TR_RECORD_WORLDSTATE` file), so the offline tooling sees one stream.
|
||||
##
|
||||
## It is deliberately PURE (no bot API, no env reads): the bot passes plain
|
||||
## floats, the offline guard test passes the recorded fixture, and both go
|
||||
## through the SAME row builders — so a field that the test proves present is
|
||||
## a field the live bot writes.
|
||||
##
|
||||
## Key convention: `e*` = the enemy, `s*` = us, `b*` = the enemy's PREVIOUS
|
||||
## belief in the tracker (before this scan's update), `lst` = the tick that
|
||||
## state came from. `age = tick - blst` is the scan PARITY, recorded rather
|
||||
## than inferred.
|
||||
|
||||
import std/[json, os, math]
|
||||
|
||||
const
|
||||
ScanRecordKey* = "aim_scan" ## wrapper key, sibling of "meta"/"end"
|
||||
FireRecordKey* = "aim_fire"
|
||||
|
||||
type
|
||||
AimScan* = object
|
||||
## One `onScannedBot` event, as the bot saw it BEFORE the update.
|
||||
tick*: int
|
||||
eid*: int
|
||||
ex*, ey*: float ## raw scanned values
|
||||
eh*, es*: float ## scanned heading (= direction) and speed
|
||||
ee*: float
|
||||
sx*, sy*: float ## our state at the scan (the FIRE SITE)
|
||||
sh*, ss*: float
|
||||
gun*: int ## bot.currentGun — the gun that fired, not the rack slot
|
||||
bx*, by*: float ## previous belief, BEFORE this scan's update
|
||||
bh*, bs*: float
|
||||
blst*: int ## previous lastSeenTick; -1 = never scanned before
|
||||
rlock*: bool ## radar lock engaged (false = the melee radar)
|
||||
rdir*: float ## 36 deg scan window centre (radar heading)
|
||||
lbear*: float ## bearing the lock is chasing (believed target)
|
||||
boff*: float ## scanned bearing - rdir: where in the window it landed
|
||||
|
||||
AimFire* = object
|
||||
## One firing decision, as the model computed it.
|
||||
tick*: int
|
||||
eid*: int
|
||||
gun*: int ## bot.currentGun = the gun that fired
|
||||
power*: float
|
||||
aim*: float ## the raw angle handed to setFire/turret
|
||||
turret*: float ## getGunDirection() at the command
|
||||
terr*: float ## signed turret error (aim - turret)
|
||||
heat*: float ## getGunHeat() BEFORE firing
|
||||
ax*, ay*: float ## the intercept the gun predicted
|
||||
tof*: float ## implied time of flight, ticks
|
||||
ex*, ey*: float ## the WorldState the predictor CONSUMED
|
||||
eh*, es*: float
|
||||
ee*: float
|
||||
sx*, sy*: float
|
||||
lst*: int ## which tick that enemy state came from (scan parity)
|
||||
|
||||
proc bearing*(x, y, fx, fy: float): float =
|
||||
arctan2(y - fy, x - fx).radToDeg
|
||||
|
||||
proc scanRow*(s: AimScan): JsonNode =
|
||||
## The `aim_scan` line. Every field is unconditional: a capture that
|
||||
## silently omits a field is worse than no capture.
|
||||
result = newJObject()
|
||||
result[ScanRecordKey] = newJObject()
|
||||
let b = result[ScanRecordKey]
|
||||
b["tick"] = %s.tick
|
||||
b["eid"] = %s.eid
|
||||
b["ex"] = %s.ex
|
||||
b["ey"] = %s.ey
|
||||
b["eh"] = %s.eh
|
||||
b["es"] = %s.es
|
||||
b["ee"] = %s.ee
|
||||
b["sx"] = %s.sx
|
||||
b["sy"] = %s.sy
|
||||
b["sh"] = %s.sh
|
||||
b["ss"] = %s.ss
|
||||
b["gun"] = %s.gun
|
||||
b["bx"] = %s.bx
|
||||
b["by"] = %s.by
|
||||
b["bh"] = %s.bh
|
||||
b["bs"] = %s.bs
|
||||
b["blst"] = %s.blst
|
||||
b["age"] = %(if s.blst >= 0: s.tick - s.blst else: -1)
|
||||
b["rlock"] = %s.rlock
|
||||
b["rdir"] = %s.rdir
|
||||
b["lbear"] = %s.lbear
|
||||
b["boff"] = %s.boff
|
||||
|
||||
proc fireRow*(f: AimFire): JsonNode =
|
||||
## The `aim_fire` line.
|
||||
result = newJObject()
|
||||
result[FireRecordKey] = newJObject()
|
||||
let b = result[FireRecordKey]
|
||||
b["tick"] = %f.tick
|
||||
b["eid"] = %f.eid
|
||||
b["gun"] = %f.gun
|
||||
b["power"] = %f.power
|
||||
b["aim"] = %f.aim
|
||||
b["turret"] = %f.turret
|
||||
b["terr"] = %f.terr
|
||||
b["heat"] = %f.heat
|
||||
b["ax"] = %f.ax
|
||||
b["ay"] = %f.ay
|
||||
b["tof"] = %f.tof
|
||||
b["ex"] = %f.ex
|
||||
b["ey"] = %f.ey
|
||||
b["eh"] = %f.eh
|
||||
b["es"] = %f.es
|
||||
b["ee"] = %f.ee
|
||||
b["sx"] = %f.sx
|
||||
b["sy"] = %f.sy
|
||||
b["lst"] = %f.lst
|
||||
|
||||
proc appendLine*(path: string, row: JsonNode) =
|
||||
## Append one JSONL line, fully guarded: a full disk or a bad path must
|
||||
## never take the bot down (same contract as the existing recorders).
|
||||
try:
|
||||
let f = open(path, fmAppend)
|
||||
f.writeLine($row)
|
||||
f.close()
|
||||
except CatchableError:
|
||||
discard
|
||||
@@ -52,6 +52,7 @@ type
|
||||
vBulletDebugGun*: string
|
||||
vBulletDebugMax*: int
|
||||
recordWorldState*: bool
|
||||
captureAim*: bool ## j177: aim_scan / aim_fire records, default off
|
||||
geoDebug*: bool
|
||||
debugDraw*: bool
|
||||
radarForceSpin*: bool
|
||||
@@ -245,6 +246,8 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
||||
emit("TR_RESULT_LOG", onOff(ctx.resultLog), sourceOf("TR_RESULT_LOG"))
|
||||
emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState),
|
||||
sourceOfPresence("TR_RECORD_WORLDSTATE"))
|
||||
emit("TR_CAPTURE_AIM", onOff(ctx.captureAim),
|
||||
sourceOfPresence("TR_CAPTURE_AIM"))
|
||||
emit("TR_RADAR_FORCE_SPIN", onOff(ctx.radarForceSpin),
|
||||
sourceOfPresence("TR_RADAR_FORCE_SPIN"))
|
||||
emit("TR_RADAR_SCANLOG", onOff(ctx.radarScanLog),
|
||||
@@ -657,7 +660,7 @@ proc knownEnvNames*(): seq[string] =
|
||||
"GUN_SELECTOR_SHRINK", "GUN_SELECTOR_DWELL", "GUN_SELECTOR_MARGIN",
|
||||
"GUN_SELECTOR_POINT_TIE", "GUN_SELECTOR_SEED",
|
||||
"GUN_RACK_DISABLE", "GUN_STATS_PATH", "GUN_SHOTLOG_PATH",
|
||||
"TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE",
|
||||
"TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE", "TR_CAPTURE_AIM",
|
||||
"TR_RADAR_FORCE_SPIN", "TR_RADAR_SCANLOG", "TR_RADAR_SCAN_LOG_PATH",
|
||||
"TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY",
|
||||
VBulletDebugEnv, VBulletDebugGunEnv, VBulletDebugMaxEnv,
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
## A trailing live end marker is also optional:
|
||||
## {"end":{"enemy_died":<bool>,"ticks":<int>}}
|
||||
## It lets the replay reproduce the live resolver's final-tick behaviour.
|
||||
## `aim_scan` / `aim_fire` annotation lines (written only when
|
||||
## TR_CAPTURE_AIM is set) carry no `ex` and are skipped.
|
||||
##
|
||||
## The replay never calls the gun selector, so it is RNG-free for every
|
||||
## deterministic gun. Tsetlin is stochastic and is expected to differ.
|
||||
@@ -196,6 +198,10 @@ proc loadFixture*(path: string): Fixture =
|
||||
if node["end"].hasKey("enemy_died"):
|
||||
result.enemyDied = node["end"]["enemy_died"].getBool()
|
||||
continue
|
||||
# j177: the recorder can also write `aim_scan` / `aim_fire` lines into the
|
||||
# same file when TR_CAPTURE_AIM is set. They are annotations on ticks, not
|
||||
# ticks, so they carry no `ex` and are skipped here.
|
||||
if not node.hasKey("ex"): continue
|
||||
result.states.add stateFromJson(node, arenaW, arenaH, enemyId)
|
||||
result.lastSeen.add (if node.hasKey("lst"): node["lst"].getInt() else: -1)
|
||||
result.enemyId = enemyId
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# j177 gun-path default-parity golden.
|
||||
# Generated from the PRE-CHANGE tree (`git archive 55e92bc`) with
|
||||
# TR_CAPTURE_AIM unset, over the whole tr_drussgt_vs_modularbot.jsonl.
|
||||
# Format: <gun> shots=<n> hits=<n>, one line per rack gun
|
||||
HeadOn shots=400 hits=59
|
||||
Linear shots=400 hits=47
|
||||
Tsetlin shots=400 hits=74
|
||||
Circular shots=400 hits=51
|
||||
GuessFactor shots=400 hits=44
|
||||
Pattern shots=400 hits=46
|
||||
WallBounce shots=400 hits=55
|
||||
Accel shots=400 hits=59
|
||||
StopShot shots=400 hits=84
|
||||
Displace shots=400 hits=46
|
||||
AvgLead shots=400 hits=51
|
||||
DecayGF shots=400 hits=47
|
||||
KNN shots=400 hits=29
|
||||
TMSelect shots=0 hits=0
|
||||
@@ -0,0 +1,132 @@
|
||||
## j177 guard: the aim capture actually contains every field it promises, and
|
||||
## the default gun path is byte-for-byte unchanged.
|
||||
##
|
||||
## NO battle, NO Java, NO server, NO GUI.
|
||||
## nim c -r --path:common_libs --path:ModularBot_garage/src \
|
||||
## --nimcache:/tmp/nc_j177 common_libs/tests/test_aim_capture.nim
|
||||
##
|
||||
## Part 1 (the guard): with the capture ON, build one `aim_scan` and one
|
||||
## `aim_fire` row from a real recorded tick of
|
||||
## tools/fixtures/tr_drussgt_vs_modularbot.jsonl, through the SAME
|
||||
## `aim_capture` row builders ModularBot.nim calls, and assert every required
|
||||
## key is present and carries the value that was passed in. A capture that
|
||||
## silently omits a field is worse than none.
|
||||
##
|
||||
## Part 2 (default parity): replay the whole recorded fixture through the real
|
||||
## VirtualTracker + the guns with the capture OFF and compare the per-gun
|
||||
## fitness report against `fixtures/aim_capture_gunpath.golden`. That golden
|
||||
## was generated from the PRE-CHANGE tree (`git archive 55e92bc`) with the
|
||||
## knob unset — regenerating it from this code would defeat the check.
|
||||
## Part 2b: the same replay over a fixture that ALSO carries aim_scan/aim_fire
|
||||
## lines must give the identical report, i.e. the annotations are inert.
|
||||
|
||||
import std/[json, os, strutils, sequtils, strformat]
|
||||
import gun_harness/offline_range
|
||||
import range_guns
|
||||
import ../../ModularBot_garage/src/aim_capture
|
||||
|
||||
const
|
||||
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
||||
fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
|
||||
fixture = repoRoot / "tools" / "fixtures" / fixtureRel
|
||||
goldenPath = currentSourcePath().parentDir / "fixtures" / "aim_capture_gunpath.golden"
|
||||
annotated = "/tmp/j177_annotated_fixture.jsonl"
|
||||
ScanKeys = ["tick", "eid", "ex", "ey", "eh", "es", "ee", "sx", "sy", "sh", "ss",
|
||||
"gun", "bx", "by", "bh", "bs", "blst", "age", "rlock", "rdir",
|
||||
"lbear", "boff"]
|
||||
FireKeys = ["tick", "eid", "gun", "power", "aim", "turret", "terr", "heat",
|
||||
"ax", "ay", "tof", "ex", "ey", "eh", "es", "ee", "sx", "sy", "lst"]
|
||||
|
||||
var failures = 0
|
||||
proc check(name: string, ok: bool) =
|
||||
if ok: echo "PASS: ", name
|
||||
else:
|
||||
echo "FAIL: ", name
|
||||
inc failures
|
||||
|
||||
proc replayReport(path: string): string =
|
||||
## Per-gun shots/hits over the whole fixture, through the REAL tracker and
|
||||
## the REAL guns, with the capture OFF.
|
||||
let res = replayFixture(loadFixture(path), buildAllGunDrivers(seed = 1))
|
||||
for r in res:
|
||||
result.add r.name & " shots=" & $r.shots & " hits=" & $r.hits & "\n"
|
||||
|
||||
# ── Part 1: the records carry every field ────────────────────────────────────
|
||||
proc fieldCheck() =
|
||||
let states = loadFixture(fixture).states
|
||||
check("fixture loaded", states.len > 1000)
|
||||
let ws = states[900]
|
||||
let scan = scanRow(AimScan(
|
||||
tick: 900, eid: 7,
|
||||
ex: ws.enemyX, ey: ws.enemyY, eh: ws.enemyHeading, es: ws.enemySpeed,
|
||||
ee: ws.enemyEnergy, sx: ws.selfX, sy: ws.selfY, sh: ws.selfHeading,
|
||||
ss: ws.selfSpeed, gun: 5,
|
||||
bx: ws.enemyX - 8.0, by: ws.enemyY - 8.0, bh: ws.enemyHeading,
|
||||
bs: ws.enemySpeed, blst: 896,
|
||||
rlock: true, rdir: 42.5, lbear: bearing(ws.enemyX - 8, ws.enemyY - 8, ws.selfX, ws.selfY),
|
||||
boff: -12.25))
|
||||
let fire = fireRow(AimFire(
|
||||
tick: 900, eid: 7, gun: 5, power: 1.6, aim: 88.25, turret: 74.0,
|
||||
terr: 14.25, heat: 0.31, ax: ws.enemyX + 40.0, ay: ws.enemyY - 15.0,
|
||||
tof: 12.5, ex: ws.enemyX, ey: ws.enemyY, eh: ws.enemyHeading,
|
||||
es: ws.enemySpeed, ee: ws.enemyEnergy, sx: ws.selfX, sy: ws.selfY, lst: 897))
|
||||
|
||||
for k in ScanKeys:
|
||||
check("aim_scan has " & k, scan[ScanRecordKey].hasKey(k))
|
||||
for k in FireKeys:
|
||||
check("aim_fire has " & k, fire[FireRecordKey].hasKey(k))
|
||||
|
||||
check("aim_scan carries the gun id", scan[ScanRecordKey]["gun"].getInt() == 5)
|
||||
check("aim_fire carries the gun id", fire[FireRecordKey]["gun"].getInt() == 5)
|
||||
check("aim_scan records the scan parity age",
|
||||
scan[ScanRecordKey]["age"].getInt() == 4)
|
||||
check("aim_fire records the source tick (parity)",
|
||||
fire[FireRecordKey]["lst"].getInt() == 897)
|
||||
check("aim_fire carries the aim angle", fire[FireRecordKey]["aim"].getFloat() == 88.25)
|
||||
check("aim_fire carries the turret error", fire[FireRecordKey]["terr"].getFloat() == 14.25)
|
||||
check("aim_fire carries the WorldState the model consumed",
|
||||
fire[FireRecordKey]["ex"].getFloat() == ws.enemyX and
|
||||
fire[FireRecordKey]["ey"].getFloat() == ws.enemyY)
|
||||
|
||||
echo "\n--- aim_scan record ---"
|
||||
echo $scan
|
||||
echo "--- aim_fire record ---"
|
||||
echo $fire
|
||||
echo ""
|
||||
|
||||
# ── Part 2: default gun-path parity ──────────────────────────────────────────
|
||||
proc parityCheck() =
|
||||
let clean = replayReport(fixture)
|
||||
let ticks = loadFixture(fixture).states.len
|
||||
if defined(aimCapGenGolden):
|
||||
var g = "# j177 gun-path default-parity golden.\n"
|
||||
g.add "# Generated from the PRE-CHANGE tree (`git archive 55e92bc`) with\n"
|
||||
g.add "# TR_CAPTURE_AIM unset, over the whole " & fixtureRel & ".\n"
|
||||
g.add "# Format: <gun> shots=<n> hits=<n>, one line per rack gun\n"
|
||||
g.add clean
|
||||
createDir(goldenPath.parentDir)
|
||||
writeFile(goldenPath, g)
|
||||
echo "wrote ", goldenPath, " (", ticks, " ticks)"
|
||||
return
|
||||
check("golden exists", fileExists(goldenPath))
|
||||
if not fileExists(goldenPath): return
|
||||
let g = lines(goldenPath).toSeq().filterIt(not it.startsWith("#")).join("\n").strip()
|
||||
check("gun path byte-for-byte identical over " & $ticks & " ticks", g == clean.strip())
|
||||
|
||||
# 2b: the annotation lines must be inert for the replay.
|
||||
let extra = @[
|
||||
$scanRow(AimScan(tick: 0, eid: 1, ex: 1.0, ey: 2.0, eh: 3.0, es: 4.0, ee: 5.0,
|
||||
sx: 6.0, sy: 7.0, sh: 8.0, ss: 9.0, gun: 3,
|
||||
bx: 0.0, by: 0.0, blst: -1, rlock: true, rdir: 1.0, lbear: 2.0)),
|
||||
$fireRow(AimFire(tick: 1, eid: 1, gun: 3, power: 1.5, aim: 1.0, turret: 2.0,
|
||||
terr: 3.0, heat: 0.0, ax: 1.0, ay: 1.0, tof: 1.0,
|
||||
ex: 1.0, ey: 1.0, eh: 1.0, es: 1.0, ee: 1.0,
|
||||
sx: 1.0, sy: 1.0, lst: 0))]
|
||||
writeFile(annotated, (lines(fixture).toSeq() & extra).join("\n"))
|
||||
check("aim records do not perturb the replay", replayReport(annotated) == clean)
|
||||
removeFile(annotated)
|
||||
|
||||
fieldCheck()
|
||||
parityCheck()
|
||||
echo (if failures == 0: "\nALL PASS" else: "\n" & $failures & " FAILURE(S)")
|
||||
quit(if failures == 0: 0 else: 1)
|
||||
@@ -616,6 +616,7 @@ Measured byte-identical on `bmPath`. Kept for experiments; leave at defaults.
|
||||
| `TR_RADAR_SCAN_LOG_PATH` | `/tmp/radar_scan_log.jsonl` | where that goes |
|
||||
| `TR_TRACKER_PROBE` | off | presence-based; per-tick enemy tracker vs server enemy count |
|
||||
| `TR_TRACKER_PROBE_PATH` | `/tmp/tracker_probe.jsonl` | where that goes |
|
||||
| `TR_CAPTURE_AIM` | off | presence-based; append `aim_scan` / `aim_fire` records — what the lead model BELIEVED (gun id, blst, age, boff, aim, turret, terr, heat, ax/ay, tof) to the same capture file as `TR_RECORD_WORLDSTATE` (needs `TR_RECORD_WORLDSTATE=1`) |
|
||||
| `TR_VBULLET_DEBUG` | off | presence-based; overlay the virtual bullets in the GUI debug graphics (see below) |
|
||||
| `TR_VBULLET_DEBUG_GUN` | selected gun | `all`/`*` for every gun, or a gun name (e.g. `Pattern`); unset = only the currently selected gun |
|
||||
| `TR_VBULLET_DEBUG_MAX` | `32` | cap on bullets drawn per tick |
|
||||
@@ -633,6 +634,21 @@ selector's training signal visible. Turn it on with:
|
||||
- colour per gun is the SAME table as the turret (`vbullet_draw.gunColors`), with
|
||||
a one-line legend in the top-left corner.
|
||||
|
||||
### Known limitations — `TR_CAPTURE_AIM` (j177)
|
||||
|
||||
**`aim_fire` only records shots that PASSED `setFire`.** The capture is written
|
||||
from the bot's own fire call, so a shot the **server rejected or that the bot
|
||||
never issued** produces no `aim_fire` record at all.
|
||||
|
||||
That is a real blind spot: from these records you can never answer *why* a shot
|
||||
did not happen — e.g. the gun was still hot, or the turret was not yet aligned.
|
||||
A missing `aim_fire` is ambiguous between "no target / didn't try" and "tried and
|
||||
was refused". The `aim_scan` records carry the belief state (age, boff, turret,
|
||||
heat) for every scan, so you can often *infer* the cause by looking at the scans
|
||||
that precede the gap, but the capture does not state it. Read the gaps as
|
||||
"no recorded shot", never as "the server blocked it". Closing this needs a
|
||||
pre-`setFire` gate record, which is j177+ work, not present today.
|
||||
|
||||
The **adaptive-melee radar** has no env knobs. Its tuning lives in compile-time
|
||||
constants in `radars/adaptive_melee_radar.nim:36-50`: `MaxRadarTurnRate=45`,
|
||||
`FreshnessTicks=16`, `FreshStreakTicks=3`, `MarginDeg=20`,
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
# Range vs approach: melee is unavailable against DrussGT, so the gun at range is the only lever
|
||||
|
||||
**Date:** 2026-09-27 · **Job:** j167 · **Branch:** `research/lead-targeting`
|
||||
**Evidence base:** j166 (`worktrees/j166-aim` @ `a5a49bd`), j165 (`fe77056`), j159
|
||||
(`4a1f3e1`), j165/j151/j152/j154, j160/j163 (`0df7763` / `51bfa57`), j161
|
||||
(`docs/ram_floor_exhaustion_ab.md:219`).
|
||||
**Instrument for the new numbers below:** `worktrees/j167-ceiling/j167_probe.py`
|
||||
(branch `j167-ceiling`) — pure replay of the recorded corpora
|
||||
(`/tmp/tfil_ab2/out/`, 60 252 of our own scored shots over 140 battles; and
|
||||
`/tmp/firelag_live2/`, 1 700 shots / 1 664 incoming bullets over 4 battles).
|
||||
**No battle, A/B, server or GUI was run for this document.**
|
||||
|
||||
---
|
||||
|
||||
## 1. The ceiling
|
||||
|
||||
**Melee is structurally unavailable against DrussGT, and the exhaust/ram line is a
|
||||
niche rather than a lever.** The evidence is two-sided and independent: (a) *our
|
||||
mover's own ruler* — 94% of forced (no-safe-tile) picks happen at range > 300 u
|
||||
and only 6-7% of picks reach the chosen tile at the estimated arrival time, with
|
||||
the destination hot on arrival 35-42% of the time; and (b) *the j166 pursuit
|
||||
probe* — 35 windows × 250 ticks of open-loop kinematics in which **every**
|
||||
steering law is equal-or-worse than doing nothing clever:
|
||||
|
||||
| steering law (j166) | closing (u/tick) | contact % | TTI (ticks) |
|
||||
|---|---:|---:|---:|
|
||||
| current-position closing | 4.03 | 65.7 | 72.3 |
|
||||
| body/barrel ray | 1.38 | 40.0 | 131.4 |
|
||||
| velocity intercept (degenerate at equal speed) | — | 0 over 2 118 ticks | — |
|
||||
| best case: lag-5 lead | 4.20 | 65.7 | 68.9 |
|
||||
|
||||
The root cause of the historical **0/59 proactive-ram** result
|
||||
(`docs/ramming_negative_result.md`) is not a bad gate: **DrussGT never let the
|
||||
distance drop.** Per-round minimum distance 152-338 u, median ~490 u, and
|
||||
`frac(dist < 50) = 0.000` in all four recorded rounds. A pursuit that never gets
|
||||
below 152 u cannot make contact, whatever the gate says. It is also not a
|
||||
gun-side problem: **the server never transmits the enemy's gun direction**
|
||||
(`ScannedBotEvent` = `energy, x, y, direction` where `direction` is the BODY
|
||||
heading, plus `speed`; `TurnProcessor.kt:313-323`). There is no aim-based lead,
|
||||
no aim-based dodge and no early warning available. Against DrussGT the
|
||||
body-to-bullet angle has median **90.1 deg**, and the body ray passes within
|
||||
10 deg of us on **0.0% of 1 794 ticks** — its gun is always on us, its body
|
||||
never is.
|
||||
|
||||
**Recorded so the idea is not re-proposed:** the j166 lag-5 residue does improve
|
||||
TTI (72.3 → 68.9) and **converts to contact 0% of the time**. A 4% TTI gain with
|
||||
zero contact conversion is noise, not a lead.
|
||||
|
||||
### The honest remaining niches for exhaust/ram
|
||||
|
||||
1. **An opponent that closes on us.** Ram works whenever the other side comes to
|
||||
us. Nothing here generalises away from that.
|
||||
2. **A late-round exhaustion when they are already near.** The one conversion
|
||||
ever recorded came from a *finisher* (enemy 16 → 1 energy), which is already
|
||||
the default gate.
|
||||
3. **Any 2v1+ mode**, where closing dynamics are not symmetric.
|
||||
|
||||
Against DrussGT specifically none of these will move the score, and
|
||||
`TR_RAM_FLOOR_ENERGY` is under test in j163 — do not duplicate it.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the ceiling implies
|
||||
|
||||
**If range is held, the only remaining lever is the gun at range, and the binding
|
||||
numbers are the gun's, not the tile picker's.** The long-range hit rate is
|
||||
**~9-10%** (Pattern live: 12.3% at 300-450 px, 9.2% at 450+; overall 10.5% —
|
||||
`docs/headon_longrange_live.md`), the live hit half-window at 450 px is
|
||||
**`atan(18/450) = 2.29°`** (`docs/gun_campaign.md:59`), and the measured arrival
|
||||
aim error is **16.19° mean-abs at 450+** (`docs/bitbrain_campaign.md:107`,
|
||||
`docs/headon_longrange_live.md:85`). 16.19° is **7× the window**. The tile picker
|
||||
cannot close a 7× gap that sits downstream of the gun.
|
||||
|
||||
### New measurement — arrival aim error decomposed (j167, 23 275 shots at 450+ px)
|
||||
|
||||
Arrival aim error is defined non-circularly: the angle between the fired bearing
|
||||
and the bearing to where the target *actually is* when the bullet arrives
|
||||
(`tof = 20 - 3·power`, so the flight time comes from the power, not from the
|
||||
shot's own geometry). It splits **exactly**, as signed angles, into
|
||||
|
||||
* **B, the model part** = the error the gun's own lead model leaves behind, and
|
||||
* **C, manoeuvre** = the target's path curvature relative to the
|
||||
constant-velocity extrapolation from the true state at fire time.
|
||||
|
||||
| band (px) | n | mean|A| | mean|B| (model) | mean|C| (manoeuvre) | sd(B) | sd(C) | corr(B,C) |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 0-100 | 24 767 | 83.36 | 101.56 | 49.38 | 124.5 | 75.9 | −0.65 |
|
||||
| 300-450 | 11 372 | 13.07 | 21.77 | 11.00 | 26.0 | 13.0 | −0.87 |
|
||||
| **450+** | **23 275** | **11.26** | **17.18** | **7.73** | **20.7** | **9.3** | **−0.85** |
|
||||
|
||||
At 450+ the model part's variance is **2.2× the manoeuvre part's**, and
|
||||
`corr(B,C) = −0.85` means the two largely *cancel* — the net 11.26° is much
|
||||
smaller than either part. **The 16° is a lead-model number, not a dodge number.**
|
||||
Two supporting numbers: a naive constant-velocity extrapolation of a **2-tick-old**
|
||||
position scores 8.45° mean-abs at 450+, and the time-of-flight implied by the
|
||||
shot's own geometry (holding the current velocity) sits a **median 10 ticks short**
|
||||
of the power-derived arrival tick (p10 −18, p90 +31) — i.e. the gun systematically
|
||||
**under-leads in time**, consistent with `docs/lead_capture_by_range.md`
|
||||
(capture 0.135 at 450+).
|
||||
|
||||
> **Do not read "a stale-CV model scores 8.45°" as "simplify the gun".** This is
|
||||
> exactly the offline-ruler trap that killed HeadOn: the ruler said a no-lead gun
|
||||
> was equal-or-better at 300+ and live it hit **20×/23× less**
|
||||
> (`docs/headon_longrange_live.md`). The corpus is closed-loop — the target's
|
||||
> manoeuvre is a *reaction to our own bullet* — so (B) and (C) are not separable
|
||||
> here, and per `docs/offline_harness_trust.md` (j89: 0/6 on closed-loop) this
|
||||
> instrument ranks per-gun single-tick prediction, it does not predict a live A/B.
|
||||
|
||||
### Cross-reference: what is still open in the gun docs
|
||||
|
||||
| doc | finding | status after this ceiling |
|
||||
|---|---|---|
|
||||
| `docs/gun_campaign.md:59` | hit half-window 2.29° at 450 px; measured signal 4.6-7.6° | **STILL OPEN and now the load-bearing number.** The decomposition says the gap is in the *model*, and the model is systematically 10 ticks short in time-of-flight. |
|
||||
| `docs/gun_campaign.md:40-45` | lead amplitude is dead (1.0/1.5/2.0/3.0 all worse); radial knobs are bearing-invariant by construction | **CLOSED.** |
|
||||
| `docs/gun_campaign.md:737-753` | `len6` +0.49 wins/run (p=0.039, n=15) did not replicate on n=33 | **CLOSED.** |
|
||||
| `docs/bitbrain_campaign.md:189` | BitBrain / TMHorizon corrector adds no measurable aim (16.199 vs 16.193) | **CLOSED.** |
|
||||
| `docs/bitbrain_campaign.md:107` | Pattern's own lead correlation with the required lead is 0.165 at 450+ | **STILL OPEN.** It is the same defect the decomposition names. |
|
||||
| `docs/state_window_gate.md` | single wave-relative state at Q=4 predicts the miss bin at 0.4094 vs 0.2348 majority, but bins are 4.58-7.63° wide | **STILL OPEN, and now the best-placed surviving idea** — it is a *model* correction, which is where the error is. |
|
||||
| `docs/gun_rack_analysis.md:423-455` | the 16-candidate rack ranking A/B found no winner; knobs added, all neutral | **CLOSED** (13 guns, `onlyPattern` shipped). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Negative-results ledger — mechanisms closed by measurement
|
||||
|
||||
Do not re-litigate any row. The unit of evidence is the **opponent**.
|
||||
|
||||
| mechanism | knob / job | headline number | verdict |
|
||||
|---|---|---|---|
|
||||
| Geometry-weighted tile draw | `TR_TFIL_GEO_MODE/TAU`, j152 `38fbc6e`, A/B'd j159 `4a1f3e1` | **−8.83 damage/run, p=0.0061**; wins −0.05, p=0.46; +26.3 px mean distance on 15/15 opponents | **REJECTED.** Default off, stays off. |
|
||||
| The bounded hold | `TR_TFIL_HOLD_MAX_TICKS`, j154 `2223ca6` | mechanism-positive, outcome-null (j146/j153) | **Default off.** No live win. |
|
||||
| The proactive ram | `oldram` vs `base` gate `dist<200` | **p=0.69**, damage 279 vs 284, survival 17/49 vs 16/49; **0/59 opportunity→contact** | **CLOSED** (`docs/ramming_negative_result.md`). |
|
||||
| The aim-based ram | j166 `a5a49bd` | body ray within 10° of us on **0.0% of 1 794 ticks**; body/barrel ray contact 40.0% vs 65.7% for doing nothing clever | **IMPOSSIBLE** — the server never sends gun direction (`TurnProcessor.kt:313-323`). |
|
||||
| Arrival commitment (`tfil`) | j144 `d2005ab` | mechanism-positive, outcome-null | Default off. |
|
||||
| Turn-cost tiebreak among safe tiles | j145 `39c90fd` | real but small mechanism, under-powered outcome null (300 battles, 5 arms) | Default off. |
|
||||
| Field shape (safety) | j146 `de5d02b` | safe-set broken 63.5% → 30.4% offline; live null on damage and wins (375 battles, 5 arms) | **Default off.** |
|
||||
| Corridor bound | j148 `5e213df` `TR_{TFIL,STRAFE}_CORRIDOR_TICKS` | never landed in a live A/B | Untested, not a candidate. |
|
||||
| Ring arrival commitment | j165 `fe77056` `TR_TFIL_RING_COMMIT_ARRIVAL` | reach 0.24% → **3.05%**, picks 5 521 → 525, byte-for-byte default parity over 20 026 ticks, 148 guards | **Mechanism-positive, default off.** The strongest surviving movement mechanism. |
|
||||
| Firing floor / enemy-exhaustion ram | j160 `23bce2d`, A/B'd j163 `51bfa57` | **clean negative**; the offline energy corpus missed the live game by 200× | **Under test in j163 — do not duplicate.** |
|
||||
| Fire-detection lag | j147 `d21f7ce` `TR_FIRE_LAG` | displacement 19.06 → 5.37 px, deadline error 0.99 → 0.06 ticks; **live outcome-neutral**; ceiling ~10% of incoming damage (measured below) | **Default off, permanently.** |
|
||||
| Hard arrival bound | j151 `a01141c` `TR_TFIL_ARRIVE_TICKS` | mechanism-positive, outcome-null | Default off. |
|
||||
|
||||
> **Methodological caution (j161), binding on everything above.** Pooled tests
|
||||
> can hide real per-opponent effects: j159's safety signal was **p=0.0008
|
||||
> per-opponent while the pooled test was null** (`docs/ram_floor_exhaustion_ab.md:219`).
|
||||
> **Any future mechanism claim must report per-opponent mechanism metrics, not a
|
||||
> pooled mean.** A pooled null is not evidence of absence; it is evidence that
|
||||
> the heterogeneity was not averaged down.
|
||||
|
||||
---
|
||||
|
||||
## 4. Lead-time lever 1 — what a 2-tick-stale ghost really costs
|
||||
|
||||
`TR_FIRE_LAG` back-dates the bullet ghost (default 0). Energy-drop shot
|
||||
detection lags **1.9 ticks mean**; median bullet flight is **19 ticks**
|
||||
(`onHitByBullet` gives 82 hits / 7 421 ticks, one update per ~90 ticks).
|
||||
|
||||
**Measured on 55 750 incoming bullets** (`/tmp/tfil_ab2/out/`). For each bullet:
|
||||
the time to closest approach of the target's recorded path to the bullet line
|
||||
(**median 9 ticks**, p10 1, p90 39), and the minimum number of ticks of lead time
|
||||
a max-speed hard-turn dodge needs to build 17 px of lateral displacement:
|
||||
|
||||
| minimum dodge lead time (ticks) | 0 | 1 | 2 | 3 | 4 | 5+ |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| share of incoming bullets | **57%** | 33% | 4% | 2% | 1% | 2% |
|
||||
|
||||
**57% of incoming bullets are already undodgeable at the instant they are fired**,
|
||||
and only **~10%** (need ≥ 2 ticks) are in a regime where a 2-tick detection lag
|
||||
can change anything. Applying the lag to the open-loop dodge model:
|
||||
|
||||
| ghost lag (ticks) | modelled hits | Δ vs perfect | share of all bullets whose hit/miss verdict flips |
|
||||
|---|---:|---:|---:|
|
||||
| 0 | 22 419 | — | — |
|
||||
| **1.9 / 2** | **25 100** | **+2 681 (+12.0%)** | **10.18%** |
|
||||
| 3 | 26 230 | +14.6% | 14.63% |
|
||||
| 5 | 28 949 | +22.0% | 22.04% |
|
||||
|
||||
**Verdict: the 1.9-tick lag costs on the order of 10% more incoming hits** — at
|
||||
the measured ~200 damage/run, roughly **20 damage/run**, an order of magnitude
|
||||
below the movement A/B damage MDE. This is consistent with `TR_FIRE_LAG`'s already
|
||||
measured live outcome-neutral result. The ghost is *wrong*, but wrongness at
|
||||
10% of incoming damage cannot be turned into wins at this sample size.
|
||||
|
||||
**Recommendation: `TR_FIRE_LAG` stays off permanently.** It is a correctness fix
|
||||
with a measured, bounded, sub-MDE payoff.
|
||||
|
||||
---
|
||||
|
||||
## 5. Lead-time lever 2 — the 16° decomposed, component by component
|
||||
|
||||
At 450+ px (23 275 shots), against the 11.26° net arrival error:
|
||||
|
||||
| component | measured | addressable? |
|
||||
|---|---|---|
|
||||
| **(a) enemy body-gun decoupling** | `\|gun dir − body heading\|` median **89.9°** (p10 25.9, p90 154.0, n=60 928). Extrapolating the target along its **gun** instead of its **body** would put the arrival bearing **79.5° median** wrong. | **Not present, and not addressable.** The intercept model uses the target's *recorded position and velocity*, both of which are the true body quantities and both exactly observed. Body-gun decoupling therefore contributes **exactly 0** to our arrival error. It is fatal for *aim-based* leading and threat warning (j166) and irrelevant to *position-based* leading. |
|
||||
| **(b) our own leading model** | mean|·| **17.18°**, sd **20.7**; implied time-of-flight a **median 10 ticks short** of the power-derived arrival tick | **DOMINANT, and addressable.** This is ~2.2× the manoeuvre variance and it is the whole of the 16°. |
|
||||
| **(c) target manoeuvre between scan and fire** | mean|·| **7.73°**, sd **9.3** | Small relative to (b), and **irreducible** — it is the dodger's own unpredictability, exactly the ~half of the under-lead `docs/lead_capture_by_range.md` attributes to a trivial predictor's own ceiling. |
|
||||
| **(d) gun turn rate / time-to-fire** | the correct solution drifts a **median 0.416°/tick** (p90 5.45). The gun turns at 10°/tick, so a 17° correction takes **1.7 ticks ≈ 0.40°** of drift. | **Not binding.** Contributes ~**0.4°, i.e. ~3% of the 11.26° error.** The gun can always reach the answer; it aims at the wrong answer. |
|
||||
|
||||
**So the 16° is not (a), not (c) and not (d). It is (b) — the lead model's
|
||||
time-of-flight, short by ~10 ticks.** Caveat, stated once and load-bearing: on a
|
||||
closed-loop corpus (B) and (C) are not cleanly separable, since the target's
|
||||
manoeuvre is a reaction to our own shot; the `corr(B,C) = −0.85` is exactly that
|
||||
confound showing up. The *rank order* (b) ≫ (c) ≫ (d) > (a)=0 is robust to it
|
||||
because (b) and (c) differ by 2.2× in variance and (d) is 3%.
|
||||
|
||||
---
|
||||
|
||||
## 6. The proposed lever: pre-multiply before learning — PREMISE DEAD
|
||||
|
||||
The design: aim error is largely a *product* (bearing-rate × time-of-flight), so
|
||||
pre-multiply the two features and feed one small Tsetlin machine. **Measured on
|
||||
the same corpus, the premise does not hold and the experiment should not be
|
||||
built.** `y` = the required lead angle (current bearing → arrival bearing), i.e.
|
||||
exactly the quantity the gun must predict; `b` = the observable 4-tick finite
|
||||
difference of the bearing; `t = 20 − 3·power`.
|
||||
|
||||
| band (px) | n | corr(**b·t**, y) | corr(b+t, y) | R² additive [1,b,t] | R² product [1,b·t] | held-out side acc, additive | held-out side acc, product | held-out residual rms (deg) |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 0-200 | 24 934 | −0.1020 | −0.1022 | 0.0105 | 0.0104 | 0.579 | 0.580 | 75.98 |
|
||||
| 200-300 | 671 | 0.1190 | 0.0766 | 0.0147 | 0.0142 | 0.488 | 0.487 | 13.70 |
|
||||
| 300-450 | 11 372 | **0.1501** | 0.0299 | 0.0256 | 0.0225 | 0.544 | 0.534 | 8.43 |
|
||||
| **450+** | **23 275** | **0.2833** | 0.1052 | **0.0831** | 0.0803 | **0.603** | **0.601** | **6.68** |
|
||||
| pooled | 60 252 | −0.1005 | −0.1009 | 0.0102 | 0.0101 | — | — | — |
|
||||
|
||||
*(side accuracy is 2-fold held-out and balanced; the TM record is ~0.47-0.49)*
|
||||
|
||||
Two things are true and the second kills the idea:
|
||||
|
||||
1. **As a single scalar, the product is much the better feature at range**:
|
||||
`corr(b·t, y) = 0.283` vs `corr(b+t, y) = 0.105` at 450+ — 2.7× better, and
|
||||
5× better at 300-450. So the *premise* ("the error is a product, not a sum")
|
||||
is **confirmed as a statement about correlation**.
|
||||
2. **But it buys nothing a weight-sum cannot already express.** The best linear
|
||||
additive model on the same two features reaches **R² 0.0831 vs the product's
|
||||
0.0803**, and the held-out balanced side accuracy is **0.603 (additive) vs
|
||||
0.601 (product)** — a 0.002 difference, i.e. nothing. Pooled, the two are
|
||||
identical (−0.1005 vs −0.1009; R² 0.0102 vs 0.0101). A TM with two input
|
||||
features **already reconstructs the product term**; the multiplication is what
|
||||
the network was doing anyway.
|
||||
|
||||
**Even the ceiling is out of reach.** The best held-out residual on the required
|
||||
lead at 450+ is **6.68° rms**, against a live hit half-window of **2.29°** — a
|
||||
2.9× shortfall. Pre-multiplying does not get a classifier to 2.29°; nothing in
|
||||
this family does. **Do not build it.** The spec is recorded here so the idea is
|
||||
closed on measurement rather than on taste.
|
||||
|
||||
*(Had it survived, the spec would have been: one TM, ONE input feature `b·t`
|
||||
binarised on sign, plus the 4-bit horizon one-hot as today; offline gate =
|
||||
held-out balanced side accuracy above 0.55 and residual rms below 3° at 450+;
|
||||
live gate = wins/run with CI excluding 0 and sign-flip p<0.05 at 210 runs/arm,
|
||||
damage not detectably down, MDE 0.17 wins/run. Predicted accuracy was 0.60 side
|
||||
accuracy, which is a real signal against the 0.47-0.49 record — and still not
|
||||
close enough to the window to convert.)*
|
||||
|
||||
---
|
||||
|
||||
## 7. The A/B queue, in priority order, with the MDE honestly restated
|
||||
|
||||
Throughput **22.7-23.2 runs/min**; movement gate resolved **0.17 wins/run at 210
|
||||
runs/arm**; the `1/√n` extrapolation to 0.10 wins/run is **607 runs/arm ≈ 1.4 h —
|
||||
a FLOOR on elapsed time, not an estimate**, because opponent heterogeneity does
|
||||
not average down. A null at this sample size **only excludes a LARGE effect.**
|
||||
(j163 additionally measured 14-22 runs/min, not 22.7-23.2, so even the floor is
|
||||
optimistic.)
|
||||
|
||||
| # | experiment | what it tests | cost | a null would license |
|
||||
|---|---|---|---|---|
|
||||
| **1** | **The lead-model time-of-flight correction** (j167's (b)): re-derive the gun's arrival prediction so the implied flight is the power-derived tick, not 10 ticks short. | The one component that carries 2.2× the error variance at 450+, and the only open axis in `docs/gun_campaign.md` (lead *information*, not amplitude). | Offline gate first: arrival aim error at 450+ must fall below 11.26° mean-abs on held-out battles, ideally <8°; only then 2 arms × 15 opponents × 14 runs = 420 battles ≈ **0.3-0.4 h** wall. | Closing the single open gun axis. Nothing left in the gun. |
|
||||
| 2 | `TR_TFIL_RING_COMMIT_ARRIVAL` (j165, default off) | Whether the largest surviving *movement* mechanism (reach 0.24% → 3.05%, picks 5 521 → 525, 148 guards) converts to wins. | 210 runs/arm ≈ **1.4 h floor**. | Retiring the whole ring/approach programme: if even a 12× reach gain is outcome-null, the ceiling argument is confirmed end to end. |
|
||||
| 3 | `TR_FIRE_LAG` (tfil/strafe, default off) | Nothing worth testing — its ceiling is now measured at **~10% of incoming damage ≈ 20 dmg/run**, below the MDE. | Would be 1.4 h to learn nothing. | Nothing. **Skip it**; the measurement has already answered it. |
|
||||
| 4 | `TR_TFIL_ARRIVE_TICKS` (j151, default off) | Whether a hard arrival bound converts now that the ring is rehabilitated. | 1.4 h. | Retiring it with j151's own null attached. |
|
||||
| 5 | `TR_RAM_FLOOR_ENERGY` (j160, j163) | **Under test in j163. DO NOT DUPLICATE.** | — | — |
|
||||
|
||||
**Recommendation.** Run **only experiment 1**, and only after the *offline* gate
|
||||
passes; if the offline gate does not move the 450+ arrival error below ~8°, run
|
||||
nothing at all. Given five consecutive nulls or near-nulls (j144, j145, j146,
|
||||
j147, j159) plus a clean negative in j163, spending 1.4 h of live time on
|
||||
experiments 2-4 is not justified — those are mechanism-positive
|
||||
mechanisms whose outcome nulls are already the standing record, and a null there
|
||||
teaches nothing that the ledger does not already say.
|
||||
|
||||
**"The ceiling is real and we should stop spending on movement" is the answer.**
|
||||
The remaining budget belongs to the gun's lead model, or it is not spent.
|
||||
Reference in New Issue
Block a user