feat(@projects/@magic-civilization): ✨ add move validation logic for courier diplomacy
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
21c3436251
commit
76cd3a70de
2 changed files with 107 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue