diff --git a/crates/rustmc-server/src/world.rs b/crates/rustmc-server/src/world.rs index 4518306..5757795 100644 --- a/crates/rustmc-server/src/world.rs +++ b/crates/rustmc-server/src/world.rs @@ -1,5 +1,7 @@ //! Original coordinate-derived preview terrain; no vanilla generation parity. +pub mod edits; + #[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/docs/research/hatch-edit-overlay.md b/docs/research/hatch-edit-overlay.md new file mode 100644 index 0000000..84ca5b0 --- /dev/null +++ b/docs/research/hatch-edit-overlay.md @@ -0,0 +1,45 @@ +# 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.