Update source files

This commit is contained in:
2025-10-25 16:15:50 -05:00
commit 635712304f
215 changed files with 32973 additions and 0 deletions

View File

@@ -0,0 +1,269 @@
//! Fluent assertion API for testing Bevy ECS World state
//!
//! Provides chainable assertion methods that test behavior through public interfaces.
use assert2::assert;
use extension_traits::extension;
use std::collections::HashSet;
use borders_core::prelude::*;
/// Fluent assertion builder for World state
///
/// Access via `world.assert()` to chain multiple assertions.
///
/// # Example
/// ```
/// world.assert()
/// .player_owns(tile, player_id)
/// .has_territory_changes()
/// .no_invalid_state();
/// ```
pub struct WorldAssertions<'w> {
world: &'w World,
}
/// Extension trait to add assertion capabilities to World
#[extension(pub trait WorldAssertExt)]
impl World {
/// Begin a fluent assertion chain
fn assert(&self) -> WorldAssertions<'_> {
WorldAssertions { world: self }
}
}
impl<'w> WorldAssertions<'w> {
/// Assert that a player owns a specific tile
#[track_caller]
pub fn player_owns(self, tile: U16Vec2, expected: NationId) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
let actual = mgr.get_nation_id(tile);
assert!(actual == Some(expected), "Expected player {} to own tile {:?}, but found {:?}", expected.get(), tile, actual.map(|id| id.get()));
self
}
/// Assert that a tile is unclaimed
#[track_caller]
pub fn tile_unclaimed(self, tile: U16Vec2) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
let ownership = mgr.get_ownership(tile);
assert!(ownership.is_unclaimed(), "Expected tile {:?} to be unclaimed, but it's owned by {:?}", tile, ownership.nation_id());
self
}
/// Assert that an attack exists between attacker and target
#[track_caller]
pub fn attack_exists(self, attacker: NationId, target: Option<NationId>) -> Self {
let attacks = self.world.resource::<ActiveAttacks>();
let player_attacks = attacks.get_attacks_for_player(attacker);
let exists = player_attacks.iter().any(|(att, tgt, _, _, is_outgoing)| *att == attacker && *tgt == target && *is_outgoing);
assert!(exists, "Expected attack from player {} to {:?}, but none found. Active attacks: {:?}", attacker.get(), target.map(|t| t.get()), player_attacks);
self
}
/// Assert that no attack exists between attacker and target
#[track_caller]
pub fn no_attack(self, attacker: NationId, target: Option<NationId>) -> Self {
let attacks = self.world.resource::<ActiveAttacks>();
let player_attacks = attacks.get_attacks_for_player(attacker);
let exists = player_attacks.iter().any(|(att, tgt, _, _, is_outgoing)| *att == attacker && *tgt == target && *is_outgoing);
assert!(!exists, "Expected NO attack from player {} to {:?}, but found one. Active attacks: {:?}", attacker.get(), target.map(|t| t.get()), player_attacks);
self
}
/// Assert that a player has specific border tiles
#[track_caller]
pub fn border_tiles(self, player_id: NationId, expected: &HashSet<U16Vec2>) -> Self {
let entity_map = self.world.resource::<borders_core::game::PlayerEntityMap>();
let entity = entity_map.0.get(&player_id).expect("Player entity not found");
let border_tiles = self.world.get::<BorderTiles>(*entity).expect("BorderTiles component not found");
assert!(&border_tiles.0 == expected, "Border tiles mismatch for player {}.\nExpected: {:?}\nActual: {:?}", player_id.get(), expected, border_tiles.0);
self
}
/// Assert that TerritoryManager has recorded changes
#[track_caller]
pub fn has_territory_changes(self) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
assert!(mgr.has_changes(), "Expected territory changes to be tracked, but ChangeBuffer is empty");
self
}
/// Assert that TerritoryManager has NO recorded changes
#[track_caller]
pub fn no_territory_changes(self) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
assert!(!mgr.has_changes(), "Expected no territory changes, but ChangeBuffer contains: {:?}", mgr.iter_changes().collect::<Vec<_>>());
self
}
/// Assert that a resource exists in the world
#[track_caller]
pub fn resource_exists<T: Resource>(self, resource_name: &str) -> Self {
assert!(self.world.contains_resource::<T>(), "Expected resource '{}' to exist, but it was not found", resource_name);
self
}
/// Assert that a resource does NOT exist in the world
#[track_caller]
pub fn resource_missing<T: Resource>(self, resource_name: &str) -> Self {
assert!(!self.world.contains_resource::<T>(), "Expected resource '{}' to be missing, but it exists", resource_name);
self
}
/// Assert that no invalid game state exists
///
/// Checks common invariants:
/// - No self-attacks in ActiveAttacks
/// - All player entities have required components
/// - Territory ownership is consistent
#[track_caller]
pub fn no_invalid_state(self) -> Self {
let attacks = self.world.resource::<ActiveAttacks>();
let entity_map = self.world.resource::<borders_core::game::PlayerEntityMap>();
for &player_id in entity_map.0.keys() {
let player_attacks = attacks.get_attacks_for_player(player_id);
for (attacker, target, _, _, _) in player_attacks {
if let Some(target_id) = target {
assert!(attacker != target_id, "INVARIANT VIOLATION: Player {} is attacking itself", attacker.get());
}
}
}
for (&player_id, &entity) in &entity_map.0 {
assert!(self.world.get::<borders_core::game::Troops>(entity).is_some(), "Player {} entity missing Troops component", player_id.get());
assert!(self.world.get::<BorderTiles>(entity).is_some(), "Player {} entity missing BorderTiles component", player_id.get());
}
self
}
/// Assert that a player has a specific troop count
#[track_caller]
pub fn player_troops(self, player_id: NationId, expected: f32) -> Self {
let entity_map = self.world.resource::<borders_core::game::PlayerEntityMap>();
let entity = entity_map.0.get(&player_id).expect("Player entity not found");
let troops = self.world.get::<borders_core::game::Troops>(*entity).expect("Troops component not found");
let difference = (troops.0 - expected).abs();
assert!(difference < 0.01, "Expected player {} to have {} troops, but found {} (difference: {})", player_id.get(), expected, troops.0, difference);
self
}
/// Assert that a tile is a border tile
#[allow(clippy::wrong_self_convention)]
#[track_caller]
pub fn is_border(self, tile: U16Vec2) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
assert!(mgr.is_border(tile), "Expected tile {:?} to be a border tile, but it is not", tile);
self
}
/// Assert that a tile is NOT a border tile
#[track_caller]
pub fn not_border(self, tile: U16Vec2) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
assert!(!mgr.is_border(tile), "Expected tile {:?} to NOT be a border tile, but it is", tile);
self
}
/// Assert that a player owns all tiles in a slice
#[track_caller]
pub fn player_owns_all(self, tiles: &[U16Vec2], expected: NationId) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
for &tile in tiles {
let actual = mgr.get_nation_id(tile);
assert!(actual == Some(expected), "Expected player {} to own tile {:?}, but found {:?}", expected.get(), tile, actual.map(|id| id.get()));
}
self
}
/// Assert that the ChangeBuffer contains exactly N changes
#[track_caller]
pub fn change_count(self, expected: usize) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
let actual = mgr.iter_changes().count();
assert!(actual == expected, "Expected {} changes in ChangeBuffer, but found {}", expected, actual);
self
}
/// Assert that a player entity has all standard game components
///
/// Checks for: PlayerName, PlayerColor, BorderTiles, Troops, TerritorySize
#[track_caller]
pub fn player_has_components(self, player_id: NationId) -> Self {
let entity_map = self.world.resource::<borders_core::game::PlayerEntityMap>();
let entity = entity_map.0.get(&player_id).unwrap_or_else(|| panic!("Player entity not found for player {}", player_id.get()));
assert!(self.world.get::<borders_core::game::PlayerName>(*entity).is_some(), "Player {} missing PlayerName component", player_id.get());
assert!(self.world.get::<borders_core::game::PlayerColor>(*entity).is_some(), "Player {} missing PlayerColor component", player_id.get());
assert!(self.world.get::<BorderTiles>(*entity).is_some(), "Player {} missing BorderTiles component", player_id.get());
assert!(self.world.get::<borders_core::game::Troops>(*entity).is_some(), "Player {} missing Troops component", player_id.get());
assert!(self.world.get::<borders_core::game::TerritorySize>(*entity).is_some(), "Player {} missing TerritorySize component", player_id.get());
self
}
/// Assert that the map has specific dimensions
#[track_caller]
pub fn map_dimensions(self, width: u16, height: u16) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
assert!(mgr.width() == width, "Expected map width {}, but found {}", width, mgr.width());
assert!(mgr.height() == height, "Expected map height {}, but found {}", height, mgr.height());
assert!(mgr.len() == (width as usize) * (height as usize), "Expected map size {}, but found {}", (width as usize) * (height as usize), mgr.len());
self
}
/// Assert that BorderCache is synchronized with ECS BorderTiles component
#[track_caller]
pub fn border_cache_synced(self, player_id: NationId) -> Self {
let entity_map = self.world.resource::<borders_core::game::PlayerEntityMap>();
let entity = entity_map.0.get(&player_id).expect("Player entity not found");
let component_borders = self.world.get::<BorderTiles>(*entity).expect("BorderTiles component not found");
let cache = self.world.resource::<BorderCache>();
let cache_borders = cache.get(player_id);
match cache_borders {
Some(cached) => {
assert!(&component_borders.0 == cached, "BorderCache out of sync with BorderTiles component for player {}.\nECS component: {:?}\nCache: {:?}", player_id.get(), component_borders.0, cached);
}
None => {
assert!(component_borders.0.is_empty(), "BorderCache missing entry for player {} but component has {} borders", player_id.get(), component_borders.0.len());
}
}
self
}
/// Assert that a player has NO border tiles
#[track_caller]
pub fn no_border_tiles(self, player_id: NationId) -> Self {
let entity_map = self.world.resource::<borders_core::game::PlayerEntityMap>();
let entity = entity_map.0.get(&player_id).expect("Player entity not found");
let border_tiles = self.world.get::<BorderTiles>(*entity).expect("BorderTiles component not found");
assert!(border_tiles.0.is_empty(), "Expected player {} to have no border tiles, but found {} tiles: {:?}", player_id.get(), border_tiles.0.len(), border_tiles.0);
self
}
/// Assert that a player has a specific number of border tiles
#[track_caller]
pub fn border_count(self, player_id: NationId, expected: usize) -> Self {
let entity_map = self.world.resource::<borders_core::game::PlayerEntityMap>();
let entity = entity_map.0.get(&player_id).expect("Player entity not found");
let border_tiles = self.world.get::<BorderTiles>(*entity).expect("BorderTiles component not found");
assert!(border_tiles.0.len() == expected, "Expected player {} to have {} border tiles, but found {}", player_id.get(), expected, border_tiles.0.len());
self
}
}

