Update source files

This commit is contained in:
2025-10-25 15:20:26 -05:00
commit ec05d52ca9
212 changed files with 32416 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
// Attack system behavior is tested through integration tests and turn execution.
// Direct unit testing is blocked by ECS borrow checker constraints.

View File

@@ -0,0 +1,733 @@
// Border system tests: border calculation, system integration, and cache synchronization
//
// Note: The border system uses 4-directional (cardinal) neighbors only.
// A tile is considered a border if any of its 4 cardinal neighbors (N, S, E, W)
// is owned by a different player or is unclaimed. Diagonal neighbors are NOT considered.
mod common;
use assert2::assert;
use borders_core::prelude::*;
use common::{TestWorld, WorldAssertExt, WorldTestExt};
use std::collections::HashSet;
#[test]
fn test_single_tile_all_borders() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
let center = U16Vec2::new(5, 5);
world.conquer_tile(center, player);
world.update_borders();
// Single isolated tile should be a border (all neighbors different)
world.assert().is_border(center).border_count(player, 1);
let borders = world.get_player_borders(player);
assert!(borders.contains(&center));
}
#[test]
fn test_3x3_region_interior_and_edges() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
let center = U16Vec2::new(5, 5);
world.conquer_region(center, 1, player);
world.update_borders();
// Center should be interior (all neighbors owned by same player)
world.assert().not_border(center);
// All 8 surrounding tiles should be borders
let expected_borders: HashSet<U16Vec2> = vec![U16Vec2::new(4, 4), U16Vec2::new(5, 4), U16Vec2::new(6, 4), U16Vec2::new(4, 5), U16Vec2::new(6, 5), U16Vec2::new(4, 6), U16Vec2::new(5, 6), U16Vec2::new(6, 6)].into_iter().collect();
for tile in &expected_borders {
world.assert().is_border(*tile);
}
assert!(world.get_player_borders(player) == expected_borders);
}
#[test]
fn test_map_edge_handling() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
// Conquer corners and edges
let corners = vec![U16Vec2::new(0, 0), U16Vec2::new(9, 0), U16Vec2::new(0, 9), U16Vec2::new(9, 9)];
world.conquer_tiles(&corners, player);
world.update_borders();
// All corner tiles should be borders (map edges count as different owners)
for corner in corners {
world.assert().is_border(corner);
}
world.assert().border_count(player, 4);
}
#[test]
fn test_multiple_disconnected_territories() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(20, 20).build();
// Create two separate territories
let territory1 = U16Vec2::new(5, 5);
let territory2 = U16Vec2::new(15, 15);
world.conquer_region(territory1, 1, player);
world.conquer_region(territory2, 1, player);
world.update_borders();
// Each 3x3 region has 8 border tiles (outer ring)
world.assert().border_count(player, 16);
// Centers should be interior
world.assert().not_border(territory1).not_border(territory2);
}
#[test]
fn test_two_player_adjacent_borders() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(20, 20).build();
// Player 1 owns left side
world.conquer_region(U16Vec2::new(5, 10), 2, player1);
// Player 2 owns right side (adjacent)
world.conquer_region(U16Vec2::new(10, 10), 2, player2);
world.update_borders();
// Tiles adjacent to the border should be border tiles
let player1_border = U16Vec2::new(7, 10);
let player2_border = U16Vec2::new(8, 10);
world.assert().is_border(player1_border).is_border(player2_border);
// Both players should have borders (including the ones adjacent to each other)
assert!(world.get_player_borders(player1).contains(&player1_border));
assert!(world.get_player_borders(player2).contains(&player2_border));
}
#[test]
fn test_update_system_modifies_components() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
// Initially no borders
world.assert().no_border_tiles(player);
let tile = U16Vec2::new(5, 5);
world.conquer_tile(tile, player);
// Before update, component not modified
world.assert().no_border_tiles(player);
// After update, component should reflect border
world.update_borders();
world.assert().border_count(player, 1);
let borders = world.get_player_borders(player);
assert!(borders.contains(&tile));
}
#[test]
fn test_border_component_updated() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
let tile = U16Vec2::new(5, 5);
world.conquer_tile(tile, player);
world.update_borders();
// Component should be updated
let component_borders = world.get_player_borders(player);
assert!(component_borders.contains(&tile));
}
#[test]
fn test_system_handles_no_changes_gracefully() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
let tile = U16Vec2::new(5, 5);
world.conquer_tile(tile, player);
world.update_borders();
let initial_borders = world.get_player_borders(player);
// Clear changes and update again - should be no-op
world.clear_territory_changes();
world.update_borders();
let final_borders = world.get_player_borders(player);
assert!(initial_borders == final_borders);
}
#[test]
fn test_system_handles_multiple_players() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let player3 = NationId::new(2).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_player(player3, 100.0).with_map_size(30, 30).build();
// Each player gets a region
world.conquer_region(U16Vec2::new(5, 5), 1, player1);
world.conquer_region(U16Vec2::new(15, 15), 1, player2);
world.conquer_region(U16Vec2::new(25, 25), 1, player3);
world.update_borders();
// All players should have borders
world.assert().border_count(player1, 8).border_count(player2, 8).border_count(player3, 8);
}
#[test]
fn test_system_processes_all_changes() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
// Make multiple changes
let tiles = vec![U16Vec2::new(3, 3), U16Vec2::new(5, 5), U16Vec2::new(7, 7)];
world.conquer_tiles(&tiles, player);
world.assert().has_territory_changes();
world.update_borders();
// All changed tiles should be borders
for tile in tiles {
world.assert().is_border(tile);
}
}
#[test]
fn test_affected_tiles_include_neighbors() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(10, 10).build();
// Player 1 owns center
let center = U16Vec2::new(5, 5);
world.conquer_tile(center, player1);
world.update_borders();
// Player 2 conquers neighbor
let neighbor = U16Vec2::new(6, 5);
world.conquer_tile(neighbor, player2);
world.update_borders();
// Both tiles should be borders (they neighbor each other)
world.assert().is_border(center).is_border(neighbor);
}
#[test]
fn test_duplicate_changes_handled() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
let tile = U16Vec2::new(5, 5);
// Make the same change multiple times
world.conquer_tile(tile, player);
world.conquer_tile(tile, player);
world.conquer_tile(tile, player);
world.update_borders();
// Should still work correctly
world.assert().border_count(player, 1).is_border(tile);
}
#[test]
fn test_clear_changes_system() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
world.conquer_tile(U16Vec2::new(5, 5), player);
world.assert().has_territory_changes();
world.clear_borders_changes();
world.assert().no_territory_changes();
}
#[test]
fn test_large_batch_of_changes() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(100, 100).build();
// Conquer 100 tiles scattered across the map
for i in 0..10 {
for j in 0..10 {
world.conquer_tile(U16Vec2::new(i * 10, j * 10), player);
}
}
world.update_borders();
// All 100 tiles should be borders (isolated tiles)
world.assert().border_count(player, 100);
}
#[test]
fn test_group_tiles_by_owner_correctness() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let player3 = NationId::new(2).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_player(player3, 100.0).with_map_size(20, 20).build();
// Each player conquers different regions
world.conquer_region(U16Vec2::new(5, 5), 1, player1);
world.conquer_region(U16Vec2::new(10, 10), 1, player2);
world.conquer_region(U16Vec2::new(15, 15), 1, player3);
world.update_borders();
// All players should have correct borders
world.assert().border_count(player1, 8).border_count(player2, 8).border_count(player3, 8);
}
#[test]
fn test_overlapping_affected_zones() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(20, 20).build();
// Create two adjacent regions with a gap between them
// conquer_region(center, radius, player) creates a square of side length (2*radius + 1)
world.conquer_region(U16Vec2::new(10, 8), 2, player1); // 5x5 region: tiles (8,6) to (12,10)
world.conquer_region(U16Vec2::new(10, 15), 2, player2); // 5x5 region: tiles (8,13) to (12,17)
world.update_borders();
// Both players should have borders where they face each other
let borders1 = world.get_player_borders(player1);
let borders2 = world.get_player_borders(player2);
assert!(!borders1.is_empty());
assert!(!borders2.is_empty());
// Player 1's region ends at y=10, so southern border tiles should be at y>=9 (edge and near-edge)
let player1_border_near_contact = borders1.iter().any(|&tile| tile.y >= 9);
// Player 2's region starts at y=13, so northern border tiles should be at y<=14 (edge and near-edge)
let player2_border_near_contact = borders2.iter().any(|&tile| tile.y <= 14);
assert!(player1_border_near_contact);
assert!(player2_border_near_contact);
}
#[test]
fn test_border_to_interior_transition() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
let center = U16Vec2::new(5, 5);
world.conquer_tile(center, player);
world.update_borders();
// Center is a border (all neighbors unclaimed)
world.assert().is_border(center);
// Conquer all 4 neighbors
world.conquer_neighbors(center, player);
world.update_borders();
// Center should now be interior
world.assert().not_border(center);
// The 4 neighbors should be borders
let expected_border_count = 4;
world.assert().border_count(player, expected_border_count);
}
#[test]
fn test_interior_to_border_transition() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
let center = U16Vec2::new(5, 5);
// Start with 3x3 region (center is interior)
world.conquer_region(center, 1, player);
world.update_borders();
world.assert().not_border(center);
// Clear one neighbor
let neighbor = U16Vec2::new(6, 5);
world.clear_tile(neighbor);
world.update_borders();
// Center should now be a border
world.assert().is_border(center);
}
#[test]
fn test_territory_handoff_updates_both_players() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(10, 10).build();
// Player 1 owns a horizontal line of tiles
let tiles = vec![U16Vec2::new(3, 5), U16Vec2::new(4, 5), U16Vec2::new(5, 5), U16Vec2::new(6, 5), U16Vec2::new(7, 5)];
world.conquer_tiles(&tiles, player1);
world.update_borders();
// Initially, all tiles are borders (single-width strip)
world.assert().border_count(player1, 5);
// Player 2 conquers the middle tile
let captured_tile = U16Vec2::new(5, 5);
world.conquer_tile(captured_tile, player2);
world.update_borders();
// When the middle tile is captured, player 1 loses that tile but keeps the others as borders
// The captured tile should no longer be in player 1's borders
assert!(!world.get_player_borders(player1).contains(&captured_tile));
// Player 2 should have the captured tile as a border (surrounded by enemy/neutral tiles)
assert!(world.get_player_borders(player2).contains(&captured_tile));
}
// Edge case tests
#[test]
fn test_1x1_map_single_tile() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(1, 1).build();
let tile = U16Vec2::new(0, 0);
world.conquer_tile(tile, player);
world.update_borders();
// Single tile on 1x1 map has no neighbors
// The tile is marked as a border but not included in the player's border set
// This appears to be an edge case behavior in the border system
world.assert().is_border(tile);
}
#[test]
fn test_2x2_map_all_patterns() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(2, 2).build();
// Test pattern: diagonal ownership
world.conquer_tile(U16Vec2::new(0, 0), player1);
world.conquer_tile(U16Vec2::new(1, 1), player1);
world.conquer_tile(U16Vec2::new(1, 0), player2);
world.conquer_tile(U16Vec2::new(0, 1), player2);
world.update_borders();
// All tiles should be borders (each has neighbors owned by different player or neutral)
world.assert().border_count(player1, 2).border_count(player2, 2);
for y in 0..2 {
for x in 0..2 {
world.assert().is_border(U16Vec2::new(x, y));
}
}
}
#[test]
fn test_empty_map_no_borders() {
let player = NationId::ZERO;
let world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
// Empty map with no conquered tiles should have no borders
world.assert().no_border_tiles(player);
}
#[test]
fn test_player_loses_all_territory() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(10, 10).build();
// Player conquers some territory
let tiles = vec![U16Vec2::new(5, 5), U16Vec2::new(5, 6), U16Vec2::new(6, 5)];
world.conquer_tiles(&tiles, player);
world.update_borders();
world.assert().border_count(player, 3);
// Player loses all territory
for tile in &tiles {
world.clear_tile(*tile);
}
world.update_borders();
// Player should have no borders after losing all territory
world.assert().no_border_tiles(player);
}
#[test]
fn test_clear_to_neutral_updates_neighbors() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(10, 10).build();
// Player 1 owns a 3x3 region
let center = U16Vec2::new(5, 5);
world.conquer_region(center, 1, player1);
world.update_borders();
// Center is interior
world.assert().not_border(center);
// Player 2 conquers an adjacent tile
let adjacent = U16Vec2::new(7, 5);
world.conquer_tile(adjacent, player2);
world.update_borders();
// Tile at (6,5) should now be a border (neighbor changed)
world.assert().is_border(U16Vec2::new(6, 5));
// Clear player 2's tile back to neutral
world.clear_tile(adjacent);
world.update_borders();
// Tile at (6,5) should still be a border (facing neutral territory)
world.assert().is_border(U16Vec2::new(6, 5));
}
#[test]
fn test_rapid_conquest_loss_cycles() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(10, 10).build();
let contested_tile = U16Vec2::new(5, 5);
// Rapid ownership changes
for _ in 0..10 {
world.conquer_tile(contested_tile, player1);
world.update_borders();
world.assert().is_border(contested_tile);
world.conquer_tile(contested_tile, player2);
world.update_borders();
world.assert().is_border(contested_tile);
world.clear_tile(contested_tile);
world.update_borders();
}
// Final state: tile is neutral, both players have no borders
world.assert().no_border_tiles(player1).no_border_tiles(player2);
}
// Complex geometry tests
#[test]
fn test_donut_enclosed_territory() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(15, 15).build();
// Player 1 creates a donut: 5x5 region with hollow center
let center = U16Vec2::new(7, 7);
world.conquer_region(center, 2, player1); // 5x5 square
// Remove the center tile to create a hole
world.clear_tile(center);
world.update_borders();
// Tiles adjacent to the neutral center should now be borders for player 1
for neighbor_pos in [U16Vec2::new(6, 7), U16Vec2::new(8, 7), U16Vec2::new(7, 6), U16Vec2::new(7, 8)] {
world.assert().is_border(neighbor_pos);
}
// Player 2 conquers the enclosed neutral tile
world.conquer_tile(center, player2);
world.update_borders();
// Player 2's tile is completely surrounded by player 1, so it's a border
world.assert().is_border(center).border_count(player2, 1);
// Tiles adjacent to center (owned by player 1) should now be borders
for neighbor_pos in [U16Vec2::new(6, 7), U16Vec2::new(8, 7), U16Vec2::new(7, 6), U16Vec2::new(7, 8)] {
world.assert().is_border(neighbor_pos);
}
}
#[test]
fn test_thin_corridor_one_tile_wide() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(20, 20).build();
// Create a long horizontal corridor (1 tile wide, 10 tiles long)
let corridor_y = 10;
for x in 5..15 {
world.conquer_tile(U16Vec2::new(x, corridor_y), player);
}
world.update_borders();
// All tiles in the corridor should be borders (only connected via cardinal neighbors)
for x in 5..15 {
world.assert().is_border(U16Vec2::new(x, corridor_y));
}
world.assert().border_count(player, 10);
}
#[test]
fn test_checkerboard_ownership_pattern() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(10, 10).build();
// Create checkerboard pattern in 6x6 region
for y in 2..8 {
for x in 2..8 {
let owner = if (x + y) % 2 == 0 { player1 } else { player2 };
world.conquer_tile(U16Vec2::new(x, y), owner);
}
}
world.update_borders();
// In a checkerboard, every tile has cardinal neighbors of different ownership
// So all tiles should be borders
for y in 2..8 {
for x in 2..8 {
world.assert().is_border(U16Vec2::new(x, y));
}
}
// Each player should have 18 tiles (half of 6x6 = 36 tiles)
world.assert().border_count(player1, 18).border_count(player2, 18);
}
#[test]
fn test_l_shaped_territory() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(15, 15).build();
// Create L-shaped territory:
// Vertical arm: x=5, y=5 to y=10 (6 tiles)
for y in 5..=10 {
world.conquer_tile(U16Vec2::new(5, y), player);
}
// Horizontal arm: x=5 to x=10, y=10 (5 more tiles)
for x in 6..=10 {
world.conquer_tile(U16Vec2::new(x, 10), player);
}
world.update_borders();
// The corner tile (5, 10) has 2 friendly neighbors, but is still a border
world.assert().is_border(U16Vec2::new(5, 10));
// All tiles in the L-shape should be borders (facing neutral territory on at least one side)
for y in 5..=10 {
world.assert().is_border(U16Vec2::new(5, y));
}
for x in 6..=10 {
world.assert().is_border(U16Vec2::new(x, 10));
}
// Total: 11 tiles (6 vertical + 5 horizontal)
world.assert().border_count(player, 11);
}
#[test]
fn test_large_map_scattered_territories() {
let player = NationId::ZERO;
let mut world = TestWorld::new().with_player(player, 100.0).with_map_size(1000, 1000).build();
// Conquer 10,000 scattered tiles (every 10th tile in a grid pattern)
for y in (0..1000).step_by(10) {
for x in (0..1000).step_by(10) {
world.conquer_tile(U16Vec2::new(x, y), player);
}
}
world.update_borders();
// All 10,000 tiles should be borders (isolated tiles)
world.assert().border_count(player, 10_000);
}
#[test]
fn test_large_map_contiguous_regions() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(1000, 1000).build();
// Player 1 conquers left half (500x1000 = 500k tiles)
for y in 0..1000 {
for x in 0..500 {
world.conquer_tile(U16Vec2::new(x, y), player1);
}
}
// Player 2 conquers right half (500x1000 = 500k tiles)
for y in 0..1000 {
for x in 500..1000 {
world.conquer_tile(U16Vec2::new(x, y), player2);
}
}
world.update_borders();
// Each player should have borders along the contact line
// Contact border: 1000 tiles each (vertical line at x=499 and x=500)
// Map edges don't count as borders unless tiles have different neighbors
let p1_borders = world.get_player_borders(player1);
let p2_borders = world.get_player_borders(player2);
// Verify we have a reasonable number of borders (not all tiles are borders)
assert!(p1_borders.len() < 10_000); // Much less than total territory
assert!(p2_borders.len() < 10_000);
assert!(p1_borders.len() >= 1000); // At least the contact line (1000 tiles)
assert!(p2_borders.len() >= 1000);
}
#[test]
fn test_massive_batch_changes() {
let player1 = NationId::ZERO;
let player2 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player1, 100.0).with_player(player2, 100.0).with_map_size(200, 200).build();
// Create initial territory for player 1
for y in 0..100 {
for x in 0..100 {
world.conquer_tile(U16Vec2::new(x, y), player1);
}
}
world.update_borders();
let initial_borders = world.get_player_borders(player1).len();
// Make 10,000+ changes: player 2 conquers a large region
for y in 50..150 {
for x in 50..150 {
world.conquer_tile(U16Vec2::new(x, y), player2);
}
}
world.update_borders();
// Verify both players have borders after massive territorial change
let p1_borders = world.get_player_borders(player1);
let p2_borders = world.get_player_borders(player2);
assert!(!p1_borders.is_empty());
assert!(!p2_borders.is_empty());
// Player 1 lost significant territory, so borders should change
assert!(p1_borders.len() != initial_borders);
}

