diff --git a/crates/rustmc-server/src/world.rs b/crates/rustmc-server/src/world.rs index 4518306..57df41c 100644 --- a/crates/rustmc-server/src/world.rs +++ b/crates/rustmc-server/src/world.rs @@ -1,5 +1,8 @@ //! Original coordinate-derived preview terrain; no vanilla generation parity. +pub mod edits; +pub mod owner; + #[cfg(test)] mod tests_interaction; diff --git a/crates/rustmc-server/src/world/edits.rs b/crates/rustmc-server/src/world/edits.rs new file mode 100644 index 0000000..44c0eff --- /dev/null +++ b/crates/rustmc-server/src/world/edits.rs @@ -0,0 +1,321 @@ +//! Bounded in-memory edits over an immutable generated chunk baseline. +//! +//! This is a building block, not a gameplay handler. Callers must validate +//! session authority, reach, collision and admission before calling `replace`. +//! One owner serializes writes through `&mut self`. Nothing is persisted and +//! this module is deliberately not connected to the read-only Java preview. +//! A baseline must always be the unmodified chunk from the same generator. + +use super::{Block, CHUNK_SIDE, Chunk, WORLD_HEIGHT}; +use std::collections::BTreeMap; + +type Position = (i64, i64, i64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditError { + OutOfBounds, + WrongChunk, + StaleState { actual: Block }, + Capacity, + RevisionExhausted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EditResult { + pub previous: Block, + pub current: Block, + pub revision: u64, + pub changed: bool, +} + +/// Accepted edits are never evicted to make room for another edit. A full +/// store rejects new positions, but existing positions may still be updated +/// or restored to baseline. Re-streaming can reapply edits to a fresh chunk. +#[derive(Debug)] +pub struct EditOverlay { + edits: BTreeMap, + capacity: usize, + revision: u64, +} + +impl EditOverlay { + pub fn new(capacity: usize) -> Self { + Self { + edits: BTreeMap::new(), + capacity, + revision: 0, + } + } + + pub fn len(&self) -> usize { + self.edits.len() + } + pub fn is_empty(&self) -> bool { + self.edits.is_empty() + } + pub fn revision(&self) -> u64 { + self.revision + } + + fn index(chunk: &Chunk, (x, y, z): Position) -> Result { + if !(0..WORLD_HEIGHT as i64).contains(&y) { + return Err(EditError::OutOfBounds); + } + let side = CHUNK_SIDE as i64; + if x.div_euclid(side) != i64::from(chunk.x) || z.div_euclid(side) != i64::from(chunk.z) { + return Err(EditError::WrongChunk); + } + Ok( + (y as usize * CHUNK_SIDE + z.rem_euclid(side) as usize) * CHUNK_SIDE + + x.rem_euclid(side) as usize, + ) + } + + pub fn block(&self, baseline: &Chunk, position: Position) -> Result { + let index = Self::index(baseline, position)?; + Ok(self + .edits + .get(&position) + .copied() + .unwrap_or(baseline.blocks[index])) + } + + /// Compare against authoritative state, then commit one ordered change. + /// Every error leaves both the edit set and the revision unchanged. + pub fn replace( + &mut self, + baseline: &Chunk, + position: Position, + expected: Block, + next: Block, + ) -> Result { + let index = Self::index(baseline, position)?; + let previous = self.block(baseline, position)?; + if previous != expected { + return Err(EditError::StaleState { actual: previous }); + } + if previous == next { + return Ok(EditResult { + previous, + current: next, + revision: self.revision, + changed: false, + }); + } + let restoring = next == baseline.blocks[index]; + if !restoring && !self.edits.contains_key(&position) && self.edits.len() >= self.capacity { + return Err(EditError::Capacity); + } + let revision = self + .revision + .checked_add(1) + .ok_or(EditError::RevisionExhausted)?; + if restoring { + self.edits.remove(&position); + } else { + self.edits.insert(position, next); + } + self.revision = revision; + Ok(EditResult { + previous, + current: next, + revision, + changed: true, + }) + } + + /// Materialize this chunk's latest accepted state for encoding. Supply a + /// fresh unedited baseline each time; previously encoded chunks are stale. + pub fn materialize(&self, baseline: &Chunk) -> Chunk { + let mut chunk = baseline.clone(); + let x_min = i64::from(chunk.x) * CHUNK_SIDE as i64; + let x_max = x_min + CHUNK_SIDE as i64 - 1; + for (&position, &block) in self + .edits + .range((x_min, i64::MIN, i64::MIN)..=(x_max, i64::MAX, i64::MAX)) + { + if let Ok(index) = Self::index(&chunk, position) { + chunk.blocks[index] = block; + } + } + chunk + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::world::Generator; + const P: Position = (0, 127, 0); + + #[test] + fn accepted_edits_survive_regeneration_and_independent_readers() { + let generator = Generator::new(2026); + let baseline = generator.generate(0, 0); + let mut edits = EditOverlay::new(2); + let placed = edits + .replace(&baseline, P, Block::Air, Block::Stone) + .unwrap(); + assert_eq!(placed.revision, 1); + for _ in 0..2 { + let reader = edits.materialize(&generator.generate(0, 0)); + assert_eq!(reader.block(0, 127, 0), Some(Block::Stone)); + } + assert_eq!(baseline.block(0, 127, 0), Some(Block::Air)); + } + + #[test] + fn conflicting_writer_rejected_without_mutating_or_incrementing_revision() { + let baseline = Generator::new(2026).generate(0, 0); + let mut edits = EditOverlay::new(2); + edits + .replace(&baseline, P, Block::Air, Block::Stone) + .unwrap(); + assert_eq!( + edits.replace(&baseline, P, Block::Air, Block::Dirt), + Err(EditError::StaleState { + actual: Block::Stone + }) + ); + assert_eq!(edits.revision(), 1); + assert_eq!(edits.block(&baseline, P), Ok(Block::Stone)); + assert_eq!( + edits + .replace(&baseline, P, Block::Stone, Block::Dirt) + .unwrap() + .revision, + 2 + ); + } + + #[test] + fn full_store_preserves_accepted_edits_and_allows_restore_and_reuse() { + let baseline = Generator::new(2026).generate(0, 0); + let mut edits = EditOverlay::new(1); + edits + .replace(&baseline, P, Block::Air, Block::Stone) + .unwrap(); + let other = (1, 127, 0); + assert_eq!( + edits.replace(&baseline, other, Block::Air, Block::Dirt), + Err(EditError::Capacity) + ); + assert_eq!(edits.block(&baseline, P), Ok(Block::Stone)); + assert_eq!(edits.block(&baseline, other), Ok(Block::Air)); + assert_eq!(edits.revision(), 1); + edits + .replace(&baseline, P, Block::Stone, Block::Dirt) + .unwrap(); + edits + .replace(&baseline, P, Block::Dirt, Block::Air) + .unwrap(); + assert!(edits.is_empty()); + edits + .replace(&baseline, other, Block::Air, Block::Dirt) + .unwrap(); + assert_eq!(edits.len(), 1); + assert_eq!( + edits.materialize(&baseline).block(0, 127, 0), + Some(Block::Air) + ); + } + + #[test] + fn negative_coordinates_and_adjacent_chunks_remain_distinct() { + let generator = Generator::new(2026); + let left = generator.generate(-1, -1); + let right = generator.generate(0, -1); + let mut edits = EditOverlay::new(2); + edits + .replace(&left, (-1, 127, -1), Block::Air, Block::Stone) + .unwrap(); + edits + .replace(&right, (0, 127, -1), Block::Air, Block::Dirt) + .unwrap(); + assert_eq!( + edits.materialize(&left).block(15, 127, 15), + Some(Block::Stone) + ); + assert_eq!( + edits.materialize(&right).block(0, 127, 15), + Some(Block::Dirt) + ); + assert_eq!(edits.block(&left, (0, 127, -1)), Err(EditError::WrongChunk)); + } + + #[test] + fn out_of_bounds_and_extreme_coordinates_never_modify_state() { + let baseline = Generator::new(2026).generate(0, 0); + let mut edits = EditOverlay::new(2); + for position in [ + (0, -1, 0), + (0, 128, 0), + (i64::MIN, 127, 0), + (i64::MAX, 127, 0), + (0, 127, i64::MIN), + ] { + assert!( + edits + .replace(&baseline, position, Block::Air, Block::Stone) + .is_err() + ); + } + assert!(edits.is_empty()); + assert_eq!(edits.revision(), 0); + } + + #[test] + fn no_op_does_not_consume_capacity_or_revision() { + let baseline = Generator::new(2026).generate(0, 0); + let mut edits = EditOverlay::new(0); + assert!( + !edits + .replace(&baseline, P, Block::Air, Block::Air) + .unwrap() + .changed + ); + assert_eq!( + edits.replace(&baseline, P, Block::Air, Block::Stone), + Err(EditError::Capacity) + ); + assert_eq!(edits.revision(), 0); + assert!(edits.is_empty()); + } + + #[test] + fn breaking_generated_block_and_restoring_it_releases_the_slot() { + let baseline = Generator::new(2026).generate(0, 0); + let position = (0, 20, 0); + let original = baseline.block(0, 20, 0).unwrap(); + assert_ne!(original, Block::Air); + let mut edits = EditOverlay::new(1); + edits + .replace(&baseline, position, original, Block::Air) + .unwrap(); + assert_eq!( + edits.materialize(&baseline).block(0, 20, 0), + Some(Block::Air) + ); + edits + .replace(&baseline, position, Block::Air, original) + .unwrap(); + assert!(edits.is_empty()); + assert_eq!(edits.materialize(&baseline), baseline); + } + + #[test] + fn revision_overflow_fails_before_any_mutation() { + let baseline = Generator::new(2026).generate(0, 0); + let mut edits = EditOverlay::new(2); + edits + .replace(&baseline, P, Block::Air, Block::Stone) + .unwrap(); + edits.revision = u64::MAX; + assert_eq!( + edits.replace(&baseline, P, Block::Stone, Block::Air), + Err(EditError::RevisionExhausted) + ); + assert_eq!(edits.block(&baseline, P), Ok(Block::Stone)); + assert_eq!(edits.revision(), u64::MAX); + } +} diff --git a/crates/rustmc-server/src/world/owner.rs b/crates/rustmc-server/src/world/owner.rs new file mode 100644 index 0000000..565b0b9 --- /dev/null +++ b/crates/rustmc-server/src/world/owner.rs @@ -0,0 +1,219 @@ +//! Single-writer, bounded ownership for original preview terrain. +//! +//! Baselines never leave this owner as mutable references. Loading uses its +//! pinned generator; reads return detached snapshots. This is not admission, +//! authentication, networking, persistence, or a live Java gameplay handler. + +use super::edits::{EditError, EditOverlay, EditResult}; +use super::{Block, CHUNK_SIDE, Chunk, Generator, WORLD_HEIGHT}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OwnerError { + InvalidPosition, + ChunkNotLoaded, + LoadedCapacity, + Edit(EditError), +} + +pub struct WorldOwner { + generator: Generator, + baselines: BTreeMap<(i32, i32), Chunk>, + loaded_capacity: usize, + edits: EditOverlay, +} + +impl WorldOwner { + pub fn new(seed: u64, loaded_capacity: usize, edit_capacity: usize) -> Self { + Self { + generator: Generator::new(seed), + baselines: BTreeMap::new(), + loaded_capacity, + edits: EditOverlay::new(edit_capacity), + } + } + + /// Existing loads are idempotent, even at capacity. No implicit eviction. + pub fn load(&mut self, x: i32, z: i32) -> Result<(), OwnerError> { + if self.baselines.contains_key(&(x, z)) { + return Ok(()); + } + if self.baselines.len() >= self.loaded_capacity { + return Err(OwnerError::LoadedCapacity); + } + self.baselines.insert((x, z), self.generator.generate(x, z)); + Ok(()) + } + + /// Unloading releases terrain memory but never discards accepted edits. + pub fn unload(&mut self, x: i32, z: i32) -> bool { + self.baselines.remove(&(x, z)).is_some() + } + + pub fn loaded_count(&self) -> usize { + self.baselines.len() + } + + pub fn revision(&self) -> u64 { + self.edits.revision() + } + + fn key((x, y, z): (i64, i64, i64)) -> Result<(i32, i32), OwnerError> { + if !(0..WORLD_HEIGHT as i64).contains(&y) { + return Err(OwnerError::InvalidPosition); + } + let cx = i32::try_from(x.div_euclid(CHUNK_SIDE as i64)); + let cz = i32::try_from(z.div_euclid(CHUNK_SIDE as i64)); + match (cx, cz) { + (Ok(x), Ok(z)) => Ok((x, z)), + _ => Err(OwnerError::InvalidPosition), + } + } + + pub fn block(&self, position: (i64, i64, i64)) -> Result { + let key = Self::key(position)?; + let baseline = self.baselines.get(&key).ok_or(OwnerError::ChunkNotLoaded)?; + self.edits + .block(baseline, position) + .map_err(OwnerError::Edit) + } + + pub fn replace( + &mut self, + position: (i64, i64, i64), + expected: Block, + next: Block, + ) -> Result { + let key = Self::key(position)?; + let baseline = self.baselines.get(&key).ok_or(OwnerError::ChunkNotLoaded)?; + self.edits + .replace(baseline, position, expected, next) + .map_err(OwnerError::Edit) + } + + /// Detached snapshot paired with the owner's revision at observation time. + pub fn snapshot(&self, x: i32, z: i32) -> Result<(u64, Chunk), OwnerError> { + let baseline = self + .baselines + .get(&(x, z)) + .ok_or(OwnerError::ChunkNotLoaded)?; + Ok((self.revision(), self.edits.materialize(baseline))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + const P: (i64, i64, i64) = (-1, 127, -1); + + #[test] + fn unloaded_access_cannot_generate_or_mutate_terrain() { + let mut owner = WorldOwner::new(2026, 1, 2); + assert_eq!(owner.block(P), Err(OwnerError::ChunkNotLoaded)); + assert_eq!(owner.snapshot(-1, -1), Err(OwnerError::ChunkNotLoaded)); + assert_eq!( + owner.replace(P, Block::Air, Block::Stone), + Err(OwnerError::ChunkNotLoaded) + ); + assert_eq!(owner.loaded_count(), 0); + assert_eq!(owner.revision(), 0); + } + + #[test] + fn capacity_rejects_new_load_without_evicting_existing_state() { + let mut owner = WorldOwner::new(2026, 1, 2); + owner.load(-1, -1).unwrap(); + owner.replace(P, Block::Air, Block::Stone).unwrap(); + owner.load(-1, -1).unwrap(); + assert_eq!(owner.load(0, 0), Err(OwnerError::LoadedCapacity)); + assert_eq!(owner.loaded_count(), 1); + assert_eq!(owner.block(P), Ok(Block::Stone)); + assert_eq!( + WorldOwner::new(0, 0, 0).load(0, 0), + Err(OwnerError::LoadedCapacity) + ); + } + + #[test] + fn pinned_baseline_and_edits_survive_unload_reload() { + let mut owner = WorldOwner::new(2026, 1, 2); + owner.load(-1, -1).unwrap(); + let baseline = owner.snapshot(-1, -1).unwrap().1; + assert_eq!(baseline, Generator::new(2026).generate(-1, -1)); + owner.replace(P, Block::Air, Block::Stone).unwrap(); + let edited = owner.snapshot(-1, -1).unwrap(); + assert!(owner.unload(-1, -1)); + assert!(!owner.unload(-1, -1)); + assert_eq!( + owner.replace(P, Block::Stone, Block::Dirt), + Err(OwnerError::ChunkNotLoaded) + ); + owner.load(0, 0).unwrap(); + owner.unload(0, 0); + owner.load(-1, -1).unwrap(); + assert_eq!(owner.snapshot(-1, -1).unwrap(), edited); + owner.replace(P, Block::Stone, Block::Air).unwrap(); + assert_eq!(owner.snapshot(-1, -1).unwrap().1, baseline); + } + + #[test] + fn independent_snapshots_share_commits_but_cannot_mutate_the_owner() { + let mut owner = WorldOwner::new(2026, 1, 2); + owner.load(-1, -1).unwrap(); + let before = owner.snapshot(-1, -1).unwrap(); + owner.replace(P, Block::Air, Block::Stone).unwrap(); + let first = owner.snapshot(-1, -1).unwrap(); + let mut second = owner.snapshot(-1, -1).unwrap(); + assert_eq!(first, second); + assert_eq!(first.0, 1); + assert_eq!(before.0, 0); + assert_eq!(before.1.block(15, 127, 15), Some(Block::Air)); + second.1.x = 100; + assert_eq!(owner.snapshot(-1, -1).unwrap(), first); + assert_eq!( + owner.replace(P, Block::Air, Block::Dirt), + Err(OwnerError::Edit(EditError::StaleState { + actual: Block::Stone + })) + ); + assert_eq!(owner.revision(), 1); + } + + #[test] + fn invalid_coordinates_and_negative_chunk_edges_are_checked() { + let mut owner = WorldOwner::new(2026, 1, 2); + owner.load(-1, -1).unwrap(); + for p in [ + (0, -1, 0), + (0, 128, 0), + (i64::MIN, 127, 0), + (i64::MAX, 127, 0), + ] { + assert_eq!(owner.block(p), Err(OwnerError::InvalidPosition)); + assert_eq!( + owner.replace(p, Block::Air, Block::Stone), + Err(OwnerError::InvalidPosition) + ); + } + assert_eq!(owner.block((-16, 127, -16)), Ok(Block::Air)); + assert_eq!(owner.block((-17, 127, -1)), Err(OwnerError::ChunkNotLoaded)); + assert_eq!(owner.revision(), 0); + } + + #[test] + fn unloading_does_not_bypass_edit_capacity() { + let mut owner = WorldOwner::new(2026, 1, 1); + owner.load(-1, -1).unwrap(); + owner.replace(P, Block::Air, Block::Stone).unwrap(); + owner.unload(-1, -1); + owner.load(0, 0).unwrap(); + assert_eq!( + owner.replace((0, 127, 0), Block::Air, Block::Dirt), + Err(OwnerError::Edit(EditError::Capacity)) + ); + assert_eq!(owner.revision(), 1); + owner.unload(0, 0); + owner.load(-1, -1).unwrap(); + assert_eq!(owner.block(P), Ok(Block::Stone)); + } +} diff --git a/docs/research/hatch-edit-overlay.md b/docs/research/hatch-edit-overlay.md new file mode 100644 index 0000000..9606fe0 --- /dev/null +++ b/docs/research/hatch-edit-overlay.md @@ -0,0 +1,63 @@ +# Hatch development branch: bounded block-edit overlay + +This independent Apache-2.0 implementation is based on RustMC commit +`cf5cac9742db9ae06625a1922bc63e57730c5f74`. It is not accepted upstream. +External contributions are currently paused under CONTRIBUTING.md; no PR, +maintainer endorsement, or completed multiplayer milestone is implied. + +## Implemented + +`world::edits::EditOverlay` stores accepted changes separately from generated +terrain. It uses an explicit entry cap, never evicts accepted edits, rejects +stale expected values, assigns monotonically increasing revisions, and +materializes fresh chunk copies for consumers. Restoring a cell to its baseline +value releases its slot. Rejected edits leave revision and content unchanged. +Eight new tests exercise regeneration, independent readers, conflicting writes, +capacity, restoring terrain, negative chunk boundaries, invalid coordinates, +no-ops, and revision overflow. + +## Validation on Linux, Rust 1.98.1 + +- Targeted edit-overlay tests: 8 passed. +- Full workspace: 272 tests passed, 12 ignored (operator-owned fixtures required). +- Formatting, check, clippy with warnings denied, build, documentation, + dependency-license checker and example configuration validation passed. +- Baseline server library before the change: 209 passed, 12 ignored. + +These are executed local results, not GitHub CI results or a two-client demo. + +## Boundaries and next work + +The module is not wired into the Java preview. It does not validate player +permissions, reach, collision, inventory, session sequences or tick admission; +these belong before the mutation primitive. No disk persistence, packet +broadcast, player visibility, authentication or cross-play is added. +The caller must supply fresh immutable baseline chunks from the SAME generator +and must serialize writes through one authoritative owner. A useful next step +is a bounded owner that pins the generator and validates loaded-chunk access, +followed by session sequencing and reproducible two-reader integration tests. +Do not expose a network gameplay endpoint or mark world_ready true. + +## Provenance + +Original Rust code written for Hatch from RustMC's public world API and its +M3 authoritative block-interaction design. No other server implementation, +Mojang code, assets, registry data or private materials were copied. + +## Bounded owner (independent second step) + +`world::owner::WorldOwner` pins one original preview Generator by seed, retains +private immutable baselines for at most the configured loaded chunk count, and +owns the bounded edit overlay. Access requires a loaded chunk; global positions +are checked before conversion to chunk coordinates. Loading at capacity fails +without eviction. Unloading frees baseline memory but retains edits and revision, +so cycling loaded chunks cannot evade edit capacity. Snapshot reads return an +owned chunk and its observation revision; existing snapshots can become stale. +Six additional tests exercise unloaded rejection, capacity, pinned regeneration, +independent reads, stale writers, negative coordinates and edit retention. + +This is an in-memory single-writer foundation only. It does not authenticate +sessions, validate reach/collisions, process network intents, persist edits or +invalidate packet caches. Java preview and world readiness remain unchanged. +Next: bounded sequence admission with explicit identity/authorization integration +seams, without connecting unauthenticated preview input to this owner.