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:
2026-09-26 09:08:39 +02:00
parent 8e109bae0b
commit 2d8b7d7875
10 changed files with 495 additions and 6 deletions
+2
View File
@@ -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
+14
View File
@@ -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()
+201
View File
@@ -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)
+16 -4
View File
@@ -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)