Files
settled-reach/server/src/simulation/chunk_streaming.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

338 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<SimulationTime>,
cadence: Res<ChunkStreamingCadence>,
radius: Res<ChunkLoadRadius>,
walkability: Option<ResMut<WalkabilityMap>>,
player_query: Query<&TilePosition, With<PlayerCharacter>>,
) {
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::<SimulationTime>();
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::<WalkabilityMap>().chunk_count(), 64);
schedule.run(&mut world);
// All original chunks (0..8, 0..8) must still be loaded — nothing unloaded
let wm = world.resource::<WalkabilityMap>();
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::<WalkabilityMap>().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::<WalkabilityMap>().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::<WalkabilityMap>();
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::<WalkabilityMap>();
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<PlayerCharacter>>();
let mut pos = q.single_mut(&mut world).unwrap();
pos.x = 80;
pos.y = 80;
// Advance tick so cadence gate passes
world.resource_mut::<SimulationTime>().tick = 1;
schedule.run(&mut world);
// Now chunk (2,2) and its neighbors should be loaded
let wm = world.resource::<WalkabilityMap>();
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::<WalkabilityMap>().chunk_count(), 9);
// Reload all chunks to simulate "something loads chunks back"
world.insert_resource(WalkabilityMap::new(160, 160, 1));
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 25);
// tick=5 → should NOT run (5 % 10 != 0)
world.resource_mut::<SimulationTime>().tick = 5;
schedule.run(&mut world);
assert_eq!(
world.resource::<WalkabilityMap>().chunk_count(),
25,
"should not have run at tick 5"
);
// tick=10 → should run (10 % 10 == 0)
world.resource_mut::<SimulationTime>().tick = 10;
schedule.run(&mut world);
assert_eq!(world.resource::<WalkabilityMap>().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::<WalkabilityMap>().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::<SimulationTime>();
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::<WalkabilityMap>().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::<WalkabilityMap>();
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);
}
}