214 lines
7.8 KiB
Rust
214 lines
7.8 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
use super::*;
|
|
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
|
use shared::save::SaveError;
|
|
use shared::world::{BlockId, ChunkData, ChunkPos};
|
|
use std::collections::HashSet;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use crate::save::{RegionFile, SaveRequest, region_path};
|
|
|
|
/// Builds a generator with a small, cheap terrain configuration for streaming tests.
|
|
fn test_generator() -> VoxelGenerator {
|
|
let config = WorldGenConfig {
|
|
base_height: 8,
|
|
noise_scale: 0.05,
|
|
surface_block: BlockId(1),
|
|
subsurface_block: BlockId(2),
|
|
stone_block: BlockId(3),
|
|
};
|
|
VoxelGenerator::new(config, 42)
|
|
}
|
|
|
|
/// Builds a server world whose saves resolve against `region_dir`, backed by a small baseline cache.
|
|
fn test_world(region_dir: std::path::PathBuf) -> ServerWorld {
|
|
let capacity = std::num::NonZeroUsize::new(64).unwrap_or(std::num::NonZeroUsize::MIN);
|
|
ServerWorld::new(test_generator(), region_dir, capacity)
|
|
}
|
|
|
|
/// Repeatedly reconciles `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout.
|
|
fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet<ChunkPos>) -> StreamStats {
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
let stats = world.reconcile(desired);
|
|
if stats.in_flight == 0 {
|
|
return stats;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"worker pool did not drain in time"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
}
|
|
}
|
|
|
|
/// Issues a flush against the world's save actor and blocks until every dirty region is written. Because write-backs and this flush travel the same sender to the single actor thread, the reply confirms the preceding writes are durable.
|
|
fn flush(world: &ServerWorld) -> Result<(), SaveError> {
|
|
let (reply_tx, reply_rx) = crossbeam_channel::bounded(1);
|
|
// A send error means the actor has already stopped, leaving nothing to flush.
|
|
if world
|
|
.save_tx
|
|
.send(SaveRequest::Flush { reply: reply_tx })
|
|
.is_err()
|
|
{
|
|
return Ok(());
|
|
}
|
|
reply_rx.recv().unwrap_or(Ok(()))
|
|
}
|
|
|
|
#[test]
|
|
fn reconcile_converges_over_multiple_passes() -> Result<(), SaveError> {
|
|
// A fresh empty directory means every load is a miss and resolves to the baseline.
|
|
let dir = tempfile::tempdir()?;
|
|
let mut world = test_world(dir.path().to_path_buf());
|
|
let mut desired = HashSet::new();
|
|
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired);
|
|
|
|
// The first pass only dispatches work; because loading is off-thread, nothing is resident yet and every position is in flight.
|
|
let first = world.reconcile(&desired);
|
|
assert_eq!(first.loaded, 0);
|
|
assert_eq!(first.resident, 0);
|
|
assert!(first.in_flight > 0);
|
|
|
|
// Later passes drain finished chunks until the pool is idle, at which point every desired position must be resident.
|
|
let final_stats = drain_to_idle(&mut world, &desired);
|
|
assert_eq!(final_stats.in_flight, 0);
|
|
assert_eq!(final_stats.resident, desired.len());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn evicted_chunk_is_not_repopulated_on_arrival() -> Result<(), SaveError> {
|
|
let dir = tempfile::tempdir()?;
|
|
let mut world = test_world(dir.path().to_path_buf());
|
|
let target = ChunkPos::new(0, 0, 0);
|
|
let mut desired = HashSet::new();
|
|
desired.insert(target);
|
|
|
|
// Dispatch the chunk, then immediately stop wanting it.
|
|
world.reconcile(&desired);
|
|
|
|
// Every subsequent pass reconciles against an empty desired set, so the finished chunk is discarded on arrival rather than inserted.
|
|
let empty = HashSet::new();
|
|
let final_stats = drain_to_idle(&mut world, &empty);
|
|
assert_eq!(final_stats.in_flight, 0);
|
|
assert_eq!(final_stats.resident, 0);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> {
|
|
// A modified chunk is written to disk, then streamed back; the resident chunk must show the edit rather than the bare baseline.
|
|
let dir = tempfile::tempdir()?;
|
|
let pos = ChunkPos::new(0, 0, 0);
|
|
let edited_index = 100u32;
|
|
let edited_block = BlockId(999);
|
|
|
|
let mut data = ChunkData::new(pos, 0);
|
|
data.set(edited_index, edited_block);
|
|
|
|
let mut region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?;
|
|
region.write_chunk(pos, &data, 0)?;
|
|
region.save()?;
|
|
|
|
let mut world = test_world(dir.path().to_path_buf());
|
|
let mut desired = HashSet::new();
|
|
desired.insert(pos);
|
|
drain_to_idle(&mut world, &desired);
|
|
|
|
// The resident chunk must carry the stored edit layered over its regenerated baseline.
|
|
assert!(
|
|
world
|
|
.chunk(pos)
|
|
.is_some_and(|chunk| chunk.blocks[edited_index as usize] == edited_block)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn dirty_chunk_is_written_back_on_eviction() -> Result<(), SaveError> {
|
|
// A resident chunk edited away from its baseline must survive an evict -> flush -> reload round-trip.
|
|
let dir = tempfile::tempdir()?;
|
|
let pos = ChunkPos::new(0, 0, 0);
|
|
let edited_index = 100usize;
|
|
let edited_block = BlockId(999);
|
|
|
|
let mut world = test_world(dir.path().to_path_buf());
|
|
let mut desired = HashSet::new();
|
|
desired.insert(pos);
|
|
drain_to_idle(&mut world, &desired);
|
|
|
|
// Mutate the resident chunk so it diverges from the baseline the eviction diff regenerates.
|
|
assert!(
|
|
world
|
|
.chunks
|
|
.get_mut(&pos)
|
|
.map(|chunk| chunk.blocks[edited_index] = edited_block)
|
|
.is_some()
|
|
);
|
|
|
|
// Reconciling against an empty desired set evicts the chunk, sending its diff to the actor.
|
|
world.reconcile(&HashSet::new());
|
|
// The flush shares the eviction's sender, so its reply confirms the write-back is on disk.
|
|
flush(&world)?;
|
|
|
|
// A fresh world over the same directory must stream the chunk back with the edit intact.
|
|
let mut reloaded = test_world(dir.path().to_path_buf());
|
|
drain_to_idle(&mut reloaded, &desired);
|
|
assert!(
|
|
reloaded
|
|
.chunk(pos)
|
|
.is_some_and(|chunk| chunk.blocks[edited_index] == edited_block)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> {
|
|
// An unmodified chunk equals its baseline, so eviction must persist no record for it.
|
|
let dir = tempfile::tempdir()?;
|
|
let pos = ChunkPos::new(0, 0, 0);
|
|
|
|
let mut world = test_world(dir.path().to_path_buf());
|
|
let mut desired = HashSet::new();
|
|
desired.insert(pos);
|
|
drain_to_idle(&mut world, &desired);
|
|
|
|
// Evict without modifying the chunk, then flush.
|
|
world.reconcile(&HashSet::new());
|
|
flush(&world)?;
|
|
|
|
// No record may exist for a chunk that never diverged from its baseline.
|
|
let region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?;
|
|
assert!(region.read_chunk(pos)?.is_none());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cylinder_contains_expected_columns() {
|
|
let mut set = HashSet::new();
|
|
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set);
|
|
|
|
assert!(set.contains(&ChunkPos::new(0, 0, 0)));
|
|
// A corner cell is outside the disc (dx=2, dz=2 -> 8 > 4).
|
|
assert!(!set.contains(&ChunkPos::new(2, 0, 2)));
|
|
// An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4).
|
|
assert!(set.contains(&ChunkPos::new(2, 0, 0)));
|
|
// The vertical extent is radius/2 = 1, so y=2 is out of range.
|
|
assert!(!set.contains(&ChunkPos::new(0, 2, 0)));
|
|
assert!(set.contains(&ChunkPos::new(0, 1, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn cylinder_translates_with_center() {
|
|
let mut origin = HashSet::new();
|
|
cylinder_chunks(ChunkPos::new(0, 0, 0), 3, &mut origin);
|
|
|
|
let mut shifted = HashSet::new();
|
|
cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted);
|
|
|
|
// The shape is translation-invariant: the same count regardless of center.
|
|
assert_eq!(origin.len(), shifted.len());
|
|
}
|