View File

@@ -0,0 +1,233 @@
/// Test builders for creating Bevy World and game state
///
/// Provides fluent API for constructing test environments with minimal boilerplate.
use borders_core::prelude::*;
pub struct TestWorld {
map_width: u16,
map_height: u16,
players: Vec<(NationId, f32, Option<String>)>, // (id, troops, name)
territories: Vec<(NationId, Vec<U16Vec2>)>,
terrain: Option<TerrainData>,
spawn_phase_active: bool,
rng_seed: u64,
}
impl TestWorld {
/// Create a new test world builder with default settings
pub fn new() -> Self {
Self { map_width: 100, map_height: 100, players: Vec::new(), territories: Vec::new(), terrain: None, spawn_phase_active: false, rng_seed: 0xDEADBEEF }
}
/// Set the map size
pub fn with_map_size(mut self, width: u16, height: u16) -> Self {
self.map_width = width;
self.map_height = height;
self
}
/// Add a player with specified troops
pub fn with_player(mut self, id: NationId, troops: f32) -> Self {
self.players.push((id, troops, None));
self
}
/// Add a named player
pub fn with_named_player(mut self, id: NationId, troops: f32, name: String) -> Self {
self.players.push((id, troops, Some(name)));
self
}
/// Set territory ownership for a player
pub fn with_territory(mut self, id: NationId, tiles: &[U16Vec2]) -> Self {
self.territories.push((id, tiles.to_vec()));
self
}
/// Provide custom terrain data
pub fn with_terrain(mut self, terrain: TerrainData) -> Self {
self.terrain = Some(terrain);
self
}
/// Activate spawn phase
pub fn with_spawn_phase(mut self) -> Self {
self.spawn_phase_active = true;
self
}
/// Set RNG seed for deterministic tests
pub fn with_rng_seed(mut self, seed: u64) -> Self {
self.rng_seed = seed;
self
}
/// Build the World with all configured state
pub fn build(self) -> World {
let mut world = World::new();
// Initialize Time resources (required by many systems)
world.insert_resource(Time::default());
world.insert_resource(FixedTime::from_seconds(0.1));
// Generate or use provided terrain
let terrain = self.terrain.unwrap_or_else(|| MapBuilder::new(self.map_width, self.map_height).all_conquerable().build());
// Initialize TerritoryManager
let mut territory_manager = TerritoryManager::new(self.map_width, self.map_height);
let conquerable_tiles: Vec<bool> = {
let mut tiles = Vec::with_capacity((self.map_width as usize) * (self.map_height as usize));
for y in 0..self.map_height {
for x in 0..self.map_width {
tiles.push(terrain.is_conquerable(U16Vec2::new(x, y)));
}
}
tiles
};
territory_manager.reset(self.map_width, self.map_height, &conquerable_tiles);
// Apply territory ownership
for (nation_id, tiles) in &self.territories {
for &tile in tiles {
territory_manager.conquer(tile, *nation_id);
}
}
// Create player entities
let mut entity_map = PlayerEntityMap::default();
for (i, (nation_id, troops, name)) in self.players.iter().enumerate() {
let player_name = name.clone().unwrap_or_else(|| format!("Player {}", nation_id.get()));
let color = HSLColor::new(
(i as f32 * 137.5) % 360.0, // golden angle distribution
0.6,
0.5,
);
let entity = world.spawn((*nation_id, PlayerName(player_name), PlayerColor(color), BorderTiles::default(), Troops(*troops), TerritorySize(0), borders_core::game::ships::ShipCount::default())).id();
entity_map.0.insert(*nation_id, entity);
}
// Insert core resources
world.insert_resource(entity_map);
world.insert_resource(territory_manager);
world.insert_resource(ActiveAttacks::new());
world.insert_resource(terrain);
world.insert_resource(DeterministicRng::new(self.rng_seed));
world.insert_resource(BorderCache::default());
world.insert_resource(AttackControls::default());
world.insert_resource(ClientPlayerId(NationId::ZERO));
world.insert_resource(HumanPlayerCount(1));
world.insert_resource(LocalPlayerContext::new(NationId::ZERO));
// Optional spawn phase
if self.spawn_phase_active {
world.insert_resource(SpawnPhase { active: true });
world.insert_resource(SpawnTimeout::new(30.0));
}
// Compute coastal tiles
let map_size = U16Vec2::new(self.map_width, self.map_height);
let coastal_tiles = CoastalTiles::compute(world.resource::<TerrainData>(), map_size);
world.insert_resource(coastal_tiles);
world
}
}
impl Default for TestWorld {
fn default() -> Self {
Self::new()
}
}
/// Builder for programmatic terrain generation
///
/// # Example
/// ```
/// let terrain = MapBuilder::new(100, 100)
/// .all_conquerable()
/// .build();
/// ```
pub struct MapBuilder {
width: u16,
height: u16,
terrain_data: Vec<u8>,
tile_types: Vec<TileType>,
}
impl MapBuilder {
/// Create a new map builder
pub fn new(width: u16, height: u16) -> Self {
let size = (width as usize) * (height as usize);
Self { width, height, terrain_data: vec![0; size], tile_types: Vec::new() }
}
/// Make all tiles land and conquerable
pub fn all_conquerable(mut self) -> Self {
let size = (self.width as usize) * (self.height as usize);
self.terrain_data = vec![0x80; size]; // bit 7 = land/conquerable
self.tile_types = vec![TileType { name: "water".to_string(), color_base: "blue".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 255, expansion_cost: 255 }, TileType { name: "land".to_string(), color_base: "green".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }];
self
}
/// Create islands: center 50x50 land, rest water
pub fn islands(mut self) -> Self {
self.tile_types = vec![TileType { name: "water".to_string(), color_base: "blue".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 255, expansion_cost: 255 }, TileType { name: "land".to_string(), color_base: "green".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }];
let center_x = self.width / 2;
let center_y = self.height / 2;
let island_size = 25u16;
for y in 0..self.height {
for x in 0..self.width {
let idx = (y as usize) * (self.width as usize) + (x as usize);
let in_island = x.abs_diff(center_x) < island_size && y.abs_diff(center_y) < island_size;
self.terrain_data[idx] = if in_island { 0x80 } else { 0x00 };
}
}
self
}
/// Create continents: alternating vertical strips of land/water
pub fn continents(mut self) -> Self {
self.tile_types = vec![TileType { name: "water".to_string(), color_base: "blue".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 255, expansion_cost: 255 }, TileType { name: "land".to_string(), color_base: "green".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }];
for y in 0..self.height {
for x in 0..self.width {
let idx = (y as usize) * (self.width as usize) + (x as usize);
// 30-tile wide strips
let is_land = (x / 30) % 2 == 0;
self.terrain_data[idx] = if is_land { 0x80 } else { 0x00 };
}
}
self
}
/// Set specific tiles as land
pub fn with_land(mut self, tiles: &[U16Vec2]) -> Self {
for &tile in tiles {
let idx = (tile.y as usize) * (self.width as usize) + (tile.x as usize);
if idx < self.terrain_data.len() {
self.terrain_data[idx] = 0x80;
}
}
self
}
/// Build the TerrainData
pub fn build(self) -> TerrainData {
let tiles: Vec<u8> = self.terrain_data.iter().map(|&byte| if byte & 0x80 != 0 { 1 } else { 0 }).collect();
let num_land_tiles = tiles.iter().filter(|&&t| t == 1).count();
let terrain_tile_map = TileMap::from_vec(self.width, self.height, self.terrain_data);
TerrainData { _manifest: MapManifest { map: MapMetadata { size: U16Vec2::new(self.width, self.height), num_land_tiles }, name: "Test Map".to_string(), nations: Vec::new() }, terrain_data: terrain_tile_map, tiles, tile_types: self.tile_types }
}
}