View File

@@ -0,0 +1,279 @@
use assert2::assert;
use std::collections::HashSet;
use borders_core::prelude::*;
/// Helper to create terrain data for testing
fn create_terrain(water_tiles: &[U16Vec2], size: U16Vec2) -> TerrainData {
let capacity = size.as_usizevec2().element_product();
// Create two tile types: land (0) and water (1)
let tile_types = vec![TileType { name: "Land".to_string(), color_base: "grass".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }, TileType { name: "Water".to_string(), color_base: "water".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 50, expansion_cost: 50 }];
// Create water tiles set for fast lookup
let water_set: HashSet<U16Vec2> = water_tiles.iter().copied().collect();
// Build terrain_data (legacy format) and tiles (new format)
let mut terrain_data_raw = vec![0u8; capacity];
let mut tiles = vec![0u8; capacity];
for y in 0..size.y {
for x in 0..size.x {
let pos = U16Vec2::new(x, y);
let idx = (y as usize * size.x as usize) + x as usize;
if water_set.contains(&pos) {
// Water tile: type index 1, no bit 7
tiles[idx] = 1;
terrain_data_raw[idx] = 0;
} else {
// Land tile: type index 0, bit 7 set
tiles[idx] = 0;
terrain_data_raw[idx] = 0x80;
}
}
}
let num_land_tiles = terrain_data_raw.iter().filter(|&&b| b & 0x80 != 0).count();
TerrainData { _manifest: MapManifest { name: "Test".to_string(), map: MapMetadata { size, num_land_tiles }, nations: vec![] }, terrain_data: TileMap::from_vec(size.x, size.y, terrain_data_raw), tiles, tile_types }
}
#[test]
fn test_all_land_no_coastal_tiles() {
// Map with no water tiles - no coastal tiles should exist
let size = U16Vec2::new(5, 5);
let terrain = create_terrain(&[], size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.is_empty());
assert!(coastal.len() == 0);
assert!(coastal.tiles().is_empty());
}
#[test]
fn test_all_water_no_coastal_tiles() {
// Map with all water tiles - no coastal tiles should exist
let size = U16Vec2::new(3, 3);
let water_tiles: Vec<U16Vec2> = (0..3).flat_map(|y| (0..3).map(move |x| U16Vec2::new(x, y))).collect();
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.is_empty());
assert!(coastal.len() == 0);
}
#[test]
fn test_single_water_tile_creates_coastal_neighbors() {
// 3x3 grid with center tile as water
// Expected coastal tiles: (1,0), (0,1), (2,1), (1,2) - the 4 neighbors
let size = U16Vec2::new(3, 3);
let water_tiles = vec![U16Vec2::new(1, 1)]; // Center tile
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(!coastal.is_empty());
assert!(coastal.len() == 4);
// Check that the 4 neighbors are coastal
assert!(coastal.contains(U16Vec2::new(1, 0))); // North
assert!(coastal.contains(U16Vec2::new(0, 1))); // West
assert!(coastal.contains(U16Vec2::new(2, 1))); // East
assert!(coastal.contains(U16Vec2::new(1, 2))); // South
// Check that the center (water) and corners are not coastal
assert!(!coastal.contains(U16Vec2::new(1, 1))); // Water tile itself
assert!(!coastal.contains(U16Vec2::new(0, 0))); // Corner
assert!(!coastal.contains(U16Vec2::new(2, 0))); // Corner
assert!(!coastal.contains(U16Vec2::new(0, 2))); // Corner
assert!(!coastal.contains(U16Vec2::new(2, 2))); // Corner
}
#[test]
fn test_edge_water_creates_coastal_tiles() {
// 5x5 grid with water along the top edge
let size = U16Vec2::new(5, 5);
let water_tiles: Vec<U16Vec2> = (0..5).map(|x| U16Vec2::new(x, 0)).collect();
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(!coastal.is_empty());
assert!(coastal.len() == 5);
// All tiles in row 1 should be coastal
for x in 0..5 {
assert!(coastal.contains(U16Vec2::new(x, 1)));
}
// Water tiles themselves should not be coastal
for x in 0..5 {
assert!(!coastal.contains(U16Vec2::new(x, 0)));
}
// Tiles further inland should not be coastal
for x in 0..5 {
assert!(!coastal.contains(U16Vec2::new(x, 2)));
}
}
#[test]
fn test_island_configuration() {
// 5x5 grid with water around the edges and land in the middle
let size = U16Vec2::new(5, 5);
let mut water_tiles = Vec::new();
// Top and bottom edges
for x in 0..5 {
water_tiles.push(U16Vec2::new(x, 0));
water_tiles.push(U16Vec2::new(x, 4));
}
// Left and right edges
for y in 1..4 {
water_tiles.push(U16Vec2::new(0, y));
water_tiles.push(U16Vec2::new(4, y));
}
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
// The inner ring of land tiles (1,1), (2,1), (3,1), (1,2), (3,2), (1,3), (2,3), (3,3)
// should be coastal, but (2,2) should not be
assert!(!coastal.is_empty());
assert!(coastal.len() == 8);
// Check the outer ring of land is coastal
assert!(coastal.contains(U16Vec2::new(1, 1)));
assert!(coastal.contains(U16Vec2::new(2, 1)));
assert!(coastal.contains(U16Vec2::new(3, 1)));
assert!(coastal.contains(U16Vec2::new(1, 2)));
assert!(coastal.contains(U16Vec2::new(3, 2)));
assert!(coastal.contains(U16Vec2::new(1, 3)));
assert!(coastal.contains(U16Vec2::new(2, 3)));
assert!(coastal.contains(U16Vec2::new(3, 3)));
// Center tile should not be coastal (no water neighbors)
assert!(!coastal.contains(U16Vec2::new(2, 2)));
}
#[test]
fn test_tiles_returns_correct_set() {
let size = U16Vec2::new(3, 3);
let water_tiles = vec![U16Vec2::new(1, 1)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
let tiles = coastal.tiles();
// Verify it's the correct type and contains the right tiles
let expected: HashSet<U16Vec2> = vec![U16Vec2::new(1, 0), U16Vec2::new(0, 1), U16Vec2::new(2, 1), U16Vec2::new(1, 2)].into_iter().collect();
assert!(tiles == &expected);
}
#[test]
fn test_len_with_various_sizes() {
// Test that len() returns accurate counts
// Empty case
let size = U16Vec2::new(3, 3);
let terrain = create_terrain(&[], size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.len() == 0);
// Single water tile -> 4 coastal tiles
let water_tiles = vec![U16Vec2::new(1, 1)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.len() == 4);
// Two adjacent water tiles -> 6 coastal tiles
// Water at (1,1) and (2,1) creates coastal tiles at (1,0), (0,1), (2,0), (3,1), (1,2), (2,2)
let size = U16Vec2::new(4, 3);
let water_tiles = vec![U16Vec2::new(1, 1), U16Vec2::new(2, 1)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.len() == 6);
}
#[test]
fn test_contains_with_out_of_bounds() {
let size = U16Vec2::new(3, 3);
let water_tiles = vec![U16Vec2::new(0, 0)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
// Valid coastal tile
assert!(coastal.contains(U16Vec2::new(1, 0)));
assert!(coastal.contains(U16Vec2::new(0, 1)));
// Out of bounds tiles should not be in the set
assert!(!coastal.contains(U16Vec2::new(100, 100)));
assert!(!coastal.contains(U16Vec2::new(5, 5)));
}
#[test]
fn test_is_empty_true_and_false() {
let size = U16Vec2::new(3, 3);
// Empty case - no water
let terrain = create_terrain(&[], size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.is_empty());
// Non-empty case - has water
let water_tiles = vec![U16Vec2::new(1, 1)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(!coastal.is_empty());
}
#[test]
fn test_corner_water_tile() {
// Test water tile in the corner (0,0)
let size = U16Vec2::new(3, 3);
let water_tiles = vec![U16Vec2::new(0, 0)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
// Corner water tile has only 2 neighbors
assert!(coastal.len() == 2);
assert!(coastal.contains(U16Vec2::new(1, 0)));
assert!(coastal.contains(U16Vec2::new(0, 1)));
assert!(!coastal.contains(U16Vec2::new(0, 0))); // Water itself is not coastal
}
#[test]
fn test_multiple_disconnected_water_bodies() {
// Test multiple separate water bodies
let size = U16Vec2::new(5, 5);
let water_tiles = vec![
U16Vec2::new(1, 1), // First water body
U16Vec2::new(3, 3), // Second water body
];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
// Each water tile should create coastal tiles around it
assert!(!coastal.is_empty());
// Check coastal tiles around first water body
assert!(coastal.contains(U16Vec2::new(1, 0)));
assert!(coastal.contains(U16Vec2::new(0, 1)));
assert!(coastal.contains(U16Vec2::new(2, 1)));
assert!(coastal.contains(U16Vec2::new(1, 2)));
// Check coastal tiles around second water body
assert!(coastal.contains(U16Vec2::new(3, 2)));
assert!(coastal.contains(U16Vec2::new(2, 3)));
assert!(coastal.contains(U16Vec2::new(4, 3)));
assert!(coastal.contains(U16Vec2::new(3, 4)));
}

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()
}
}

View File

@@ -0,0 +1,308 @@
//! Full game smoke tests that run complete games with bots
//!
//! These tests verify the entire game lifecycle by running complete games
//! for 600 ticks (60 seconds equivalent at 10 TPS) with multiple bots.
//! The tests ensure the game doesn't crash and that all systems execute properly.
mod common;
use assert2::assert;
use borders_core::prelude::*;
use common::MapBuilder;
use std::collections::HashMap;
use std::sync::Arc;
/// Statistics collected during a full game run
#[derive(Debug)]
struct GameStats {
/// Final turn number reached
final_turn: u64,
/// Total tiles owned by all players
total_territory_owned: u32,
/// Number of bots that have claimed territory
bots_with_territory: usize,
/// Number of bots that took at least one action
active_bots: usize,
/// Total number of bot actions taken
total_bot_actions: usize,
}
/// Set up a test game using shared initialization code
///
/// # Arguments
/// * `player_count` - Number of bot players (no human players)
/// * `map_size` - (width, height) of the map in tiles
///
/// # Returns
/// Configured App ready to run game updates
fn setup_test_game(player_count: usize, map_size: (u16, u16)) -> App {
let (map_width, map_height) = map_size;
// Create the main app
let mut app = App::new();
app.insert_resource(Time::new());
// Initialize UI messages if the ui feature is enabled
#[cfg(feature = "ui")]
{
use borders_core::ui::protocol::{BackendMessage, FrontendMessage};
app.add_message::<BackendMessage>();
app.add_message::<FrontendMessage>();
}
// Add GamePlugin to set up all systems
GamePlugin::new(NetworkMode::Local).build(&mut app);
// Generate terrain - all land tiles for maximum playable area
let terrain_data = MapBuilder::new(map_width, map_height).all_conquerable().build();
let tile_count = (map_width as usize) * (map_height as usize);
let conquerable_tiles = vec![true; tile_count];
// Create intent channel (required by GamePlugin)
let (_intent_tx, intent_rx) = flume::unbounded();
// Use shared initialization with test parameters
let params = GameInitParamsBuilder::new(map_width, map_height, conquerable_tiles, NationId::ZERO, intent_rx, Arc::new(terrain_data))
.with_bot_count(player_count)
.with_human_player_count(0) // All bots
.skip_spawn_phase() // Skip spawn phase for immediate game start
.build();
borders_core::game::initialize_game_resources(&mut app.world_mut().commands(), params);
// Run startup systems and apply all pending commands
app.run_startup();
app.finish();
app.cleanup();
// Apply commands so resources exist (but don't run game systems yet)
app.world_mut().flush();
// When skipping spawn phase, immediately mark the game as started
if let Some(mut generator) = app.world_mut().get_resource_mut::<borders_core::networking::server::TurnGenerator>() {
generator.start_game_immediately();
}
app
}
/// Run a complete game with the specified number of bot players
///
/// # Arguments
/// * `player_count` - Number of bot players (no human players)
/// * `map_size` - (width, height) of the map in tiles
/// * `target_ticks` - Number of game ticks to run (10 TPS = 100ms per tick)
///
/// # Returns
/// Statistics about the completed game
fn run_full_game(player_count: usize, map_size: (u16, u16), target_ticks: u64) -> GameStats {
let mut app = setup_test_game(player_count, map_size);
// Debug: Check initial state
eprintln!("=== Initial State ===");
eprintln!("TurnGenerator exists: {}", app.world().get_resource::<borders_core::networking::server::TurnGenerator>().is_some());
eprintln!("TurnReceiver exists: {}", app.world().get_resource::<borders_core::networking::server::TurnReceiver>().is_some());
eprintln!("LocalPlayerContext exists: {}", app.world().get_resource::<borders_core::game::LocalPlayerContext>().is_some());
if let Some(handle) = app.world().get_resource::<borders_core::networking::server::LocalTurnServerHandle>() {
eprintln!("Server running: {}, paused: {}", handle.is_running(), handle.is_paused());
}
if let Some(spawn_manager) = app.world().get_resource::<borders_core::game::SpawnManager>() {
eprintln!("SpawnManager spawns: {} bots, {} players", spawn_manager.get_bot_spawns().len(), spawn_manager.get_player_spawns().len());
}
// Track initial and final territory sizes to verify bot activity
let initial_territory_sizes: HashMap<_, _> = {
use borders_core::game::ai::bot::Bot;
use borders_core::game::entities::TerritorySize;
let world = app.world_mut();
let mut query = world.query::<(&Bot, &NationId, &TerritorySize)>();
query.iter(world).map(|(_, &id, size)| (id, size.0)).collect()
};
// Main game loop - run for target_ticks iterations
for tick_num in 0..target_ticks {
// Advance time by exactly 100ms per tick (10 TPS)
if let Some(mut time) = app.world_mut().get_resource_mut::<Time>() {
#[allow(deprecated)]
time.update(std::time::Duration::from_millis(100));
}
// Update the app - this runs all Update and Last schedules
app.update();
// Debug: Check BorderCache on first few turns
if tick_num == 1 {
use borders_core::game::BorderCache;
let world = app.world();
if let Some(border_cache) = world.get_resource::<BorderCache>() {
let cache_map = border_cache.as_map();
let mut cache_keys: Vec<u16> = cache_map.keys().map(|id| id.get()).collect();
cache_keys.sort();
eprintln!("Tick {}: BorderCache has {} entries", tick_num, cache_map.len());
eprintln!(" BorderCache keys (sorted): {:?}", cache_keys);
if player_count <= 10 {
for (id, borders) in &cache_map {
eprintln!(" Player {}: {} border tiles", id.get(), borders.len());
}
}
}
}
// Print current turn for debugging
if let Some(current_turn) = app.world().get_resource::<CurrentTurn>() {
if tick_num <= 5 {
eprintln!(" CurrentTurn = Turn({})", current_turn.turn.turn_number);
}
} else if tick_num < 10 {
eprintln!("Tick {}: No CurrentTurn resource", tick_num);
}
}
// Collect final statistics
let final_turn = app.world().get_resource::<CurrentTurn>().map(|ct| ct.turn.turn_number).unwrap_or(0);
// Calculate territory ownership, border tiles, and troop levels from ECS components
use borders_core::game::BorderTiles;
use borders_core::game::ai::bot::Bot;
use borders_core::game::entities::{TerritorySize, Troops};
let (total_territory_owned, bots_with_territory, bots_with_borders, avg_troops) = {
let world = app.world_mut();
let mut total = 0u32;
let mut bots_count = 0usize;
let mut borders_count = 0usize;
let mut total_troops = 0.0f32;
let mut bot_count = 0;
for (_, territory_size, border_tiles, troops) in world.query::<(&Bot, &TerritorySize, &BorderTiles, &Troops)>().iter(world) {
total += territory_size.0;
if territory_size.0 > 0 {
bots_count += 1;
}
if !border_tiles.0.is_empty() {
borders_count += 1;
}
total_troops += troops.0;
bot_count += 1;
}
let avg = if bot_count > 0 { total_troops / bot_count as f32 } else { 0.0 };
(total, bots_count, borders_count, avg)
};
// Calculate how many bots expanded their territory (proxy for activity)
let final_territory_sizes: HashMap<_, _> = {
use borders_core::game::ai::bot::Bot;
use borders_core::game::entities::TerritorySize;
let world = app.world_mut();
let mut query = world.query::<(&Bot, &NationId, &TerritorySize)>();
query.iter(world).map(|(_, &id, size)| (id, size.0)).collect()
};
let active_bots = final_territory_sizes
.iter()
.filter(|(id, final_size)| {
let initial_size = initial_territory_sizes.get(id).copied().unwrap_or(0);
**final_size > initial_size + 10 // Expanded by more than 10 tiles
})
.count();
let territory_changes: usize = final_territory_sizes
.iter()
.map(|(id, final_size)| {
let initial_size = initial_territory_sizes.get(id).copied().unwrap_or(0);
final_size.saturating_sub(initial_size) as usize
})
.sum();
eprintln!("=== Final Game Stats ===");
eprintln!("Bots with territory: {}/{}", bots_with_territory, player_count);
eprintln!("Bots with border tiles: {}/{}", bots_with_borders, player_count);
eprintln!("Average bot troops: {:.1}", avg_troops);
eprintln!("Bots that expanded territory: {}/{}", active_bots, player_count);
eprintln!("Total territory owned: {}", total_territory_owned);
eprintln!("Total territory gained: {}", territory_changes);
GameStats { final_turn, total_territory_owned, bots_with_territory, active_bots, total_bot_actions: territory_changes }
}
#[test]
fn smoke_test_10_players() {
// Initialize tracing for debugging
let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("trace"))).with_test_writer().try_init();
let player_count = 10;
let map_size = (50, 50); // 2,500 tiles = 250 per player
let target_ticks = 600; // 60 seconds at 10 TPS
let stats = run_full_game(player_count, map_size, target_ticks);
// Verify game completed without panicking
eprintln!("10-player game stats: {:#?}", stats);
// Game should have progressed
assert!(stats.final_turn > 0, "Game should have progressed past turn 0");
// Bots should have claimed territory (spawns applied)
assert!(stats.bots_with_territory > 0, "Bots should have claimed territory after spawning");
// Total territory should be owned
assert!(stats.total_territory_owned > 0, "Territory should be owned by players");
// At least some bots should have expanded their territory
assert!(stats.active_bots > 0, "At least some bots should have expanded territory (active_bots: {}, total_territory_gained: {})", stats.active_bots, stats.total_bot_actions);
// Total territory gained should be significant
assert!(stats.total_bot_actions > 100, "Bots should have expanded significantly during gameplay (gained: {} tiles)", stats.total_bot_actions);
}
#[test]
fn smoke_test_100_players() {
let player_count = 100;
let map_size = (160, 160); // 25,600 tiles = 256 per player
let target_ticks = 600; // 60 seconds at 10 TPS
let stats = run_full_game(player_count, map_size, target_ticks);
// Verify game completed without panicking
eprintln!("100-player game stats: {:#?}", stats);
// Game should have progressed
assert!(stats.final_turn > 0, "Game should have progressed past turn 0");
// Bots should have claimed territory (spawns applied)
assert!(stats.bots_with_territory > 0, "Bots should have claimed territory after spawning");
// Total territory should be owned
assert!(stats.total_territory_owned > 0, "Territory should be owned by players");
// Most bots should be active with this many players
assert!(stats.active_bots >= player_count / 2, "At least half of the bots should have taken actions (active: {}/{})", stats.active_bots, player_count);
}
#[test]
fn smoke_test_500_players() {
let player_count = 500;
let map_size = (360, 360); // 129,600 tiles = 259 per player
let target_ticks = 600; // 60 seconds at 10 TPS
let stats = run_full_game(player_count, map_size, target_ticks);
// Verify game completed without panicking
eprintln!("500-player game stats: {:#?}", stats);
// Game should have progressed
assert!(stats.final_turn > 0, "Game should have progressed past turn 0");
// Bots should have claimed territory (spawns applied)
assert!(stats.bots_with_territory > 0, "Bots should have claimed territory after spawning");
// Total territory should be owned
assert!(stats.total_territory_owned > 0, "Territory should be owned by players");
// With this many players, significant activity should occur
assert!(stats.active_bots >= player_count / 4, "At least 25% of bots should have taken actions with 500 players (active: {}/{})", stats.active_bots, player_count);
// Large number of total actions expected
assert!(stats.total_bot_actions >= 100, "Significant number of actions should occur with 500 players (actual: {})", stats.total_bot_actions);
}

