From 453189ec02234fda1a3a9b94c6668cc8274cfec9 Mon Sep 17 00:00:00 2001 From: autocommit Date: Tue, 28 Apr 2026 15:46:47 -0700 Subject: [PATCH] =?UTF-8?q?test(mc-turn):=20=E2=9C=85=20Add=20and=20update?= =?UTF-8?q?=20tests=20for=20courier=20resolver=20logic=20and=20improvement?= =?UTF-8?q?=20mechanics=20in=20the=20simulator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-turn/src/courier_resolver.rs | 573 ++++++++++++++++++ .../crates/mc-turn/src/improvement_tests.rs | 103 ++++ 2 files changed, 676 insertions(+) create mode 100644 src/simulator/crates/mc-turn/src/courier_resolver.rs create mode 100644 src/simulator/crates/mc-turn/src/improvement_tests.rs diff --git a/src/simulator/crates/mc-turn/src/courier_resolver.rs b/src/simulator/crates/mc-turn/src/courier_resolver.rs new file mode 100644 index 00000000..908c406d --- /dev/null +++ b/src/simulator/crates/mc-turn/src/courier_resolver.rs @@ -0,0 +1,573 @@ +//! Real `CourierMapView` impl for `GameState` + A* courier pathfinding (p3-03). +//! +//! `GameStateMapView` implements the mc-trade trait against the live `GameState`. +//! `dispatch_courier` computes an A* path from sender capital to receiver capital +//! and stores it in `CourierRoute.planned_path`, setting `eta_turn` accordingly. +//! +//! # Severance (pending p3-04) +//! `route_intact` and `intercept_chance_at` both return safe values for now. +//! Real severance (Steam Track / Resonance Wire pillage) requires a per-hex +//! improvement layer that doesn't exist yet — tracked in p3-04. + +use mc_core::algorithms::hex::offset_neighbors; +use mc_core::WonderId; +use mc_trade::{CourierMapView, CourierRoute}; +use std::collections::{BinaryHeap, HashMap}; +use std::cmp::Reverse; + +use crate::game_state::{GameState, PlayerState}; + +// ── Movement table ──────────────────────────────────────────────────────────── + +/// Hex steps a courier of the given era tier advances per turn. +/// +/// Derived from `courier_tier.delay_class` in each courier unit JSON: +/// era_2=very_high(1), era_3=high(2), era_4=medium_high(3), era_5=medium(4), +/// era_6=low(5), era_7=near_instant(8), era_8=near_instant(8), era_9=instant(16). +pub fn movement_per_turn(era_tier: u8) -> u32 { + match era_tier { + 2 => 1, + 3 => 2, + 4 => 3, + 5 => 4, + 6 => 5, + 7 => 8, + 8 => 8, + 9 => 16, + _ => 1, + } +} + +/// ETA in turns: ceil(path_hexes / movement_per_turn). +pub fn eta_turns(path_len: usize, era_tier: u8) -> u32 { + if path_len == 0 { + return 0; + } + let mpt = movement_per_turn(era_tier) as usize; + ((path_len + mpt - 1) / mpt) as u32 +} + +// ── A* hex pathfinding ──────────────────────────────────────────────────────── + +/// Terrain movement cost for a tile at (col, row) given the courier era tier. +/// Returns `None` to indicate impassable (no path through this hex). +/// +/// Era_2 Foot Runner avoids mountains (elevation ≥ 0.7, biome contains "mountain"). +/// Era_3 Tunnel Runner prefers tiles with cave access (has_cave). +/// All other tiers treat every land tile as passable with cost 1. +/// Ocean / deep water tiles are always impassable. +fn tile_cost(state: &GameState, col: i32, row: i32, era_tier: u8) -> Option { + let grid = state.grid.as_ref()?; + let tile = grid.tile(col, row)?; + + // Water is impassable for all couriers. + if tile.biome_id.contains("ocean") || tile.biome_id.contains("sea") { + return None; + } + + match era_tier { + 2 => { + // Foot Runner avoids mountain hexes. + if tile.biome_id.contains("mountain") || tile.elevation >= 0.7 { + Some(4) // passable but very costly; hard block would prevent any cross-mountain route + } else { + Some(1) + } + } + 3 => { + // Tunnel Runner: prefer cave tiles, avoid mountains at surface. + if tile.has_cave { + Some(1) + } else if tile.biome_id.contains("mountain") || tile.elevation >= 0.7 { + Some(3) + } else { + Some(2) + } + } + _ => Some(1), + } +} + +/// A* shortest path in hex offset coordinates from `start` to `goal`. +/// Returns the path including both endpoints, or `None` if unreachable. +pub fn astar_path( + state: &GameState, + start: (i32, i32), + goal: (i32, i32), + era_tier: u8, +) -> Option> { + let grid = state.grid.as_ref()?; + let (w, h) = (grid.width, grid.height); + + if start == goal { + return Some(vec![start]); + } + + // Priority queue: (f_cost, g_cost, pos). Wrapped in Reverse for min-heap. + let mut open: BinaryHeap> = BinaryHeap::new(); + let mut g_cost: HashMap<(i32, i32), u32> = HashMap::new(); + let mut came_from: HashMap<(i32, i32), (i32, i32)> = HashMap::new(); + + g_cost.insert(start, 0); + let h = hex_dist(start, goal); + open.push(Reverse((h, 0, start))); + + while let Some(Reverse((_, g, pos))) = open.pop() { + if pos == goal { + return Some(reconstruct_path(&came_from, goal)); + } + // Skip stale entries. + if g > *g_cost.get(&pos).unwrap_or(&u32::MAX) { + continue; + } + for neighbor in offset_neighbors(pos.0, pos.1, w, h) { + let step_cost = match tile_cost(state, neighbor.0, neighbor.1, era_tier) { + Some(c) => c, + None => continue, // impassable + }; + let new_g = g + step_cost; + if new_g < *g_cost.get(&neighbor).unwrap_or(&u32::MAX) { + g_cost.insert(neighbor, new_g); + came_from.insert(neighbor, pos); + let f = new_g + hex_dist(neighbor, goal); + open.push(Reverse((f, new_g, neighbor))); + } + } + } + None +} + +fn reconstruct_path( + came_from: &HashMap<(i32, i32), (i32, i32)>, + goal: (i32, i32), +) -> Vec<(i32, i32)> { + let mut path = Vec::new(); + let mut cur = goal; + loop { + path.push(cur); + match came_from.get(&cur) { + Some(&prev) => cur = prev, + None => break, + } + } + path.reverse(); + path +} + +fn hex_dist(a: (i32, i32), b: (i32, i32)) -> u32 { + mc_core::algorithms::hex::offset_distance(a.0, a.1, b.0, b.1) as u32 +} + +// ── dispatch_courier ────────────────────────────────────────────────────────── + +/// Populate `route.planned_path`, `route.path_step`, and `route.eta_turn` using +/// A* from the sender capital to the receiver capital. +/// +/// Call this once when a `SharedMapAgreement` is first signed and a courier unit +/// is dispatched. If the grid is absent (headless test mode) or no path exists, +/// the path is left empty and `eta_turn` is set via straight-line distance. +pub fn dispatch_courier(state: &GameState, route: &mut CourierRoute, current_turn: u32) { + let sender_pos = state + .players + .get(route.sender as usize) + .and_then(|p| p.capital_position); + let receiver_pos = state + .players + .get(route.receiver as usize) + .and_then(|p| p.capital_position); + + let (Some(src), Some(dst)) = (sender_pos, receiver_pos) else { + return; + }; + + if let Some(path) = astar_path(state, src, dst, route.courier_era_tier) { + let path_steps = path.len().saturating_sub(1); // steps = nodes - 1 + let eta = eta_turns(path_steps, route.courier_era_tier); + route.planned_path = path; + route.path_step = 0; + route.position = src; + route.eta_turn = Some(current_turn + eta); + } else { + // Fallback: straight-line distance estimate (no path found). + let dist = hex_dist(src, dst) as usize; + let eta = eta_turns(dist, route.courier_era_tier); + route.position = src; + route.eta_turn = Some(current_turn + eta); + } +} + +// ── GameStateMapView ────────────────────────────────────────────────────────── + +const ADAMANTINE_ECHO_ID: &str = "adamantine_echo"; + +/// Improvement IDs that gate the courier route and sever it when pillaged or destroyed. +const SEVERABLE_INFRA: &[(&str, u8)] = &[ + ("beacon_tower", 6), // era_6: killable structure (hp=35), severable=false but destroyable + ("steam_track", 7), // era_7: severable=true (pillage suspends) + ("resonance_wire", 8), // era_8: severable=true (pillage suspends) + ("hold_network_citadel", 9), // era_9: mesh reroute handled separately +]; + +/// Real `CourierMapView` backed by `GameState`. +/// +/// `intercept_chance_at` returns 0.0 — terrain random intercept is not modeled. +/// `route_intact` checks the planned path for severed/destroyed infrastructure tiles. +pub struct GameStateMapView<'a> { + pub state: &'a GameState, +} + +impl<'a> CourierMapView for GameStateMapView<'a> { + fn capital_position(&self, player: u8) -> Option<(i32, i32)> { + self.state + .players + .get(player as usize) + .and_then(|p| p.capital_position) + } + + /// Returns 0.0 — terrain random intercept not modeled; severance is p3-04. + fn intercept_chance_at(&self, _pos: (i32, i32), _courier_owner: u8) -> f32 { + 0.0 + } + + /// Returns false if any hex on the courier's planned path has a severed or + /// destroyed infrastructure improvement that the courier's era tier requires. + /// + /// Hold-Network exception: if both sender and receiver have a + /// `hold_network_citadel` wonder, the mesh reroutes and the route stays intact. + fn route_intact(&self, route: &CourierRoute) -> bool { + if route.planned_path.is_empty() { + return true; + } + + let required_infra: Option<&str> = SEVERABLE_INFRA + .iter() + .find(|(_, era)| *era == route.courier_era_tier) + .map(|(id, _)| *id); + + let Some(infra_id) = required_infra else { + return true; + }; + + for &(col, row) in &route.planned_path[route.path_step..] { + let key = (col as u16, row as u16); + if let Some(imp) = self.state.tile_improvements.get(&key) { + if imp.id == infra_id { + let severed = (imp.severable && imp.pillaged) + || (!imp.severable && imp.hp == 0); + if severed { + // Hold-Network mesh reroute: both players have the citadel wonder. + let citadel = WonderId::new("hold_network_citadel"); + let both_have_citadel = self.state + .players + .get(route.sender as usize) + .map(|p| p.wonders_built.contains_key(&citadel)) + .unwrap_or(false) + && self.state + .players + .get(route.receiver as usize) + .map(|p| p.wonders_built.contains_key(&citadel)) + .unwrap_or(false); + if !both_have_citadel { + return false; + } + } + } + } + } + true + } + + fn movement_per_turn(&self, era_tier: u8) -> u32 { + movement_per_turn(era_tier) + } + + fn adamantine_echo_active(&self, player_a: u8, player_b: u8) -> bool { + let wonder = WonderId::new(ADAMANTINE_ECHO_ID); + let has = |p: u8| { + self.state + .players + .get(p as usize) + .map(|ps| ps.wonders_built.contains_key(&wonder)) + .unwrap_or(false) + }; + has(player_a) && has(player_b) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use mc_trade::{ + CourierRoute, DiplomacyEvent, DiplomaticAgreement, SharedMapAgreement, TradeLedger, + step_shared_map_agreements, + }; + + // Minimal deterministic RNG for tests. + struct ConstRng; + impl rand::RngCore for ConstRng { + fn next_u32(&mut self) -> u32 { 0 } + fn next_u64(&mut self) -> u64 { 0 } + fn fill_bytes(&mut self, dest: &mut [u8]) { dest.fill(0); } + } + + fn make_state_with_capitals( + w: i32, + h: i32, + cap_a: (i32, i32), + cap_b: (i32, i32), + ) -> GameState { + use mc_core::grid::GridState; + use crate::game_state::PlayerState; + + let mut state = GameState::default(); + state.grid = Some(GridState::new(w, h)); + + let mut pa = PlayerState::default(); + pa.player_index = 0; + pa.capital_position = Some(cap_a); + + let mut pb = PlayerState::default(); + pb.player_index = 1; + pb.capital_position = Some(cap_b); + + state.players = vec![pa, pb]; + state + } + + fn make_courier_route(sender: u8, receiver: u8, era_tier: u8, pos: (i32, i32)) -> CourierRoute { + CourierRoute { + sender, + receiver, + courier_era_tier: era_tier, + dispatched_turn: 1, + position: pos, + eta_turn: None, + delivered: false, + intercepted: false, + planned_path: Vec::new(), + path_step: 0, + } + } + + /// Foot runner from (0,0) to (4,0): path length 4, movement 1/turn → ETA 4 turns. + #[test] + fn foot_runner_eta_matches_movement_table() { + let state = make_state_with_capitals(10, 10, (0, 0), (4, 0)); + let mut route = make_courier_route(0, 1, 2, (0, 0)); + dispatch_courier(&state, &mut route, 1); + + assert!(!route.planned_path.is_empty(), "path must be computed"); + let path_steps = route.planned_path.len().saturating_sub(1); + let expected_eta = 1 + eta_turns(path_steps, 2); + assert_eq!(route.eta_turn, Some(expected_eta), "ETA should match movement table"); + } + + /// Tier upgrade: same capitals, era_5 courier has lower ETA than era_2. + #[test] + fn tier_upgrade_shrinks_eta() { + let state = make_state_with_capitals(10, 10, (0, 0), (6, 0)); + + let mut route_era2 = make_courier_route(0, 1, 2, (0, 0)); + dispatch_courier(&state, &mut route_era2, 1); + + let mut route_era5 = make_courier_route(0, 1, 5, (0, 0)); + dispatch_courier(&state, &mut route_era5, 1); + + let eta_era2 = route_era2.eta_turn.unwrap(); + let eta_era5 = route_era5.eta_turn.unwrap(); + assert!(eta_era5 < eta_era2, "era_5 ETA ({eta_era5}) must be < era_2 ETA ({eta_era2})"); + } + + /// Adamantine Echo: agreement delivers on the first step regardless of distance. + #[test] + fn adamantine_echo_delivers_instantly() { + let mut state = make_state_with_capitals(10, 10, (0, 0), (9, 0)); + + // Give both players the Adamantine Echo wonder. + state.players[0].wonders_built.insert(WonderId::new("adamantine_echo"), 10); + state.players[1].wonders_built.insert(WonderId::new("adamantine_echo"), 10); + + let map = GameStateMapView { state: &state }; + let mut rng = ConstRng; + + let mut ledger = TradeLedger::default(); + let id = ledger.alloc_agreement_id(); + ledger.agreements.push(DiplomaticAgreement::SharedMap(SharedMapAgreement { + agreement_id: id, + partners: (0, 1), + turn_started: 1, + duration: 3, + share_turns_remaining: 0, + payment_gold: 10, + payment_luxury: None, + courier_route: Some(make_courier_route(0, 1, 2, (0, 0))), + })); + + let events = step_shared_map_agreements(&mut ledger, &map, &mut rng); + assert_eq!(events.len(), 1); + assert!( + matches!(&events[0], DiplomacyEvent::SharedMapDelivered(e) if e.agreement_id == id), + "expected SharedMapDelivered, got: {events:?}" + ); + } + + /// Severance: pillaging a Steam Track hex mid-route → intercept on next step. + /// Payment is retained (agreement stays, intercepted=true). + #[test] + fn steam_track_pillage_intercepts_on_next_step() { + use mc_core::improvement::TileImprovement; + use std::collections::BTreeSet; + + let mut state = make_state_with_capitals(10, 10, (0, 0), (6, 0)); + + // Place a Steam Track along the expected path at (3, 0). + state.tile_improvements.insert( + (3u16, 0u16), + TileImprovement { + id: "steam_track".to_string(), + hp: 0, + severable: true, + pillaged: false, + flags: BTreeSet::new(), + }, + ); + + let mut route = make_courier_route(0, 1, 7, (0, 0)); + dispatch_courier(&state, &mut route, 1); + assert!(!route.planned_path.is_empty()); + + // Route is intact before pillage. + { + let map = GameStateMapView { state: &state }; + assert!(map.route_intact(&route)); + } + + // Courier steps along path a bit (advance path_step so courier is mid-route). + // Move route.path_step forward past start. + let mut ledger = TradeLedger::default(); + let id = ledger.alloc_agreement_id(); + let agreement_route = { + let mut r = route.clone(); + r.path_step = 0; + r + }; + ledger.agreements.push(DiplomaticAgreement::SharedMap(SharedMapAgreement { + agreement_id: id, + partners: (0, 1), + turn_started: 1, + duration: 5, + share_turns_remaining: 0, + payment_gold: 20, + payment_luxury: None, + courier_route: Some(agreement_route), + })); + + // Step once to advance the courier (intact route). + { + let map = GameStateMapView { state: &state }; + let _ = step_shared_map_agreements(&mut ledger, &map, &mut ConstRng); + } + + // Now pillage the Steam Track. + state.tile_improvements.get_mut(&(3u16, 0u16)).unwrap().pillaged = true; + + // Next step: route_intact returns false → CourierIntercepted emitted. + let events = { + let map = GameStateMapView { state: &state }; + step_shared_map_agreements(&mut ledger, &map, &mut ConstRng) + }; + assert!( + events.iter().any(|e| matches!(e, DiplomacyEvent::CourierIntercepted(ci) if ci.agreement_id == id)), + "expected CourierIntercepted after pillage, got: {events:?}" + ); + + // Agreement retained; payment kept. + assert_eq!(ledger.agreements.len(), 1, "agreement stays (payment retained)"); + if let DiplomaticAgreement::SharedMap(sm) = &ledger.agreements[0] { + let r = sm.courier_route.as_ref().unwrap(); + assert!(r.intercepted); + assert!(!r.delivered); + } + } + + /// Hold-Network reroute: when Steam Track is severed but both players have + /// Hold-Network Citadel wonders, the route stays intact (mesh reroutes). + /// NOTE: Hold-Network Citadel is era_9; era_7 Steam Messenger is severed + /// but era_9 warden (which owns the citadel mesh) would handle reroute. + /// This test models the spec: severance + both-have-citadel → route intact. + #[test] + fn hold_network_reroutes_around_severed_steam_track() { + use mc_core::improvement::TileImprovement; + use std::collections::BTreeSet; + + let mut state = make_state_with_capitals(10, 10, (0, 0), (4, 0)); + + // Both players have Hold-Network Citadel wonders. + state.players[0].wonders_built.insert(WonderId::new("hold_network_citadel"), 9); + state.players[1].wonders_built.insert(WonderId::new("hold_network_citadel"), 9); + + // Severed steam track at (2,0). + state.tile_improvements.insert( + (2u16, 0u16), + TileImprovement { + id: "steam_track".to_string(), + hp: 0, + severable: true, + pillaged: true, + flags: BTreeSet::new(), + }, + ); + + let mut route = make_courier_route(0, 1, 7, (0, 0)); + dispatch_courier(&state, &mut route, 1); + + let map = GameStateMapView { state: &state }; + // With Hold-Network mesh: route_intact returns true (rerouted). + assert!( + map.route_intact(&route), + "Hold-Network should keep route intact despite severed Steam Track" + ); + } + + /// Real-map pathfinding: courier uses A* path, not straight-line. + /// 10×10 grid, capitals at (0,0) and (4,0). After dispatch, the path + /// runs along the grid, and stepping the agreement moves by movement_per_turn. + #[test] + fn real_map_courier_uses_path() { + let state = make_state_with_capitals(10, 10, (0, 0), (4, 0)); + let map = GameStateMapView { state: &state }; + let mut rng = ConstRng; + + let mut route = make_courier_route(0, 1, 2, (0, 0)); + dispatch_courier(&state, &mut route, 1); + assert!(!route.planned_path.is_empty(), "path must be populated by dispatch"); + assert_eq!(route.planned_path[0], (0, 0), "path starts at sender capital"); + assert_eq!(*route.planned_path.last().unwrap(), (4, 0), "path ends at receiver capital"); + + // Wrap in a ledger and step until delivered. + let mut ledger = TradeLedger::default(); + let id = ledger.alloc_agreement_id(); + ledger.agreements.push(DiplomaticAgreement::SharedMap(SharedMapAgreement { + agreement_id: id, + partners: (0, 1), + turn_started: 1, + duration: 4, + share_turns_remaining: 0, + payment_gold: 10, + payment_luxury: None, + courier_route: Some(route), + })); + + let mut delivered = false; + for _ in 0..20 { + let events = step_shared_map_agreements(&mut ledger, &map, &mut rng); + if events.iter().any(|e| matches!(e, DiplomacyEvent::SharedMapDelivered(_))) { + delivered = true; + break; + } + } + assert!(delivered, "courier must reach destination along the planned path"); + } +} diff --git a/src/simulator/crates/mc-turn/src/improvement_tests.rs b/src/simulator/crates/mc-turn/src/improvement_tests.rs new file mode 100644 index 00000000..5bdbac9b --- /dev/null +++ b/src/simulator/crates/mc-turn/src/improvement_tests.rs @@ -0,0 +1,103 @@ +//! Unit tests for the per-hex improvement layer (p3-04). + +use crate::game_state::GameState; +use mc_core::improvement::TileImprovementSpec; +use std::collections::BTreeSet; + +fn severable_spec(id: &str) -> TileImprovementSpec { + TileImprovementSpec { + id: id.to_string(), + hp: 10, + severable: true, + flags: BTreeSet::from(["courier_infrastructure".to_string()]), + } +} + +fn nonseverable_spec(id: &str) -> TileImprovementSpec { + TileImprovementSpec { + id: id.to_string(), + hp: 35, + severable: false, + flags: BTreeSet::from(["destroyable_structure".to_string()]), + } +} + +#[test] +fn improvement_at_empty_grid_returns_none() { + let gs = GameState::default(); + assert!(gs.improvement_at(5, 3).is_none()); +} + +#[test] +fn set_improvement_and_improvement_at_round_trip() { + let mut gs = GameState::default(); + let spec = severable_spec("steam_track"); + gs.set_improvement(4, 7, &spec); + + let imp = gs.improvement_at(4, 7).expect("improvement must be present"); + assert_eq!(imp.id, "steam_track"); + assert_eq!(imp.hp, 10); + assert!(imp.severable); + assert!(!imp.pillaged); + assert!(imp.flags.contains("courier_infrastructure")); + + // Different hex still empty. + assert!(gs.improvement_at(4, 8).is_none()); +} + +#[test] +fn pillage_severable_sets_pillaged_flag_and_returns_true() { + let mut gs = GameState::default(); + gs.set_improvement(2, 2, &severable_spec("resonance_wire")); + + let result = gs.pillage_improvement(2, 2); + assert!(result, "pillage of severable must return true"); + + let imp = gs.improvement_at(2, 2).expect("severable stays after pillage"); + assert!(imp.pillaged); +} + +#[test] +fn pillage_nonseverable_destroys_improvement_and_returns_false() { + let mut gs = GameState::default(); + gs.set_improvement(1, 1, &nonseverable_spec("beacon_tower")); + + let result = gs.pillage_improvement(1, 1); + assert!(!result, "pillage of non-severable must return false"); + assert!( + gs.improvement_at(1, 1).is_none(), + "non-severable must be removed after pillage" + ); +} + +#[test] +fn pillage_empty_hex_returns_false() { + let mut gs = GameState::default(); + assert!(!gs.pillage_improvement(9, 9)); +} + +#[test] +fn remove_improvement_clears_hex() { + let mut gs = GameState::default(); + gs.set_improvement(3, 3, &severable_spec("tunnel")); + assert!(gs.improvement_at(3, 3).is_some()); + gs.remove_improvement(3, 3); + assert!(gs.improvement_at(3, 3).is_none()); +} + +#[test] +fn serde_round_trip_with_improvements() { + let mut gs = GameState::default(); + gs.set_improvement(0, 0, &severable_spec("steam_track")); + gs.set_improvement(10, 5, &nonseverable_spec("beacon_tower")); + gs.pillage_improvement(0, 0); + + let json = serde_json::to_string(&gs).expect("serialize must succeed"); + let restored: GameState = serde_json::from_str(&json).expect("deserialize must succeed"); + + let st = restored.improvement_at(0, 0).expect("steam_track present"); + assert!(st.pillaged); + let bt = restored.improvement_at(10, 5).expect("beacon_tower present"); + assert!(!bt.severable); + assert!(restored.improvement_at(1, 1).is_none()); +}