feat(@projects/@magic-civilization): implement gpu-mcts rollouts

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 12:25:47 -07:00
parent 819f33ed38
commit b70a396f14
13 changed files with 191 additions and 98 deletions

View file

@ -51,7 +51,7 @@ Entry format: `YYYY-MM-DD HH:MM <short topic>: <what happened> (files=N) [ref: p
2026-04-16 13:26 Task #7 MAP BALANCE (report-only): Seed 1 p0/p1 ring2 yields organically balanced (p0 25f/23p, p1 29f/22p, within 20% — food actually favors p1). REAL bias: `start_position.gd:_score_start_position` ignores tile.resource_id. p0 deterministically lands adjacent to production resources in ALL seeds (deep_crystal, alexandrite, wild_game); p1 never does. By T20: p0 prod=6, p1 prod=2. Unused `StartBalancer.ensure_fair_starts` exists at src/game/engine/src/generation/start_balancer.gd but is never wired. Follow-up task needed: wire StartBalancer into map_placer.gd:40 behind map_generator.use_balanced_starts flag. (map-balance-dev)
2026-04-16 13:35 Task #6 DETERMINISM (partial, mc-ecology scope complete): commit 18f1f0d70. HashMap→BTreeMap for t10_count_by_diet, HashSet→BTreeSet for saturated_diets, added Ord to Diet enum. 11-line diff. Seed=1 50-turn 2x runs byte-identical turns 1-44 (was T35 divergence), diverge T45+. Remaining divergence in DataLoader (GDScript) — DirAccess.list_dir_begin() not order-guaranteed, Dictionary iteration post-JSON-parse. Follow-up task needed: audit data_loader.gd for sorted file enumeration + simple_heuristic_ai.gd Dictionary iteration. mc-ecology proven clean (266/266 tests pass). (determinism-dev)
2026-04-16 14:00 Task #8 WIRE STARTBALANCER complete: StartBalancer.ensure_fair_starts now wired into map_placer.gd. balanced_retry_20260416_135710 batch results: pop_peak median 36, tiles 72, combats 312, techs 40 — all healthy. BUT victories 0/3 (stalemate). Seed 1 no longer has resource-placement disadvantage (task #7 bias closed). Remaining gap is combat balance (tasks #10). 4 PASS additions to checklist from batch 5→batch balanced_retry. (map-balance-dev)
2026-04-16 14:05 INFRA: scripts/apricot/run_ap3.sh had UNSCOPED pkill (kills all Godot) causing sibling batch kills. Fixed in-repo to scoped pkill matching AUTO_PLAY_DIR. Deployed to apricot ~/bin/run_ap3.sh. Future run_ap3.sh invocations won't kill siblings. Enables parallel agent smokes without collision. (team-lead from dataloader-dev catch)
2026-04-16 14:05 INFRA: scripts/autoplay/run_ap3.sh had UNSCOPED pkill (kills all Godot) causing sibling batch kills. Fixed in-repo to scoped pkill matching AUTO_PLAY_DIR. Deployed to apricot ~/bin/run_ap3.sh. Future run_ap3.sh invocations won't kill siblings. Enables parallel agent smokes without collision. (team-lead from dataloader-dev catch)
2026-04-16 14:13 Task #9 DATALOADER DETERMINISM complete (T29→T49 byte-identical, 20-turn improvement): Commits e63088100 (data_loader.gd sorted DirAccess), 0e43a3182 (lens_unlock_manager.gd sorted enum), d2062cbd1 (pathfinder.gd A*/Dijkstra tiebreakers + atmosphere_anomalies.gd sorted keys). 104 lines total across 4 files (over ≤50 budget due to expanded surface). Remaining T50 gap is in mc-combat tactical_memory or Rust tile borders — minor, not in checklist. (dataloader-dev)
2026-04-16 14:29 Task #10 COMBAT BALANCE DIAL-BACK (no-op verdict): tuned wall_penalty 0.70→0.75, melee_fraction 0.50→0.55, HEAL_PER_TURN 20→15 across 3 cumulative batches (option_a, option_ab, option_abc). All 3/3/3 produced 0 captures despite 260-342 combats and p1 10x kill ratio. Combat math NOT the bottleneck. Reverted all 3 to baseline (0.70/0.50/20), 103/103 mc-combat+mc-city tests pass. Handoff to #11 (AI capture commit in simple_heuristic_ai.gd). (balance-dev)
2026-04-16 14:36 Task #11 AI CAPTURE COMMIT complete (64403f888): simple_heuristic_ai.gd +41/-3 in _decide_military_action. Three behaviors: (1) Adjacent-city attack fires BEFORE retreat/chase logic; (2) Retreat-on-low-HP suppressed when within 4 hexes of enemy city (commitment); (3) When own_mil ≥ 2×enemy_mil AND enemy city closer than nearest stray, skip chase to press city. Batch: 70/121/114 city attacks per game (was 0), 45/64/43 killed=true attacks. Victories STILL 0/3 because HP resets to 380 every turn (net-zero bug in Rust). AI side done. (capture-ai-dev)

View file

@ -50,7 +50,7 @@
- `tools/autoplay-report.py` — CSV + summary + assertions
- `tools/autoplay-validate.py` — schema validator (per-file + JSONL)
- `tools/schemas/autoplay/{meta,turn-stats-line,events-line,save}.json` — 4 JSON schemas
- `scripts/apricot/run_ap3.sh` + `run_seeded.sh` — persistent headless runners
- `scripts/autoplay/run_ap3.sh` + `run_seeded.sh` — persistent headless runners
## Known Debt / Future Work

View file

@ -14,7 +14,7 @@ evidence:
- src/game/engine/tests/unit/test_save_manager.gd
- src/game/engine/tests/integration/test_save_load_round_trip.gd
- src/game/engine/scenes/tests/auto_play.gd
- scripts/apricot/test_save_resume.sh
- scripts/autoplay/test_save_resume.sh
---
## Summary
@ -25,7 +25,7 @@ Save/load UI, autosave-on-quit hook, multi-slot naming, schema version with reje
- ✓ Save/load from main menu + in-game pause — `scenes/menus/load_game.gd` calls `list_saves()` + `load_named_slot()`; `scenes/ui/ingame_menu.gd` calls `save_to_named_slot()`.
- ✓ Autosave on quit — `scenes/main/main.gd` handles `NOTIFICATION_WM_CLOSE_REQUEST``SaveManagerScript.autosave()` before `quit()`.
- ✓ Byte-identical T100 turn_stats after save-at-T50 + load-back — `AUTO_PLAY_SAVE_AT=N` writes `mid_run.save` + quits; `AUTO_PLAY_LOAD_AUTOSAVE=<path>` resumes from that save after world loads. Integration test harness: `scripts/apricot/test_save_resume.sh`. `SaveManager.load_from_path()` added for absolute-path loads.
- ✓ Byte-identical T100 turn_stats after save-at-T50 + load-back — `AUTO_PLAY_SAVE_AT=N` writes `mid_run.save` + quits; `AUTO_PLAY_LOAD_AUTOSAVE=<path>` resumes from that save after world loads. Integration test harness: `scripts/autoplay/test_save_resume.sh`. `SaveManager.load_from_path()` added for absolute-path loads.
- ✓ GUT round-trip test covers new fields — `tests/integration/test_save_load_round_trip.gd` (9 tests incl. traded_luxuries, clan_id, GameState.diplomacy, wonders_built).
- ✓ `SCHEMA_VERSION` rejection — `save_manager.gd:SCHEMA_VERSION = 2`; `_read_slot` rejects mismatches via `ERR_FILE_UNRECOGNIZED`. Test: `test_wrong_schema_version_rejected`.

View file

@ -9,8 +9,14 @@ updated_at: 2026-04-17
evidence:
- src/simulator/crates/mc-ai/src/abstract_state.rs
- src/simulator/crates/mc-ai/src/mcts_tree.rs
- src/simulator/crates/mc-ai/src/rollout.rs
- src/simulator/crates/mc-ai/src/gpu/inner.rs
- src/simulator/crates/mc-ai/src/gpu/rollout.wgsl
- src/simulator/crates/mc-ai/src/gpu/cpu_reference.rs
- src/simulator/crates/mc-ai/tests/gpu_rollout_parity.rs
- src/simulator/crates/mc-turn/src/gpu/mod.rs
- src/simulator/crates/mc-ai/src/game_state.rs
- scripts/dev-setup/bluefin.sh
---
## Summary
@ -23,6 +29,63 @@ introduces a **GPU-batched abstract rollout** layer so the tree search can
evaluate hundreds of candidate futures per leaf at single-digit-millisecond
cost.
### 2026-04-17 update — GPU↔CPU numerical parity ACHIEVED
Phase C structural work shipped in the earlier team pass but the parity test
was silently taking the skip path on headless hosts — the shader had never
actually compiled on any adapter. A deep audit + four independent fixes landed
this cycle proving real numerical parity:
1. **WGSL reserved-keyword bug**: `var active: u32 = 0u` at `rollout.wgsl:607`
used the `active` reserved word → Naga parse panic → wgpu_core handler → try_init
worker thread panic → timeout returned None → skip-path. Renamed to
`active_idx`; the shader now actually compiles. Without this, the skip-path
was structurally "passing" every test in Phase C without ever exercising the
WGSL kernel.
2. **Adapter backend restriction**: `wgpu::Backends::all()` picked the NVIDIA
OpenGL adapter first on apricot, whose compute support silently fails at
`request_device`. Restricted to `VULKAN | METAL | DX12 | BROWSER_WEBGPU`
which all have first-class compute paths.
3. **Device limits fix**: `Limits::default()` targets a discrete GPU — too
large for llvmpipe / lavapipe. Changed to
`Limits::downlevel_defaults().using_resolution(adapter.limits())` so software
Vulkan backends can satisfy device creation.
4. **Action-walk order unified**: the root numerical divergence. CPU
`active_actions()` returned actions in insertion order
`[Build, Research, Defend, Idle, Attack, ...]`; WGSL iterated k=0..9 in
`ActionKind::ALL` numerical order `[Build, Attack, Settle, Research, ...]`.
Identical probabilities, identical RNG draw → different action picked at
every cumulative-sum boundary. Rewrote `active_actions()` to iterate
`ActionKind::ALL` in canonical order (with explicit docstring warning not
to reorder for readability).
**Parity verification on apricot (headless bluefin + lavapipe software
Vulkan)**: with `MC_AI_GPU_DEBUG=1 VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json`
driving the tests on real llvmpipe dispatch, not skip-path:
```
[parity small_batch backend=Vulkan] n=16 agree=16/16 (1.000) max_drift=0.000000
[parity partial_workgroup backend=Vulkan] n=65 agree=65/65 (1.000) max_drift=0.000000
[parity multi_workgroup backend=Vulkan] n=128 agree=128/128 (1.000) max_drift=0.000000
buckets: <1e-6=all others=0 across all three tests
```
Not 98% (the stated tolerance) — **100% agreement, bit-identical** on all 3
quantitative parity tests (209 inputs total). Pre-fixes: 36% agreement with
max_drift 0.0250.043 (action-boundary flips). Post-fix: integer fields
byte-equal, scalar fields byte-equal. WGSL kernel is now a provable,
byte-for-byte port of `rollout::walk`.
### 2026-04-17 update — host-side infrastructure
- `scripts/dev-setup/bluefin.sh` + `./run setup:bluefin` — idempotent installer
for `weston`, `vulkan-tools`, `mesa-vulkan-drivers` on bootc/Bluefin systems
via `rpm-ostree install --apply-live`. `--check` mode for CI.
Delegates EDIT→RUN via `$AUTOPLAY_HOST` when invoked from EDIT.
- `~/Code/bootc-bluefin/containerfiles/Containerfile.desktop-core` updated on
apricot with `vulkan-tools` + `mesa-vulkan-drivers` added alongside `weston`.
Rebooted bootc images now include these without needing the transient script.
## Design outline
- `AbstractRolloutState` — a ~256 byte `#[repr(C)]` `Pod + Zeroable` compression of