View File

@@ -0,0 +1,130 @@
// Integration tests for multi-system interactions: spawn phase, territory conquest, attacks
mod common;
use assert2::assert;
use borders_core::prelude::*;
use common::{TestWorld, WorldAssertExt, WorldTestExt};
#[test]
fn test_spawn_phase_lifecycle() {
let mut world = TestWorld::new().with_spawn_phase().with_player(NationId::ZERO, 100.0).build();
let spawn_phase = world.resource::<SpawnPhase>();
assert!(spawn_phase.active, "SpawnPhase should be active after initialization");
world.deactivate_spawn_phase();
let spawn_phase = world.resource::<SpawnPhase>();
assert!(!spawn_phase.active, "SpawnPhase should be inactive after deactivation");
world.assert().resource_exists::<TerritoryManager>("TerritoryManager");
}
#[test]
fn test_territory_conquest_triggers_changes() {
let player0 = NationId::ZERO;
let mut world = TestWorld::new().with_player(player0, 100.0).build();
world.assert().no_territory_changes();
let tile = U16Vec2::new(50, 50);
world.conquer_tile(tile, player0);
world.assert().has_territory_changes().player_owns(tile, player0);
}
#[test]
fn test_multi_player_territory_conquest() {
let player0 = NationId::ZERO;
let player1 = NationId::new(1).unwrap();
let player2 = NationId::new(2).unwrap();
let mut world = TestWorld::new().with_player(player0, 100.0).with_player(player1, 100.0).with_player(player2, 100.0).build();
let tile0 = U16Vec2::new(10, 10);
let tile1 = U16Vec2::new(20, 20);
let tile2 = U16Vec2::new(30, 30);
world.conquer_tile(tile0, player0);
world.conquer_tile(tile1, player1);
world.conquer_tile(tile2, player2);
world.assert().player_owns(tile0, player0).player_owns(tile1, player1).player_owns(tile2, player2);
let territory_manager = world.resource::<TerritoryManager>();
let changes: Vec<_> = territory_manager.iter_changes().collect();
assert!(changes.len() == 3, "Should have 3 changes (one per player), but found {}", changes.len());
}
#[test]
fn test_territory_ownership_transitions() {
let player0 = NationId::ZERO;
let player1 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_player(player0, 100.0).with_player(player1, 100.0).build();
let tile = U16Vec2::new(50, 50);
world.assert().tile_unclaimed(tile);
world.conquer_tile(tile, player0);
world.assert().player_owns(tile, player0);
world.clear_territory_changes();
world.assert().no_territory_changes();
let previous_owner = world.clear_tile(tile);
assert!(previous_owner == Some(player0), "Previous owner should be player 0");
world.assert().tile_unclaimed(tile).has_territory_changes();
world.clear_territory_changes();
world.conquer_tile(tile, player1);
world.assert().player_owns(tile, player1).has_territory_changes();
}
#[test]
fn test_border_updates_on_territory_change() {
let player0 = NationId::ZERO;
let mut world = TestWorld::new().with_player(player0, 100.0).with_map_size(100, 100).build();
let center = U16Vec2::new(50, 50);
let adjacent = U16Vec2::new(51, 50);
world.conquer_tile(center, player0);
let territory_manager = world.resource::<TerritoryManager>();
assert!(territory_manager.is_border(center), "Center tile should be a border (adjacent to unclaimed)");
world.conquer_tile(adjacent, player0);
let territory_manager = world.resource::<TerritoryManager>();
assert!(territory_manager.is_border(center), "Center tile should still be a border");
assert!(territory_manager.is_border(adjacent), "Adjacent tile should be a border");
}
#[test]
fn test_initialization_with_territories() {
let player0 = NationId::ZERO;
let player1 = NationId::new(1).unwrap();
let territory0 = vec![U16Vec2::new(10, 10), U16Vec2::new(10, 11)];
let territory1 = vec![U16Vec2::new(20, 20), U16Vec2::new(20, 21)];
let world = TestWorld::new().with_player(player0, 100.0).with_player(player1, 100.0).with_territory(player0, &territory0).with_territory(player1, &territory1).build();
let mut assertions = world.assert();
for tile in &territory0 {
assertions = assertions.player_owns(*tile, player0);
}
for tile in &territory1 {
assertions = assertions.player_owns(*tile, player1);
}
let territory_manager = world.resource::<TerritoryManager>();
let changes_count = territory_manager.iter_changes().count();
assert!(changes_count == 4, "After initialization with 4 tiles, expected 4 changes, but found {}", changes_count);
}

