feat(@projects/@magic-civilization): ✨ add staging export pipeline
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
98402e156e
commit
18dc9e8441
3 changed files with 478 additions and 1 deletions
|
|
@ -43,3 +43,17 @@ scripts/
|
|||
- **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.
|
||||
|
||||
## Export staging (p2-06)
|
||||
|
||||
`tools/export-single.sh` rsyncs the project to `.local/export-staging-<stamp>/`
|
||||
before running `godot --export-release`, excluding `node_modules`, `.local`,
|
||||
`target`, `.git`, and `dist`. Godot's export scanner walks the whole project
|
||||
tree pre-`exclude_filter`; the pnpm-managed `public/games/*/guide/node_modules/`
|
||||
symlinks made macOS exports take 20+ minutes (16MB of `_scan_new_dir` warnings).
|
||||
Staging drops that to under 10s of scan time.
|
||||
|
||||
- **Default**: on for `macos`, off elsewhere.
|
||||
- **Force on**: `EXPORT_STAGED=1 ./run export:linux`.
|
||||
- **Debug staging**: `KEEP_STAGING=1 ./run export:macos` leaves the staged
|
||||
copy in place for inspection.
|
||||
|
|
|
|||
|
|
@ -3328,3 +3328,386 @@ impl GdClimateEffectsPhysics {
|
|||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ── GdPrologue ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Bridge over `mc_turn::prologue` for the Freepeople tribe-founding
|
||||
// cold-open (p0-34). Owns the global `PrologueTurn` counter plus a
|
||||
// per-player map of `PlayerPrologue` + scattered wanderers. GDScript
|
||||
// drives it via:
|
||||
//
|
||||
// 1. `register_player(player_id, start_q, start_r, mode_str, map_seed, grid)` —
|
||||
// place the spawn box, scatter `N` wanderers, stash the centroid.
|
||||
// Called once per player at game boot when `setup.json:start_turn == -1`.
|
||||
// 2. `state()` / `display_turn()` — GDScript reads these every frame for
|
||||
// the HUD banner + input gate.
|
||||
// 3. `wanderers_for(player_id)` — `PackedInt32Array` of flat `[q, r, …]`
|
||||
// so the hex renderer draws a circle-plus-'W' glyph per entry.
|
||||
// 4. `centroid(player_id)` — `Vector2i(q, r)` read by the camera pan on
|
||||
// turn-0 resolution.
|
||||
// 5. `advance()` — called once per player end-turn during the prologue.
|
||||
// Transitions `TurnMinusOne → TurnZero → Normal`, runs
|
||||
// `roll_wanderer_directions` on the `-1→0` edge, `step_wanderers` +
|
||||
// `converge_tribe` on the `0→1` edge. Returns a Dictionary with
|
||||
// `chronicle_events: Array[Dictionary]` for GDScript to dispatch.
|
||||
// 6. `dwarf_tribe(player_id)` — after turn-0 resolution returns
|
||||
// `{owner, q, r, founding_pop_override, ancestors_merged}`.
|
||||
// 7. `found_capital(player_id)` — consumes the Dwarf Tribe, returns the
|
||||
// override population + emits a `capital_founded` chronicle entry.
|
||||
//
|
||||
// Design:
|
||||
// * Stateful. One instance lives on TurnManager for the full game.
|
||||
// * Per-player storage via `Vec<PlayerEntry>` indexed by `player_id`.
|
||||
// `player_id` is a `u8` (matches `Wanderer.owner` + `ChronicleEntry`).
|
||||
// * PRNG state derived from `map_seed` + splitmix per roll step; same
|
||||
// seed reproduces byte-identical wanderer positions and direction
|
||||
// rolls. Determinism gate (p1-09).
|
||||
|
||||
use mc_core::{HexCoord, PlayerPrologue};
|
||||
use mc_turn::chronicle::{Chronicle, ChronicleEntry};
|
||||
use mc_turn::prologue::{
|
||||
self as prologue, ConvergenceOutcome, DwarfTribe, PrologueRng, PrologueState,
|
||||
PrologueTurn, StartMode, Wanderer, DEFAULT_LUCKY_INWARD_BIAS_PROB,
|
||||
LUCKY_MAX_BONUS_POP,
|
||||
};
|
||||
use mc_mapgen::{place_spawn_box, SpawnBoxParams};
|
||||
|
||||
/// Per-player prologue bookkeeping owned by `GdPrologue`.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlayerEntry {
|
||||
player_id: u8,
|
||||
prologue: PlayerPrologue,
|
||||
wanderers: Vec<Wanderer>,
|
||||
tribe: Option<DwarfTribe>,
|
||||
mode: StartMode,
|
||||
}
|
||||
|
||||
/// Godot-visible prologue driver. One per game. Lives on `TurnManager`.
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=RefCounted)]
|
||||
pub struct GdPrologue {
|
||||
turn: PrologueTurn,
|
||||
players: Vec<PlayerEntry>,
|
||||
chronicle: Chronicle,
|
||||
map_seed: u64,
|
||||
base: Base<RefCounted>,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl IRefCounted for GdPrologue {
|
||||
fn init(base: Base<RefCounted>) -> Self {
|
||||
Self {
|
||||
turn: PrologueTurn::new_game(),
|
||||
players: Vec::new(),
|
||||
chronicle: Chronicle::new(),
|
||||
map_seed: 0,
|
||||
base,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl GdPrologue {
|
||||
/// State encoded as `i64` for GDScript: `0 = TurnMinusOne`,
|
||||
/// `1 = TurnZero`, `2 = Normal`.
|
||||
#[func]
|
||||
fn state(&self) -> i64 {
|
||||
match self.turn.state {
|
||||
PrologueState::TurnMinusOne => 0,
|
||||
PrologueState::TurnZero => 1,
|
||||
PrologueState::Normal => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Integer turn label — `-1`, `0`, `1`, `2`, ….
|
||||
#[func]
|
||||
fn display_turn(&self) -> i64 {
|
||||
self.turn.display_turn() as i64
|
||||
}
|
||||
|
||||
/// True while `state() in {TurnMinusOne, TurnZero}`. GDScript uses this
|
||||
/// as the input gate predicate.
|
||||
#[func]
|
||||
fn is_prologue(&self) -> bool {
|
||||
self.turn.state.is_prologue()
|
||||
}
|
||||
|
||||
/// Set the map seed that drives all downstream PRNG streams. Must be
|
||||
/// called before any `register_player` call for determinism.
|
||||
#[func]
|
||||
fn set_map_seed(&mut self, seed: i64) {
|
||||
// Widen negative seeds into the u64 space losslessly; callers
|
||||
// usually pass a positive hash already.
|
||||
self.map_seed = seed as u64;
|
||||
}
|
||||
|
||||
/// Register a player's spawn box. `mode` is `"tournament"` or `"lucky"`
|
||||
/// (snake_case to match `setup.json`). `radius` is the spawn-box hex
|
||||
/// radius (default `3` per setup). Tournament count / lucky range are
|
||||
/// currently hardcoded to `SpawnBoxParams::default()` — tuning knob
|
||||
/// lives in setup.json and is surfaced via follow-up task if needed.
|
||||
///
|
||||
/// `grid` must be a fully-generated GdGridState (biome data is read to
|
||||
/// prune ocean/coast/mountain tiles from the wanderer pool).
|
||||
#[func]
|
||||
fn register_player(
|
||||
&mut self,
|
||||
player_id: i64,
|
||||
start_q: i64,
|
||||
start_r: i64,
|
||||
mode: GString,
|
||||
radius: i64,
|
||||
grid: Gd<GdGridState>,
|
||||
) -> bool {
|
||||
if !(0..=255).contains(&player_id) {
|
||||
godot_error!("GdPrologue::register_player: player_id {} out of u8 range", player_id);
|
||||
return false;
|
||||
}
|
||||
let pid = player_id as u8;
|
||||
let mode = match mode.to_string().as_str() {
|
||||
"tournament" => StartMode::Tournament,
|
||||
"lucky" => StartMode::Lucky,
|
||||
other => {
|
||||
godot_error!("GdPrologue::register_player: unknown mode '{}'", other);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let params = SpawnBoxParams {
|
||||
mode,
|
||||
radius: if radius > 0 { radius as i32 } else { 3 },
|
||||
..SpawnBoxParams::default()
|
||||
};
|
||||
let start = HexCoord::new(start_q as i32, start_r as i32);
|
||||
let grid_ref = grid.bind();
|
||||
let box_result = place_spawn_box(
|
||||
self.map_seed,
|
||||
pid,
|
||||
start,
|
||||
¶ms,
|
||||
&grid_ref.inner,
|
||||
);
|
||||
drop(grid_ref);
|
||||
self.players.push(PlayerEntry {
|
||||
player_id: pid,
|
||||
prologue: box_result.to_player_prologue(),
|
||||
wanderers: box_result.wanderers,
|
||||
tribe: None,
|
||||
mode,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Flat `[q0, r0, q1, r1, …]` list of live wanderer positions for the
|
||||
/// given player. Excludes merged-into-tribe wanderers. Empty array if
|
||||
/// the player is unregistered.
|
||||
#[func]
|
||||
fn wanderers_for(&self, player_id: i64) -> PackedInt32Array {
|
||||
let mut out = PackedInt32Array::new();
|
||||
let Some(entry) = self.find_player(player_id) else {
|
||||
return out;
|
||||
};
|
||||
for w in &entry.wanderers {
|
||||
if w.merged_into_tribe {
|
||||
continue;
|
||||
}
|
||||
out.push(w.position.q);
|
||||
out.push(w.position.r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Axial centroid of the player's spawn box. `Vector2i(0, 0)` if the
|
||||
/// player is unregistered or the prologue has already cleared.
|
||||
#[func]
|
||||
fn centroid(&self, player_id: i64) -> Vector2i {
|
||||
let Some(entry) = self.find_player(player_id) else {
|
||||
return Vector2i::new(0, 0);
|
||||
};
|
||||
match entry.prologue.spawn_box_centroid {
|
||||
Some(c) => Vector2i::new(c.q, c.r),
|
||||
None => Vector2i::new(0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance one prologue turn. Does the work required for the edge:
|
||||
/// - On `TurnMinusOne → TurnZero`: roll wanderer directions for every
|
||||
/// registered player.
|
||||
/// - On `TurnZero → Normal`: step wanderers, run `converge_tribe` for
|
||||
/// every player, stash the resulting Dwarf Tribe.
|
||||
/// Returns a Dictionary with:
|
||||
/// * `new_state: int` (post-transition state)
|
||||
/// * `new_turn: int` (post-transition turn counter)
|
||||
/// * `chronicle_events: Array[Dictionary]` (entries emitted this
|
||||
/// step, ready for GDScript to forward to EventBus)
|
||||
#[func]
|
||||
fn advance(&mut self) -> Dictionary {
|
||||
let prior_state = self.turn.state;
|
||||
let chronicle_len_before = self.chronicle.len();
|
||||
|
||||
match prior_state {
|
||||
PrologueState::TurnMinusOne => {
|
||||
// Edge -1 → 0: roll directions per player.
|
||||
for entry in self.players.iter_mut() {
|
||||
let Some(centroid) = entry.prologue.spawn_box_centroid else {
|
||||
continue;
|
||||
};
|
||||
// Per-player PRNG stream — disjoint from mapgen's spawn-box
|
||||
// stream via a different tag (0x50726f6c6f677565 = "Prologue").
|
||||
let mut rng = PrologueRng::new(self.map_seed)
|
||||
.substream(0x5072_6f6c_6f67_7565)
|
||||
.substream(entry.player_id as u64);
|
||||
prologue::roll_wanderer_directions(
|
||||
&mut entry.wanderers,
|
||||
centroid,
|
||||
entry.mode,
|
||||
DEFAULT_LUCKY_INWARD_BIAS_PROB,
|
||||
&mut rng,
|
||||
);
|
||||
}
|
||||
}
|
||||
PrologueState::TurnZero => {
|
||||
// Edge 0 → 1: step wanderers, converge per player.
|
||||
for entry in self.players.iter_mut() {
|
||||
let Some(centroid) = entry.prologue.spawn_box_centroid else {
|
||||
continue;
|
||||
};
|
||||
prologue::step_wanderers(&mut entry.wanderers);
|
||||
let outcome: ConvergenceOutcome = prologue::converge_tribe(
|
||||
&mut entry.wanderers,
|
||||
entry.player_id,
|
||||
centroid,
|
||||
entry.mode,
|
||||
LUCKY_MAX_BONUS_POP,
|
||||
&mut self.chronicle,
|
||||
);
|
||||
entry.tribe = Some(outcome.tribe);
|
||||
}
|
||||
}
|
||||
PrologueState::Normal => {
|
||||
// No-op: prologue is over. Caller shouldn't drive advance()
|
||||
// past Normal, but we don't panic — just report current state.
|
||||
}
|
||||
}
|
||||
|
||||
self.turn.advance();
|
||||
|
||||
let mut out = Dictionary::new();
|
||||
out.set("new_state", self.state());
|
||||
out.set("new_turn", self.display_turn());
|
||||
out.set("chronicle_events", self.chronicle_slice_as_array(chronicle_len_before));
|
||||
out
|
||||
}
|
||||
|
||||
/// Snapshot of the Dwarf Tribe for `player_id` after turn-0 resolution.
|
||||
/// Empty Dictionary if no tribe has spawned yet. Fields:
|
||||
/// `{owner: int, q: int, r: int, founding_pop_override: int, ancestors_merged: int}`
|
||||
#[func]
|
||||
fn dwarf_tribe(&self, player_id: i64) -> Dictionary {
|
||||
let mut d = Dictionary::new();
|
||||
let Some(entry) = self.find_player(player_id) else {
|
||||
return d;
|
||||
};
|
||||
let Some(tribe) = &entry.tribe else {
|
||||
return d;
|
||||
};
|
||||
d.set("owner", tribe.owner as i64);
|
||||
d.set("q", tribe.position.q as i64);
|
||||
d.set("r", tribe.position.r as i64);
|
||||
d.set("founding_pop_override", tribe.founding_pop_override as i64);
|
||||
d.set("ancestors_merged", tribe.ancestors_merged as i64);
|
||||
d
|
||||
}
|
||||
|
||||
/// Consume the Dwarf Tribe for `player_id`, emit a `capital_founded`
|
||||
/// chronicle entry, clear the player's prologue centroid. Returns a
|
||||
/// Dictionary:
|
||||
/// `{population: int, q: int, r: int,
|
||||
/// chronicle_events: Array[Dictionary]}`
|
||||
/// Empty Dictionary if the player has no tribe to consume.
|
||||
#[func]
|
||||
fn found_capital(&mut self, player_id: i64) -> Dictionary {
|
||||
let mut d = Dictionary::new();
|
||||
if !(0..=255).contains(&player_id) {
|
||||
return d;
|
||||
}
|
||||
let pid = player_id as u8;
|
||||
let chronicle_len_before = self.chronicle.len();
|
||||
let Some(entry_idx) = self.players.iter().position(|e| e.player_id == pid) else {
|
||||
return d;
|
||||
};
|
||||
let tribe = match self.players[entry_idx].tribe.take() {
|
||||
Some(t) => t,
|
||||
None => return d,
|
||||
};
|
||||
let pop = prologue::found_capital(
|
||||
&tribe,
|
||||
&mut self.players[entry_idx].prologue,
|
||||
&mut self.chronicle,
|
||||
);
|
||||
d.set("population", pop as i64);
|
||||
d.set("q", tribe.position.q as i64);
|
||||
d.set("r", tribe.position.r as i64);
|
||||
d.set("chronicle_events", self.chronicle_slice_as_array(chronicle_len_before));
|
||||
d
|
||||
}
|
||||
|
||||
/// Full chronicle snapshot since game start, serialized as an
|
||||
/// `Array[Dictionary]`. Primarily for test / proof-scene inspection.
|
||||
#[func]
|
||||
fn all_chronicle_events(&self) -> VariantArray {
|
||||
self.chronicle_slice_as_array(0)
|
||||
}
|
||||
|
||||
// ── Helpers (not `#[func]`) ──────────────────────────────────────
|
||||
|
||||
fn find_player(&self, player_id: i64) -> Option<&PlayerEntry> {
|
||||
if !(0..=255).contains(&player_id) {
|
||||
return None;
|
||||
}
|
||||
let pid = player_id as u8;
|
||||
self.players.iter().find(|e| e.player_id == pid)
|
||||
}
|
||||
|
||||
fn chronicle_slice_as_array(&self, from: usize) -> VariantArray {
|
||||
let mut arr = VariantArray::new();
|
||||
for entry in self.chronicle.entries().iter().skip(from) {
|
||||
arr.push(&chronicle_entry_to_dict(entry).to_variant());
|
||||
}
|
||||
arr
|
||||
}
|
||||
}
|
||||
|
||||
fn chronicle_entry_to_dict(entry: &ChronicleEntry) -> Dictionary {
|
||||
let mut d = Dictionary::new();
|
||||
match entry {
|
||||
ChronicleEntry::TribeConverged {
|
||||
turn,
|
||||
player_id,
|
||||
centroid,
|
||||
ancestors_merged,
|
||||
founding_pop,
|
||||
} => {
|
||||
d.set("event", "tribe_converged");
|
||||
d.set("turn", *turn as i64);
|
||||
d.set("player_id", *player_id as i64);
|
||||
d.set("q", centroid.q as i64);
|
||||
d.set("r", centroid.r as i64);
|
||||
d.set("ancestors_merged", *ancestors_merged as i64);
|
||||
d.set("founding_pop", *founding_pop as i64);
|
||||
}
|
||||
ChronicleEntry::CapitalFounded {
|
||||
turn,
|
||||
player_id,
|
||||
position,
|
||||
pop,
|
||||
} => {
|
||||
d.set("event", "capital_founded");
|
||||
d.set("turn", *turn as i64);
|
||||
d.set("player_id", *player_id as i64);
|
||||
d.set("q", position.q as i64);
|
||||
d.set("r", position.r as i64);
|
||||
d.set("pop", *pop as i64);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,18 @@
|
|||
#
|
||||
# This script is the lowest-level single-platform export. `tools/export.sh` calls it in parallel
|
||||
# across all platforms.
|
||||
#
|
||||
# ── Staging (scan-inflation fix, p2-06) ───────────────────────────────
|
||||
# Godot's export scanner walks the ENTIRE project tree before applying
|
||||
# `exclude_filter`. The pnpm-managed guides (`public/games/*/guide/`)
|
||||
# each carry a symlinked `node_modules/` pointing at the hoisted store,
|
||||
# which balloons the scan to 20+ min on macOS and emits ~16MB of
|
||||
# `_scan_new_dir` warnings. To sidestep this, we rsync the project
|
||||
# into `.local/export-staging-<stamp>/` excluding node_modules,
|
||||
# .local/build, target, .git, and run godot against the staged tree.
|
||||
# Artifacts still land in REPO_ROOT/.local/build/godot/<version>/<platform>/.
|
||||
# Controlled by EXPORT_STAGED (default: 1 on macOS, 0 elsewhere).
|
||||
# Staging dir is cleaned on success unless KEEP_STAGING=1.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
|
|
@ -126,22 +138,90 @@ else
|
|||
mode_label="release"
|
||||
fi
|
||||
|
||||
# ── Staging (p2-06) ──────────────────────────────────────────────────
|
||||
# Default on for macOS to avoid the symlinked-node_modules scan storm.
|
||||
# Opt-in on other platforms via EXPORT_STAGED=1.
|
||||
if [ -z "${EXPORT_STAGED:-}" ]; then
|
||||
case "$platform" in
|
||||
macos) EXPORT_STAGED=1 ;;
|
||||
*) EXPORT_STAGED=0 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
export_game_dir="$GAME_DIR"
|
||||
staging_root=""
|
||||
if [ "$EXPORT_STAGED" = "1" ]; then
|
||||
if ! command -v rsync &>/dev/null; then
|
||||
echo -e "${RED}EXPORT_STAGED=1 requires rsync${NC}"
|
||||
exit 6
|
||||
fi
|
||||
stamp="$(date +%Y%m%d_%H%M%S)_$$"
|
||||
staging_root="$REPO_ROOT/.local/export-staging-$stamp"
|
||||
staged_game_dir="$staging_root/src/game"
|
||||
echo -e "${BLUE}Staging project → $staging_root (excluding node_modules, .local/build, target, .git)${NC}"
|
||||
mkdir -p "$staging_root"
|
||||
# -a preserves symlinks; --copy-unsafe-links would dereference out-of-tree
|
||||
# symlinks (the node_modules targets live in the repo root's hoisted store
|
||||
# OUTSIDE the staging copy, so they'd be dereferenced — which is exactly
|
||||
# what inflates the scan). --no-links drops symlinks entirely, but the
|
||||
# .gdextension addons under src/game/addons/ are real files, so -a is fine.
|
||||
# Key: --exclude on node_modules stops those symlinks from being copied
|
||||
# at all, so the staged tree has no node_modules to scan.
|
||||
rsync -a \
|
||||
--exclude='.git' \
|
||||
--exclude='.local' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='target' \
|
||||
--exclude='dist' \
|
||||
--exclude='.vite' \
|
||||
--exclude='.vite-temp' \
|
||||
--exclude='*.log' \
|
||||
"$REPO_ROOT/" "$staging_root/" || {
|
||||
echo -e "${RED}rsync to staging failed${NC}"
|
||||
rm -rf "$staging_root"
|
||||
exit 7
|
||||
}
|
||||
# Also need the compiled GDExtension under addons — sits outside .local
|
||||
# (it's at src/game/addons/magic_civ_physics/) so it came across via rsync.
|
||||
# Godot imports its own .godot/ cache on first run; point it at a writable
|
||||
# location inside staging so we don't touch the real src/game/.godot/.
|
||||
export_game_dir="$staged_game_dir"
|
||||
if [ ! -f "$export_game_dir/project.godot" ]; then
|
||||
echo -e "${RED}Staged project.godot missing at $export_game_dir — staging failed${NC}"
|
||||
rm -rf "$staging_root"
|
||||
exit 8
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Exporting $platform ($mode_label) → $out_path${NC}"
|
||||
echo -e "${DIM}preset: $preset_name${NC}"
|
||||
echo -e "${DIM}godot: $godot_cmd${NC}"
|
||||
[ -n "$staging_root" ] && echo -e "${DIM}staged: $export_game_dir${NC}"
|
||||
|
||||
cleanup_staging() {
|
||||
if [ -n "$staging_root" ] && [ -d "$staging_root" ] && [ "${KEEP_STAGING:-0}" != "1" ]; then
|
||||
rm -rf "$staging_root"
|
||||
echo -e "${DIM}cleaned staging: $staging_root${NC}"
|
||||
elif [ -n "$staging_root" ] && [ "${KEEP_STAGING:-0}" = "1" ]; then
|
||||
echo -e "${DIM}staging kept (KEEP_STAGING=1): $staging_root${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Run the export ───────────────────────────────────────────────────
|
||||
# shellcheck disable=SC2086
|
||||
if $godot_cmd --headless --path "$GAME_DIR" "$flag" "$preset_name" "$out_path" 2>&1; then
|
||||
if $godot_cmd --headless --path "$export_game_dir" "$flag" "$preset_name" "$out_path" 2>&1; then
|
||||
if [ -e "$out_path" ]; then
|
||||
size="$(du -h "$out_path" 2>/dev/null | cut -f1)"
|
||||
echo -e "${GREEN} ✓ Exported $artifact ($size)${NC}"
|
||||
cleanup_staging
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED} ✗ Export reported success but $out_path is missing${NC}"
|
||||
cleanup_staging
|
||||
exit 4
|
||||
fi
|
||||
else
|
||||
echo -e "${RED} ✗ Godot export failed${NC}"
|
||||
cleanup_staging
|
||||
exit 5
|
||||
fi
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue