Synvael/crates/server/src/tests/world_server.rs

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