//! Chunk streaming system (#578, D-012). //! //! Loads chunks near the player and unloads distant chunks based on a //! configurable radius. For v0.1 the radius covers the entire hand-authored //! district (256×256 visual tiles = 8×8 chunks of 32 tiles), so all chunks //! remain loaded. The architecture supports future per-demand loading (v0.3+). //! //! The system runs on a configurable tick cadence (default: every 10 ticks). //! It queries the player's TilePosition, computes which chunks should be //! loaded (Chebyshev distance ≤ radius from the player's chunk), and //! loads/unloads accordingly. use bevy_ecs::prelude::*; use crate::simulation::movement::{ChunkCoord, PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::time::SimulationTime; /// How many chunks around the player to keep loaded (Chebyshev distance). /// /// Default: 8, which covers the full v0.1 district (256×256 = 8×8 chunks). /// For v0.3+ borderless generation, set to 3-4 for memory-bounded streaming. #[derive(Resource, Debug, Clone)] pub struct ChunkLoadRadius { pub radius: i32, } impl Default for ChunkLoadRadius { fn default() -> Self { Self { radius: 8 } } } /// How often the streaming system runs, in simulation ticks. /// /// Default: 10 ticks (one game-minute at D-031 cadence). /// Lower values increase responsiveness but add per-tick overhead. #[derive(Resource, Debug, Clone)] pub struct ChunkStreamingCadence { pub ticks: u64, } impl Default for ChunkStreamingCadence { fn default() -> Self { Self { ticks: 10 } } } /// Chunk streaming system — loads/unloads chunks around the player position. /// /// Runs on cadence (every `ChunkStreamingCadence.ticks` simulation ticks). /// Computes the set of chunks within `ChunkLoadRadius` of the player's /// current chunk (Chebyshev distance), loads missing chunks, and unloads /// chunks that are now out of range. /// /// For v0.1, radius=8 covers the entire district so nothing ever unloads. /// For v0.3+, the generator fills newly loaded chunks with terrain data. pub fn chunk_streaming( time: Res, cadence: Res, radius: Res, walkability: Option>, player_query: Query<&TilePosition, With>, ) { let Some(mut walkability) = walkability else { return; }; // Cadence gate — only run every N ticks if cadence.ticks > 0 && !time.tick.is_multiple_of(cadence.ticks) { return; } let Ok(player_pos) = player_query.single() else { return; }; let player_chunk = player_pos.chunk_coord(); let r = radius.radius; // Load chunks within radius that aren't already loaded let mut loaded = 0u32; for cx in (player_chunk.cx - r)..=(player_chunk.cx + r) { for cy in (player_chunk.cy - r)..=(player_chunk.cy + r) { let coord = ChunkCoord { cx, cy, z: player_chunk.z, }; if walkability.load_chunk(coord) { loaded += 1; } } } // Unload chunks outside radius let mut unloaded = 0u32; let to_check = walkability.loaded_chunk_coords(); for coord in to_check { // Only manage chunks on the player's z-level if coord.z != player_chunk.z { continue; } let dx = (coord.cx - player_chunk.cx).abs(); let dy = (coord.cy - player_chunk.cy).abs(); if dx > r || dy > r { walkability.unload_chunk(&coord); unloaded += 1; } } if loaded > 0 || unloaded > 0 { tracing::debug!( "Chunk streaming: loaded {}, unloaded {} (player chunk: ({},{},{}), radius: {})", loaded, unloaded, player_chunk.cx, player_chunk.cy, player_chunk.z, r, ); } } #[cfg(test)] mod tests { use super::*; use bevy_ecs::schedule::Schedule; fn setup_streaming_world( radius: i32, cadence: u64, player_pos: TilePosition, map_width: i32, map_height: i32, ) -> (World, Schedule) { let mut world = World::new(); world.init_resource::(); world.insert_resource(ChunkLoadRadius { radius }); world.insert_resource(ChunkStreamingCadence { ticks: cadence }); world.insert_resource(WalkabilityMap::new(map_width, map_height, 1)); world.spawn((PlayerCharacter, player_pos)); let mut schedule = Schedule::default(); schedule.add_systems(chunk_streaming); (world, schedule) } #[test] fn default_radius_covers_v01_district() { // 256×256 visual district = 8×8 chunks of 32 tiles each. // Player at center (128, 128). Default radius=8. // All original 64 chunks should still be loaded after streaming runs // (streaming may also create empty chunks beyond the district boundary). let (mut world, mut schedule) = setup_streaming_world( 8, 1, // run every tick TilePosition::new(128, 128, 0), 256, 256, ); // Initial state: WalkabilityMap::new(256, 256, 1) creates 8×8 = 64 chunks assert_eq!(world.resource::().chunk_count(), 64); schedule.run(&mut world); // All original chunks (0..8, 0..8) must still be loaded — nothing unloaded let wm = world.resource::(); for cx in 0..8 { for cy in 0..8 { assert!( wm.has_chunk(&ChunkCoord { cx, cy, z: 0 }), "chunk ({},{}) should still be loaded", cx, cy, ); } } // Total count ≥ 64 (streaming may also load chunks beyond district boundary) assert!(wm.chunk_count() >= 64); } #[test] fn small_radius_unloads_distant_chunks() { // Start with a 5×5 chunk map (160×160 tiles), player at center. // Use radius=1 so only 3×3=9 chunks around the player are kept. let (mut world, mut schedule) = setup_streaming_world( 1, 1, TilePosition::new(80, 80, 0), // chunk (2,2) — center of 5×5 160, 160, ); // Initial: 5×5 = 25 chunks let initial_count = world.resource::().chunk_count(); assert_eq!(initial_count, 25); schedule.run(&mut world); // After streaming: only 3×3 = 9 chunks around player chunk (2,2) let after_count = world.resource::().chunk_count(); assert_eq!(after_count, 9, "expected 3×3 chunks within radius=1"); // Verify the player's chunk is still loaded let wm = world.resource::(); assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 })); // Corner chunks should be unloaded assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); assert!(!wm.has_chunk(&ChunkCoord { cx: 4, cy: 4, z: 0 })); } #[test] fn player_movement_loads_new_chunks() { // Start with radius=1, player at (16, 16) → chunk (0,0). // Map is 3×3 chunks (96×96 tiles). let (mut world, mut schedule) = setup_streaming_world( 1, 1, TilePosition::new(16, 16, 0), // chunk (0,0) 96, 96, ); // Run streaming — unloads distant chunks schedule.run(&mut world); // Only chunks (0,0), (0,1), (1,0), (1,1) should be loaded // (radius=1 from chunk (0,0): cx ∈ [-1..1], cy ∈ [-1..1], // but negative coords weren't in the original map. // So loaded: (0,0) and its positive neighbors within range) let wm = world.resource::(); assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 })); assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 1, z: 0 })); assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 })); // Chunk (2,2) should be unloaded (distance > 1 from (0,0)) assert!(!wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 })); // Move player to chunk (2,2) let mut q = world.query_filtered::<&mut TilePosition, With>(); let mut pos = q.single_mut(&mut world).unwrap(); pos.x = 80; pos.y = 80; // Advance tick so cadence gate passes world.resource_mut::().tick = 1; schedule.run(&mut world); // Now chunk (2,2) and its neighbors should be loaded let wm = world.resource::(); assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 })); assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 2, z: 0 })); assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 1, z: 0 })); // And chunk (0,0) should now be unloaded (distance 2 from (2,2)) assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); } #[test] fn cadence_gate_skips_intermediate_ticks() { let (mut world, mut schedule) = setup_streaming_world( 1, 10, // run every 10 ticks TilePosition::new(80, 80, 0), 160, 160, ); // tick=0 → runs (0 % 10 == 0) schedule.run(&mut world); assert_eq!(world.resource::().chunk_count(), 9); // Reload all chunks to simulate "something loads chunks back" world.insert_resource(WalkabilityMap::new(160, 160, 1)); assert_eq!(world.resource::().chunk_count(), 25); // tick=5 → should NOT run (5 % 10 != 0) world.resource_mut::().tick = 5; schedule.run(&mut world); assert_eq!( world.resource::().chunk_count(), 25, "should not have run at tick 5" ); // tick=10 → should run (10 % 10 == 0) world.resource_mut::().tick = 10; schedule.run(&mut world); assert_eq!(world.resource::().chunk_count(), 9); } #[test] fn zero_cadence_runs_every_tick() { let (mut world, mut schedule) = setup_streaming_world( 1, 0, // cadence=0 means run every tick TilePosition::new(80, 80, 0), 160, 160, ); // tick=0, cadence=0: condition is `0 > 0 && ...` which is false → runs schedule.run(&mut world); assert_eq!(world.resource::().chunk_count(), 9); } #[test] fn other_z_levels_untouched() { // Create a map with 2 z-levels. Player on z=0 with radius=0 (only own chunk). let mut world = World::new(); world.init_resource::(); world.insert_resource(ChunkLoadRadius { radius: 0 }); world.insert_resource(ChunkStreamingCadence { ticks: 1 }); // 2×2 chunks on 2 z-levels = 8 chunks total world.insert_resource(WalkabilityMap::new(64, 64, 2)); world.spawn((PlayerCharacter, TilePosition::new(16, 16, 0))); let mut schedule = Schedule::default(); schedule.add_systems(chunk_streaming); // Before: 8 chunks (2×2×2) assert_eq!(world.resource::().chunk_count(), 8); schedule.run(&mut world); // After: z=0 should have only 1 chunk (player's own), z=1 untouched (2×2=4) // Total: 1 + 4 = 5 let wm = world.resource::(); assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); assert!(!wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 })); // z=1 chunks all still there assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 1 })); assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 1 })); assert_eq!(wm.chunk_count(), 5); } }