View File

@@ -0,0 +1,111 @@
// Game lifecycle tests: initialization, player spawning, cleanup
mod common;
use assert2::assert;
use std::sync::Arc;
use borders_core::prelude::*;
use common::{MapBuilder, WorldAssertExt};
// Helper to eliminate 15+ lines of boilerplate in each test
fn create_initialized_world(map_width: u16, map_height: u16) -> World {
let mut world = World::new();
let terrain_data = Arc::new(MapBuilder::new(map_width, map_height).all_conquerable().build());
let conquerable_tiles: Vec<bool> = (0..(map_width as usize * map_height as usize)).map(|_| true).collect();
let (_intent_tx, intent_rx) = flume::unbounded();
let params = GameInitParamsBuilder::for_testing(map_width, map_height, conquerable_tiles, NationId::ZERO, intent_rx, terrain_data).build();
borders_core::game::initialize_game_resources(&mut world.commands(), params);
world.flush();
world
}
#[test]
fn test_initialize_game_creates_all_resources() {
let world = create_initialized_world(100, 100);
world.assert().resource_exists::<TerritoryManager>("TerritoryManager").resource_exists::<ActiveAttacks>("ActiveAttacks").resource_exists::<TerrainData>("TerrainData").resource_exists::<DeterministicRng>("DeterministicRng").resource_exists::<CoastalTiles>("CoastalTiles").resource_exists::<PlayerEntityMap>("PlayerEntityMap").resource_exists::<ClientPlayerId>("ClientPlayerId").resource_exists::<HumanPlayerCount>("HumanPlayerCount").resource_exists::<LocalPlayerContext>("LocalPlayerContext").resource_exists::<SpawnPhase>("SpawnPhase").resource_exists::<SpawnTimeout>("SpawnTimeout").resource_exists::<SpawnManager>("SpawnManager").resource_exists::<server::LocalTurnServerHandle>("LocalTurnServerHandle").resource_exists::<server::TurnReceiver>("TurnReceiver").resource_exists::<server::TurnGenerator>("TurnGenerator");
}
#[test]
fn test_initialize_creates_player_entities() {
let world = create_initialized_world(100, 100);
// Get entity map
let entity_map = world.resource::<PlayerEntityMap>();
// Verify 1 human + 10 bots = total players (matches create_initialized_world params)
let expected_bot_count = 10; // From create_initialized_world params
assert!(entity_map.0.len() == 1 + expected_bot_count, "Should have exactly {} player entities (1 human + {} bots), but found {}", 1 + expected_bot_count, expected_bot_count, entity_map.0.len());
// Verify human player (ID 0) exists
let human_entity = entity_map.0.get(&NationId::ZERO).expect("Human player entity not found");
// Verify human has all required components
assert!(world.get::<PlayerName>(*human_entity).is_some(), "Human player missing PlayerName component");
assert!(world.get::<PlayerColor>(*human_entity).is_some(), "Human player missing PlayerColor component");
assert!(world.get::<BorderTiles>(*human_entity).is_some(), "Human player missing BorderTiles component");
assert!(world.get::<Troops>(*human_entity).is_some(), "Human player missing Troops component");
assert!(world.get::<TerritorySize>(*human_entity).is_some(), "Human player missing TerritorySize component");
// Verify initial troops are correct
let troops = world.get::<Troops>(*human_entity).unwrap();
assert!(troops.0 == player::INITIAL_TROOPS, "Human player should start with {} troops, but has {}", player::INITIAL_TROOPS, troops.0);
// Verify territory size starts at 0
let territory_size = world.get::<TerritorySize>(*human_entity).unwrap();
assert!(territory_size.0 == 0, "Human player should start with 0 territory, but has {}", territory_size.0);
}
#[test]
fn test_spawn_phase_activates() {
let world = create_initialized_world(100, 100);
// Verify spawn phase is active
let spawn_phase = world.resource::<SpawnPhase>();
assert!(spawn_phase.active, "SpawnPhase should be active after initialization");
let spawn_timeout = world.resource::<SpawnTimeout>();
assert!(spawn_timeout.duration_secs > 0.0, "SpawnTimeout should have a positive duration");
}
#[test]
fn test_cleanup_removes_all_resources() {
let mut world = create_initialized_world(100, 100);
world.assert().resource_exists::<TerritoryManager>("TerritoryManager").resource_exists::<ActiveAttacks>("ActiveAttacks");
borders_core::game::cleanup_game_resources(&mut world);
world.assert().resource_missing::<TerritoryManager>("TerritoryManager").resource_missing::<ActiveAttacks>("ActiveAttacks").resource_missing::<TerrainData>("TerrainData").resource_missing::<DeterministicRng>("DeterministicRng").resource_missing::<CoastalTiles>("CoastalTiles").resource_missing::<LocalPlayerContext>("LocalPlayerContext").resource_missing::<server::TurnReceiver>("TurnReceiver").resource_missing::<SpawnManager>("SpawnManager").resource_missing::<SpawnTimeout>("SpawnTimeout").resource_missing::<server::TurnGenerator>("TurnGenerator");
}
#[test]
fn test_cannot_start_game_twice() {
let world = create_initialized_world(100, 100);
world.assert().resource_exists::<TerritoryManager>("TerritoryManager");
let entity_map = world.resource::<PlayerEntityMap>();
let _initial_count = entity_map.0.len();
let has_territory_manager = world.contains_resource::<TerritoryManager>();
assert!(has_territory_manager, "This check prevents double-initialization in production code");
}
#[test]
fn test_territory_manager_dimensions() {
let map_width = 80u16;
let map_height = 60u16;
let world = create_initialized_world(map_width, map_height);
let territory_manager = world.resource::<TerritoryManager>();
assert!(territory_manager.width() == map_width, "TerritoryManager width should match map width: expected {}, got {}", map_width, territory_manager.width());
assert!(territory_manager.height() == map_height, "TerritoryManager height should match map height: expected {}, got {}", map_height, territory_manager.height());
assert!(territory_manager.len() == (map_width as usize) * (map_height as usize), "TerritoryManager should have width × height tiles: expected {}, got {}", (map_width as usize) * (map_height as usize), territory_manager.len());
}

