From 18dc9e84416c7c0d2d2132f5a61357bb6ea34d41 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 17 Apr 2026 23:24:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20staging=20export=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- scripts/README.md | 14 ++ src/simulator/api-gdext/src/lib.rs | 383 +++++++++++++++++++++++++++++ tools/export-single.sh | 82 +++++- 3 files changed, 478 insertions(+), 1 deletion(-) diff --git a/scripts/README.md b/scripts/README.md index 05e619a5..6ae4c4cc 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -43,3 +43,17 @@ scripts/ - **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. + +## Export staging (p2-06) + +`tools/export-single.sh` rsyncs the project to `.local/export-staging-/` +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. diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 84b74bc7..3121edb2 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -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` 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, + tribe: Option, + mode: StartMode, +} + +/// Godot-visible prologue driver. One per game. Lives on `TurnManager`. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdPrologue { + turn: PrologueTurn, + players: Vec, + chronicle: Chronicle, + map_seed: u64, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdPrologue { + fn init(base: Base) -> 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, + ) -> 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 +} diff --git a/tools/export-single.sh b/tools/export-single.sh index 7077efbf..a0c2335c 100755 --- a/tools/export-single.sh +++ b/tools/export-single.sh @@ -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-/` excluding node_modules, +# .local/build, target, .git, and run godot against the staged tree. +# Artifacts still land in REPO_ROOT/.local/build/godot///. +# 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