Expand voxel gameplay, lighting, full-height streaming and world imports
MVP checks / mvp (push) Canceled after 0s

Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
This commit is contained in:
Emil
2026-09-17 02:10:53 +03:00
parent 26748912c6
commit c7e86663d8
111 changed files with 24257 additions and 598 deletions
+28
View File
@@ -407,6 +407,21 @@ fn world_name(dimension: &str) -> String {
}
pub fn import_anvil(source: &Path, destination: &Path, mode: Mode) -> Result<Report> {
import_anvil_impl(source, destination, mode, false)
}
/// Load source sections as an immutable baseline, without per-block edit history.
/// Subsequent edits remain journaled; resetting them restores the imported map.
pub fn import_anvil_baseline(source: &Path, destination: &Path, mode: Mode) -> Result<Report> {
import_anvil_impl(source, destination, mode, true)
}
fn import_anvil_impl(
source: &Path,
destination: &Path,
mode: Mode,
baseline: bool,
) -> Result<Report> {
let stage = io::stage(destination)?;
ensure!(
!stage
@@ -475,6 +490,16 @@ pub fn import_anvil(source: &Path, destination: &Path, mode: Mode) -> Result<Rep
.iter()
.map(|s| store.register_block(s))
.collect::<Result<Vec<_>>>()?;
if baseline {
let cells = Box::new(std::array::from_fn(|i| registry[indices[i]]));
let nonair = cells.iter().filter(|&&block| block != 0).count();
report.nonair_blocks += nonair as u64;
report.sections += 1;
if nonair > 0 {
store.materialize_section(&world, [x, y, z], &cells)?;
}
continue;
}
let mut changes = Vec::new();
for (index, pid) in indices.into_iter().enumerate() {
let block = registry[pid];
@@ -512,6 +537,9 @@ pub fn import_anvil(source: &Path, destination: &Path, mode: Mode) -> Result<Rep
}
store.flush()?;
drop(store);
if baseline {
report.issue("import_baseline", "preserved", "WorldStore", "Source blocks stored as immutable section baselines without per-block edit history. Later edits are journaled; reset restores the imported baseline.");
}
report.issue("opaque_world_data","preserved","compat/original","All source files retained with SHA256: level.dat, entities, biomes, block entities, POI, datapacks, playerdata, unknown files. Native simulation edits blocks only; no entity/biome migration.");
let mut provenance = Provenance {
schema_version: 1,
+1 -1
View File
@@ -5,7 +5,7 @@ mod entities;
mod io;
pub mod nbt;
mod schem;
pub use anvil::{export_anvil, import_anvil};
pub use anvil::{export_anvil, import_anvil, import_anvil_baseline};
use anyhow::{Result, ensure};
pub use schem::{export_schem, import_schem};
use serde::{Deserialize, Serialize};
+14 -2
View File
@@ -1,6 +1,8 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use shacraft_compat::{Mode, export_anvil, export_schem, import_anvil, import_schem};
use shacraft_compat::{
Mode, export_anvil, export_schem, import_anvil, import_anvil_baseline, import_schem,
};
use std::path::PathBuf;
#[derive(Parser)]
#[command(about = "Offline bounded Java26.2 world converter; every destination must be new")]
@@ -13,6 +15,9 @@ enum Command {
ImportAnvil {
source: PathBuf,
destination: PathBuf,
/// Store source sections without creating per-block edit history.
#[arg(long)]
baseline: bool,
#[arg(long, value_enum, default_value = "exact")]
mode: Mode,
},
@@ -61,7 +66,14 @@ fn run() -> Result<()> {
source,
destination,
mode,
} => import_anvil(&source, &destination, mode)?,
baseline,
} => {
if baseline {
import_anvil_baseline(&source, &destination, mode)?
} else {
import_anvil(&source, &destination, mode)?
}
}
Command::ExportAnvil {
store,
destination,
+51 -1
View File
@@ -1,5 +1,5 @@
use shacraft_compat::{
Mode, export_anvil, export_schem, import_anvil, import_schem,
Mode, export_anvil, export_schem, import_anvil, import_anvil_baseline, import_schem,
nbt::{self, Tag},
};
use shacraft_core::{BlockChange, WorldStore};
@@ -151,6 +151,56 @@ fn genuine_java_world_import_exact_roundtrip_edit_and_new_chunk() {
assert_eq!(store.registry()[id as usize], "minecraft:stone");
}
#[test]
fn anvil_baseline_preserves_roundtrip_edits_snapshots_and_reset() {
let t = tempfile::tempdir().unwrap();
let path = t.path().join("baseline");
let source = fixture("java26_2-world");
let report = import_anvil_baseline(&source, &path, Mode::Exact).unwrap();
assert_eq!(report.nonair_blocks, 3);
assert_eq!(report.worlds["main"], 0);
let exact = t.path().join("exact");
export_anvil(&path, None, &exact, Mode::Exact).unwrap();
assert_tree_equal(&source, &exact);
let db = rusqlite::Connection::open(path.join("worlds.sqlite3")).unwrap();
let history: i64 = db
.query_row("SELECT count(*) FROM operation_changes", [], |r| r.get(0))
.unwrap();
assert_eq!(history, 0);
drop(db);
let mut store = WorldStore::open(&path, 2).unwrap();
let pos = [-1, -1, 31];
let original = store.get_block("main", pos).unwrap();
assert_ne!(original, 0);
store
.edit("main", 0, "remove", vec![BlockChange { pos, block: 0 }])
.unwrap();
drop(store);
assert!(export_anvil(&path, None, &t.path().join("rejected"), Mode::Exact).is_err());
let edited = t.path().join("edited");
export_anvil(&path, None, &edited, Mode::BestEffort).unwrap();
let reimport = t.path().join("reimport");
import_anvil_baseline(&edited, &reimport, Mode::Exact).unwrap();
assert_eq!(
WorldStore::open(&reimport, 2)
.unwrap()
.get_block("main", pos)
.unwrap(),
0
);
let mut store = WorldStore::open(&path, 2).unwrap();
store.reset_world("main", 1, "reset").unwrap();
assert_eq!(store.get_block("main", pos).unwrap(), original);
store.create_world("playable", Some("main")).unwrap();
assert_eq!(store.get_block("playable", pos).unwrap(), original);
assert!(
store
.read_section("playable", [-1, -1, 1])
.unwrap()
.is_some()
);
}
#[test]
fn native_new_world_export_and_schematic() {
let t = tempfile::tempdir().unwrap();
+10
View File
@@ -111,6 +111,16 @@ pub(crate) fn configure(conn: &mut Connection) -> Result<()> {
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
tx.commit().context("storage_commit: initialize schema")?;
}
// Additive v1 extension: immutable natural terrain underneath edit overlays.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS generated_sections (
world_id INTEGER NOT NULL REFERENCES worlds(id),
x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL,
blob_id INTEGER NOT NULL REFERENCES blobs(id),
PRIMARY KEY(world_id,x,y,z)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS generated_blob_idx ON generated_sections(blob_id);",
)?;
let check: String = conn.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?;
if check != "ok" {
bail!("storage_corrupt: {check}");
+85 -3
View File
@@ -162,9 +162,14 @@ impl WorldStore {
params![world.id, world.revision],
)?;
let id = tx.last_insert_rowid();
tx.execute(
"INSERT INTO snapshot_sections(snapshot_id,x,y,z,blob_id)
SELECT ?1,x,y,z,blob_id FROM generated_sections WHERE world_id=?2",
params![id, world.id],
)?;
if let Some(base) = world.base {
tx.execute(
"INSERT INTO snapshot_sections(snapshot_id,x,y,z,blob_id)
"INSERT OR REPLACE INTO snapshot_sections(snapshot_id,x,y,z,blob_id)
SELECT ?1,x,y,z,blob_id FROM snapshot_sections WHERE snapshot_id=?2",
params![id, base],
)?;
@@ -246,6 +251,51 @@ impl WorldStore {
result
}
/// Read a complete stored section, including explicit all-air overrides.
/// None means no durable data exists; a procedural host may generate it.
pub fn read_section(&mut self, name: &str, pos: Pos) -> Result<Option<Box<[u32; 4096]>>> {
validate_world_name(name)?;
validate_section_pos(pos)?;
let world = lookup_world(&self.conn, name)?;
effective_blob(&self.conn, world, pos)?
.map(|id| load_decoded(&self.conn, &mut self.cache, self.registry.len(), id))
.transpose()
}
/// Persist the immutable procedural baseline before its first edit. This
/// materializes already-existing natural terrain and does not advance the
/// edit revision. A repeated call never overwrites a baseline or overlay.
pub fn materialize_section(
&mut self,
name: &str,
pos: Pos,
cells: &[u32; 4096],
) -> Result<bool> {
validate_world_name(name)?;
validate_section_pos(pos)?;
ensure!(
cells.iter().all(|id| (*id as usize) < self.registry.len()),
"unknown_block in generated section"
);
let started = Instant::now();
let tx = self
.conn
.transaction_with_behavior(TransactionBehavior::Immediate)?;
let world = lookup_world(&tx, name)?;
if effective_blob(&tx, world, pos)?.is_some() {
return Ok(false);
}
let blob = intern_blob(&tx, cells)?;
let [x, y, z] = pos;
tx.execute(
"INSERT INTO generated_sections(world_id,x,y,z,blob_id) VALUES(?1,?2,?3,?4,?5)",
params![world.id, x, y, z, blob],
)?;
tx.commit().context("storage_commit: materialize_section")?;
self.record_commit(started);
Ok(true)
}
pub fn edit(
&mut self,
name: &str,
@@ -530,6 +580,7 @@ impl WorldStore {
"WITH section_keys AS (
SELECT x,y,z FROM world_sections WHERE world_id=?1
UNION SELECT x,y,z FROM snapshot_sections WHERE snapshot_id=?2
UNION SELECT x,y,z FROM generated_sections WHERE world_id=?1
) SELECT x,y,z FROM section_keys
WHERE (?3 IS NULL OR (x,y,z) > (?3,?4,?5))
ORDER BY x,y,z LIMIT ?6",
@@ -579,6 +630,7 @@ impl WorldStore {
SELECT b.id FROM blobs b
WHERE NOT EXISTS(SELECT 1 FROM world_sections w WHERE w.blob_id=b.id)
AND NOT EXISTS(SELECT 1 FROM snapshot_sections s WHERE s.blob_id=b.id)
AND NOT EXISTS(SELECT 1 FROM generated_sections g WHERE g.blob_id=b.id)
LIMIT ?1)",
[max_blobs as i64],
)?;
@@ -630,6 +682,7 @@ impl WorldStore {
"snapshot_count": count("snapshots"),
"immutable_blob_count": count("blobs"),
"overlay_section_count": count("world_sections"),
"materialized_natural_sections": count("generated_sections"),
"operation_count": count("operations"),
"history_change_count": count("operation_changes"),
"database_bytes": file_size("worlds.sqlite3"),
@@ -749,6 +802,18 @@ fn canonical_state(state: &str) -> Result<String> {
}
}
fn validate_section_pos(pos: Pos) -> Result<()> {
for axis in pos {
let min = axis.checked_mul(16).context("invalid section coordinate")?;
let max = min.checked_add(15).context("invalid section coordinate")?;
ensure!(
min >= -COORD_LIMIT && max <= COORD_LIMIT,
"section outside coordinate limits"
);
}
Ok(())
}
fn locate(pos: Pos) -> (SectionPos, usize) {
let section = pos.map(|v| v.div_euclid(16));
let [x, y, z] = pos.map(|v| v.rem_euclid(16) as usize);
@@ -798,6 +863,23 @@ fn base_blob(conn: &Connection, base: Option<i64>, [x, y, z]: SectionPos) -> Res
}
}
fn natural_blob(
conn: &Connection,
world: World,
pos @ [x, y, z]: SectionPos,
) -> Result<Option<i64>> {
if let Some(id) = base_blob(conn, world.base, pos)? {
return Ok(Some(id));
}
Ok(conn
.query_row(
"SELECT blob_id FROM generated_sections WHERE world_id=?1 AND x=?2 AND y=?3 AND z=?4",
params![world.id, x, y, z],
|r| r.get(0),
)
.optional()?)
}
fn effective_blob(
conn: &Connection,
world: World,
@@ -812,7 +894,7 @@ fn effective_blob(
.optional()?;
match own {
Some(id) => Ok(Some(id)),
None => base_blob(conn, world.base, pos),
None => natural_blob(conn, world, pos),
}
}
@@ -959,7 +1041,7 @@ fn apply_changes(
if !section_changed {
continue;
}
let baseline = if let Some(id) = base_blob(tx, world.base, pos)? {
let baseline = if let Some(id) = natural_blob(tx, world, pos)? {
load_decoded(tx, cache, registry_len, id)?
} else {
Box::new([0; CELL_COUNT])
+78
View File
@@ -0,0 +1,78 @@
use shacraft_core::{BlockChange, WorldStore};
#[test]
fn procedural_baseline_preserves_deletion_undo_reset_clone_and_restart() {
let dir = tempfile::tempdir().unwrap();
let stone;
{
let mut store = WorldStore::open(dir.path(), 2).unwrap();
stone = store.register_block("minecraft:stone").unwrap();
store.create_world("natural", None).unwrap();
let cells = [stone; 4096];
assert!(
store
.materialize_section("natural", [-1, -4, 1], &cells)
.unwrap()
);
assert_eq!(store.revision("natural").unwrap(), 0);
assert!(
!store
.materialize_section("natural", [-1, -4, 1], &[0; 4096])
.unwrap()
);
let pos = [-1, -64, 16];
assert_eq!(store.get_block("natural", pos).unwrap(), stone);
store
.edit("natural", 0, "mine", vec![BlockChange { pos, block: 0 }])
.unwrap();
assert_eq!(store.get_block("natural", pos).unwrap(), 0);
store.create_world("copy", Some("natural")).unwrap();
assert_eq!(store.get_block("copy", pos).unwrap(), 0);
assert_eq!(store.get_block("copy", [-2, -64, 16]).unwrap(), stone);
store.undo("natural", 1, "undo", "mine").unwrap();
assert_eq!(store.get_block("natural", pos).unwrap(), stone);
store
.edit(
"natural",
2,
"mine-again",
vec![BlockChange { pos, block: 0 }],
)
.unwrap();
store.reset_world("natural", 3, "reset").unwrap();
assert_eq!(store.get_block("natural", pos).unwrap(), stone);
assert_eq!(store.get_block("copy", pos).unwrap(), 0);
store.collect_garbage(100).unwrap();
}
let mut store = WorldStore::open(dir.path(), 2).unwrap();
assert_eq!(store.get_block("natural", [-1, -64, 16]).unwrap(), stone);
assert_eq!(store.get_block("copy", [-1, -64, 16]).unwrap(), 0);
assert!(store.read_section("copy", [-1, -4, 1]).unwrap().is_some());
assert_eq!(
store.section_positions("natural", None, 16).unwrap(),
vec![[-1, -4, 1]]
);
}
#[test]
fn generated_input_is_checked_before_mutation_and_air_is_explicit() {
let dir = tempfile::tempdir().unwrap();
let mut store = WorldStore::open(dir.path(), 1).unwrap();
store.create_world("natural", None).unwrap();
assert!(
store
.materialize_section("natural", [i32::MAX, 0, 0], &[0; 4096])
.is_err()
);
assert!(
store
.materialize_section("natural", [0, 0, 0], &[u32::MAX; 4096])
.is_err()
);
assert!(store.read_section("natural", [0, 0, 0]).unwrap().is_none());
store
.materialize_section("natural", [0, 0, 0], &[0; 4096])
.unwrap();
assert_eq!(
*store.read_section("natural", [0, 0, 0]).unwrap().unwrap(),
[0; 4096]
);
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "shacraft-physics"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
crate-type = ["rlib", "cdylib"]
[dependencies]
serde.workspace = true
serde_json.workspace = true
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+624
View File
@@ -0,0 +1,624 @@
use shacraft_physics::{
Aabb, CollisionBlock, Controls, PhysicsBody, PhysicsSettings, PhysicsWorld, Pose, step,
};
fn block(pos: [i32; 3], state: &str, collision: Vec<Aabb>) -> CollisionBlock {
CollisionBlock {
pos,
state: format!("minecraft:{state}"),
collision,
}
}
fn floor(state: &str) -> PhysicsWorld {
let mut world = PhysicsWorld::default();
for x in -12..=12 {
for z in -60..=60 {
world
.blocks
.push(block([x, -1, z], state, vec![Aabb::unit()]));
}
}
world
}
fn player() -> PhysicsBody {
PhysicsBody {
position: [0.5, 0., 0.5],
velocity: [0., -0.0784000015258789, 0.],
on_ground: true,
..Default::default()
}
}
fn run(body: &mut PhysicsBody, input: &Controls, world: &PhysicsWorld, ticks: usize) {
for _ in 0..ticks {
step(body, input, world, &PhysicsSettings::default());
}
}
#[test]
fn original_java26_2_travel_kernel_trajectories() {
let fixture: serde_json::Value =
serde_json::from_str(include_str!("fixtures/java26.2.json")).unwrap();
for (case, trajectory) in fixture["travel_kernel_trajectories"].as_object().unwrap() {
let medium = trajectory["medium"].as_str().unwrap_or("air");
// A fixed shallow-fluid depth is a kernel-only harness condition;
// the integrated world correctly changes depth as a player falls.
if case == "lava_shallow_move_stop" {
continue;
}
let world = if medium == "air" {
floor(trajectory["surface"].as_str().unwrap())
} else {
let mut world = liquid_world(&format!("{medium}[level=0]"));
for block in &mut world.blocks {
block.pos[1] += 10;
}
world
};
let mut body = PhysicsBody {
position: serde_json::from_value(trajectory["initial_position"].clone()).unwrap(),
velocity: serde_json::from_value(trajectory["initial_velocity"].clone()).unwrap(),
flying: trajectory["flying"].as_bool().unwrap_or(false),
on_ground: medium == "air" && !trajectory["flying"].as_bool().unwrap_or(false),
..Default::default()
};
let input_ticks = trajectory["input_ticks"].as_u64().unwrap();
for sample in trajectory["samples"].as_array().unwrap() {
let tick = sample["tick"].as_u64().unwrap();
// Kernel fixture keeps sprint asserted after input release; the
// integrated player controller ends swimming at release instead.
if medium == "water" && trajectory["sprint"].as_bool().unwrap() && tick > input_ticks {
break;
}
let input = Controls {
forward: if tick <= input_ticks { 1. } else { 0. },
sprint: trajectory["sprint"].as_bool().unwrap(),
jump: tick == 1 && trajectory["jump_first_tick"].as_bool().unwrap(),
..Default::default()
};
step(
&mut body,
&input,
&world,
&PhysicsSettings {
allow_flight: true,
..Default::default()
},
);
// Java yaw zero faces +Z; Shacraft yaw zero faces -Z.
for (field, actual) in [("position", body.position), ("velocity", body.velocity)] {
for axis in 0..3 {
let expected =
sample[field][axis].as_f64().unwrap() * if axis == 2 { -1. } else { 1. };
assert!(
(actual[axis] - expected).abs() < 2.0e-6,
"{case} tick {tick}: {field}[{axis}] actual {} expected {expected}",
actual[axis]
);
}
}
assert_eq!(
body.on_ground,
sample["on_ground"].as_bool().unwrap(),
"{case} tick {tick}"
);
}
}
}
#[test]
fn full_cube_stops_player_but_half_slab_is_stepped_without_jump() {
let mut wall = floor("stone");
wall.blocks
.push(block([0, 0, -1], "stone", vec![Aabb::unit()]));
let mut blocked = player();
run(
&mut blocked,
&Controls {
forward: 1.,
..Default::default()
},
&wall,
20,
);
assert!((blocked.position[2] - blocked.width() / 2.).abs() < 1.0e-6);
assert_eq!(blocked.position[1], 0.);
wall.blocks.last_mut().unwrap().collision[0].max[1] = 0.5;
let mut climbed = player();
run(
&mut climbed,
&Controls {
forward: 1.,
..Default::default()
},
&wall,
5,
);
assert!(climbed.position[2] < 0. && climbed.position[1] >= 0.5);
}
#[test]
fn step_up_preserves_clearance_under_low_ceiling() {
let mut world = floor("stone");
world.blocks.push(block(
[0, 0, -1],
"stone_slab[type=bottom]",
vec![Aabb::new([0.; 3], [1., 0.5, 1.])],
));
world
.blocks
.push(block([0, 2, -1], "stone", vec![Aabb::unit()]));
let mut body = player();
run(
&mut body,
&Controls {
forward: 1.,
..Default::default()
},
&world,
12,
);
assert!(
body.position[2] >= body.width() / 2. - 1.0e-6,
"standing body must not step into a ceiling"
);
}
#[test]
fn sneaking_keeps_support_at_straight_and_diagonal_edges() {
let world = PhysicsWorld {
blocks: vec![block([0, -1, 0], "stone", vec![Aabb::unit()])],
};
for strafe in [0., 1.] {
let mut body = player();
run(
&mut body,
&Controls {
forward: 1.,
strafe,
sneak: true,
..Default::default()
},
&world,
120,
);
assert_eq!(body.position[1], 0.);
assert!(body.position[2] > -body.width() / 2.);
assert!(body.position[0] < 1. + body.width() / 2.);
assert_eq!(body.pose, Pose::Crouching);
}
let mut walking = player();
run(
&mut walking,
&Controls {
forward: 1.,
..Default::default()
},
&world,
20,
);
assert!(walking.position[1] < -1.);
}
#[test]
fn crawl_pose_persists_until_there_is_headroom() {
let mut world = floor("stone");
world
.blocks
.push(block([0, 1, 0], "stone", vec![Aabb::unit()]));
let mut body = PhysicsBody {
pose: Pose::Swimming,
..player()
};
step(
&mut body,
&Controls::default(),
&world,
&PhysicsSettings::default(),
);
assert_eq!(body.pose, Pose::Swimming);
world.blocks.pop();
step(
&mut body,
&Controls::default(),
&world,
&PhysicsSettings::default(),
);
assert_eq!(body.pose, Pose::Standing);
}
#[test]
fn diagonal_input_is_normalized_and_air_control_is_weaker() {
let world = floor("stone");
let mut straight = player();
let mut diagonal = player();
run(
&mut straight,
&Controls {
forward: 1.,
..Default::default()
},
&world,
20,
);
run(
&mut diagonal,
&Controls {
forward: 1.,
strafe: 1.,
..Default::default()
},
&world,
20,
);
let a = (straight.position[0] - 0.5).hypot(straight.position[2] - 0.5);
let b = (diagonal.position[0] - 0.5).hypot(diagonal.position[2] - 0.5);
assert!(
(b / a - 1. / 0.98).abs() < 1.0e-6,
"vanilla normalizes a diagonal after multiplying input by .98"
);
let mut air = PhysicsBody {
position: [0., 20., 0.],
..Default::default()
};
step(
&mut air,
&Controls {
forward: 1.,
..Default::default()
},
&world,
&PhysicsSettings::default(),
);
assert!((air.position[2] + 0.019600000381469584).abs() < 1.0e-6);
}
#[test]
fn held_jump_has_ten_tick_cooldown_and_release_rearms() {
let world = floor("stone");
let mut body = player();
let jump = Controls {
jump: true,
..Default::default()
};
step(&mut body, &jump, &world, &PhysicsSettings::default());
assert_eq!(body.jump_cooldown, 10);
body.position[1] = 0.;
body.velocity = [0., -0.08, 0.];
body.on_ground = true;
step(&mut body, &jump, &world, &PhysicsSettings::default());
assert_eq!(body.position[1], 0.);
step(
&mut body,
&Controls::default(),
&world,
&PhysicsSettings::default(),
);
step(&mut body, &jump, &world, &PhysicsSettings::default());
assert!(body.position[1] > 0.4);
}
fn liquid_world(state: &str) -> PhysicsWorld {
let mut world = PhysicsWorld::default();
for x in -3..=3 {
for z in -12..=3 {
for y in -4..=4 {
world.blocks.push(block([x, y, z], state, vec![]));
}
}
}
world
}
#[test]
fn water_swimming_lava_and_waterlogged_cells_apply_distinct_motion() {
let mut water = PhysicsBody {
position: [0.5, 0., 0.5],
..Default::default()
};
let mut lava = water.clone();
let input = Controls {
forward: 1.,
..Default::default()
};
run(&mut water, &input, &liquid_world("water[level=0]"), 20);
run(&mut lava, &input, &liquid_world("lava[level=0]"), 20);
assert!(water.in_water && !water.in_lava && lava.in_lava);
assert!(water.position[2] < lava.position[2]);
assert!(water.velocity[1] > lava.velocity[1]);
let mut swimmer = PhysicsBody {
position: [0.5, 0., 0.5],
..Default::default()
};
run(
&mut swimmer,
&Controls {
sprint: true,
forward: 1.,
pitch: 0.6,
..Default::default()
},
&liquid_world("water[level=0]"),
10,
);
assert!(swimmer.swimming && swimmer.pose == Pose::Swimming);
assert!(swimmer.position[1] > 0.);
let mut logged = PhysicsBody {
position: [0.5, 0., 0.5],
..Default::default()
};
run(
&mut logged,
&input,
&liquid_world("oak_sign[waterlogged=true]"),
2,
);
assert!(logged.in_water);
}
#[test]
fn climbable_clamps_fall_and_jump_climbs() {
let world = liquid_world("ladder[facing=north]");
let mut body = PhysicsBody {
position: [0.5, 2., 0.5],
velocity: [0., -2., 0.],
..Default::default()
};
step(
&mut body,
&Controls::default(),
&world,
&PhysicsSettings::default(),
);
assert!(body.on_climbable && body.position[1] > 1.84);
let height = body.position[1];
run(
&mut body,
&Controls {
jump: true,
..Default::default()
},
&world,
5,
);
assert!(body.position[1] > height);
let height = body.position[1];
body.velocity[1] = -0.1;
run(
&mut body,
&Controls {
sneak: true,
..Default::default()
},
&world,
5,
);
assert!((body.position[1] - height).abs() < 1.0e-6);
}
#[test]
fn slime_bounces_without_sneak_and_honey_limits_jump() {
let world = floor("slime_block");
let mut body = PhysicsBody {
position: [0.5, 0.1, 0.5],
velocity: [0., -0.9, 0.],
..Default::default()
};
step(
&mut body,
&Controls::default(),
&world,
&PhysicsSettings::default(),
);
assert!(body.velocity[1] > 0.7);
let mut sneaking = PhysicsBody {
position: [0.5, 0.1, 0.5],
velocity: [0., -0.9, 0.],
..Default::default()
};
step(
&mut sneaking,
&Controls {
sneak: true,
..Default::default()
},
&world,
&PhysicsSettings::default(),
);
assert!(sneaking.velocity[1] < 0.);
let mut honey = player();
step(
&mut honey,
&Controls {
jump: true,
..Default::default()
},
&floor("honey_block"),
&PhysicsSettings::default(),
);
assert!((honey.position[1] - 0.21).abs() < 1.0e-6);
}
#[test]
fn cobweb_slows_falling_and_bubble_column_lifts() {
let mut web = PhysicsBody {
position: [0.5, 2., 0.5],
velocity: [0., -1., 0.],
..Default::default()
};
step(
&mut web,
&Controls::default(),
&liquid_world("cobweb"),
&PhysicsSettings::default(),
);
assert!((web.position[1] - 1.95).abs() < 1.0e-6);
let mut bubble = PhysicsBody {
position: [0.5, 0., 0.5],
..Default::default()
};
run(
&mut bubble,
&Controls::default(),
&liquid_world("bubble_column[drag=false]"),
10,
);
assert!(bubble.position[1] > 0.5 && bubble.velocity[1] > 0.);
}
#[test]
fn powder_snow_and_scaffolding_have_entity_sensitive_support() {
for material in ["powder_snow", "scaffolding"] {
let world = PhysicsWorld {
blocks: vec![block([0, -1, 0], material, vec![Aabb::unit()])],
};
let mut body = player();
let settings = PhysicsSettings {
leather_boots: true,
..Default::default()
};
step(&mut body, &Controls::default(), &world, &settings);
assert_eq!(body.position[1], 0., "{material}");
run(
&mut body,
&Controls {
sneak: true,
..Default::default()
},
&world,
5,
);
assert!(body.position[1] < 0., "{material}");
}
}
#[test]
fn flight_is_authorized_and_frozen_body_does_not_move() {
let mut denied = player();
step(
&mut denied,
&Controls {
fly_toggle: true,
..Default::default()
},
&PhysicsWorld::default(),
&PhysicsSettings::default(),
);
assert!(!denied.flying);
let mut flight = player();
let settings = PhysicsSettings {
allow_flight: true,
..Default::default()
};
step(
&mut flight,
&Controls {
fly_toggle: true,
jump: true,
..Default::default()
},
&PhysicsWorld::default(),
&settings,
);
assert!(flight.flying && flight.position[1] > 0.);
let position = flight.position;
step(
&mut flight,
&Controls {
jump: true,
forward: 1.,
..Default::default()
},
&PhysicsWorld::default(),
&PhysicsSettings {
frozen: true,
..settings
},
);
assert_eq!(flight.position, position);
assert_eq!(flight.velocity, [0.; 3]);
}
#[test]
fn fluid_and_callback_results_do_not_depend_on_block_serialization_order() {
let mut world = PhysicsWorld {
blocks: vec![
block([0, 0, 0], "water[level=5]", vec![]),
block([1, 0, 0], "water[level=7]", vec![]),
block([0, 0, 1], "water[level=2]", vec![]),
block([1, 0, 1], "water[level=6]", vec![]),
block([-1, 0, 0], "water[level=0]", vec![]),
],
};
let start = PhysicsBody {
position: [0.95, 0., 0.95],
..Default::default()
};
let mut forward = start.clone();
step(
&mut forward,
&Controls::default(),
&world,
&PhysicsSettings::default(),
);
world.blocks.reverse();
let mut reversed = start;
step(
&mut reversed,
&Controls::default(),
&world,
&PhysicsSettings::default(),
);
assert_eq!(forward.position, reversed.position);
assert_eq!(forward.velocity, reversed.velocity);
assert!(
forward.velocity[0].abs() + forward.velocity[2].abs() > 0.,
"fixture must exercise current pushing"
);
}
#[test]
fn shallow_grounded_water_jump_uses_the_normal_jump_impulse() {
let mut world = floor("stone");
world
.blocks
.push(block([0, 0, 0], "water[level=7]", vec![]));
let mut body = player();
step(
&mut body,
&Controls {
jump: true,
..Default::default()
},
&world,
&PhysicsSettings::default(),
);
assert!((body.position[1] - 0.41999998688697815).abs() < 1.0e-12);
assert_eq!(body.jump_cooldown, 10);
}
#[test]
fn ground_flight_shortcut_hovers_and_landing_still_cancels_flight() {
let world = floor("stone");
let settings = PhysicsSettings {
allow_flight: true,
..Default::default()
};
let mut body = player();
step(&mut body, &Controls::default(), &world, &settings);
assert!(body.on_ground && body.velocity[1] < 0.);
step(
&mut body,
&Controls {
fly_toggle: true,
..Default::default()
},
&world,
&settings,
);
assert!(body.flying && !body.on_ground);
for _ in 0..5 {
step(&mut body, &Controls::default(), &world, &settings);
assert!(body.flying && !body.on_ground);
assert_eq!(body.position[1], 0.);
assert_eq!(body.velocity[1], 0.);
}
body.position[1] = 0.1;
body.velocity[1] = -0.2;
step(&mut body, &Controls::default(), &world, &settings);
assert!(body.on_ground && !body.flying);
assert_eq!(body.position[1], 0.);
}
+1
View File
@@ -11,6 +11,7 @@ serde.workspace = true
serde_json.workspace = true
shacraft-core.workspace = true
shacraft-content = { path = "../shacraft-content" }
shacraft-physics = { path = "../shacraft-physics" }
axum = { version = "0.8.9", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["fs", "limit", "trace"] }
+58 -9
View File
@@ -11,6 +11,7 @@ impl Game {
.map(|w| {
let mut v = json!(w);
v["mode"] = json!(self.config(&w.name).mode);
v["terrain"] = json!(self.config(&w.name).terrain);
v
})
.collect::<Vec<_>>()
@@ -67,11 +68,53 @@ impl Game {
.map(|n| n * 1024)
});
Ok(
json!({"tick":self.tick,"tick_ms":self.last_tick_ms,"max_tick_ms":self.max_tick_ms,"uptime_seconds":self.started.elapsed().as_secs(),"players":self.players.len(),"active_worlds":self.players.values().map(|p|&p.world).collect::<HashSet<_>>().len(),"world_count":self.store.list_worlds()?.len(),"plans":self.plans.len(),"entities":self.meta.entities.len(),"entity_revision":self.meta.revision,"rss_bytes":rss,"slow_clients_disconnected":self.slow_clients,"queued_client_bytes":self.players.values().map(|p|p.out.bytes.load(Ordering::Relaxed)).sum::<usize>(),"limits":{"players":32,"command_queue":64,"messages_per_client":16,"bytes_per_client":8388608,"build_plans":16,"plan_cells":32768},"storage":self.store.stats(),"catalog":self.catalog.summary(),"wasm":self.jump.metrics()}),
json!({"tick":self.tick,"tick_ms":self.last_tick_ms,"max_tick_ms":self.max_tick_ms,"uptime_seconds":self.started.elapsed().as_secs(),"players":self.players.len(),"active_worlds":self.players.values().map(|p|&p.world).collect::<HashSet<_>>().len(),"world_count":self.store.list_worlds()?.len(),"plans":self.plans.len(),"entities":self.meta.entities.len(),"entity_revision":self.meta.revision,"rss_bytes":rss,"slow_clients_disconnected":self.slow_clients,"queued_client_bytes":self.players.values().map(|p|p.out.bytes.load(Ordering::Relaxed)).sum::<usize>(),"limits":{"players":32,"command_queue":64,"messages_per_client":16,"bytes_per_client":8388608,"build_plans":16,"plan_cells":32768},"terrain":self.terrain.stats(),"stream":{"batches":self.stream_batches,"sections":self.stream_sections,"last_ms":self.last_stream_ms,"waiting_players":self.players.values().filter(|p|p.waiting_terrain).count()},"tick_samples_ms":self.tick_samples,"storage":self.store.stats(),"catalog":self.catalog.summary(),"wasm":self.jump.metrics()}),
)
}
"diagnostics.snapshot" => Ok(
json!({"tick":self.tick,"tick_ms":self.last_tick_ms,"stream_ms":self.last_stream_ms,"terrain":self.terrain.stats(),"players":self.players.values().map(|p|json!({"id":p.id,"world":p.world,"position":p.position,"velocity":p.body.velocity,"waiting_terrain":p.waiting_terrain,"view":p.view,"generation":p.view_generation,"sent_sections":p.sent_sections.len(),"view_distance":p.view_distance,"stream_radius":p.stream_radius(),"pending_inputs":p.pending_inputs.len(),"processed_seq":p.processed_seq})).collect::<Vec<_>>()}),
),
"player.teleport" => {
let id = string(&p, "id")?;
let position: [f64; 3] = serde_json::from_value(p["position"].clone())?;
ensure!(
position
.iter()
.all(|v| v.is_finite() && v.abs() <= crate::terrain::BORDER)
&& (-128.0..=1024.0).contains(&position[1]),
"position outside playable bounds"
);
let player = self.players.get_mut(id).context("unknown player")?;
player.position = position;
player.velocity = 0.;
player.grounded = false;
player.input = Input::default();
player.reset_motion();
if p["flying"] == true {
player.body.flying = true;
}
self.snapshot(id, false)?;
Ok(json!({"id":id,"position":position}))
}
"terrain.inspect" => {
let world = string(&p, "world")?;
let position: Pos = serde_json::from_value(p["position"].clone())?;
let generator = self
.generators
.get(world)
.context("world is not procedural")?;
Ok(
json!({"world":world,"config":generator.config,"position":position,"height":generator.height(position[0],position[2]),"biome":generator.biome(position[0],position[2]),"min_y":crate::terrain::MIN_Y,"max_y":crate::terrain::MAX_Y,"border":crate::terrain::BORDER}),
)
}
"world.create" => {
let world = string(&p, "world")?;
if let Some(config) = p.get("terrain") {
self.create_overworld(world, serde_json::from_value(config.clone())?)?;
return Ok(
json!({"world":world,"revision":0,"terrain":self.config(world).terrain}),
);
}
self.store.create_world(world, p["template"].as_str())?;
let conf = if let Some(template) = p["template"].as_str() {
let mut c = self.config(template);
@@ -84,13 +127,14 @@ impl Game {
};
self.meta.worlds.insert(world.into(), conf);
self.save_meta()?;
self.open_generators()?;
Ok(json!({"world":world,"revision":0}))
}
"world.read" => {
let world = string(&p, "world")?;
let min: Pos = serde_json::from_value(p["min"].clone())?;
let max: Pos = serde_json::from_value(p["max"].clone())?;
let blocks = self.store.read_region(world, min, max)?;
let blocks = self.read_world_region(world, min, max)?;
let response = json!({"world":world,"revision":self.store.revision(world)?,"blocks":blocks,"materials":self.materials(&blocks)});
ensure!(
response.to_string().len() <= 6 * 1024 * 1024,
@@ -105,9 +149,11 @@ impl Game {
.context("expected_revision required")?;
let op = string(&p, "operation_id")?;
let changes: Vec<BlockChange> = serde_json::from_value(p["changes"].clone())?;
self.materialize_changes(world, &changes)?;
let result = self.store.edit(world, rev, op, changes.clone())?;
self.terrain_changed(world, &changes);
if !result.replayed {
self.broadcast(world,json!({"type":"blocks","revision":result.revision,"changes":changes,"materials":self.materials(&changes)}));
self.broadcast_blocks(world, result.revision, &changes);
}
Ok(json!(result))
}
@@ -248,7 +294,7 @@ impl Game {
.context("min_players too large")?;
}
if p.get("elimination_y").is_some() {
conf.elimination_y = number(&p, "elimination_y")?;
conf.elimination_y = f64::from(number(&p, "elimination_y")?);
}
if let Some(value) = p.get("floor_block") {
let name = value
@@ -274,14 +320,17 @@ impl Game {
ensure!(plan.expires > Instant::now(), "plan expired");
let world = plan.world.clone();
let changes = plan.changes.clone();
let revision = plan.revision;
self.materialize_changes(&world, &changes)?;
let result = self.store.edit(
&world,
plan.revision,
revision,
string(&p, "operation_id")?,
changes.clone(),
)?;
if !result.replayed {
self.broadcast(&world,json!({"type":"blocks","revision":result.revision,"changes":changes,"materials":self.materials(&changes)}));
self.terrain_changed(&world, &changes);
self.broadcast_blocks(&world, result.revision, &changes);
}
Ok(json!(result))
}
@@ -291,7 +340,7 @@ impl Game {
serde_json::from_value(p.get("min").cloned().unwrap_or(json!([-24, -4, -24])))?;
let max: Pos =
serde_json::from_value(p.get("max").cloned().unwrap_or(json!([24, 24, 24])))?;
let blocks = self.store.read_region(world, min, max)?;
let blocks = self.read_world_region(world, min, max)?;
let colors = blocks
.iter()
.map(|b| {
@@ -470,8 +519,8 @@ impl Game {
)
}
}
fn entity_position(v: &Value) -> Result<[f32; 3]> {
let p: [f32; 3] = serde_json::from_value(v.clone())?;
fn entity_position(v: &Value) -> Result<[f64; 3]> {
let p: [f64; 3] = serde_json::from_value(v.clone())?;
ensure!(
p.iter().all(|n| n.is_finite() && n.abs() <= 32700.),
"position outside playable limits +/-32700"
+548 -115
View File
@@ -3,8 +3,11 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shacraft_content::{Catalog, StateDefinition};
use shacraft_core::{BlockChange, Pos, WorldStore};
use shacraft_physics::{
Aabb as PhysicsAabb, CollisionBlock, Controls, PhysicsBody, PhysicsSettings, PhysicsWorld,
};
use std::{
collections::{BTreeMap, HashMap, HashSet},
collections::{BTreeMap, HashMap, HashSet, VecDeque},
path::Path,
sync::{
Arc,
@@ -47,15 +50,16 @@ impl Outbox {
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WorldConfig {
pub spawn: [f32; 3],
pub spawn: [f64; 3],
pub mode: String,
pub countdown_seconds: u32,
pub round_seconds: u32,
pub min_players: usize,
pub elimination_y: f32,
pub spawn_points: Vec<[f32; 3]>,
pub spectator_spawn: [f32; 3],
pub elimination_y: f64,
pub spawn_points: Vec<[f64; 3]>,
pub spectator_spawn: [f64; 3],
pub floor_block: String,
pub terrain: Option<crate::terrain::TerrainConfig>,
}
impl Default for WorldConfig {
fn default() -> Self {
@@ -69,6 +73,7 @@ impl Default for WorldConfig {
spawn_points: Vec::new(),
spectator_spawn: [13., 2., 0.],
floor_block: "minecraft:snow_block".into(),
terrain: None,
}
}
}
@@ -107,8 +112,14 @@ impl WorldConfig {
.chain(&self.spawn_points)
{
ensure!(
point.iter().all(|n| n.is_finite() && n.abs() <= 32700.),
"spawn position outside playable limits +/-32700"
point.iter().all(|n| n.is_finite()
&& n.abs()
<= if self.terrain.is_some() {
crate::terrain::BORDER
} else {
32700.
}),
"spawn position outside playable world border"
);
ensure!(
point[1] > self.elimination_y + 0.5,
@@ -118,11 +129,14 @@ impl WorldConfig {
for (i, a) in self.spawn_points.iter().enumerate() {
for b in self.spawn_points.iter().skip(i + 1) {
ensure!(
(0..3).map(|axis| (a[axis] - b[axis]).powi(2)).sum::<f32>() >= 0.64,
(0..3).map(|axis| (a[axis] - b[axis]).powi(2)).sum::<f64>() >= 0.64,
"spawn_points are too close together"
);
}
}
if let Some(terrain) = &self.terrain {
terrain.validate()?;
}
let floor = catalog
.state(&self.floor_block)
.context("unknown floor_block")?;
@@ -149,23 +163,117 @@ struct Input {
forward: f32,
strafe: f32,
jump: bool,
sprint: bool,
sneak: bool,
fly_toggle: bool,
}
impl Input {
fn controls(&self) -> Controls {
Controls {
forward: f64::from(self.forward),
strafe: f64::from(self.strafe),
yaw: f64::from(self.yaw),
pitch: f64::from(self.pitch),
jump: self.jump,
sprint: self.sprint,
sneak: self.sneak,
fly_toggle: self.fly_toggle,
}
}
}
struct Player {
id: String,
name: String,
world: String,
position: [f32; 3],
position: [f64; 3],
velocity: f32,
grounded: bool,
body: PhysicsBody,
physics_settings: PhysicsSettings,
prediction: bool,
pending_inputs: VecDeque<Input>,
received_seq: u64,
processed_seq: u64,
motion_epoch: u64,
// Latest received view angles stay independent of queued movement replay.
look: [f32; 2],
input: Input,
out: Outbox,
last_input: Instant,
last_action: Instant,
last_chat: Instant,
last_snapshot: Instant,
last_view: Instant,
chunk_stream: bool,
section_stream: bool,
view_distance: i32,
buffered_view: bool,
full_height: bool,
last_distance_change: Instant,
sent_sections: HashSet<Pos>,
pending_sections: VecDeque<Pos>,
view_generation: u64,
welcomed: bool,
last_batch: Instant,
waiting_terrain: bool,
view: [i32; 3],
alive: bool,
}
impl Player {
fn stream_radius(&self) -> i32 {
self.view_distance + i32::from(self.buffered_view)
}
}
impl Player {
fn reset_motion(&mut self) {
self.body = PhysicsBody {
position: self.position,
velocity: [0., f64::from(self.velocity) / 20., 0.],
on_ground: self.grounded,
..PhysicsBody::default()
};
self.pending_inputs.clear();
self.processed_seq = self.received_seq;
self.motion_epoch += 1;
}
fn synchronize_pose(&mut self) {
if self.body.position != self.position {
self.reset_motion();
}
self.body.on_ground = self.grounded;
if (self.body.velocity[1] * 20.) as f32 != self.velocity {
self.body.velocity[1] = f64::from(self.velocity) / 20.;
}
}
}
#[derive(Serialize)]
struct ViewSection {
section: Pos,
blocks: Vec<BlockChange>,
}
fn view_bounds(center: Pos) -> (Pos, Pos) {
(
[center[0] - 32, center[1] - 16, center[2] - 32],
[center[0] + 31, center[1] + 31, center[2] + 31],
)
}
fn view_sections(center: Pos) -> Vec<Pos> {
let (min, max) = view_bounds(center);
let min = min.map(|v| v.div_euclid(16));
let max = max.map(|v| v.div_euclid(16));
let mut sections = Vec::with_capacity(48);
for x in min[0]..=max[0] {
for y in min[1]..=max[1] {
for z in min[2]..=max[2] {
sections.push([x, y, z]);
}
}
}
sections
}
#[derive(Clone)]
struct Match {
phase: String,
@@ -195,6 +303,13 @@ pub struct Game {
last_tick_ms: f64,
slow_clients: u64,
pub jump: crate::packages::JumpModule,
terrain: crate::terrain::TerrainService,
generators: HashMap<String, Arc<crate::terrain::Generator>>,
sky_cache: HashMap<(String, i32, i32), Vec<i16>>,
stream_batches: u64,
stream_sections: u64,
last_stream_ms: f64,
tick_samples: VecDeque<f64>,
}
impl Game {
pub fn open(
@@ -240,11 +355,27 @@ impl Game {
last_tick_ms: 0.,
slow_clients: 0,
jump,
terrain: crate::terrain::TerrainService::new(),
generators: HashMap::new(),
sky_cache: HashMap::new(),
stream_batches: 0,
stream_sections: 0,
last_stream_ms: 0.,
tick_samples: VecDeque::with_capacity(600),
};
for conf in game.meta.worlds.values() {
conf.validate(&game.catalog)?;
}
game.seed()?;
game.open_generators()?;
if !game
.store
.list_worlds()?
.iter()
.any(|world| world.name == "overworld")
{
game.create_overworld("overworld", crate::terrain::TerrainConfig::default())?;
}
for world in game.meta.active_matches.clone() {
let revision = game.store.revision(&world)?;
game.store.reset_world(
@@ -490,6 +621,10 @@ impl Game {
self.step();
self.last_tick_ms = start.elapsed().as_secs_f64() * 1000.;
self.max_tick_ms = self.max_tick_ms.max(self.last_tick_ms);
if self.tick_samples.len() == 600 {
self.tick_samples.pop_front();
}
self.tick_samples.push_back(self.last_tick_ms);
next += Duration::from_millis(50);
if next < Instant::now() {
next = Instant::now() + Duration::from_millis(50);
@@ -520,10 +655,19 @@ impl Game {
self.meta.worlds.get(world).cloned().unwrap_or_default()
}
fn players_json(&self, world: &str) -> Value {
json!(self.players.values().filter(|p|p.world==world).map(|p|json!({"id":p.id,"name":p.name,"position":p.position,"yaw":p.input.yaw,"pitch":p.input.pitch,"alive":p.alive})).collect::<Vec<_>>())
json!(self.players.values().filter(|p|p.world==world).map(|p|json!({"id":p.id,"name":p.name,"position":p.body.position,"yaw":p.look[0],"pitch":p.look[1],"alive":p.alive,"velocity":p.body.velocity,"pose":p.body.pose,"height":p.body.height(),"eye_height":p.body.eye_height(),"sprinting":p.body.sprinting,"flying":p.body.flying})).collect::<Vec<_>>())
}
fn motion_json(&self, p: &Player) -> Value {
let mut settings = p.physics_settings.clone();
settings.frozen |= p.waiting_terrain;
json!({"body":p.body,"ack":p.processed_seq,"tick":self.tick,
"epoch":p.motion_epoch,"settings":settings,"waiting_terrain":p.waiting_terrain})
}
fn materials(&self, blocks: &[BlockChange]) -> Vec<Value> {
let ids: HashSet<_> = blocks.iter().map(|b| b.block).collect();
self.materials_for_ids(blocks.iter().map(|b| b.block))
}
fn materials_for_ids(&self, ids: impl Iterator<Item = u32>) -> Vec<Value> {
let ids: HashSet<_> = ids.collect();
let mut budget = 0;
ids.into_iter()
.filter_map(|id| self.material(id))
@@ -545,18 +689,98 @@ impl Game {
Some(v)
}
fn snapshot(&mut self, id: &str, welcome: bool) -> Result<()> {
let player = self.players.get(id).context("not joined")?;
let allow_flight = self.config(&player.world).mode == "creative" || !player.alive;
let frozen = self
.matches
.get(&player.world)
.is_some_and(|m| m.phase == "countdown");
if let Some(player) = self.players.get_mut(id) {
player.synchronize_pose();
player.physics_settings.allow_flight = allow_flight;
player.physics_settings.frozen = frozen;
player.physics_settings.jump_impulse = None;
if !allow_flight {
player.body.flying = false;
}
if !player.alive {
player.body.flying = true;
}
}
let p = self.players.get(id).context("not joined")?;
let world = p.world.clone();
let center = p.position.map(|v| (v.floor() as i32).div_euclid(16) * 16);
let blocks = self.store.read_region(
&world,
[center[0] - 32, center[1] - 8, center[2] - 32],
[center[0] + 31, center[1] + 31, center[2] + 31],
)?;
let value = json!({"type":if welcome{"welcome"}else{"snapshot"},"id":id,"world":world,"revision":self.store.revision(&world)?,"spawn":p.position,"blocks":blocks,"materials":self.materials(&blocks),"players":self.players_json(&world),"entities":self.entity_list(&world),"manifest_hash":self.manifest["hash"],"view_center":center});
if p.section_stream {
return self.begin_section_view(id, center, true);
}
let (min, max) = if p.chunk_stream {
view_bounds(center)
} else {
(
[center[0] - 32, center[1] - 8, center[2] - 32],
[center[0] + 31, center[1] + 31, center[2] + 31],
)
};
let blocks = self.read_world_region(&world, min, max)?;
let p = &self.players[id];
let mut value = json!({"type":if welcome{"welcome"}else{"snapshot"},"id":id,"world":world,"revision":self.store.revision(&world)?,"spawn":p.position,"blocks":blocks,"materials":self.materials(&blocks),"players":self.players_json(&world),"entities":self.entity_list(&world),"manifest_hash":self.manifest["hash"],"view_center":center});
if p.chunk_stream {
value["features"] = json!(["chunk_stream_v1"]);
value["view_min"] = json!(min);
value["view_max"] = json!(max);
}
if p.prediction {
value["motion"] = self.motion_json(p);
let mut features = if p.chunk_stream {
vec!["chunk_stream_v1"]
} else {
vec![]
};
features.push("movement_prediction_v1");
value["features"] = json!(features);
}
if let Some(p) = self.players.get_mut(id) {
p.view = center;
p.last_snapshot = Instant::now();
p.last_view = p.last_snapshot;
}
self.send(id, value);
Ok(())
}
fn stream_view(&mut self, id: &str, center: Pos) -> Result<()> {
let p = self.players.get(id).context("not joined")?;
let world = p.world.clone();
let from_center = p.view;
let old_sections = view_sections(from_center);
let new_sections = view_sections(center);
let old: HashSet<_> = old_sections.iter().copied().collect();
let new: HashSet<_> = new_sections.iter().copied().collect();
let unload: Vec<_> = old_sections
.into_iter()
.filter(|section| !new.contains(section))
.collect();
let mut sections = Vec::new();
for section in new_sections
.into_iter()
.filter(|section| !old.contains(section))
{
let min = section.map(|v| v * 16);
let max = min.map(|v| v + 15);
sections.push(ViewSection {
section,
blocks: self.store.read_region(&world, min, max)?,
});
}
let materials = self.materials_for_ids(
sections
.iter()
.flat_map(|section| section.blocks.iter().map(|block| block.block)),
);
let (min, max) = view_bounds(center);
let value = json!({"type":"chunks","world":world,"revision":self.store.revision(&world)?,"from_center":from_center,"view_center":center,"view_min":min,"view_max":max,"unload":unload,"sections":sections,"materials":materials});
if let Some(p) = self.players.get_mut(id) {
p.view = center;
p.last_view = Instant::now();
}
self.send(id, value);
Ok(())
@@ -574,12 +798,30 @@ impl Game {
self.config(world).mode != "template",
"template is not a playable world"
);
ensure!(
!self.generators.contains_key(world)
|| p["features"]
.as_array()
.is_some_and(|f| f.iter().any(|v| v == "chunk_stream_v2")),
"This world requires chunk_stream_v2; refresh the client"
);
let name = p["name"].as_str().unwrap_or("Игрок").trim();
ensure!(
!name.is_empty() && name.chars().count() <= 32 && !name.chars().any(char::is_control),
"invalid player name"
);
let id = Uuid::new_v4().to_string();
let view_distance = if let Some(value) = p.get("view_distance") {
let radius = value.as_i64().context("view_distance must be an integer")?;
ensure!(
(i64::from(terrain::MIN_VIEW_RADIUS)..=i64::from(terrain::MAX_VIEW_RADIUS))
.contains(&radius),
"view_distance must be 2..64 chunks"
);
radius as i32
} else {
terrain::VIEW_RADIUS
};
let now = Instant::now();
self.players.insert(
id.clone(),
@@ -594,12 +836,50 @@ impl Game {
},
velocity: 0.,
grounded: false,
body: PhysicsBody::default(),
physics_settings: PhysicsSettings {
allow_flight: self.config(world).mode == "creative",
..PhysicsSettings::default()
},
prediction: p["features"].as_array().is_some_and(|features| {
features
.iter()
.any(|feature| feature.as_str() == Some("movement_prediction_v1"))
}),
pending_inputs: VecDeque::new(),
received_seq: 0,
processed_seq: 0,
motion_epoch: 0,
look: [0.; 2],
input: Input::default(),
out,
last_input: now,
last_action: now - Duration::from_secs(1),
last_chat: now - Duration::from_secs(1),
last_snapshot: now,
last_view: now,
section_stream: p["features"]
.as_array()
.is_some_and(|f| f.iter().any(|v| v == "chunk_stream_v2")),
sent_sections: HashSet::new(),
pending_sections: VecDeque::new(),
view_distance,
full_height: p["features"]
.as_array()
.is_some_and(|f| f.iter().any(|v| v == "full_height_v1")),
buffered_view: p["features"]
.as_array()
.is_some_and(|f| f.iter().any(|v| v == "view_buffer_v1")),
last_distance_change: now - Duration::from_secs(1),
view_generation: 0,
welcomed: false,
last_batch: now - Duration::from_secs(1),
waiting_terrain: false,
chunk_stream: p["features"].as_array().is_some_and(|features| {
features
.iter()
.any(|feature| feature.as_str() == Some("chunk_stream_v1"))
}),
view: [0; 3],
alive: !self.matches.contains_key(world),
},
@@ -612,6 +892,32 @@ impl Game {
let player = self.players.get(id).context("not joined")?;
let world = player.world.clone();
match typ {
"view_distance" => {
ensure!(
player.section_stream,
"view distance requires chunk_stream_v2"
);
let distance = p["chunks"].as_i64().context("chunks must be an integer")?;
ensure!(
(i64::from(terrain::MIN_VIEW_RADIUS)..=i64::from(terrain::MAX_VIEW_RADIUS))
.contains(&distance),
"view distance must be 2..64 chunks"
);
if player.view_distance == distance as i32 {
return Ok(());
}
ensure!(
player.last_distance_change.elapsed() >= Duration::from_millis(500),
"view distance change rate limited"
);
let center = player
.position
.map(|v| (v.floor() as i32).div_euclid(16) * 16);
let player = self.players.get_mut(id).unwrap();
player.view_distance = distance as i32;
player.last_distance_change = Instant::now();
self.begin_section_view(id, center, false)?;
}
"input" => {
let seq = p["seq"].as_u64().context("input sequence required")?;
let yaw = number(&p, "yaw")?;
@@ -626,18 +932,52 @@ impl Game {
"invalid movement input"
);
let player = self.players.get_mut(id).unwrap();
if seq > player.input.seq {
player.input = Input {
if seq > player.received_seq {
let input = Input {
seq,
yaw,
pitch,
forward,
strafe,
jump: p["jump"].as_bool().unwrap_or(false),
sprint: p["sprint"].as_bool().unwrap_or(false),
sneak: p["sneak"].as_bool().unwrap_or(false),
fly_toggle: p["fly_toggle"].as_bool().unwrap_or(false),
};
if player.prediction {
ensure!(
player.pending_inputs.len() < 32,
"movement command queue full"
);
player.pending_inputs.push_back(input);
} else {
player.input = input;
}
player.received_seq = seq;
player.look = [yaw, pitch];
player.last_input = Instant::now();
}
}
"look" => {
let yaw = number(&p, "yaw")?;
let pitch = number(&p, "pitch")?;
ensure!(pitch.abs() <= 1.6 && yaw.abs() < 1e7, "invalid look input");
let player = self.players.get_mut(id).unwrap();
player.look = [yaw, pitch];
}
"input_reset" => {
let player = self.players.get_mut(id).unwrap();
player.pending_inputs.clear();
player.processed_seq = player.received_seq;
player.input = Input {
seq: player.processed_seq,
yaw: player.input.yaw,
pitch: player.input.pitch,
..Input::default()
};
player.motion_epoch += 1;
player.last_input = Instant::now();
}
"break" | "place" => {
ensure!(
player.last_action.elapsed() >= Duration::from_millis(110),
@@ -661,7 +1001,7 @@ impl Game {
if conf.mode == "spleef" {
ensure!(typ=="break"&&self.matches.get(&world).is_some_and(|m|m.phase=="active"&&m.participants.contains(id)),"Spleef allows breaking the configured floor only during a match");
ensure!(
self.store.get_block(&world, pos)? == self.id(&conf.floor_block)?,
self.world_block(&world, pos)? == self.id(&conf.floor_block)?,
"arena boundary is protected"
);
}
@@ -676,8 +1016,12 @@ impl Game {
.context("invalid block id")? as u32
};
if block != 0 {
let boxes = self
.definition(block)
.map(|s| s.collision.clone())
.unwrap_or_else(|| vec![shacraft_content::Aabb::new([0.; 3], [1.; 3])]);
ensure!(
self.store.get_block(&world, pos)? == 0,
self.world_block(&world, pos)? == 0,
"placement cell occupied"
);
ensure!(
@@ -685,15 +1029,28 @@ impl Game {
.players
.values()
.filter(|q| q.world == world)
.any(|q| intersects(
q.position,
[pos[0] as f32, pos[1] as f32, pos[2] as f32],
[pos[0] as f32 + 1., pos[1] as f32 + 1., pos[2] as f32 + 1.]
)),
.any(|q| boxes.iter().any(|b| {
let half = q.body.width() / 2.;
let min = [
q.body.position[0] - half,
q.body.position[1],
q.body.position[2] - half,
];
let max = [
q.body.position[0] + half,
q.body.position[1] + q.body.height(),
q.body.position[2] + half,
];
(0..3).all(|i| {
min[i] < f64::from(pos[i]) + f64::from(b.max[i]) - 1e-7
&& max[i] > f64::from(pos[i]) + f64::from(b.min[i]) + 1e-7
})
})),
"placement intersects a player"
);
}
let changes = vec![BlockChange { pos, block }];
self.materialize_changes(&world, &changes)?;
let rev = self.store.revision(&world)?;
let result = self.store.edit(
&world,
@@ -701,8 +1058,9 @@ impl Game {
&format!("play-{}", Uuid::new_v4()),
changes.clone(),
)?;
self.terrain_changed(&world, &changes);
self.players.get_mut(id).unwrap().last_action = Instant::now();
self.broadcast(&world,json!({"type":"blocks","revision":result.revision,"changes":changes,"materials":self.materials(&changes)}));
self.broadcast_blocks(&world, result.revision, &changes);
}
"switch_world" => {
ensure!(
@@ -713,6 +1071,10 @@ impl Game {
self.store.revision(target)?;
let conf = self.config(target);
ensure!(conf.mode != "template", "template is not playable");
ensure!(
conf.terrain.is_none() || player.section_stream,
"procedural worlds require chunk_stream_v2; refresh the browser client"
);
if let Some(m) = self.matches.get_mut(&world) {
m.participants.remove(id);
}
@@ -725,8 +1087,10 @@ impl Game {
conf.spawn
};
q.velocity = 0.;
q.grounded = false;
q.input = Input::default();
q.alive = !spectator;
q.reset_motion();
self.snapshot(id, false)?;
}
"resync" => {
@@ -748,7 +1112,10 @@ impl Game {
let q = self.players.get_mut(id).unwrap();
q.position = spawn;
q.velocity = 0.;
q.grounded = false;
q.input = Input::default();
q.alive = true;
q.reset_motion();
}
"start_match" => {
self.start_match(&world)?;
@@ -774,6 +1141,7 @@ impl Game {
}
Ok(())
}
#[cfg(test)]
fn collision_boxes(
&mut self,
world: &str,
@@ -812,36 +1180,88 @@ impl Game {
.get(id as usize)
.and_then(|s| self.catalog.state(s))
}
fn physics_world(&mut self, world: &str, body: &PhysicsBody) -> Result<PhysicsWorld> {
// Include the swept motion plus neighbors needed by shapes, fluids and stepping.
let min = std::array::from_fn(|axis| {
(body.position[axis] + body.velocity[axis].min(0.) - 3.).floor() as i32
});
let max = std::array::from_fn(|axis| {
(body.position[axis]
+ body.velocity[axis].max(0.)
+ if axis == 1 { body.height() + 3. } else { 3. })
.ceil() as i32
});
let blocks = self
.read_world_region(world, min, max)?
.into_iter()
.map(|block| {
let definition = self.definition(block.block);
CollisionBlock {
pos: block.pos,
state: self
.store
.registry()
.get(block.block as usize)
.cloned()
.unwrap_or_default(),
collision: definition
.map(|d| {
d.collision
.iter()
.map(|b| PhysicsAabb {
min: b.min.map(f64::from),
max: b.max.map(f64::from),
})
.collect()
})
.unwrap_or_else(|| vec![PhysicsAabb::unit()]),
}
})
.collect();
Ok(PhysicsWorld { blocks })
}
fn step(&mut self) {
self.tick += 1;
self.pump_terrain();
let ids: Vec<_> = self.players.keys().cloned().collect();
for id in ids {
let Some(mut p) = self.players.remove(&id) else {
continue;
};
p.synchronize_pose();
if p.last_input.elapsed() > Duration::from_secs(1) {
p.input.forward = 0.;
p.input.strafe = 0.;
p.input.jump = false;
p.pending_inputs.clear();
p.processed_seq = p.received_seq;
p.input = Input {
seq: p.processed_seq,
yaw: p.input.yaw,
pitch: p.input.pitch,
..Input::default()
};
}
if p.prediction {
if let Some(input) = p.pending_inputs.pop_front() {
p.processed_seq = input.seq;
p.input = input;
}
} else {
p.processed_seq = p.input.seq;
}
let conf = self.config(&p.world);
let frozen = self
p.physics_settings.frozen = self
.matches
.get(&p.world)
.is_some_and(|m| m.phase == "countdown");
let speed = if frozen { 0. } else { 5. };
let len = (p.input.forward * p.input.forward + p.input.strafe * p.input.strafe)
.sqrt()
.max(1.);
let dx = (p.input.yaw.sin() * p.input.forward + p.input.yaw.cos() * p.input.strafe)
* speed
* 0.05
/ len;
let dz = (-p.input.yaw.cos() * p.input.forward + p.input.yaw.sin() * p.input.strafe)
* speed
* 0.05
/ len;
if p.input.jump && p.grounded && !frozen {
p.physics_settings.allow_flight = conf.mode == "creative" || !p.alive;
p.physics_settings.jump_impulse = None;
if !p.alive {
p.body.flying = true;
}
if p.input.jump
&& p.body.on_ground
&& p.body.jump_cooldown <= 1
&& !p.physics_settings.frozen
{
let under = [
p.position[0].floor() as i32,
(p.position[1] - 0.2).floor() as i32,
@@ -853,62 +1273,49 @@ impl Game {
.ok()
.and_then(|id| self.store.registry().get(id as usize))
.is_some_and(|state| self.jump.handles(state));
p.velocity = if trampoline {
self.jump.call().unwrap_or(7.)
} else {
7.
};
p.grounded = false;
if trampoline {
p.physics_settings.jump_impulse =
Some(f64::from(self.jump.call().unwrap_or(7.)) / 20.);
}
}
p.velocity = (p.velocity - 20. * 0.05).max(-30.);
let movement = [dx, p.velocity * 0.05, dz];
p.grounded = false;
let center = p.position.map(|v| v.floor() as i32);
let boxes = match self.collision_boxes(
&p.world,
[center[0] - 3, center[1] - 5, center[2] - 3],
[center[0] + 3, center[1] + 5, center[2] + 3],
) {
Ok(boxes) => boxes,
Err(e) => {
p.out
.send(json!({"type":"error","message":format!("world read failed: {e}")}));
let physics_world = match self.physics_world(&p.world, &p.body) {
Ok(world) => {
p.waiting_terrain = false;
world
}
Err(error) if error.to_string().contains("terrain pending") => {
p.waiting_terrain = true;
// Freeze the body and discard acknowledged motion instead of
// falling through sections that have not been generated yet.
p.body.velocity = [0.; 3];
p.velocity = 0.;
p.pending_inputs.clear();
p.processed_seq = p.received_seq;
p.motion_epoch += 1;
self.players.insert(id, p);
continue;
}
Err(error) => {
p.out.send(
json!({"type":"error","message":format!("world read failed: {error}")}),
);
self.players.insert(id, p);
continue;
}
};
for axis in [0, 2, 1] {
if movement[axis].abs() < 1e-6 {
continue;
}
let steps = (movement[axis].abs() / 0.15).ceil().max(1.) as usize;
for _ in 0..steps {
let mut candidate = p.position;
candidate[axis] += movement[axis] / steps as f32;
if boxes.iter().any(|b| intersects(candidate, b.min, b.max)) {
let mut low = 0.;
let mut high = 1.;
for _ in 0..10 {
let t = (low + high) * 0.5;
let mut at = p.position;
at[axis] += (candidate[axis] - p.position[axis]) * t;
if boxes.iter().any(|b| intersects(at, b.min, b.max)) {
high = t;
} else {
low = t;
}
}
p.position[axis] += (candidate[axis] - p.position[axis]) * low;
if axis == 1 {
p.grounded = movement[1] < 0.;
p.velocity = 0.;
}
break;
}
p.position = candidate;
}
}
if p.position[1] < conf.elimination_y || p.position.iter().any(|v| v.abs() > 32700.) {
shacraft_physics::step(
&mut p.body,
&p.input.controls(),
&physics_world,
&p.physics_settings,
);
p.input.fly_toggle = false;
p.position = p.body.position;
p.velocity = (p.body.velocity[1] * 20.) as f32;
p.grounded = p.body.on_ground;
if p.position[1] < conf.elimination_y
|| p.position.iter().any(|v| v.abs() > crate::terrain::BORDER)
{
let active = self
.matches
.get(&p.world)
@@ -920,12 +1327,21 @@ impl Game {
p.position = conf.spawn;
}
p.velocity = 0.;
p.grounded = false;
p.input = Input::default();
p.reset_motion();
}
self.players.insert(id.clone(), p);
let p = &self.players[&id];
let center = p.position.map(|v| (v.floor() as i32).div_euclid(16) * 16);
if center != p.view && p.last_snapshot.elapsed() > Duration::from_secs(2) {
let _ = self.snapshot(&id, false);
if center != p.view {
if p.section_stream {
let _ = self.begin_section_view(&id, center, false);
} else if p.chunk_stream && p.last_view.elapsed() >= Duration::from_millis(250) {
let _ = self.stream_view(&id, center);
} else if !p.chunk_stream && p.last_snapshot.elapsed() > Duration::from_secs(2) {
let _ = self.snapshot(&id, false);
}
}
}
self.advance_matches();
@@ -937,13 +1353,15 @@ impl Game {
.players
.values()
.filter(|p| p.world == world)
.map(|p| (p.id.clone(), p.input.seq))
.map(|p| (p.id.clone(), p.processed_seq))
.collect();
for (id, ack) in ids {
self.send(
&id,
json!({"type":"state","tick":self.tick,"ack":ack,"players":players,"match":m}),
);
let mut message =
json!({"type":"state","tick":self.tick,"ack":ack,"players":players,"match":m});
if let Some(player) = self.players.get(&id).filter(|p| p.prediction) {
message["motion"] = self.motion_json(player);
}
self.send(&id, message);
}
}
self.plans.retain(|_, p| p.expires > Instant::now());
@@ -951,17 +1369,22 @@ impl Game {
fn raycast(&mut self, id: &str) -> Result<Option<(Pos, Pos)>> {
let p = self.players.get(id).context("not joined")?;
let world = p.world.clone();
let eye = [p.position[0], p.position[1] + 1.62, p.position[2]];
let dir = [
p.input.yaw.sin() * p.input.pitch.cos(),
p.input.pitch.sin(),
-p.input.yaw.cos() * p.input.pitch.cos(),
let eye = [
p.position[0],
p.position[1] + p.body.eye_height(),
p.position[2],
];
let dir = [
p.look[0].sin() * p.look[1].cos(),
p.look[1].sin(),
-p.look[0].cos() * p.look[1].cos(),
];
let dir = dir.map(f64::from);
let end = std::array::from_fn::<_, 3, _>(|i| eye[i] + dir[i] * 6.);
let min = std::array::from_fn(|i| eye[i].min(end[i]).floor() as i32 - 3);
let max = std::array::from_fn(|i| eye[i].max(end[i]).ceil() as i32 + 3);
let mut nearest: Option<(f32, Pos, Pos)> = None;
for change in self.store.read_region(&world, min, max)? {
let mut nearest: Option<(f64, Pos, Pos)> = None;
for change in self.read_world_region(&world, min, max)? {
let cell = change.pos;
let boxes = self
.definition(change.block)
@@ -971,8 +1394,8 @@ impl Game {
if let Some((t, normal)) = ray_box(
eye,
dir,
std::array::from_fn(|i| cell[i] as f32 + b.min[i]),
std::array::from_fn(|i| cell[i] as f32 + b.max[i]),
std::array::from_fn(|i| f64::from(cell[i]) + f64::from(b.min[i])),
std::array::from_fn(|i| f64::from(cell[i]) + f64::from(b.max[i])),
) && nearest.is_none_or(|old| t < old.0)
{
nearest = Some((t, cell, normal));
@@ -1011,7 +1434,7 @@ impl Game {
self.store
.reset_world(world, rev, &format!("match-reset-{}", Uuid::new_v4()))?;
for (i, id) in ids.iter().enumerate() {
let angle = i as f32 / ids.len() as f32 * std::f32::consts::TAU;
let angle = i as f64 / ids.len() as f64 * std::f64::consts::TAU;
let p = self.players.get_mut(id).unwrap();
p.position = conf.spawn_points.get(i).copied().unwrap_or([
angle.sin() * 7.,
@@ -1020,6 +1443,9 @@ impl Game {
]);
p.alive = true;
p.velocity = 0.;
p.grounded = false;
p.input = Input::default();
p.reset_motion();
}
self.matches.insert(
world.into(),
@@ -1071,6 +1497,9 @@ impl Game {
p.position = conf.spawn;
p.velocity = 0.;
p.alive = true;
p.grounded = false;
p.input = Input::default();
p.reset_motion();
}
self.meta.active_matches.remove(&world);
let _ = self.save_meta();
@@ -1088,6 +1517,8 @@ impl Game {
}
}
pub(super) fn resnapshot(&mut self, world: &str) {
self.terrain.clear_world(world);
self.sky_cache.retain(|(w, _, _), _| w != world);
let ids: Vec<_> = self
.players
.values()
@@ -1107,6 +1538,7 @@ impl Game {
.collect()
}
}
#[cfg(test)]
fn intersects(p: [f32; 3], min: [f32; 3], max: [f32; 3]) -> bool {
let a = [p[0] - 0.3, p[1] + 0.001, p[2] - 0.3];
let b = [p[0] + 0.3, p[1] + 1.799, p[2] + 0.3];
@@ -1123,10 +1555,11 @@ fn string<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
p[key].as_str().context(format!("{key} string required"))
}
include!("control.rs");
include!("world_streaming.rs");
fn ray_box(eye: [f32; 3], dir: [f32; 3], min: [f32; 3], max: [f32; 3]) -> Option<(f32, Pos)> {
let mut near = 0f32;
let mut far = 6f32;
fn ray_box(eye: [f64; 3], dir: [f64; 3], min: [f64; 3], max: [f64; 3]) -> Option<(f64, Pos)> {
let mut near = 0f64;
let mut far = 6f64;
let mut normal = [0; 3];
for i in 0..3 {
if dir[i].abs() < 1e-7 {
+14 -1
View File
@@ -1,6 +1,7 @@
mod game;
mod packages;
mod render;
mod terrain;
use anyhow::{Context, Result};
use axum::{
Json, Router,
@@ -116,7 +117,19 @@ async fn main() -> Result<()> {
.route("/api/control", post(control))
.route("/packages/{id}/{version}/{*path}", get(resource))
.route("/ws", get(websocket))
.fallback_service(ServeDir::new(&args.client))
.fallback_service(
Router::new()
.fallback_service(ServeDir::new(&args.client))
.layer(axum::middleware::map_response(
|mut response: Response| async move {
response.headers_mut().insert(
header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
);
response
},
)),
)
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
.layer(tower::limit::ConcurrencyLimitLayer::new(16))
.with_state(app);
+775
View File
@@ -0,0 +1,775 @@
//! Original deterministic terrain. Unedited sections are reproducible cache data.
use anyhow::{Result, ensure};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shacraft_core::Pos;
use std::{
collections::{HashMap, HashSet, VecDeque},
sync::{Arc, Mutex, mpsc},
time::Instant,
};
pub const MIN_Y: i32 = -64;
pub const MAX_Y: i32 = 319;
pub const BORDER: f64 = 29_999_872.;
pub const VIEW_RADIUS: i32 = 3;
pub const MIN_VIEW_RADIUS: i32 = 2;
pub const MAX_VIEW_RADIUS: i32 = 64;
pub const VERTICAL_RADIUS: i32 = 2;
pub type Cells = [u32; 4096];
pub type TerrainKey = (String, Pos);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TerrainConfig {
pub seed: u64,
pub version: u32,
}
impl Default for TerrainConfig {
fn default() -> Self {
Self {
seed: 20260914,
version: 1,
}
}
}
impl TerrainConfig {
pub fn validate(&self) -> Result<()> {
ensure!(self.version == 1, "unsupported terrain generator version");
Ok(())
}
}
#[derive(Clone)]
pub struct Palette {
pub grass: u32,
pub dirt: u32,
pub stone: u32,
pub deep: u32,
pub bedrock: u32,
pub sand: u32,
pub water: u32,
pub lava: u32,
pub log: u32,
pub leaves: u32,
pub snow: u32,
pub coal: u32,
pub iron: u32,
pub copper: u32,
pub gold: u32,
pub diamond: u32,
pub deep_iron: u32,
pub deep_diamond: u32,
}
#[derive(Clone)]
pub struct Generator {
pub config: TerrainConfig,
pub palette: Palette,
}
#[derive(Clone, Copy)]
struct Column {
height: i32,
desert: bool,
mountain: bool,
}
#[derive(Clone, Copy)]
struct Tree {
x: i32,
z: i32,
ground: i32,
height: i32,
}
fn hash(seed: u64, x: i32, y: i32, z: i32) -> u64 {
let mut h = seed
^ (x as i64 as u64).wrapping_mul(0x9e3779b185ebca87)
^ (y as i64 as u64).wrapping_mul(0xc2b2ae3d27d4eb4f)
^ (z as i64 as u64).wrapping_mul(0x165667b19e3779f9);
h = (h ^ (h >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
h = (h ^ (h >> 27)).wrapping_mul(0x94d049bb133111eb);
h ^ (h >> 31)
}
fn smooth(v: f64) -> f64 {
v * v * v * (v * (v * 6. - 15.) + 10.)
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
fn noise(seed: u64, x: f64, y: f64, z: f64) -> f64 {
let [ix, iy, iz] = [x.floor() as i32, y.floor() as i32, z.floor() as i32];
let [fx, fy, fz] = [
smooth(x - x.floor()),
smooth(y - y.floor()),
smooth(z - z.floor()),
];
let n = |a, b, c| ((hash(seed, a, b, c) >> 11) as f64 / ((1u64 << 53) as f64)) * 2. - 1.;
lerp(
lerp(
lerp(n(ix, iy, iz), n(ix + 1, iy, iz), fx),
lerp(n(ix, iy + 1, iz), n(ix + 1, iy + 1, iz), fx),
fy,
),
lerp(
lerp(n(ix, iy, iz + 1), n(ix + 1, iy, iz + 1), fx),
lerp(n(ix, iy + 1, iz + 1), n(ix + 1, iy + 1, iz + 1), fx),
fy,
),
fz,
)
}
impl Generator {
fn column(&self, x: i32, z: i32) -> Column {
let s = self.config.seed;
let nx = f64::from(x);
let nz = f64::from(z);
let continent = noise(s, nx / 420., 0., nz / 420.);
let hills = noise(s ^ 17, nx / 105., 0., nz / 105.) * 15.
+ noise(s ^ 29, nx / 36., 0., nz / 36.) * 4.;
let ridge = (1. - noise(s ^ 71, nx / 240., 0., nz / 240.).abs()).powi(3);
let mountains = ((continent - 0.12) * 2.8).clamp(0., 1.) * ridge * 96.;
let mut height = 68. + continent * 35. + hills + mountains;
let river = noise(s ^ 131, nx / 210., 0., nz / 210.).abs();
if river < 0.045 && mountains < 14. {
height = lerp(58., height, (river / 0.045).powi(2));
}
// A dry, softly blended spawn clearing; the rest of the world is unbounded noise.
let distance = nx.hypot(nz - 8.);
if distance < 40. {
height = lerp(76., height, smooth((distance / 40.).clamp(0., 1.)));
}
Column {
height: (height.round() as i32).clamp(28, 210),
desert: noise(s ^ 911, nx / 330., 0., nz / 330.) > 0.27 && mountains < 30.,
mountain: mountains > 38.,
}
}
pub fn height(&self, x: i32, z: i32) -> i32 {
self.column(x, z).height
}
pub fn biome(&self, x: i32, z: i32) -> &'static str {
let c = self.column(x, z);
if c.height < 61 {
"ocean"
} else if c.mountain {
"mountains"
} else if c.desert {
"desert"
} else if noise(
self.config.seed ^ 351,
f64::from(x) / 180.,
0.,
f64::from(z) / 180.,
) > -0.15
{
"forest"
} else {
"plains"
}
}
fn trees(&self, sx: i32, sz: i32) -> Vec<Tree> {
let mut trees = Vec::new();
for gx in (sx * 16 - 3).div_euclid(8)..=(sx * 16 + 18).div_euclid(8) {
for gz in (sz * 16 - 3).div_euclid(8)..=(sz * 16 + 18).div_euclid(8) {
let h = hash(self.config.seed ^ 771, gx, 0, gz);
let x = gx * 8 + 2 + (h % 4) as i32;
let z = gz * 8 + 2 + ((h >> 8) % 4) as i32;
let c = self.column(x, z);
let forest = self.biome(x, z) == "forest";
if c.height <= 63
|| c.desert
|| c.mountain
|| h % 100 >= if forest { 58 } else { 12 }
|| f64::from(x).hypot(f64::from(z - 8)) < 12.
{
continue;
}
trees.push(Tree {
x,
z,
ground: c.height,
height: 4 + ((h >> 20) % 3) as i32,
});
}
}
trees
}
fn ground_block(&self, x: i32, y: i32, z: i32, c: Column) -> u32 {
let p = &self.palette;
let seed = self.config.seed;
if !(MIN_Y..=MAX_Y).contains(&y) {
return 0;
}
if y == MIN_Y || y < MIN_Y + 5 && hash(seed ^ 1, x, y, z) % 5 > (y - MIN_Y) as u64 {
return p.bedrock;
}
if y > c.height {
return if y <= 62 { p.water } else { 0 };
}
if y < c.height - 4 && y > MIN_Y + 5 {
let n = noise(
seed ^ 801,
f64::from(x) / 34.,
f64::from(y) / 24.,
f64::from(z) / 34.,
);
let tunnel = noise(
seed ^ 807,
f64::from(x) / 28.,
f64::from(y) / 18.,
f64::from(z) / 28.,
)
.abs()
< 0.065
&& noise(
seed ^ 809,
f64::from(x) / 65.,
f64::from(y) / 37.,
f64::from(z) / 65.,
)
.abs()
< 0.18;
if n > 0.39 || tunnel {
return if y < -54 {
p.lava
} else if y < 18 && n > 0.69 {
p.water
} else {
0
};
}
}
// A naturally open ravine near spawn is a repeatable route underground.
if (26..=35).contains(&x)
&& (-24..=30).contains(&z)
&& y > -26
&& y <= c.height
&& f64::from(x - 30).abs()
< 2.4 + noise(seed ^ 999, 0., f64::from(y) / 14., f64::from(z) / 18.) * 1.5
{
return 0;
}
if y == c.height {
return if c.desert || c.height <= 63 {
p.sand
} else if c.height > 145 {
p.snow
} else {
p.grass
};
}
if y >= c.height - 3 {
return if c.desert || c.height <= 63 {
p.sand
} else {
p.dirt
};
}
let ore = hash(
seed ^ 331,
x.div_euclid(2),
y.div_euclid(2),
z.div_euclid(2),
) % 1000;
let edge = !hash(seed ^ 333, x, y, z).is_multiple_of(5);
if edge {
if y < 0 && ore < 5 {
return p.deep_diamond;
}
if y < 18 && ore < 5 {
return p.diamond;
}
if y < 28 && (5..12).contains(&ore) {
return p.gold;
}
if y < 66 && (12..35).contains(&ore) {
return if y < 0 { p.deep_iron } else { p.iron };
}
if y > 0 && y < 88 && (35..55).contains(&ore) {
return p.copper;
}
if y > 0 && (55..86).contains(&ore) {
return p.coal;
}
}
if y < 0 { p.deep } else { p.stone }
}
pub fn generate(&self, section: Pos) -> Cells {
let [sx, sy, sz] = section;
let mut cells = [0; 4096];
if sy * 16 > MAX_Y || sy * 16 + 15 < MIN_Y {
return cells;
}
for z in 0..16 {
for x in 0..16 {
let wx = sx * 16 + x;
let wz = sz * 16 + z;
let c = self.column(wx, wz);
for y in 0..16 {
cells[(x + 16 * z + 256 * y) as usize] =
self.ground_block(wx, sy * 16 + y, wz, c);
}
}
}
for tree in self.trees(sx, sz) {
for y in tree.ground + 1..=tree.ground + tree.height + 2 {
for z in tree.z - 2..=tree.z + 2 {
for x in tree.x - 2..=tree.x + 2 {
if x.div_euclid(16) != sx
|| y.div_euclid(16) != sy
|| z.div_euclid(16) != sz
{
continue;
}
let trunk = x == tree.x && z == tree.z && y <= tree.ground + tree.height;
let leaf = y >= tree.ground + tree.height - 2
&& (x - tree.x).abs() + (z - tree.z).abs()
<= if y > tree.ground + tree.height { 2 } else { 3 };
let i = (x.rem_euclid(16) + 16 * z.rem_euclid(16) + 256 * y.rem_euclid(16))
as usize;
if trunk {
cells[i] = self.palette.log;
} else if leaf && cells[i] == 0 {
cells[i] = self.palette.leaves;
}
}
}
}
}
cells
}
/// Highest opaque natural cell. Used even when its section is not streamed.
pub fn sky_heights(&self, sx: i32, sz: i32) -> [i16; 256] {
let mut heights = [-65; 256];
for z in 0..16 {
for x in 0..16 {
let wx = sx * 16 + x;
let wz = sz * 16 + z;
let c = self.column(wx, wz);
let mut top = c.height;
while top > MIN_Y {
let block = self.ground_block(wx, top, wz, c);
if block != 0 && block != self.palette.water && block != self.palette.lava {
break;
}
top -= 1;
}
heights[(x + 16 * z) as usize] = top as i16;
}
}
for t in self.trees(sx, sz) {
if t.x.div_euclid(16) == sx && t.z.div_euclid(16) == sz {
heights[(t.x.rem_euclid(16) + 16 * t.z.rem_euclid(16)) as usize] =
(t.ground + t.height) as i16;
}
}
heights
}
}
struct Job {
key: TerrainKey,
generator: Arc<Generator>,
}
struct Finished {
key: TerrainKey,
cells: Arc<Cells>,
milliseconds: f64,
}
struct Cached {
cells: Arc<Cells>,
used: u64,
}
/// Fixed CPU workers and bounded pending jobs. Old natural sections can be
/// discarded at any time; only edited sections require durable storage.
pub struct TerrainService {
tx: Option<mpsc::SyncSender<Job>>,
rx: mpsc::Receiver<Finished>,
workers: Vec<std::thread::JoinHandle<()>>,
cache: HashMap<TerrainKey, Cached>,
lru: VecDeque<(TerrainKey, u64)>,
pending: HashSet<TerrainKey>,
clock: u64,
pub generated: u64,
pub discarded: u64,
read_interests: HashMap<TerrainKey, Instant>,
pub last_ms: f64,
pub max_ms: f64,
pub hits: u64,
pub misses: u64,
pub evictions: u64,
}
impl TerrainService {
pub fn new() -> Self {
let (tx, jobs) = mpsc::sync_channel::<Job>(32);
let jobs = Arc::new(Mutex::new(jobs));
let (finished, rx) = mpsc::sync_channel(32);
let mut workers = Vec::new();
for i in 0..2 {
let jobs = jobs.clone();
let finished = finished.clone();
workers.push(
std::thread::Builder::new()
.name(format!("terrain-{i}"))
.spawn(move || {
loop {
let job = match jobs.lock().unwrap().recv() {
Ok(job) => job,
Err(_) => break,
};
let start = Instant::now();
let cells = Arc::new(job.generator.generate(job.key.1));
if finished
.send(Finished {
key: job.key,
cells,
milliseconds: start.elapsed().as_secs_f64() * 1000.,
})
.is_err()
{
break;
}
}
})
.expect("terrain worker thread"),
);
}
Self {
tx: Some(tx),
rx,
workers,
cache: HashMap::new(),
lru: VecDeque::new(),
pending: HashSet::new(),
read_interests: HashMap::new(),
clock: 0,
generated: 0,
discarded: 0,
last_ms: 0.,
max_ms: 0.,
hits: 0,
misses: 0,
evictions: 0,
}
}
/// A control read may target terrain with no player nearby. Keep its
/// completed jobs long enough for the caller to retry, without unbounded pins.
pub fn retain_for_read(&mut self, key: TerrainKey) {
if self.read_interests.len() >= 512
&& !self.read_interests.contains_key(&key)
&& let Some(old) = self
.read_interests
.iter()
.min_by_key(|(_, t)| **t)
.map(|(k, _)| k.clone())
{
self.read_interests.remove(&old);
}
self.read_interests
.insert(key, Instant::now() + std::time::Duration::from_secs(10));
}
pub fn request(&mut self, key: TerrainKey, generator: Arc<Generator>) -> bool {
if self.cache.contains_key(&key) || self.pending.contains(&key) {
return true;
}
if self.pending.len() >= 32 {
return false;
}
if self
.tx
.as_ref()
.unwrap()
.try_send(Job {
key: key.clone(),
generator,
})
.is_err()
{
return false;
}
self.pending.insert(key);
true
}
pub fn poll(&mut self, wanted: &HashSet<TerrainKey>) {
if self.lru.len() > self.cache.len() * 4 + 128 {
self.lru.retain(|(key, stamp)| {
self.cache
.get(key)
.is_some_and(|entry| entry.used == *stamp)
});
}
self.read_interests
.retain(|_, until| *until > Instant::now());
while let Ok(done) = self.rx.try_recv() {
self.pending.remove(&done.key);
self.generated += 1;
self.last_ms = done.milliseconds;
self.max_ms = self.max_ms.max(done.milliseconds);
if wanted.contains(&done.key) || self.read_interests.contains_key(&done.key) {
self.insert(done.key, done.cells);
} else {
self.discarded += 1;
}
}
}
pub fn get(&mut self, key: &TerrainKey) -> Option<Arc<Cells>> {
self.clock += 1;
if let Some(entry) = self.cache.get_mut(key) {
entry.used = self.clock;
self.lru.push_back((key.clone(), self.clock));
self.hits += 1;
Some(entry.cells.clone())
} else {
self.misses += 1;
None
}
}
pub fn contains(&self, key: &TerrainKey) -> bool {
self.cache.contains_key(key)
}
pub fn insert(&mut self, key: TerrainKey, cells: Arc<Cells>) {
self.clock += 1;
while self.cache.len() >= 8192 && !self.cache.contains_key(&key) {
let Some((old, stamp)) = self.lru.pop_front() else {
break;
};
if self
.cache
.get(&old)
.is_some_and(|entry| entry.used == stamp)
{
self.cache.remove(&old);
self.evictions += 1;
}
}
self.lru.push_back((key.clone(), self.clock));
self.cache.insert(
key,
Cached {
cells,
used: self.clock,
},
);
}
pub fn clear_world(&mut self, world: &str) {
self.cache.retain(|(w, _), _| w != world);
}
pub fn invalidate(&mut self, world: &str, sections: &HashSet<Pos>) {
self.cache
.retain(|(w, pos), _| w != world || !sections.contains(pos));
}
pub fn stats(&self) -> Value {
json!({"workers":2,"pending":self.pending.len(),"queue_limit":32,"cache_sections":self.cache.len(),"cache_limit":8192,"cache_bytes":self.cache.len()*16384,"generated_sections":self.generated,"discarded_results":self.discarded,"cache_hits":self.hits,"cache_misses":self.misses,"cache_evictions":self.evictions,"last_generate_ms":self.last_ms,"max_generate_ms":self.max_ms})
}
}
impl Drop for TerrainService {
fn drop(&mut self) {
self.tx.take();
// Drain completed work while joining: workers may be backpressured.
while self.workers.iter().any(|w| !w.is_finished()) {
while self.rx.try_recv().is_ok() {}
std::thread::yield_now();
}
for worker in self.workers.drain(..) {
let _ = worker.join();
}
}
}
/// Keep one prefetched ring resident while crossing a single horizontal section.
pub fn view_needs_move(previous: Pos, next: Pos, buffered: bool) -> bool {
(0..3).any(|axis| {
(previous[axis] - next[axis]).abs() > if buffered && axis != 1 { 16 } else { 0 }
})
}
#[cfg(test)]
pub fn bounds(center: Pos, radius: i32) -> (Pos, Pos) {
view_bounds(center, radius, false)
}
pub fn view_bounds(center: Pos, radius: i32, full_height: bool) -> (Pos, Pos) {
(
[
center[0] - radius * 16,
if full_height {
MIN_Y
} else {
center[1] - VERTICAL_RADIUS * 16
},
center[2] - radius * 16,
],
[
center[0] + (radius + 1) * 16 - 1,
if full_height {
MAX_Y
} else {
center[1] + (VERTICAL_RADIUS + 1) * 16 - 1
},
center[2] + (radius + 1) * 16 - 1,
],
)
}
pub fn sections(center: Pos, radius: i32) -> Vec<Pos> {
view_sections(center, radius, false)
}
pub fn view_sections(center: Pos, radius: i32, full_height: bool) -> Vec<Pos> {
let c = center.map(|v| v.div_euclid(16));
let (min, max) = view_bounds(center, radius, full_height);
let mut result = Vec::new();
for x in c[0] - radius..=c[0] + radius {
for z in c[2] - radius..=c[2] + radius {
for y in min[1].div_euclid(16)..=max[1].div_euclid(16) {
result.push([x, y, z]);
}
}
}
result.sort_by_key(|p| (p[0] - c[0]).pow(2) + (p[2] - c[2]).pow(2) + (p[1] - c[1]).pow(2) * 2);
result
}
/// Palette + runs avoids JSON objects and repeated world coordinates per voxel.
pub fn pack(cells: &Cells) -> Value {
if cells.iter().all(|id| *id == cells[0]) {
return json!({"palette":[cells[0]],"runs":[4096,0]});
}
let mut palette = Vec::new();
let mut indices = HashMap::new();
let mut runs = Vec::<u32>::new();
let mut previous = u32::MAX;
let mut count = 0;
for &block in cells {
let next = indices.len() as u32;
let index = *indices.entry(block).or_insert_with(|| {
palette.push(block);
next
});
if index == previous {
count += 1;
} else {
if count > 0 {
runs.extend([count, previous]);
}
previous = index;
count = 1;
}
}
runs.extend([count, previous]);
json!({"palette":palette,"runs":runs})
}
#[cfg(test)]
mod tests {
use super::*;
fn generator(seed: u64) -> Generator {
Generator {
config: TerrainConfig { seed, version: 1 },
palette: Palette {
grass: 1,
dirt: 2,
stone: 3,
deep: 4,
bedrock: 5,
sand: 6,
water: 7,
lava: 8,
log: 9,
leaves: 10,
snow: 11,
coal: 12,
iron: 13,
copper: 14,
gold: 15,
diamond: 16,
deep_iron: 17,
deep_diamond: 18,
},
}
}
#[test]
fn generation_is_order_independent_and_seeded_at_negative_and_distant_coordinates() {
let g = generator(20260914);
let other = generator(921);
for pos in [[-1, 3, -1], [1, -2, 0], [1_800_000, 4, -1_800_000]] {
let first = g.generate(pos);
let _ = g.generate([pos[0] + 1, pos[1], pos[2]]);
assert_eq!(first, g.generate(pos));
}
assert_ne!(g.generate([19, 3, -7]), other.generate([19, 3, -7]));
}
#[test]
fn world_has_deep_caves_ores_surface_water_and_an_unbroken_bedrock_bottom() {
let g = generator(20260914);
let bottom = g.generate([0, -4, 0]);
assert!(bottom[..256].iter().all(|id| *id == g.palette.bedrock));
let mut caves = 0;
let mut deep = 0;
let mut ores = 0;
for sy in -3..2 {
for sx in -2..=2 {
for sz in -2..=2 {
for id in g.generate([sx, sy, sz]) {
if id == 0 {
caves += 1;
}
if id == g.palette.deep {
deep += 1;
}
if (12..=18).contains(&id) {
ores += 1;
}
}
}
}
}
assert!(caves > 1000);
assert!(deep > 10000);
assert!(ores > 1000);
assert!(g.generate([0, 20, 0]).iter().all(|id| *id == 0));
assert!(g.height(0, 8) > 62);
assert!((-1000..1000).step_by(40).any(|x| g.height(x, 100) < 62));
}
#[test]
fn natural_sky_height_agrees_with_the_highest_opaque_generated_cell_including_ravine() {
let g = generator(20260914);
for [sx, sz] in [[0, 0], [1, 0], [-1, -1]] {
let heights = g.sky_heights(sx, sz);
let sections: Vec<_> = (MIN_Y / 16..=MAX_Y / 16)
.map(|sy| g.generate([sx, sy, sz]))
.collect();
for z in 0..16 {
for x in 0..16 {
let top = (MIN_Y..=MAX_Y)
.rev()
.find(|y| {
let sy = (y.div_euclid(16) - MIN_Y / 16) as usize;
let id = sections[sy][(x + 16 * z + 256 * y.rem_euclid(16)) as usize];
id != 0
&& id != g.palette.water
&& id != g.palette.lava
&& id != g.palette.leaves
})
.unwrap();
assert_eq!(
i32::from(heights[(x + 16 * z) as usize]),
top,
"{sx}/{sz}/{x}/{z}"
);
}
}
}
}
#[test]
fn view_and_wire_payloads_are_bounded_and_near_sections_are_first() {
let view = sections([-16, 64, 32], VIEW_RADIUS);
assert_eq!(view.len(), 245);
assert_eq!(view[0], [-1, 4, 2]);
assert!(view.len() * 4096 <= 1_048_576);
assert_eq!(pack(&[3; 4096]), json!({"palette":[3],"runs":[4096,0]}));
let cells = std::array::from_fn(|i| (i % 3) as u32);
let value = pack(&cells);
let runs = value["runs"].as_array().unwrap();
assert_eq!(
runs.iter()
.step_by(2)
.map(|v| v.as_u64().unwrap())
.sum::<u64>(),
4096
);
}
}
+918 -1
View File
@@ -51,9 +51,20 @@ fn fixture() -> (TempDir, Game) {
(dir, game)
}
fn connect(game: &mut Game, world: &str, name: &str) -> TestClient {
connect_with_features(game, world, name, &[])
}
fn connect_with_features(
game: &mut Game,
world: &str,
name: &str,
features: &[&str],
) -> TestClient {
let (tx, inbox) = tmpsc::channel(16);
let bytes = Arc::new(AtomicUsize::new(0));
let p = json!({"type":"join","protocol":1,"name":name,"world":world,"manifest_hash":game.manifest["hash"]});
let mut p = json!({"type":"join","protocol":1,"name":name,"world":world,"manifest_hash":game.manifest["hash"]});
if !features.is_empty() {
p["features"] = json!(features);
}
let id = game
.join(
p,
@@ -83,6 +94,854 @@ fn entity_spawn(game: &mut Game, kind: &str) -> Result<Value> {
game.query("entity.spawn",json!({"world":"lobby","kind":kind,"position":[2.,1.,2.],"properties":{"custom_name":"Saved animal","nbt":{"test:unrecognized":{"type":"long","value":"9223372036854775807"}}},"expected_revision":game.meta.revision,"operation_id":Uuid::new_v4().to_string()}))
}
fn physics_fixture() -> (TempDir, Game, TestClient) {
let (dir, mut game) = fixture();
game.store.create_world("physics", None).unwrap();
game.meta.worlds.insert(
"physics".into(),
WorldConfig {
spawn: [0.5, 1., 0.5],
elimination_y: -100.,
..WorldConfig::default()
},
);
let stone = game.id("minecraft:stone").unwrap();
let mut floor = Vec::new();
for x in -4..=4 {
for z in -30..=4 {
floor.push(BlockChange {
pos: [x, 0, z],
block: stone,
});
}
}
edit(&mut game, "physics", floor);
let mut client = connect_with_features(
&mut game,
"physics",
"Walker",
&["chunk_stream_v1", "movement_prediction_v1"],
);
client.drain();
ticks(&mut game, &mut [&mut client], 2);
(dir, game, client)
}
fn movement(seq: u64) -> Value {
json!({"type":"input","seq":seq,"yaw":0.,"pitch":0.,"forward":1.,"strafe":0.})
}
fn settle_sections(game: &mut Game, client: &mut TestClient) -> Vec<Value> {
let deadline = Instant::now() + Duration::from_secs(15);
let mut messages = client.drain();
while game.players[&client.id].sent_sections.len()
< ((game.players[&client.id].view_distance * 2 + 1).pow(2) * 5) as usize
{
assert!(
Instant::now() < deadline,
"terrain did not settle: {}",
game.query("diagnostics.snapshot", json!({})).unwrap()
);
game.players.get_mut(&client.id).unwrap().last_batch =
Instant::now() - Duration::from_secs(1);
game.step();
messages.extend(client.drain());
std::thread::sleep(Duration::from_millis(1));
}
messages
}
#[test]
fn render_distance_changes_only_the_requesting_view_and_preserves_motion() {
let (_dir, mut game) = fixture();
game.store.create_world("distance-test", None).unwrap();
let mut first =
connect_with_features(&mut game, "distance-test", "First", &["chunk_stream_v2"]);
game.query(
"player.teleport",
json!({"id":first.id,"position":[0.,80.,8.],"flying":true}),
)
.unwrap();
settle_sections(&mut game, &mut first);
let position = game.players[&first.id].position;
let epoch = game.players[&first.id].motion_epoch;
let mut second =
connect_with_features(&mut game, "distance-test", "Second", &["chunk_stream_v2"]);
second.drain();
game.client(&first.id, json!({"type":"view_distance","chunks":6}))
.unwrap();
let view = first
.drain()
.into_iter()
.find(|v| v["type"] == "view")
.unwrap();
assert_eq!(view["total_sections"], 845);
assert_eq!(view["unload"], json!([]));
assert_eq!(game.players[&first.id].sent_sections.len(), 245);
assert_eq!(game.players[&second.id].view_distance, 3);
assert!(second.drain().is_empty());
game.players.remove(&second.id);
for value in [json!(1), json!(65), json!(3.5), json!("6"), Value::Null] {
assert!(
game.client(&first.id, json!({"type":"view_distance","chunks":value}))
.is_err()
);
assert_eq!(game.players[&first.id].view_distance, 6);
}
assert_eq!(game.players[&first.id].position, position);
assert_eq!(game.players[&first.id].motion_epoch, epoch);
settle_sections(&mut game, &mut first);
game.players
.get_mut(&first.id)
.unwrap()
.last_distance_change = Instant::now() - Duration::from_secs(1);
game.client(&first.id, json!({"type":"view_distance","chunks":2}))
.unwrap();
let view = first
.drain()
.into_iter()
.find(|v| v["type"] == "view")
.unwrap();
assert_eq!(view["total_sections"], 125);
assert_eq!(view["unload"].as_array().unwrap().len(), 720);
assert_eq!(game.players[&first.id].sent_sections.len(), 125);
assert_eq!(game.store.revision("distance-test").unwrap(), 0);
assert!(
game.section_data("overworld", [1_875_000, 4, 0])
.unwrap()
.unwrap()
.iter()
.all(|id| *id == 0)
);
}
#[test]
fn full_height_stream_retains_ground_and_roof_during_vertical_flight() {
let (_dir, mut game) = fixture();
game.store.create_world("height-test", None).unwrap();
let mut client = connect_with_features(
&mut game,
"height-test",
"Fly",
&["chunk_stream_v2", "view_buffer_v1", "full_height_v1"],
);
let welcome = client
.drain()
.into_iter()
.find(|v| v["type"] == "welcome")
.unwrap();
assert_eq!(welcome["view_min"][1], -64);
assert_eq!(welcome["view_max"][1], 319);
assert_eq!(welcome["total_sections"], 1944);
let p = game.players.get_mut(&client.id).unwrap();
p.sent_sections.extend([[0, -4, 0], [0, 19, 0]]);
let center = [p.view[0], 512, p.view[2]];
game.begin_section_view(&client.id, center, false).unwrap();
let view = client
.drain()
.into_iter()
.find(|v| v["type"] == "view")
.unwrap();
assert_eq!(view["view_min"][1], -64);
assert_eq!(view["view_max"][1], 319);
assert_eq!(view["unload"], json!([]));
assert!(game.players[&client.id].sent_sections.contains(&[0, -4, 0]));
assert!(game.players[&client.id].sent_sections.contains(&[0, 19, 0]));
assert_eq!(terrain::view_sections([0, 96, 0], 65, true).len(), 411864);
}
#[test]
fn sixty_four_chunk_view_queues_the_full_buffer_and_delivers_a_retained_outer_ring() {
let (_dir, mut game) = fixture();
game.store.create_world("distance-64", None).unwrap();
let mut client = connect_with_features(
&mut game,
"distance-64",
"Wide",
&["chunk_stream_v2", "view_buffer_v1"],
);
game.query(
"player.teleport",
json!({"id":client.id,"position":[0.,80.,0.],"flying":true}),
)
.unwrap();
game.client(&client.id, json!({"type":"view_distance","chunks":64}))
.unwrap();
let view = client
.drain()
.into_iter()
.rev()
.find(|m| m["type"] == "view")
.unwrap();
assert_eq!(view["view_distance"], 64);
assert_eq!(view["stream_radius"], 65);
assert_eq!(view["total_sections"], 85805);
assert_eq!(view["view_min"][0], -1040);
assert_eq!(view["view_max"][0], 1055);
let center = game.players[&client.id].view;
let outer = [64, center[1] / 16, -64];
assert!(game.players[&client.id].pending_sections.contains(&outer));
assert_eq!(
game.players[&client.id].pending_sections.front().unwrap()[0],
0
);
let gold = game.id("minecraft:gold_block").unwrap();
game.store
.materialize_section("distance-64", outer, &[gold; 4096])
.unwrap();
// Model an otherwise retained view with one outer section still missing.
let player = game.players.get_mut(&client.id).unwrap();
player.sent_sections.extend(
terrain::sections(center, 65)
.into_iter()
.filter(|pos| *pos != outer),
);
game.begin_section_view(&client.id, center, false).unwrap();
client.drain();
assert_eq!(
game.players[&client.id].pending_sections,
VecDeque::from([outer])
);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
assert!(Instant::now() < deadline, "outer section was not delivered");
game.players.get_mut(&client.id).unwrap().last_batch =
Instant::now() - Duration::from_secs(1);
game.pump_terrain();
if let Some(batch) = client.drain().into_iter().find(|m| m["type"] == "sections") {
assert_eq!(batch["sections"][0]["section"], json!(outer));
assert_eq!(batch["sections"][0]["palette"], json!([gold]));
assert_eq!(batch["loaded_sections"], 85805);
break;
}
}
assert!(game.players[&client.id].pending_sections.is_empty());
assert_eq!(game.store.revision("distance-64").unwrap(), 0);
}
#[test]
fn procedural_stream_is_bounded_retains_overlap_and_never_advances_edit_revision() {
let (_dir, mut game) = fixture();
let mut legacy = connect(&mut game, "lobby", "Legacy");
legacy.drain();
game.players.get_mut(&legacy.id).unwrap().last_snapshot =
Instant::now() - Duration::from_secs(2);
assert!(
game.client(
&legacy.id,
json!({"type":"switch_world","world":"overworld"})
)
.is_err()
);
assert_eq!(game.players[&legacy.id].world, "lobby");
game.players.remove(&legacy.id);
let revision = game.store.revision("overworld").unwrap();
let mut client = connect_with_features(
&mut game,
"overworld",
"Explorer",
&["chunk_stream_v2", "movement_prediction_v1"],
);
let welcome = client
.drain()
.into_iter()
.find(|m| m["type"] == "welcome")
.unwrap();
assert_eq!(welcome["blocks"], json!([]));
assert_eq!(welcome["total_sections"], 245);
let first = game.players[&client.id].position;
game.client(&client.id, movement(1)).unwrap();
game.step();
// A cold collision read either holds the authoritative body or uses a
// completed solid section; it can never fall through ungenerated ground.
assert!(game.players[&client.id].position[1] >= first[1] - 0.01);
let messages = settle_sections(&mut game, &mut client);
let batches: Vec<_> = messages
.iter()
.filter(|m| m["type"] == "sections")
.collect();
assert!(!batches.is_empty());
for batch in batches {
assert!(batch["sections"].as_array().unwrap().len() <= 32);
assert!(batch.to_string().len() < 512 * 1024);
}
assert_eq!(game.store.revision("overworld").unwrap(), revision);
let before = game.players[&client.id].sent_sections.clone();
let center = game.players[&client.id].view;
game.begin_section_view(&client.id, [center[0] + 16, center[1], center[2]], false)
.unwrap();
let retained = &game.players[&client.id].sent_sections;
assert_eq!(retained.len(), 210);
assert!(retained.is_subset(&before));
let update = client.drain();
assert!(
update
.iter()
.any(|m| m["type"] == "view" && m["unload"].as_array().unwrap().len() == 35)
);
assert!(!update.iter().any(|m| m["type"] == "snapshot"));
}
#[test]
fn remote_procedural_reads_finish_without_players_and_air_edits_survive_restart() {
let (dir, mut game) = fixture();
let min = [1_000_000, -20, -1_000_000];
let max = [min[0] + 17, min[1] + 3, min[2] + 3];
let query = json!({"world":"overworld","min":min,"max":max});
let deadline = Instant::now() + Duration::from_secs(10);
let read = loop {
match game.query("world.read", query.clone()) {
Ok(value) => break value,
Err(e) => assert!(e.to_string().contains("terrain pending"), "{e}"),
}
assert!(Instant::now() < deadline);
game.step();
std::thread::sleep(Duration::from_millis(1));
};
let natural = &read["blocks"][0];
assert!(natural.is_object());
let pos: Pos = serde_json::from_value(natural["pos"].clone()).unwrap();
let original = natural["block"].as_u64().unwrap() as u32;
let revision = game.store.revision("overworld").unwrap();
game.query("world.edit",json!({"world":"overworld","expected_revision":revision,"operation_id":"mine-natural","changes":[{"pos":pos,"block":0}]})).unwrap();
assert_eq!(game.world_block("overworld", pos).unwrap(), 0);
game.terrain.clear_world("overworld");
assert_eq!(game.world_block("overworld", pos).unwrap(), 0);
drop(game);
let mut game = open_game(dir.path());
assert_eq!(game.world_block("overworld", pos).unwrap(), 0);
let revision = game.store.revision("overworld").unwrap();
game.query("world.undo",json!({"world":"overworld","expected_revision":revision,"operation_id":"undo-natural","target_operation":"mine-natural"})).unwrap();
assert_eq!(game.world_block("overworld", pos).unwrap(), original);
let before = game.store.stats()["materialized_natural_sections"].clone();
assert!(
game.materialize_changes(
"overworld",
&[BlockChange {
pos: [100, 320, 0],
block: original
}]
)
.is_err()
);
assert!(
game.materialize_changes(
"overworld",
&[BlockChange {
pos: [100, 70, 0],
block: u32::MAX
}]
)
.is_err()
);
assert_eq!(game.store.stats()["materialized_natural_sections"], before);
}
#[test]
fn distant_player_coordinates_keep_sub_block_precision_and_wait_for_cold_terrain() {
let (_dir, mut game) = fixture();
let mut client = connect_with_features(
&mut game,
"overworld",
"Far",
&["chunk_stream_v2", "movement_prediction_v1"],
);
client.drain();
let position = [29_999_000.125, 250.25, -29_999_000.875];
game.query(
"player.teleport",
json!({"id":client.id,"position":position,"flying":true}),
)
.unwrap();
assert_eq!(game.players[&client.id].body.position, position);
let snapshot = client
.drain()
.into_iter()
.find(|m| m["type"] == "snapshot")
.unwrap();
assert_eq!(snapshot["spawn"], json!(position));
let _ = settle_sections(&mut game, &mut client);
assert_eq!(game.players[&client.id].position, position);
game.client(&client.id, movement(1)).unwrap();
ticks(&mut game, &mut [&mut client], 1);
let next = game.players[&client.id].position;
assert!(next[2] < position[2] && next[2] > position[2] - 1.);
assert_eq!(next[0], position[0]);
assert_eq!(next[1], position[1]);
}
#[test]
fn movement_queue_acknowledges_processed_commands_and_bounds_packet_bursts() {
let (_dir, mut game, mut client) = physics_fixture();
for seq in 1..=32 {
game.client(&client.id, movement(seq)).unwrap();
}
assert!(game.client(&client.id, movement(33)).is_err());
assert_eq!(game.players[&client.id].processed_seq, 0);
let before = game.players[&client.id].body.position;
game.step();
let state = client
.drain()
.into_iter()
.find(|m| m["type"] == "state")
.unwrap();
assert_eq!(state["ack"], 1);
assert_eq!(state["motion"]["ack"], 1);
assert_eq!(game.players[&client.id].pending_inputs.len(), 31);
assert!((before[2] - game.players[&client.id].body.position[2] - 0.098).abs() < 1e-6);
game.client(&client.id, movement(1)).unwrap();
assert_eq!(
game.players[&client.id].pending_inputs.len(),
31,
"duplicate must not move twice"
);
game.client(&client.id, movement(33)).unwrap();
ticks(&mut game, &mut [&mut client], 32);
assert_eq!(game.players[&client.id].processed_seq, 33);
}
#[test]
fn server_movement_preserves_shared_double_precision_state_over_ticks() {
let (_dir, mut game, mut client) = physics_fixture();
let mut expected = game.players[&client.id].body.clone();
for seq in 1..=80 {
let mut input = movement(seq);
input["forward"] = json!(if seq <= 55 { 1. } else { 0. });
input["sprint"] = json!(seq > 20 && seq < 50);
input["jump"] = json!(seq == 25);
input["sneak"] = json!(seq > 55);
game.client(&client.id, input).unwrap();
let p = &game.players[&client.id];
let controls = p.pending_inputs.front().unwrap().controls();
let settings = p.physics_settings.clone();
let world = game.physics_world("physics", &expected).unwrap();
shacraft_physics::step(&mut expected, &controls, &world, &settings);
ticks(&mut game, &mut [&mut client], 1);
let actual = &game.players[&client.id].body;
for axis in 0..3 {
assert!(
(actual.position[axis] - expected.position[axis]).abs() < 1e-12,
"tick {seq}, axis {axis} was rounded by the adapter"
);
assert!((actual.velocity[axis] - expected.velocity[axis]).abs() < 1e-12);
}
}
}
#[test]
fn input_reset_cancels_backlog_and_respawn_invalidates_prediction_epoch() {
let (_dir, mut game, mut client) = physics_fixture();
let epoch = game.players[&client.id].motion_epoch;
for seq in 1..=8 {
game.client(&client.id, movement(seq)).unwrap();
}
game.client(&client.id, json!({"type":"input_reset"}))
.unwrap();
let p = &game.players[&client.id];
assert!(p.pending_inputs.is_empty());
assert_eq!(p.processed_seq, 8);
assert_eq!(p.motion_epoch, epoch + 1);
ticks(&mut game, &mut [&mut client], 2);
assert_eq!(game.players[&client.id].body.position[2], 0.5);
game.client(&client.id, movement(9)).unwrap();
ticks(&mut game, &mut [&mut client], 1);
game.client(&client.id, movement(10)).unwrap();
game.client(&client.id, json!({"type":"respawn"})).unwrap();
let p = &game.players[&client.id];
assert_eq!(p.body.position, [0.5, 1., 0.5]);
assert_eq!(p.body.velocity, [0.; 3]);
assert_eq!(p.motion_epoch, epoch + 2);
assert_eq!(p.processed_seq, 10);
assert!(p.pending_inputs.is_empty());
}
#[test]
fn flight_is_authorized_by_world_and_revoked_in_switch_snapshot() {
let (_dir, mut game, mut client) = physics_fixture();
let mut input = movement(1);
input["forward"] = json!(0.);
input["fly_toggle"] = json!(true);
input["jump"] = json!(true);
input["position"] = json!([5000., 5000., 5000.]);
game.client(&client.id, input).unwrap();
ticks(&mut game, &mut [&mut client], 2);
assert!(game.players[&client.id].body.flying);
assert!(game.players[&client.id].body.position[1] > 1.);
assert!(game.players[&client.id].body.position[1] < 2.);
game.players.get_mut(&client.id).unwrap().last_snapshot =
Instant::now() - Duration::from_secs(2);
game.client(&client.id, json!({"type":"switch_world","world":"arena_1"}))
.unwrap();
let snapshot = client
.drain()
.into_iter()
.find(|m| m["type"] == "snapshot")
.unwrap();
assert_eq!(snapshot["motion"]["settings"]["allow_flight"], false);
assert_eq!(snapshot["motion"]["body"]["flying"], false);
let mut input = movement(2);
input["fly_toggle"] = json!(true);
game.client(&client.id, input).unwrap();
ticks(&mut game, &mut [&mut client], 1);
assert!(!game.players[&client.id].body.flying);
}
#[test]
fn raycast_uses_pose_eye_height_and_look_does_not_consume_movement() {
let (_dir, mut game, mut client) = physics_fixture();
let stone = game.id("minecraft:stone").unwrap();
edit(
&mut game,
"physics",
vec![BlockChange {
pos: [0, 1, -2],
block: stone,
}],
);
let p = game.players.get_mut(&client.id).unwrap();
p.body.pose = shacraft_physics::Pose::Swimming;
assert_eq!(game.raycast(&client.id).unwrap().unwrap().0, [0, 1, -2]);
game.players.get_mut(&client.id).unwrap().body.pose = shacraft_physics::Pose::Standing;
assert!(game.raycast(&client.id).unwrap().is_none());
game.client(&client.id, movement(1)).unwrap();
game.client(&client.id, json!({"type":"look","yaw":0.,"pitch":-0.5}))
.unwrap();
assert!(game.raycast(&client.id).unwrap().is_some());
assert_eq!(game.players[&client.id].processed_seq, 0);
assert_eq!(game.players[&client.id].pending_inputs.len(), 1);
ticks(&mut game, &mut [&mut client], 1);
assert_eq!(game.players[&client.id].processed_seq, 1);
assert_eq!(
game.players[&client.id].look,
[0., -0.5],
"queued movement must not roll back the action ray"
);
assert!(game.raycast(&client.id).unwrap().is_some());
}
fn streaming_fixture() -> (TempDir, Game, TestClient) {
let (dir, mut game) = fixture();
game.store.create_world("stream", None).unwrap();
game.meta.worlds.insert(
"stream".into(),
WorldConfig {
spawn: [0.5, 2., 0.5],
elimination_y: -32700.,
..WorldConfig::default()
},
);
let client = connect_with_features(&mut game, "stream", "Explorer", &["chunk_stream_v1"]);
(dir, game, client)
}
fn move_view(game: &mut Game, client: &mut TestClient, position: [f32; 3]) -> Vec<Value> {
let player = game.players.get_mut(&client.id).unwrap();
player.position = position.map(f64::from);
player.velocity = 0.;
player.input = Input::default();
player.last_view = Instant::now() - Duration::from_secs(1);
game.step();
client.drain()
}
fn chunk_message(messages: &[Value]) -> &Value {
assert!(messages.iter().all(|message| message["type"] != "snapshot"));
let chunks: Vec<_> = messages
.iter()
.filter(|message| message["type"] == "chunks")
.collect();
assert_eq!(chunks.len(), 1, "one incremental view update: {messages:?}");
chunks[0]
}
fn received_sections(message: &Value, field: &str) -> HashSet<Pos> {
message[field]
.as_array()
.unwrap()
.iter()
.map(|value| {
serde_json::from_value(if field == "sections" {
value["section"].clone()
} else {
value.clone()
})
.unwrap()
})
.collect()
}
#[test]
fn chunk_stream_welcome_and_resync_use_complete_aligned_sections() {
let (_dir, mut game, mut client) = streaming_fixture();
let welcome = client.drain().remove(0);
assert_eq!(welcome["type"], "welcome");
assert_eq!(welcome["features"], json!(["chunk_stream_v1"]));
assert_eq!(welcome["view_center"], json!([0, 0, 0]));
assert_eq!(welcome["view_min"], json!([-32, -16, -32]));
assert_eq!(welcome["view_max"], json!([31, 31, 31]));
let stone = game.id("minecraft:stone").unwrap();
edit(
&mut game,
"stream",
vec![
BlockChange {
pos: [-32, -16, -32],
block: stone,
},
BlockChange {
pos: [31, 31, 31],
block: stone,
},
BlockChange {
pos: [-32, -17, -32],
block: stone,
},
BlockChange {
pos: [32, 31, 31],
block: stone,
},
],
);
game.players.get_mut(&client.id).unwrap().last_snapshot =
Instant::now() - Duration::from_secs(1);
game.client(&client.id, json!({"type":"resync"})).unwrap();
let snapshot = client.drain().remove(0);
assert_eq!(snapshot["type"], "snapshot");
assert_eq!(snapshot["view_min"], welcome["view_min"]);
assert_eq!(snapshot["view_max"], welcome["view_max"]);
let positions: HashSet<Pos> = snapshot["blocks"]
.as_array()
.unwrap()
.iter()
.map(|block| serde_json::from_value(block["pos"].clone()).unwrap())
.collect();
assert_eq!(positions, HashSet::from([[-32, -16, -32], [31, 31, 31]]));
assert_eq!(snapshot["revision"], game.store.revision("stream").unwrap());
assert!(game.client(&client.id, json!({"type":"resync"})).is_err());
}
#[test]
fn chunk_stream_x_move_sends_only_entering_sections_and_materials() {
let (_dir, mut game, mut client) = streaming_fixture();
client.drain();
let stone = game.id("minecraft:stone").unwrap();
let ore = game.id("minecraft:diamond_ore").unwrap();
edit(
&mut game,
"stream",
vec![
BlockChange {
pos: [0, 0, 0],
block: stone,
},
BlockChange {
pos: [-32, 0, 0],
block: stone,
},
BlockChange {
pos: [32, 0, 0],
block: ore,
},
],
);
let last_snapshot = game.players[&client.id].last_snapshot;
let messages = move_view(&mut game, &mut client, [16.5, 2., 0.5]);
let chunks = chunk_message(&messages);
assert_eq!(chunks["world"], "stream");
assert_eq!(chunks["from_center"], json!([0, 0, 0]));
assert_eq!(chunks["view_center"], json!([16, 0, 0]));
assert_eq!(chunks["view_min"], json!([-16, -16, -32]));
assert_eq!(chunks["view_max"], json!([47, 31, 31]));
let entering = received_sections(chunks, "sections");
let leaving = received_sections(chunks, "unload");
assert_eq!(entering.len(), 12);
assert!(entering.iter().all(|section| section[0] == 2));
assert_eq!(leaving.len(), 12);
assert!(leaving.iter().all(|section| section[0] == -2));
let blocks: Vec<_> = chunks["sections"]
.as_array()
.unwrap()
.iter()
.flat_map(|section| section["blocks"].as_array().unwrap())
.collect();
assert_eq!(blocks, vec![&json!({"pos":[32,0,0],"block":ore})]);
assert_eq!(chunks["materials"].as_array().unwrap().len(), 1);
assert_eq!(chunks["materials"][0]["id"], ore);
assert_eq!(chunks["revision"], game.store.revision("stream").unwrap());
assert!(chunks.get("spawn").is_none() && chunks.get("players").is_none());
assert_eq!(game.players[&client.id].last_snapshot, last_snapshot);
}
#[test]
fn chunk_stream_negative_diagonal_vertical_and_disjoint_moves_are_exact() {
let (_dir, mut game, mut client) = streaming_fixture();
client.drain();
let mut previous = [0, 0, 0];
for (center, expected_count) in [
([-16, 0, 0], 12),
([0, 16, 16], 30),
([0, -16, 16], 32),
([160, 48, -160], 48),
] {
let position = center.map(|coordinate| coordinate as f32 + 0.5);
let messages = move_view(&mut game, &mut client, position);
let chunks = chunk_message(&messages);
let before: HashSet<_> = view_sections(previous).into_iter().collect();
let after: HashSet<_> = view_sections(center).into_iter().collect();
assert_eq!(chunks["from_center"], json!(previous));
assert_eq!(chunks["view_center"], json!(center));
assert_eq!(
received_sections(chunks, "sections"),
after.difference(&before).copied().collect()
);
assert_eq!(
received_sections(chunks, "unload"),
before.difference(&after).copied().collect()
);
assert_eq!(chunks["sections"].as_array().unwrap().len(), expected_count);
assert!(
chunks["sections"]
.as_array()
.unwrap()
.iter()
.all(|section| section["blocks"] == json!([]))
);
previous = center;
}
}
#[test]
fn chunk_stream_reentering_section_reads_latest_edit_and_deletion() {
let (_dir, mut game, mut client) = streaming_fixture();
client.drain();
let stone = game.id("minecraft:stone").unwrap();
let ore = game.id("minecraft:gold_ore").unwrap();
edit(
&mut game,
"stream",
vec![
BlockChange {
pos: [-32, 0, 0],
block: stone,
},
BlockChange {
pos: [-31, 0, 0],
block: stone,
},
],
);
move_view(&mut game, &mut client, [16.5, 2., 0.5]);
edit(
&mut game,
"stream",
vec![
BlockChange {
pos: [-32, 0, 0],
block: ore,
},
BlockChange {
pos: [-31, 0, 0],
block: 0,
},
],
);
let messages = move_view(&mut game, &mut client, [0.5, 2., 0.5]);
let chunks = chunk_message(&messages);
let blocks: Vec<_> = chunks["sections"]
.as_array()
.unwrap()
.iter()
.flat_map(|section| section["blocks"].as_array().unwrap())
.collect();
assert_eq!(blocks, vec![&json!({"pos":[-32,0,0],"block":ore})]);
assert_eq!(chunks["revision"], game.store.revision("stream").unwrap());
}
#[test]
fn chunk_stream_keeps_view_when_center_unchanged_and_throttles_only_view_updates() {
let (_dir, mut game, mut client) = streaming_fixture();
client.drain();
let messages = move_view(&mut game, &mut client, [15.5, 2., 15.5]);
assert!(messages.iter().all(|message| message["type"] == "state"));
let player = game.players.get_mut(&client.id).unwrap();
player.position = [16.5, 2., 15.5];
player.last_view = Instant::now();
player.last_snapshot = Instant::now() - Duration::from_secs(3);
game.step();
assert!(
client
.drain()
.iter()
.all(|message| message["type"] == "state")
);
game.players.get_mut(&client.id).unwrap().last_view =
Instant::now() - Duration::from_millis(251);
game.step();
chunk_message(&client.drain());
game.client(&client.id, json!({"type":"resync"})).unwrap();
assert_eq!(client.drain()[0]["type"], "snapshot");
}
#[test]
fn chunk_stream_world_switch_starts_with_a_full_snapshot() {
let (_dir, mut game, mut client) = streaming_fixture();
client.drain();
move_view(&mut game, &mut client, [48.5, 18., 48.5]);
game.players.get_mut(&client.id).unwrap().last_snapshot =
Instant::now() - Duration::from_secs(3);
game.client(&client.id, json!({"type":"switch_world","world":"lobby"}))
.unwrap();
let messages = client.drain();
assert_eq!(messages.len(), 1);
let snapshot = &messages[0];
assert_eq!(snapshot["type"], "snapshot");
assert_eq!(snapshot["world"], "lobby");
assert_eq!(snapshot["view_center"], json!([0, 0, 0]));
assert_eq!(snapshot["view_min"], json!([-32, -16, -32]));
assert_eq!(snapshot["view_max"], json!([31, 31, 31]));
assert!(snapshot["blocks"].as_array().unwrap().len() > 100);
assert_eq!(game.players[&client.id].view, [0, 0, 0]);
}
#[test]
fn unnegotiated_clients_keep_legacy_snapshot_bounds_and_interval() {
let (_dir, mut game, mut streaming) = streaming_fixture();
streaming.drain();
let stone = game.id("minecraft:stone").unwrap();
edit(
&mut game,
"stream",
vec![
BlockChange {
pos: [0, -8, 0],
block: stone,
},
BlockChange {
pos: [0, -9, 0],
block: stone,
},
],
);
for features in [vec![], vec!["unknown_future_feature"]] {
let mut client = connect_with_features(&mut game, "stream", "Legacy", &features);
let welcome = client.drain().remove(0);
assert!(welcome.get("features").is_none());
assert!(welcome.get("view_min").is_none());
assert_eq!(welcome["blocks"], json!([{"pos":[0,-8,0],"block":stone}]));
let messages = move_view(&mut game, &mut client, [16.5, 2., 0.5]);
assert!(messages.iter().all(|message| message["type"] == "state"));
game.players.get_mut(&client.id).unwrap().last_snapshot =
Instant::now() - Duration::from_secs(3);
game.step();
let messages = client.drain();
assert!(messages.iter().any(|message| message["type"] == "snapshot"));
assert!(messages.iter().all(|message| message["type"] != "chunks"));
game.players.remove(&client.id);
streaming.drain();
}
}
#[test]
fn official_collision_shapes_drive_world_physics() {
let (_dir, mut game) = fixture();
@@ -121,6 +980,7 @@ fn authoritative_actions_reject_forged_targets_and_sync_real_edits() {
let player = game.players.get_mut(&a.id).unwrap();
player.position = [0.5, 1., 8.5];
player.input.pitch = -1.2;
player.look[1] = -1.2;
let before = game.store.revision("lobby").unwrap();
assert!(
game.client(&a.id, json!({"type":"break","pos":[20,0,20]}))
@@ -441,6 +1301,7 @@ fn configured_spleef_rules_control_full_round_and_survive_restart() {
let player = game.players.get_mut(&a.id).unwrap();
player.position = [0.5, 1., 2.5];
player.input.pitch = -1.2;
player.look[1] = -1.2;
player.last_input = Instant::now();
let (hit, _) = game.raycast(&a.id).unwrap().unwrap();
assert_eq!(game.store.get_block("stone_arena", hit).unwrap(), stone);
@@ -552,3 +1413,59 @@ fn interrupted_creation_of_managed_world_can_finish_its_seed() {
assert_eq!(game.store.revision("lobby").unwrap(), 1);
assert_ne!(game.store.get_block("lobby", [0, 0, 0]).unwrap(), 0);
}
#[test]
fn buffered_views_prefetch_a_ring_and_do_not_unload_on_single_chunk_crossings() {
let (_dir, mut game) = fixture();
game.store.create_world("buffer-test", None).unwrap();
let mut client = connect_with_features(
&mut game,
"buffer-test",
"Buffered",
&["chunk_stream_v2", "view_buffer_v1"],
);
let welcome = client
.drain()
.into_iter()
.find(|m| m["type"] == "welcome")
.unwrap();
assert_eq!(welcome["view_distance"], 3);
assert_eq!(welcome["stream_radius"], 4);
assert_eq!(welcome["total_sections"], 405);
let initial = game.players[&client.id].view;
let generation = game.players[&client.id].view_generation;
let original: HashSet<_> = terrain::sections(initial, 4).into_iter().collect();
game.players.get_mut(&client.id).unwrap().sent_sections = original.clone();
// Move the streaming interest directly, avoiding unrelated physics/spawn.
for dx in [16, 0, -16, 0, 16] {
game.players.get_mut(&client.id).unwrap().position[0] = f64::from(initial[0] + dx);
game.pump_terrain();
assert_eq!(game.players[&client.id].view, initial);
assert_eq!(game.players[&client.id].view_generation, generation);
assert_eq!(game.players[&client.id].sent_sections, original);
assert!(!client.drain().iter().any(|m| m["type"] == "view"));
}
game.players.get_mut(&client.id).unwrap().position[0] = f64::from(initial[0] + 32);
game.pump_terrain();
let view = client
.drain()
.into_iter()
.find(|m| m["type"] == "view")
.unwrap();
assert_eq!(view["view_center"][0], initial[0] + 32);
assert_eq!(view["total_sections"], 405);
assert_eq!(view["unload"].as_array().unwrap().len(), 90);
for pos in view["unload"].as_array().unwrap() {
let x = pos[0].as_i64().unwrap() * 16 + 15;
assert!(
i64::from(initial[0] + 32) - x > 3 * 16,
"unloads must lie beyond the requested radius"
);
}
let new_center: Pos = serde_json::from_value(view["view_center"].clone()).unwrap();
let (min, max) = terrain::bounds(new_center, 4);
for dx in [-16, 0, 16, 31] {
let x = new_center[0] + dx;
assert!(min[0] <= x - 3 * 16 && max[0] >= x + 3 * 16);
}
}
@@ -0,0 +1,516 @@
use crate::terrain::{self, Generator, TerrainConfig, TerrainKey};
impl Game {
fn broadcast_blocks(&mut self, world: &str, revision: u64, changes: &[BlockChange]) {
let mut columns = Vec::new();
if self.generators.contains_key(world) {
let positions: HashSet<_> = changes
.iter()
.map(|c| (c.pos[0].div_euclid(16), c.pos[2].div_euclid(16)))
.collect();
for (x, z) in positions {
if let Ok(heights) = self.sky_column(world, x, z) {
columns.push(json!({"column":[x,z],"heights":heights}));
}
}
}
self.broadcast(world,json!({"type":"blocks","world":world,"revision":revision,"changes":changes,"materials":self.materials(changes),"columns":columns}));
}
fn generator_palette(&self) -> Result<terrain::Palette> {
Ok(terrain::Palette {
grass: self.id("minecraft:grass_block")?,
dirt: self.id("minecraft:dirt")?,
stone: self.id("minecraft:stone")?,
deep: self.id("minecraft:deepslate")?,
bedrock: self.id("minecraft:bedrock")?,
sand: self.id("minecraft:sand")?,
water: self.id("minecraft:water")?,
lava: self.id("minecraft:lava")?,
log: self.id("minecraft:oak_log")?,
leaves: self.id("minecraft:oak_leaves")?,
snow: self.id("minecraft:snow_block")?,
coal: self.id("minecraft:coal_ore")?,
iron: self.id("minecraft:iron_ore")?,
copper: self.id("minecraft:copper_ore")?,
gold: self.id("minecraft:gold_ore")?,
diamond: self.id("minecraft:diamond_ore")?,
deep_iron: self.id("minecraft:deepslate_iron_ore")?,
deep_diamond: self.id("minecraft:deepslate_diamond_ore")?,
})
}
fn open_generators(&mut self) -> Result<()> {
let palette = self.generator_palette()?;
for (name, conf) in &self.meta.worlds {
if let Some(config) = &conf.terrain {
config.validate()?;
self.generators.insert(
name.clone(),
Arc::new(Generator {
config: config.clone(),
palette: palette.clone(),
}),
);
}
}
Ok(())
}
fn create_overworld(&mut self, name: &str, config: TerrainConfig) -> Result<()> {
config.validate()?;
let generator = Arc::new(Generator {
config: config.clone(),
palette: self.generator_palette()?,
});
self.store.create_world(name, None)?;
self.meta.worlds.insert(
name.into(),
WorldConfig {
spawn: [0., f64::from(generator.height(0, 8) + 1), 8.],
elimination_y: -128.,
terrain: Some(config),
..WorldConfig::default()
},
);
self.save_meta()?;
self.generators.insert(name.into(), generator);
Ok(())
}
fn section_data(&mut self, world: &str, pos: Pos) -> Result<Option<Arc<terrain::Cells>>> {
let key = (world.to_owned(), pos);
if let Some(cells) = self.terrain.get(&key) {
return Ok(Some(cells));
}
if pos.iter().any(|v| {
i64::from(*v) * 16 < -i64::from(shacraft_core::COORD_LIMIT)
|| i64::from(*v) * 16 + 15 > i64::from(shacraft_core::COORD_LIMIT)
}) {
let cells = Arc::new([0; 4096]);
self.terrain.insert(key, cells.clone());
return Ok(Some(cells));
}
if let Some(cells) = self.store.read_section(world, pos)? {
let cells: Arc<terrain::Cells> = Arc::from(cells);
self.terrain.insert(key, cells.clone());
return Ok(Some(cells));
}
if let Some(generator) = self.generators.get(world) {
if pos[1] * 16 > terrain::MAX_Y || pos[1] * 16 + 15 < terrain::MIN_Y {
let cells = Arc::new([0; 4096]);
self.terrain.insert(key, cells.clone());
return Ok(Some(cells));
}
self.terrain.request(key, generator.clone());
Ok(None)
} else {
let cells = Arc::new([0; 4096]);
self.terrain.insert(key, cells.clone());
Ok(Some(cells))
}
}
fn read_world_region(&mut self, world: &str, min: Pos, max: Pos) -> Result<Vec<BlockChange>> {
if !self.generators.contains_key(world) {
return self.store.read_region(world, min, max);
}
let mut volume = 1i64;
ensure!(
min.iter()
.chain(max.iter())
.all(|v| i64::from(*v).abs() <= i64::from(shacraft_core::COORD_LIMIT)),
"coordinate outside storage bounds"
);
for i in 0..3 {
ensure!(min[i] <= max[i], "invalid region");
volume = volume
.checked_mul(i64::from(max[i]) - i64::from(min[i]) + 1)
.context("region overflow")?;
ensure!(
volume <= shacraft_core::MAX_READ_VOLUME as i64,
"region too large"
);
}
let mut sections = Vec::new();
let mut pending = false;
for sy in min[1].div_euclid(16)..=max[1].div_euclid(16) {
for sz in min[2].div_euclid(16)..=max[2].div_euclid(16) {
for sx in min[0].div_euclid(16)..=max[0].div_euclid(16) {
let pos = [sx, sy, sz];
if let Some(cells) = self.section_data(world, pos)? {
sections.push((pos, cells));
} else {
pending = true;
self.terrain.retain_for_read((world.into(), pos));
}
}
}
}
ensure!(!pending, "terrain pending; retry when generated");
let mut result = Vec::new();
for ([sx, sy, sz], cells) in sections {
for y in min[1].max(sy * 16)..=max[1].min(sy * 16 + 15) {
for z in min[2].max(sz * 16)..=max[2].min(sz * 16 + 15) {
for x in min[0].max(sx * 16)..=max[0].min(sx * 16 + 15) {
let block = cells[(x.rem_euclid(16)
+ 16 * z.rem_euclid(16)
+ 256 * y.rem_euclid(16))
as usize];
if block != 0 {
result.push(BlockChange {
pos: [x, y, z],
block,
});
}
}
}
}
}
Ok(result)
}
fn world_block(&mut self, world: &str, pos: Pos) -> Result<u32> {
if !self.generators.contains_key(world) {
return self.store.get_block(world, pos);
}
let cells = self
.section_data(world, pos.map(|v| v.div_euclid(16)))?
.context("terrain pending")?;
Ok(
cells[(pos[0].rem_euclid(16) + 16 * pos[2].rem_euclid(16) + 256 * pos[1].rem_euclid(16))
as usize],
)
}
fn materialize_changes(&mut self, world: &str, changes: &[BlockChange]) -> Result<()> {
if !self.generators.contains_key(world) {
return Ok(());
}
ensure!(
changes.len() <= shacraft_core::MAX_EDIT_CELLS,
"edit exceeds cell limit"
);
let mut unique = HashSet::new();
for change in changes {
ensure!(
change
.pos
.iter()
.all(|v| f64::from(*v).abs() <= terrain::BORDER),
"outside playable bounds"
);
ensure!(
(change.block as usize) < self.store.registry().len(),
"unknown block"
);
ensure!(unique.insert(change.pos), "duplicate position");
}
let positions: HashSet<_> = changes
.iter()
.map(|c| c.pos.map(|v| v.div_euclid(16)))
.collect();
let mut sections = Vec::new();
for pos in positions {
ensure!(
pos[1] >= terrain::MIN_Y / 16 && pos[1] <= terrain::MAX_Y / 16,
"outside build height -64..319"
);
self.terrain.retain_for_read((world.into(), pos));
let cells = self
.section_data(world, pos)?
.context("terrain pending; retry when loaded")?;
sections.push((pos, cells));
}
for (pos, cells) in sections {
self.store.materialize_section(world, pos, &cells)?;
}
Ok(())
}
fn terrain_changed(&mut self, world: &str, changes: &[BlockChange]) {
let sections = changes
.iter()
.map(|c| c.pos.map(|v| v.div_euclid(16)))
.collect();
self.terrain.invalidate(world, &sections);
self.sky_cache.retain(|(w, sx, sz), _| {
w != world
|| !changes
.iter()
.any(|c| c.pos[0].div_euclid(16) == *sx && c.pos[2].div_euclid(16) == *sz)
});
}
fn sky_column(&mut self, world: &str, sx: i32, sz: i32) -> Result<Vec<i16>> {
let key = (world.to_owned(), sx, sz);
if let Some(heights) = self.sky_cache.get(&key) {
return Ok(heights.clone());
}
let Some(generator) = self.generators.get(world).cloned() else {
return Ok(vec![-32768; 256]);
};
let mut heights = generator.sky_heights(sx, sz);
// Only edited columns have durable sections. Resolve their actual top
// against the seed, including air overrides left by mining a roof.
let mut stored = HashMap::new();
for sy in terrain::MIN_Y.div_euclid(16)..=terrain::MAX_Y.div_euclid(16) {
if let Some(cells) = self.store.read_section(world, [sx, sy, sz])? {
stored.insert(sy, cells);
}
}
if !stored.is_empty() {
let highest = stored.keys().max().copied().unwrap() * 16 + 15;
let natural_max = i32::from(*heights.iter().max().unwrap());
let mut generated = HashMap::new();
for z in 0..16 {
for x in 0..16 {
let mut top = highest.max(natural_max);
loop {
let sy = top.div_euclid(16);
let index = (x + 16 * z + 256 * top.rem_euclid(16)) as usize;
let block = if let Some(cells) = stored.get(&sy) {
cells[index]
} else {
let cells = generated
.entry(sy)
.or_insert_with(|| generator.generate([sx, sy, sz]));
cells[index]
};
let opaque = self.definition(block).is_some_and(|d| {
d.opacity >= 1.
&& !d.state.contains("leaves")
&& !d.state.contains("water")
&& (!d.state.contains("glass") || d.state.contains("tinted_glass"))
&& d.render
.iter()
.any(|b| b.min == [0.; 3] && b.max == [1.; 3])
});
if opaque || top <= terrain::MIN_Y {
break;
}
top -= 1;
}
heights[(x + 16 * z) as usize] = top as i16;
}
}
}
if self.sky_cache.len() >= 2048 {
self.sky_cache.clear();
}
self.sky_cache.insert(key, heights.to_vec());
Ok(heights.to_vec())
}
fn begin_section_view(&mut self, id: &str, center: Pos, welcome: bool) -> Result<()> {
let p = self.players.get_mut(id).context("not joined")?;
let ordered = terrain::view_sections(center, p.stream_radius(), p.full_height);
let new: HashSet<_> = ordered.iter().copied().collect();
let unload: Vec<_> = p
.sent_sections
.iter()
.filter(|pos| !new.contains(*pos))
.copied()
.collect();
p.sent_sections.retain(|pos| new.contains(pos));
if welcome {
p.sent_sections.clear();
}
p.pending_sections = ordered
.into_iter()
.filter(|pos| !p.sent_sections.contains(pos))
.collect();
p.view = center;
p.view_generation += 1;
p.last_view = Instant::now();
let world = p.world.clone();
let generation = p.view_generation;
let radius = p.stream_radius();
let (min, max) = terrain::view_bounds(center, radius, p.full_height);
let mut message = json!({"type":"view","world":world,"revision":self.store.revision(&world)?,"generation":generation,"view_center":center,"view_min":min,"view_max":max,"unload":unload,"total_sections":new.len()});
message["view_distance"] = json!(p.view_distance);
message["stream_radius"] = json!(radius);
message["full_height"] = json!(p.full_height);
if welcome {
let p = &self.players[id];
message["type"] = json!(if p.welcomed { "snapshot" } else { "welcome" });
message["id"] = json!(id);
message["spawn"] = json!(p.body.position);
message["features"] = json!([
"chunk_stream_v2",
"movement_prediction_v1",
"view_distance_v1",
"view_buffer_v1",
"full_height_v1"
]);
message["max_view_distance"] = json!(terrain::MAX_VIEW_RADIUS);
message["blocks"] = json!([]);
message["materials"] = json!([]);
message["players"] = self.players_json(&world);
message["entities"] = json!(self.entity_list(&world));
message["motion"] = self.motion_json(p);
message["manifest_hash"] = self.manifest["hash"].clone();
message["terrain"] = json!(self.config(&world).terrain);
message["world_bounds"] =
json!({"min_y":terrain::MIN_Y,"max_y":terrain::MAX_Y,"border":terrain::BORDER});
let p = self.players.get_mut(id).unwrap();
p.welcomed = true;
p.last_snapshot = Instant::now();
}
self.send(id, message);
Ok(())
}
fn pump_terrain(&mut self) {
let start = Instant::now();
let moved: Vec<_> = self
.players
.values()
.filter(|p| p.section_stream)
.filter_map(|p| {
let center = p.position.map(|v| (v.floor() as i32).div_euclid(16) * 16);
terrain::view_needs_move(p.view, center, p.buffered_view)
.then_some((p.id.clone(), center))
})
.collect();
for (id, center) in moved {
let _ = self.begin_section_view(&id, center, false);
}
let mut wanted = HashSet::<TerrainKey>::new();
let mut priorities = HashMap::<TerrainKey, i32>::new();
// Shared requests deduplicate between players. The squared distance
// order puts collision sections before the outer visual/preload ring.
for p in self.players.values() {
if !self.generators.contains_key(&p.world) && !p.section_stream {
continue;
}
let center = p.position.map(|v| (v.floor() as i32).div_euclid(16) * 16);
// Queue order is computed once per view, not by sorting up to
// 411,864 sections every simulation tick. Only a bounded lookahead
// and the current collision neighborhood need server residency.
let upcoming = if p.section_stream {
p.pending_sections
.iter()
.take(if p.full_height { 256 } else { 128 })
.copied()
.collect::<Vec<_>>()
} else {
terrain::sections(center, p.stream_radius())
};
for pos in terrain::sections(center, 2).into_iter().chain(upcoming) {
let key = (p.world.clone(), pos);
wanted.insert(key.clone());
{
let c = center.map(|v| v / 16);
let distance = (pos[0] - c[0]).pow(2)
+ (pos[2] - c[2]).pow(2)
+ (pos[1] - c[1]).pow(2) * 2;
// Delivered terrain can leave the server cache. Rebuild it
// only for collision or another client's unsent view.
if !p.sent_sections.contains(&pos) || distance <= 6 {
priorities
.entry(key)
.and_modify(|priority| *priority = (*priority).min(distance))
.or_insert(distance);
}
}
}
}
self.terrain.poll(&wanted);
let mut ordered: Vec<_> = priorities
.into_iter()
.map(|(key, distance)| (distance, key))
.collect();
ordered.sort_by_key(|(distance, _)| *distance);
// Scheduling a large view can itself exhaust the budget on a slower
// CPU. Give loading its own bounded slice so new sections still progress.
let load_start = Instant::now();
for (_, key) in ordered {
if self.terrain.contains(&key) {
continue;
}
if load_start.elapsed().as_secs_f64() > 0.004 {
break;
}
let _ = self.section_data(&key.0, key.1);
}
let ids: Vec<_> = self
.players
.values()
.filter(|p| p.section_stream && p.last_batch.elapsed() >= Duration::from_millis(50))
.map(|p| p.id.clone())
.collect();
for id in ids {
let p = &self.players[&id];
let world = p.world.clone();
let generation = p.view_generation;
if p.out.bytes.load(Ordering::Relaxed) > 512 * 1024 {
continue;
}
let requested: Vec<_> = p
.pending_sections
.iter()
.copied()
.take(if p.full_height { 128 } else { 32 })
.collect();
let considered = requested.len();
let mut packed = Vec::new();
let mut delivered = Vec::new();
let mut material_ids = HashSet::new();
let mut columns = HashMap::new();
let mut dense_sections = 0;
let mut estimated_bytes = 0;
for pos in requested {
if start.elapsed().as_secs_f64() > 0.010 && !packed.is_empty() {
break;
}
let Some(cells) = self.terrain.get(&(world.clone(), pos)) else {
continue;
};
let uniform = cells.iter().all(|id| *id == cells[0]);
if !uniform && dense_sections >= 32 {
break;
}
let mut value = terrain::pack(&cells);
let bytes = 128
+ 12 * (value["palette"].as_array().unwrap().len()
+ value["runs"].as_array().unwrap().len());
if !packed.is_empty() && estimated_bytes + bytes > 256 * 1024 {
break;
}
estimated_bytes += bytes;
if uniform {
if cells[0] != 0 {
material_ids.insert(cells[0]);
}
} else {
dense_sections += 1;
material_ids.extend(cells.iter().copied().filter(|id| *id != 0));
}
value["section"] = json!(pos);
packed.push(value);
delivered.push(pos);
if self.generators.contains_key(&world)
&& !columns.contains_key(&(pos[0], pos[2]))
&& let Ok(heights) = self.sky_column(&world, pos[0], pos[2])
{
columns.insert(
(pos[0], pos[2]),
json!({"column":[pos[0],pos[2]],"heights":heights}),
);
}
}
if packed.is_empty() {
continue;
}
let p = self.players.get_mut(&id).unwrap();
let delivered: HashSet<_> = delivered.into_iter().collect();
let remaining: Vec<_> = p
.pending_sections
.drain(..considered)
.filter(|pos| !delivered.contains(pos))
.collect();
for pos in remaining.into_iter().rev() {
p.pending_sections.push_front(pos);
}
p.sent_sections.extend(delivered);
p.last_batch = Instant::now();
let loaded = p.sent_sections.len();
let total = p.sent_sections.len() + p.pending_sections.len();
let value = json!({"type":"sections","world":world,"revision":self.store.revision(&world).unwrap_or(0),"generation":generation,"sections":packed,"columns":columns.into_values().collect::<Vec<_>>(),"materials":self.materials_for_ids(material_ids.into_iter()),"loaded_sections":loaded,"total_sections":total});
self.stream_batches += 1;
self.stream_sections += value["sections"].as_array().unwrap().len() as u64;
self.send(&id, value);
}
self.last_stream_ms = start.elapsed().as_secs_f64() * 1000.;
}
}