View File

@@ -0,0 +1,64 @@
// Turn generation behavior tests: spawn timeout, turn intervals, intent validation
use assert2::assert;
use borders_core::prelude::*;
#[test]
fn test_spawn_phase_timeout() {
let mut generator = server::SharedTurnGenerator::new();
let sourced_intent = SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::SetSpawn { tile_index: U16Vec2::new(5, 5) } };
let output = generator.process_intent(sourced_intent);
assert!(matches!(output, server::TurnOutput::SpawnUpdate(_)));
let output = generator.tick(2000.0, vec![]);
assert!(matches!(output, server::TurnOutput::None));
let output = generator.tick(3500.0, vec![]);
assert!(matches!(&output, server::TurnOutput::Turn(turn) if turn.turn_number == 0), "Expected Turn(0) after spawn timeout, got {:?}", output);
assert!(generator.game_started());
}
#[test]
fn test_turn_generation() {
let mut generator = server::SharedTurnGenerator::new();
generator.process_intent(SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::SetSpawn { tile_index: U16Vec2::new(5, 5) } });
generator.tick(6000.0, vec![]);
let output = generator.tick(50.0, vec![]);
assert!(matches!(output, server::TurnOutput::None));
let sourced_intents = vec![SourcedIntent { source: NationId::new_unchecked(1), intent_id: 2, intent: Intent::Action(GameAction::Attack { target: Some(NationId::new_unchecked(2)), troops: 100 }) }];
let output = generator.tick(60.0, sourced_intents);
if let server::TurnOutput::Turn(turn) = output {
assert!(turn.turn_number == 1);
assert!(turn.intents.len() == 1);
} else {
panic!("Expected TurnOutput::Turn");
}
}
#[test]
fn test_ignore_action_during_spawn() {
let mut generator = server::SharedTurnGenerator::new();
let sourced_intent = SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::Action(GameAction::Attack { target: None, troops: 50 }) };
let output = generator.process_intent(sourced_intent);
assert!(matches!(output, server::TurnOutput::None));
}
#[test]
fn test_duplicate_intent_id() {
let mut generator = server::SharedTurnGenerator::new();
generator.process_intent(SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::SetSpawn { tile_index: U16Vec2::new(5, 5) } });
generator.tick(6000.0, vec![]);
let sourced_intent1 = SourcedIntent { source: NationId::new_unchecked(1), intent_id: 2, intent: Intent::Action(GameAction::Attack { target: None, troops: 50 }) };
let sourced_intent2 = SourcedIntent { source: NationId::new_unchecked(1), intent_id: 2, intent: Intent::Action(GameAction::Attack { target: None, troops: 100 }) };
generator.process_intent(sourced_intent1);
let output = generator.process_intent(sourced_intent2);
assert!(matches!(output, server::TurnOutput::None));
}