View File

@@ -0,0 +1,33 @@
//! Pre-built test scenarios for integration tests
//!
//! These fixtures represent common complex scenarios used across multiple tests.
//! They are lazily initialized on first use.
use once_cell::sync::Lazy;
use std::sync::Arc;
use super::builders::MapBuilder;
use borders_core::prelude::*;
/// Standard 100x100 plains map (all conquerable)
pub static PLAINS_MAP: Lazy<Arc<TerrainData>> = Lazy::new(|| Arc::new(MapBuilder::new(100, 100).all_conquerable().build()));
/// Island archipelago map: 50x50 islands separated by water
pub static ISLAND_MAP: Lazy<Arc<TerrainData>> = Lazy::new(|| Arc::new(MapBuilder::new(100, 100).islands().build()));
/// Continental map: vertical strips of land and water
pub static CONTINENT_MAP: Lazy<Arc<TerrainData>> = Lazy::new(|| Arc::new(MapBuilder::new(100, 100).continents().build()));
/// Get a clone of the plains map
pub fn get_plains_map() -> TerrainData {
(*PLAINS_MAP.clone()).clone()
}
/// Get a clone of the island map
pub fn get_island_map() -> TerrainData {
(*ISLAND_MAP.clone()).clone()
}
/// Get a clone of the continent map
pub fn get_continent_map() -> TerrainData {
(*CONTINENT_MAP.clone()).clone()
}

