diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index c630f0b5..e4fbebce 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -2828,6 +2828,126 @@ impl GdTrade { } } + /// Create a `CourierRoute` resource for the given sender/receiver capitals. + /// + /// Returns a `GdCourierRoute` RefCounted resource. + #[func] + fn courier_route_new( + &self, + sender_capital_idx: i64, + receiver_capital_idx: i64, + ) -> Gd { + use mc_trade::CourierRoute; + let route = CourierRoute { + sender: sender_capital_idx as u8, + receiver: receiver_capital_idx as u8, + courier_era_tier: 2, + dispatched_turn: 0, + position: (0, 0), + eta_turn: None, + delivered: false, + intercepted: false, + planned_path: Vec::new(), + path_step: 0, + }; + Gd::from_init_fn(|base| GdCourierRoute { inner: route, base }) + } + + /// Create an `OpenBordersAgreement` resource. + /// + /// Returns a `GdOpenBordersAgreement` RefCounted resource. + #[func] + fn open_borders_agreement_new( + &self, + player_a: i64, + player_b: i64, + gold: i64, + turns: i64, + ) -> Gd { + use mc_trade::{OpenBordersAgreement, TradeLedger}; + let mut tmp_ledger = TradeLedger::default(); + let id = tmp_ledger.alloc_agreement_id(); + let (a, b) = (player_a as u8, player_b as u8); + let (pa, pb) = if a <= b { (a, b) } else { (b, a) }; + let ag = OpenBordersAgreement { + agreement_id: id, + partners: (pa, pb), + turn_started: 0, + turns_remaining: turns as u32, + payment_gold: gold as u32, + payment_luxury: None, + }; + Gd::from_init_fn(|base| GdOpenBordersAgreement { inner: ag, base }) + } + + /// Create a `SharedMapAgreement` resource. + /// + /// Returns a `GdSharedMapAgreement` RefCounted resource. + #[func] + fn shared_map_agreement_new( + &self, + player_a: i64, + player_b: i64, + gold: i64, + turns: i64, + ) -> Gd { + use mc_trade::{SharedMapAgreement, TradeLedger}; + let mut tmp_ledger = TradeLedger::default(); + let id = tmp_ledger.alloc_agreement_id(); + let (a, b) = (player_a as u8, player_b as u8); + let (pa, pb) = if a <= b { (a, b) } else { (b, a) }; + let ag = SharedMapAgreement { + agreement_id: id, + partners: (pa, pb), + turn_started: 0, + duration: turns as u32, + share_turns_remaining: 0, + payment_gold: gold as u32, + payment_luxury: None, + courier_route: None, + }; + Gd::from_init_fn(|base| GdSharedMapAgreement { inner: ag, base }) + } + + /// Advance all OpenBorders and SharedMap agreements in `ledger` by one turn. + /// + /// `ledger` — `GdTradeLedger` resource (mutated in place). + /// `map_view` — `GdCourierMapView` resource providing the spatial view. + /// `current_turn` — used as RNG seed for deterministic intercept rolls. + /// + /// Returns an `Array[Dictionary]` of diplomacy events emitted this step. + #[func] + fn step_shared_map_agreements( + &self, + mut ledger: Gd, + map_view: Gd, + current_turn: i64, + ) -> Array { + use mc_trade::step_shared_map_agreements; + use rand::SeedableRng; + use rand::rngs::SmallRng; + use mc_turn::courier_resolver::GameStateMapView; + + let mv_bind = map_view.bind(); + let Some(ref gs_gd) = mv_bind.game_state else { + godot_error!("GdTrade::step_shared_map_agreements: map_view has no GameState"); + return Array::new(); + }; + let gs_bind = gs_gd.bind(); + let map_impl = GameStateMapView { state: &gs_bind.inner }; + let mut rng = SmallRng::seed_from_u64(current_turn as u64); + let events = step_shared_map_agreements( + &mut ledger.bind_mut().inner, + &map_impl, + &mut rng, + ); + + events + .into_iter() + .map(diplomacy_event_to_dict) + .collect() + } + /// Break all trades involving a war pair and advance relation states. /// /// `ledger_json` — current `TradeLedger` JSON. @@ -2871,6 +2991,398 @@ impl GdTrade { } } +// ── GdTrade courier diplomacy types ───────────────────────────────────── + +/// Convert a `DiplomacyEvent` to a `Dictionary` for GDScript consumption. +fn diplomacy_event_to_dict(ev: mc_trade::DiplomacyEvent) -> Dictionary { + use mc_trade::DiplomacyEvent; + let mut d = Dictionary::new(); + match ev { + DiplomacyEvent::CourierDispatched(e) => { + d.set("type", GString::from("courier_dispatched")); + d.set("agreement_id", e.agreement_id as i64); + d.set("from_player", e.from_player as i64); + d.set("to_player", e.to_player as i64); + d.set("courier_unit", GString::from(e.courier_unit)); + d.set("eta_turns", e.eta_turns as i64); + } + DiplomacyEvent::CourierIntercepted(e) => { + d.set("type", GString::from("courier_intercepted")); + d.set("agreement_id", e.agreement_id as i64); + d.set("at_col", e.at_position.0 as i64); + d.set("at_row", e.at_position.1 as i64); + d.set("by_player", e.by_player as i64); + } + DiplomacyEvent::SharedMapDelivered(e) => { + d.set("type", GString::from("shared_map_delivered")); + d.set("agreement_id", e.agreement_id as i64); + d.set("from_player", e.from_player as i64); + d.set("to_player", e.to_player as i64); + d.set("turns_remaining", e.turns_remaining as i64); + } + DiplomacyEvent::SharedMapExpired(e) => { + d.set("type", GString::from("shared_map_expired")); + d.set("agreement_id", e.agreement_id as i64); + } + DiplomacyEvent::OpenBordersSigned(e) => { + d.set("type", GString::from("open_borders_signed")); + d.set("agreement_id", e.agreement_id as i64); + d.set("player_a", e.player_a as i64); + d.set("player_b", e.player_b as i64); + d.set("turns_remaining", e.turns_remaining as i64); + } + DiplomacyEvent::OpenBordersExpired(e) => { + d.set("type", GString::from("open_borders_expired")); + d.set("agreement_id", e.agreement_id as i64); + } + other => { + if let Ok(s) = serde_json::to_string(&other) { + d.set("type", GString::from("other")); + d.set("json", GString::from(s)); + } + } + } + d +} + +// ── GdCourierRoute ─────────────────────────────────────────────────────── + +/// Godot-visible wrapper for `CourierRoute`. Holds the in-transit state +/// of a courier moving from one capital to another. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdCourierRoute { + pub(crate) inner: mc_trade::CourierRoute, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdCourierRoute { + fn init(base: Base) -> Self { + use mc_trade::CourierRoute; + Self { + inner: CourierRoute { + sender: 0, + receiver: 0, + courier_era_tier: 2, + dispatched_turn: 0, + position: (0, 0), + eta_turn: None, + delivered: false, + intercepted: false, + planned_path: Vec::new(), + path_step: 0, + }, + base, + } + } +} + +#[godot_api] +impl GdCourierRoute { + #[func] + fn get_sender(&self) -> i64 { self.inner.sender as i64 } + #[func] + fn get_receiver(&self) -> i64 { self.inner.receiver as i64 } + #[func] + fn get_courier_era_tier(&self) -> i64 { self.inner.courier_era_tier as i64 } + #[func] + fn is_delivered(&self) -> bool { self.inner.delivered } + #[func] + fn is_intercepted(&self) -> bool { self.inner.intercepted } + #[func] + fn get_position_col(&self) -> i64 { self.inner.position.0 as i64 } + #[func] + fn get_position_row(&self) -> i64 { self.inner.position.1 as i64 } + #[func] + fn get_eta_turn(&self) -> i64 { + self.inner.eta_turn.map(|t| t as i64).unwrap_or(-1) + } + + #[func] + fn to_dict(&self) -> Dictionary { + let mut d = Dictionary::new(); + d.set("sender", self.inner.sender as i64); + d.set("receiver", self.inner.receiver as i64); + d.set("courier_era_tier", self.inner.courier_era_tier as i64); + d.set("delivered", self.inner.delivered); + d.set("intercepted", self.inner.intercepted); + d.set("position_col", self.inner.position.0 as i64); + d.set("position_row", self.inner.position.1 as i64); + d.set("eta_turn", self.inner.eta_turn.map(|t| t as i64).unwrap_or(-1)); + d + } +} + +// ── GdOpenBordersAgreement ─────────────────────────────────────────────── + +/// Godot-visible wrapper for `OpenBordersAgreement`. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdOpenBordersAgreement { + pub(crate) inner: mc_trade::OpenBordersAgreement, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdOpenBordersAgreement { + fn init(base: Base) -> Self { + use mc_trade::OpenBordersAgreement; + Self { + inner: OpenBordersAgreement { + agreement_id: 0, + partners: (0, 1), + turn_started: 0, + turns_remaining: 0, + payment_gold: 0, + payment_luxury: None, + }, + base, + } + } +} + +#[godot_api] +impl GdOpenBordersAgreement { + #[func] + fn get_agreement_id(&self) -> i64 { self.inner.agreement_id as i64 } + #[func] + fn get_player_a(&self) -> i64 { self.inner.partners.0 as i64 } + #[func] + fn get_player_b(&self) -> i64 { self.inner.partners.1 as i64 } + #[func] + fn get_turns_remaining(&self) -> i64 { self.inner.turns_remaining as i64 } + #[func] + fn get_payment_gold(&self) -> i64 { self.inner.payment_gold as i64 } + + #[func] + fn to_dict(&self) -> Dictionary { + let mut d = Dictionary::new(); + d.set("type", GString::from("open_borders")); + d.set("agreement_id", self.inner.agreement_id as i64); + d.set("partner_a", self.inner.partners.0 as i64); + d.set("partner_b", self.inner.partners.1 as i64); + d.set("turns_remaining", self.inner.turns_remaining as i64); + d.set("payment_gold", self.inner.payment_gold as i64); + d + } +} + +// ── GdSharedMapAgreement ───────────────────────────────────────────────── + +/// Godot-visible wrapper for `SharedMapAgreement`. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdSharedMapAgreement { + pub(crate) inner: mc_trade::SharedMapAgreement, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdSharedMapAgreement { + fn init(base: Base) -> Self { + use mc_trade::SharedMapAgreement; + Self { + inner: SharedMapAgreement { + agreement_id: 0, + partners: (0, 1), + turn_started: 0, + duration: 0, + share_turns_remaining: 0, + payment_gold: 0, + payment_luxury: None, + courier_route: None, + }, + base, + } + } +} + +#[godot_api] +impl GdSharedMapAgreement { + #[func] + fn get_agreement_id(&self) -> i64 { self.inner.agreement_id as i64 } + #[func] + fn get_player_a(&self) -> i64 { self.inner.partners.0 as i64 } + #[func] + fn get_player_b(&self) -> i64 { self.inner.partners.1 as i64 } + #[func] + fn get_share_turns_remaining(&self) -> i64 { self.inner.share_turns_remaining as i64 } + #[func] + fn get_payment_gold(&self) -> i64 { self.inner.payment_gold as i64 } + #[func] + fn is_delivered(&self) -> bool { + self.inner.courier_route.as_ref().map(|r| r.delivered).unwrap_or(false) + } + + #[func] + fn get_courier_route(&self) -> Option> { + self.inner.courier_route.as_ref().map(|r| { + Gd::from_init_fn(|base| GdCourierRoute { inner: r.clone(), base }) + }) + } + + #[func] + fn to_dict(&self) -> Dictionary { + let mut d = Dictionary::new(); + d.set("type", GString::from("shared_map")); + d.set("agreement_id", self.inner.agreement_id as i64); + d.set("partner_a", self.inner.partners.0 as i64); + d.set("partner_b", self.inner.partners.1 as i64); + d.set("share_turns_remaining", self.inner.share_turns_remaining as i64); + d.set("payment_gold", self.inner.payment_gold as i64); + d.set("delivered", self.is_delivered()); + d + } +} + +// ── GdTradeLedger ──────────────────────────────────────────────────────── + +/// Godot-visible wrapper for `TradeLedger`. Holds all active diplomatic +/// agreements. Construct via `GdTradeLedger.from_json(json)` or +/// `GdTradeLedger.create()` for an empty ledger. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdTradeLedger { + pub(crate) inner: mc_trade::TradeLedger, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdTradeLedger { + fn init(base: Base) -> Self { + Self { + inner: mc_trade::TradeLedger::default(), + base, + } + } +} + +#[godot_api] +impl GdTradeLedger { + /// Create an empty trade ledger. + #[func] + fn create() -> Gd { + Gd::from_init_fn(|base| GdTradeLedger { + inner: mc_trade::TradeLedger::default(), + base, + }) + } + + /// Deserialize a `TradeLedger` from JSON. + #[func] + fn from_json(json: GString) -> Gd { + let inner = serde_json::from_str(&json.to_string()).unwrap_or_else(|e| { + godot_error!("GdTradeLedger::from_json error: {}", e); + mc_trade::TradeLedger::default() + }); + Gd::from_init_fn(|base| GdTradeLedger { inner, base }) + } + + /// Serialize the ledger to JSON. + #[func] + fn to_json(&self) -> GString { + match serde_json::to_string(&self.inner) { + Ok(s) => GString::from(s), + Err(e) => { + godot_error!("GdTradeLedger::to_json error: {}", e); + GString::new() + } + } + } + + /// Returns all active OpenBorders agreements as an `Array[GdOpenBordersAgreement]`. + #[func] + fn iter_open_borders(&self) -> Array> { + use mc_trade::DiplomaticAgreement; + self.inner + .agreements + .iter() + .filter_map(|ag| { + if let DiplomaticAgreement::OpenBorders(ob) = ag { + Some(Gd::from_init_fn(|base| GdOpenBordersAgreement { + inner: ob.clone(), + base, + })) + } else { + None + } + }) + .collect() + } + + /// Returns all active SharedMap agreements as an `Array[GdSharedMapAgreement]`. + #[func] + fn iter_shared_map(&self) -> Array> { + use mc_trade::DiplomaticAgreement; + self.inner + .agreements + .iter() + .filter_map(|ag| { + if let DiplomaticAgreement::SharedMap(sm) = ag { + Some(Gd::from_init_fn(|base| GdSharedMapAgreement { + inner: sm.clone(), + base, + })) + } else { + None + } + }) + .collect() + } + + /// Push an OpenBorders agreement into this ledger. + #[func] + fn add_open_borders(&mut self, agreement: Gd) { + use mc_trade::DiplomaticAgreement; + self.inner + .agreements + .push(DiplomaticAgreement::OpenBorders(agreement.bind().inner.clone())); + } + + /// Push a SharedMap agreement into this ledger. + #[func] + fn add_shared_map(&mut self, agreement: Gd) { + use mc_trade::DiplomaticAgreement; + self.inner + .agreements + .push(DiplomaticAgreement::SharedMap(agreement.bind().inner.clone())); + } +} + +// ── GdCourierMapView ───────────────────────────────────────────────────── + +/// Godot-visible `CourierMapView` backed by a `GdGameState` handle. +/// Delegates `capital_position`, `route_intact`, and `intercept_chance_at` +/// to `mc_turn::courier_resolver::GameStateMapView`. +/// +/// Construct via `GdCourierMapView.from_game_state(game_state)`. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdCourierMapView { + pub(crate) game_state: Option>, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdCourierMapView { + fn init(base: Base) -> Self { + Self { game_state: None, base } + } +} + +#[godot_api] +impl GdCourierMapView { + /// Construct a `GdCourierMapView` from an existing `GdGameState` handle. + #[func] + fn from_game_state(state: Gd) -> Gd { + Gd::from_init_fn(|base| GdCourierMapView { + game_state: Some(state), + base, + }) + } +} + // ── GdCulture ─────────────────────────────────────────────────────────── // // Thin wrapper over `mc_culture::CulturePool`. GDScript calls `register_city` diff --git a/src/simulator/crates/mc-trade/src/lib.rs b/src/simulator/crates/mc-trade/src/lib.rs index 284379c7..bd7099c6 100644 --- a/src/simulator/crates/mc-trade/src/lib.rs +++ b/src/simulator/crates/mc-trade/src/lib.rs @@ -582,49 +582,54 @@ pub fn step_shared_map_agreements( } DiplomaticAgreement::SharedMap(sm) => { + // Copy stable fields up front to avoid split-borrow conflicts below. + let agreement_id = sm.agreement_id; + let duration = sm.duration; + // Phase A: courier already delivered — tick share window. - if let Some(ref route) = sm.courier_route { - if route.delivered { + if sm.courier_route.as_ref().map(|r| r.delivered).unwrap_or(false) { + if sm.share_turns_remaining == 0 { + events.push(DiplomacyEvent::SharedMapExpired(SharedMapExpired { + agreement_id, + })); + to_remove.push(idx); + } else { + sm.share_turns_remaining -= 1; if sm.share_turns_remaining == 0 { events.push(DiplomacyEvent::SharedMapExpired(SharedMapExpired { - agreement_id: sm.agreement_id, + agreement_id, })); to_remove.push(idx); - } else { - sm.share_turns_remaining -= 1; - if sm.share_turns_remaining == 0 { - events.push(DiplomacyEvent::SharedMapExpired(SharedMapExpired { - agreement_id: sm.agreement_id, - })); - to_remove.push(idx); - } } - continue; - } - - // Phase B: courier in transit — check if already intercepted. - if route.intercepted { - // Terminal state — skip until caller cleans up the agreement. - continue; - } - - // Safety check: route still structurally sound. - if !map.route_intact(route) { - // Infrastructure severed — courier intercepted at current position. - route.intercepted = true; - events.push(DiplomacyEvent::CourierIntercepted(CourierIntercepted { - agreement_id: sm.agreement_id, - at_position: route.position, - by_player: route.receiver, // severed by hostile action on receiver side - })); - continue; } + continue; } - // Advance courier toward destination capital. + // Phase B: terminal state — already intercepted. + if sm.courier_route.as_ref().map(|r| r.intercepted).unwrap_or(false) { + continue; + } + + // Phase C: route_intact check (severance). Read-only first, then mut. + let severed = sm.courier_route.as_ref().map(|r| !map.route_intact(r)).unwrap_or(false); + if severed { + if let Some(route) = sm.courier_route.as_mut() { + let pos = route.position; + let by = route.receiver; + route.intercepted = true; + events.push(DiplomacyEvent::CourierIntercepted(CourierIntercepted { + agreement_id, + at_position: pos, + by_player: by, + })); + } + continue; + } + + // Phase D: advance courier. let route = match sm.courier_route.as_mut() { Some(r) => r, - None => continue, // no courier dispatched yet + None => continue, }; let dest = match map.capital_position(route.receiver) { @@ -632,42 +637,42 @@ pub fn step_shared_map_agreements( None => continue, }; - // Adamantine Echo: both players have the wonder — deliver instantly. + // Adamantine Echo: deliver instantly (copy route fields before mut sm). if map.adamantine_echo_active(route.sender, route.receiver) { + let (sender, receiver) = (route.sender, route.receiver); route.position = dest; route.delivered = true; - sm.share_turns_remaining = sm.duration; + let _ = route; // release borrow on sm.courier_route before touching sm fields + sm.share_turns_remaining = duration; events.push(DiplomacyEvent::SharedMapDelivered(SharedMapDelivered { - agreement_id: sm.agreement_id, - from_player: route.sender, - to_player: route.receiver, + agreement_id, + from_player: sender, + to_player: receiver, turns_remaining: sm.share_turns_remaining, })); continue; } - // Advance `movement_per_turn` steps along planned_path (if populated), - // or fall back to straight-line for legacy / test paths with empty paths. + // Advance movement_per_turn steps along planned_path (or straight-line fallback). let steps = map.movement_per_turn(route.courier_era_tier); + let mut intercept_event: Option = None; for _ in 0..steps { - if route.position == dest { + if route.position == dest || route.intercepted { break; } - - // Check intercept at current position before each step. let intercept_roll: f32 = rng.gen(); let intercept_chance = map.intercept_chance_at(route.position, route.sender); if intercept_roll < intercept_chance { + let pos = route.position; + let by = route.receiver; route.intercepted = true; - events.push(DiplomacyEvent::CourierIntercepted(CourierIntercepted { - agreement_id: sm.agreement_id, - at_position: route.position, - by_player: route.receiver, - })); + intercept_event = Some(CourierIntercepted { + agreement_id, + at_position: pos, + by_player: by, + }); break; } - - // Advance one step: follow planned_path if available, else straight-line. if !route.planned_path.is_empty() { let next_step = route.path_step + 1; if next_step < route.planned_path.len() { @@ -679,27 +684,23 @@ pub fn step_shared_map_agreements( } else { let (cx, cy) = route.position; let (dx, dy) = dest; - let step_x = (dx - cx).signum(); - let step_y = (dy - cy).signum(); - route.position = (cx + step_x, cy + step_y); - } - - if route.intercepted { - break; + route.position = (cx + (dx - cx).signum(), cy + (dy - cy).signum()); } } - - if route.intercepted { + if let Some(ev) = intercept_event { + events.push(DiplomacyEvent::CourierIntercepted(ev)); continue; } - if route.position == dest { + if route.position == dest && !route.delivered { + let (sender, receiver) = (route.sender, route.receiver); route.delivered = true; - sm.share_turns_remaining = sm.duration; + drop(route); + sm.share_turns_remaining = duration; events.push(DiplomacyEvent::SharedMapDelivered(SharedMapDelivered { - agreement_id: sm.agreement_id, - from_player: route.sender, - to_player: route.receiver, + agreement_id, + from_player: sender, + to_player: receiver, turns_remaining: sm.share_turns_remaining, })); }