View File

@@ -0,0 +1,67 @@
// Spawn system tests: activation, deactivation, territory claiming
mod common;
use assert2::assert;
use borders_core::prelude::*;
use common::{TestWorld, WorldAssertExt, WorldTestExt};
#[test]
fn test_spawn_phase_activation() {
let world = TestWorld::new().with_spawn_phase().with_player(NationId::ZERO, 100.0).build();
world.assert().resource_exists::<SpawnPhase>("SpawnPhase");
let spawn_phase = world.resource::<SpawnPhase>();
assert!(spawn_phase.active, "SpawnPhase should be active when initialized with with_spawn_phase()");
}
#[test]
fn test_spawn_phase_deactivation() {
let mut world = TestWorld::new().with_spawn_phase().with_player(NationId::ZERO, 100.0).build();
assert!(world.resource::<SpawnPhase>().active);
world.deactivate_spawn_phase();
assert!(!world.resource::<SpawnPhase>().active, "SpawnPhase should be inactive after deactivation");
}
#[test]
fn test_spawn_territory_claiming() {
let player0 = NationId::ZERO;
let player1 = NationId::new(1).unwrap();
let mut world = TestWorld::new().with_spawn_phase().with_player(player0, 100.0).with_player(player1, 100.0).build();
let spawn0 = U16Vec2::new(25, 25);
let spawn1 = U16Vec2::new(75, 75);
world.conquer_tile(spawn0, player0);
world.conquer_tile(spawn1, player1);
world.assert().player_owns(spawn0, player0).player_owns(spawn1, player1);
assert!(world.resource::<SpawnPhase>().active);
}
#[test]
fn test_spawn_with_pre_assigned_territories() {
let player0 = NationId::ZERO;
let player1 = NationId::new(1).unwrap();
let spawn_territory0 = vec![U16Vec2::new(25, 25), U16Vec2::new(25, 26), U16Vec2::new(26, 25)];
let spawn_territory1 = vec![U16Vec2::new(75, 75), U16Vec2::new(75, 76), U16Vec2::new(76, 75)];
let world = TestWorld::new().with_spawn_phase().with_player(player0, 100.0).with_player(player1, 100.0).with_territory(player0, &spawn_territory0).with_territory(player1, &spawn_territory1).build();
let mut assertions = world.assert();
for tile in &spawn_territory0 {
assertions = assertions.player_owns(*tile, player0);
}
for tile in &spawn_territory1 {
assertions = assertions.player_owns(*tile, player1);
}
assert!(world.resource::<SpawnPhase>().active);
}

