diff --git a/src/simulator/crates/mc-trade/src/lib.rs b/src/simulator/crates/mc-trade/src/lib.rs index 6c5b065c..284379c7 100644 --- a/src/simulator/crates/mc-trade/src/lib.rs +++ b/src/simulator/crates/mc-trade/src/lib.rs @@ -303,6 +303,14 @@ pub struct CourierRoute { pub delivered: bool, /// True once this courier has been intercepted (terminal state). pub intercepted: bool, + /// Pre-computed A* path from sender capital to receiver capital (offset coords). + /// Empty until the route resolver populates it; straight-line fallback applies when empty. + /// Populated by `mc_turn::courier_resolver::dispatch_courier`. + #[serde(default)] + pub planned_path: Vec<(i32, i32)>, + /// Index into `planned_path` of the courier's current step. + #[serde(default)] + pub path_step: usize, } // ── Courier-gated diplomacy event structs (p3-01 c4) ───────────────────── @@ -505,6 +513,29 @@ pub trait CourierMapView { /// True if the courier route is still structurally intact (e.g. beacon chain /// not destroyed). For basic couriers this is always true. fn route_intact(&self, route: &CourierRoute) -> bool; + + /// Hex steps the courier advances per turn, based on `courier_era_tier`. + /// Default: 1 step/turn (era_2 Foot Runner baseline). + fn movement_per_turn(&self, era_tier: u8) -> u32 { + // Tiers 2-9 mapped from delay_class in unit JSONs. + match era_tier { + 2 => 1, // very_high delay + 3 => 2, // high + 4 => 3, // medium_high + 5 => 4, // medium + 6 => 5, // low + 7 => 8, // near_instant (steam track) + 8 => 8, // near_instant (resonance wire) + 9 => 16, // instant (hold network) + _ => 1, + } + } + + /// True if both players in the agreement have the Adamantine Echo wonder, + /// collapsing courier delay to zero (deliver next turn regardless of path). + fn adamantine_echo_active(&self, _player_a: u8, _player_b: u8) -> bool { + false + } } /// Advance all SharedMap and OpenBorders agreements by one turn. @@ -579,12 +610,18 @@ pub fn step_shared_map_agreements( // Safety check: route still structurally sound. if !map.route_intact(route) { - // Route severed but not intercepted — skip this turn. + // 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; } } - // Advance courier one step toward destination capital. + // Advance courier toward destination capital. let route = match sm.courier_route.as_mut() { Some(r) => r, None => continue, // no courier dispatched yet @@ -595,26 +632,66 @@ pub fn step_shared_map_agreements( None => continue, }; - // Check intercept at current position before moving. - let intercept_roll: f32 = rng.gen(); - let intercept_chance = map.intercept_chance_at(route.position, route.sender); - if intercept_roll < intercept_chance { - route.intercepted = true; - events.push(DiplomacyEvent::CourierIntercepted(CourierIntercepted { + // Adamantine Echo: both players have the wonder — deliver instantly. + if map.adamantine_echo_active(route.sender, route.receiver) { + route.position = dest; + route.delivered = true; + sm.share_turns_remaining = sm.duration; + events.push(DiplomacyEvent::SharedMapDelivered(SharedMapDelivered { agreement_id: sm.agreement_id, - at_position: route.position, - by_player: route.receiver, + from_player: route.sender, + to_player: route.receiver, + turns_remaining: sm.share_turns_remaining, })); continue; } - // Straight-line step toward destination capital. - // TODO(p3-01): replace with real A* pathfinding once the spatial graph is wired. - 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); + // Advance `movement_per_turn` steps along planned_path (if populated), + // or fall back to straight-line for legacy / test paths with empty paths. + let steps = map.movement_per_turn(route.courier_era_tier); + for _ in 0..steps { + if route.position == dest { + 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 { + route.intercepted = true; + events.push(DiplomacyEvent::CourierIntercepted(CourierIntercepted { + agreement_id: sm.agreement_id, + at_position: route.position, + by_player: route.receiver, + })); + 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() { + route.path_step = next_step; + route.position = route.planned_path[next_step]; + } else { + route.position = dest; + } + } 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; + } + } + + if route.intercepted { + continue; + } if route.position == dest { route.delivered = true; diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index 240b2124..3c8f91e1 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -8,6 +8,7 @@ use mc_core::formation::{ RallyPointRequest, SplitFormationRequest, }; use mc_core::grid::GridState; +use mc_core::improvement::{TileImprovement, TileImprovementSpec}; use mc_core::WonderId; use mc_culture::CulturePool; use mc_tech::PlayerTechState; @@ -41,6 +42,29 @@ mod relations_as_pairs { } } +/// Serde adapter: round-trips `BTreeMap<(u16,u16), TileImprovement>` as a +/// `Vec<((u16,u16), TileImprovement)>` for the same reason as `relations_as_pairs`. +mod improvements_as_pairs { + use mc_core::improvement::TileImprovement; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::collections::BTreeMap; + + pub fn serialize( + map: &BTreeMap<(u16, u16), TileImprovement>, + ser: S, + ) -> Result { + let pairs: Vec<(&(u16, u16), &TileImprovement)> = map.iter().collect(); + pairs.serialize(ser) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + let pairs: Vec<((u16, u16), TileImprovement)> = Vec::deserialize(de)?; + Ok(pairs.into_iter().collect()) + } +} + /// A player-vs-player attack request queued by GDScript (click-to-attack path). /// /// Drained each turn by `TurnProcessor::process_pvp_combat` before the @@ -95,6 +119,74 @@ pub struct GameState { /// Auto-join toggle requests queued by GDScript this turn. #[serde(default)] pub pending_auto_join_requests: Vec, + /// Sparse per-hex improvement layer. Keyed by (col, row) as u16; most + /// tiles will be unimproved, so a BTreeMap wastes far less memory than an + /// Option on every TileState. Serialized as pairs to avoid the JSON + /// "key must be a string" restriction on tuple keys. + #[serde(default, with = "improvements_as_pairs")] + pub tile_improvements: BTreeMap<(u16, u16), TileImprovement>, + /// Registry of improvement specs loaded from + /// `public/resources/improvements/*.json`. Populated at game start via + /// `load_improvement_specs`; absent in unit tests that don't need it. + #[serde(skip)] + pub improvement_registry: BTreeMap, +} + +impl GameState { + /// Return the improvement at `(col, row)`, if any. + pub fn improvement_at(&self, col: u16, row: u16) -> Option<&TileImprovement> { + self.tile_improvements.get(&(col, row)) + } + + /// Place `spec`-derived improvement at `(col, row)`, overwriting any + /// existing one. Derives `hp`, `severable`, and `flags` from the spec. + pub fn set_improvement(&mut self, col: u16, row: u16, spec: &TileImprovementSpec) { + self.tile_improvements.insert( + (col, row), + TileImprovement { + id: spec.id.clone(), + hp: spec.hp, + severable: spec.severable, + pillaged: false, + flags: spec.flags.clone(), + }, + ); + } + + /// Remove the improvement at `(col, row)` entirely. + pub fn remove_improvement(&mut self, col: u16, row: u16) { + self.tile_improvements.remove(&(col, row)); + } + + /// Pillage the improvement at `(col, row)`. + /// + /// - If the improvement is `severable`: sets `pillaged = true` and returns + /// `true` (the improvement remains but its route-gating effect is + /// suspended). + /// - If the improvement is not severable (e.g. a Beacon Tower): removes it + /// outright and returns `false`. + /// - If there is no improvement: returns `false`. + pub fn pillage_improvement(&mut self, col: u16, row: u16) -> bool { + match self.tile_improvements.get_mut(&(col, row)) { + Some(imp) if imp.severable => { + imp.pillaged = true; + true + } + Some(_) => { + self.tile_improvements.remove(&(col, row)); + false + } + None => false, + } + } + + /// Populate `improvement_registry` from a slice of already-parsed specs. + /// Call once at game start after loading JSON files. + pub fn load_improvement_specs(&mut self, specs: impl IntoIterator) { + for spec in specs { + self.improvement_registry.insert(spec.id.clone(), spec); + } + } } /// Per-player state. Wide struct by design — every field is read directly by diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index bdd8db38..5451400a 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -29,6 +29,7 @@ pub mod prologue; pub mod snapshot; pub mod spatial_index; pub mod victory; +pub mod courier_resolver; #[cfg(feature = "gpu")] pub mod gpu; @@ -38,10 +39,14 @@ mod processor_invariants; #[cfg(test)] mod bridge_contract_tests; +#[cfg(test)] +mod improvement_tests; + pub use action::{legal_actions, ActionAvailability, ActionKind, DisabledReason, UnitCapability}; pub use action_handlers::{invoke as invoke_action, ActionError}; pub use chronicle::{Chronicle, ChronicleEntry}; pub use game_state::{AttackRequest, BuildingRallyPoint, CityEcology, GameState, MapUnit, PlayerState, TechState}; +pub use mc_core::improvement::{RawImprovementJson, TileImprovement, TileImprovementSpec}; pub use combat_event::{FaunaCombatEvent, PvpCombatEvent, SiegeEvent, StrategicGateRejection, TurnResult}; pub use processor::{LairCombatConfig, TurnProcessor}; pub use prologue::{