View File

@@ -0,0 +1,206 @@
#![allow(dead_code)]
#![allow(unused_imports)]
/// Shared test utilities and helpers
///
/// This module provides infrastructure for testing the Bevy ECS-based game logic:
/// - `builders`: Fluent API for constructing test worlds and maps
/// - `assertions`: Fluent assertion API for ECS state verification
/// - `fixtures`: Pre-built test scenarios for integration tests
///
/// # Usage Example
/// ```
/// use common::builders::TestWorld;
///
/// let mut world = TestWorld::new()
/// .with_player(NationId::ZERO, 100.0)
/// .with_map_size(50, 50)
/// .build();
///
/// world.conquer_tile(U16Vec2::new(5, 5), NationId::ZERO);
/// world.assert().player_owns(U16Vec2::new(5, 5), NationId::ZERO);
/// ```
use borders_core::prelude::*;
use extension_traits::extension;
use std::collections::HashMap;
use std::collections::HashSet;
mod assertions;
mod builders;
mod fixtures;
// Re-export commonly used items
pub use assertions::*;
pub use builders::*;
pub use fixtures::*;
/// Extension trait providing convenient action methods for World in tests
#[extension(pub trait WorldTestExt)]
impl World {
/// Conquer a tile for a player
fn conquer_tile(&mut self, tile: U16Vec2, player: NationId) {
self.resource_mut::<TerritoryManager>().conquer(tile, player);
}
/// Conquer multiple tiles for a player
fn conquer_tiles(&mut self, tiles: &[U16Vec2], player: NationId) {
let mut territory_manager = self.resource_mut::<TerritoryManager>();
for &tile in tiles {
territory_manager.conquer(tile, player);
}
}
/// Conquer a square region of tiles centered at a position
///
/// Conquers all tiles within `radius` steps of `center` (using taxicab distance).
/// For radius=1, conquers a 3x3 square. For radius=2, conquers a 5x5 square, etc.
fn conquer_region(&mut self, center: U16Vec2, radius: i32, player: NationId) {
let mut territory_manager = self.resource_mut::<TerritoryManager>();
for dy in -radius..=radius {
for dx in -radius..=radius {
let tile = center.as_ivec2() + glam::IVec2::new(dx, dy);
if tile.x >= 0 && tile.y >= 0 {
let tile = tile.as_u16vec2();
if tile.x < territory_manager.width() && tile.y < territory_manager.height() {
territory_manager.conquer(tile, player);
}
}
}
}
}
/// Conquer all 4-directional neighbors of a tile
fn conquer_neighbors(&mut self, center: U16Vec2, player: NationId) {
let map_size = {
let mgr = self.resource::<TerritoryManager>();
U16Vec2::new(mgr.width(), mgr.height())
};
let mut territory_manager = self.resource_mut::<TerritoryManager>();
for neighbor in neighbors(center, map_size) {
territory_manager.conquer(neighbor, player);
}
}
/// Clear ownership of a tile, returning the previous owner
fn clear_tile(&mut self, tile: U16Vec2) -> Option<NationId> {
self.resource_mut::<TerritoryManager>().clear(tile)
}
fn est(&mut self) {}
/// Clear all territory changes from the change buffer
fn clear_territory_changes(&mut self) {
self.resource_mut::<TerritoryManager>().clear_changes();
}
/// Deactivate the spawn phase
fn deactivate_spawn_phase(&mut self) {
self.resource_mut::<SpawnPhase>().active = false;
}
/// Activate the spawn phase
fn activate_spawn_phase(&mut self) {
self.resource_mut::<SpawnPhase>().active = true;
}
/// Get the number of territory changes in the ChangeBuffer
fn get_change_count(&self) -> usize {
self.resource::<TerritoryManager>().iter_changes().count()
}
/// Get the entity associated with a player
fn get_player_entity(&self, player_id: NationId) -> Entity {
let entity_map = self.resource::<PlayerEntityMap>();
*entity_map.0.get(&player_id).unwrap_or_else(|| panic!("Player entity not found for player {}", player_id.get()))
}
/// Run the border update logic (inline implementation for testing)
fn update_borders(&mut self) {
// Check if there are changes
{
let territory_manager = self.resource::<TerritoryManager>();
if !territory_manager.has_changes() {
return;
}
}
// Collect all data we need from TerritoryManager before mutating
let (changed_tiles, map_size, tiles_by_owner) = {
let territory_manager = self.resource::<TerritoryManager>();
let changed_tiles: HashSet<U16Vec2> = territory_manager.iter_changes().collect();
let map_size = U16Vec2::new(territory_manager.width(), territory_manager.height());
let mut affected_tiles = HashSet::with_capacity(changed_tiles.len() * 5);
for &tile in &changed_tiles {
affected_tiles.insert(tile);
affected_tiles.extend(neighbors(tile, map_size));
}
// Group tiles by owner
let mut tiles_by_owner: HashMap<NationId, HashSet<U16Vec2>> = HashMap::new();
for &tile in &affected_tiles {
if let Some(nation_id) = territory_manager.get_nation_id(tile) {
tiles_by_owner.entry(nation_id).or_default().insert(tile);
}
}
(changed_tiles, map_size, tiles_by_owner)
};
// Build ownership snapshot for border checking
let ownership_snapshot: HashMap<U16Vec2, Option<NationId>> = {
let territory_manager = self.resource::<TerritoryManager>();
let mut snapshot = HashMap::new();
for &tile in changed_tiles.iter() {
for neighbor in neighbors(tile, map_size) {
snapshot.entry(neighbor).or_insert_with(|| territory_manager.get_nation_id(neighbor));
}
snapshot.insert(tile, territory_manager.get_nation_id(tile));
}
snapshot
};
// Update each player's borders
let mut players_query = self.query::<(&NationId, &mut BorderTiles)>();
for (nation_id, mut component_borders) in players_query.iter_mut(self) {
let empty_set = HashSet::new();
let player_tiles = tiles_by_owner.get(nation_id).unwrap_or(&empty_set);
// Process tiles owned by this player
for &tile in player_tiles {
let is_border = neighbors(tile, map_size).any(|neighbor| ownership_snapshot.get(&neighbor).and_then(|&owner| owner) != Some(*nation_id));
if is_border {
component_borders.0.insert(tile);
} else {
component_borders.0.remove(&tile);
}
}
// Remove tiles that changed ownership away from this player
for &tile in changed_tiles.iter() {
if ownership_snapshot.get(&tile).and_then(|&owner| owner) != Some(*nation_id) {
component_borders.0.remove(&tile);
}
}
}
}
/// Clear territory changes
fn clear_borders_changes(&mut self) {
self.resource_mut::<TerritoryManager>().clear_changes();
}
/// Get border tiles for a player from ECS component
fn get_player_borders(&self, player_id: NationId) -> HashSet<U16Vec2> {
let entity = self.get_player_entity(player_id);
self.get::<BorderTiles>(entity).expect("Player entity missing BorderTiles component").0.clone()
}
/// Get border tiles for a player from BorderCache
fn get_border_cache(&self, player_id: NationId) -> Option<HashSet<U16Vec2>> {
self.resource::<BorderCache>().get(player_id).cloned()
}
}