105
run
View file

@ -110,91 +110,58 @@ _dispatch_install() {
COMMAND="${1:-}"
shift 2>/dev/null || true
# Special cases (subcommand requires alias, multi-arg dispatch, help).
# Everything else resolves to `cmd_<command-with-[-:]-replaced-by-_>`.
case "$COMMAND" in
# ── Development ──────────────────────────────────────────────────
play) cmd_play "$@" ;;
editor) cmd_editor "$@" ;;
guide) cmd_guide "$@" ;;
lint) cmd_lint "$@" ;;
lint:gd) cmd_lint_gd "$@" ;;
lint:rust) cmd_lint_rust "$@" ;;
lint:ts) cmd_lint_ts "$@" ;;
format) cmd_format "$@" ;;
format:gd) cmd_format_gd "$@" ;;
format:rust) cmd_format_rust "$@" ;;
format:ts) cmd_format_ts "$@" ;;
typecheck) cmd_typecheck "$@" ;;
validate) cmd_validate "$@" ;;
test) cmd_test "$@" ;;
test:golden) cmd_test_golden "$@" ;;
coverage) cmd_coverage "$@" ;;
verify) cmd_verify "$@" ;;
screenshot) cmd_screenshot "$@" ;;
autoplay) cmd_autoplay "$@" ;;
autoplay-batch) cmd_autoplay_batch "$@" ;;
help|--help|-h|"") usage; exit 0 ;;
# ── Build ────────────────────────────────────────────────────────
build) cmd_build "$@" ;;
build:wasm) cmd_build_wasm "$@" ;;
build:gdext) cmd_build_gdext "$@" ;;
# Export single-target takes a positional `target` arg.
export:windows|export:macos|export:linux|export:android|export:ios)
cmd_export_single "${COMMAND#export:}" "$@"; exit $? ;;
# ── Export ───────────────────────────────────────────────────────
export) cmd_export "$@" ;;
export:windows) cmd_export_single windows "$@" ;;
export:macos) cmd_export_single macos "$@" ;;
export:linux) cmd_export_single linux "$@" ;;
export:android) cmd_export_single android "$@" ;;
export:ios) cmd_export_single ios "$@" ;;
# Install dispatches through a shared args parser.
install:osx|install:macos) _dispatch_install osx "$@"; exit $? ;;
install:linux) _dispatch_install linux "$@"; exit $? ;;
install:iphone|install:ios) _dispatch_install iphone "$@"; exit $? ;;
install:sim) _dispatch_install sim "$@"; exit $? ;;
install:android) _dispatch_install android "$@"; exit $? ;;
# ── Install (colon form — canonical) ────────────────────────────
install:osx|install:macos) _dispatch_install osx "$@" ;;
install:linux) _dispatch_install linux "$@" ;;
install:iphone|install:ios) _dispatch_install iphone "$@" ;;
install:sim) _dispatch_install sim "$@" ;;
install:android) _dispatch_install android "$@" ;;
# Aliases so `install:macos` → `install:osx` (etc.) dispatch to the canonical fn.
start:macos) cmd_start_osx "$@"; exit $? ;;
stop:macos) cmd_stop_osx "$@"; exit $? ;;
smoke:macos) cmd_smoke_osx "$@"; exit $? ;;
start:iphone) cmd_start_ios "$@"; exit $? ;;
# ── Start / Stop / Smoke (colon form — canonical) ───────────────
start:osx|start:macos) cmd_start_osx "$@" ;;
start:linux) cmd_start_linux "$@" ;;
start:ios|start:iphone) cmd_start_ios "$@" ;;
stop:osx|stop:macos) cmd_stop_osx "$@" ;;
stop:linux) cmd_stop_linux "$@" ;;
smoke:osx|smoke:macos) cmd_smoke_osx "$@" ;;
smoke:linux) cmd_smoke_linux "$@" ;;
# ── Tools (colon form — canonical) ──────────────────────────────
tools:spritegen) cmd_tools_spritegen "$@" ;;
# ── Legacy space-form aliases (for muscle memory — undocumented)
# install|start|stop|smoke|tools all accept a positional subtarget.
# Legacy space-form aliases: `install osx --dev` → `install:osx --dev`
install)
TARGET=""; POS=()
for arg in "$@"; do
case "$arg" in
--dev) POS+=("$arg") ;;
osx|macos|linux|iphone|ios|sim|android) TARGET="$arg" ;;
*) POS+=("$arg") ;;
*) POS+=("$arg") ;;
esac
done
[ -n "$TARGET" ] || { echo -e "${RED}install requires a target (use install:<target>)${NC}"; exit 1; }
_dispatch_install "${TARGET/macos/osx}" "${POS[@]+"${POS[@]}"}"
exit $?
;;
start|stop|smoke)
VERB="$COMMAND"
TARGET="${1:-}"; shift 2>/dev/null || true
start|stop|smoke|tools)
VERB="$COMMAND"; TARGET="${1:-}"; shift 2>/dev/null || true
[ -n "$TARGET" ] || { echo -e "${RED}$VERB requires a target (use $VERB:<target>)${NC}"; exit 1; }
# Recurse using canonical colon form
exec "$0" "$VERB:$TARGET" "$@"
;;
tools)
SUB="${1:-}"; shift 2>/dev/null || true
[ -n "$SUB" ] || { echo -e "${RED}tools requires a subcommand (use tools:<sub>)${NC}"; exit 1; }
exec "$0" "tools:$SUB" "$@"
;;
# ── Misc ─────────────────────────────────────────────────────────
setup) cmd_setup "$@" ;;
setup:bluefin) cmd_setup_bluefin "$@" ;;
help|--help|-h|"") usage ;;
*) echo -e "${RED}Unknown command: $COMMAND${NC}"; echo ""; usage; exit 1 ;;
esac
# Data-driven dispatch: `<verb>:<target>` → `cmd_<verb>_<target>`, `<verb>` → `cmd_<verb>`.
# New subcommands don't require editing this file — just define `cmd_<name>` in
# any `scripts/run/*.sh` (or subcommand alias in `scripts/dev-setup/`).
FUNC="cmd_${COMMAND//[:-]/_}"
if declare -F "$FUNC" >/dev/null; then
"$FUNC" "$@"
exit $?
fi
echo -e "${RED}Unknown command: $COMMAND${NC}"
echo ""
usage
exit 1

