feat(@projects/@magic-civilization): add staging export pipeline

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 23:24:54 -07:00
parent 98402e156e
commit 18dc9e8441
3 changed files with 478 additions and 1 deletions

View file

@ -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.

View file

@ -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,
&params,
&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
}

View file

@ -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