diff --git a/.gitignore b/.gitignore index 805c2eb..0a87a52 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ __pycache__/ devbox.json devbox.lock +# Local settings file (explicit path only - never a wildcard pattern) +ModularBot_garage/.env + # Compiled bot binaries — all builds go to *_garage/out/ *_garage/out/ diff --git a/ModularBot_garage/.env.example b/ModularBot_garage/.env.example new file mode 100644 index 0000000..4d554f9 --- /dev/null +++ b/ModularBot_garage/.env.example @@ -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 diff --git a/ModularBot_garage/ModularBot.sh b/ModularBot_garage/ModularBot.sh index abc5c0c..6a1088b 100755 --- a/ModularBot_garage/ModularBot.sh +++ b/ModularBot_garage/ModularBot.sh @@ -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 diff --git a/ModularBot_garage/ModularBot_garage.sh b/ModularBot_garage/ModularBot_garage.sh index abc5c0c..6a1088b 100755 --- a/ModularBot_garage/ModularBot_garage.sh +++ b/ModularBot_garage/ModularBot_garage.sh @@ -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 diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index fce36e2..a0e18f3 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -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 diff --git a/ModularBot_garage/src/env_boot.nim b/ModularBot_garage/src/env_boot.nim new file mode 100644 index 0000000..84bdb54 --- /dev/null +++ b/ModularBot_garage/src/env_boot.nim @@ -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() diff --git a/ModularBot_garage/src/env_dotenv.nim b/ModularBot_garage/src/env_dotenv.nim new file mode 100644 index 0000000..c9b3d11 --- /dev/null +++ b/ModularBot_garage/src/env_dotenv.nim @@ -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 (also --env-file=) +## 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) diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 9c4eaa2..96cbd8a 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -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) diff --git a/common_libs/tests/test_env_dotenv.nim b/common_libs/tests/test_env_dotenv.nim new file mode 100644 index 0000000..3382fe9 --- /dev/null +++ b/common_libs/tests/test_env_dotenv.nim @@ -0,0 +1,180 @@ +## Guard test for the .env loader (the module the bot uses to read its +## settings from a `.env` file). +## +## NO battle, NO Java, NO server. Run with: +## nim c -r --path:common_libs common_libs/tests/test_env_dotenv.nim +## +## It pins the pieces that must not silently drift: +## * the parser (comments, `export `, quotes, empty value, CRLF, and a +## malformed line that MUST raise with the file+line); +## * resolution precedence (flag > TR_ENV_FILE > ./.env > .env next to the +## executable > none); +## * the FILE WINS over the real environment, and the conflict is reported; +## * an explicitly requested missing file raises (the caller then exits +## nonzero), while a missing DEFAULT file is a silent no-op. + +import std/[os, strutils] +import "../../ModularBot_garage/src/env_dotenv" + +var failures = 0 +var checks = 0 + +proc check(name: string, ok: bool) = + inc checks + if ok: echo "PASS: ", name + else: echo "FAIL: ", name; inc failures + +let tmpDir = getTempDir() / "test_env_dotenv_guard" +createDir(tmpDir) + +proc tmpPath(name: string): string = tmpDir / name + +proc writeTmp(name, content: string): string = + result = tmpPath(name) + writeFile(result, content) + +# ── 1. the parser ──────────────────────────────────────────────────────────── + +proc testParser() = + let entries = parseEnvFileContent("""# a comment, ignored + +TR_MOVEMENT=tfil +export TR_RACK_TMHORIZON=both +QUOTED="hello world" +SINGLE='x y' +EMPTY= + SPACED = trimmed +""", "sample.env") + check "comment + blank lines are skipped (6 entries)", entries.len == 6 + if entries.len == 6: + check "plain KEY=VALUE", entries[0].key == "TR_MOVEMENT" and entries[0].value == "tfil" + check "leading `export ` is tolerated", + entries[1].key == "TR_RACK_TMHORIZON" and entries[1].value == "both" + check "double quotes are stripped", + entries[2].key == "QUOTED" and entries[2].value == "hello world" + check "single quotes are stripped", + entries[3].key == "SINGLE" and entries[3].value == "x y" + check "an empty value is allowed", + entries[4].key == "EMPTY" and entries[4].value == "" + check "whitespace around key and value is trimmed", + entries[5].key == "SPACED" and entries[5].value == "trimmed" + + let crlf = parseEnvFileContent("A=1\r\nB=two\r\n", "crlf.env") + check "CRLF line endings are stripped from the value", + crlf.len == 2 and crlf[0].value == "1" and crlf[1].value == "two" + + var raised = false + try: + discard parseEnvFileContent("this line has no equals sign\n", "bad.env") + except ValueError as e: + raised = true + check "malformed line error names the file", "bad.env" in e.msg + check "malformed line error names the line number", ":1:" in e.msg + check "malformed line RAISES (never silently ignored)", raised + + var emptyKeyRaised = false + try: + discard parseEnvFileContent("=value\n", "bad2.env") + except ValueError: + emptyKeyRaised = true + check "an empty key raises", emptyKeyRaised + +# ── 2. resolution precedence ───────────────────────────────────────────────── + +proc testResolution() = + let flagChoice = chooseEnvFile("/x/flag.env", "/x/tr.env", + "/cwd/.env", "/exe/.env") + check "flag beats TR_ENV_FILE and the defaults", + flagChoice.source == "flag" and flagChoice.path == "/x/flag.env" and + flagChoice.explicit + + let envChoice = chooseEnvFile("", "/x/tr.env", "/cwd/.env", "/exe/.env") + check "TR_ENV_FILE beats the default paths", + envChoice.source == EnvFileEnvVar and envChoice.path == "/x/tr.env" and + envChoice.explicit + + let cwdFile = writeTmp("cwd.env", "A=1\n") + let exeFile = writeTmp("exe.env", "A=2\n") + let cwdChoice = chooseEnvFile("", "", cwdFile, exeFile) + check "./.env (cwd) is chosen when it exists and is not explicit", + cwdChoice.source == "cwd" and cwdChoice.path == cwdFile and + not cwdChoice.explicit + + let exeChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"), exeFile) + check ".env next to the executable is the fallback", + exeChoice.source == "exe" and exeChoice.path == exeFile + + let noneChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"), + tmpPath("missing_exe.env")) + check "no candidate exists -> none (silent)", + noneChoice.path == "" and noneChoice.source == "none" and + not noneChoice.explicit + + check "--env-file is parsed", + flagEnvFilePath(@["--server", "x", "--env-file", "/a/b.env"]) == "/a/b.env" + check "--env-file= is parsed", + flagEnvFilePath(@["--env-file=/a/b.env"]) == "/a/b.env" + check "no --env-file yields empty", flagEnvFilePath(@["--server", "x"]) == "" + var raised = false + try: + discard flagEnvFilePath(@["--env-file"]) + except EnvFileError: + raised = true + check "--env-file with no argument raises", raised + +# ── 3. the file wins over the real environment ─────────────────────────────── + +proc testFileWins() = + let f = writeTmp("wins.env", "TR_TEST_J126=from-file\nTR_TEST_SAME_J126=same\n") + putEnv("TR_TEST_J126", "from-shell") + putEnv("TR_TEST_SAME_J126", "same") + let conflicts = applyEnvFile(f) + check "the file value WINS over the exported value", + getEnv("TR_TEST_J126") == "from-file" + check "the conflict is reported with key, file value and shell value", + conflicts.len == 1 and conflicts[0].key == "TR_TEST_J126" and + conflicts[0].fileValue == "from-file" and + conflicts[0].shellValue == "from-shell" + check "an identical value is NOT a conflict", + conflicts.len == 1 # TR_TEST_SAME_J126 is absent from conflicts + check "keys from the file are tracked for the boot report", + isFromEnvFile("TR_TEST_J126") and isFromEnvFile("TR_TEST_SAME_J126") + delEnv("TR_TEST_J126") + delEnv("TR_TEST_SAME_J126") + +# ── 4. missing explicit file vs missing default file ───────────────────────── + +proc testMissingFiles() = + var raised = false + try: + discard loadEnvFile(EnvFileChoice(path: tmpPath("nope.env"), + source: "flag", explicit: true)) + except EnvFileError as e: + raised = true + check "missing explicit file error names the path", "nope.env" in e.msg + check "missing explicit file RAISES (caller exits nonzero)", raised + + var quiet = true + var n = 0 + try: + n = loadEnvFile(EnvFileChoice(path: tmpPath("missing-default.env"), + source: "cwd", explicit: false)).len + except CatchableError: + quiet = false + check "missing default file is a silent no-op", quiet and n == 0 + + check "no file at all is a silent no-op", + loadEnvFile(EnvFileChoice(path: "", source: "none", explicit: false)).len == 0 + +# ── driver ─────────────────────────────────────────────────────────────────── + +testParser() +testResolution() +testFileWins() +testMissingFiles() + +echo "\n", checks, " checks, ", failures, " failure(s)" +if failures > 0: + echo checks, " check(s) run, ", failures, " FAILED" + quit(1) +echo "All env-dotenv checks passed." diff --git a/docs/env_reference.md b/docs/env_reference.md index e7ead4e..e62501d 100644 --- a/docs/env_reference.md +++ b/docs/env_reference.md @@ -1,5 +1,29 @@ # Environment variable reference +## Putting your settings in a .env file + +Instead of typing `export ...` in a shell, put your settings in a file. The bot +reads them for you. This is easier to keep tidy, and no stale shell variable can +surprise you. + +1. In the bot folder, copy `.env.example` to `.env`. +2. Open `.env` and set the knobs you want. One `KEY=VALUE` per line. Lines + starting with `#` are comments. +3. Start the bot from the bot folder (`./ModularBot`). It picks up `.env` + automatically. +4. To use a different file, pass it on the command line: + `./out/ModularBot --env-file /path/to/my.env`. +5. If you ask for a file that does not exist, the bot stops with an error. A + missing default `.env` is fine and its absence is silent. + +**The file wins over the shell.** If the same name is set in both places with a +different value, the file value is used, and the bot prints one line telling you +which shell value was overridden. Start the report check with: +`grep '^\[env\]' /tmp/modularbot_stdout.log`. Values that came from the file are +labelled `(source: .env)`. + +--- + Every knob the bot reads. **Most are read once per process, at module init; a few are read lazily on first use** (`TR_TMHORIZON_SHIFT`/`_BIG_MULT`/`_LOG`/ `_RESET_ON_TARGET` in `ensureConfig`, `TR_PATTERN_RAD_*` inside `predict`). Either