feat(simulator): Add CourierRoute and OpenBordersAgreement structs/enums with trade logic to simulator API and trade crate

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-28 15:52:58 -07:00
parent 0658101581
commit 1cbf3fcd4e
2 changed files with 576 additions and 63 deletions

View file

@ -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<GdCourierRoute> {
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<GdOpenBordersAgreement> {
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<GdSharedMapAgreement> {
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<GdTradeLedger>,
map_view: Gd<GdCourierMapView>,
current_turn: i64,
) -> Array<Dictionary> {
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<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdCourierRoute {
fn init(base: Base<RefCounted>) -> 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<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdOpenBordersAgreement {
fn init(base: Base<RefCounted>) -> 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<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdSharedMapAgreement {
fn init(base: Base<RefCounted>) -> 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<Gd<GdCourierRoute>> {
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<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdTradeLedger {
fn init(base: Base<RefCounted>) -> Self {
Self {
inner: mc_trade::TradeLedger::default(),
base,
}
}
}
#[godot_api]
impl GdTradeLedger {
/// Create an empty trade ledger.
#[func]
fn create() -> Gd<GdTradeLedger> {
Gd::from_init_fn(|base| GdTradeLedger {
inner: mc_trade::TradeLedger::default(),
base,
})
}
/// Deserialize a `TradeLedger` from JSON.
#[func]
fn from_json(json: GString) -> Gd<GdTradeLedger> {
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<Gd<GdOpenBordersAgreement>> {
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<Gd<GdSharedMapAgreement>> {
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<GdOpenBordersAgreement>) {
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<GdSharedMapAgreement>) {
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<Gd<GdGameState>>,
base: Base<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdCourierMapView {
fn init(base: Base<RefCounted>) -> 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<GdGameState>) -> Gd<GdCourierMapView> {
Gd::from_init_fn(|base| GdCourierMapView {
game_state: Some(state),
base,
})
}
}
// ── GdCulture ───────────────────────────────────────────────────────────
//
// Thin wrapper over `mc_culture::CulturePool`. GDScript calls `register_city`

View file

@ -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<CourierIntercepted> = 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,
}));
}