j126: read bot settings from a .env file (default ./.env, --env-file flag, TR_ENV_FILE); file wins over shell leftovers, boot report labels (source: .env)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# Copy this file to `.env` (same folder as the bot) and edit what you need.
|
||||
# The bot reads `.env` automatically when you start it from its folder.
|
||||
# To use a different file: ./out/ModularBot --env-file /path/to/my.env
|
||||
#
|
||||
# The file WINS over variables you exported in the shell. If a shell value
|
||||
# differs, the bot prints a warning line so you know it was overridden.
|
||||
#
|
||||
# Everything is optional. Leave a line out (or commented, with #) to keep the
|
||||
# built-in default. Values shown are the defaults.
|
||||
|
||||
# ── movement ─────────────────────────────────────────────────────────────────
|
||||
# Which dodging engine to run: strafe (default), tfil, tfil_ring, surf.
|
||||
TR_MOVEMENT=strafe
|
||||
|
||||
# ── gun rack: which guns the bot may choose (off | 1v1 | melee | both) ───────
|
||||
# The shipped default is Pattern only. Turn a gun on with `both`.
|
||||
TR_RACK_PATTERN=both
|
||||
#TR_RACK_TMHORIZON=both
|
||||
#TR_RACK_HEADON=both
|
||||
|
||||
# Drop whole guns by number (comma separated ids; empty = keep all).
|
||||
GUN_RACK_DISABLE=
|
||||
|
||||
# ── power / energy policy ────────────────────────────────────────────────────
|
||||
# 1 = use the energy-aware power cap (default). 0 = no cap (for experiments).
|
||||
TR_POWER_POLICY=1
|
||||
# Lowest cap when our own energy is low. NOTE: this is a CEILING, not a floor.
|
||||
TR_POWER_ENERGY_MIN=0.5
|
||||
|
||||
# ── logs (set the value to 1; presence alone turns some of them on) ──────────
|
||||
# Print one line per power decision.
|
||||
#TR_POWER_LOG=1
|
||||
# Print one line per ram start/stop and why.
|
||||
#TR_RAM_LOG=1
|
||||
# Print movement band/class changes.
|
||||
#TR_MOVEMENT_LOG=1
|
||||
# Print the per-shot thinking of the TM horizon gun.
|
||||
#TR_TMHORIZON_LOG=1
|
||||
# Print one line per round result.
|
||||
TR_RESULT_LOG=1
|
||||
|
||||
# Where the per-round gun stats and the per-shot log are written.
|
||||
GUN_STATS_PATH=/tmp/gun_stats.jsonl
|
||||
GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl
|
||||
|
||||
# ── measurement helpers (leave off unless you are measuring) ─────────────────
|
||||
#TR_RECORD_WORLDSTATE=1
|
||||
#TR_RADAR_SCANLOG=1
|
||||
#TR_TRACKER_PROBE=1
|
||||
|
||||
# ── boot report ──────────────────────────────────────────────────────────────
|
||||
# 1 = print the [env] report on startup (default). 0 = do not print it.
|
||||
TR_ENV_REPORT=1
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/bin/sh
|
||||
cd "$(dirname "$0")"
|
||||
exec ./out/ModularBot >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log
|
||||
exec ./out/ModularBot "$@" >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/bin/sh
|
||||
cd "$(dirname "$0")"
|
||||
exec ./out/ModularBot >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log
|
||||
exec ./out/ModularBot "$@" >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
|
||||
## Movement: OscillatorModule (perpendicular strafing).
|
||||
|
||||
import env_boot # MUST be first: applies the .env file before any other
|
||||
# module's top-level `let` reads the environment.
|
||||
import std/[math, os, strformat, tables, sets, json, random, strutils]
|
||||
import robocode_tankroyale_botapi
|
||||
import radar_harness/radar_interface
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
## Imported FIRST by ModularBot.nim, purely for the module-init side effect
|
||||
## below. Nim runs an imported module's top-level code before the importer's,
|
||||
## and sibling imports in the order they are listed, so:
|
||||
##
|
||||
## import env_boot # <-- .env applied HERE
|
||||
## import robocode_tankroyale_botapi
|
||||
## import movements/... # <-- their top-level `let`s now see the file
|
||||
##
|
||||
## That ordering is the whole point: it is what lets every existing env reader
|
||||
## in every other module keep working unchanged. Do not reorder this import.
|
||||
|
||||
import env_dotenv
|
||||
|
||||
bootDotEnv()
|
||||
@@ -0,0 +1,201 @@
|
||||
## .env file support for the bot.
|
||||
##
|
||||
## Why: a dozen TR_* variables were exported in the shell that launched the
|
||||
## server/GUI, and stale leftovers were repeatedly mistaken for live settings
|
||||
## (e.g. a forgotten TR_RACK_TMHORIZON). This module lets those settings live in
|
||||
## a file instead — and the FILE WINS over a leftover shell export.
|
||||
##
|
||||
## Values are applied with putEnv() before anything else reads the environment,
|
||||
## so every existing reader (getEnv, existsEnv, the envInt/envFloat/envBool
|
||||
## helpers, the PRESENCE-based knobs like TR_POWER_LOG) keeps working unchanged.
|
||||
##
|
||||
## Resolution order (first hit wins):
|
||||
## 1. --env-file <path> (also --env-file=<path>)
|
||||
## 2. TR_ENV_FILE
|
||||
## 3. ./.env in the current working directory (the bot root when launched
|
||||
## from the bot dir, which the launcher scripts do)
|
||||
## 4. .env next to the executable
|
||||
## 5. none
|
||||
##
|
||||
## An EXPLICIT request (1 or 2) that names a missing file is a hard error: an
|
||||
## explicit request that silently does nothing is exactly the bug this fixes.
|
||||
## A missing default file (3/4) is normal and silent.
|
||||
##
|
||||
## The boot report (env_report.nim) reads the resolved path/source and the set
|
||||
## of keys that came from the file, so it can label values `(source: .env)`.
|
||||
|
||||
import std/[os, strutils, sets]
|
||||
|
||||
const EnvFileFlag* = "--env-file"
|
||||
const EnvFileEnvVar* = "TR_ENV_FILE"
|
||||
const DefaultEnvFile* = ".env"
|
||||
|
||||
type
|
||||
EnvFileError* = object of CatchableError
|
||||
## Raised when an explicitly requested env file cannot be used.
|
||||
|
||||
EnvEntry* = object
|
||||
key*: string
|
||||
value*: string
|
||||
|
||||
EnvConflict* = object
|
||||
## A key present in BOTH the file and the real environment with different
|
||||
## values. The file value is the one applied; `shellValue` is what it
|
||||
## replaced (the stale leftover that used to win by accident).
|
||||
key*: string
|
||||
fileValue*: string
|
||||
shellValue*: string
|
||||
|
||||
EnvFileChoice* = object
|
||||
path*: string ## resolved path; "" means "no file"
|
||||
source*: string ## "flag" | "TR_ENV_FILE" | "cwd" | "exe" | "none"
|
||||
explicit*: bool ## requested via flag/env var (missing => hard error)
|
||||
|
||||
# ── resolved state (read by the boot report) ─────────────────────────────────
|
||||
|
||||
var
|
||||
gEnvFilePath = ""
|
||||
gEnvFileSource = "none"
|
||||
gEnvFileKeys: HashSet[string]
|
||||
gEnvFileLoaded = false
|
||||
|
||||
proc envFilePath*(): string = gEnvFilePath
|
||||
proc envFileSource*(): string = gEnvFileSource
|
||||
proc isFromEnvFile*(key: string): bool = key in gEnvFileKeys
|
||||
|
||||
# ── CLI parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
proc flagEnvFilePath*(params: openArray[string] = commandLineParams()): string =
|
||||
## The value of `--env-file` / `--env-file=`, or "" when absent. Raises when
|
||||
## `--env-file` is the last argument (a request we cannot honour).
|
||||
var i = 0
|
||||
while i < params.len:
|
||||
let p = params[i]
|
||||
if p == EnvFileFlag:
|
||||
if i + 1 < params.len:
|
||||
return params[i + 1]
|
||||
raise newException(EnvFileError, EnvFileFlag & " needs a path argument")
|
||||
elif p.startsWith(EnvFileFlag & "="):
|
||||
return p[EnvFileFlag.len + 1 .. ^1]
|
||||
inc i
|
||||
return ""
|
||||
|
||||
# ── resolution ───────────────────────────────────────────────────────────────
|
||||
|
||||
proc chooseEnvFile*(flagValue, envValue, cwdCandidate, exeCandidate: string): EnvFileChoice =
|
||||
## Pure resolution: flag, then TR_ENV_FILE, then `./.env`, then the `.env`
|
||||
## next to the executable, then none.
|
||||
if flagValue.len > 0:
|
||||
return EnvFileChoice(path: flagValue, source: "flag", explicit: true)
|
||||
if envValue.len > 0:
|
||||
return EnvFileChoice(path: envValue, source: EnvFileEnvVar, explicit: true)
|
||||
if fileExists(cwdCandidate):
|
||||
return EnvFileChoice(path: cwdCandidate, source: "cwd", explicit: false)
|
||||
if fileExists(exeCandidate):
|
||||
return EnvFileChoice(path: exeCandidate, source: "exe", explicit: false)
|
||||
return EnvFileChoice(path: "", source: "none", explicit: false)
|
||||
|
||||
# ── parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
proc parseEnvFileContent*(content, filename: string): seq[EnvEntry] =
|
||||
## Parse the usual .env shape: one KEY=VALUE per line, blank lines and `#`
|
||||
## comments skipped, a leading `export ` tolerated, surrounding single or
|
||||
## double quotes stripped, whitespace trimmed, an empty VALUE allowed.
|
||||
##
|
||||
## A malformed line RAISES with the file name and line number — it is never
|
||||
## silently ignored, because a typo that does nothing is the failure mode we
|
||||
## are fixing.
|
||||
var lineno = 0
|
||||
for raw in content.splitLines():
|
||||
inc lineno
|
||||
var line = raw
|
||||
if line.len > 0 and line[^1] == '\r': line.setLen(line.len - 1)
|
||||
let stripped = line.strip()
|
||||
if stripped.len == 0 or stripped[0] == '#': continue
|
||||
var body = stripped
|
||||
if body.startsWith("export") and (body.len == 6 or body[6] in Whitespace):
|
||||
body = body[6 .. ^1].strip()
|
||||
let eq = body.find('=')
|
||||
if eq < 0:
|
||||
raise newException(ValueError,
|
||||
filename & ":" & $lineno & ": expected KEY=VALUE, got: " & stripped)
|
||||
let key = body[0 ..< eq].strip()
|
||||
if key.len == 0:
|
||||
raise newException(ValueError,
|
||||
filename & ":" & $lineno & ": empty key in: " & stripped)
|
||||
var value = body[eq + 1 .. ^1].strip()
|
||||
if value.len >= 2 and
|
||||
((value[0] == '"' and value[^1] == '"') or
|
||||
(value[0] == '\'' and value[^1] == '\'')):
|
||||
value = value[1 ..< value.len - 1]
|
||||
result.add EnvEntry(key: key, value: value)
|
||||
|
||||
# ── applying ─────────────────────────────────────────────────────────────────
|
||||
|
||||
proc applyEnvFile*(path: string): seq[EnvConflict] =
|
||||
## Read `path`, apply every entry with putEnv (the FILE WINS over the real
|
||||
## environment), remember the from-file keys, and return the keys whose file
|
||||
## value replaced a DIFFERENT exported value. Raises on a read error or a
|
||||
## malformed line.
|
||||
let entries = parseEnvFileContent(readFile(path), path)
|
||||
for e in entries:
|
||||
if existsEnv(e.key):
|
||||
let shell = getEnv(e.key)
|
||||
if shell != e.value:
|
||||
result.add EnvConflict(key: e.key, fileValue: e.value, shellValue: shell)
|
||||
putEnv(e.key, e.value)
|
||||
gEnvFileKeys.incl e.key
|
||||
|
||||
proc loadEnvFile*(choice: EnvFileChoice): seq[EnvConflict] =
|
||||
## Apply the chosen file. A missing EXPLICIT file raises EnvFileError; a
|
||||
## missing default file is a silent no-op. No file at all is a silent no-op.
|
||||
if choice.path.len == 0: return @[]
|
||||
if not fileExists(choice.path):
|
||||
if choice.explicit:
|
||||
raise newException(EnvFileError, "env file not found: " & choice.path)
|
||||
return @[]
|
||||
result = applyEnvFile(choice.path)
|
||||
|
||||
proc bootDotEnv*() =
|
||||
## Resolve and apply the .env file exactly once. Called at process start by
|
||||
## env_boot.nim (imported first by ModularBot.nim) so every later module-level
|
||||
## read sees the file values.
|
||||
if gEnvFileLoaded: return
|
||||
gEnvFileLoaded = true
|
||||
|
||||
var flag = ""
|
||||
try:
|
||||
flag = flagEnvFilePath()
|
||||
except EnvFileError as e:
|
||||
stderr.writeLine "[dotenv] ERROR: " & e.msg
|
||||
quit(1)
|
||||
|
||||
let envValue = if existsEnv(EnvFileEnvVar): getEnv(EnvFileEnvVar).strip() else: ""
|
||||
let choice = chooseEnvFile(flag, envValue,
|
||||
getCurrentDir() / DefaultEnvFile,
|
||||
getAppDir() / DefaultEnvFile)
|
||||
|
||||
if choice.path.len == 0:
|
||||
gEnvFilePath = ""
|
||||
gEnvFileSource = "none"
|
||||
return
|
||||
|
||||
try:
|
||||
let conflicts = loadEnvFile(choice)
|
||||
gEnvFilePath = choice.path
|
||||
gEnvFileSource = choice.source
|
||||
for c in conflicts:
|
||||
stderr.writeLine "[dotenv] " & c.key & " = " & c.fileValue &
|
||||
" (from " & choice.path & ") overrides the exported value " & c.shellValue
|
||||
except EnvFileError as e:
|
||||
stderr.writeLine "[dotenv] ERROR: " & e.msg
|
||||
stderr.writeLine "[dotenv] it was requested explicitly (--env-file or " &
|
||||
EnvFileEnvVar & "); refusing to start with the request silently ignored."
|
||||
quit(1)
|
||||
except ValueError as e:
|
||||
stderr.writeLine "[dotenv] ERROR: " & e.msg
|
||||
quit(1)
|
||||
except CatchableError as e:
|
||||
stderr.writeLine "[dotenv] ERROR: cannot use env file " & choice.path &
|
||||
": " & e.msg
|
||||
quit(1)
|
||||
@@ -20,6 +20,7 @@
|
||||
## printed, because unrelated variables may hold secrets or machine paths.
|
||||
|
||||
import std/[os, strutils, algorithm, times, sets]
|
||||
import env_dotenv
|
||||
import gun_harness/virtual_bullets
|
||||
import gun_harness/selector
|
||||
import movements/ram_decision
|
||||
@@ -89,13 +90,19 @@ proc tieBreakName(t: TieBreakMode): string =
|
||||
proc sourceOf(name: string): string =
|
||||
## Parsed-value knobs go through `getEnv(name, "")`; an empty value means the
|
||||
## shipped default, so a non-empty value is the only thing that counts as
|
||||
## "from the env".
|
||||
if getEnv(name, "").len > 0: "env" else: "default"
|
||||
## "from the env". A value that came from the resolved .env file is labelled
|
||||
## `.env` even though the file applied it with putEnv (so `getEnv` finds it).
|
||||
if isFromEnvFile(name): ".env"
|
||||
elif getEnv(name, "").len > 0: "env"
|
||||
else: "default"
|
||||
|
||||
proc sourceOfPresence(name: string): string =
|
||||
## Flags read with `existsEnv` are "from the env" whenever they are present,
|
||||
## even if their value is "0" (presence is what the code tests).
|
||||
if existsEnv(name): "env" else: "default"
|
||||
## even if their value is "0" (presence is what the code tests). A value that
|
||||
## came from the .env file is labelled `.env`.
|
||||
if isFromEnvFile(name): ".env"
|
||||
elif existsEnv(name): "env"
|
||||
else: "default"
|
||||
|
||||
proc disabledGunsString(guns: HashSet[int]): string =
|
||||
if guns.len == 0: return "none"
|
||||
@@ -461,6 +468,8 @@ proc knownEnvNames*(): seq[string] =
|
||||
"TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY",
|
||||
"TR_POWER_LOG",
|
||||
"TR_RESULT_LOG",
|
||||
# the .env loader's own environment hook (TR_ENV_FILE names the file)
|
||||
"TR_ENV_FILE",
|
||||
"TR_RAM_OPPORTUNITY", "TR_RAM_OPP_DIST", "TR_RAM_OPP_MARGIN",
|
||||
"TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST",
|
||||
"TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG",
|
||||
@@ -609,6 +618,9 @@ proc printEnvReport*(ctx: EnvReportContext) =
|
||||
return
|
||||
|
||||
echo "[env] === ENVIRONMENT (boot report) ==="
|
||||
echo "[env] env file: ",
|
||||
(if envFilePath().len > 0: envFilePath() & " (source: " & envFileSource() & ")"
|
||||
else: "none")
|
||||
printRawEnvironment()
|
||||
printMistakeWarnings()
|
||||
printEffectiveValues(ctx)
|
||||
|
||||
Reference in New Issue
Block a user