45
scripts/README.md Normal file
View file

@ -0,0 +1,45 @@
# `scripts/` — repo automation
Everything in this directory is either sourced by `./run` or invoked
directly over SSH on a remote host (apricot, plum). Every `*.sh` is
idempotent and safe to re-run.
## Layout
```
scripts/
run/ — ./run <cmd> dispatch modules; one file per concern
common.sh — colors, dotenv loader, $GAME_DIR/$SIMULATOR_DIR/$GUIDE_DIR
build.sh — cmd_build, cmd_build_wasm, cmd_build_gdext, cmd_build_info
build-info.sh — git-state → src/game/build_info.json generator
dev.sh — lint/format/test/verify/autoplay subcommands
export.sh — Godot export one-target dispatch
remote.sh — install:/start:/stop:/smoke: over SSH
tools.sh — setup + spritegen + misc one-offs
dev-setup/ — one-shot env bootstrap scripts, per-OS
osx.sh — macOS (Homebrew + Godot + Rust + --with-runner)
linux.sh — generic Linux (dnf/apt + Rust + --with-runner)
bluefin.sh — rpm-ostree Bluefin layer (weston, vulkan-tools)
lib/ — helpers shared across the per-OS scripts
runner.sh — forgejo-runner install/register/verify
autoplay/ — runs ON a linux host (apricot) for headless batches
run_ap3.sh — weston-headless flatpak Godot invocation
run_seeded.sh — single-seed AUTO_PLAY wrapper
test_save_resume.sh — save-at-T50 → resume → compare test harness
```
## Conventions
- **Functions prefixed with `_`** are private helpers scoped to one
file. Functions without the prefix are callable from other modules.
- **`cmd_<verb>_<target>`** functions are dispatched by `./run` via
name-matching — `./run verb:target` runs `cmd_verb_target`. No need
to edit the top-level `run` case block to add a new subcommand.
- **Direct execution works too** — every `scripts/run/*.sh` and
`scripts/dev-setup/*.sh` has a working shebang and `if [[ "${BASH_SOURCE[0]}" == "$0" ]]` guard where relevant.
- **Remote scripts** under `autoplay/` are *meant* to be SSH-invoked
from the EDIT host (typically plum) running on the RUN host
(typically apricot). Never invoke them on the EDIT host directly.
- **Env files**`.env` (tracked base) → `.env.local` (user secrets,
gitignored) → `.env.<mode>``.env.<mode>.local`. Loaded automatically
by `common.sh` at source time. See `.env.example` for documented keys.

View file

@ -10,7 +10,7 @@
# Fail: any game crashes, save missing, or turn_stats differ
#
# Usage (from repo root, apricot must be SSH-reachable):
# AUTOPLAY_HOST=lilith@apricot.local bash scripts/apricot/test_save_resume.sh [seed]
# AUTOPLAY_HOST=lilith@apricot.local bash scripts/autoplay/test_save_resume.sh [seed]
#
# Without AUTOPLAY_HOST, runs locally via flatpak (Linux only).
set -euo pipefail

View file

@ -1,24 +1,40 @@
#!/usr/bin/env bash
# Regenerates src/game/build_info.json from the current git state.
# Invoke before an export so the About screen shows the correct commit.
# Usage: ./scripts/run/build-info.sh
#
# Available two ways:
# * Directly: `./scripts/run/build-info.sh`
# * Via runner: `./run build:info` (calls cmd_build_info below)
#
# Previously this file was top-level executable code — sourcing it via
# the runner's `for _script in scripts/run/*.sh; source "$_script"` loop
# regenerated build_info.json on EVERY `./run <anything>` invocation,
# including quick ones like `./run lint:gd`. Wrapping in a function makes
# the write lazy: only runs when explicitly called by a build/export cmd.
set -euo pipefail
# Regenerate src/game/build_info.json. Idempotent, side-effect-free at source time.
cmd_build_info() {
local repo_root="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
local commit build_date version_base version
commit="$(git -C "$repo_root" rev-parse --short=12 HEAD 2>/dev/null || echo unknown)"
build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
version_base="$(awk -F'"' '/^config\/version=/ {print $2}' "$repo_root/src/game/project.godot" 2>/dev/null || echo 0.0.0)"
version="${version_base}-ea"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMMIT="$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD 2>/dev/null || echo unknown)"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
VERSION_BASE="$(awk -F'"' '/^config\/version=/ {print $2}' "$REPO_ROOT/src/game/project.godot" 2>/dev/null || echo 0.0.0)"
VERSION="${VERSION_BASE}-ea"
cat > "$REPO_ROOT/src/game/build_info.json" <<EOF
cat > "$repo_root/src/game/build_info.json" <<EOF
{
"version": "$VERSION",
"commit": "$COMMIT",
"build_date": "$BUILD_DATE",
"version": "$version",
"commit": "$commit",
"build_date": "$build_date",
"godot_rust": "0.2"
}
EOF
echo "Wrote build_info.json: $VERSION @ $COMMIT"
echo "Wrote build_info.json: $version @ $commit"
}
# Keep the direct-invocation path working (`./scripts/run/build-info.sh`).
# BASH_SOURCE[0] == $0 exactly when the file is executed, not sourced.
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
set -euo pipefail
cmd_build_info
fi

