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:
parent
adfecb0e96
commit
453189ec02
2 changed files with 676 additions and 0 deletions
573
src/simulator/crates/mc-turn/src/courier_resolver.rs
Normal file
573
src/simulator/crates/mc-turn/src/courier_resolver.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
103
src/simulator/crates/mc-turn/src/improvement_tests.rs
Normal file
103
src/simulator/crates/mc-turn/src/improvement_tests.rs
Normal 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());
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue