Files
settled-reach/server/src/simulation/chunk_streaming.rs
T
jpmschweitzerandClaude Fable 5 346d87df7a chore(meta): docs/build sweep + tooling test gate (T-1069, T-1066)
- make test-tooling: planet-gen determinism guard + import_economics
  --dry-run, wired into pre-push on TOOLING_CHANGED; ruff widened to
  E4/E7/E9/F/W (90 safe auto-fixes applied; E402/E702/F841 ignored with
  documented counts)
- one-generator reality fixed in DEVOPS.md, asset-pipeline rule, CLAUDE.md
  (import_economics sole generator since #951/D-223); dead check-protocol
  target deleted; DEVOPS hook/config sections rewritten from the actual
  hook sources; team-patterns gate description updated (client+tooling)
- project.yaml: 0.2.0 → 0.4.0 per the 0.{phase}.{n} scheme, description
  refreshed from the v0.1 Sova narration to cascade reality
- stale comment sweep: voxel.rs stub claims (all 8 families implemented),
  cascade.rs TODO recited to T-1044, main.rs D-192 handshake claim,
  relationships.rs/chunk_streaming.rs version targets → phase language
- gitignore: client/settings.db* e2e-run artifacts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:55 +02:00

340 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. Until Phase 5 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 per-demand loading over the
//! generated world once player control lands (Phase 5).
//!
//! 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 hand-authored district (256×256 = 8×8
/// chunks). For Phase 5 borderless streaming over the generated world, set to
/// 3-4 for memory-bounded loading.
#[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);
}
}