test(mc-turn): Add and update tests for courier resolver logic and improvement mechanics in the simulator

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-28 15:46:47 -07:00
parent adfecb0e96
commit 453189ec02
2 changed files with 676 additions and 0 deletions

View file

@ -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<u32> {
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<Vec<(i32, i32)>> {
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<Reverse<(u32, u32, (i32, i32))>> = 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");
}
}

View file

@ -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());
}