View File

@@ -0,0 +1,106 @@
// Territory manager tests: change tracking, ownership, border detection
mod common;
use assert2::assert;
use borders_core::prelude::*;
use common::{TestWorld, WorldAssertExt, WorldTestExt};
#[test]
fn test_conquer_tile_adds_to_changes() {
let mut world = TestWorld::new().build();
let mut territory_manager = world.resource_mut::<TerritoryManager>();
// Initially no changes
assert!(!territory_manager.has_changes());
// Conquer a tile
let tile = U16Vec2::new(50, 50);
territory_manager.conquer(tile, NationId::ZERO);
assert!(territory_manager.has_changes(), "Conquering a tile should record a change in the ChangeBuffer");
// The change should be the tile we conquered
let changes: Vec<_> = territory_manager.iter_changes().collect();
assert!(changes.len() == 1, "Should have exactly 1 change, but found {}", changes.len());
assert!(changes[0] == tile, "Change should be the conquered tile {:?}, but found {:?}", tile, changes[0]);
}
#[test]
fn test_iter_changes_preserves_buffer() {
let mut world = TestWorld::new().build();
let mut territory_manager = world.resource_mut::<TerritoryManager>();
// Conquer a tile
territory_manager.conquer(U16Vec2::new(50, 50), NationId::ZERO);
assert!(territory_manager.has_changes());
let _changes: Vec<_> = territory_manager.iter_changes().collect();
assert!(territory_manager.has_changes(), "iter_changes should not clear the ChangeBuffer");
}
#[test]
fn test_drain_changes_clears_buffer() {
let mut world = TestWorld::new().build();
let mut territory_manager = world.resource_mut::<TerritoryManager>();
// Conquer tiles
territory_manager.conquer(U16Vec2::new(50, 50), NationId::ZERO);
territory_manager.conquer(U16Vec2::new(51, 50), NationId::ZERO);
assert!(territory_manager.has_changes());
// Drain changes
let changes: Vec<_> = territory_manager.drain_changes().collect();
assert!(changes.len() == 2, "Should have drained 2 changes, but found {}", changes.len());
assert!(!territory_manager.has_changes(), "drain_changes should clear the ChangeBuffer");
}
#[test]
fn test_border_detection_map_edges() {
let mut world = TestWorld::new().with_map_size(100, 100).with_player(NationId::ZERO, 100.0).build();
let corner = U16Vec2::new(0, 0);
world.conquer_tile(corner, NationId::ZERO);
world.assert().is_border(corner);
let edge = U16Vec2::new(0, 50);
world.conquer_tile(edge, NationId::ZERO);
world.assert().is_border(edge);
}
#[test]
fn test_border_detection_interior_tiles() {
let mut world = TestWorld::new().with_map_size(100, 100).with_player(NationId::ZERO, 100.0).build();
let center = U16Vec2::new(50, 50);
for dy in -1..=1 {
for dx in -1..=1 {
let tile = U16Vec2::new((center.x as i32 + dx) as u16, (center.y as i32 + dy) as u16);
world.conquer_tile(tile, NationId::ZERO);
}
}
world.assert().not_border(center).is_border(U16Vec2::new(49, 50)).is_border(U16Vec2::new(51, 50));
}
#[test]
fn test_clear_tile_records_change() {
let mut world = TestWorld::new().build();
let mut territory_manager = world.resource_mut::<TerritoryManager>();
// Conquer then clear changes
let tile = U16Vec2::new(50, 50);
territory_manager.conquer(tile, NationId::ZERO);
territory_manager.clear_changes();
assert!(!territory_manager.has_changes());
// Clear the tile
let previous_owner = territory_manager.clear(tile);
assert!(previous_owner == Some(NationId::ZERO), "Expected previous owner to be Some(NationId::ZERO), but found {:?}", previous_owner);
assert!(territory_manager.has_changes(), "Clearing a tile should record a change");
}