Expand voxel gameplay, lighting, full-height streaming and world imports
MVP checks / mvp (push) Canceled after 0s
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:
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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
+7814
File diff suppressed because it is too large
Load Diff
@@ -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.);
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, §ions);
|
||||
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.;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user