From 76cd3a70de1e8b38e2bb87d5f41bc4f623c759fa Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 27 Apr 2026 05:59:45 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20move=20validation=20logic=20for=20courier=20d?= =?UTF-8?q?iplomacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../objectives/p3-01-courier-diplomacy.md | 3 +- src/simulator/crates/mc-core/src/grid/mod.rs | 105 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/.project/objectives/p3-01-courier-diplomacy.md b/.project/objectives/p3-01-courier-diplomacy.md index 0817b926..b63161a6 100644 --- a/.project/objectives/p3-01-courier-diplomacy.md +++ b/.project/objectives/p3-01-courier-diplomacy.md @@ -9,7 +9,8 @@ updated_at: 2026-04-27 evidence: - .project/AGE-OF-DWARVES-FEATURES.md (items 59a, 59b) - public/games/age-of-dwarves/data/eras.json (10-era spine the courier tiers track) - - src/simulator/crates/mc-trade/src/lib.rs (existing luxury↔gold trade engine to extend) + - src/simulator/crates/mc-trade/src/lib.rs (DiplomaticAgreement enum + OpenBorders + SharedMap + CourierRoute + step_shared_map_agreements, c4) + - src/simulator/crates/mc-trade/tests/courier_lifecycle.rs (3 lifecycle integration tests, c4) --- ## Summary diff --git a/src/simulator/crates/mc-core/src/grid/mod.rs b/src/simulator/crates/mc-core/src/grid/mod.rs index 1f59339a..b0a739a7 100644 --- a/src/simulator/crates/mc-core/src/grid/mod.rs +++ b/src/simulator/crates/mc-core/src/grid/mod.rs @@ -12,6 +12,19 @@ pub use edge::{ canonical_edge, edges_of_hex, hexes_of_edge, reverse_dir, EdgeFeatures, EdgeId, EdgeOccupant, }; +/// Reasons a centre-to-centre move can be rejected by +/// [`GridState::validate_centre_to_centre_move`]. Distinct variants so the +/// caller (UI, AI, combat) can route on the cause. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum MoveBlockedReason { + /// `from` and `to` are not adjacent hexes. + NotAdjacent, + /// An enemy wall on the shared edge blocks the move. + WallBlocks, + /// A hostile unit stands on the shared edge. + EdgeOccupied, +} + /// Ley magic school affinity. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -381,6 +394,48 @@ impl GridState { (row * self.width + col) as usize } + /// Validate a single-step centre-to-centre move from `from` to `to` for + /// `player_id`. Hexes are addressed in axial `(q, r)` coords. + /// + /// Returns `Ok(EdgeId)` of the traversed edge if the move is legal, or + /// `Err(MoveBlockedReason)` describing why it isn't. The callsite gets + /// the edge id for free so combat / UI feedback can address the block. + /// + /// This is the primitive the eventual A* pathfinder will call per + /// neighbour expansion; it composes [`Self::is_edge_passable_for`] with + /// an adjacency check so callers don't have to recompute the direction. + pub fn validate_centre_to_centre_move( + &self, + from: (i32, i32), + to: (i32, i32), + player_id: u32, + ) -> Result { + let dir = match crate::algorithms::hex::AXIAL_DIRECTIONS + .iter() + .position(|&(dq, dr)| (from.0 + dq, from.1 + dr) == to) + { + Some(d) => d as u8, + None => return Err(MoveBlockedReason::NotAdjacent), + }; + let edge = canonical_edge(from, dir); + if self.is_edge_passable_for(edge, player_id) { + Ok(edge) + } else { + // Distinguish wall blockage from occupant blockage so the caller + // can render the right cause to the player. + let wall_blocks = self + .edge_features + .get(&edge) + .and_then(|f| f.wall_owner) + .is_some_and(|owner| owner != player_id); + if wall_blocks { + Err(MoveBlockedReason::WallBlocks) + } else { + Err(MoveBlockedReason::EdgeOccupied) + } + } + } + /// Can `player_id` traverse `edge`? Consults [`Self::edge_features`] for /// walls and [`Self::edges`] for hostile occupants per `HEX_GEOMETRY.md` /// §7 (centre-to-centre movement is blocked if an enemy stands on the @@ -625,6 +680,56 @@ mod tests { ); } + #[test] + fn validate_move_accepts_legal_step() { + let grid = GridState::new(4, 4); + let result = grid.validate_centre_to_centre_move((0, 0), (1, 0), 1); + assert!(result.is_ok(), "clean adjacent step must be Ok, got {result:?}"); + assert_eq!(result.unwrap(), canonical_edge((0, 0), 0)); + } + + #[test] + fn validate_move_rejects_non_adjacent() { + let grid = GridState::new(4, 4); + let result = grid.validate_centre_to_centre_move((0, 0), (3, 3), 1); + assert_eq!(result, Err(MoveBlockedReason::NotAdjacent)); + } + + #[test] + fn validate_move_rejects_wall_blockage_distinct_from_occupant() { + let mut grid = GridState::new(4, 4); + let edge = canonical_edge((0, 0), 0); + grid.edge_features.insert( + edge, + EdgeFeatures { + wall_owner: Some(2), + ..Default::default() + }, + ); + let result = grid.validate_centre_to_centre_move((0, 0), (1, 0), 1); + assert_eq!( + result, + Err(MoveBlockedReason::WallBlocks), + "wall must surface as WallBlocks, not EdgeOccupied" + ); + } + + #[test] + fn validate_move_rejects_enemy_occupant() { + let mut grid = GridState::new(4, 4); + let edge = canonical_edge((0, 0), 0); + grid.edges.insert( + edge, + EdgeOccupant { + unit_id: 7, + aligned_to: (1, 0), + owner_player_id: 2, + }, + ); + let result = grid.validate_centre_to_centre_move((0, 0), (1, 0), 1); + assert_eq!(result, Err(MoveBlockedReason::EdgeOccupied)); + } + #[test] fn migrate_preserves_existing_non_river_features() { // edge_features may already carry road/bridge/wall data from scenario