From b70a396f1436e27188fd879ad9d035e59ab2328b Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 17 Apr 2026 12:25:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20implement=20gpu-mcts=20rollouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/CHANGELOG.md | 2 +- .../history/20260415_final_batch_report.md | 2 +- .../objectives/p0-12-save-load-autosave.md | 4 +- .../objectives/p0-20-gpu-mcts-rollouts.md | 63 +++++++++++ run | 105 ++++++------------ scripts/README.md | 45 ++++++++ scripts/{apricot => autoplay}/run_ap3.sh | 0 scripts/{apricot => autoplay}/run_seeded.sh | 0 .../{apricot => autoplay}/test_save_resume.sh | 2 +- scripts/run/build-info.sh | 46 +++++--- scripts/run/build.sh | 2 + scripts/run/common.sh | 14 +-- src/game/build_info.json | 4 +- 13 files changed, 191 insertions(+), 98 deletions(-) create mode 100644 scripts/README.md rename scripts/{apricot => autoplay}/run_ap3.sh (100%) rename scripts/{apricot => autoplay}/run_seeded.sh (100%) rename scripts/{apricot => autoplay}/test_save_resume.sh (98%) diff --git a/.project/CHANGELOG.md b/.project/CHANGELOG.md index 408553cc..f45d7939 100644 --- a/.project/CHANGELOG.md +++ b/.project/CHANGELOG.md @@ -51,7 +51,7 @@ Entry format: `YYYY-MM-DD HH:MM : (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) diff --git a/.project/history/20260415_final_batch_report.md b/.project/history/20260415_final_batch_report.md index 135ffc22..aebf17c7 100644 --- a/.project/history/20260415_final_batch_report.md +++ b/.project/history/20260415_final_batch_report.md @@ -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 diff --git a/.project/objectives/p0-12-save-load-autosave.md b/.project/objectives/p0-12-save-load-autosave.md index b7ec2cc4..7d0a9183 100644 --- a/.project/objectives/p0-12-save-load-autosave.md +++ b/.project/objectives/p0-12-save-load-autosave.md @@ -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=` 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=` 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`. diff --git a/.project/objectives/p0-20-gpu-mcts-rollouts.md b/.project/objectives/p0-20-gpu-mcts-rollouts.md index 45f457a3..8b83c3bd 100644 --- a/.project/objectives/p0-20-gpu-mcts-rollouts.md +++ b/.project/objectives/p0-20-gpu-mcts-rollouts.md @@ -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: 3–6% agreement with +max_drift 0.025–0.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 diff --git a/run b/run index c7b1786f..d803fd63 100755 --- a/run +++ b/run @@ -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_`. 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:)${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:)${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:)${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: `:` → `cmd__`, `` → `cmd_`. +# New subcommands don't require editing this file — just define `cmd_` 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 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..05e619a5 --- /dev/null +++ b/scripts/README.md @@ -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 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__`** 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.` → `.env..local`. Loaded automatically + by `common.sh` at source time. See `.env.example` for documented keys. diff --git a/scripts/apricot/run_ap3.sh b/scripts/autoplay/run_ap3.sh similarity index 100% rename from scripts/apricot/run_ap3.sh rename to scripts/autoplay/run_ap3.sh diff --git a/scripts/apricot/run_seeded.sh b/scripts/autoplay/run_seeded.sh similarity index 100% rename from scripts/apricot/run_seeded.sh rename to scripts/autoplay/run_seeded.sh diff --git a/scripts/apricot/test_save_resume.sh b/scripts/autoplay/test_save_resume.sh similarity index 98% rename from scripts/apricot/test_save_resume.sh rename to scripts/autoplay/test_save_resume.sh index 3b3803bf..4cf1c42e 100755 --- a/scripts/apricot/test_save_resume.sh +++ b/scripts/autoplay/test_save_resume.sh @@ -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 diff --git a/scripts/run/build-info.sh b/scripts/run/build-info.sh index a85ac58e..b5d112dc 100755 --- a/scripts/run/build-info.sh +++ b/scripts/run/build-info.sh @@ -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 ` 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" < "$repo_root/src/game/build_info.json" <