feat(@projects/@magic-civilization): add move validation logic for courier diplomacy

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-27 05:59:45 -07:00
parent 21c3436251
commit 76cd3a70de
2 changed files with 107 additions and 1 deletions

View file

@ -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

View file

@ -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<EdgeId, MoveBlockedReason> {
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