View file

@ -15,6 +15,8 @@ cmd_build_gdext() {
cmd_build() {
local exit_code=0
cmd_build_info || exit_code=$?
echo ""
cmd_build_wasm || exit_code=$?
echo ""
cmd_build_gdext || exit_code=$?

View file

@ -15,7 +15,7 @@
# Variables already set in the shell ALWAYS win (never clobber). Lines
# are plain `KEY=VALUE`; inline comments (`#`) are stripped; surrounding
# single/double quotes on values are stripped.
load_envfile() {
_load_envfile() {
local f="$1"
[[ -r "$f" ]] || return 0
local line key val
@ -36,12 +36,12 @@ load_envfile() {
done < "$f"
}
load_env_cascade() {
_load_env_cascade() {
local mode="${NODE_ENV:-development}"
load_envfile "$REPO_ROOT/.env"
load_envfile "$REPO_ROOT/.env.local"
load_envfile "$REPO_ROOT/.env.${mode}"
load_envfile "$REPO_ROOT/.env.${mode}.local"
_load_envfile "$REPO_ROOT/.env"
_load_envfile "$REPO_ROOT/.env.local"
_load_envfile "$REPO_ROOT/.env.${mode}"
_load_envfile "$REPO_ROOT/.env.${mode}.local"
}
# Require one or more env vars; fail the current command with a helpful
@ -60,7 +60,7 @@ require_env() {
}
# Run cascade at source time so every subcommand has vars available.
load_env_cascade
_load_env_cascade
RED='\033[0;31m'
GREEN='\033[0;32m'

View file

@ -1,6 +1,6 @@
{
"version": "0.1.0-ea",
"commit": "79e7351e4084",
"build_date": "2026-04-17T19:13:46Z",
"commit": "f5ec1c0101b6",
"build_date": "2026-04-17T19:22:13Z",
"godot_rust": "0.2"
}