mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #7199
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
f622f09367
196 files changed
+8602
-896
No files matched your search
@@ -96,8 +96,9 @@ jobs:
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
|
||||
# recompiling the workspace with a different dependency graph.
|
||||
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
|
||||
Generated
+1
@@ -1583,6 +1583,7 @@ name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -688,4 +688,15 @@ mod tests {
|
||||
let (v, _) = parse_snbt("{n:1.5}").unwrap();
|
||||
assert_eq!(v["n"], Value::from(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_text_preserves_legacy_and_decodes_components() {
|
||||
let legacy = Tag::String("§6Golden Name".into());
|
||||
let text = name_text(&legacy);
|
||||
assert_eq!(text.plain(), "Golden Name");
|
||||
|
||||
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
|
||||
let text = name_text(&comp);
|
||||
assert_eq!(text.plain(), "Custom Name");
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,16 @@ pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("textures");
|
||||
// A prepared root's: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(&base) {
|
||||
out.extend(
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|file| file.strip_suffix(".png"))
|
||||
.map(|rel| format!("{namespace}:{rel}")),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
|
||||
@@ -1206,8 +1206,13 @@ impl Audio {
|
||||
/// `SoundManager.reload` (resource packs changed): stop every sound, then
|
||||
/// read sound events and sound files from the new resource root.
|
||||
pub fn reload(&mut self, root: &Path) {
|
||||
self.reload_with(root, SoundRegistry::load(root));
|
||||
}
|
||||
/// [`Self::reload`] with the root's sound events already read
|
||||
/// (`SoundRegistry::load`, which a pack reload runs off the render thread).
|
||||
pub fn reload_with(&mut self, root: &Path, registry: SoundRegistry) {
|
||||
self.stop_all();
|
||||
self.engine.registry = SoundRegistry::load(root);
|
||||
self.engine.registry = registry;
|
||||
self.backend.root = root.to_owned();
|
||||
let mut cache = self.backend.cache.lock().unwrap();
|
||||
cache.entries.clear();
|
||||
|
||||
@@ -62,18 +62,65 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
|
||||
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CellIter {
|
||||
radius: i32,
|
||||
ring: i32,
|
||||
x: i32,
|
||||
pending_z: Option<i32>,
|
||||
}
|
||||
|
||||
impl CellIter {
|
||||
pub fn new(radius: i32) -> Self {
|
||||
Self {
|
||||
radius,
|
||||
ring: 0,
|
||||
x: 0,
|
||||
pending_z: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for CellIter {
|
||||
type Item = (i32, i32);
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if let Some(z) = self.pending_z.take() {
|
||||
let x = self.x;
|
||||
self.x += 1;
|
||||
return Some((x, z));
|
||||
}
|
||||
let r2 = self.radius * self.radius;
|
||||
while self.ring <= 2 * self.radius {
|
||||
let min_abs = self.ring - self.radius;
|
||||
while self.x <= self.ring {
|
||||
if min_abs > 0 && self.x > -min_abs && self.x < min_abs {
|
||||
self.x = min_abs;
|
||||
}
|
||||
let x = self.x;
|
||||
let z = self.ring - x.abs();
|
||||
if z <= self.radius && x * x + z * z <= r2 {
|
||||
if z != 0 {
|
||||
self.pending_z = Some(z);
|
||||
return Some((x, -z));
|
||||
} else {
|
||||
self.x += 1;
|
||||
return Some((x, 0));
|
||||
}
|
||||
}
|
||||
self.x += 1;
|
||||
}
|
||||
self.ring += 1;
|
||||
self.x = -self.ring;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
|
||||
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
|
||||
(0..=2 * radius).flat_map(move |ring| {
|
||||
(-ring..=ring).flat_map(move |x| {
|
||||
let z = ring - x.abs();
|
||||
let visible = z <= radius && x * x + z * z <= radius * radius;
|
||||
(visible && z != 0)
|
||||
.then_some((x, -z))
|
||||
.into_iter()
|
||||
.chain(visible.then_some((x, z)))
|
||||
})
|
||||
})
|
||||
pub fn cells(radius: i32) -> CellIter {
|
||||
CellIter::new(radius)
|
||||
}
|
||||
|
||||
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
|
||||
@@ -150,9 +197,121 @@ impl Input<'_> {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Mesh {
|
||||
key: Option<MeshKey>,
|
||||
cells: Vec<(i32, i32)>,
|
||||
cached_radius: Option<i32>,
|
||||
pub vertices: Vec<crate::mesher::Vertex>,
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
p: [[f32; 3]; 4],
|
||||
shade: u32,
|
||||
normal: [i8; 4],
|
||||
uv: [f32; 2],
|
||||
) {
|
||||
for pos in p {
|
||||
vertices.push(crate::mesher::Vertex {
|
||||
normal,
|
||||
pos,
|
||||
uv,
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn emit_face(
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
direction: usize,
|
||||
inside: bool,
|
||||
x0: f32,
|
||||
z0: f32,
|
||||
uv: [f32; 2],
|
||||
) {
|
||||
let x1 = x0 + 12.0;
|
||||
let z1 = z0 + 12.0;
|
||||
const Y0: f32 = 0.0;
|
||||
const Y1: f32 = 4.0;
|
||||
let (p, shade, normal) = match direction {
|
||||
0 => (
|
||||
[[x1, Y0, z0], [x1, Y0, z1], [x0, Y0, z1], [x0, Y0, z0]],
|
||||
1,
|
||||
[0, -127, 0, 0],
|
||||
),
|
||||
1 => (
|
||||
[[x0, Y1, z0], [x0, Y1, z1], [x1, Y1, z1], [x1, Y1, z0]],
|
||||
0,
|
||||
[0, 127, 0, 0],
|
||||
),
|
||||
2 => (
|
||||
[[x0, Y0, z0], [x0, Y1, z0], [x1, Y1, z0], [x1, Y0, z0]],
|
||||
2,
|
||||
[0, 0, -127, 0],
|
||||
),
|
||||
3 => (
|
||||
[[x1, Y0, z1], [x1, Y1, z1], [x0, Y1, z1], [x0, Y0, z1]],
|
||||
2,
|
||||
[0, 0, 127, 0],
|
||||
),
|
||||
4 => (
|
||||
[[x0, Y0, z1], [x0, Y1, z1], [x0, Y1, z0], [x0, Y0, z0]],
|
||||
3,
|
||||
[-127, 0, 0, 0],
|
||||
),
|
||||
_ => (
|
||||
[[x1, Y0, z0], [x1, Y1, z0], [x1, Y1, z1], [x1, Y0, z1]],
|
||||
3,
|
||||
[127, 0, 0, 0],
|
||||
),
|
||||
};
|
||||
if inside {
|
||||
push_quad(
|
||||
vertices,
|
||||
[p[3], p[2], p[1], p[0]],
|
||||
shade,
|
||||
normal.map(|c| -c),
|
||||
uv,
|
||||
);
|
||||
} else {
|
||||
push_quad(vertices, p, shade, normal, uv);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn emit_faces(
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
mask: u16,
|
||||
x0: f32,
|
||||
z0: f32,
|
||||
uv: [f32; 2],
|
||||
) {
|
||||
for d in [1, 0, 2, 3, 4, 5] {
|
||||
if mask & (1 << d) != 0 {
|
||||
emit_face(vertices, d, false, x0, z0, uv);
|
||||
}
|
||||
}
|
||||
if mask >> 6 != 0 {
|
||||
for d in 0..6 {
|
||||
if mask & (1 << (d + 6)) != 0 {
|
||||
emit_face(vertices, d, true, x0, z0, uv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn emit_flat_cell(vertices: &mut Vec<crate::mesher::Vertex>, x0: f32, z0: f32, uv: [f32; 2]) {
|
||||
let x1 = x0 + 12.0;
|
||||
let z1 = z0 + 12.0;
|
||||
let p = [[x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]];
|
||||
push_quad(vertices, p, 0, [0; 4], uv);
|
||||
push_quad(vertices, [p[3], p[2], p[1], p[0]], 0, [0; 4], uv);
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Returns true only when vertices must be uploaded. Sub-cell motion and
|
||||
/// color changes update only the uniform, as in CloudRenderer.prepare.
|
||||
@@ -161,80 +320,39 @@ impl Mesh {
|
||||
if self.key.as_ref() == Some(&key) {
|
||||
return (false, uniform);
|
||||
}
|
||||
if self.cached_radius != Some(key.radius) {
|
||||
self.cells = self::cells(key.radius).collect();
|
||||
self.cached_radius = Some(key.radius);
|
||||
}
|
||||
self.vertices.clear();
|
||||
let (w, h, cells) = input.texture;
|
||||
let (w, h) = (i64::from(*w), i64::from(*h));
|
||||
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
|
||||
|
||||
let (w, h, tex_cells) = input.texture;
|
||||
let (cell_x, cell_z) = (key.cell_x, key.cell_z);
|
||||
let bottom = uniform.offset[1];
|
||||
let uv = input.uv;
|
||||
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
|
||||
for (dx, dz) in self::cells(radius) {
|
||||
let (dx, dz) = (i64::from(dx), i64::from(dz));
|
||||
let (cx, cz) = (cell_x + dx, cell_z + dz);
|
||||
|
||||
let (w_i64, h_i64) = (i64::from(*w), i64::from(*h));
|
||||
let filled = |cx: i64, cz: i64| {
|
||||
tex_cells[(cz.rem_euclid(h_i64) * w_i64 + cx.rem_euclid(w_i64)) as usize]
|
||||
};
|
||||
|
||||
for &(dx, dz) in &self.cells {
|
||||
let (dx_i64, dz_i64) = (i64::from(dx), i64::from(dz));
|
||||
let (cx, cz) = (cell_x + dx_i64, cell_z + dz_i64);
|
||||
if !filled(cx, cz) {
|
||||
continue;
|
||||
}
|
||||
let x0 = dx as f32 * 12.0;
|
||||
let z0 = dz as f32 * 12.0;
|
||||
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
|
||||
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
|
||||
// `shade`.
|
||||
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
|
||||
for v in p {
|
||||
// Overlay 4: clouds (unlit, faded by the cloud fog end).
|
||||
self.vertices.push(crate::mesher::Vertex {
|
||||
normal,
|
||||
pos: v,
|
||||
uv,
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
if input.fast {
|
||||
for p in flat_cell(x0, z0, 0.0) {
|
||||
quad(p, 0, [0; 4]);
|
||||
}
|
||||
emit_flat_cell(&mut self.vertices, x0, z0, uv);
|
||||
continue;
|
||||
}
|
||||
let quads = [
|
||||
(
|
||||
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
|
||||
1,
|
||||
[0, -127, 0, 0],
|
||||
),
|
||||
(
|
||||
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
|
||||
0,
|
||||
[0, 127, 0, 0],
|
||||
),
|
||||
(
|
||||
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
|
||||
2,
|
||||
[0, 0, -127, 0],
|
||||
),
|
||||
(
|
||||
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
|
||||
2,
|
||||
[0, 0, 127, 0],
|
||||
),
|
||||
(
|
||||
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
|
||||
3,
|
||||
[-127, 0, 0, 0],
|
||||
),
|
||||
(
|
||||
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
|
||||
3,
|
||||
[127, 0, 0, 0],
|
||||
),
|
||||
];
|
||||
|
||||
let mask = face_mask(
|
||||
dx as i32,
|
||||
dz as i32,
|
||||
dx,
|
||||
dz,
|
||||
bottom,
|
||||
[
|
||||
!filled(cx, cz - 1),
|
||||
@@ -243,15 +361,9 @@ impl Mesh {
|
||||
!filled(cx + 1, cz),
|
||||
],
|
||||
);
|
||||
for (direction, inside) in faces(mask) {
|
||||
let (p, shade, n) = quads[usize::from(direction)];
|
||||
if inside {
|
||||
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
|
||||
} else {
|
||||
quad(p, shade, n);
|
||||
}
|
||||
}
|
||||
emit_faces(&mut self.vertices, mask, x0, z0, uv);
|
||||
}
|
||||
|
||||
self.key = Some(key);
|
||||
(true, uniform)
|
||||
}
|
||||
|
||||
@@ -182,12 +182,24 @@ where
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = CollisionContext::position(bounds.min_position().y);
|
||||
self.resolve_with_context(bounds, movement, max_step, on_ground, context)
|
||||
}
|
||||
/// [`Self::resolve`] with the entity's captured collision context.
|
||||
pub fn resolve_with_context(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
context: CollisionContext<'_>,
|
||||
) -> Result<Vec3> {
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
@@ -223,23 +235,12 @@ impl SceneCollisions<'_, '_> {
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.context(bounds);
|
||||
let mut scene = EntityCollisions {
|
||||
EntityCollisions {
|
||||
blocks: self,
|
||||
entities: &[],
|
||||
border,
|
||||
};
|
||||
Ok(resolve_world_movement(
|
||||
&mut scene,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
.resolve_with_context(bounds, movement, max_step, on_ground, context)
|
||||
}
|
||||
/// Resolve movement with the entity's captured collision context. Most
|
||||
/// callers only need the position context; entities such as falling blocks
|
||||
@@ -326,6 +327,88 @@ where
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Whether a `BlockCollisionScan` of `bounds` would find no shape, told without
|
||||
/// building one: no block that can collide in its inner cells, and no block that
|
||||
/// reaches out of its cell in the sections of its outer ones (where unsure, false:
|
||||
/// the scan decides). The same cells, blocks and section flags the scan reads.
|
||||
pub fn cells_without_collision(&self, bounds: Aabb) -> bool {
|
||||
let (min, max) = (bounds.min_position(), bounds.max_position());
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return false;
|
||||
}
|
||||
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
|
||||
let lo = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
|
||||
.offset(-1, -1, -1);
|
||||
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
return false;
|
||||
}
|
||||
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
|
||||
let mut section: Option<((i32, i32), i32, bool)> = None;
|
||||
for z in lo.z..=hi.z {
|
||||
for y in lo.y..=hi.y {
|
||||
for x in lo.x..=hi.x {
|
||||
let outer = [
|
||||
x == lo.x || x == hi.x,
|
||||
y == lo.y || y == hi.y,
|
||||
z == lo.z || z == hi.z,
|
||||
];
|
||||
let kind = outer.into_iter().filter(|&o| o).count();
|
||||
if kind == 3 {
|
||||
continue;
|
||||
}
|
||||
let at = (x >> 4, z >> 4);
|
||||
let chunk = match column {
|
||||
Some((c, chunk)) if c == at => chunk,
|
||||
_ => {
|
||||
let chunk = self.world.chunks.get(&at).map(|c| &**c);
|
||||
column = Some((at, chunk));
|
||||
chunk
|
||||
}
|
||||
};
|
||||
// The scan passes the cells of a column that is not resident.
|
||||
let Some(chunk) = chunk else {
|
||||
continue;
|
||||
};
|
||||
if kind != 0 {
|
||||
let reaches = match section {
|
||||
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
|
||||
_ => {
|
||||
let reaches = self.section_reaches_out(chunk, y >> 4);
|
||||
section = Some((at, y >> 4, reaches));
|
||||
reaches
|
||||
}
|
||||
};
|
||||
if reaches {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
|
||||
/// its cell; a global palette, or a section outside the column, may.
|
||||
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
type Error = io::Error;
|
||||
/// The resident column, looked up once per chunk the scan enters.
|
||||
@@ -339,13 +422,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
Ok(blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
}))
|
||||
Ok(self.section_reaches_out(chunk, section_y))
|
||||
}
|
||||
fn is_suffocating(
|
||||
&mut self,
|
||||
@@ -818,3 +895,88 @@ mod shulker_collision_tests {
|
||||
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod section_tests {
|
||||
use super::*;
|
||||
|
||||
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
|
||||
/// reaching into the cell above) and the scene's stone: wherever the cells say no
|
||||
/// block can collide, the full resolution returns the movement, to the bit; the rest
|
||||
/// is left to it. Needs the prepared resources.
|
||||
#[test]
|
||||
fn cells_without_collision_leave_the_movement_as_resolving_does() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("cached-collision.nbt").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let state = |name: &str| assets.registry.default_state(name).unwrap();
|
||||
// Torches, and a fence at the bottom of a section above air.
|
||||
for x in 17..30 {
|
||||
world.set(x, 20, 20, state("minecraft:torch")).unwrap();
|
||||
}
|
||||
world.set(5, 32, 5, state("minecraft:oak_fence")).unwrap();
|
||||
let scene = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
// Under the fence: its section's outer cells may hold a shape reaching in.
|
||||
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
|
||||
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
|
||||
// A torch's cell, and the air just over the scene's stone floor (y 0..4).
|
||||
let torches = Aabb::new(Vec3::new(20.4, 20.1, 20.4), Vec3::new(20.6, 20.3, 20.6));
|
||||
assert!(scene.cells_without_collision(torches));
|
||||
let over_floor = Aabb::new(Vec3::new(30.4, 5.2, 30.4), Vec3::new(30.6, 5.4, 30.6));
|
||||
assert!(scene.cells_without_collision(over_floor));
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let (mut skipped, mut resolved) = (0, 0);
|
||||
for i in 0..4000 {
|
||||
let size = if i % 2 == 0 { 0.2 } else { 0.6 };
|
||||
// Near the floor (y 0..4) and the stepped pyramid (y 4..11) for most, higher for the rest.
|
||||
let height = if i % 4 == 0 { 40.0 } else { 14.0 };
|
||||
let at = Vec3::new(next() * 80.0 - 40.0, next() * height, next() * 80.0 - 40.0);
|
||||
let bb = Aabb::new(at, Vec3::new(at.x + size, at.y + size, at.z + size));
|
||||
let reach = if i % 3 == 0 { 3.0 } else { 0.3 };
|
||||
let d = Vec3::new(
|
||||
(next() - 0.5) * reach,
|
||||
(next() - 0.5) * reach,
|
||||
(next() - 0.5) * reach,
|
||||
);
|
||||
let fast = scene.cells_without_collision(bb.expand_towards(d));
|
||||
let mut full = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
let moved = full.resolve(bb, d, 0.0, false).unwrap();
|
||||
if fast {
|
||||
skipped += 1;
|
||||
assert_eq!(
|
||||
moved.components().map(f64::to_bits),
|
||||
d.components().map(f64::to_bits),
|
||||
"{bb:?} by {d:?}"
|
||||
);
|
||||
} else {
|
||||
resolved += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
skipped > 500 && resolved > 500,
|
||||
"{skipped} skipped, {resolved} resolved"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
var back = c;
|
||||
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
|
||||
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
|
||||
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
|
||||
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
|
||||
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
|
||||
if abs(normal.w) >= 0.5 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
|
||||
@@ -23,7 +23,12 @@ use horizon_optifine::fusion::{
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, fs, num::NonZeroU16, path::Path};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
num::NonZeroU16,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
|
||||
struct Sheet {
|
||||
@@ -172,44 +177,60 @@ fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
|
||||
for ns in namespaces.flatten() {
|
||||
let ns_name = ns.file_name().to_string_lossy().into_owned();
|
||||
let textures = ns.path().join("textures");
|
||||
let mut dirs = vec![textures.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
// Linked folders too: mods' content links into the packs' tree.
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
continue;
|
||||
}
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
for path in mcmeta_files(&textures) {
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others
|
||||
/// from the folders (linked folders too: mods' content links into the packs' tree).
|
||||
fn mcmeta_files(textures: &Path) -> Vec<PathBuf> {
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(textures) {
|
||||
return files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png.mcmeta"))
|
||||
.map(|file| textures.join(file))
|
||||
.collect();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut dirs = vec![textures.to_owned()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
} else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,6 +1447,13 @@ fn instance_descriptor(
|
||||
}
|
||||
descriptor.backends = backends.with_env();
|
||||
let dx12 = cfg!(windows) && descriptor.backends.contains(wgpu::Backends::DX12);
|
||||
// FXC spends about 2.6 s on every start compiling the pipelines (DXC about
|
||||
// 1 s); what it compiled once is kept in the game folder and read back
|
||||
// after that (third_party/wgpu-hal/src/dx12/disk_cache.rs).
|
||||
#[cfg(windows)]
|
||||
if dx12 {
|
||||
wgpu::hal::dx12::disk_cache::set_file(dx12_shader_cache(&crate::logging::game_home()));
|
||||
}
|
||||
if !cfg!(debug_assertions) && !dx12 {
|
||||
descriptor
|
||||
.flags
|
||||
@@ -1460,6 +1467,11 @@ fn instance_descriptor(
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
/// Where DX12 keeps the shaders it compiled, inside a game folder.
|
||||
pub fn dx12_shader_cache(home: &std::path::Path) -> std::path::PathBuf {
|
||||
home.join("cache").join("dx12-shaders.bin")
|
||||
}
|
||||
|
||||
/// The window's display connection, so the OpenGL backend creates its EGL
|
||||
/// display from the X11 or Wayland connection the window lives on. Without
|
||||
/// one, wgpu-hal falls back to `EGL_MESA_platform_surfaceless`, whose configs
|
||||
|
||||
@@ -70,8 +70,11 @@ pub struct SkinJob {
|
||||
/// face's axis rides in the part's first 6 normal words ([`SkinPart::item`]),
|
||||
/// shaded on the CPU, and the normal is `mesher::CULL_BACK`.
|
||||
pub item: u32,
|
||||
/// Always 0: the shader reads the last as a zero it cannot fold away.
|
||||
pub spare: [u32; 2],
|
||||
/// 1 for a model whose render type culls back faces (`mobs::Polygon::cull_back`):
|
||||
/// the normal's w is `mesher::SHADED_CULL_W` instead of -127.
|
||||
pub cull: u32,
|
||||
/// Always 0: the shader reads it as a zero it cannot fold away.
|
||||
pub spare: u32,
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
|
||||
@@ -376,7 +379,12 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
let color = part.normal[v.axis as usize].to_bits();
|
||||
(color, crate::mesher::CULL_BACK)
|
||||
} else {
|
||||
(job.color, [packed[0], packed[1], packed[2], -127])
|
||||
let w = if job.cull != 0 {
|
||||
crate::mesher::SHADED_CULL_W
|
||||
} else {
|
||||
-127
|
||||
};
|
||||
(job.color, [packed[0], packed[1], packed[2], w])
|
||||
};
|
||||
out[(job.dst + k) as usize] = Vertex {
|
||||
pos: pos.to_array(),
|
||||
@@ -490,6 +498,8 @@ mod tests {
|
||||
color: 0xff80_40ff,
|
||||
light: 0xf0_70,
|
||||
overlay: 1,
|
||||
// The bat's encoding: the shader must write -126 like `expand`.
|
||||
cull: 1,
|
||||
..Default::default()
|
||||
},
|
||||
SkinJob {
|
||||
|
||||
@@ -16,8 +16,8 @@ const PART_WORDS: u32 = 25u;
|
||||
// A job (16 words): first model vertex, vertex count, first part, first output
|
||||
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), whether
|
||||
// it draws an item's quads (`SkinJob::item`), 2 spare (0: the last one is
|
||||
// `keep`'s).
|
||||
// it draws an item's quads (`SkinJob::item`), whether its quads cull back faces
|
||||
// (`SkinJob::cull`), 1 spare (0: `keep`'s).
|
||||
const JOB_WORDS: u32 = 16u;
|
||||
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
|
||||
// (4 x i8), glint_uv (2).
|
||||
@@ -89,6 +89,7 @@ fn skin(
|
||||
let overlay = jobs[j + 11u];
|
||||
let glint = jobs[j + 12u] != 0u;
|
||||
let item = jobs[j + 13u] != 0u;
|
||||
let cull = jobs[j + 14u] != 0u;
|
||||
keep = jobs[j + 15u];
|
||||
for (var k = lane; k < count; k += 64u) {
|
||||
let v = (first + k) * MODEL_WORDS;
|
||||
@@ -110,6 +111,10 @@ fn skin(
|
||||
// An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`.
|
||||
var vertex_color = color;
|
||||
var normal = pack(n);
|
||||
if cull {
|
||||
// `mesher::SHADED_CULL_W` (-126) in the normal's w byte.
|
||||
normal = (normal & 0x00ffffffu) | (u32(-126 & 0xff) << 24u);
|
||||
}
|
||||
if item {
|
||||
vertex_color = parts[part * PART_WORDS + 16u + axis];
|
||||
normal = 1u << 24u;
|
||||
|
||||
@@ -273,16 +273,20 @@ const ZEROS: u64 = 4 << 20;
|
||||
pub(super) struct Buffers {
|
||||
pub(super) layout: wgpu::BindGroupLayout,
|
||||
objects: BTreeMap<u32, BufferObject>,
|
||||
/// The fewest bytes each one's shaders bind it as (`Post::buffer_minimums`).
|
||||
minimums: BTreeMap<u32, u64>,
|
||||
pub(super) group: Option<wgpu::BindGroup>,
|
||||
buffers: Vec<wgpu::Buffer>,
|
||||
made_for: [u32; 2],
|
||||
}
|
||||
|
||||
impl Buffers {
|
||||
/// The layout of `objects`, bound in `stages`.
|
||||
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
|
||||
/// `minimums` long.
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
objects: BTreeMap<u32, BufferObject>,
|
||||
minimums: BTreeMap<u32, u64>,
|
||||
stages: wgpu::ShaderStages,
|
||||
) -> Self {
|
||||
let entries: Vec<_> = objects
|
||||
@@ -305,6 +309,7 @@ impl Buffers {
|
||||
Self {
|
||||
layout,
|
||||
objects,
|
||||
minimums,
|
||||
group: None,
|
||||
buffers: Vec::new(),
|
||||
made_for: [0; 2],
|
||||
@@ -330,7 +335,8 @@ impl Buffers {
|
||||
.objects
|
||||
.iter()
|
||||
.map(|(binding, o)| {
|
||||
let wanted = o.size(size).next_multiple_of(4);
|
||||
let least = self.minimums.get(binding).copied().unwrap_or(0);
|
||||
let wanted = buffer_size(*o, size, least);
|
||||
if wanted > largest {
|
||||
log::warn!(
|
||||
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
|
||||
@@ -368,6 +374,13 @@ impl Buffers {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
|
||||
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
|
||||
/// OpenGL binds the smaller buffer; a multiple of 4.
|
||||
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
|
||||
object.size(render).max(least).next_multiple_of(4)
|
||||
}
|
||||
|
||||
/// A debug readback of one of the pack's images (`HORIZON_SHADER_PACK_DUMP=<name>`): every 60
|
||||
/// frames, how many of its texels are not zero, logged.
|
||||
pub(super) struct Dump {
|
||||
@@ -458,3 +471,27 @@ impl Dump {
|
||||
self.pending = Some((buffer, texel, dims, row));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A buffer the pack declares smaller than a shader's block grows to the block (40 bytes
|
||||
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
|
||||
#[test]
|
||||
fn a_buffer_is_at_least_its_shaders_block() {
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
|
||||
assert_eq!(
|
||||
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
|
||||
40992
|
||||
);
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
|
||||
let per_pixel = BufferObject::PerPixel {
|
||||
bytes: 4,
|
||||
scale: [0.5, 0.5],
|
||||
};
|
||||
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
|
||||
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
|
||||
}
|
||||
}
|
||||
@@ -1686,6 +1686,8 @@ impl PackPost {
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
) -> Self {
|
||||
// What the pack's shaders need of its storage buffers, before its parts move out.
|
||||
let minimums = post.buffer_minimums();
|
||||
let features = device.features();
|
||||
let formats: [wgpu::TextureFormat; BUFFERS] =
|
||||
std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features));
|
||||
@@ -1769,7 +1771,7 @@ impl PackPost {
|
||||
if gpu.storage_stages().is_empty() {
|
||||
objects.clear();
|
||||
}
|
||||
let buffers = pack_images::Buffers::new(device, objects, gpu.storage_stages());
|
||||
let buffers = pack_images::Buffers::new(device, objects, minimums, gpu.storage_stages());
|
||||
let storable = (0..BUFFERS)
|
||||
.filter(|&i| gpu.storable(adapter, formats[i], features))
|
||||
.fold(0u16, |m, i| m | 1 << i);
|
||||
|
||||
@@ -193,6 +193,16 @@ fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
.then(|| (field(16), field(20)))
|
||||
}
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(dir) {
|
||||
out.extend(
|
||||
files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png"))
|
||||
.map(|file| dir.join(file)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -50,6 +50,9 @@ pub struct Vertex {
|
||||
/// `Vertex::normal` of a baked-colour quad of an `entitySolid` model: the render type culls
|
||||
/// back faces, which the entity pipelines here do not, so `entity.wgsl` discards them.
|
||||
pub const CULL_BACK: [i8; 4] = [0, 0, 0, 1];
|
||||
/// `Vertex::normal.w` of a shaded quad of an `entityCutoutCull` model (the bat): shaded by the
|
||||
/// normal like -127, and `entity.wgsl` also discards its back-facing pixels.
|
||||
pub const SHADED_CULL_W: i8 = -126;
|
||||
/// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour,
|
||||
/// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes
|
||||
/// rather than vanilla's 28: the UV is fixed point, which holds model UVs
|
||||
|
||||
@@ -996,7 +996,14 @@ impl ParticleLevel<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
match scene.resolve(bb, Vec3::new(d[0], d[1], d[2]), 0.0, false) {
|
||||
let movement = Vec3::new(d[0], d[1], d[2]);
|
||||
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
|
||||
// block of the swept box's cells can collide, it finds no shape and the
|
||||
// movement stands, as the resolution below would return it.
|
||||
if scene.cells_without_collision(bb.expand_towards(movement)) {
|
||||
return d;
|
||||
}
|
||||
match scene.resolve(bb, movement, 0.0, false) {
|
||||
Ok(v) => [v.x, v.y, v.z],
|
||||
Err(_) => d,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! or a core on a busy machine) shows apart from one that worked. Costs two
|
||||
//! clock reads per span when enabled and a relaxed load otherwise.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
cell::{Cell, RefCell},
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -14,6 +14,10 @@ use std::{
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
thread_local! {
|
||||
static SPANS: RefCell<Vec<Span>> = const { RefCell::new(Vec::new()) };
|
||||
/// Spans recorded on this thread alone, wall time only ([`record_here`]).
|
||||
static HERE: Cell<bool> = const { Cell::new(false) };
|
||||
/// How many timed spans the thread is inside: the next span's [`Span::depth`].
|
||||
static DEPTH: Cell<u8> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A named timing: wall milliseconds and the thread's CPU milliseconds in it.
|
||||
@@ -22,6 +26,8 @@ pub struct Span {
|
||||
pub name: &'static str,
|
||||
pub ms: f32,
|
||||
pub cpu_ms: f32,
|
||||
/// How many timed spans it ran inside (0: none): `tick_mobs` is inside `tick`.
|
||||
pub depth: u8,
|
||||
}
|
||||
|
||||
pub fn enable(on: bool) {
|
||||
@@ -31,19 +37,49 @@ pub fn enable(on: bool) {
|
||||
pub fn enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Record this thread's spans even with spans off, their wall time only (no CPU clock read):
|
||||
/// what the render thread's slow frames spent their time in, for the opt-in performance
|
||||
/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]).
|
||||
pub fn record_here(on: bool) {
|
||||
HERE.with(|h| h.set(on));
|
||||
}
|
||||
/// Whether this thread records spans: all of them on, or [`record_here`].
|
||||
#[inline]
|
||||
pub fn recording_here() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get)
|
||||
}
|
||||
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
|
||||
/// print it.
|
||||
#[inline]
|
||||
fn cpu_if_enabled() -> Option<Duration> {
|
||||
if ENABLED.load(Ordering::Relaxed) {
|
||||
thread_cpu()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Run `f`, recording its duration under `name` when spans are enabled.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
if !recording_here() {
|
||||
return f();
|
||||
}
|
||||
let (started, cpu) = (Instant::now(), thread_cpu());
|
||||
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
|
||||
let (started, cpu) = (Instant::now(), cpu_if_enabled());
|
||||
let out = f();
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = thread_cpu()
|
||||
.zip(cpu)
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
out
|
||||
}
|
||||
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
|
||||
@@ -52,7 +88,7 @@ pub struct Laps(Option<(Instant, Option<Duration>)>);
|
||||
/// Start timing the steps of a function (see [`Laps`]).
|
||||
#[inline]
|
||||
pub fn laps() -> Laps {
|
||||
Laps(enabled().then(|| (Instant::now(), thread_cpu())))
|
||||
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled())))
|
||||
}
|
||||
impl Laps {
|
||||
/// Record the step that just ended under `name`, and start the next.
|
||||
@@ -61,12 +97,21 @@ impl Laps {
|
||||
let Some((started, cpu)) = &mut self.0 else {
|
||||
return;
|
||||
};
|
||||
let (now, now_cpu) = (Instant::now(), thread_cpu());
|
||||
let now = Instant::now();
|
||||
let now_cpu = cpu.and_then(|_| thread_cpu());
|
||||
let ms = (now - *started).as_secs_f32() * 1000.0;
|
||||
let cpu_ms = now_cpu
|
||||
.zip(*cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
let depth = DEPTH.with(Cell::get);
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
}
|
||||
}
|
||||
@@ -83,11 +128,62 @@ pub fn thread_cpu() -> Option<Duration> {
|
||||
horizon_threads::thread_cpu()
|
||||
}
|
||||
|
||||
/// The frame's spans as they were recorded, each after the spans inside it; clears them.
|
||||
pub fn take_raw() -> Vec<Span> {
|
||||
SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()))
|
||||
}
|
||||
/// The frame's spans, summed by name, slowest first; clears them.
|
||||
pub fn take() -> Vec<Span> {
|
||||
let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()));
|
||||
merge(&take_raw())
|
||||
}
|
||||
/// What a frame spent its time in, from its spans as recorded ([`take_raw`]): the slowest of
|
||||
/// the outermost spans (summed by name, as a frame can tick several times), then the slowest
|
||||
/// span inside it while that one took at least half of it, and so on. The path of names
|
||||
/// (`tick/tick_mobs`) and the last one's milliseconds; `None` without spans.
|
||||
pub fn culprit(raw: &[Span]) -> Option<(String, f32)> {
|
||||
// The spans directly inside each one: recorded just before it, one level deeper.
|
||||
let mut inside: Vec<Vec<usize>> = vec![Vec::new(); raw.len()];
|
||||
let mut outer: Vec<usize> = Vec::new();
|
||||
for (i, span) in raw.iter().enumerate() {
|
||||
while let Some(&j) = outer.last()
|
||||
&& raw[j].depth > span.depth
|
||||
{
|
||||
outer.pop();
|
||||
inside[i].push(j);
|
||||
}
|
||||
outer.push(i);
|
||||
}
|
||||
let slowest = |group: &[usize]| {
|
||||
let mut sums: Vec<(&'static str, f32, Vec<usize>)> = Vec::new();
|
||||
for &i in group {
|
||||
match sums.iter_mut().find(|s| s.0 == raw[i].name) {
|
||||
Some(sum) => {
|
||||
sum.1 += raw[i].ms;
|
||||
sum.2.push(i);
|
||||
}
|
||||
None => sums.push((raw[i].name, raw[i].ms, vec![i])),
|
||||
}
|
||||
}
|
||||
sums.into_iter().max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
};
|
||||
let (name, mut ms, mut at) = slowest(&outer)?;
|
||||
let mut path = name.to_owned();
|
||||
loop {
|
||||
let next: Vec<usize> = at.iter().flat_map(|&i| inside[i].iter().copied()).collect();
|
||||
match slowest(&next) {
|
||||
Some((name, inner_ms, spans)) if inner_ms >= ms * 0.5 => {
|
||||
path.push('/');
|
||||
path.push_str(name);
|
||||
(ms, at) = (inner_ms, spans);
|
||||
}
|
||||
_ => return Some((path, ms)),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Spans summed by name, slowest first.
|
||||
pub fn merge(spans: &[Span]) -> Vec<Span> {
|
||||
let mut sums: Vec<Span> = Vec::new();
|
||||
for span in spans {
|
||||
for &span in spans {
|
||||
match sums.iter_mut().find(|s| s.name == span.name) {
|
||||
Some(sum) => {
|
||||
sum.ms += span.ms;
|
||||
@@ -309,21 +405,25 @@ mod tests {
|
||||
name: "tick",
|
||||
ms: 2.0,
|
||||
cpu_ms: 2.0,
|
||||
depth: 0,
|
||||
},
|
||||
Span {
|
||||
name: "submit",
|
||||
ms: 30.0,
|
||||
cpu_ms: 0.2,
|
||||
depth: 0,
|
||||
},
|
||||
Span {
|
||||
name: "tick",
|
||||
ms: 3.0,
|
||||
cpu_ms: 2.9,
|
||||
depth: 0,
|
||||
},
|
||||
Span {
|
||||
name: "hud",
|
||||
ms: 0.3,
|
||||
cpu_ms: 0.3,
|
||||
depth: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -333,7 +433,8 @@ mod tests {
|
||||
Span {
|
||||
name: "submit",
|
||||
ms: 30.0,
|
||||
cpu_ms: 0.2
|
||||
cpu_ms: 0.2,
|
||||
depth: 0,
|
||||
}
|
||||
);
|
||||
assert_eq!((spans[1].name, spans[1].ms), ("tick", 5.0));
|
||||
@@ -341,6 +442,66 @@ mod tests {
|
||||
assert!(super::take().is_empty());
|
||||
}
|
||||
|
||||
/// A thread that records on its own keeps its spans with spans off, each with how many
|
||||
/// spans it ran inside, after them.
|
||||
#[test]
|
||||
fn a_recording_thread_keeps_its_spans_and_their_nesting() {
|
||||
std::thread::spawn(|| {
|
||||
super::record_here(true);
|
||||
super::time("tick", || {
|
||||
super::time("tick_mobs", || ());
|
||||
super::time("tick_items", || ());
|
||||
});
|
||||
super::time("renderer", || ());
|
||||
let raw = super::take_raw();
|
||||
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
("tick_mobs", 1),
|
||||
("tick_items", 1),
|
||||
("tick", 0),
|
||||
("renderer", 0)
|
||||
]
|
||||
);
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
|
||||
/// at least half of it; repeated spans (several ticks a frame) add up.
|
||||
#[test]
|
||||
fn a_frame_is_put_down_to_the_step_that_took_most_of_it() {
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
let frame = [
|
||||
span("tick_mobs", 90.0, 1),
|
||||
span("tick_items", 5.0, 1),
|
||||
span("tick", 100.0, 0),
|
||||
span("tick_mobs", 90.0, 1),
|
||||
span("tick", 100.0, 0),
|
||||
span("r_uploads", 120.0, 1),
|
||||
span("renderer", 150.0, 0),
|
||||
];
|
||||
assert_eq!(
|
||||
super::culprit(&frame),
|
||||
Some(("tick/tick_mobs".to_owned(), 180.0))
|
||||
);
|
||||
// Spread over many steps: the outer span alone.
|
||||
let spread = [
|
||||
span("tick_mobs", 40.0, 1),
|
||||
span("tick_items", 40.0, 1),
|
||||
span("tick", 300.0, 0),
|
||||
];
|
||||
assert_eq!(super::culprit(&spread), Some(("tick".to_owned(), 300.0)));
|
||||
assert_eq!(super::culprit(&[]), None);
|
||||
}
|
||||
|
||||
/// Laps record the steps between them while spans are on, and nothing while off.
|
||||
#[test]
|
||||
fn laps_time_consecutive_steps() {
|
||||
|
||||
@@ -223,6 +223,7 @@ pub struct Session {
|
||||
title_measured: bool,
|
||||
to_world_ms: Option<u32>,
|
||||
opening: Option<Instant>,
|
||||
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -250,8 +251,21 @@ impl Session {
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading).
|
||||
pub fn frame(&mut self, ms: f32) {
|
||||
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
|
||||
/// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts
|
||||
/// under the step it spent most of its time in ([`crate::spans::culprit`]).
|
||||
pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) {
|
||||
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
if ms >= SLOW_FRAME_MS {
|
||||
let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default();
|
||||
// A path too long for the item keeps its last steps.
|
||||
while path.len() > SlowFrames::MAX_NAME
|
||||
&& let Some((_, rest)) = path.split_once('/')
|
||||
{
|
||||
path = rest.to_owned();
|
||||
}
|
||||
SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms);
|
||||
}
|
||||
if let Some(opening) = self.opening.take() {
|
||||
crate::spans::startup::mark("world.first_frame");
|
||||
if self.to_world_ms.is_none() {
|
||||
@@ -281,6 +295,7 @@ impl Session {
|
||||
to_world_ms: self.to_world_ms,
|
||||
to_core_ms: self.to_title_ms.and_then(|_| readiness_ms(&CORE_READY_MS)),
|
||||
to_rest_ms: self.to_title_ms.and_then(|_| readiness_ms(&REST_READY_MS)),
|
||||
slow_frames: self.slow_frames.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -490,10 +505,10 @@ mod tests {
|
||||
assert!(session.opening.is_some());
|
||||
session.cancel_world_opening();
|
||||
assert!(session.opening.is_none());
|
||||
session.frame(16.0);
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_none());
|
||||
session.world_opening();
|
||||
session.frame(16.0);
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_some());
|
||||
}
|
||||
|
||||
@@ -502,7 +517,7 @@ mod tests {
|
||||
let home = home("session");
|
||||
let mut session = Session::default();
|
||||
session.world_opening();
|
||||
(0..2000).for_each(|_| session.frame(16.6));
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
|
||||
allow(
|
||||
@@ -512,11 +527,11 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
(0..2000).for_each(|_| session.frame(16.6));
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
|
||||
session.world_opening();
|
||||
(0..4000).for_each(|_| session.frame(16.6));
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[]));
|
||||
(0..1200).for_each(|_| session.tick(4.0));
|
||||
session.finish(
|
||||
&home,
|
||||
@@ -537,4 +552,34 @@ mod tests {
|
||||
assert!(perf.to_world_ms.is_some());
|
||||
assert!(serde_json::to_vec(&pending[0].1).unwrap().len() < 1024);
|
||||
}
|
||||
|
||||
/// Frames of 250 ms or more count under the step that took most of them; a frame with no
|
||||
/// span counts under none. Faster frames leave no entry.
|
||||
#[test]
|
||||
fn slow_frames_are_put_down_to_their_step() {
|
||||
use crate::spans::Span;
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
let mut session = Session::default();
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
|
||||
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
|
||||
session.frame(320.0, &frozen);
|
||||
session.frame(900.0, &frozen);
|
||||
session.frame(260.0, &[]);
|
||||
session.frame(249.0, &frozen);
|
||||
let perf = session.summary(Settings::default()).unwrap();
|
||||
let seen: Vec<_> = perf
|
||||
.slow_frames
|
||||
.iter()
|
||||
.map(|s| (s.span.as_str(), s.frames, s.frame_max_ms, s.span_max_ms))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[("renderer/r_uploads", 2, 900.0, 280.0), ("", 1, 260.0, 0.0)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,12 @@ use crate::{
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
text::Text,
|
||||
};
|
||||
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::RwLock,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
|
||||
/// its texture's resolution, its borders (left, top, right, bottom) in the
|
||||
@@ -50,41 +55,69 @@ fn parse_nine_slice(v: &serde_json::Value) -> Option<NineSlice> {
|
||||
}
|
||||
/// Read `gui.scaling` metadata for every GUI sprite under `root`.
|
||||
pub fn load_metadata(root: &Path) {
|
||||
set_metadata(read_metadata(root));
|
||||
}
|
||||
/// The GUI sprites' nine-slice metadata of a resource root, read off the
|
||||
/// render thread by a pack reload ([`read_metadata`]) and put in use with
|
||||
/// [`set_metadata`].
|
||||
pub struct UiMetadata(HashMap<String, NineSlice>);
|
||||
/// Puts read metadata in use ([`load_metadata`] in two halves).
|
||||
pub fn set_metadata(metadata: UiMetadata) {
|
||||
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(metadata.0);
|
||||
}
|
||||
/// Reads the metadata [`load_metadata`] puts in use, without putting it in use.
|
||||
pub fn read_metadata(root: &Path) -> UiMetadata {
|
||||
let mut map = HashMap::new();
|
||||
let base = root.join("assets/minecraft/textures");
|
||||
let mut stack = vec![base.join("gui/sprites")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
let sprites = base.join("gui/sprites");
|
||||
// A prepared root's: from its data file, without listing the disk.
|
||||
let files: Vec<PathBuf> = match horizon_core::prepared_files::files_under(&sprites) {
|
||||
Some(files) => files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png.mcmeta"))
|
||||
.map(|file| sprites.join(file))
|
||||
.collect(),
|
||||
None => {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![sprites];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.to_string_lossy().ends_with(".png.mcmeta") {
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
};
|
||||
for p in files {
|
||||
let name = p.to_string_lossy().into_owned();
|
||||
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let name = p.to_string_lossy().into_owned();
|
||||
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
|
||||
UiMetadata(map)
|
||||
}
|
||||
/// The sprite's nine-slice metadata, when its `.png.mcmeta` has any.
|
||||
fn nine_slice_metadata(sprite: &str) -> Option<NineSlice> {
|
||||
|
||||
@@ -1425,6 +1425,29 @@ mod tests {
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
|
||||
/// the recipe book and the trigger the same whole id (`award_crafted`
|
||||
/// qualifies). The bare name the crafting copy uses never matched —
|
||||
/// the second half of #7186.
|
||||
#[test]
|
||||
fn a_recipe_crafted_criterion_matches_the_whole_id_only() {
|
||||
let w = Flat;
|
||||
let criterion = serde_json::json!({"recipes": "minecraft:folder_recipe"});
|
||||
assert!(
|
||||
!id_matches(&criterion["recipes"], "recipe", "folder_recipe", &w),
|
||||
"the crafting copy's bare name never matched the criterion"
|
||||
);
|
||||
assert!(
|
||||
id_matches(
|
||||
&criterion["recipes"],
|
||||
"recipe",
|
||||
&crate::crafting::qualified("folder_recipe"),
|
||||
&w
|
||||
),
|
||||
"the whole id award_crafted passes matches"
|
||||
);
|
||||
}
|
||||
|
||||
/// `LockCode.canUnlock` for a chest with
|
||||
/// `lock:{items:"minecraft:gold_ingot"}`: a gold ingot opens it, an empty
|
||||
/// hand or another item does not, and a spectator always does.
|
||||
|
||||
@@ -1305,6 +1305,18 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
augment(i, ingredients, stacks, &mut owner, &mut seen)
|
||||
})
|
||||
}
|
||||
/// A resource id with its default namespace filled in — the whole id the
|
||||
/// book, the network copy and the advancements' JSON know a recipe by,
|
||||
/// whatever the caller passes. `sources` names a `minecraft:` recipe bare
|
||||
/// on purpose (vanilla's own ids); this puts the namespace back.
|
||||
pub fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let sources = Self::sources(root, overlay)?;
|
||||
|
||||
@@ -91,6 +91,11 @@ pub struct Creative {
|
||||
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
|
||||
/// search changes (selectTab, refreshSearchResults), not on every frame.
|
||||
shown: Mutex<Option<Shown>>,
|
||||
/// SessionSearchTrees.creativeByNameSearch: the lower-cased tooltip lines
|
||||
/// of every search item in one language (keyed by the `itemGroup.search`
|
||||
/// string), built on the first search and kept until the language or the
|
||||
/// item list changes.
|
||||
lines_cache: Mutex<Option<(String, Arc<[Vec<String>]>)>>,
|
||||
}
|
||||
/// The grid's stacks for one tab, search text and language.
|
||||
struct Shown {
|
||||
@@ -373,6 +378,7 @@ impl Creative {
|
||||
exported_search,
|
||||
permissions: false,
|
||||
shown: Mutex::new(None),
|
||||
lines_cache: Mutex::new(None),
|
||||
};
|
||||
creative.rebuild();
|
||||
creative
|
||||
@@ -398,6 +404,7 @@ impl Creative {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
*self.lines_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
(self.search_items, self.search_index) = self
|
||||
.exported_search
|
||||
.iter()
|
||||
@@ -531,6 +538,46 @@ impl Creative {
|
||||
/// the name tree, both over vanilla's suffix arrays (substring matches,
|
||||
/// results in the tab's order).
|
||||
pub fn search_results(&self, term: &str, names: &Names) -> Vec<usize> {
|
||||
let english =
|
||||
names.display_get("itemGroup.search").as_deref() == names.get("itemGroup.search");
|
||||
let local = if term.is_empty() || term.starts_with('#') {
|
||||
Arc::from([])
|
||||
} else if english && self.search_index.iter().all(|e| !e.lines.is_empty()) {
|
||||
// The exported English lines are the whole index.
|
||||
Arc::from([])
|
||||
} else {
|
||||
let language = names.display_get("itemGroup.search").unwrap_or_default();
|
||||
let mut cache = self.lines_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match cache.as_ref().filter(|(l, _)| *l == language) {
|
||||
Some((_, lines)) => lines.clone(),
|
||||
None => {
|
||||
let lines = self.localized_lines(&|k| names.display_get(k));
|
||||
*cache = Some((language, lines.clone()));
|
||||
lines
|
||||
}
|
||||
}
|
||||
};
|
||||
self.search_results_in(term, names, &local, english)
|
||||
}
|
||||
/// Every search item's tooltip lines through `translate`, lower-cased.
|
||||
fn localized_lines(&self, translate: &dyn Fn(&str) -> Option<String>) -> Arc<[Vec<String>]> {
|
||||
self.search_items
|
||||
.iter()
|
||||
.map(|s| {
|
||||
crate::item_tooltip::lines(s, None, translate)
|
||||
.iter()
|
||||
.map(|l| java_lower(l))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn search_results_in(
|
||||
&self,
|
||||
term: &str,
|
||||
names: &Names,
|
||||
local: &[Vec<String>],
|
||||
english: bool,
|
||||
) -> Vec<usize> {
|
||||
let all = 0..self.search_index.len();
|
||||
if term.is_empty() {
|
||||
return all.collect();
|
||||
@@ -552,18 +599,13 @@ impl Creative {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// FullTextSearchTree: tooltip lines, and the item id after a colon.
|
||||
// The export's lines are English (they come from the vanilla jars);
|
||||
// the hover name in the selected language is matched too, so a
|
||||
// localized search finds the item. It is the whole name (a tipped
|
||||
// arrow's potion, the ominous banner), as the first line is.
|
||||
// FullTextSearchTree: the creative tooltip lines in the selected
|
||||
// language, and the item id after a colon. The export's lines are
|
||||
// English (they come from the vanilla jars), so they stand in only
|
||||
// while the language is English; otherwise every line (name, lore,
|
||||
// effects, ...) is rebuilt through the language, as vanilla's
|
||||
// getTooltipLines does.
|
||||
let text = java_lower(term);
|
||||
let localized = |i: usize| {
|
||||
java_lower(&crate::item_tooltip::name_line(
|
||||
&self.search_items[i],
|
||||
&|k| names.display_get(k),
|
||||
))
|
||||
};
|
||||
all.filter(|&i| {
|
||||
let e = &self.search_index[i];
|
||||
let fallback;
|
||||
@@ -573,16 +615,17 @@ impl Creative {
|
||||
} else {
|
||||
&e.lines
|
||||
};
|
||||
let exported = english || e.lines.is_empty();
|
||||
let local = local.get(i).map_or(&[][..], Vec::as_slice);
|
||||
let hit = |t: &str| {
|
||||
(exported && any(lines.iter().map(String::as_str), t))
|
||||
|| any(local.iter().map(String::as_str), t)
|
||||
};
|
||||
match text.split_once(':') {
|
||||
None => {
|
||||
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
|
||||
}
|
||||
None => hit(&text),
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
e.namespace.contains(ns)
|
||||
&& (e.path.contains(path)
|
||||
|| any(lines.iter().map(String::as_str), path)
|
||||
|| localized(i).contains(path))
|
||||
e.namespace.contains(ns) && (e.path.contains(path) || hit(path))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1396,15 +1439,33 @@ mod tests {
|
||||
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
|
||||
]),
|
||||
};
|
||||
let q = |s: &str| c.search_results(s, &names);
|
||||
let tr = |k: &str| names.get(k).map(str::to_owned);
|
||||
let local = c.localized_lines(&tr);
|
||||
let q = |s: &str| c.search_results_in(s, &names, &local, false);
|
||||
assert_eq!(q("铁砧"), [0]);
|
||||
assert_eq!(q("木锄"), [2]);
|
||||
// The English tooltip lines the export indexed still match.
|
||||
assert_eq!(q("anvil"), [0]);
|
||||
// Only the selected language's lines count outside English.
|
||||
assert!(q("anvil").is_empty());
|
||||
assert_eq!(c.search_results_in("anvil", &names, &local, true), [0]);
|
||||
// A colon's path searches the display name too.
|
||||
assert_eq!(q("minecraft:铁砧"), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_every_localized_tooltip_line() {
|
||||
// Vanilla's FullTextSearchTree indexes the selected language's
|
||||
// tooltip lines; the export's English lines do not apply.
|
||||
let c = creative(0);
|
||||
let names = Names::default();
|
||||
let ru: HashMap<&str, &str> = HashMap::from([("item.minecraft.anvil", "Наковальня")]);
|
||||
let tr = |k: &str| ru.get(k).map(|v| (*v).to_owned());
|
||||
let local = c.localized_lines(&tr);
|
||||
let q = |s: &str, english| c.search_results_in(s, &names, &local, english);
|
||||
assert_eq!(q("наков", false), [0]);
|
||||
assert!(q("anvil", false).is_empty());
|
||||
assert_eq!(q("anvil", true), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_drag_follows_the_mouse_while_held() {
|
||||
let mut c = creative(90);
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3, VoxelShape},
|
||||
geometry::{Aabb, Direction, Vec3, VoxelShape, border::BorderGeometry},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
@@ -104,7 +104,7 @@ pub struct ItemEntity {
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
pub on_ground: bool,
|
||||
pub age: u32,
|
||||
pub age: i32,
|
||||
pub pickup_delay: u32,
|
||||
pub bob_offset: f32,
|
||||
/// `Entity.tickCount`: ticks since the item entered the level, which
|
||||
@@ -261,6 +261,7 @@ impl ItemEntity {
|
||||
no_physics: bool,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&BorderGeometry>,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -291,8 +292,11 @@ impl ItemEntity {
|
||||
// horizontal speed. The idle branch still runs bubble-column push
|
||||
// (`applyEffectsFromBlocksForLastMovements`), so lift builds up and
|
||||
// the item moves on the next wake-up (#4558).
|
||||
if !self.on_ground || horizontal > 1.0e-5 || (self.age + self.id).is_multiple_of(4) {
|
||||
let mut scene = SceneCollisions {
|
||||
if !self.on_ground
|
||||
|| horizontal > 1.0e-5
|
||||
|| self.ticks.wrapping_add(self.id).is_multiple_of(4)
|
||||
{
|
||||
let scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
@@ -308,17 +312,14 @@ impl ItemEntity {
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else if entity_boxes.is_empty() {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
} else {
|
||||
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still).
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still), and
|
||||
// so does the world border (`Entity.collectColliders`).
|
||||
crate::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: entity_boxes,
|
||||
border: None,
|
||||
border: border.map(|b| (b, [self.position.x, self.position.z])),
|
||||
}
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
@@ -441,7 +442,9 @@ impl ItemEntity {
|
||||
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
|
||||
self.needs_sync = true;
|
||||
}
|
||||
self.age += 1;
|
||||
if self.age != i16::MIN as i32 {
|
||||
self.age += 1;
|
||||
}
|
||||
// ItemEntity.tick refreshes immersion after moving.
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
@@ -548,8 +551,11 @@ impl ItemEntity {
|
||||
}
|
||||
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
|
||||
/// delay isn't infinite, before its lifetime ends.
|
||||
fn mergeable(&self, age: u32, max_stack: u32) -> bool {
|
||||
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
|
||||
fn mergeable(&self, age: i32, max_stack: u32) -> bool {
|
||||
self.pickup_delay != u32::MAX
|
||||
&& age != i16::MIN as i32
|
||||
&& age < 6000
|
||||
&& self.stack.count < max_stack
|
||||
}
|
||||
}
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
@@ -685,7 +691,7 @@ pub fn merge_with_neighbours(
|
||||
max_stack: &dyn Fn(&ItemStack) -> u32,
|
||||
) {
|
||||
let age = |items: &[ItemEntity], j: usize| {
|
||||
if j == i {
|
||||
if j == i && items[j].age != i16::MIN as i32 {
|
||||
items[j].age - 1
|
||||
} else {
|
||||
items[j].age
|
||||
@@ -729,7 +735,7 @@ pub fn merge_with_neighbours(
|
||||
let to_item = &mut items[to];
|
||||
to_item.pickup_delay = to_item.pickup_delay.max(delay);
|
||||
// This item's age goes up once its tick ends.
|
||||
to_item.age = to_age.min(from_age) + u32::from(to == i);
|
||||
to_item.age = to_age.min(from_age) + i32::from(to == i);
|
||||
if items[from].stack.count == 0 {
|
||||
removed[from] = true;
|
||||
cells.remove(from, items[from].position);
|
||||
@@ -852,6 +858,7 @@ impl ExperienceOrb {
|
||||
player: Option<[f64; 3]>,
|
||||
server: bool,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
border: Option<&BorderGeometry>,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -916,8 +923,10 @@ impl ExperienceOrb {
|
||||
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
|
||||
// push and before the move.
|
||||
let wanted = self.velocity;
|
||||
// Entity.collide: the world border's walls stop the orb too.
|
||||
let border = border.map(|b| (b, [self.position.x, self.position.z]));
|
||||
let moved = scene
|
||||
.resolve(bounds, wanted, 0.0, self.on_ground)
|
||||
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -1775,7 +1784,7 @@ mod armor_stand_render_tests {
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
|
||||
fn item(id: &str, count: u32, x: f64, age: u32) -> ItemEntity {
|
||||
fn item(id: &str, count: u32, x: f64, age: i32) -> ItemEntity {
|
||||
let at = Vec3::new(x, 64.0, 0.5);
|
||||
ItemEntity {
|
||||
no_gravity: false,
|
||||
@@ -1799,7 +1808,7 @@ mod merge_tests {
|
||||
age,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: age,
|
||||
ticks: age.max(0) as u32,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
}
|
||||
@@ -1954,6 +1963,73 @@ mod merge_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_border_tests {
|
||||
use super::*;
|
||||
|
||||
/// `Entity.collectColliders` (#7080): the world border's wall stops an
|
||||
/// item and an experience orb as it stops the player. A border of size
|
||||
/// 10 around (20, 20) has its east wall at x = 25; each body starts at
|
||||
/// x = 24 on the flat ground of the test scene, moving 1.5 east.
|
||||
#[test]
|
||||
fn the_world_border_stops_items_and_experience_orbs() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let border = BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984).unwrap();
|
||||
for bordered in [false, true] {
|
||||
let border = bordered.then_some(&border);
|
||||
let mut item = super::honey_slide_tests::falling_item(24.0, 4.0, 20.5, 1.5, 0.0, 0.0);
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
border,
|
||||
);
|
||||
let mut orb = ExperienceOrb {
|
||||
no_gravity: false,
|
||||
id: 2,
|
||||
position: Vec3::new(24.0, 4.0, 20.5),
|
||||
previous: Vec3::new(24.0, 4.0, 20.5),
|
||||
velocity: Vec3::new(1.5, 0.0, 0.0),
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: true,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
};
|
||||
orb.tick(&world, &facts, &collision, None, true, &mut || 0.0, border);
|
||||
for (what, east) in [
|
||||
("item", item.bounds().max_position().x),
|
||||
("orb", orb.bounds().max_position().x),
|
||||
] {
|
||||
if bordered {
|
||||
assert!(
|
||||
(east - 25.0).abs() < 1.0e-7,
|
||||
"the {what} stops on the wall at x = 25, got {east}"
|
||||
);
|
||||
} else {
|
||||
assert!(east > 25.0, "no border: the {what} goes on, got {east}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod honey_slide_tests {
|
||||
use super::*;
|
||||
@@ -1984,7 +2060,8 @@ mod honey_slide_tests {
|
||||
let mut item = falling_item(at.x, at.y, at.z, 0.0, 0.0, 0.0);
|
||||
item.on_ground = true;
|
||||
item.age = 0;
|
||||
item.id = 1;
|
||||
item.ticks = 0;
|
||||
item.id = 0;
|
||||
let mut ys = Vec::new();
|
||||
for _ in 0..8 {
|
||||
item.tick(
|
||||
@@ -1995,6 +2072,7 @@ mod honey_slide_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
ys.push(item.velocity.y);
|
||||
}
|
||||
@@ -2059,6 +2137,7 @@ mod honey_slide_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
@@ -2355,6 +2434,7 @@ mod slime_bounce_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(falling.position.y, 4.0);
|
||||
assert!(falling.velocity.y > 0.0);
|
||||
@@ -2373,6 +2453,7 @@ mod slime_bounce_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(!falling.needs_sync);
|
||||
}
|
||||
@@ -2465,6 +2546,7 @@ mod entity_collision_tests {
|
||||
false,
|
||||
&fluids,
|
||||
boxes,
|
||||
None,
|
||||
);
|
||||
}
|
||||
item.position.y
|
||||
@@ -2513,6 +2595,7 @@ mod entity_collision_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
item.position.x
|
||||
|
||||
@@ -262,7 +262,8 @@ mod tests {
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
&[],
|
||||
None
|
||||
));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
@@ -275,7 +276,8 @@ mod tests {
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
&[],
|
||||
None
|
||||
));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
@@ -289,6 +291,7 @@ mod tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
|
||||
@@ -677,10 +677,10 @@ pub fn mob_to_nbt(
|
||||
| "minecraft:trader_llama" => {
|
||||
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
|
||||
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
|
||||
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
|
||||
for key in ["EatingHaystack", "Bred"] {
|
||||
map.entry(key.into()).or_insert(Tag::Byte(0));
|
||||
}
|
||||
map.insert("Bred".into(), Tag::Byte(i8::from(m.extra.bred)));
|
||||
// AbstractHorse.addAdditionalSaveData; Horizon does not model the
|
||||
// eating haystack yet.
|
||||
map.entry("EatingHaystack".into()).or_insert(Tag::Byte(0));
|
||||
if let Some(owner) = m.extra.owner {
|
||||
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
@@ -1608,6 +1608,7 @@ pub fn mob_from_nbt(
|
||||
if crate::mobs::equine::is_equine(m.species()) {
|
||||
m.extra.tame = flag(tag, "Tame");
|
||||
m.extra.variant3 = number(tag.get("Temper")).unwrap_or(0).clamp(0, 100) as u8;
|
||||
m.extra.bred = flag(tag, "Bred");
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
let v = number(tag.get("Variant")).unwrap_or(0) as i32;
|
||||
m.extra.variant = (v & 0xFF).clamp(0, 6) as u8;
|
||||
@@ -2033,7 +2034,10 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
("Item", item_to_nbt(&e.stack)),
|
||||
("Health", Tag::Short(e.health as i16)),
|
||||
("Fire", Tag::Short(e.fire_ticks as i16)),
|
||||
("Age", Tag::Short(e.age.min(i16::MAX as u32) as i16)),
|
||||
(
|
||||
"Age",
|
||||
Tag::Short(e.age.clamp(i16::MIN as i32, i16::MAX as i32) as i16),
|
||||
),
|
||||
(
|
||||
"PickupDelay",
|
||||
Tag::Short(e.pickup_delay.min(i16::MAX as u32) as i16),
|
||||
@@ -2110,8 +2114,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
// Age -32768 marks items that never despawn; Horizon counts upwards.
|
||||
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
|
||||
age: number(tag.get("Age")).unwrap_or(0) as i32,
|
||||
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0).clamp(0, 32767) as u32,
|
||||
bob_offset,
|
||||
ticks: 0,
|
||||
@@ -2412,7 +2415,7 @@ const THROWABLE_ITEMS: [&str; 5] = [
|
||||
fn throwable_item(kind: &ProjectileKind) -> Option<&str> {
|
||||
match kind {
|
||||
ProjectileKind::Thrown(t) => Some(t.item),
|
||||
ProjectileKind::Potion(stack) => Some(&stack.item),
|
||||
ProjectileKind::Potion(stack, _) => Some(&stack.item),
|
||||
_ => THROWABLE_ITEMS.iter().find(|id| **id == kind.id()).copied(),
|
||||
}
|
||||
}
|
||||
@@ -2428,20 +2431,37 @@ pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
|
||||
"minecraft:snowball" => Some(ProjectileKind::Snowball),
|
||||
"minecraft:egg" => Some(ProjectileKind::Egg),
|
||||
"minecraft:ender_pearl" => Some(ProjectileKind::EnderPearl),
|
||||
_ => Some(ProjectileKind::Potion(Box::new(
|
||||
crate::items::ItemStack::new(id, 1),
|
||||
))),
|
||||
// The entity type is the id; the `Item` read later may be another potion.
|
||||
_ => Some(ProjectileKind::Potion(
|
||||
Box::new(crate::items::ItemStack::new(id, 1)),
|
||||
id == "minecraft:lingering_potion",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether its chunk saves it: the kinds `arrow_to_nbt`, `fireball_to_nbt`
|
||||
/// and [`throwable_to_nbt`] write.
|
||||
pub fn saved_with_chunk(p: &Projectile) -> bool {
|
||||
matches!(
|
||||
p.kind,
|
||||
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
|
||||
) || (p.arrow.is_some() && matches!(p.kind, ProjectileKind::Arrow { .. }))
|
||||
|| throwable_item(&p.kind).is_some()
|
||||
// No wildcard: a kind added to `ProjectileKind` must say here whether its
|
||||
// chunk saves it (`Entity.shouldBeSaved` is true for every projectile in
|
||||
// vanilla), and a `true` needs a writer in `entity_chunks`.
|
||||
match &p.kind {
|
||||
ProjectileKind::LargeFireball { .. }
|
||||
| ProjectileKind::SmallFireball
|
||||
| ProjectileKind::WindCharge
|
||||
| ProjectileKind::BreezeWindCharge
|
||||
| ProjectileKind::Snowball
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::EnderPearl
|
||||
| ProjectileKind::Potion(..)
|
||||
| ProjectileKind::Thrown(_) => true,
|
||||
ProjectileKind::Arrow { .. } => p.arrow.is_some(),
|
||||
// Not written yet: tridents save from their own list.
|
||||
ProjectileKind::Trident
|
||||
| ProjectileKind::LlamaSpit
|
||||
| ProjectileKind::ShulkerBullet { .. }
|
||||
| ProjectileKind::WitherSkull { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ThrowableItemProjectile.addAdditionalSaveData`: `Projectile`'s keys and
|
||||
@@ -2472,7 +2492,7 @@ pub fn throwable_to_nbt(
|
||||
// effects); when there is none, `getItem`'s default is written.
|
||||
if tag.get("Item").is_none() {
|
||||
let stack = match &p.kind {
|
||||
ProjectileKind::Potion(stack) => (**stack).clone(),
|
||||
ProjectileKind::Potion(stack, _) => (**stack).clone(),
|
||||
_ => crate::items::ItemStack::new(item, 1),
|
||||
};
|
||||
put(&mut tag, "Item", item_to_nbt(&stack));
|
||||
@@ -2486,7 +2506,7 @@ pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Project
|
||||
let mut kind = throwable_kind(&kind(tag)?)?;
|
||||
// `readAdditionalSaveData` sets the `Item` it flies as, potion effects
|
||||
// and all; a tag without one keeps `getItem`'s default.
|
||||
if let ProjectileKind::Potion(stack) = &mut kind
|
||||
if let ProjectileKind::Potion(stack, _) = &mut kind
|
||||
&& let Some(item) = tag.get("Item").and_then(item_from_nbt)
|
||||
{
|
||||
**stack = item;
|
||||
@@ -2745,12 +2765,25 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
put(&mut tag, "block_pos", Tag::IntArray(p.pos.to_vec()));
|
||||
tag
|
||||
}
|
||||
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved; a
|
||||
/// summoned one has none, and `setPos` puts its block at the block holding
|
||||
/// the summon position.
|
||||
fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
|
||||
match tag.get("block_pos") {
|
||||
Some(Tag::IntArray(v)) if v.len() == 3 => Some([v[0], v[1], v[2]]),
|
||||
_ => {
|
||||
let at = position(tag)?;
|
||||
Some([
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The block position, facing and variant of a saved painting.
|
||||
pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> {
|
||||
let pos = match tag.get("block_pos") {
|
||||
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
|
||||
_ => return None,
|
||||
};
|
||||
let pos = attached_block_pos(tag)?;
|
||||
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
|
||||
Some((pos, facing, text(tag.get("variant"))?))
|
||||
}
|
||||
@@ -2863,10 +2896,7 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
|
||||
tag
|
||||
}
|
||||
pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
|
||||
let pos = match tag.get("block_pos") {
|
||||
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
|
||||
_ => return None,
|
||||
};
|
||||
let pos = attached_block_pos(tag)?;
|
||||
Some(ItemFrame {
|
||||
id,
|
||||
pos,
|
||||
@@ -4902,7 +4932,10 @@ mod fireball_nbt_tests {
|
||||
assert_eq!(p.kind.id(), id);
|
||||
assert_eq!(
|
||||
p.kind,
|
||||
ProjectileKind::Potion(Box::new(crate::items::ItemStack::new(id, 1)))
|
||||
ProjectileKind::Potion(
|
||||
Box::new(crate::items::ItemStack::new(id, 1)),
|
||||
id == "minecraft:lingering_potion"
|
||||
)
|
||||
);
|
||||
assert!(saved_with_chunk(&p));
|
||||
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
|
||||
@@ -4924,7 +4957,7 @@ mod fireball_nbt_tests {
|
||||
),
|
||||
]);
|
||||
let p = throwable_from_nbt(&harming, None).unwrap();
|
||||
let ProjectileKind::Potion(stack) = &p.kind else {
|
||||
let ProjectileKind::Potion(stack, _) = &p.kind else {
|
||||
panic!("a potion");
|
||||
};
|
||||
assert_eq!(stack.potion(), Some("minecraft:harming"));
|
||||
@@ -4948,6 +4981,67 @@ mod fireball_nbt_tests {
|
||||
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wind_charges_in_flight_are_saved_and_read_back() {
|
||||
// vanilla 26.3 saves them (`Entity.shouldBeSaved` is true; the round
|
||||
// trip of a vanilla world lost all four before this).
|
||||
for id in ["minecraft:wind_charge", "minecraft:breeze_wind_charge"] {
|
||||
let saved = Tag::compound([
|
||||
("id", Tag::String(id.into())),
|
||||
("Pos", doubles(&[4.5, 80.0, 10.5])),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("acceleration_power", Tag::Double(0.1_f64.to_bits())),
|
||||
]);
|
||||
let p = fireball_from_nbt(&saved, None).unwrap();
|
||||
assert_eq!(p.kind.id(), id);
|
||||
assert!(saved_with_chunk(&p), "{id}");
|
||||
let tag = fireball_to_nbt(&p, [1; 4], None, Some(&saved)).unwrap();
|
||||
assert_eq!(kind(&tag).as_deref(), Some(id));
|
||||
assert_eq!(tag.get("NoGravity"), Some(&Tag::Byte(1)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lingering_potion_keeps_its_type_whatever_item_it_flies_as() {
|
||||
// `/summon minecraft:lingering_potion ~ ~ ~ {Item:{id:"minecraft:splash_potion"}}`
|
||||
// is a `ThrownLingeringPotion` in vanilla and stays one when saved.
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String("minecraft:lingering_potion".into())),
|
||||
("Pos", doubles(&[24.5, 80.0, 8.5])),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new("minecraft:splash_potion", 1)),
|
||||
),
|
||||
]);
|
||||
let p = throwable_from_nbt(&tag, None).unwrap();
|
||||
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
|
||||
let back = throwable_to_nbt(&p, [1; 4], None, Some(&tag)).unwrap();
|
||||
assert_eq!(kind(&back).as_deref(), Some("minecraft:lingering_potion"));
|
||||
let item = back.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!(&*item.item, "minecraft:splash_potion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_summoned_painting_or_frame_has_no_block_pos_and_sits_at_its_position() {
|
||||
// BlockAttachedEntity.setPos: the block holding the summon position.
|
||||
let painting = Tag::compound([
|
||||
("id", Tag::String("minecraft:painting".into())),
|
||||
("Pos", doubles(&[28.5, 80.0, 6.5])),
|
||||
("variant", Tag::String("minecraft:kebab".into())),
|
||||
("facing", Tag::Byte(3)),
|
||||
]);
|
||||
assert_eq!(
|
||||
painting_from_nbt(&painting),
|
||||
Some(([28, 80, 6], 3, "minecraft:kebab".to_string()))
|
||||
);
|
||||
let frame = Tag::compound([
|
||||
("id", Tag::String("minecraft:item_frame".into())),
|
||||
("Pos", doubles(&[-0.5, 80.0, 6.5])),
|
||||
("Facing", Tag::Byte(1)),
|
||||
]);
|
||||
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
|
||||
let player = Some([5, 6, 7, 8]);
|
||||
@@ -4981,11 +5075,12 @@ mod fireball_nbt_tests {
|
||||
let resaved = throwable_to_nbt(&away, [1; 4], away.owner_uuid, None).unwrap();
|
||||
assert_eq!(uuid_from_tag(resaved.get("Owner")), player);
|
||||
}
|
||||
// the chunk saves arrows, fireballs and these, and so takes them with it.
|
||||
// the chunk saves arrows, fireballs, wind charges and these, and so takes them with it.
|
||||
let kinds = [
|
||||
(ProjectileKind::SmallFireball, true),
|
||||
(ProjectileKind::Snowball, true),
|
||||
(ProjectileKind::WindCharge, false),
|
||||
(ProjectileKind::WindCharge, true),
|
||||
(ProjectileKind::BreezeWindCharge, true),
|
||||
(ProjectileKind::Trident, false),
|
||||
];
|
||||
for (kind, saved) in kinds {
|
||||
|
||||
@@ -3828,9 +3828,7 @@ impl Game<'_> {
|
||||
stuck: Vec3::ZERO,
|
||||
});
|
||||
// ServerLevel.addFreshEntity.
|
||||
if !self.native_entity_joins(self.next_entity, "minecraft:item", position, false) {
|
||||
self.dropped.pop();
|
||||
} else {
|
||||
if self.native_joins_flush(false, self.next_entity) {
|
||||
self.native_item_spawn(self.next_entity, &item, count, position);
|
||||
}
|
||||
}
|
||||
@@ -3943,8 +3941,8 @@ impl Game<'_> {
|
||||
// THROW and a creative tab's drop included.
|
||||
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
// Player.drop: the drop statistics.
|
||||
self.stats.add("dropped", &thrown.item, thrown.count as i32);
|
||||
self.stats.custom("drop", 1);
|
||||
self.award_stat("dropped", &thrown.item, thrown.count as i32);
|
||||
self.award_custom_stat("drop", 1);
|
||||
let player = self.native_acting_id();
|
||||
self.native_item_dropped(player, &thrown.item, thrown.count);
|
||||
self.throw_from_eyes(thrown, eye, true);
|
||||
@@ -4011,9 +4009,6 @@ impl Game<'_> {
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut removed = vec![false; items.len()];
|
||||
let mut cells = horizon_client::entities::ItemCells::new(&items);
|
||||
let catalog = world_items!(self);
|
||||
let max_stack =
|
||||
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
|
||||
// A LAN or server guest only shows the host's items.
|
||||
let merging = self.remote.is_none();
|
||||
let obstacles = horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
|
||||
@@ -4062,6 +4057,7 @@ impl Game<'_> {
|
||||
no_physics,
|
||||
&self.res.motion().fluids,
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// Entity.handleOnAboveBubbleColumn: the server sends the particles of
|
||||
@@ -4087,6 +4083,9 @@ impl Game<'_> {
|
||||
&& (alive || item.age == 6000 && item.position.y > -128.0)
|
||||
&& item.ticks.is_multiple_of(rate)
|
||||
{
|
||||
let catalog = world_items!(self);
|
||||
let max_stack =
|
||||
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
|
||||
horizon_client::entities::merge_with_neighbours(
|
||||
&mut items,
|
||||
&mut removed,
|
||||
@@ -4096,6 +4095,22 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let item = &mut items[i];
|
||||
let mut alive = alive || (item.age < 6000 && item.position.y > -128.0);
|
||||
if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 {
|
||||
let mut extra_life = 0;
|
||||
self.native_item_expire(item.id, &item.stack, &mut extra_life);
|
||||
if extra_life > 0 {
|
||||
item.age = 6000 - extra_life.min(32766);
|
||||
alive = true;
|
||||
} else if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_item_despawn(
|
||||
item.id,
|
||||
&item.stack.item,
|
||||
item.stack.count as i32,
|
||||
[item.position.x, item.position.y, item.position.z],
|
||||
);
|
||||
}
|
||||
}
|
||||
if !alive || removed[i] {
|
||||
removed[i] = true;
|
||||
cells.remove(i, item.position);
|
||||
@@ -4107,8 +4122,7 @@ impl Game<'_> {
|
||||
self.pack.assets.registry,
|
||||
world_items!(self),
|
||||
);
|
||||
// shortcut: age is the fresh-drop clock; use entity ticks for saved/frozen-age phase parity.
|
||||
if environment.should_play_lava_hurt_sound(item.age) {
|
||||
if environment.should_play_lava_hurt_sound(item.ticks) {
|
||||
let pitch = 2.0 + self.world.random.next_float() * 0.4;
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.burn",
|
||||
@@ -4171,7 +4185,7 @@ impl Game<'_> {
|
||||
let thrower = item.thrower;
|
||||
match self.give(item.stack.clone()) {
|
||||
None => {
|
||||
self.stats.add("picked_up", &kind, before as i32);
|
||||
self.award_stat("picked_up", &kind, before as i32);
|
||||
if let Some(thrower) = thrower {
|
||||
self.trigger_thrown_picked_up_by_player(&item.stack, thrower);
|
||||
}
|
||||
@@ -4190,8 +4204,7 @@ impl Game<'_> {
|
||||
}
|
||||
Some(rest) => {
|
||||
if rest.count < before {
|
||||
self.stats
|
||||
.add("picked_up", &kind, (before - rest.count) as i32);
|
||||
self.award_stat("picked_up", &kind, (before - rest.count) as i32);
|
||||
if let Some(thrower) = thrower {
|
||||
self.trigger_thrown_picked_up_by_player(&rest, thrower);
|
||||
}
|
||||
@@ -4422,7 +4435,7 @@ impl Game<'_> {
|
||||
});
|
||||
// NoteBlock.attack awards the statistic.
|
||||
if note {
|
||||
self.stats.custom("play_noteblock", 1);
|
||||
self.award_custom_stat("play_noteblock", 1);
|
||||
}
|
||||
self.process_level_events()
|
||||
}
|
||||
@@ -5207,7 +5220,7 @@ impl Game<'_> {
|
||||
PlayerEffect::DropItem(_) => {}
|
||||
PlayerEffect::EatCake => {
|
||||
// CakeBlock.eat
|
||||
self.stats.custom("eat_cake_slice", 1);
|
||||
self.award_custom_stat("eat_cake_slice", 1);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
let food = &mut p.health.food;
|
||||
food.food = (food.food + 2).min(20);
|
||||
@@ -5422,7 +5435,7 @@ impl Game<'_> {
|
||||
} else if &*name == "minecraft:ender_chest" {
|
||||
self.open_ender_chest(key);
|
||||
// EnderChestBlock.useWithoutItem
|
||||
self.stats.custom("open_enderchest", 1);
|
||||
self.award_custom_stat("open_enderchest", 1);
|
||||
self.anger_piglins(true);
|
||||
if self.container_opener_event(key, true) {
|
||||
self.container_edge_sound(key, true);
|
||||
@@ -5461,7 +5474,7 @@ impl Game<'_> {
|
||||
_ => "",
|
||||
};
|
||||
if !stat.is_empty() {
|
||||
self.stats.custom(stat, 1);
|
||||
self.award_custom_stat(stat, 1);
|
||||
}
|
||||
// Opening a piglin-guarded container angers the piglins who see it.
|
||||
if self.guarded_by_piglins(&name) {
|
||||
@@ -5538,7 +5551,7 @@ impl Game<'_> {
|
||||
f.stuck = Vec3::ZERO;
|
||||
f.velocity = Vec3::ZERO;
|
||||
}
|
||||
let mut scene = collisions::SceneCollisions {
|
||||
let scene = collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: Some(collisions::PowderSnowSubject {
|
||||
@@ -5557,7 +5570,13 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
// On an error the caller keeps the block as it was before this tick.
|
||||
let movement = scene.resolve_with_context(
|
||||
// Entity.collide: the world border's walls stop it too.
|
||||
let movement = collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &[],
|
||||
border: Some((self.border.geometry(), [f.position.x, f.position.z])),
|
||||
}
|
||||
.resolve_with_context(
|
||||
horizon_client::falling_block::bounds(f.position),
|
||||
wanted,
|
||||
0.0,
|
||||
@@ -5742,7 +5761,7 @@ impl Game<'_> {
|
||||
if wears && per_block > 0 && max > 0 {
|
||||
self.damage_held(per_block);
|
||||
}
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
fn break_block(&mut self, pos: [i32; 3]) -> Result<()> {
|
||||
self.lan_break_pos = Some(pos);
|
||||
@@ -5851,7 +5870,7 @@ impl Game<'_> {
|
||||
.correct_for_drops(held.as_ref(), &facts.name);
|
||||
if harvest {
|
||||
// Block.playerDestroy: the mined statistic, the exhaustion and the drops.
|
||||
self.stats.add("mined", &facts.name, 1);
|
||||
self.award_stat("mined", &facts.name, 1);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.cause_food_exhaustion(0.005);
|
||||
}
|
||||
@@ -6230,7 +6249,7 @@ impl Game<'_> {
|
||||
&& result == horizon_world::level::InteractionResult::Success
|
||||
&& let Some(used) = held.as_ref()
|
||||
{
|
||||
self.stats.add("used", &used.item, 1);
|
||||
self.award_stat("used", &used.item, 1);
|
||||
self.consume_held();
|
||||
}
|
||||
// FlowerPotBlock.useItemOn: the plant is potted, used up, and counted.
|
||||
@@ -6238,8 +6257,8 @@ impl Game<'_> {
|
||||
&& result == horizon_world::level::InteractionResult::Success
|
||||
&& let Some(used) = held.as_ref()
|
||||
{
|
||||
self.stats.add("used", &used.item, 1);
|
||||
self.stats.custom("pot_flower", 1);
|
||||
self.award_stat("used", &used.item, 1);
|
||||
self.award_custom_stat("pot_flower", 1);
|
||||
self.consume_held();
|
||||
if let (Some(player), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
@@ -6258,8 +6277,8 @@ impl Game<'_> {
|
||||
.name
|
||||
.clone();
|
||||
match &*name {
|
||||
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
|
||||
"minecraft:bell" => self.stats.custom("bell_ring", 1),
|
||||
"minecraft:note_block" => self.award_custom_stat("tune_noteblock", 1),
|
||||
"minecraft:bell" => self.award_custom_stat("bell_ring", 1),
|
||||
"minecraft:lever" => self.lever_puff(hit.pos),
|
||||
_ => {}
|
||||
}
|
||||
@@ -6499,7 +6518,7 @@ impl Game<'_> {
|
||||
// BlockItem.useOn: a consumable that could not be placed is used,
|
||||
// which ItemStack.useOn counts as a use of the item.
|
||||
if self.start_eating(&info) {
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
} else {
|
||||
// BlockItem.place's FAIL.
|
||||
self.use_failed = true;
|
||||
@@ -6546,7 +6565,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.process_level_events()?;
|
||||
// BlockItem.place: the used statistic and the placed_block trigger.
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.trigger_block("minecraft:placed_block", target, &stack);
|
||||
self.native_place_block(&stack, target, state);
|
||||
self.check_wither_spawn(target)?;
|
||||
@@ -6640,7 +6659,7 @@ impl Game<'_> {
|
||||
let group = horizon_client::cooldowns::group(stack, None);
|
||||
self.add_cooldown(group, instrument.use_ticks());
|
||||
}
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
fn apply_consume_effect(&mut self, effect: &horizon_client::items::ConsumeEffect) {
|
||||
use horizon_client::items::ConsumeEffect;
|
||||
@@ -6874,7 +6893,7 @@ impl Game<'_> {
|
||||
0.8,
|
||||
pitch,
|
||||
);
|
||||
self.stats.add("used", &item, 1);
|
||||
self.award_stat("used", &item, 1);
|
||||
}
|
||||
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
|
||||
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
|
||||
@@ -7277,6 +7296,8 @@ impl Game<'_> {
|
||||
/// What the tick runs after the local player's own update: effects, the
|
||||
/// world's entities and blocks, and the network hub.
|
||||
fn tick_world(&mut self, running: bool) -> Result<()> {
|
||||
// Entities added since the last tick join the level before it ticks.
|
||||
self.native_joins_flush(false, 0);
|
||||
self.share_spawn_conditions();
|
||||
horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes"));
|
||||
self.update_chunk_tickets();
|
||||
@@ -8081,6 +8102,10 @@ impl Game<'_> {
|
||||
self.settings.file.bool("hideSplashTexts"),
|
||||
);
|
||||
}
|
||||
// ScreenEvent.Render.Post over the title's own screens: a mod draws
|
||||
// over them here (a pack browser over Create New World, say), and the
|
||||
// toasts still come after, as over any screen.
|
||||
self.fire_title_screen_render_post(&mut batch);
|
||||
self.draw_toasts(&mut batch, false);
|
||||
self.draw_tutorial_toasts(&mut batch, false);
|
||||
self.draw_pack_toasts(&mut batch, false);
|
||||
@@ -8426,6 +8451,21 @@ impl Game<'_> {
|
||||
let view = self.screen_view(batch, screen);
|
||||
self.native_screen_render_post(batch, &view);
|
||||
}
|
||||
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the title's
|
||||
/// own screen (`title`, `world_select`, `world_create`, `multiplayer`…),
|
||||
/// once a frame, after it has drawn and before the toasts. Not when a
|
||||
/// screen of the game's own is over the title (Options draws and fires
|
||||
/// its own), nor while a dialog, a prompt or a connect or load hides it.
|
||||
/// Client only, read-only: what a mod draws is its own.
|
||||
fn fire_title_screen_render_post(&self, batch: &mut HudBatch) {
|
||||
if self.screen != Screen::Playing || self.screen_over_playing() || self.remote_loading() {
|
||||
return;
|
||||
}
|
||||
let Some(view) = self.title_screen_view(batch) else {
|
||||
return;
|
||||
};
|
||||
self.native_screen_render_post(batch, &view);
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks`
|
||||
/// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`,
|
||||
/// as the screen's constructor and `containerTick` do.
|
||||
@@ -8740,9 +8780,15 @@ impl Game<'_> {
|
||||
/// (special ones never do).
|
||||
fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
|
||||
for (recipe, ingredients) in crafts {
|
||||
self.trigger_recipe_crafted(recipe, ingredients);
|
||||
// The whole id: the advancements' `recipe_crafted` criteria name
|
||||
// `minecraft:x`, and the crafting copy names the recipe bare.
|
||||
let recipe = horizon_client::crafting::qualified(recipe);
|
||||
self.trigger_recipe_crafted(&recipe, ingredients);
|
||||
}
|
||||
let recipes: Vec<String> = crafts.iter().map(|(r, _)| r.clone()).collect();
|
||||
let recipes: Vec<String> = crafts
|
||||
.iter()
|
||||
.map(|(r, _)| horizon_client::crafting::qualified(r))
|
||||
.collect();
|
||||
let index = &world_stations!(self).recipe_index;
|
||||
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
@@ -10297,7 +10343,7 @@ impl Game<'_> {
|
||||
// (native_item_crafted).
|
||||
let player = self.native_acting_id();
|
||||
self.native_item_crafted(player, &format!("minecraft:{recipe}"), &taken);
|
||||
self.stats.add("crafted", &made.0, made.1 as i32);
|
||||
self.award_stat("crafted", &made.0, made.1 as i32);
|
||||
self.award_crafted(&[(format!("minecraft:{recipe}"), ingredients)]);
|
||||
if !shift || crafted >= 64 {
|
||||
break;
|
||||
@@ -11365,8 +11411,7 @@ impl Game<'_> {
|
||||
mob.kind.width,
|
||||
mob.kind.height,
|
||||
);
|
||||
self.stats
|
||||
.custom("damage_dealt", (actual * 10.0).round() as i32);
|
||||
self.award_custom_stat("damage_dealt", (actual * 10.0).round() as i32);
|
||||
if crit {
|
||||
self.critical_hit(
|
||||
self.lan_acting,
|
||||
@@ -12080,7 +12125,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.close_container();
|
||||
if self.remote.is_none() {
|
||||
self.stats.custom("leave_game", 1);
|
||||
self.award_custom_stat("leave_game", 1);
|
||||
}
|
||||
self.lan_close(false);
|
||||
self.free_player_beds()?;
|
||||
@@ -12482,7 +12527,7 @@ impl Game<'_> {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(stat) = result.stat {
|
||||
self.stats.custom(stat, 1);
|
||||
self.award_custom_stat(stat, 1);
|
||||
}
|
||||
if let Some(block) = result.block
|
||||
&& let Ok(new) = registry.resolve(&block)
|
||||
@@ -12617,7 +12662,7 @@ impl Game<'_> {
|
||||
self.audio.play(&sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
// ItemStack.useOn: a use that acted counts as one of the item.
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.damage_held(damage);
|
||||
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), Some(new));
|
||||
Ok(true)
|
||||
@@ -12714,7 +12759,7 @@ impl Game<'_> {
|
||||
let broken = stack.item.clone();
|
||||
let gone = stack.clone();
|
||||
*cell = None;
|
||||
self.stats.add("broken", &broken, 1);
|
||||
self.award_stat("broken", &broken, 1);
|
||||
// LivingEntity.onEquippedItemBroken: the sound and the crumbs.
|
||||
self.player_equipment_broke(
|
||||
horizon_world::player_inventory::EquipmentSlot::MainHand,
|
||||
@@ -12805,7 +12850,7 @@ impl Game<'_> {
|
||||
self.process_level_events()?;
|
||||
// `player.awardStat(Stats.ITEM_USED.get(this))`,
|
||||
// `getPickupSound` and `level.gameEvent(player, FLUID_PICKUP)`.
|
||||
self.stats.add("used", "minecraft:bucket", 1);
|
||||
self.award_stat("used", "minecraft:bucket", 1);
|
||||
let sound = if filled == "minecraft:lava_bucket" {
|
||||
"minecraft:item.bucket.fill_lava"
|
||||
} else {
|
||||
@@ -12925,7 +12970,7 @@ impl Game<'_> {
|
||||
self.sculk_actor(),
|
||||
Some(state),
|
||||
);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
@@ -13126,9 +13171,7 @@ impl Game<'_> {
|
||||
health: 5,
|
||||
});
|
||||
// ServerLevel.addFreshEntity.
|
||||
if !self.native_entity_joins(self.next_entity, "minecraft:experience_orb", at, false) {
|
||||
self.orbs.pop();
|
||||
}
|
||||
self.native_joins_flush(false, 0);
|
||||
}
|
||||
fn can_place_used_fire(&mut self, target: [i32; 3]) -> bool {
|
||||
let pos = BlockPos::new(target[0], target[1], target[2]);
|
||||
@@ -13247,6 +13290,7 @@ impl Game<'_> {
|
||||
target,
|
||||
self.remote.is_none(),
|
||||
&mut next_float,
|
||||
Some(self.border.geometry()),
|
||||
)
|
||||
});
|
||||
self.orbs = orbs;
|
||||
@@ -13324,9 +13368,12 @@ impl Game<'_> {
|
||||
/// sound every fifth level (at most every 100 ticks).
|
||||
pub(super) fn give_orb_points(&mut self, gained: u32) {
|
||||
let at = self.eye_position();
|
||||
let old_level = self.experience.level;
|
||||
let gave = self.experience.give(gained as i32);
|
||||
self.native_player_level_changed(old_level);
|
||||
// Player.giveExperienceLevels runs per level, so the first multiple of 5
|
||||
// crossed plays the sound (and starts the cooldown for the rest).
|
||||
if let Some(level) = self.experience.give(gained as i32)
|
||||
if let Some(level) = gave
|
||||
&& self.ticks > self.last_level_up + 100
|
||||
{
|
||||
self.last_level_up = self.ticks;
|
||||
@@ -14128,7 +14175,7 @@ impl Game<'_> {
|
||||
self.snap_player_model_pitch();
|
||||
}
|
||||
self.occupy_bed(head);
|
||||
self.stats.custom("sleep_in_bed", 1);
|
||||
self.award_custom_stat("sleep_in_bed", 1);
|
||||
self.trigger_player("minecraft:slept_in_bed");
|
||||
self.stats.reset("custom", "minecraft:time_since_rest");
|
||||
}
|
||||
@@ -14810,10 +14857,19 @@ impl Game<'_> {
|
||||
if self.remote.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// `DedicatedServer.saveAllChunks` tells the management protocol's clients when a save starts and when
|
||||
// it ends without failing, for every save: the command, the autosave, the pause.
|
||||
let dedicated = self.options.dedicated.is_some();
|
||||
if dedicated {
|
||||
dedicated::events::record_server("saving", serde_json::json!({}));
|
||||
}
|
||||
self.save_level(wait)?;
|
||||
self.save_parked_levels(wait)?;
|
||||
self.save_game_metadata()?;
|
||||
self.native_world_saved(wait);
|
||||
if dedicated {
|
||||
dedicated::events::record_server("saved", serde_json::json!({}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn save_game_metadata(&mut self) -> Result<()> {
|
||||
@@ -14829,12 +14885,17 @@ impl Game<'_> {
|
||||
self.save_schedule();
|
||||
self.save_warden_tracker();
|
||||
self.sync_level();
|
||||
self.store_owner();
|
||||
let stored_owner = self.store_owner();
|
||||
write_level_for_role(
|
||||
&mut self.level,
|
||||
&self.world_root,
|
||||
self.options.execution_role,
|
||||
)?;
|
||||
// PlayerEvent.SaveToFile for the owner, once level.dat is written.
|
||||
if let Some((uuid, name)) = stored_owner {
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
self.native_player_save_to_file(uuid, &name, &directory);
|
||||
}
|
||||
// PlayerList.saveAll: every placed player saves with the server, to
|
||||
// their own file (`PlayerDataStorage.save`); the owner is also in the
|
||||
// file level.dat names, which is the one it loads from
|
||||
@@ -14846,9 +14907,11 @@ impl Game<'_> {
|
||||
// The vehicle tree goes into a player's file when they leave
|
||||
// (`take_parent_vehicle`); here it stays in the chunk saves.
|
||||
let mut host = host;
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
self.guest_attachments_into(g);
|
||||
horizon_client::lan::playerdata::save(g, &self.world_root);
|
||||
self.native_player_save_to_file(g.uuid, &g.name, &directory);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
@@ -16779,14 +16842,19 @@ impl Game<'_> {
|
||||
// the screen that is open then (`ScreenEvent.CharacterTyped.Pre`).
|
||||
// Options keeps its text on the key: its own key path routes a key's
|
||||
// text between its boxes and its key-bind capture, and no key that
|
||||
// carries text closes that screen or moves its focus.
|
||||
// carries text closes that screen or moves its focus. The title's
|
||||
// screens keep theirs for the same reason: their key path types into
|
||||
// the world name, the seed and a server's address.
|
||||
let mut typed: Option<String> = None;
|
||||
if let WindowEvent::KeyboardInput {
|
||||
event: key_event, ..
|
||||
} = &mut event
|
||||
&& key_event.state == ElementState::Pressed
|
||||
{
|
||||
if self.screen_hooks_open() && self.screen != Screen::Options {
|
||||
if self.screen_hooks_open()
|
||||
&& self.screen != Screen::Options
|
||||
&& !control::title_owns_text(&self.screen, self.title.is_some())
|
||||
{
|
||||
typed = key_event.text.take().map(|text| text.to_string());
|
||||
}
|
||||
let canceled = match key_event.physical_key {
|
||||
@@ -17764,12 +17832,19 @@ impl Game<'_> {
|
||||
result => result?,
|
||||
};
|
||||
self.apply_cursor_icon();
|
||||
// The frame's spans, taken every frame they are recorded so they never
|
||||
// pile up: the slow-frame line's, and the session's (`perf.frame`).
|
||||
let frame_spans = if horizon_client::spans::recording_here() {
|
||||
horizon_client::spans::take_raw()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0;
|
||||
// Spans timed between frames (`idle`) come first.
|
||||
let spans = horizon_client::spans::take();
|
||||
let spans = horizon_client::spans::merge(&frame_spans);
|
||||
let between = between.as_secs_f32() * 1000.0;
|
||||
if cpu > threshold || between > threshold {
|
||||
// The render thread's own CPU time in the frame:
|
||||
@@ -17873,7 +17948,8 @@ impl Game<'_> {
|
||||
if self.title.is_some() {
|
||||
self.perf.title_shown();
|
||||
} else if self.screen == Screen::Playing && self.player.is_some() {
|
||||
self.perf.frame(elapsed.as_secs_f32() * 1000.0);
|
||||
self.perf
|
||||
.frame(elapsed.as_secs_f32() * 1000.0, &frame_spans);
|
||||
if startup::filling() {
|
||||
let mut fill: Vec<(&'static str, usize)> =
|
||||
self.streaming.stages().to_vec();
|
||||
@@ -18978,6 +19054,7 @@ impl Game<'_> {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.reload_resources(sync);
|
||||
}
|
||||
self.run_native_advancement_grants();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the players are not sent the reloaded data: {e}")
|
||||
@@ -20706,6 +20783,14 @@ pub fn run_with() -> Result<()> {
|
||||
options.spikes = Some(dedicated::SLOW_TICK_MS);
|
||||
}
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
// A player who allows performance statistics also says what their slow frames spent their
|
||||
// time in: this thread, which draws the frames, times its steps (wall time only).
|
||||
if options.execution_role.is_client()
|
||||
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
|
||||
.allows(horizon_telemetry::Kind::Perf)
|
||||
{
|
||||
horizon_client::spans::record_here(true);
|
||||
}
|
||||
let vanilla_root = options.resources.clone();
|
||||
if let Some(error) = missing_resources(&vanilla_root) {
|
||||
// The game build started by hand (the release's zip), not by the
|
||||
|
||||
@@ -611,13 +611,45 @@ impl Game<'_> {
|
||||
for (ai, ci) in hits {
|
||||
let a = &tree.list[ai];
|
||||
let was = self.adv.progress.done(a);
|
||||
self.adv.progress.grant(&a.id, &a.criteria[ci].name, &date);
|
||||
let criterion = &a.criteria[ci].name;
|
||||
if self.adv.progress.grant(&a.id, criterion, &date) {
|
||||
self.advancement_progress(&a.id, criterion, true);
|
||||
if let (Some(player), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_advancement_criterion_grant(player, &name, &a.id, criterion);
|
||||
}
|
||||
}
|
||||
self.progress_changed(ai);
|
||||
if !was && self.adv.progress.done(a) {
|
||||
self.award(ai);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PlayerAdvancements.award` / `revoke` after a criterion's progress changed.
|
||||
fn advancement_progress(&mut self, advancement: &str, criterion: &str, granted: bool) {
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_advancement_progress(player, &name, advancement, criterion, granted);
|
||||
}
|
||||
}
|
||||
/// The criteria `CriteriaTriggers.TICK` granted network players in the hub
|
||||
/// (their first tick and a reload), told to the mods.
|
||||
pub(super) fn run_native_advancement_grants(&mut self) {
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
for batch in std::mem::take(&mut host.hub.native_advancement_grants) {
|
||||
for grant in batch.grants {
|
||||
self.native_player_advancement_progress(
|
||||
batch.player,
|
||||
&batch.name,
|
||||
&grant.advancement,
|
||||
&grant.criterion,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PlayerAdvancements.progressChanged`, kept for a network player's client.
|
||||
fn progress_changed(&mut self, index: usize) {
|
||||
if self.lan_acting.is_some() {
|
||||
@@ -692,7 +724,7 @@ impl Game<'_> {
|
||||
match result {
|
||||
Ok(unlocked) => {
|
||||
self.recipe_unlocked(&unlocked);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
@@ -713,7 +745,9 @@ impl Game<'_> {
|
||||
}
|
||||
// AdvancementRewards.grant
|
||||
if a.rewards.experience > 0 {
|
||||
let old_level = self.experience.level;
|
||||
self.experience.give(a.rewards.experience);
|
||||
self.native_player_level_changed(old_level);
|
||||
}
|
||||
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
|
||||
let rewards: Vec<String> = a
|
||||
@@ -1962,6 +1996,7 @@ impl Game<'_> {
|
||||
let was = self.adv.progress.done(a);
|
||||
if self.adv.progress.grant(&a.id, c, &date) {
|
||||
changed = true;
|
||||
self.advancement_progress(&a.id, c, true);
|
||||
self.progress_changed(index);
|
||||
if !was && self.adv.progress.done(a) {
|
||||
self.award(index);
|
||||
@@ -1976,16 +2011,25 @@ impl Game<'_> {
|
||||
let a = &tree.list[index];
|
||||
self.progress_changed(index);
|
||||
match criterion {
|
||||
Some(c) => self.adv.progress.revoke(&a.id, c),
|
||||
Some(c) => self.revoke_criterion(&a.id, c),
|
||||
None => {
|
||||
let mut any = false;
|
||||
for c in &a.criteria {
|
||||
any |= self.adv.progress.revoke(&a.id, &c.name);
|
||||
any |= self.revoke_criterion(&a.id, &c.name);
|
||||
}
|
||||
any
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PlayerAdvancements.revoke(holder, criterion)`: the progress change and
|
||||
/// the hook for it.
|
||||
fn revoke_criterion(&mut self, advancement: &str, criterion: &str) -> bool {
|
||||
let revoked = self.adv.progress.revoke(advancement, criterion);
|
||||
if revoked {
|
||||
self.advancement_progress(advancement, criterion, false);
|
||||
}
|
||||
revoked
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ClientAdvancements.update` (ClientboundUpdateAdvancementsPacket):
|
||||
|
||||
@@ -272,7 +272,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
|
||||
}
|
||||
self.stats.add("used", &held.item, 1);
|
||||
self.award_stat("used", &held.item, 1);
|
||||
let smokey = beehive::smokey(&self.mob_world_base(&[]), pos);
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !smokey
|
||||
|
||||
@@ -1121,6 +1121,16 @@ impl Game<'_> {
|
||||
);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_vehicle_create(
|
||||
self.boats.last().unwrap().entity_type().as_str(),
|
||||
[
|
||||
self.boats.last().unwrap().position.x,
|
||||
self.boats.last().unwrap().position.y,
|
||||
self.boats.last().unwrap().position.z,
|
||||
],
|
||||
);
|
||||
}
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
@@ -1345,18 +1355,24 @@ impl Game<'_> {
|
||||
/// reason that destroys the boat (DISCARDED included), while the boat item
|
||||
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
|
||||
/// a creative hit discards it. True when the boat was removed.
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, mut damage: f32, creative: bool) -> bool {
|
||||
let mut cancel = false;
|
||||
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
|
||||
if cancel {
|
||||
return false;
|
||||
}
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_dir = -b.hurt_dir;
|
||||
b.hurt_time = 10;
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
self.native_vehicle_destroy("boat", at);
|
||||
self.eject_boat_mobs(i, true);
|
||||
let id = self.boats[i].id;
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
// AbstractBoat.remove: a destroyed boat drops its lead.
|
||||
self.boat_removed(&b);
|
||||
// `AbstractChestBoat.remove` spills the chest through
|
||||
|
||||
@@ -873,7 +873,7 @@ impl Game<'_> {
|
||||
let Some(book) = &l.book else { return false };
|
||||
let pages = view_pages(book.data());
|
||||
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
|
||||
self.stats.custom("interact_with_lectern", 1);
|
||||
self.award_custom_stat("interact_with_lectern", 1);
|
||||
self.open_book(BookScreen::View {
|
||||
pages,
|
||||
page,
|
||||
|
||||
@@ -51,7 +51,7 @@ impl Game<'_> {
|
||||
self.native_campfire_start(pos, &held.item);
|
||||
// ItemStack.consumeAndReturn: creative keeps the item.
|
||||
self.consume_held();
|
||||
self.stats.custom("interact_with_campfire", 1);
|
||||
self.award_custom_stat("interact_with_campfire", 1);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
pos,
|
||||
|
||||
@@ -381,6 +381,18 @@ fn templates_under(root: &Path) -> Vec<String> {
|
||||
|
||||
/// The `namespace:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
|
||||
let suffix = format!(".{extension}");
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(root) {
|
||||
let mut out: Vec<String> = files
|
||||
.iter()
|
||||
.filter_map(|file| file.strip_suffix(&suffix))
|
||||
.filter(|path| !path.is_empty() && !path.ends_with('/'))
|
||||
.map(|path| format!("{namespace}:{path}"))
|
||||
.collect();
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_owned()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
|
||||
@@ -66,7 +66,7 @@ impl Game<'_> {
|
||||
{
|
||||
// addBook: one book goes in (a creative player keeps theirs).
|
||||
let book = book.clone();
|
||||
self.stats.add("used", &book.item, 1);
|
||||
self.award_stat("used", &book.item, 1);
|
||||
let sound = if &*book.item == "minecraft:enchanted_book" {
|
||||
"minecraft:block.chiseled_bookshelf.insert.enchanted"
|
||||
} else {
|
||||
|
||||
@@ -265,9 +265,9 @@ impl Game<'_> {
|
||||
// hit), in tenths.
|
||||
MotionEvent::Health(HealthEvent::Statistic { name, amount }) => {
|
||||
match *name {
|
||||
"resisted" => self.stats.custom("damage_resisted", *amount),
|
||||
"absorbed" => self.stats.custom("damage_absorbed", *amount),
|
||||
"blocked" => self.stats.custom("damage_blocked_by_shield", *amount),
|
||||
"resisted" => self.award_custom_stat("damage_resisted", *amount),
|
||||
"absorbed" => self.award_custom_stat("damage_absorbed", *amount),
|
||||
"blocked" => self.award_custom_stat("damage_blocked_by_shield", *amount),
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
@@ -308,8 +308,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// Player.actuallyHurt: damage_taken in tenths.
|
||||
self.stats
|
||||
.custom("damage_taken", (amount * 10.0).round() as i32);
|
||||
self.award_custom_stat("damage_taken", (amount * 10.0).round() as i32);
|
||||
self.combat.entries.push(Entry {
|
||||
damage_type: name,
|
||||
damage: amount,
|
||||
|
||||
@@ -159,6 +159,17 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
|
||||
i32::try_from(id).ok()
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `native_player_level_change` for a level that was `from` before an experience change.
|
||||
pub(super) fn native_player_level_changed(&mut self, from: i32) {
|
||||
let to = self.experience.level;
|
||||
if from != to
|
||||
&& !horizon_client::mod_api::mods().is_empty()
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_level_change(player, &name, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
pub(super) fn run_command(&mut self, command: &str) -> bool {
|
||||
if self.client_mod_command(command) {
|
||||
@@ -2175,7 +2186,8 @@ impl Game<'_> {
|
||||
if self.ui.difficulty == 0 && t.category == "monster" {
|
||||
return Err(err("commands.summon.failed.peaceful", &[]));
|
||||
}
|
||||
self.spawn_mob_spawned(&short, pos);
|
||||
self.spawn_mob_spawned(&short, pos)
|
||||
.ok_or_else(|| err("commands.summon.failed.uuid", &[]))?;
|
||||
name = Text::translate(&entity_name_key(&kind), &[]);
|
||||
} else {
|
||||
match short.as_str() {
|
||||
@@ -2202,13 +2214,7 @@ impl Game<'_> {
|
||||
health: 5,
|
||||
});
|
||||
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
|
||||
if !self.native_entity_joins(
|
||||
self.next_entity,
|
||||
"minecraft:experience_orb",
|
||||
pos,
|
||||
false,
|
||||
) {
|
||||
self.orbs.pop();
|
||||
if !self.native_joins_flush(false, self.next_entity) {
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
}
|
||||
name = Text::translate("entity.minecraft.experience_orb", &[]);
|
||||
@@ -2256,7 +2262,11 @@ impl Game<'_> {
|
||||
let empty = horizon_world::nbt::Tag::Compound(Default::default());
|
||||
cart.spawner = Some(Box::new(Spawner::load(&empty)));
|
||||
}
|
||||
let id = cart.id;
|
||||
self.carts.push(cart);
|
||||
if !self.native_joins_flush(false, id) {
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
}
|
||||
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
|
||||
}
|
||||
// SummonCommand.createEntity: any other type from an empty compound.
|
||||
@@ -2737,6 +2747,7 @@ impl Game<'_> {
|
||||
Ok(count)
|
||||
}
|
||||
/// Player.giveExperiencePoints including negative amounts.
|
||||
/// The caller reports the level change to the mods.
|
||||
fn give_points(&mut self, amount: i32) {
|
||||
let e = &mut self.experience;
|
||||
e.progress += amount as f32 / horizon_client::entities::Experience::needed(e.level) as f32;
|
||||
@@ -2922,6 +2933,7 @@ impl Game<'_> {
|
||||
/// ExperienceCommand's add and set on the player in place: false when
|
||||
/// the points to set do not fit the level.
|
||||
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
|
||||
let old_level = self.experience.level;
|
||||
if add {
|
||||
if levels {
|
||||
self.experience.level = (self.experience.level + amount).max(0);
|
||||
@@ -2940,8 +2952,30 @@ impl Game<'_> {
|
||||
}
|
||||
self.experience.progress = amount as f32 / needed as f32;
|
||||
}
|
||||
self.native_player_level_changed(old_level);
|
||||
true
|
||||
}
|
||||
/// `GameRules.set` as the `/gamerule` command and the management protocol call it: the rule changes, the
|
||||
/// world follows it, and a dedicated server tells the event stream (`MinecraftServer.onGameRuleChanged`
|
||||
/// tells the management protocol's clients).
|
||||
pub(super) fn set_game_rule(&mut self, rule: &gamerules::Rule, value: Value) {
|
||||
self.ui.rules.set(rule.name, value);
|
||||
self.sync_rules();
|
||||
if self.options.dedicated.is_some() {
|
||||
let (value_type, json) = match value {
|
||||
Value::Bool(b) => ("boolean", serde_json::json!(b)),
|
||||
Value::Int(i) => ("integer", serde_json::json!(i)),
|
||||
};
|
||||
super::dedicated::events::record_server(
|
||||
"gamerule",
|
||||
serde_json::json!({
|
||||
"key": format!("minecraft:{}", rule.name),
|
||||
"valueType": value_type,
|
||||
"value": json,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
|
||||
let Some(name) = ctx.literals.get(1).cloned() else {
|
||||
return Ok(0);
|
||||
@@ -2964,8 +2998,7 @@ impl Game<'_> {
|
||||
if current == value {
|
||||
return Err(err("commands.gamerule.not_set", &[&rule.name, &value]));
|
||||
}
|
||||
self.ui.rules.set(rule.name, value);
|
||||
self.sync_rules();
|
||||
self.set_game_rule(rule, value);
|
||||
self.feedback(Text::tr("commands.gamerule.set", &[&rule.name, &value]));
|
||||
Ok(match value {
|
||||
Value::Bool(b) => i32::from(b),
|
||||
|
||||
@@ -276,7 +276,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(in crate::game) fn tick_worldborder(&mut self) {
|
||||
let remaining = self.border.remaining_ticks();
|
||||
self.border.tick();
|
||||
if remaining > 0
|
||||
&& self.border.remaining_ticks() == 0
|
||||
&& (self.serving.is_some() || self.options.dedicated.is_some())
|
||||
{
|
||||
self.native_world_border_bounds_change_finish();
|
||||
}
|
||||
self.player_border();
|
||||
self.border_damage();
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ impl Game<'_> {
|
||||
if level < 7 {
|
||||
self.composter_add(pos, &layers);
|
||||
self.native_compost_item(pos, &stack.item);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
}
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
|
||||
@@ -49,7 +49,7 @@ impl Game<'_> {
|
||||
) {
|
||||
self.emit_consume(consumable, stack, 16);
|
||||
// ServerPlayer: the used statistic and the consume_item trigger.
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.trigger_item("minecraft:consume_item", stack);
|
||||
// FoodProperties.onConsume, the player's one ConsumableListener.
|
||||
if let Some((nutrition, saturation)) = info.food {
|
||||
|
||||
@@ -233,6 +233,14 @@ impl Game<'_> {
|
||||
self.sign_input(None, Some(text));
|
||||
return Ok(());
|
||||
}
|
||||
// The title's screens: the world name, the seed, a server's address.
|
||||
// A screen opened over the title (Options, say) keeps its own text.
|
||||
if title_owns_text(&self.screen, self.title.is_some())
|
||||
&& let Some(title) = &mut self.title
|
||||
{
|
||||
title.type_text(text);
|
||||
return Ok(());
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
@@ -1459,10 +1467,24 @@ fn key_code(name: &str) -> Option<KeyCode> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The title's own screens (not one opened over it, as Options is) take the
|
||||
/// text: the world name, the seed, a server's address.
|
||||
pub(super) fn title_owns_text(screen: &Screen, title_up: bool) -> bool {
|
||||
title_up && *screen == Screen::Playing
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_title_takes_text_only_on_its_own_screens() {
|
||||
assert!(title_owns_text(&Screen::Playing, true));
|
||||
assert!(!title_owns_text(&Screen::Playing, false));
|
||||
assert!(!title_owns_text(&Screen::Options, true));
|
||||
assert!(!title_owns_text(&Screen::Chat, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_names_the_protocol_the_release_and_the_command_groups() {
|
||||
let reply = hello();
|
||||
|
||||
@@ -263,6 +263,10 @@ pub(super) fn named_mob_pickable(
|
||||
alive && Some(id) != riding && Some(id) != camera
|
||||
}
|
||||
|
||||
/// What an entity drew on the GPU path: the skin frame's mark before its draws, and their
|
||||
/// camera-relative bounds (`cull_skinned`).
|
||||
pub(super) type Skinned = Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn player_view(&self) -> Option<PlayerView> {
|
||||
let p = self.player.as_ref()?;
|
||||
@@ -427,6 +431,9 @@ impl Game<'_> {
|
||||
let id = mob.id;
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
if !self.mobs.iter().any(|m| m.id == id) {
|
||||
return None;
|
||||
}
|
||||
self.enchant_spawn_equipment(id);
|
||||
self.halloween_spawn_head(id);
|
||||
if !persistent && !scripted {
|
||||
@@ -1290,9 +1297,9 @@ impl Game<'_> {
|
||||
// entity, so a kill by a mob (a tame wolf credited to its owner)
|
||||
// leaves it out; ServerPlayer.awardKillScore counts the rest.
|
||||
if !killed_by_mob(m) {
|
||||
self.stats.add("killed", &m.kind.id, 1);
|
||||
self.award_stat("killed", &m.kind.id, 1);
|
||||
}
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.award_custom_stat("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(m.id));
|
||||
let view = self.mob_entity(m);
|
||||
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
|
||||
@@ -2103,6 +2110,34 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::PufferfishSting {
|
||||
mob,
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
poison_ticks,
|
||||
} => {
|
||||
// Pufferfish.touch: the poison and
|
||||
// `playSound(PUFFER_FISH_STING, 1, 1)` sit inside the
|
||||
// `hurtServer` branch, so only a hit that landed poisons
|
||||
// and stings, at the fish's own position as
|
||||
// `Entity.playSound` puts it, and not when it is silent.
|
||||
let landed = self.mob_hits_mob(target, amount, source);
|
||||
if landed && let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
t.add_effect("minecraft:poison", poison_ticks, 0);
|
||||
}
|
||||
if landed && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if !m.silent {
|
||||
self.handle_mob_events(vec![MobEvent::Sound(
|
||||
"entity.puffer_fish.sting",
|
||||
at,
|
||||
1.0,
|
||||
1.0,
|
||||
)])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::SonicBoom {
|
||||
target,
|
||||
attacker,
|
||||
@@ -2277,21 +2312,29 @@ impl Game<'_> {
|
||||
thrown.thrower = Some(thrower);
|
||||
}
|
||||
}
|
||||
MobEvent::DropItem { item, count, at } => self.spawn_item(
|
||||
ItemStack {
|
||||
item,
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
MobEvent::DropItem {
|
||||
mob,
|
||||
item,
|
||||
count,
|
||||
at,
|
||||
// The ItemEntity constructor's motion, from the entity's
|
||||
// own random (not the level's), so spawnAtLocation drops
|
||||
// (a fox's berries, a panda's held food, a goat's horn)
|
||||
// land apart.
|
||||
dispenser::new_item_motion(),
|
||||
10,
|
||||
),
|
||||
} => {
|
||||
self.native_entity_drop_item(mob, &item, count as i32, at);
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
item,
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
at,
|
||||
// The ItemEntity constructor's motion, from the entity's
|
||||
// own random (not the level's), so spawnAtLocation drops
|
||||
// (a fox's berries, a panda's held food, a goat's horn)
|
||||
// land apart.
|
||||
dispenser::new_item_motion(),
|
||||
10,
|
||||
);
|
||||
}
|
||||
MobEvent::DropGoatHorn {
|
||||
mob,
|
||||
screaming,
|
||||
@@ -2946,7 +2989,7 @@ impl Game<'_> {
|
||||
super::commands::Target::Guest,
|
||||
);
|
||||
self.for_player(who, |g| {
|
||||
g.stats.custom("animals_bred", 1);
|
||||
g.award_custom_stat("animals_bred", 1);
|
||||
g.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
|
||||
});
|
||||
}
|
||||
@@ -3883,7 +3926,7 @@ impl Game<'_> {
|
||||
if rang {
|
||||
let _ = self.process_level_events();
|
||||
if p.owner == Some(Who::Player) {
|
||||
self.stats.custom("bell_ring", 1);
|
||||
self.award_custom_stat("bell_ring", 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4101,7 +4144,7 @@ impl Game<'_> {
|
||||
let _ = self.process_level_events();
|
||||
self.native_target_hit(self.dim.id(), pos, strength);
|
||||
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
|
||||
self.stats.custom("target_hit", 1);
|
||||
self.award_custom_stat("target_hit", 1);
|
||||
let kind = if p
|
||||
.arrow
|
||||
.as_ref()
|
||||
@@ -4734,7 +4777,7 @@ impl Game<'_> {
|
||||
// flies as: the splash happens where the hit was, a lingering
|
||||
// potion's cloud sits at the mob it hit (ThrownLingeringPotion),
|
||||
// and the potion that lands names the hurt it deals.
|
||||
(ProjectileKind::Potion(stack), hit) => {
|
||||
(ProjectileKind::Potion(stack, _), hit) => {
|
||||
let block = match hit {
|
||||
Hit::Block { pos, face, .. } => Some((
|
||||
pos,
|
||||
@@ -6111,7 +6154,7 @@ impl Game<'_> {
|
||||
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
|
||||
if done {
|
||||
self.swing_arm();
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
}
|
||||
return Ok(true);
|
||||
@@ -6142,7 +6185,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_spawn_egg_entity_data(id, stack);
|
||||
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
&& let Some(s) = self.held_mut().as_mut()
|
||||
@@ -6284,7 +6327,7 @@ impl Game<'_> {
|
||||
&self,
|
||||
out: &mut Vec<Vertex>,
|
||||
entity_start: usize,
|
||||
skinned: Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>,
|
||||
skinned: Skinned,
|
||||
) {
|
||||
let corners = skinned.and_then(|(_, bounds)| bounds);
|
||||
if let Some((lo, hi)) = corners {
|
||||
@@ -6536,7 +6579,9 @@ impl Game<'_> {
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
if layers {
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
// Held items join the GPU path's draws unless an outline reads their vertices.
|
||||
let skin = (skinning && outline.is_none()).then_some(&mut skinned);
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ impl Game<'_> {
|
||||
};
|
||||
let stack = stack.clone();
|
||||
self.block_entity_event(pos, 1, WOBBLE_POSITIVE);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
// consumeAndReturn(1): one item goes in (a creative player keeps theirs).
|
||||
let inserted = match &mut pot_item {
|
||||
Some(p) => {
|
||||
|
||||
@@ -15,8 +15,8 @@ use horizon_client::{
|
||||
properties::{GAME_MODES, ServerProperties, SystemEntropy},
|
||||
user_cache::UserCache,
|
||||
user_lists::{
|
||||
BanEntry, BanList, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry, WhiteList,
|
||||
format_date,
|
||||
BanEntry, BanList, Change, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry,
|
||||
WhiteList, format_date,
|
||||
},
|
||||
},
|
||||
text::Text,
|
||||
@@ -408,6 +408,19 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The event a change of a list is (`operators_added`, `bans_removed`...), and the entry it is about.
|
||||
fn listed<E>(list: &str, change: Change<E>) -> (String, E) {
|
||||
match change {
|
||||
Change::Added(e) => (format!("{list}_added"), e),
|
||||
Change::Removed(e) => (format!("{list}_removed"), e),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a ban says besides who it is for: the reason and the source, and when it ends (milliseconds, or none).
|
||||
fn ban_fields<T>(ban: &BanEntry<T>) -> serde_json::Value {
|
||||
serde_json::json!({ "reason": ban.reason, "source": ban.source, "expires": ban.expires })
|
||||
}
|
||||
|
||||
/// The operator, whitelist and ban lists (DedicatedPlayerList), shared with
|
||||
/// the connection threads that check logins.
|
||||
pub(super) struct Lists {
|
||||
@@ -431,6 +444,30 @@ impl Lists {
|
||||
enforce_whitelist: false,
|
||||
op_level: 4,
|
||||
};
|
||||
// Each list tells the event stream what changes in it, as vanilla's tell their `NotificationService`.
|
||||
l.ops.watch(|change| {
|
||||
let (kind, e) = listed("operators", change);
|
||||
events::record(
|
||||
&kind,
|
||||
&e.user.name,
|
||||
e.user.id,
|
||||
serde_json::json!({ "level": e.level, "bypassesPlayerLimit": e.bypasses_player_limit }),
|
||||
);
|
||||
});
|
||||
l.white.watch(|change| {
|
||||
let (kind, e) = listed("allowlist", change);
|
||||
events::record(&kind, &e.user.name, e.user.id, serde_json::json!({}));
|
||||
});
|
||||
l.bans.watch(|change| {
|
||||
let (kind, e) = listed("bans", change);
|
||||
events::record(&kind, &e.user.name, e.user.id, ban_fields(&e));
|
||||
});
|
||||
l.ip_bans.watch(|change| {
|
||||
let (kind, e) = listed("ip_bans", change);
|
||||
let mut fields = ban_fields(&e);
|
||||
fields["ip"] = e.user.clone().into();
|
||||
events::record_server(&kind, fields);
|
||||
});
|
||||
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
|
||||
let warn = |what: &str, r: std::result::Result<(), String>| {
|
||||
if let Err(e) = r {
|
||||
@@ -641,6 +678,18 @@ struct ServerState {
|
||||
force_game_mode: bool,
|
||||
}
|
||||
|
||||
/// `MinecraftServer.isAutoSave`: all of Horizon's server levels share this save flag.
|
||||
fn auto_save(state: &ServerState) -> bool {
|
||||
state.autosave
|
||||
}
|
||||
|
||||
/// `MinecraftServer.setAutoSave`: enable or disable periodic saves and report whether it changed.
|
||||
fn set_auto_save(state: &mut ServerState, enabled: bool) -> bool {
|
||||
let changed = state.autosave != enabled;
|
||||
state.autosave = enabled;
|
||||
changed
|
||||
}
|
||||
|
||||
/// The dedicated-only commands' replies vanilla sends with `broadcast`
|
||||
/// (`sendSuccess(..., true)`): the operators hear of them.
|
||||
const BROADCAST: &[&str] = &[
|
||||
@@ -1063,8 +1112,12 @@ impl Game<'_> {
|
||||
self.save_game()?;
|
||||
}
|
||||
if !paused && !state.autosave {
|
||||
// `save-off`: the periodic save inside the tick is skipped.
|
||||
self.ticks = self.ticks.max(1);
|
||||
// `MinecraftServer.autoSave` keeps its schedule while every level's
|
||||
// `noSave` flag is set, but writes nothing. The tick below counts down
|
||||
// once, so reset one tick early when this save would have been due.
|
||||
if self.ticks_until_autosave <= 1 {
|
||||
self.ticks_until_autosave = self.next_autosave_interval() + 1;
|
||||
}
|
||||
}
|
||||
// MinecraftServer.tickServer: while paused only the connections run.
|
||||
// `MinecraftServer.runServer` catches what the tick throws, writes the crash
|
||||
@@ -1372,19 +1425,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"save-off" => {
|
||||
if state.autosave {
|
||||
state.autosave = false;
|
||||
if set_auto_save(state, false) {
|
||||
self.console_reply(Text::translate("commands.save.disabled", &[]));
|
||||
} else {
|
||||
self.console_reply(Text::translate("commands.save.alreadyOff", &[]));
|
||||
}
|
||||
}
|
||||
"save-on" => {
|
||||
if state.autosave {
|
||||
self.console_reply(Text::translate("commands.save.alreadyOn", &[]));
|
||||
} else {
|
||||
state.autosave = true;
|
||||
if set_auto_save(state, true) {
|
||||
self.console_reply(Text::translate("commands.save.enabled", &[]));
|
||||
} else {
|
||||
self.console_reply(Text::translate("commands.save.alreadyOn", &[]));
|
||||
}
|
||||
}
|
||||
"list" => {
|
||||
@@ -1780,6 +1831,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `DedicatedServer.setEnforceWhitelist`: the flag, and `enforce-whitelist` in
|
||||
/// server.properties.
|
||||
pub(super) fn set_enforce_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
|
||||
lists.lock().unwrap().enforce_whitelist = on;
|
||||
self.store_property("enforce-whitelist", on.to_string());
|
||||
}
|
||||
|
||||
/// DedicatedServerSettings.update: change one key and store the file.
|
||||
pub(super) fn store_property(&mut self, key: &str, value: String) {
|
||||
let path = PathBuf::from("server.properties");
|
||||
|
||||
@@ -21,11 +21,21 @@ static EVENTS: Mutex<Events> = Mutex::new(Events {
|
||||
|
||||
/// Records one event: its kind, the player's name and UUID, and what else it says.
|
||||
pub(in crate::game) fn record(kind: &str, name: &str, uuid: u128, mut fields: Value) {
|
||||
fields["name"] = name.into();
|
||||
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
|
||||
push(kind, fields);
|
||||
}
|
||||
|
||||
/// Records an event of the server itself, with no player (`saving` and `saved`, around a world save; an IP
|
||||
/// ban), and what else it says.
|
||||
pub(in crate::game) fn record_server(kind: &str, fields: Value) {
|
||||
push(kind, fields);
|
||||
}
|
||||
|
||||
fn push(kind: &str, mut fields: Value) {
|
||||
let mut events = EVENTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
fields["seq"] = events.next.into();
|
||||
fields["type"] = kind.into();
|
||||
fields["name"] = name.into();
|
||||
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
|
||||
fields["time"] = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64)
|
||||
@@ -52,6 +62,7 @@ pub(super) fn since(since: u64) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// One test: the events are one process-wide list, so two tests recording at once would count each other's.
|
||||
#[test]
|
||||
fn events_are_numbered_and_read_from_a_number() {
|
||||
let first = since(u64::MAX)["next"].as_u64().unwrap();
|
||||
@@ -65,5 +76,11 @@ mod tests {
|
||||
assert_eq!(events[1]["message"], "hi");
|
||||
assert_eq!(events[1]["uuid"], "00000000-0000-0000-0000-000000000001");
|
||||
assert_eq!(since(first + 1)["events"].as_array().unwrap().len(), 1);
|
||||
// An event of the server itself (a world save) carries no player.
|
||||
record_server("saving", json!({}));
|
||||
let event = &since(first + 2)["events"][0];
|
||||
assert_eq!(event["type"], "saving");
|
||||
assert_eq!(event["seq"].as_u64(), Some(first + 3));
|
||||
assert!(event.get("name").is_none() && event.get("uuid").is_none());
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
//! `AllowlistService`, `BanlistService`, `IpBanlistService` and `OperatorService` on the running server's lists. Players are found as
|
||||
//! `MinecraftPlayerListService.getUser` finds them, each change is logged as `JsonRpcLogger` logs it, and the
|
||||
//! players a change shuts out are disconnected. The supervisor answers with the lists, from their files.
|
||||
use super::{GAME_MODES, ServerState, log};
|
||||
use super::{GAME_MODES, ServerState, auto_save, log, set_auto_save};
|
||||
use crate::game::Game;
|
||||
use horizon_client::{
|
||||
command::vanilla::DIFFICULTIES,
|
||||
gamerules::{self, Value as GameRuleValue},
|
||||
server::{
|
||||
profiles,
|
||||
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
|
||||
@@ -29,6 +30,15 @@ struct Op {
|
||||
bypass: Option<bool>,
|
||||
}
|
||||
|
||||
/// A `GameRuleUpdate` as `TYPED_CODEC` writes it: the rule's id, its type and its value.
|
||||
fn typed_rule(rule: &gamerules::Rule, value: GameRuleValue) -> Value {
|
||||
let (kind, value) = match value {
|
||||
GameRuleValue::Bool(b) => ("boolean", json!(b)),
|
||||
GameRuleValue::Int(i) => ("integer", json!(i)),
|
||||
};
|
||||
json!({ "key": format!("minecraft:{}", rule.name), "type": kind, "value": value })
|
||||
}
|
||||
|
||||
/// `NameAndId`'s record `toString`, as vanilla's log lines show it.
|
||||
fn shown(user: &NameAndId) -> String {
|
||||
format!(
|
||||
@@ -49,6 +59,63 @@ impl Game<'_> {
|
||||
let entries = body["entries"].as_array().cloned().unwrap_or_default();
|
||||
let lists = state.lists.clone();
|
||||
match body["method"].as_str().unwrap_or_default() {
|
||||
// `GameRulesService.get`: every rule the server has, with its type and current value.
|
||||
"gamerules" => {
|
||||
let rules = &self.ui.rules;
|
||||
let all: Vec<Value> = rules
|
||||
.available_rules()
|
||||
.map(|r| typed_rule(r, rules.get(r.name).unwrap_or(r.default)))
|
||||
.collect();
|
||||
return json!({ "value": all });
|
||||
}
|
||||
// `GameRulesService.update`: the rule is set only when its value changes, and logged.
|
||||
"gamerules/update" => {
|
||||
let update = &body["value"];
|
||||
let Some(rule) = update["key"]
|
||||
.as_str()
|
||||
.and_then(gamerules::rule)
|
||||
.filter(|r| self.ui.rules.get(r.name).is_some())
|
||||
else {
|
||||
return json!({ "invalid": format!("Unknown game rule: {}", update["key"]) });
|
||||
};
|
||||
let wanted = &update["value"];
|
||||
let value = match (rule.default, wanted) {
|
||||
(GameRuleValue::Bool(_), Value::Bool(b)) => Some(GameRuleValue::Bool(*b)),
|
||||
(GameRuleValue::Int(_), n) => n
|
||||
.as_i64()
|
||||
.and_then(|n| i32::try_from(n).ok())
|
||||
.filter(|n| (rule.min..=rule.max).contains(n))
|
||||
.map(GameRuleValue::Int),
|
||||
_ => None,
|
||||
};
|
||||
let Some(value) = value else {
|
||||
return json!({ "invalid": format!("Invalid value {wanted} for game rule {}", rule.name) });
|
||||
};
|
||||
let current = self.ui.rules.get(rule.name).unwrap_or(rule.default);
|
||||
if current != value {
|
||||
rpc(format!(
|
||||
"Game rule '{}' updated from '{current}' to '{value}'",
|
||||
rule.name
|
||||
));
|
||||
self.set_game_rule(rule, value);
|
||||
}
|
||||
return json!({ "value": typed_rule(rule, value) });
|
||||
}
|
||||
"serversettings/autosave" => {
|
||||
return json!({ "value": auto_save(state) });
|
||||
}
|
||||
"serversettings/autosave/set" => {
|
||||
let Some(enabled) = body["value"].as_bool() else {
|
||||
return json!({ "error": "autosave must be a boolean" });
|
||||
};
|
||||
rpc(format!(
|
||||
"Update autosave from {} to {}",
|
||||
auto_save(state),
|
||||
enabled
|
||||
));
|
||||
set_auto_save(state, enabled);
|
||||
return json!({ "value": auto_save(state) });
|
||||
}
|
||||
"serversettings/motd" => {
|
||||
return json!({
|
||||
"value": self.lan.as_ref().map(|lan| lan.hub.lan.shared.motd()).unwrap_or_default()
|
||||
@@ -105,6 +172,46 @@ impl Game<'_> {
|
||||
self.set_force_game_mode(force);
|
||||
return json!({ "value": state.force_game_mode });
|
||||
}
|
||||
"serversettings/use_allowlist" => {
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().use_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/use_allowlist/set" => {
|
||||
let Some(use_allowlist) = body["value"].as_bool() else {
|
||||
return json!({ "error": "using allowlist must be a boolean" });
|
||||
};
|
||||
let current = lists.lock().unwrap().use_whitelist;
|
||||
rpc(format!(
|
||||
"Update using allowlist from {} to {}",
|
||||
current, use_allowlist
|
||||
));
|
||||
self.set_using_whitelist(&lists, use_allowlist);
|
||||
self.kick_unlisted_players(&lists);
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().use_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/enforce_allowlist" => {
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().enforce_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/enforce_allowlist/set" => {
|
||||
let Some(enforce) = body["value"].as_bool() else {
|
||||
return json!({ "error": "enforce allowlist must be a boolean" });
|
||||
};
|
||||
let current = lists.lock().unwrap().enforce_whitelist;
|
||||
rpc(format!(
|
||||
"Update enforce allowlist from {} to {}",
|
||||
current, enforce
|
||||
));
|
||||
self.set_enforce_whitelist(&lists, enforce);
|
||||
self.kick_unlisted_players(&lists);
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().enforce_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
@@ -436,3 +543,22 @@ fn management_ip_ban(entry: &Value, ip: String) -> IpBan {
|
||||
reason,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_game_rule_is_written_as_vanillas_typed_codec_does() {
|
||||
let pvp = gamerules::rule("pvp").unwrap();
|
||||
assert_eq!(
|
||||
typed_rule(pvp, GameRuleValue::Bool(false)),
|
||||
json!({ "key": "minecraft:pvp", "type": "boolean", "value": false })
|
||||
);
|
||||
let tick = gamerules::rule("minecraft:random_tick_speed").unwrap();
|
||||
assert_eq!(
|
||||
typed_rule(tick, GameRuleValue::Int(3)),
|
||||
json!({ "key": "minecraft:random_tick_speed", "type": "integer", "value": 3 })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -327,6 +327,7 @@ mod tests {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1216,6 +1216,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
|
||||
self.boats.push(boat);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let boat = self.boats.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
boat.entity_type().as_str(),
|
||||
[boat.position.x, boat.position.y, boat.position.z],
|
||||
);
|
||||
}
|
||||
(shrink(stack), CLICK)
|
||||
}
|
||||
/// `MinecartDispenseItemBehavior`.
|
||||
@@ -1255,6 +1262,13 @@ impl Game<'_> {
|
||||
);
|
||||
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
|
||||
self.carts.push(cart);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let cart = self.carts.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
cart.kind.id(),
|
||||
[cart.position.x, cart.position.y, cart.position.z],
|
||||
);
|
||||
}
|
||||
(shrink(stack), CLICK)
|
||||
}
|
||||
|
||||
|
||||
@@ -1316,8 +1316,8 @@ impl Game<'_> {
|
||||
view: &horizon_client::advancements::EntityView,
|
||||
blow: Option<(DamageKind, bool)>,
|
||||
) {
|
||||
self.stats.add("killed", "minecraft:ender_dragon", 1);
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.award_stat("killed", "minecraft:ender_dragon", 1);
|
||||
self.award_custom_stat("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(id));
|
||||
// KilledTrigger: the damage source `die` was given.
|
||||
let blow = blow.map(|(kind, by_player)| {
|
||||
|
||||
@@ -21,6 +21,7 @@ use horizon_core::geometry::{Aabb, Direction, Vec3};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{level::data::PushReaction, piston_push};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// What `Entity.thunderHit` does to a kind: it sets the entity on fire and
|
||||
@@ -61,6 +62,8 @@ pub(super) trait Entity: EntityRules {
|
||||
/// What a selector finds this entity as (`@e`), or `None` for one it cannot
|
||||
/// name yet. Required, so a new kind states which.
|
||||
fn selector_target(&self) -> Option<Target>;
|
||||
/// The entity type id a native mod's join hook names (`minecraft:oak_boat`).
|
||||
fn native_type(&self) -> Cow<'static, str>;
|
||||
/// Saves entity `index` of this kind into its chunk's entity list: the
|
||||
/// chunk and the NBT when it lies in `keys` and is saved with its chunk.
|
||||
/// Required, so a new kind states how it saves.
|
||||
@@ -313,6 +316,15 @@ macro_rules! define_entity_handles {
|
||||
$(.chain((0..self$(.$list)+.len()).map(EntityHandle::$kind)))*
|
||||
}
|
||||
|
||||
/// Takes the entity out of its list (`Entity.discard` before it ever ticked).
|
||||
pub(super) fn discard_entity(&mut self, handle: EntityHandle) {
|
||||
match handle {
|
||||
$(EntityHandle::$kind(i) => {
|
||||
self$(.$list)+.remove(i);
|
||||
})*
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::entity_handles`], last of each list first, so the
|
||||
/// caller can remove the entity it is on.
|
||||
pub(super) fn entity_handles_rev(&self) -> impl Iterator<Item = EntityHandle> + use<> {
|
||||
@@ -452,6 +464,9 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
impl Entity for Mob {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
super::native_hooks::mob_kind(self).into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
@@ -534,6 +549,9 @@ impl Entity for Mob {
|
||||
}
|
||||
|
||||
impl Entity for ItemEntity {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
"minecraft:item".into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
@@ -576,6 +594,9 @@ impl Entity for ItemEntity {
|
||||
}
|
||||
|
||||
impl Entity for ExperienceOrb {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
"minecraft:experience_orb".into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
@@ -615,6 +636,9 @@ impl Entity for ExperienceOrb {
|
||||
}
|
||||
|
||||
impl Entity for Boat {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
self.entity_type().into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
@@ -658,6 +682,9 @@ impl Entity for Boat {
|
||||
}
|
||||
|
||||
impl Entity for Minecart {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
self.kind.id().into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
@@ -711,6 +738,9 @@ fn armor_stand_size(stand: &ArmorStand) -> (f64, f64) {
|
||||
}
|
||||
|
||||
impl Entity for ArmorStand {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
"minecraft:armor_stand".into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
@@ -783,6 +813,9 @@ impl Entity for ArmorStand {
|
||||
}
|
||||
|
||||
impl Entity for PrimedTnt {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
"minecraft:tnt".into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
@@ -824,6 +857,9 @@ impl Entity for PrimedTnt {
|
||||
}
|
||||
|
||||
impl Entity for FallingBlock {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
"minecraft:falling_block".into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
@@ -841,6 +877,9 @@ impl Entity for FallingBlock {
|
||||
}
|
||||
|
||||
impl Entity for Projectile {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
self.kind.id().into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
@@ -901,6 +940,9 @@ impl Entity for Projectile {
|
||||
}
|
||||
|
||||
impl Entity for Rocket {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
"minecraft:firework_rocket".into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
@@ -933,6 +975,9 @@ impl Entity for Rocket {
|
||||
}
|
||||
|
||||
impl Entity for ThrownTrident {
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
"minecraft:trident".into()
|
||||
}
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
|
||||
@@ -856,6 +856,9 @@ impl Game<'_> {
|
||||
damage_type: &str,
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
) -> Result<()> {
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_explosion_prime(center, radius);
|
||||
}
|
||||
if let Some(p) = &self.player {
|
||||
let d = [
|
||||
p.position.x - center[0],
|
||||
|
||||
@@ -343,7 +343,7 @@ impl Game<'_> {
|
||||
.get("minecraft:fishes")
|
||||
.is_some_and(|t| t.contains(stack.item.as_str()))
|
||||
{
|
||||
self.stats.custom("fish_caught", 1);
|
||||
self.award_custom_stat("fish_caught", 1);
|
||||
}
|
||||
let mut item = ItemStack {
|
||||
item: Arc::from(stack.item.as_str()),
|
||||
|
||||
@@ -44,7 +44,7 @@ impl Game<'_> {
|
||||
if used {
|
||||
self.swing_arm();
|
||||
// ItemStack.useOn: an item interaction counts as a use.
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
}
|
||||
Ok(used)
|
||||
@@ -89,7 +89,7 @@ impl Game<'_> {
|
||||
);
|
||||
self.damage_held(1);
|
||||
// ItemStack.useOn: an item interaction counts as a use.
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
// ServerPlayerGameMode.useItemOn: a consuming useOn reports it.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
|
||||
@@ -834,7 +834,7 @@ impl Game<'_> {
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
self.flush_native_net(&mut host);
|
||||
// PlayerList.removeAll: the owner and every guest leave the game.
|
||||
host.award_leave_game_to_all();
|
||||
self.award_leave_game_to_all(&mut host);
|
||||
self.save_leaving_guests(&mut host);
|
||||
let conns: Vec<u32> = host.hub.guests.iter().map(|g| g.conn).collect();
|
||||
for conn in conns {
|
||||
|
||||
@@ -492,6 +492,32 @@ impl Game<'_> {
|
||||
}
|
||||
Some((mark, skin.bounds))
|
||||
}
|
||||
/// [`Self::submit_item_world`] of `state` under `pose` on the GPU path, its draws counted
|
||||
/// with an entity's (`skinned`: its mark and bounds, `cull_skinned`'s); false, with
|
||||
/// nothing drawn, when the state cannot take it.
|
||||
pub(super) fn skin_item_into(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
skinned: &mut super::creatures::Skinned,
|
||||
) -> bool {
|
||||
let lights = diffuse_lights(self.dim == Dim::Nether);
|
||||
let Some((mark, bounds)) = self.submit_item_skinned(state, &[pose], light, lights) else {
|
||||
return false;
|
||||
};
|
||||
*skinned = Some(match skinned.take() {
|
||||
Some((first, before)) => (
|
||||
first,
|
||||
match (before, bounds) {
|
||||
(Some((a, b)), Some((c, d))) => Some((a.min(c), b.max(d))),
|
||||
(one, other) => one.or(other),
|
||||
},
|
||||
),
|
||||
None => (mark, bounds),
|
||||
});
|
||||
true
|
||||
}
|
||||
/// `ItemEntityRenderer.submit` for every dropped item.
|
||||
pub(super) fn dropped_item_geometry(
|
||||
&self,
|
||||
|
||||
@@ -143,7 +143,7 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.stats.custom("play_record", 1);
|
||||
self.award_custom_stat("play_record", 1);
|
||||
self.world.block_entities.insert(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
BlockEntity::Jukebox(Jukebox {
|
||||
|
||||
@@ -191,29 +191,48 @@ pub(super) struct LanHost {
|
||||
pub(super) states: lan::entity::States,
|
||||
}
|
||||
|
||||
impl LanHost {
|
||||
impl Game<'_> {
|
||||
/// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the
|
||||
/// player is saved. Once per guest, however often they are saved.
|
||||
pub(super) fn award_leave_game(&mut self, conn: u32) {
|
||||
if let Some(play) = self.play.get_mut(&conn)
|
||||
&& !play.left_game
|
||||
{
|
||||
play.left_game = true;
|
||||
/// player is saved, heard by native mods first. Once per guest, however
|
||||
/// often they are saved.
|
||||
pub(super) fn award_leave_game(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
player: u128,
|
||||
name: &str,
|
||||
) {
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return;
|
||||
};
|
||||
if std::mem::replace(&mut play.left_game, true) {
|
||||
return;
|
||||
}
|
||||
let mut cancel = false;
|
||||
self.native_player_stat_award(
|
||||
player,
|
||||
name,
|
||||
"minecraft:custom",
|
||||
"minecraft:leave_game",
|
||||
1,
|
||||
&mut cancel,
|
||||
);
|
||||
if !cancel && let Some(play) = host.play.get_mut(&conn) {
|
||||
play.stats.custom("leave_game", 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerList.removeAll`: every player who joined leaves with the world.
|
||||
pub(super) fn award_leave_game_to_all(&mut self) {
|
||||
let conns: Vec<u32> = self
|
||||
pub(super) fn award_leave_game_to_all(&mut self, host: &mut LanHost) {
|
||||
let players: Vec<(u32, u128, String)> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| g.conn)
|
||||
.map(|g| (g.conn, g.uuid, g.name.clone()))
|
||||
.collect();
|
||||
for conn in conns {
|
||||
self.award_leave_game(conn);
|
||||
for (conn, player, name) in players {
|
||||
self.award_leave_game(host, conn, player, &name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1334,7 +1353,7 @@ impl Game<'_> {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
host.award_leave_game_to_all();
|
||||
self.award_leave_game_to_all(&mut host);
|
||||
self.save_leaving_guests(&mut host);
|
||||
host.hub.disconnect_all();
|
||||
// Give the writer threads a moment to flush the disconnect packets.
|
||||
@@ -1394,10 +1413,14 @@ impl Game<'_> {
|
||||
guest: &mut horizon_client::lan::guest::Guest,
|
||||
) {
|
||||
self.guest_attachments_into(guest);
|
||||
if guest.placed && host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
|
||||
playerdata::save_owner(guest, &mut self.level, &self.world_root);
|
||||
} else if guest.placed {
|
||||
playerdata::save(guest, &self.world_root);
|
||||
if guest.placed {
|
||||
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
|
||||
playerdata::save_owner(guest, &mut self.level, &self.world_root);
|
||||
} else {
|
||||
playerdata::save(guest, &self.world_root);
|
||||
}
|
||||
let directory = playerdata::directory(&self.world_root);
|
||||
self.native_player_save_to_file(guest.uuid, &guest.name, &directory);
|
||||
}
|
||||
self.save_guest_records(host, guest);
|
||||
}
|
||||
@@ -1593,35 +1616,28 @@ impl Game<'_> {
|
||||
host.play.entry(conn).or_default().stats = stats;
|
||||
}
|
||||
/// The integrated server's owner into the world's player, for `Level::write`.
|
||||
pub(super) fn store_owner(&mut self) {
|
||||
let Some(uuid) = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| h.owner.as_ref())
|
||||
.map(|o| o.uuid)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some((riding, dim, others)) = self.lan.as_ref().and_then(|h| {
|
||||
/// Returns the owner's uuid and name when they were stored.
|
||||
pub(super) fn store_owner(&mut self) -> Option<(u128, String)> {
|
||||
let uuid = self.lan.as_ref().and_then(|h| h.owner.as_ref())?.uuid;
|
||||
let (riding, dim, others) = self.lan.as_ref().and_then(|h| {
|
||||
let g = h.hub.guests.iter().find(|g| g.uuid == uuid && g.placed)?;
|
||||
let others = self.ridden_by_others(&h.hub.guests, &g.dimension, Some(uuid), true);
|
||||
Some((g.vehicle, g.dimension.clone(), others))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
})?;
|
||||
// The vehicle the owner rides (`ServerPlayer.saveParentVehicle`).
|
||||
let vehicle = self
|
||||
.parent_vehicle_in(None, &dim, riding, &others, false)
|
||||
.map(|(tag, _)| tag);
|
||||
let Some(host) = &self.lan else {
|
||||
return;
|
||||
};
|
||||
let host = self.lan.as_ref()?;
|
||||
let mut stored = None;
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
|
||||
playerdata::store(g, &mut self.level, vehicle);
|
||||
self.save_guest_records(host, g);
|
||||
stored = Some(g.name.clone());
|
||||
} else if let Some(saved) = host.owner.as_ref().and_then(|o| o.saved.as_ref()) {
|
||||
self.level.position = saved.position;
|
||||
}
|
||||
stored.map(|name| (uuid, name))
|
||||
}
|
||||
|
||||
/// The world's default game mode (`defaultgamemode`).
|
||||
@@ -1883,7 +1899,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.lan_crafting_left(&mut host, &mut guest);
|
||||
if guest.placed {
|
||||
host.award_leave_game(guest.conn);
|
||||
self.award_leave_game(&mut host, guest.conn, guest.uuid, &guest.name);
|
||||
}
|
||||
// PlayerList.remove: the vehicle it alone rides goes too.
|
||||
self.take_parent_vehicle(&mut host, &mut guest);
|
||||
@@ -1923,6 +1939,47 @@ impl Game<'_> {
|
||||
HostEvent::Flight { conn, flying } => {
|
||||
self.lan_flight_toggled(&mut host, conn, flying);
|
||||
}
|
||||
HostEvent::Sneak { conn, sneaking } => {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.as_guest(&mut host, conn, |game| {
|
||||
game.native_player_toggle_sneak(uuid, &name, sneaking);
|
||||
});
|
||||
}
|
||||
}
|
||||
HostEvent::Kicked {
|
||||
conn,
|
||||
player,
|
||||
name,
|
||||
reason,
|
||||
} => {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The same poll can have removed the guest (their `Left`): the mods hear the
|
||||
// kick anyway, without the guest's state swapped in.
|
||||
if host.hub.guest(conn).is_some() {
|
||||
self.as_guest(&mut host, conn, |game| {
|
||||
game.native_player_kicked(player, &name, &reason);
|
||||
});
|
||||
} else {
|
||||
self.native_player_kicked(player, &name, &reason);
|
||||
}
|
||||
}
|
||||
HostEvent::Input { conn, flags } => {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.as_guest(&mut host, conn, |game| {
|
||||
game.native_player_input(uuid, &name, flags);
|
||||
});
|
||||
}
|
||||
}
|
||||
HostEvent::MainHandChanged(conn, left) => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
@@ -1935,6 +1992,25 @@ impl Game<'_> {
|
||||
self.native_player_locale_change(uuid, &name, &locale);
|
||||
}
|
||||
}
|
||||
HostEvent::PlayerLoaded(conn) => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_client_loaded_world(uuid, &name);
|
||||
}
|
||||
}
|
||||
HostEvent::RecipeBookSettings {
|
||||
conn,
|
||||
book,
|
||||
open,
|
||||
filtering,
|
||||
} => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_recipe_book_settings_change(
|
||||
uuid, &name, book, open, filtering,
|
||||
);
|
||||
}
|
||||
}
|
||||
HostEvent::Packet(conn, packet) => {
|
||||
self.lan_guest_packet(&mut host, conn, *packet);
|
||||
if !host.commands.is_empty() {
|
||||
@@ -1986,6 +2062,7 @@ impl Game<'_> {
|
||||
self.ui.rules.bool("player_movement_check"),
|
||||
self.ui.rules.bool("elytra_movement_check"),
|
||||
);
|
||||
host.hub.mods_loaded = !horizon_client::mod_api::mods().is_empty();
|
||||
let pass = self.level_pass;
|
||||
let mut events = if pass.first {
|
||||
host.hub.poll(&mut ctx)
|
||||
@@ -2007,6 +2084,14 @@ impl Game<'_> {
|
||||
.take()
|
||||
.expect("the hub is back after the quiet commands");
|
||||
}
|
||||
if !host.hub.native_advancement_grants.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_native_advancement_grants();
|
||||
host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the advancement grants");
|
||||
}
|
||||
if !host.hub.native_menu_events.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_native_menu_events();
|
||||
@@ -2160,6 +2245,7 @@ impl Game<'_> {
|
||||
// (`tick_server`): the network is read once, before the first.
|
||||
// Player packets may already have been drained before
|
||||
// scheduled block ticks (`tick_lan_packets`).
|
||||
host.hub.mods_loaded = !horizon_client::mod_api::mods().is_empty();
|
||||
let pass = self.level_pass;
|
||||
let mut events = if pass.first && !pass.packets_drained {
|
||||
host.hub.poll(&mut ctx)
|
||||
@@ -2198,6 +2284,14 @@ impl Game<'_> {
|
||||
.take()
|
||||
.expect("the hub is back after the mod messages");
|
||||
}
|
||||
if !host.hub.native_advancement_grants.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_native_advancement_grants();
|
||||
host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the advancement grants");
|
||||
}
|
||||
if !host.hub.native_menu_events.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_native_menu_events();
|
||||
@@ -2276,6 +2370,9 @@ impl Game<'_> {
|
||||
};
|
||||
self.guest_attachments
|
||||
.insert(g.uuid, std::mem::take(&mut g.attachments));
|
||||
// PlayerEvent.LoadFromFile: the player's data is read and applied,
|
||||
// the player not yet placed.
|
||||
self.native_player_load_from_file(g.uuid, &g.name, &playerdata::directory(&root));
|
||||
if !known || force {
|
||||
g.game_mode = default_mode;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ pub(super) struct GuestPlay {
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
pub(super) stats: super::stats::Stats,
|
||||
/// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the
|
||||
/// way out, the statistic is awarded once (`LanHost::award_leave_game`).
|
||||
/// way out, the statistic is awarded once (`Game::award_leave_game`).
|
||||
pub(super) left_game: bool,
|
||||
last_inventory: u64,
|
||||
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
|
||||
@@ -769,6 +769,34 @@ impl Game<'_> {
|
||||
p.guest = Some(conn);
|
||||
}
|
||||
}
|
||||
let guest = &host.hub.guests[i];
|
||||
let listening = !horizon_client::mod_api::mods().is_empty();
|
||||
for projectile in projectiles..self.creatures.projectiles.len() {
|
||||
if !listening {
|
||||
break;
|
||||
}
|
||||
let item = match &self.creatures.projectiles[projectile].kind {
|
||||
horizon_client::projectiles::ProjectileKind::Snowball => {
|
||||
Some("minecraft:snowball".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Egg => {
|
||||
Some("minecraft:egg".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::EnderPearl => {
|
||||
Some("minecraft:ender_pearl".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Trident => {
|
||||
Some("minecraft:trident".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Potion(stack, _) => {
|
||||
Some(stack.item.to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(item) = item {
|
||||
self.native_player_launch_projectile(guest.uuid, &guest.name, &item);
|
||||
}
|
||||
}
|
||||
// The guest's swings are theirs: the host's own count stays the host's.
|
||||
play.swings += self.swings_started.wrapping_sub(swings);
|
||||
self.swings_started = swings;
|
||||
@@ -1836,6 +1864,8 @@ impl Game<'_> {
|
||||
}),
|
||||
Some(conn),
|
||||
);
|
||||
let guest = &host.hub.guests[gi];
|
||||
self.native_block_damage_abort(guest.uuid, &guest.name, a.pos);
|
||||
}
|
||||
4 | 5 => {
|
||||
if !spectator {
|
||||
@@ -1845,6 +1875,13 @@ impl Game<'_> {
|
||||
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
|
||||
// hand of the item in use acting.
|
||||
6 => {
|
||||
let listening = !horizon_client::mod_api::mods().is_empty();
|
||||
let (uuid, name) = if listening {
|
||||
let guest = &host.hub.guests[gi];
|
||||
(guest.uuid, guest.name.clone())
|
||||
} else {
|
||||
(0, String::new())
|
||||
};
|
||||
self.as_guest(host, conn, |g| {
|
||||
let Some(hand) = g.used_hand() else {
|
||||
return;
|
||||
@@ -1853,7 +1890,18 @@ impl Game<'_> {
|
||||
if let Some((slot, ticks)) = g.using.take()
|
||||
&& let Some(stack) = g.stack_in(slot).cloned()
|
||||
{
|
||||
if listening
|
||||
&& &*stack.item == "minecraft:bow"
|
||||
&& let Some(arrow) = g
|
||||
.projectile_slot("minecraft:bow")
|
||||
.and_then(|slot| g.stack_in(slot).map(|s| s.item.clone()))
|
||||
{
|
||||
g.native_player_ready_arrow(uuid, &name, &arrow);
|
||||
}
|
||||
g.release_used_item(&stack, ticks, false);
|
||||
if listening {
|
||||
g.native_player_stop_using_item(uuid, &name);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1957,6 +2005,7 @@ impl Game<'_> {
|
||||
g.reset_attack_strength();
|
||||
});
|
||||
self.native_player_attack_cooldown_reset(player, &name);
|
||||
self.native_player_arm_swing(player, &name, "mainhand");
|
||||
}
|
||||
S::UseItemOn(p) => {
|
||||
host.hub.ack(conn, p.sequence);
|
||||
@@ -2291,6 +2340,8 @@ impl Game<'_> {
|
||||
if !spectator && let Some(slot) = slot {
|
||||
host.hub
|
||||
.select_bundle_item(conn, slot, p.index, &mut self.world);
|
||||
let guest = &host.hub.guests[gi];
|
||||
self.native_player_bundle_item_select(guest.uuid, &guest.name, p.slot, p.index);
|
||||
}
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
@@ -3453,6 +3504,10 @@ impl Game<'_> {
|
||||
};
|
||||
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
|
||||
// PlayerEvent.Clone, at the end of `restoreFrom`: the data is copied,
|
||||
// the new player not yet placed.
|
||||
let (cloned, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_clone(cloned, &name, true);
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
@@ -4019,6 +4074,7 @@ impl Game<'_> {
|
||||
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
|
||||
let riding = g.vehicle.is_some();
|
||||
let mut soul = None;
|
||||
let mut jumps = 0;
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
@@ -4079,6 +4135,7 @@ impl Game<'_> {
|
||||
// ServerGamePacketListenerImpl.handlePlayerPositionChange:
|
||||
// the server's copy jumps with the player, so the velocity
|
||||
// it sends them keeps the jump.
|
||||
jumps += 1;
|
||||
let _ = w.jump_from_ground();
|
||||
}
|
||||
let landed = (|| -> horizon_client::Result<()> {
|
||||
@@ -4183,7 +4240,7 @@ impl Game<'_> {
|
||||
for event in &events {
|
||||
if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event
|
||||
{
|
||||
game.stats.custom("fall_one_cm", *cm);
|
||||
game.award_custom_stat("fall_one_cm", *cm);
|
||||
}
|
||||
}
|
||||
game.record_combat(&events);
|
||||
@@ -4193,6 +4250,16 @@ impl Game<'_> {
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
if jumps > 0
|
||||
&& !horizon_client::mod_api::mods().is_empty()
|
||||
&& let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
|
||||
{
|
||||
self.as_guest(host, conn, |game| {
|
||||
for _ in 0..jumps {
|
||||
game.native_player_jump(uuid, &name);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some((at, velocity)) = soul {
|
||||
super::lan_particles::send_soul_particle(host, at, velocity);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,13 @@ impl Game<'_> {
|
||||
if let Some(maps) = maps {
|
||||
self.register_made_maps(maps);
|
||||
}
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
// A taken anvil result waits for AnvilCraftEvent.Pre: what the click
|
||||
// dropped waits with it (lan_station_op throws it or refuses the take).
|
||||
let dropped = if menu.anvil_craft.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
std::mem::take(&mut menu.dropped)
|
||||
};
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
let crafted = std::mem::take(&mut menu.crafted);
|
||||
|
||||
@@ -633,7 +633,7 @@ impl Game<'_> {
|
||||
// `potion_contents`, whose effects the guest's splash and
|
||||
// whose colour its drawing need. A plain splash potion is the
|
||||
// client's default and is not sent.
|
||||
K::Potion(stack) if stack.extra.is_some() || stack.count != 1 => {
|
||||
K::Potion(stack, _) if stack.extra.is_some() || stack.count != 1 => {
|
||||
e.meta.push((
|
||||
8,
|
||||
serializer::ITEM_STACK,
|
||||
|
||||
@@ -320,9 +320,9 @@ impl Game<'_> {
|
||||
if died {
|
||||
match s.attacker {
|
||||
Some(conn) => {
|
||||
self.as_guest(host, conn, |g| g.stats.custom("player_kills", 1));
|
||||
self.as_guest(host, conn, |g| g.award_custom_stat("player_kills", 1));
|
||||
}
|
||||
None => self.stats.custom("player_kills", 1),
|
||||
None => self.award_custom_stat("player_kills", 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::{
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
menu::{AnvilCraft, Kind, Menu},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
@@ -80,6 +80,8 @@ impl Game<'_> {
|
||||
return true;
|
||||
};
|
||||
let mut menu = Box::new(Menu::new(kind));
|
||||
// Without a mod the take runs AnvilMenu.onTake's order in the click itself.
|
||||
menu.anvil_hook = kind == Kind::Anvil && !horizon_client::mod_api::mods().is_empty();
|
||||
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
&& kind == Kind::Enchanting
|
||||
{
|
||||
@@ -136,14 +138,42 @@ impl Game<'_> {
|
||||
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
|
||||
(slots, gs.menu.data[0])
|
||||
});
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, op);
|
||||
let recomputed = before.is_some_and(|(slots, cost)| {
|
||||
cost != gs.menu.data[0]
|
||||
|| slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
|
||||
let mut undo = None;
|
||||
let hook = gs.menu.anvil_hook;
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, |menu, env| {
|
||||
undo = hook.then(|| menu.anvil_undo());
|
||||
op(menu, env);
|
||||
});
|
||||
// AnvilCraftEvent.Pre: the taken result's onTake waits for the mods.
|
||||
let mut anvil_craft = gs.menu.anvil_craft.take();
|
||||
let mut cancelled = false;
|
||||
if let Some(craft) = &anvil_craft {
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
let mut cancel = false;
|
||||
self.native_anvil_craft_pre(uuid, &name, gs.pos, craft, &mut cancel);
|
||||
if cancel && let Some(undo) = undo {
|
||||
// The take is refused: the result, inputs, cost, inventory
|
||||
// and cursor are as before the click (the resync below
|
||||
// tells the client).
|
||||
anvil_craft = None;
|
||||
cancelled = true;
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, |menu, _| menu.anvil_cancel(undo));
|
||||
} else {
|
||||
gs.menu.anvil_take(&self.menu_env());
|
||||
let dropped = std::mem::take(&mut gs.menu.dropped);
|
||||
self.drop_guest_items(host, gi, dropped);
|
||||
}
|
||||
}
|
||||
// A cancelled take runs no createResult: the inputs are untouched.
|
||||
let recomputed = !cancelled
|
||||
&& before.is_some_and(|(slots, cost)| {
|
||||
cost != gs.menu.data[0]
|
||||
|| slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
|
||||
});
|
||||
if recomputed && gs.kind == StationKind::Anvil {
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
@@ -172,7 +202,7 @@ impl Game<'_> {
|
||||
if let Some(amount) = gs.menu.ground.take() {
|
||||
self.grindstone_taken(gs.pos, amount);
|
||||
}
|
||||
self.lan_station_combined(host, conn, &mut gs);
|
||||
self.lan_station_combined(host, conn, &mut gs, anvil_craft.as_ref());
|
||||
if let Some(button) = gs.menu.enchant_button.take() {
|
||||
self.lan_enchant(host, gi, &mut gs, button);
|
||||
}
|
||||
@@ -305,8 +335,15 @@ impl Game<'_> {
|
||||
|
||||
/// The rest of a taken anvil or smithing result's `onTake`: the guest
|
||||
/// pays the levels, the anvil wears (level event 1030 or 1029), the
|
||||
/// smithing table sounds (1044).
|
||||
fn lan_station_combined(&mut self, host: &mut LanHost, conn: u32, gs: &mut GuestStation) {
|
||||
/// smithing table sounds (1044). An anvil's `craft` is then reported to
|
||||
/// the mods (`AnvilCraftEvent.Post`).
|
||||
fn lan_station_combined(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
gs: &mut GuestStation,
|
||||
craft: Option<&AnvilCraft>,
|
||||
) {
|
||||
let change = std::mem::take(&mut gs.menu.level_change);
|
||||
if change != 0 {
|
||||
self.as_guest(host, conn, |g| g.give_levels(change));
|
||||
@@ -320,6 +357,12 @@ impl Game<'_> {
|
||||
let creative = gs.menu.creative;
|
||||
// A broken anvil closes the menu on the next stillValid check.
|
||||
self.anvil_used(gs.pos, creative);
|
||||
if let Some(craft) = craft
|
||||
&& let Some(guest) = host.hub.guests.iter().find(|g| g.conn == conn)
|
||||
{
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
self.native_anvil_craft_post(uuid, &name, gs.pos, craft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +433,7 @@ impl Game<'_> {
|
||||
// Player.onEnchantmentPerformed, and EnchantmentMenu's awardStat(ENCHANT_ITEM).
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.give_levels(-need);
|
||||
game.stats.custom("enchant_item", 1);
|
||||
game.award_custom_stat("enchant_item", 1);
|
||||
// CriteriaTriggers.ENCHANTED_ITEM, with the levels it cost.
|
||||
game.trigger_enchanted_item(&enchanted, need);
|
||||
});
|
||||
|
||||
@@ -146,6 +146,7 @@ impl Game<'_> {
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
self.native_entity_unleash(id, &kind);
|
||||
if used
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
|
||||
@@ -240,7 +240,7 @@ impl Game<'_> {
|
||||
self.mace_hurt_enemy(index);
|
||||
}
|
||||
if weapon.is_some() {
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
self.post_attack(index);
|
||||
// MaceItem.postHurtEnemy
|
||||
@@ -323,7 +323,7 @@ impl Game<'_> {
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.weapon_damage.is_some())
|
||||
{
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
self.post_attack_bursts(&stack, "minecraft:player");
|
||||
// MaceItem.postHurtEnemy
|
||||
@@ -352,7 +352,7 @@ impl Game<'_> {
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.weapon_damage.is_some())
|
||||
{
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
self.post_attack_bursts(&stack, "minecraft:armor_stand");
|
||||
// MaceItem.postHurtEnemy
|
||||
|
||||
@@ -740,6 +740,7 @@ impl Game<'_> {
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
|
||||
return;
|
||||
}
|
||||
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
|
||||
let mut changed = false;
|
||||
if !c.is_empty(&view) {
|
||||
changed = self.hopper_eject(pos, &view);
|
||||
@@ -763,10 +764,10 @@ impl Game<'_> {
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let item = &self.dropped[i];
|
||||
// `ItemEntity.tick` moves 1 of 4 ticks while resting (age was
|
||||
// counted already); `checkInsideBlocks` only follows a move.
|
||||
// `ItemEntity.tick` moves 1 of 4 ticks while resting;
|
||||
// `checkInsideBlocks` only follows a move.
|
||||
let moved = item.position != item.previous
|
||||
|| (item.age.wrapping_sub(1).wrapping_add(item.id)).is_multiple_of(4);
|
||||
|| item.ticks.wrapping_add(item.id).is_multiple_of(4);
|
||||
if !moved || item.stack.count == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -866,6 +867,11 @@ impl Game<'_> {
|
||||
let front = offset(pos, facing);
|
||||
let face = facing.opposite();
|
||||
if let Some(target) = c.view(front, false) {
|
||||
self.native_hopper_inventory_search(
|
||||
[pos.0, pos.1, pos.2],
|
||||
Some([front.0, front.1, front.2]),
|
||||
None,
|
||||
);
|
||||
return self.eject_into(pos, hopper, &target, face);
|
||||
}
|
||||
let Some(id) = self.entity_container_at([
|
||||
@@ -875,15 +881,62 @@ impl Game<'_> {
|
||||
]) else {
|
||||
return false;
|
||||
};
|
||||
self.native_hopper_inventory_search([pos.0, pos.1, pos.2], None, Some(id));
|
||||
self.with_entity_container(id, |g, target| g.eject_into(pos, hopper, target, face))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// Where a container is in the world for the mods: its block, or the block a chest
|
||||
/// boat or minecart is in (its view sits under a placeholder key).
|
||||
fn container_world_pos(&self, view: &View) -> [i32; 3] {
|
||||
let (x, y, z) = view.parts[0].0;
|
||||
if x == i32::MAX - 15 && y == super::minecarts::CART_KEY_Y {
|
||||
let id = z as u32;
|
||||
let at = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| c.position)
|
||||
.or_else(|| self.boats.iter().find(|b| b.id == id).map(|b| b.position));
|
||||
if let Some(p) = at {
|
||||
return [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
}
|
||||
}
|
||||
[x, y, z]
|
||||
}
|
||||
/// `InventoryMoveItemEvent` for the item in `slot` of `holder` going from `source` to
|
||||
/// `destination`: whether a mod canceled the move. Nothing is looked up with no mod loaded.
|
||||
fn hopper_move_canceled(
|
||||
&self,
|
||||
holder: &View,
|
||||
slot: usize,
|
||||
source: &View,
|
||||
destination: &View,
|
||||
) -> bool {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let containers = self.containers();
|
||||
let Some(stack) = containers.get(holder, slot) else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_inventory_move_item(
|
||||
self.container_world_pos(source),
|
||||
self.container_world_pos(destination),
|
||||
&stack.item,
|
||||
&mut cancel,
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// The rest of `HopperBlockEntity.ejectItems`: one item into `target`.
|
||||
fn eject_into(&mut self, pos: Pos, hopper: &View, target: &View, face: Direction) -> bool {
|
||||
if self.containers().is_full(target, face) {
|
||||
return false;
|
||||
}
|
||||
for slot in 0..hopper.len() {
|
||||
if self.hopper_move_canceled(hopper, slot, hopper, target) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(hopper, slot) else {
|
||||
continue;
|
||||
};
|
||||
@@ -946,9 +999,27 @@ impl Game<'_> {
|
||||
let c = self.containers();
|
||||
let Some(source) = c.view(above, false) else {
|
||||
let id = self.entity_container_at(at)?;
|
||||
self.native_hopper_inventory_search(
|
||||
[
|
||||
hopper.parts[0].0.0,
|
||||
hopper.parts[0].0.1,
|
||||
hopper.parts[0].0.2,
|
||||
],
|
||||
None,
|
||||
Some(id),
|
||||
);
|
||||
return self
|
||||
.with_entity_container(id, |g, source| g.suck_from_slots(None, source, hopper));
|
||||
};
|
||||
self.native_hopper_inventory_search(
|
||||
[
|
||||
hopper.parts[0].0.0,
|
||||
hopper.parts[0].0.1,
|
||||
hopper.parts[0].0.2,
|
||||
],
|
||||
Some([above.0, above.1, above.2]),
|
||||
None,
|
||||
);
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return Some(false);
|
||||
@@ -959,6 +1030,18 @@ impl Game<'_> {
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
if !horizon_client::mod_api::mods().is_empty() {
|
||||
let mut cancel = false;
|
||||
self.native_inventory_move_item(
|
||||
[above.0, above.1, above.2],
|
||||
self.container_world_pos(hopper),
|
||||
&bone_meal.item,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
return Some(false);
|
||||
}
|
||||
}
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
@@ -984,6 +1067,9 @@ impl Game<'_> {
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
if self.hopper_move_canceled(source, slot, source, hopper) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -1178,7 +1178,7 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
@@ -1224,7 +1224,7 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
self.give_or_drop_stack(locked);
|
||||
}
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1875,6 +1875,13 @@ impl Game<'_> {
|
||||
.modified
|
||||
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
|
||||
self.carts.push(cart);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let cart = self.carts.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
cart.kind.id(),
|
||||
[cart.position.x, cart.position.y, cart.position.z],
|
||||
);
|
||||
}
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
@@ -2134,7 +2141,13 @@ impl Game<'_> {
|
||||
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
|
||||
/// once for creative players); TNT minecarts prime instead when moving
|
||||
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
|
||||
pub(super) fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
|
||||
pub(super) fn hurt_cart(&mut self, i: usize, mut damage: f32, creative: bool, fire: bool) {
|
||||
let kind = self.carts[i].kind.id();
|
||||
let mut cancel = false;
|
||||
self.native_vehicle_damage(kind, &mut damage, &mut cancel);
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
let destroyed = {
|
||||
let c = &mut self.carts[i];
|
||||
c.hurt_dir = -c.hurt_dir;
|
||||
@@ -2154,6 +2167,14 @@ impl Game<'_> {
|
||||
if !tnt_prime {
|
||||
self.release_vehicle_container(id);
|
||||
}
|
||||
if !tnt_prime {
|
||||
let at = [
|
||||
self.carts[i].position.x,
|
||||
self.carts[i].position.y,
|
||||
self.carts[i].position.z,
|
||||
];
|
||||
self.native_vehicle_destroy(self.carts[i].kind.id(), at);
|
||||
}
|
||||
let mut c = self.carts.remove(i);
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
|
||||
// MinecartTNT.destroy: prime instead of breaking.
|
||||
|
||||
@@ -187,10 +187,11 @@ impl Game<'_> {
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
skin: Option<&mut super::creatures::Skinned>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.captain_banner_geometry(mob, camera, alpha, light, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, skin, out);
|
||||
self.worn_block_geometry(mob, camera, alpha, light, out);
|
||||
self.head_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.golem_flower_geometry(mob, camera, alpha, light, out);
|
||||
@@ -376,6 +377,7 @@ impl Game<'_> {
|
||||
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
|
||||
/// applies the level's diffuse lighting (off for GUI previews, which
|
||||
/// light the projected quads themselves).
|
||||
/// `skin`: the mob's GPU path draws, which a lit item joins when it can.
|
||||
pub(super) fn held_item_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
@@ -383,6 +385,7 @@ impl Game<'_> {
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
lit: bool,
|
||||
mut skin: Option<&mut super::creatures::Skinned>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// ItemInHandLayer submits with OverlayTexture.NO_OVERLAY: no hurt tint.
|
||||
@@ -483,6 +486,12 @@ impl Game<'_> {
|
||||
horizon_client::cit::Hand::Main
|
||||
};
|
||||
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, hand);
|
||||
if lit
|
||||
&& let Some(skinned) = skin.as_deref_mut()
|
||||
&& self.skin_item_into(&state, t, light, skinned)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if lit {
|
||||
self.submit_item_world(&state, t, light, out);
|
||||
} else {
|
||||
|
||||
@@ -483,4 +483,7 @@ impl Host for super::Game<'_> {
|
||||
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
|
||||
self.native_item_stack(batch, stack, at, size)
|
||||
}
|
||||
fn instance_dir(&self) -> PathBuf {
|
||||
self.native_instance_dir()
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ pub(super) fn content_root(
|
||||
paths.extend(dev.iter().cloned());
|
||||
let mut known = None;
|
||||
let mut mods: Vec<ContentMod> = Vec::new();
|
||||
// The first load's list stays (`set_local`): a reload does not hash the files again.
|
||||
let announce = !horizon_protocol::mod_list::is_set();
|
||||
let mut announced = Vec::new();
|
||||
for path in &paths {
|
||||
let Ok(package) = Package::read(path) else {
|
||||
continue;
|
||||
@@ -121,8 +124,10 @@ pub(super) fn content_root(
|
||||
id: m.id.clone(),
|
||||
content,
|
||||
};
|
||||
if let Err(e) = mod_content::accept_mod(&mut mods, candidate, known) {
|
||||
log::warn!("Horizon: mod {} content not loaded: {e}", m.id);
|
||||
match mod_content::accept_mod(&mut mods, candidate, known) {
|
||||
Ok(_) if announce => announced.push(announced_entry(m, path)),
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Horizon: mod {} content not loaded: {e}", m.id),
|
||||
}
|
||||
}
|
||||
let (blocks, items) = native_declared();
|
||||
@@ -146,6 +151,9 @@ pub(super) fn content_root(
|
||||
}
|
||||
match mod_content::resource_root(root, &mods, &here.join(".horizon-cache")) {
|
||||
Ok(out) => {
|
||||
if announce {
|
||||
horizon_protocol::mod_list::set_local(announced);
|
||||
}
|
||||
if out != root {
|
||||
log::info!(
|
||||
"Horizon: content from {} mod(s) in {}",
|
||||
@@ -168,6 +176,34 @@ pub(super) fn content_root(
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded mod as a server tells joining clients (`horizon_protocol::mod_list`): its manifest's
|
||||
/// id, version and side, and the SHA-256 of its `.hmod` (none for a folder).
|
||||
fn announced_entry(
|
||||
m: &horizon_mods::Manifest,
|
||||
path: &std::path::Path,
|
||||
) -> horizon_protocol::mod_list::Entry {
|
||||
use horizon_mods::Side;
|
||||
use horizon_protocol::mod_list;
|
||||
use sha2::{Digest, Sha256};
|
||||
let sha256 = if path.is_file() {
|
||||
std::fs::read(path)
|
||||
.ok()
|
||||
.map(|bytes| Sha256::digest(bytes).into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
mod_list::Entry {
|
||||
id: m.id.clone(),
|
||||
version: m.version.clone(),
|
||||
sha256,
|
||||
side: match m.side {
|
||||
Side::Both => mod_list::Side::Both,
|
||||
Side::Server => mod_list::Side::Server,
|
||||
Side::Client => mod_list::Side::Client,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The mods' mob effects join vanilla's (`brewing::effect`), once the root
|
||||
/// holding their network ids is built. The startup root's effects, none
|
||||
/// included, stay until restart: a pack reload after a Mods switch builds a
|
||||
@@ -237,10 +273,20 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
|
||||
const BUILD_INFO: &str = "native-mods.json";
|
||||
|
||||
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
|
||||
fn built_with_mods(built: &str) -> bool {
|
||||
/// The native mods the launcher built this game with, `id version, id version`, for the crash
|
||||
/// report (`built`: its [`BUILD_INFO`]). None for a release or Nightly, which have no mods.
|
||||
fn native_mods_detail(built: &str) -> Option<String> {
|
||||
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
|
||||
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
|
||||
let list = info["mods"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let text = |key: &str| m[key].as_str().unwrap_or("?").to_owned();
|
||||
format!("{} {}", text("id"), text("version"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
(!list.is_empty()).then_some(list)
|
||||
}
|
||||
|
||||
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
|
||||
@@ -330,8 +376,13 @@ impl Game<'_> {
|
||||
.ok()
|
||||
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
|
||||
.unwrap_or_default();
|
||||
let built_mods = native_mods_detail(&built);
|
||||
let built_with_mods = built_mods.is_some();
|
||||
if let Some(list) = built_mods {
|
||||
horizon_client::crash::set_detail("Native Mods", list);
|
||||
}
|
||||
horizon_client::gfx::native_render::set_mods_built_in(
|
||||
built_with_mods(&built)
|
||||
built_with_mods
|
||||
|| self
|
||||
.settings
|
||||
.mods
|
||||
@@ -392,6 +443,18 @@ mod tests {
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_crash_report_lists_the_builds_native_mods() {
|
||||
let built = r#"{"mods":[{"id":"machine_sample","version":"0.1.0","sha256":null},{"id":"pipes","version":"2.0"}]}"#;
|
||||
assert_eq!(
|
||||
native_mods_detail(built).as_deref(),
|
||||
Some("machine_sample 0.1.0, pipes 2.0")
|
||||
);
|
||||
// A release has no file; a build without mods has an empty list.
|
||||
assert_eq!(native_mods_detail(""), None);
|
||||
assert_eq!(native_mods_detail(r#"{"mods":[]}"#), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_mods_installed_but_not_built_get_a_row_saying_so() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-not-built-{}", std::process::id()));
|
||||
|
||||
@@ -258,6 +258,87 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Entities joining the level (`native_entity_join_level`), for every kind in
|
||||
/// the entity walk. Kept out of `native_hooks.rs`, whose lines may not hold macros.
|
||||
impl Game<'_> {
|
||||
/// Tells `native_entity_join_level` once for every entity added since the
|
||||
/// last call, of any kind, in the order they were created (ids count up
|
||||
/// from one counter). A mod that cancels takes the entity back out here,
|
||||
/// before it ticks again, and `/summon` of it fails (`joins_canceled`).
|
||||
/// `loaded_from_disk` is for the entities that came with a chunk. Returns
|
||||
/// whether `watch` (an id, or 0) is still in the level. Walks the lists
|
||||
/// with static dispatch and allocates nothing but the kind name of an
|
||||
/// entity it announces; nothing runs when no mod is loaded or on a client
|
||||
/// of a server (which tells nothing).
|
||||
pub(super) fn native_joins_flush(&mut self, loaded_from_disk: bool, watch: u32) -> bool {
|
||||
use super::entity::Entity;
|
||||
if self.remote.is_some() || crate::mod_api::mods().is_empty() {
|
||||
return true;
|
||||
}
|
||||
for t in &mut self.tnts {
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
|
||||
}
|
||||
for f in &mut self.falling {
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut f.id);
|
||||
}
|
||||
for p in &mut self.creatures.projectiles {
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut p.id);
|
||||
}
|
||||
for t in &mut self.tridents {
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
|
||||
}
|
||||
for r in &mut self.rockets {
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
|
||||
}
|
||||
let mut kept = true;
|
||||
while let Some((handle, id)) = self.next_unannounced() {
|
||||
self.persist.joined_mark = id;
|
||||
let (kind, at) = super::entity::with_entity!(self, handle, e => {
|
||||
(e.native_type(), e.position())
|
||||
});
|
||||
let mut canceled = false;
|
||||
let dimension = self.dim.id();
|
||||
self.native_entity_join_level(
|
||||
dimension,
|
||||
id,
|
||||
&kind,
|
||||
[at.x, at.y, at.z],
|
||||
loaded_from_disk,
|
||||
&mut canceled,
|
||||
);
|
||||
if canceled {
|
||||
self.persist.joins_canceled += 1;
|
||||
self.forget_entity(id);
|
||||
// Handles are indexes: the entity is taken out before any
|
||||
// other change to its list.
|
||||
self.discard_entity(handle);
|
||||
kept &= id != watch;
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
/// The entity with the lowest id above the last one announced, by handle.
|
||||
fn next_unannounced(&self) -> Option<(super::entity::EntityHandle, u32)> {
|
||||
let mark = self.persist.joined_mark;
|
||||
let mut best: Option<(super::entity::EntityHandle, u32)> = None;
|
||||
for handle in self.entity_handles() {
|
||||
let id = {
|
||||
use super::entity::Entity;
|
||||
super::entity::with_entity!(self, handle, e => e.id())
|
||||
};
|
||||
if id > mark && best.is_none_or(|(_, b)| id < b) {
|
||||
best = Some((handle, id));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
/// Entities that were moved, not added (a portal's arrival keeps being
|
||||
/// silent): everything now in the level counts as announced.
|
||||
pub(super) fn native_joins_skip(&mut self) {
|
||||
self.persist.joined_mark = self.persist.joined_mark.max(self.next_entity);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -296,8 +296,10 @@
|
||||
//! `native_extract_item`, `native_move_items`, and `native_dimension` for a
|
||||
//! machine's tick, in `machines/native_items.rs`).
|
||||
//!
|
||||
//! API 47 adds entities joining a level (`native_entity_join_level`), for
|
||||
//! item entities and experience orbs so far.
|
||||
//! API 47 adds entities joining a level (`native_entity_join_level`); it tells
|
||||
//! every kind the game keeps in its entity lists (items, orbs, mobs, boats,
|
||||
//! minecarts, armor stands, TNT, falling blocks, projectiles, rockets,
|
||||
//! tridents).
|
||||
//!
|
||||
//! API 46 adds bounded world-space rim and colored-light requests
|
||||
//! (`native_visuals`) and opt-in texture-matched emissive material
|
||||
@@ -2417,13 +2419,15 @@ impl Game<'_> {
|
||||
/// `&self`, so it reads and draws only. The Win and Options screens call
|
||||
/// it on their own path, after their drawing.
|
||||
///
|
||||
/// It does not fire on the title screen, which returns before its point,
|
||||
/// nor for the screens drawn over gameplay after it (a server dialog, the
|
||||
/// server pack, link and code-of-conduct prompts, a book, a sign editor):
|
||||
/// those keep `Screen::Playing`, which it skips (the server pack prompt
|
||||
/// hides an open screen too, and that screen does not fire under it),
|
||||
/// and draw over what it drew, while vanilla's `ScreenEvent.Render.Post`
|
||||
/// fires for them.
|
||||
/// It does not fire for the screens drawn over gameplay after it (a
|
||||
/// server dialog, the server pack, link and code-of-conduct prompts, a
|
||||
/// book, a sign editor): those keep `Screen::Playing`, which it skips (the
|
||||
/// server pack prompt hides an open screen too, and that screen does not
|
||||
/// fire under it), and draw over what it drew, while vanilla's
|
||||
/// `ScreenEvent.Render.Post` fires for them. The title's own screens
|
||||
/// (`title`, `world_select`, `world_create`, `multiplayer`…) fire it too,
|
||||
/// their `screen.id` from the title state machine and the whole window
|
||||
/// their `rect` (API @API:native_instance_dir@), and no dialog or prompt over them.
|
||||
/// API 78.
|
||||
pub(super) fn native_screen_render_post(
|
||||
&self,
|
||||
@@ -2443,10 +2447,11 @@ impl Game<'_> {
|
||||
/// no screen button, no drag, no hotbar key, no sort or header button.
|
||||
///
|
||||
/// It fires before anything the screen does with the press, on the window
|
||||
/// only, and only while a screen of the game's is open (Options and the
|
||||
/// pack list opened from the title screen count, with no player and no
|
||||
/// `menu`). It does not fire on the title screen itself, the connect and
|
||||
/// loading screens, nor for a server
|
||||
/// only, and while a screen of the game's or the title's is open (Options
|
||||
/// and the pack list opened from the title screen count, with no player
|
||||
/// and no `menu`; the title's own screens since API @API:native_instance_dir@, their
|
||||
/// `screen.id` from the title state machine). It does not fire on the
|
||||
/// connect and loading screens, nor for a server
|
||||
/// dialog, the server pack, link and code-of-conduct prompts, a book or a
|
||||
/// sign editor drawn over gameplay, nor while a world loads or saves: the
|
||||
/// click there stays the game's own. A gamepad click or a click the
|
||||
@@ -2533,9 +2538,10 @@ impl Game<'_> {
|
||||
///
|
||||
/// It fires before the screen scrolls anything, for the window's wheel
|
||||
/// (horizon-ctl's `scroll-at` sends it through the window too), and
|
||||
/// only while a screen of the game's is open (Options and the pack list
|
||||
/// opened from the title screen count, with no player and no `menu`). It
|
||||
/// does not fire on the title screen itself, the connect and loading
|
||||
/// while a screen of the game's or the title's is open (Options and the
|
||||
/// pack list opened from the title screen count, with no player and no
|
||||
/// `menu`; the title's own since API @API:native_instance_dir@). It
|
||||
/// does not fire on the connect and loading
|
||||
/// screens, nor for a server dialog, the server pack, link and
|
||||
/// code-of-conduct prompts, a book or a sign editor drawn over gameplay,
|
||||
/// nor while a world loads or saves: the scroll there stays the game's
|
||||
@@ -2571,10 +2577,10 @@ impl Game<'_> {
|
||||
/// It fires for the window's presses and their repeats, and for the
|
||||
/// control socket's `key` (a release fires nothing), after the window's
|
||||
/// own shortcuts have run — the fullscreen and screenshot keys, the debug
|
||||
/// keys and the narrator — and only while a screen of the game's is open
|
||||
/// (Options and the pack list opened from the title screen count, with no
|
||||
/// player and no `menu`). It does not fire on the title screen itself, the
|
||||
/// connect and loading screens, nor for a server dialog, the server pack,
|
||||
/// keys and the narrator — and while a screen of the game's or the title's
|
||||
/// is open (Options and the pack list opened from the title screen count,
|
||||
/// with no player and no `menu`; the title's own since API @API:native_instance_dir@). It does
|
||||
/// not fire on the connect and loading screens, nor for a server dialog, the server pack,
|
||||
/// link and code-of-conduct prompts, a book or a sign editor drawn over
|
||||
/// gameplay, nor while a world loads or saves: the key there stays the
|
||||
/// game's own. A cancelled press's release still runs, so a `native_key`
|
||||
@@ -2673,6 +2679,23 @@ impl Game<'_> {
|
||||
partial_tick: self.clock.fraction() as f32,
|
||||
}
|
||||
}
|
||||
/// The title's own screen as a native mod sees it (`NativeScreenView`):
|
||||
/// its id from the title state machine (`title`, `world_select`,
|
||||
/// `world_create`, `multiplayer`, `add_server`…), no menu and no menu
|
||||
/// title, the whole window as its rectangle and no slots (no menu is
|
||||
/// open). None while no title screen is up. API @API:native_instance_dir@.
|
||||
pub(super) fn title_screen_view(&self, batch: &HudBatch) -> Option<NativeScreenView<'_>> {
|
||||
let title = self.title.as_ref()?;
|
||||
Some(NativeScreenView {
|
||||
id: title.mode.id(),
|
||||
menu: None,
|
||||
title: None,
|
||||
rect: [0.0, 0.0, batch.width, batch.height],
|
||||
slots: 0,
|
||||
mouse: self.native_cursor(),
|
||||
partial_tick: self.clock.fraction() as f32,
|
||||
})
|
||||
}
|
||||
/// The open screen's container panel in GUI pixels (`screen`'s, the one
|
||||
/// this frame draws), or the whole window for a screen with none
|
||||
/// (`NativeScreenView.rect`).
|
||||
@@ -2719,15 +2742,16 @@ impl Game<'_> {
|
||||
canceled
|
||||
}
|
||||
/// A screen the native mods' input hooks are for is open: the window has a
|
||||
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
|
||||
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
|
||||
/// and no connect or world load is in progress. The one gate they all ask
|
||||
/// before they fire.
|
||||
/// renderer, either the title's own (`title`, `world_select`,
|
||||
/// `world_create`, `multiplayer`…) or a screen over the world
|
||||
/// (`Screen::Playing` behind it, so not a book, a sign editor, a server
|
||||
/// dialog or a prompt drawn over gameplay), and no connect or world load
|
||||
/// is in progress. The one gate they all ask before they fire.
|
||||
pub(super) fn screen_hooks_open(&self) -> bool {
|
||||
self.renderer.is_some()
|
||||
&& self.screen != Screen::Playing
|
||||
&& !self.screen_over_playing()
|
||||
&& !self.remote_loading()
|
||||
&& (self.screen != Screen::Playing || self.title.is_some())
|
||||
}
|
||||
/// The open screen as an input hook sees it (`NativeScreenView`), or None
|
||||
/// when `screen_hooks_open` says no screen is. `screen.mouse` is the
|
||||
@@ -2741,7 +2765,10 @@ impl Game<'_> {
|
||||
}
|
||||
let renderer = self.renderer.as_ref()?;
|
||||
let batch = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
Some(self.screen_view(&batch, self.screen))
|
||||
if self.screen != Screen::Playing {
|
||||
return Some(self.screen_view(&batch, self.screen));
|
||||
}
|
||||
self.title_screen_view(&batch)
|
||||
}
|
||||
/// Whether the key press `key` over the open screen went no further
|
||||
/// (`native_screen_key_pressed_pre`): what `handle_window_event` and the
|
||||
@@ -3135,6 +3162,11 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_player_toggle_flight(self, mod_api::mods(), player, name, flying, cancel);
|
||||
}
|
||||
/// A guest's client changed sneaking in its player input packet, after the hub applied the new state.
|
||||
/// The guest's state is swapped in. Server-side. @API:player_toggle_sneak@.
|
||||
pub(super) fn native_player_toggle_sneak(&mut self, player: u128, name: &str, sneaking: bool) {
|
||||
mod_api::dispatch_player_toggle_sneak(self, mod_api::mods(), player, name, sneaking);
|
||||
}
|
||||
/// A player finished editing a sign: `ServerGamePacketListenerImpl.updateSignText`
|
||||
/// has taken their update packet and every check has passed (they were
|
||||
/// editing that sign, within reach, the sign is not waxed, the chat filter
|
||||
@@ -3572,6 +3604,57 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_player_advancement_done(self, mod_api::mods(), player, name, advancement);
|
||||
}
|
||||
/// A criterion of a server player's advancement was granted or revoked,
|
||||
/// after its progress changed and before any completion rewards, except
|
||||
/// the `minecraft:tick` criteria granted on a network player's first tick
|
||||
/// or a reload, which are heard after that tick's rewards and packets. A
|
||||
/// grant or revoke that changes nothing is not heard. `granted` is false for a
|
||||
/// revoke. Helpers looking up the guest through `self.lan` may find
|
||||
/// nothing during its packet. @API:player_advancement_progress@.
|
||||
pub(super) fn native_player_advancement_progress(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
advancement: &str,
|
||||
criterion: &str,
|
||||
granted: bool,
|
||||
) {
|
||||
mod_api::dispatch_player_advancement_progress(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
advancement,
|
||||
criterion,
|
||||
granted,
|
||||
);
|
||||
}
|
||||
/// A statistic is about to be awarded to a server player, before its count
|
||||
/// and scoreboard criteria change. `stat_type` and `stat` are ids such as
|
||||
/// `minecraft:mined` and `minecraft:stone`, and `value` the amount. Set
|
||||
/// `cancel` to award nothing. Helpers looking up the guest through
|
||||
/// `self.lan` may find nothing during its packet. Server-side.
|
||||
/// @API:player_stat_award@.
|
||||
pub(super) fn native_player_stat_award(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
stat_type: &str,
|
||||
stat: &str,
|
||||
value: i32,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_player_stat_award(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
stat_type,
|
||||
stat,
|
||||
value,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
/// A server player's experience points are about to increase from an orb pickup,
|
||||
/// after mending has taken its share. Change `amount` to change the points
|
||||
/// added. The player's state is not swapped in, so helpers that look them up
|
||||
@@ -4465,6 +4548,27 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_prepare_anvil(self, mod_api::mods(), player, name, pos, cost, result);
|
||||
}
|
||||
/// A player took an anvil's result at `pos` (`AnvilCraftEvent.Pre`), before the levels are charged, the inputs used up and the anvil worn. `craft` holds the inputs and the result as they were before. Set `cancel` to refuse the take: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. @API:native_anvil_craft_pre@.
|
||||
pub(super) fn native_anvil_craft_pre(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
pos: [i32; 3],
|
||||
craft: &horizon_client::menu::AnvilCraft,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_anvil_craft_pre(self, mod_api::mods(), player, name, pos, craft, cancel);
|
||||
}
|
||||
/// A player took an anvil's result at `pos` and the levels, inputs and anvil wear are done (`AnvilCraftEvent.Post`); a broken anvil is already air. `craft` holds the inputs and the result as they were before. Not called when `native_anvil_craft_pre` cancelled. @API:native_anvil_craft_post@.
|
||||
pub(super) fn native_anvil_craft_post(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
pos: [i32; 3],
|
||||
craft: &horizon_client::menu::AnvilCraft,
|
||||
) {
|
||||
mod_api::dispatch_anvil_craft_post(self, mod_api::mods(), player, name, pos, craft);
|
||||
}
|
||||
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_grindstone@.
|
||||
pub(super) fn native_prepare_grindstone(
|
||||
&mut self,
|
||||
@@ -4786,6 +4890,223 @@ impl Game<'_> {
|
||||
mod_api::dispatch_entity_enter_block(self, mod_api::mods(), entity, kind, pos, block);
|
||||
}
|
||||
|
||||
/// A server player finished loading the world around them after joining,
|
||||
/// respawning or changing dimension. The guest's state is not swapped in,
|
||||
/// so helpers that look them up through `self.lan` may find nothing while
|
||||
/// a guest packet is handled. Server-side. @API:player_client_loaded_world@.
|
||||
pub(super) fn native_player_client_loaded_world(&mut self, player: u128, name: &str) {
|
||||
mod_api::dispatch_player_client_loaded_world(self, mod_api::mods(), player, name);
|
||||
}
|
||||
/// A server player released the use-item key before the item finished
|
||||
/// being used. The guest's state is not swapped in, so helpers that look
|
||||
/// them up through `self.lan` may find nothing during packet handling.
|
||||
/// @API:player_stop_using_item@.
|
||||
pub(super) fn native_player_stop_using_item(&mut self, player: u128, name: &str) {
|
||||
mod_api::dispatch_player_stop_using_item(self, mod_api::mods(), player, name);
|
||||
}
|
||||
/// A server player's recipe book open or filter setting changed. `book`
|
||||
/// is the book type's protocol id. The guest's state is not swapped in.
|
||||
/// @API:player_recipe_book_settings_change@.
|
||||
pub(super) fn native_player_recipe_book_settings_change(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
book: i32,
|
||||
open: bool,
|
||||
filtering: bool,
|
||||
) {
|
||||
mod_api::dispatch_player_recipe_book_settings_change(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
book,
|
||||
open,
|
||||
filtering,
|
||||
);
|
||||
}
|
||||
/// A server player earned one criterion of an advancement, after the
|
||||
/// progress was recorded. @API:player_advancement_criterion_grant@.
|
||||
pub(super) fn native_player_advancement_criterion_grant(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
advancement: &str,
|
||||
criterion: &str,
|
||||
) {
|
||||
mod_api::dispatch_player_advancement_criterion_grant(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
advancement,
|
||||
criterion,
|
||||
);
|
||||
}
|
||||
/// A server player released a drawn bow with `arrow` selected. The guest's
|
||||
/// state is not swapped in during packet handling. @API:player_ready_arrow@.
|
||||
pub(super) fn native_player_ready_arrow(&mut self, player: u128, name: &str, arrow: &str) {
|
||||
mod_api::dispatch_player_ready_arrow(self, mod_api::mods(), player, name, arrow);
|
||||
}
|
||||
/// A server player selected a bundle item in `slot` at `index`. The guest's
|
||||
/// state is not swapped in during packet handling. @API:player_bundle_item_select@.
|
||||
pub(super) fn native_player_bundle_item_select(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
slot: i32,
|
||||
index: i32,
|
||||
) {
|
||||
mod_api::dispatch_player_bundle_item_select(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
slot,
|
||||
index,
|
||||
);
|
||||
}
|
||||
/// A server player stopped digging `pos` before the block broke, after
|
||||
/// the server cleared the dig and sent the block back to the client.
|
||||
/// The player's state is not swapped in. @API:block_damage_abort@.
|
||||
pub(super) fn native_block_damage_abort(&mut self, player: u128, name: &str, pos: [i32; 3]) {
|
||||
mod_api::dispatch_block_damage_abort(self, mod_api::mods(), player, name, pos);
|
||||
}
|
||||
/// A hopper at `pos` began an item transfer attempt. This runs after its
|
||||
/// cooldown and enabled checks, before pushing or pulling any items.
|
||||
/// Server-side, including the single-player server. @API:prepare_hopper_transfer@.
|
||||
pub(super) fn native_prepare_hopper_transfer(&self, pos: [i32; 3]) {
|
||||
mod_api::dispatch_prepare_hopper_transfer(self, mod_api::mods(), pos);
|
||||
}
|
||||
/// A hopper is about to move one item between containers, a composter's
|
||||
/// bone meal included. Set `cancel` to leave it in its source slot. A chest
|
||||
/// boat or minecart is reported at the block it is in. @API:inventory_move_item@.
|
||||
pub(super) fn native_inventory_move_item(
|
||||
&self,
|
||||
source: [i32; 3],
|
||||
destination: [i32; 3],
|
||||
item: &str,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_inventory_move_item(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
source,
|
||||
destination,
|
||||
item,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
/// A hopper selected a container while searching for items to push or
|
||||
/// pull. Exactly one of `container` and `entity` identifies the target.
|
||||
/// @API:hopper_inventory_search@.
|
||||
pub(super) fn native_hopper_inventory_search(
|
||||
&self,
|
||||
hopper: [i32; 3],
|
||||
container: Option<[i32; 3]>,
|
||||
entity: Option<u32>,
|
||||
) {
|
||||
mod_api::dispatch_hopper_inventory_search(self, mod_api::mods(), hopper, container, entity);
|
||||
}
|
||||
/// A vault at `pos` is about to eject a loot item. @API:vault_loot@.
|
||||
pub(super) fn native_vault_loot(&self, pos: [i32; 3], item: &str) {
|
||||
mod_api::dispatch_vault_loot(self, mod_api::mods(), pos, item);
|
||||
}
|
||||
/// A timed world-border size change finished on the server, after its
|
||||
/// final size was applied. Server-side, including the single-player
|
||||
/// server. @API:world_border_bounds_change_finish@.
|
||||
pub(super) fn native_world_border_bounds_change_finish(&mut self) {
|
||||
mod_api::dispatch_world_border_bounds_change_finish(self, mod_api::mods());
|
||||
}
|
||||
|
||||
/// A server player launched a projectile item, after it was created.
|
||||
/// `item` is the projectile's held item. @API:player_launch_projectile@.
|
||||
pub(super) fn native_player_launch_projectile(&mut self, player: u128, name: &str, item: &str) {
|
||||
mod_api::dispatch_player_launch_projectile(self, mod_api::mods(), player, name, item);
|
||||
}
|
||||
/// A server explosion is about to begin at `center`, with `radius` (Bukkit's
|
||||
/// `ExplosionPrimeEvent`). This is listen-only: the explosion proceeds as
|
||||
/// usual. Server-side, including the single-player server. @API:explosion_prime@.
|
||||
pub(super) fn native_explosion_prime(&mut self, center: [f64; 3], radius: f32) {
|
||||
mod_api::dispatch_explosion_prime(self, mod_api::mods(), center, radius);
|
||||
}
|
||||
/// A dropped item entity reached its 6000-tick lifetime and is about to be
|
||||
/// removed (Bukkit's `ItemDespawnEvent`). @API:item_despawn@.
|
||||
pub(super) fn native_item_despawn(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
item: &str,
|
||||
count: i32,
|
||||
pos: [f64; 3],
|
||||
) {
|
||||
mod_api::dispatch_item_despawn(self, mod_api::mods(), entity, item, count, pos);
|
||||
}
|
||||
/// A mob is about to drop `count` of `item` at `pos` (Bukkit's
|
||||
/// `EntityDropItemEvent`). @API:entity_drop_item@.
|
||||
pub(super) fn native_entity_drop_item(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
item: &str,
|
||||
count: i32,
|
||||
pos: [f64; 3],
|
||||
) {
|
||||
mod_api::dispatch_entity_drop_item(self, mod_api::mods(), entity, item, count, pos);
|
||||
}
|
||||
/// A leashed mob's leash was removed, before any lead drop is handled
|
||||
/// (Bukkit's `EntityUnleashEvent`). @API:entity_unleash@.
|
||||
pub(super) fn native_entity_unleash(&mut self, entity: u32, kind: &str) {
|
||||
mod_api::dispatch_entity_unleash(self, mod_api::mods(), entity, kind);
|
||||
}
|
||||
/// A vehicle is about to take damage from a player's attack. `kind` is
|
||||
/// `boat` or the minecart type; mods may change `damage` or set `cancel`
|
||||
/// to leave its damage and wobble unchanged. The attack itself still
|
||||
/// completes. Server-side. @API:native_vehicle_damage@.
|
||||
pub(super) fn native_vehicle_damage(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
damage: &mut f32,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_vehicle_damage(self, mod_api::mods(), kind, damage, cancel);
|
||||
}
|
||||
/// A boat or minecart was created at `pos`, after it joined the level.
|
||||
/// `kind` is the vehicle type. Server-side. @API:native_vehicle_create@.
|
||||
pub(super) fn native_vehicle_create(&mut self, kind: &str, pos: [f64; 3]) {
|
||||
mod_api::dispatch_vehicle_create(self, mod_api::mods(), kind, pos);
|
||||
}
|
||||
/// A boat or minecart is about to be destroyed at `pos`. TNT minecarts
|
||||
/// that prime instead of breaking are not heard. Server-side.
|
||||
/// @API:native_vehicle_destroy@.
|
||||
pub(super) fn native_vehicle_destroy(&mut self, kind: &str, pos: [f64; 3]) {
|
||||
mod_api::dispatch_vehicle_destroy(self, mod_api::mods(), kind, pos);
|
||||
}
|
||||
/// A guest sent the server's current `PlayerInput` flags, after they were applied to the hub. The guest's state is swapped in. Server-side. @API:player_input@.
|
||||
pub(super) fn native_player_input(&mut self, player: u128, name: &str, flags: i32) {
|
||||
mod_api::dispatch_player_input(self, mod_api::mods(), player, name, flags);
|
||||
}
|
||||
/// A guest's arm swing was handled by the server. `hand` is `mainhand`. Server-side. @API:player_arm_swing@.
|
||||
pub(super) fn native_player_arm_swing(&mut self, player: u128, name: &str, hand: &str) {
|
||||
mod_api::dispatch_player_arm_swing(self, mod_api::mods(), player, name, hand);
|
||||
}
|
||||
/// A guest's accepted movement made the server jump them from the ground. Server-side. @API:player_jump@.
|
||||
pub(super) fn native_player_jump(&mut self, player: u128, name: &str) {
|
||||
mod_api::dispatch_player_jump(self, mod_api::mods(), player, name);
|
||||
}
|
||||
/// A guest was disconnected by a server kick, after the hub recorded the rendered `reason`. The guest's state is
|
||||
/// swapped in unless they were already removed in the same poll. @API:player_kicked@.
|
||||
pub(super) fn native_player_kicked(&mut self, player: u128, name: &str, reason: &str) {
|
||||
mod_api::dispatch_player_kicked(self, mod_api::mods(), player, name, reason);
|
||||
}
|
||||
/// A server player's experience level changed, after the new level was applied. @API:player_level_change@.
|
||||
pub(super) fn native_player_level_change(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
from: i32,
|
||||
to: i32,
|
||||
) {
|
||||
mod_api::dispatch_player_level_change(self, mod_api::mods(), player, name, from, to);
|
||||
}
|
||||
/// A click in a menu a mod opened (`native_open_menu`), which moved
|
||||
/// nothing: the menu's `slot` (its own first, then the player's
|
||||
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
|
||||
@@ -5313,6 +5634,48 @@ impl Game<'_> {
|
||||
pub(super) fn native_player_respawned(&mut self, player: u128, name: &str) {
|
||||
mod_api::dispatch_player_respawned(self, mod_api::mods(), player, name);
|
||||
}
|
||||
/// A player was saved to the world's store (NeoForge's
|
||||
/// `PlayerEvent.SaveToFile`, `PlayerList.save`): when they leave or the
|
||||
/// world closes, when the dedicated server saves everyone, and for an
|
||||
/// integrated server's owner when the world saves them. LAN guests are
|
||||
/// not heard on integrated autosaves. Once for each save. `directory`
|
||||
/// is the world's player directory (`players/data`): write your own file
|
||||
/// there, named `<uuid>.<suffix>`, never the player's `.dat`. The player
|
||||
/// may be leaving, so no player's state is swapped in. Not cancellable.
|
||||
pub(super) fn native_player_save_to_file(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
directory: &std::path::Path,
|
||||
) {
|
||||
mod_api::dispatch_player_save_to_file(self, mod_api::mods(), player, name, directory);
|
||||
}
|
||||
/// A player was loaded from the world's save (NeoForge's
|
||||
/// `PlayerEvent.LoadFromFile`, `PlayerList.loadPlayerData`): their `.dat`
|
||||
/// has been read and applied, and they are not placed in a level yet.
|
||||
/// `directory` is the world's player directory (`players/data`): a mod
|
||||
/// reads its own file there, named `<uuid>.<suffix>` as
|
||||
/// `getPlayerFile(suffix)` names it. Heard once for each join, for the
|
||||
/// owner of an integrated server as for anyone else, on a server, with no
|
||||
/// player's state swapped in. Not cancellable.
|
||||
pub(super) fn native_player_load_from_file(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
directory: &std::path::Path,
|
||||
) {
|
||||
mod_api::dispatch_player_load_from_file(self, mod_api::mods(), player, name, directory);
|
||||
}
|
||||
/// A player's data was copied to their new player (NeoForge's
|
||||
/// `PlayerEvent.Clone`, from `ServerPlayer.restoreFrom`): `was_death` is
|
||||
/// true after a death, false when they left the End after the credits
|
||||
/// (`respawn` with `keepAllPlayerData`). The uuid stays the same, so the
|
||||
/// old and the new player share `player`. Heard before the new player is
|
||||
/// placed, on a server, with no player's state swapped in. Not
|
||||
/// cancellable.
|
||||
pub(super) fn native_player_clone(&mut self, player: u128, name: &str, was_death: bool) {
|
||||
mod_api::dispatch_player_clone(self, mod_api::mods(), player, name, was_death);
|
||||
}
|
||||
/// The number of players with the mod container at `pos` of `dimension`
|
||||
/// open went from `previous` to `viewers` (vanilla's
|
||||
/// `ContainerOpenersCounter`, as a barrel keeps it): 0 to more than 0 is
|
||||
@@ -5547,9 +5910,15 @@ impl Game<'_> {
|
||||
/// an arrival through a portal). `entity` is its ID and `kind` its type
|
||||
/// (`minecraft:item`). It is already in the level's lists, so helpers that
|
||||
/// take an entity ID find it. Set `canceled` to keep it out: it is taken
|
||||
/// back out as if never added, and a `/summon` of it fails. 47 tells
|
||||
/// item entities and experience orbs; later APIs add other kinds, so check
|
||||
/// `kind`. Server-side.
|
||||
/// back out as if never added, and a `/summon` of it fails. Tells every
|
||||
/// kind in the game's entity lists (items, orbs, mobs, boats, minecarts,
|
||||
/// armor stands, primed TNT, falling blocks, projectiles, rockets,
|
||||
/// tridents), once each, in the order they were created; check `kind`. A
|
||||
/// gameplay spawn is told when the next tick starts (a drop, an orb, a mob, a
|
||||
/// `/summon` and a chunk's entities are told at once). A mod that
|
||||
/// cancels one of the others sees it removed before it ticks again.
|
||||
/// Not told: end crystals, the dragon, cushions, paintings, item frames and
|
||||
/// the entities Horizon keeps without simulating. Server-side.
|
||||
pub(super) fn native_entity_join_level(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -5792,6 +6161,19 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_item_picked_up(self, mod_api::mods(), player, item, count, canceled);
|
||||
}
|
||||
/// A dropped item reaches its lifetime on the server (`ItemEntity.tick`,
|
||||
/// NeoForge's `ItemExpireEvent`). Every mod may set or add to `extra_life`,
|
||||
/// in ticks, which starts at zero; a positive total keeps the item that much
|
||||
/// longer, zero or less lets it expire. Extensions are capped at 32766
|
||||
/// ticks. @API:item_expire@.
|
||||
pub(super) fn native_item_expire(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
stack: &ItemStack,
|
||||
extra_life: &mut i32,
|
||||
) {
|
||||
mod_api::dispatch_item_expire(self, mod_api::mods(), entity, stack, extra_life);
|
||||
}
|
||||
/// A player took a crafting grid's result (`ResultSlot.onTake`, NeoForge's
|
||||
/// `PlayerEvent.ItemCraftedEvent`): `player` is the player's uuid, `recipe`
|
||||
/// the recipe the grid matched (`minecraft:oak_planks`) and `stack` the
|
||||
@@ -7254,6 +7636,15 @@ impl Game<'_> {
|
||||
.chat
|
||||
.add(atlas, line, None, ChatSource::Client, self.ticks, focused);
|
||||
}
|
||||
/// The instance (game) folder, which its players share: `saves/`,
|
||||
/// `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are in it, and the
|
||||
/// title's screens create worlds and read packs there. A world's own
|
||||
/// `datapacks/` is in that world's folder (`saves/<world>/datapacks`).
|
||||
/// The folder the game runs in (`--player-dir` moves a player's options
|
||||
/// and servers only, not the instance). Nothing is created. API @API:native_instance_dir@.
|
||||
pub(super) fn native_instance_dir(&self) -> PathBuf {
|
||||
Self::game_dir()
|
||||
}
|
||||
/// A native mod's own folder: `horizon-mods/.horizon-mod-data/<id>/`,
|
||||
/// where the WebAssembly mod of the same id kept its storage, so a
|
||||
/// player's settings carry over. Not created here.
|
||||
@@ -7546,6 +7937,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// The mobs from index `from` on are new in the world.
|
||||
pub(super) fn native_mobs_joined(&mut self, from: usize, natural: bool) {
|
||||
// A mod can keep a mob out; it is then taken out and not told of.
|
||||
self.native_joins_flush(false, 0);
|
||||
for i in from..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
let (kind, pos) = (mob_kind(m), [m.position.x, m.position.y, m.position.z]);
|
||||
@@ -7626,34 +8019,6 @@ impl Game<'_> {
|
||||
.collect();
|
||||
self.native_entities_left("minecraft:experience_orb", &gone);
|
||||
}
|
||||
/// `native_entity_join_level` for `entity`, just put at the end of its
|
||||
/// list: false when a mod canceled, and the caller takes it back out.
|
||||
/// A client of a server tells nothing.
|
||||
pub(super) fn native_entity_joins(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
kind: &str,
|
||||
pos: [f64; 3],
|
||||
loaded_from_disk: bool,
|
||||
) -> bool {
|
||||
if self.remote.is_some() {
|
||||
return true;
|
||||
}
|
||||
let mut canceled = false;
|
||||
let dimension = self.dim.id();
|
||||
self.native_entity_join_level(
|
||||
dimension,
|
||||
entity,
|
||||
kind,
|
||||
pos,
|
||||
loaded_from_disk,
|
||||
&mut canceled,
|
||||
);
|
||||
if canceled {
|
||||
self.persist.joins_canceled += 1;
|
||||
}
|
||||
!canceled
|
||||
}
|
||||
/// The acting player's blow killed mob `index`.
|
||||
pub(super) fn native_kill_event(&mut self, index: usize) {
|
||||
let (entity, kind) = (self.mobs[index].id, mob_kind(&self.mobs[index]));
|
||||
@@ -9089,9 +9454,12 @@ mod tests {
|
||||
// split): horizon-mixins resolves the crate by its package.
|
||||
format!("horizon_client_lib::crafting::native_{name}")
|
||||
} else if menu_hooks.contains(name) {
|
||||
format!("horizon_client::menu::native_{name}")
|
||||
// menu.rs compiles into horizon-client-lib (#5446's
|
||||
// split): horizon-mixins resolves the crate by its package.
|
||||
format!("horizon_client_lib::menu::native_{name}")
|
||||
} else if breath_hooks.contains(name) {
|
||||
format!("horizon_client::breathing::native_{name}")
|
||||
// breathing.rs compiles into horizon-client-lib too.
|
||||
format!("horizon_client_lib::breathing::native_{name}")
|
||||
} else if level_hooks.contains(name) {
|
||||
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
|
||||
} else {
|
||||
@@ -9100,6 +9468,32 @@ mod tests {
|
||||
assert_eq!(hook["target"].as_str().unwrap(), target);
|
||||
}
|
||||
assert_eq!(names, declared);
|
||||
// Every target's crate really compiles its module: #5446 moved
|
||||
// files between horizon-client and horizon-client-lib, and a
|
||||
// target left on the old crate applies to no bundle — the string
|
||||
// comparison above shares its expectation with the catalog, so it
|
||||
// alone cannot catch a move. The first path segment after the
|
||||
// crate must be a module of that crate's lib root.
|
||||
let client_root = include_str!("../lib.rs");
|
||||
let lib_root = include_str!("../client_lib.rs");
|
||||
for hook in &hooks {
|
||||
let target = hook["target"].as_str().unwrap();
|
||||
let (root, crate_name, rest) =
|
||||
if let Some(rest) = target.strip_prefix("horizon_client::") {
|
||||
(client_root, "horizon_client", rest)
|
||||
} else if let Some(rest) = target.strip_prefix("horizon_client_lib::") {
|
||||
(lib_root, "horizon_client_lib", rest)
|
||||
} else {
|
||||
// The other crates (render, world) are their own packages,
|
||||
// never split.
|
||||
continue;
|
||||
};
|
||||
let module = rest.split("::").next().unwrap_or(rest);
|
||||
assert!(
|
||||
root.contains(&format!("mod {module};")),
|
||||
"{crate_name} does not compile module `{module}` (target {target})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The damage hook gets a mob's source from its damage: the causing mob,
|
||||
|
||||
@@ -297,6 +297,9 @@ impl Game<'_> {
|
||||
textures,
|
||||
cem,
|
||||
block_entity_shapes,
|
||||
sounds,
|
||||
ui_metadata,
|
||||
language,
|
||||
} = staged;
|
||||
// Keep the working GPU resources, and hand its window surface over.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
@@ -396,10 +399,16 @@ impl Game<'_> {
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
std::mem::take(&mut self.gui.atlas.blinks);
|
||||
horizon_client::ui::load_metadata(&root);
|
||||
lang::set(lang::Language::load(&[&root], &lang::code()));
|
||||
horizon_client::ui::set_metadata(ui_metadata);
|
||||
// Read with the language in use when the reload started: one picked since then wins.
|
||||
let code = lang::code();
|
||||
lang::set(if language.code == code {
|
||||
language
|
||||
} else {
|
||||
lang::Language::load(&[&root], &code)
|
||||
});
|
||||
self.refresh_remote_language();
|
||||
self.audio.reload(&root);
|
||||
self.audio.reload_with(&root, sounds);
|
||||
self.clear_lod();
|
||||
// A pack that may replace leaf models falls back to `Off` before the
|
||||
// reload marks every section dirty.
|
||||
@@ -452,11 +461,27 @@ pub(super) fn stage_pack(
|
||||
progress.set(1);
|
||||
// The world's models and sprites and the GUI atlas read the same root and nothing of
|
||||
// each other: about a second each, side by side.
|
||||
let (render, atlas, panorama) = std::thread::scope(|s| {
|
||||
// The sound events, the GUI sprites' metadata and the language are read here too, not in
|
||||
// the frame that applies the reload (about 20 ms of it).
|
||||
let (render, atlas, panorama, (sounds, ui_metadata, language)) = std::thread::scope(|s| {
|
||||
let atlas =
|
||||
s.spawn(|| HudAtlas::build_over(&root, vanilla_root).map_err(|e| e.to_string()));
|
||||
let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root, vanilla_root));
|
||||
(assets.reload(&root), atlas.join(), panorama.join())
|
||||
let small = s.spawn(|| {
|
||||
(
|
||||
horizon_client::sound_events::SoundRegistry::load(&root),
|
||||
horizon_client::ui::read_metadata(&root),
|
||||
lang::Language::load(&[&root], &lang::code()),
|
||||
)
|
||||
});
|
||||
(
|
||||
assets.reload(&root),
|
||||
atlas.join(),
|
||||
panorama.join(),
|
||||
small
|
||||
.join()
|
||||
.unwrap_or_else(|p| std::panic::resume_unwind(p)),
|
||||
)
|
||||
});
|
||||
let render = Arc::new(render?);
|
||||
let mut atlas = atlas.unwrap_or_else(|p| std::panic::resume_unwind(p))?;
|
||||
@@ -489,6 +514,9 @@ pub(super) fn stage_pack(
|
||||
textures,
|
||||
cem,
|
||||
block_entity_shapes,
|
||||
sounds,
|
||||
ui_metadata,
|
||||
language,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -503,6 +531,10 @@ pub(super) struct StagedPack {
|
||||
pub cem: Arc<horizon_client::mobs::cem::CemModels>,
|
||||
/// The block entities' models, the packs' custom entity models over vanilla's.
|
||||
pub block_entity_shapes: Arc<horizon_client::block_entity_render::BlockEntityShapes>,
|
||||
/// The root's sound events, GUI sprite metadata and current language.
|
||||
pub sounds: horizon_client::sound_events::SoundRegistry,
|
||||
pub ui_metadata: horizon_client::ui::UiMetadata,
|
||||
pub language: lang::Language,
|
||||
}
|
||||
|
||||
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
|
||||
|
||||
@@ -43,6 +43,8 @@ pub struct EntityMemory {
|
||||
/// Entities a native mod kept out of the level (`native_entity_join_level`),
|
||||
/// counted so `/summon` can fail as vanilla's refused add does.
|
||||
pub(super) joins_canceled: u32,
|
||||
/// The highest entity id `native_entity_join_level` has been told of.
|
||||
pub(super) joined_mark: u32,
|
||||
}
|
||||
|
||||
/// The key that marks a compound queued with the chunk loads as a fresh add
|
||||
@@ -342,6 +344,8 @@ impl Game<'_> {
|
||||
let from_disk = !take_fresh(&mut tag);
|
||||
// An End gateway's traveller never left the level: nothing joins.
|
||||
let moved = take_moved(&mut tag);
|
||||
// Whatever was added since the last tick joins before this one.
|
||||
self.native_joins_flush(false, 0);
|
||||
let kind = entity_nbt::kind(&tag).unwrap_or_default();
|
||||
let uuid = entity_nbt::uuid(&tag);
|
||||
if uuid.is_some_and(|u| self.restored_vehicle(u).is_some()) {
|
||||
@@ -358,6 +362,8 @@ impl Game<'_> {
|
||||
| "minecraft:snowball"
|
||||
| "minecraft:egg"
|
||||
| "minecraft:ender_pearl"
|
||||
| "minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion"
|
||||
| "minecraft:falling_block"
|
||||
| "minecraft:firework_rocket"
|
||||
| "minecraft:trident"
|
||||
@@ -377,37 +383,19 @@ impl Game<'_> {
|
||||
if let Some(e) =
|
||||
entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob)
|
||||
{
|
||||
let (id, p) = (e.id, e.position);
|
||||
self.remember(e.id, uuid, tag);
|
||||
self.dropped.push(e);
|
||||
if !moved
|
||||
&& !self.native_entity_joins(
|
||||
id,
|
||||
"minecraft:item",
|
||||
[p.x, p.y, p.z],
|
||||
from_disk,
|
||||
)
|
||||
{
|
||||
self.dropped.pop();
|
||||
self.forget_entity(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
"minecraft:experience_orb" => {
|
||||
for mut orb in entity_nbt::orbs_from_nbt(&tag) {
|
||||
self.next_entity += 1;
|
||||
orb.id = self.next_entity;
|
||||
let (id, p) = (orb.id, orb.position);
|
||||
let id = orb.id;
|
||||
// An orb that came through a portal keeps its cooldown.
|
||||
self.restore_portal_cooldown(id, &tag);
|
||||
self.orbs.push(orb);
|
||||
self.remember_tags(id, &tag);
|
||||
let kind = "minecraft:experience_orb";
|
||||
if !moved
|
||||
&& !self.native_entity_joins(id, kind, [p.x, p.y, p.z], from_disk)
|
||||
{
|
||||
self.orbs.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
"minecraft:arrow" => {
|
||||
@@ -669,6 +657,13 @@ impl Game<'_> {
|
||||
}
|
||||
_ => self.persist.unknown.entry(key).or_default().push(tag),
|
||||
}
|
||||
// PersistentEntitySectionManager.addEntity: every kind that
|
||||
// just joined tells the mods; a moved one does not join.
|
||||
if moved {
|
||||
self.native_joins_skip();
|
||||
} else {
|
||||
self.native_joins_flush(from_disk, 0);
|
||||
}
|
||||
}
|
||||
self.native_entities_load(self.dim.id(), [key.0, key.1], count);
|
||||
}
|
||||
|
||||
@@ -691,7 +691,7 @@ impl Game<'_> {
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(m, self.own_entity_id(), camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, None, out);
|
||||
// CustomHeadLayer: a pumpkin, skull or other head item.
|
||||
self.head_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
@@ -802,7 +802,7 @@ impl Game<'_> {
|
||||
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
let wings = wings_start..verts.len();
|
||||
self.shoulder_parrots_geometry(&m, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, None, &mut verts);
|
||||
self.head_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
let height = if m.extra.crouching { 1.5 } else { 1.8 };
|
||||
// PictureInPictureRenderer: the box centre, (size, size, -size);
|
||||
|
||||
@@ -603,7 +603,7 @@ impl Game<'_> {
|
||||
if let Some(mud) = self.pack.assets.registry.default_state("minecraft:mud") {
|
||||
self.level_set(pos, mud)?;
|
||||
}
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.stats.add("used", "minecraft:bucket", 1);
|
||||
self.award_stat("used", "minecraft:bucket", 1);
|
||||
if let Some(eye) = self
|
||||
.player
|
||||
.as_ref()
|
||||
|
||||
@@ -87,7 +87,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.damage_held(1);
|
||||
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
|
||||
self.stats.add("used", "minecraft:shears", 1);
|
||||
self.award_stat("used", "minecraft:shears", 1);
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, shears);
|
||||
Ok(true)
|
||||
|
||||
@@ -434,7 +434,7 @@ impl Game<'_> {
|
||||
.raids
|
||||
.create_or_extend(center, difficulty, amplifier, seed);
|
||||
if triggered {
|
||||
self.stats.custom("raid_trigger", 1);
|
||||
self.award_custom_stat("raid_trigger", 1);
|
||||
self.trigger_player("minecraft:voluntary_exile");
|
||||
self.native_raid_state_change(id, center, "triggered");
|
||||
}
|
||||
@@ -660,7 +660,7 @@ impl Game<'_> {
|
||||
EffectInstance::new("hero_of_the_village", raid::HERO_DURATION, amplifier);
|
||||
effect.visible = false;
|
||||
g.potions.effects.add(effect);
|
||||
g.stats.custom("raid_win", 1);
|
||||
g.award_custom_stat("raid_win", 1);
|
||||
g.trigger_player("minecraft:hero_of_the_village");
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -6468,6 +6468,7 @@ impl Game<'_> {
|
||||
// collisions and the pull of the nearest player between the server's
|
||||
// updates; the server removes it on pickup.
|
||||
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
|
||||
let border = Some(self.border.geometry());
|
||||
let mut lava_random = None;
|
||||
let mut next_float = || {
|
||||
lava_random
|
||||
@@ -6501,7 +6502,15 @@ impl Game<'_> {
|
||||
l.steps -= 1;
|
||||
}
|
||||
let before = o.position;
|
||||
let kept = o.tick(world, facts, collision, target, false, &mut next_float);
|
||||
let kept = o.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
target,
|
||||
false,
|
||||
&mut next_float,
|
||||
border,
|
||||
);
|
||||
// `tick` set `previous` to the stepped position: keep the
|
||||
// pre-step one, as `xo` is in vanilla.
|
||||
o.previous = old;
|
||||
@@ -6519,11 +6528,12 @@ impl Game<'_> {
|
||||
// ItemEntity.tick runs on a client too: gravity, collisions and
|
||||
// friction move the item between the server's updates (every 20
|
||||
// ticks), and only the server removes it.
|
||||
let (world, registry, facts, collision) = (
|
||||
let (world, registry, facts, collision, border) = (
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
for d in self.dropped.iter_mut().filter(|_| running) {
|
||||
d.tick(
|
||||
@@ -6535,6 +6545,7 @@ impl Game<'_> {
|
||||
&self.res.motion().fluids,
|
||||
// The server decides what an item rests on; this copy only moves between updates.
|
||||
&[],
|
||||
border,
|
||||
);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
@@ -7358,6 +7369,23 @@ impl Game<'_> {
|
||||
// and the click ends.
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
} else if let Some(stack) = held
|
||||
.as_ref()
|
||||
.filter(|s| s.item.ends_with("_spawn_egg"))
|
||||
.filter(|_| {
|
||||
self.may_build()
|
||||
|| self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos))
|
||||
})
|
||||
{
|
||||
// SpawnEggItem.useOn on a client level: FAIL for a mob that
|
||||
// cannot spawn (the click ends, no swing), else SUCCESS (swing).
|
||||
// Either way no UseItem follows, so the server does not spawn
|
||||
// a second mob from the fluid ray (ItemStack.useOn passes in
|
||||
// adventure mode off can_place_on, which the filter above keeps).
|
||||
if self.spawn_egg_allowed(&stack.item) {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
return true;
|
||||
} else if let Some(stack) = held.as_ref()
|
||||
&& (self.predict_item_on_block(stack, &hit, hand)
|
||||
|| self.predict_map_on_banner(stack, &hit))
|
||||
|
||||
@@ -611,16 +611,17 @@ impl Game<'_> {
|
||||
// fuse down (the swell and flash follow it) between the server's
|
||||
// updates (every 10 ticks), and a client discards it when the fuse
|
||||
// runs out; only the server explodes it.
|
||||
let (world, collision, assets, facts, fluids) = (
|
||||
let (world, collision, assets, facts, fluids, border) = (
|
||||
&self.world,
|
||||
self.collision,
|
||||
&self.pack.assets,
|
||||
self.res.facts(),
|
||||
&self.res.motion().fluids,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
self.tnts.retain_mut(|t| {
|
||||
// A fluid lookup that fails leaves it where its move put it.
|
||||
let _ = t.tick(world, collision, assets, facts, fluids);
|
||||
let _ = t.tick(world, collision, assets, facts, fluids, border);
|
||||
t.fuse > 0
|
||||
});
|
||||
// FallingBlockEntity.tick runs on a client too: the block falls by
|
||||
|
||||
@@ -71,7 +71,7 @@ impl Game<'_> {
|
||||
});
|
||||
} else {
|
||||
self.close_container();
|
||||
self.stats.custom("leave_game", 1);
|
||||
self.award_custom_stat("leave_game", 1);
|
||||
self.lan_close(false);
|
||||
self.free_player_beds()?;
|
||||
self.pending_save = Some(ClosingSave {
|
||||
|
||||
@@ -252,6 +252,12 @@ impl Game<'_> {
|
||||
play.respawn_requested = false;
|
||||
play.won_game = false;
|
||||
}
|
||||
// PlayerList.respawn(keepAllPlayerData = true), CHANGED_DIMENSION:
|
||||
// PlayerEvent.Clone with `wasDeath` false.
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_clone(uuid, &name, false);
|
||||
}
|
||||
self.start_server_trip(host, conn, Dim::Overworld, None);
|
||||
self.portals.player.cooldown = 10;
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ impl Game<'_> {
|
||||
self.sculk_actor(),
|
||||
Some(state),
|
||||
);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
|
||||
@@ -255,7 +255,7 @@ impl Game<'_> {
|
||||
// itemAttackInteraction: hurtEnemy (statistic, wear), and the
|
||||
// post-attack enchantments when the target was hurt.
|
||||
if let Some(stack) = self.held().cloned() {
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
if hurt {
|
||||
self.post_attack(index);
|
||||
}
|
||||
@@ -263,8 +263,7 @@ impl Game<'_> {
|
||||
}
|
||||
// damageStatsAndHearts.
|
||||
let actual = old_health - self.mobs[index].health;
|
||||
self.stats
|
||||
.custom("damage_dealt", (actual * 10.0).round() as i32);
|
||||
self.award_custom_stat("damage_dealt", (actual * 10.0).round() as i32);
|
||||
if actual > 2.0 {
|
||||
let indicator = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::DamageIndicator,
|
||||
|
||||
@@ -562,7 +562,7 @@ impl Game<'_> {
|
||||
_ => "",
|
||||
};
|
||||
if !stat.is_empty() {
|
||||
self.stats.custom(stat, 1);
|
||||
self.award_custom_stat(stat, 1);
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -979,6 +979,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Player.giveExperienceLevels`.
|
||||
pub(super) fn give_levels(&mut self, levels: i32) {
|
||||
let old_level = self.experience.level;
|
||||
let xp = &mut self.experience;
|
||||
xp.level = xp.level.saturating_add(levels);
|
||||
if xp.level < 0 {
|
||||
@@ -986,6 +987,7 @@ impl Game<'_> {
|
||||
xp.progress = 0.0;
|
||||
xp.total = 0;
|
||||
}
|
||||
self.native_player_level_changed(old_level);
|
||||
}
|
||||
/// Whether the result slot may be taken (`mayPickup`).
|
||||
fn may_take_result(&self) -> bool {
|
||||
@@ -1051,7 +1053,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.stats.custom("enchant_item", 1);
|
||||
self.award_custom_stat("enchant_item", 1);
|
||||
self.trigger_enchanted_item(&enchanted_item, lapis_needed as i32);
|
||||
self.play_at("minecraft:block.enchantment_table.use", pos, 1.0, pitch);
|
||||
self.station_changed();
|
||||
|
||||
@@ -194,6 +194,30 @@ impl Game<'_> {
|
||||
self.stats.write(&path);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.awardStat`: `n` more of the statistic `ty` (a bare type
|
||||
/// such as `used`) and `key`, unless a native mod cancels the award.
|
||||
pub(super) fn award_stat(&mut self, ty: &str, key: &str, n: i32) {
|
||||
// No mod, no name copied: the per-tick counters come through here.
|
||||
let acting = if horizon_client::mod_api::mods().is_empty() {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting_uuid.zip(self.lan_acting_name.clone())
|
||||
};
|
||||
if let Some((player, name)) = acting {
|
||||
let mut cancel = false;
|
||||
namespaced(ty, |stat_type| {
|
||||
self.native_player_stat_award(player, &name, stat_type, key, n, &mut cancel)
|
||||
});
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.stats.add(ty, key, n);
|
||||
}
|
||||
/// `Player.awardStat(Identifier)`: `n` more of the custom statistic `key`.
|
||||
pub(super) fn award_custom_stat(&mut self, key: &str, n: i32) {
|
||||
namespaced(key, |key| self.award_stat("custom", key, n));
|
||||
}
|
||||
/// The per-tick counters (`Player.tick`/`ServerPlayer.tick` awardStat).
|
||||
pub(super) fn tick_stats(&mut self) {
|
||||
self.load_stats();
|
||||
@@ -207,17 +231,16 @@ impl Game<'_> {
|
||||
};
|
||||
let alive = !p.health.dead_or_dying();
|
||||
let crouching = p.pose == horizon_client::pose::Pose::Crouching;
|
||||
let s = &mut self.stats;
|
||||
s.custom("play_time", 1);
|
||||
s.custom("total_world_time", 1);
|
||||
self.award_custom_stat("play_time", 1);
|
||||
self.award_custom_stat("total_world_time", 1);
|
||||
if alive {
|
||||
s.custom("time_since_death", 1);
|
||||
self.award_custom_stat("time_since_death", 1);
|
||||
if !sleeping {
|
||||
s.custom("time_since_rest", 1);
|
||||
self.award_custom_stat("time_since_rest", 1);
|
||||
}
|
||||
}
|
||||
if crouching {
|
||||
s.custom("sneak_time", 1);
|
||||
self.award_custom_stat("sneak_time", 1);
|
||||
}
|
||||
}
|
||||
fn tick_movement_stats(&mut self) {
|
||||
@@ -269,51 +292,58 @@ impl Game<'_> {
|
||||
.map(|m| m.species())
|
||||
.ok_or(())
|
||||
});
|
||||
let s = &mut self.stats;
|
||||
match riding {
|
||||
Some(Ok(sp)) => {
|
||||
use horizon_client::mobs::Species;
|
||||
// `checkRidingStatistics`: only these mounts count, the horse
|
||||
// family by the full 3D distance; any other mount nothing.
|
||||
match sp {
|
||||
Species::Pig => s.custom("pig_one_cm", total),
|
||||
Species::Strider => s.custom("strider_one_cm", total),
|
||||
Species::HappyGhast => s.custom("happy_ghast_one_cm", total),
|
||||
Species::Pig => self.award_custom_stat("pig_one_cm", total),
|
||||
Species::Strider => self.award_custom_stat("strider_one_cm", total),
|
||||
Species::HappyGhast => self.award_custom_stat("happy_ghast_one_cm", total),
|
||||
Species::Nautilus | Species::ZombieNautilus => {
|
||||
s.custom("nautilus_one_cm", total)
|
||||
self.award_custom_stat("nautilus_one_cm", total)
|
||||
}
|
||||
sp if horizon_client::mobs::equine::is_equine(sp) => {
|
||||
s.custom("horse_one_cm", total)
|
||||
self.award_custom_stat("horse_one_cm", total)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Err(())) => {
|
||||
if self.boats.iter().any(|b| Some(b.id) == self.riding) {
|
||||
s.custom("boat_one_cm", total)
|
||||
self.award_custom_stat("boat_one_cm", total)
|
||||
} else if self.carts.iter().any(|c| Some(c.id) == self.riding) {
|
||||
s.custom("minecart_one_cm", total)
|
||||
self.award_custom_stat("minecart_one_cm", total)
|
||||
}
|
||||
}
|
||||
None if swimming => s.custom("swim_one_cm", total),
|
||||
None if swimming => self.award_custom_stat("swim_one_cm", total),
|
||||
None if eye_in_water.fluid == horizon_client::blocks::Fluid::Water => {
|
||||
s.custom("walk_under_water_one_cm", total)
|
||||
self.award_custom_stat("walk_under_water_one_cm", total)
|
||||
}
|
||||
None if in_water => s.custom("walk_on_water_one_cm", horizontal),
|
||||
None if climbing => s.custom("climb_one_cm", (d.y * 100.0).round() as i32),
|
||||
None if on_ground => {
|
||||
if sprinting {
|
||||
s.custom("sprint_one_cm", horizontal)
|
||||
} else if crouching {
|
||||
s.custom("crouch_one_cm", horizontal)
|
||||
} else {
|
||||
s.custom("walk_one_cm", horizontal)
|
||||
None if in_water => {
|
||||
if horizontal > 0 {
|
||||
self.award_custom_stat("walk_on_water_one_cm", horizontal)
|
||||
}
|
||||
}
|
||||
None if gliding => s.custom("aviate_one_cm", total),
|
||||
None if climbing => {
|
||||
self.award_custom_stat("climb_one_cm", (d.y * 100.0).round() as i32)
|
||||
}
|
||||
None if on_ground => {
|
||||
if horizontal > 0 {
|
||||
if sprinting {
|
||||
self.award_custom_stat("sprint_one_cm", horizontal)
|
||||
} else if crouching {
|
||||
self.award_custom_stat("crouch_one_cm", horizontal)
|
||||
} else {
|
||||
self.award_custom_stat("walk_one_cm", horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
None if gliding => self.award_custom_stat("aviate_one_cm", total),
|
||||
// Airborne and not gliding: more than 25 cm sideways per move
|
||||
// (creative flight, or a long jump), not only flying.
|
||||
None if horizontal > 25 => s.custom("fly_one_cm", horizontal),
|
||||
None if horizontal > 25 => self.award_custom_stat("fly_one_cm", horizontal),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,17 +50,20 @@ impl PrimedTnt {
|
||||
assets: &horizon_client::assets::Assets,
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
fluids: &horizon_client::motion::FluidResources,
|
||||
border: Option<&horizon_core::geometry::border::BorderGeometry>,
|
||||
) -> Result<Vec<BlockPos>> {
|
||||
self.previous = self.position;
|
||||
self.velocity.y -= self.get_gravity();
|
||||
let mut scene = SceneCollisions {
|
||||
let scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let wanted = self.velocity;
|
||||
// Entity.collide: the world border's walls stop it too.
|
||||
let border = border.map(|b| (b, [self.position.x, self.position.z]));
|
||||
let moved = scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.resolve_in_border(self.bounds(), wanted, 0.0, self.on_ground, border)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
if horizon_client::entities::move_applies(wanted, moved) {
|
||||
self.position = Vec3::new(
|
||||
@@ -246,6 +249,7 @@ impl Game<'_> {
|
||||
&self.pack.assets,
|
||||
self.res.facts(),
|
||||
&self.res.motion().fluids,
|
||||
Some(self.border.geometry()),
|
||||
)?;
|
||||
for pos in tops {
|
||||
self.bubble_column_particles(pos);
|
||||
@@ -541,6 +545,55 @@ mod tests {
|
||||
assert!(!tnt.on_ground);
|
||||
}
|
||||
|
||||
/// `Entity.collectColliders` (#7080): the world border's wall stops primed
|
||||
/// TNT. A border of size 10 around (20, 20) has its east wall at x = 25;
|
||||
/// the TNT starts at x = 24 on the test scene's flat ground, 1.5 east.
|
||||
#[test]
|
||||
fn the_world_border_stops_primed_tnt() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
|
||||
let world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
|
||||
let border =
|
||||
horizon_core::geometry::border::BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984)
|
||||
.unwrap();
|
||||
for bordered in [false, true] {
|
||||
let at = Vec3::new(24.0, 4.0, 20.5);
|
||||
let mut tnt = PrimedTnt {
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::new(1.5, 0.0, 0.0),
|
||||
fuse: 80,
|
||||
on_ground: true,
|
||||
owner: None,
|
||||
guest: None,
|
||||
power: 4.0,
|
||||
};
|
||||
let border = bordered.then_some(&border);
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, border)
|
||||
.unwrap();
|
||||
let east = tnt.bounds().max_position().x;
|
||||
if bordered {
|
||||
assert!(
|
||||
(east - 25.0).abs() < 1.0e-7,
|
||||
"stops on the wall, got {east}"
|
||||
);
|
||||
} else {
|
||||
assert!(east > 25.0, "no border: goes on, got {east}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primed_tnt_follows_water_currents_after_moving() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -580,7 +633,7 @@ mod tests {
|
||||
guest: None,
|
||||
power: 4.0,
|
||||
};
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tnt.position.x, at.x,
|
||||
@@ -592,14 +645,14 @@ mod tests {
|
||||
tnt.velocity
|
||||
);
|
||||
assert_eq!(tnt.fuse, 79);
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
assert!((tnt.position.x - at.x - 0.014).abs() < 1e-12);
|
||||
assert!(tnt.on_ground);
|
||||
tnt.position = at;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.fuse = 1;
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
assert_eq!(tnt.fuse, 0);
|
||||
assert_eq!(
|
||||
@@ -610,7 +663,7 @@ mod tests {
|
||||
tnt.position = at;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.fuse = 80;
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tnt.velocity.x, 0.0,
|
||||
@@ -626,7 +679,7 @@ mod tests {
|
||||
.unwrap();
|
||||
tnt.on_ground = false;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
assert!(
|
||||
tnt.position.y < at.y && tnt.velocity.y < 0.0,
|
||||
@@ -683,7 +736,7 @@ mod tests {
|
||||
power: 4.0,
|
||||
};
|
||||
for _ in 0..10 {
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
@@ -704,7 +757,7 @@ mod tests {
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.on_ground = false;
|
||||
for _ in 0..4 {
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
@@ -764,7 +817,7 @@ mod tests {
|
||||
let mut tnt = primed();
|
||||
for _ in 0..30 {
|
||||
tops.extend(
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
@@ -778,7 +831,7 @@ mod tests {
|
||||
let mut tnt = primed();
|
||||
for _ in 0..10 {
|
||||
assert!(
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ impl Game<'_> {
|
||||
*cell = None;
|
||||
}
|
||||
}
|
||||
self.stats.add("used", TOTEM, 1);
|
||||
self.award_stat("used", TOTEM, 1);
|
||||
self.trigger_item(
|
||||
"minecraft:used_totem",
|
||||
&horizon_client::items::ItemStack::new(TOTEM, 1),
|
||||
|
||||
@@ -251,7 +251,7 @@ impl Game<'_> {
|
||||
self.handle_mob_events(events)?;
|
||||
return Ok(true);
|
||||
}
|
||||
self.stats.custom("talked_to_villager", 1);
|
||||
self.award_custom_stat("talked_to_villager", 1);
|
||||
self.ensure_offers(index);
|
||||
let m = &self.mobs[index];
|
||||
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
|
||||
@@ -470,7 +470,7 @@ impl Game<'_> {
|
||||
/// for a server's player (when the trader is still in the level).
|
||||
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, offers: &[MerchantOffer]) {
|
||||
for offer in offers {
|
||||
self.stats.custom("traded_with_villager", 1);
|
||||
self.award_custom_stat("traded_with_villager", 1);
|
||||
let merchant = mob.and_then(|id| self.mobs.iter().find(|m| m.id == id));
|
||||
let kind = merchant.map(super::native_hooks::mob_kind);
|
||||
let villager = merchant.map(|m| self.mob_entity(m));
|
||||
|
||||
@@ -167,7 +167,7 @@ impl Game<'_> {
|
||||
};
|
||||
if taken > 0 {
|
||||
// awardStat(ITEM_USED), then stackToInsert.consume(count, player).
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
for _ in 0..taken {
|
||||
self.consume_held();
|
||||
}
|
||||
@@ -190,6 +190,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Vec3.atBottomCenterOf(pos).relative(UP, 1.2), lowered by spawnItem.
|
||||
VaultEvent::Eject(stack, v) => {
|
||||
self.native_vault_loot(pos, &stack.item);
|
||||
self.spawn_item(stack, [center[0], center[1] + 0.7 - 0.125, center[2]], v, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ impl Game<'_> {
|
||||
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
|
||||
self.audio
|
||||
.play("minecraft:entity.wind_charge.throw", Some(eye), 0.5, pitch);
|
||||
self.stats.add("used", "minecraft:wind_charge", 1);
|
||||
self.award_stat("used", "minecraft:wind_charge", 1);
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
&& let Some(s) = self.held_mut().as_mut()
|
||||
|
||||
@@ -1260,10 +1260,10 @@ mod tests {
|
||||
assert!((transform_normal(r, Vec3::Z) - Vec3::X).length() < 1e-6);
|
||||
}
|
||||
|
||||
/// Every vanilla item on the ground, turned, shaded and unshaded: the GPU path's
|
||||
/// vertices (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
|
||||
/// the bit and in the same order, for each state it takes. Needs the prepared
|
||||
/// resources.
|
||||
/// Every vanilla item on the ground, held in either hand, framed and worn on the
|
||||
/// head, turned, shaded and unshaded: the GPU path's vertices (its jobs expanded as
|
||||
/// `entity_skin.wgsl` does) are the CPU path's, to the bit and in the same order, for
|
||||
/// each state it takes. Needs the prepared resources.
|
||||
#[test]
|
||||
fn items_posed_on_the_gpu_draw_what_the_cpu_draws() {
|
||||
use crate::gfx::entity_skin::{SkinFrame, expand};
|
||||
@@ -1281,11 +1281,24 @@ mod tests {
|
||||
let view = StackView::new(id, 1);
|
||||
r.resolve(&view, DisplayContext::Ground, None, None, 7, &NoEnv)
|
||||
};
|
||||
// Each display context's transforms (`ItemTransform`s) pose the same quads.
|
||||
let contexts = [
|
||||
DisplayContext::Ground,
|
||||
DisplayContext::ThirdPersonRightHand,
|
||||
DisplayContext::ThirdPersonLeftHand,
|
||||
DisplayContext::Fixed,
|
||||
DisplayContext::Head,
|
||||
];
|
||||
let states: Vec<_> = r
|
||||
.models
|
||||
.definitions
|
||||
.keys()
|
||||
.map(|id| (id, resolve(id)))
|
||||
.flat_map(|id| {
|
||||
contexts.map(|context| {
|
||||
let view = StackView::new(id, 1);
|
||||
(id, r.resolve(&view, context, None, None, 7, &NoEnv))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Each sprite its own rect, so a run drawn with another's shows.
|
||||
let mut sprites: HashMap<String, Rect> = HashMap::new();
|
||||
|
||||
@@ -1102,4 +1102,51 @@ mod tests {
|
||||
other(&item, "minecraft:custom_model_data")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rarity_survives_lan_sync_and_enchantment_upgrade() {
|
||||
use horizon_protocol::buf::{Reader, Writer};
|
||||
use horizon_protocol::item::Slot;
|
||||
use horizon_protocol::registry::Ids;
|
||||
let mut components = vec![String::new(); component::RARITY as usize + 1];
|
||||
components[component::RARITY as usize] = "minecraft:rarity".into();
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".into(),
|
||||
Ids::from_names(vec!["minecraft:air".into(), "minecraft:stick".into()]),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".into(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let dynamic = Dynamic::default();
|
||||
let mut item = ItemStack::new("minecraft:stick", 1);
|
||||
item.data_mut()
|
||||
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
|
||||
item.enchant("minecraft:aqua_affinity", 1);
|
||||
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|
||||
|_, _| None,
|
||||
&Some(item.clone()),
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&wire, &mut w);
|
||||
let received = Slot::read(&mut Reader::new(&w.data)).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
received.component(component::RARITY),
|
||||
Some(&Component::Int(3))
|
||||
);
|
||||
|
||||
let back = from_network(&received, &builtin, &dynamic);
|
||||
assert_eq!(
|
||||
back.data().and_then(|d| d.other("minecraft:rarity")),
|
||||
Some(&Tag::String("epic".into()))
|
||||
);
|
||||
assert_eq!(crate::item_tooltip::rarity(&back, "common"), "epic");
|
||||
}
|
||||
}
|
||||
@@ -1053,7 +1053,7 @@ pub fn lines(
|
||||
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
|
||||
/// the component's own styles (including explicit `false`) winning on children.
|
||||
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
|
||||
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
|
||||
let Some(raw) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let base = crate::text::Style {
|
||||
@@ -1061,10 +1061,19 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
|
||||
italic: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
|
||||
let lines: Vec<crate::text::Text> = match raw {
|
||||
Tag::List(entries) => entries
|
||||
.iter()
|
||||
.map(|entry| crate::entity_nbt::name_text(entry).resolve_legacy())
|
||||
.collect(),
|
||||
Tag::Compound(_) | Tag::String(_) => {
|
||||
vec![crate::entity_nbt::name_text(raw).resolve_legacy()]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|mut text| {
|
||||
for span in &mut text.spans {
|
||||
span.style.inherit(&base);
|
||||
}
|
||||
@@ -1360,6 +1369,31 @@ mod tests {
|
||||
assert_eq!(rarity(&sword, "common"), "rare");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_texts_handles_compound_lists() {
|
||||
// Direct command components from issue #6405
|
||||
let cmd = r#"[custom_name=[{"text":"Simple Custom Name","italic":false}],lore=[[{"text":"Simple Custom Lore","italic":false}]],rarity=epic,enchantments={aqua_affinity:1},unbreakable={}]"#;
|
||||
let comp = horizon_items::item_data::command_components(cmd).unwrap();
|
||||
let (damage, extra) = horizon_items::item_data::decode_components(Some(&comp));
|
||||
let stack = ItemStack {
|
||||
item: "minecraft:stick".into(),
|
||||
count: 1,
|
||||
damage,
|
||||
extra,
|
||||
};
|
||||
assert_eq!(rarity(&stack, "common"), "epic");
|
||||
let name = styled_name(&stack, || "fallback".into());
|
||||
assert_eq!(name.plain(), "Simple Custom Name");
|
||||
assert_eq!(name.spans[0].style.italic, Some(false));
|
||||
|
||||
let lore = lore_texts(&stack);
|
||||
assert_eq!(lore.len(), 1);
|
||||
assert_eq!(lore[0].plain(), "Simple Custom Lore");
|
||||
assert_eq!(lore[0].spans[0].style.italic, Some(false));
|
||||
assert_eq!(lore[0].spans[0].style.color, Some(crate::text::DARK_PURPLE));
|
||||
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_line_translates_items_of_any_namespace() {
|
||||
let translate = |key: &str| match key {
|
||||
|
||||
@@ -35,6 +35,13 @@ pub struct ServerAdvancements {
|
||||
pub struct FirstTick {
|
||||
pub recipe_book_add: Option<Vec<u8>>,
|
||||
pub update_advancements: Vec<u8>,
|
||||
pub granted: Vec<CriterionGrant>,
|
||||
}
|
||||
|
||||
/// A criterion `CriteriaTriggers.TICK` newly granted, for the game to tell the mods.
|
||||
pub struct CriterionGrant {
|
||||
pub advancement: String,
|
||||
pub criterion: String,
|
||||
}
|
||||
|
||||
impl ServerAdvancements {
|
||||
@@ -89,9 +96,15 @@ impl ServerAdvancements {
|
||||
})
|
||||
.collect();
|
||||
let date = crate::advancements::date_text(now_millis.div_euclid(1000));
|
||||
let mut granted = Vec::new();
|
||||
for (a, got) in self.tree.list.iter().zip(&obtained) {
|
||||
for c in got {
|
||||
progress.grant(&a.id, c, &date);
|
||||
if progress.grant(&a.id, c, &date) {
|
||||
granted.push(CriterionGrant {
|
||||
advancement: a.id.clone(),
|
||||
criterion: (*c).to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// AdvancementRewards.grant: the recipes, in the advancements' order.
|
||||
@@ -126,6 +139,7 @@ impl ServerAdvancements {
|
||||
FirstTick {
|
||||
recipe_book_add,
|
||||
update_advancements: w.data,
|
||||
granted,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,13 +149,15 @@ impl ServerAdvancements {
|
||||
/// drops a criterion it lost), the next `flushDirty` is a first packet
|
||||
/// again, and the next `CriteriaTriggers.TICK` meets the new tree's
|
||||
/// `minecraft:tick` criteria, rewarding the recipes of the advancements
|
||||
/// that complete. Returns the recipe book packet those recipes need.
|
||||
/// that complete. The criteria it grants are added to `granted`. Returns
|
||||
/// the recipe book packet those recipes need.
|
||||
pub fn reload(
|
||||
&self,
|
||||
sync: &SyncData,
|
||||
now_millis: i64,
|
||||
progress: &mut Progress,
|
||||
book: &mut Settings,
|
||||
granted: &mut Vec<CriterionGrant>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let by_id: HashMap<&str, &crate::advancements::Advancement> =
|
||||
self.tree.list.iter().map(|a| (a.id.as_str(), a)).collect();
|
||||
@@ -159,8 +175,14 @@ impl ServerAdvancements {
|
||||
for a in &self.tree.list {
|
||||
let was_done = progress.done(a);
|
||||
for c in &a.criteria {
|
||||
if c.trigger == "minecraft:tick" && c.conditions.is_null() {
|
||||
progress.grant(&a.id, &c.name, &date);
|
||||
if c.trigger == "minecraft:tick"
|
||||
&& c.conditions.is_null()
|
||||
&& progress.grant(&a.id, &c.name, &date)
|
||||
{
|
||||
granted.push(CriterionGrant {
|
||||
advancement: a.id.clone(),
|
||||
criterion: c.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !was_done && progress.done(a) {
|
||||
@@ -694,6 +716,78 @@ mod tests {
|
||||
/// A recipe whose ingredient kind no loader knows stays out of the book:
|
||||
/// the server does not load it, and an empty ingredient slot in its
|
||||
/// place would mislead the client.
|
||||
/// The shared probe: a recipe the player crafts is learned by the
|
||||
/// name the crafting copy hands out, and rejoining sends what the
|
||||
/// book knows.
|
||||
fn crafted_recipe_reaches_the_book(name: &str, pack: bool) {
|
||||
let shaped = r#"{"type":"minecraft:crafting_shaped","pattern":["X"],
|
||||
"key":{"X":"minecraft:raw_copper"},
|
||||
"result":{"id":"minecraft:copper_ingot"}}"#;
|
||||
let root = if pack {
|
||||
resources(name, &[])
|
||||
} else {
|
||||
resources(name, &[(name, shaped)])
|
||||
};
|
||||
let packs = pack.then(|| {
|
||||
vec![(
|
||||
format!("recipe/minecraft:{name}"),
|
||||
vec![shaped.as_bytes().to_vec()],
|
||||
)]
|
||||
});
|
||||
let overlay = match &packs {
|
||||
Some(p) => horizon_world::loot::PackOverlay { files: p },
|
||||
None => horizon_world::loot::PackOverlay::NONE,
|
||||
};
|
||||
let sync = SyncData::load(&root, &overlay).unwrap();
|
||||
// What the craft hands the book: find_with over the crafting
|
||||
// copy, the way award_crafted calls it.
|
||||
let items = crate::items::Items::catalog(&root, &overlay).unwrap();
|
||||
let recipes = crate::crafting::Recipes::load(&root, &items, &overlay).unwrap();
|
||||
let grid = vec![Some(horizon_items::items::ItemStack {
|
||||
item: "minecraft:raw_copper".into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
})];
|
||||
let (found, _) = recipes
|
||||
.find_with(&grid, 1, &|_| None)
|
||||
.unwrap_or_else(|| panic!("{name}: the recipe matches a grid"));
|
||||
assert_eq!(
|
||||
found, name,
|
||||
"{name}: the crafting copy names it bare, folder and pack alike"
|
||||
);
|
||||
// The book learns what the craft awards; rejoining sends it.
|
||||
let mut book = crate::recipe_book::Settings::default();
|
||||
let (_, unlocked) = book.add_recipes(&sync.advancements.displays, &[found.to_owned()]);
|
||||
assert_eq!(
|
||||
unlocked,
|
||||
[format!("minecraft:{name}")],
|
||||
"{name}: the book knows it under its whole id"
|
||||
);
|
||||
let bytes = sync.advancements.initial_recipe_book(&book, &sync);
|
||||
let mut r = Reader::new(&bytes);
|
||||
let add =
|
||||
<horizon_protocol::packets::recipes::RecipeBookAdd as horizon_protocol::buf::Codec<
|
||||
horizon_protocol::packets::recipes::RecipeBookAdd,
|
||||
>>::read(&mut r)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!add.entries.is_empty(),
|
||||
"{name}: the crafted recipe reaches the book after rejoining"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_recipe_unlocked_by_crafting_shows_in_the_book() {
|
||||
crafted_recipe_reaches_the_book("folder_recipe", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_recipe_unlocked_by_crafting_shows_in_the_book() {
|
||||
crafted_recipe_reaches_the_book("pack_recipe", true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
|
||||
let root = resources(
|
||||
|
||||
@@ -1003,6 +1003,46 @@ fn login_and_configure(
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
// The mods that must be the same on both sides, named when they are not
|
||||
// (horizon_protocol::mod_list); the registry hash below only sees ids.
|
||||
let server_mods = horizon_protocol::mod_list::local();
|
||||
if brand == "horizon" && !server_mods.is_empty() {
|
||||
send(
|
||||
c,
|
||||
cfg::Clientbound::CustomPayload(cfg::CustomPayload {
|
||||
channel: horizon_protocol::mod_list::CHANNEL.into(),
|
||||
data: horizon_protocol::mod_list::encode(&horizon_protocol::mod_list::shared(
|
||||
&server_mods,
|
||||
)),
|
||||
}),
|
||||
)?;
|
||||
send(c, cfg::Clientbound::Ping(cfg::Ping { id: 1 }))?;
|
||||
// A client that does not answer has no mods to offer.
|
||||
let mut client_mods = Vec::new();
|
||||
loop {
|
||||
let (id, payload) = c.recv().map_err(e)?;
|
||||
match decode_config(id, &payload).map_err(e)? {
|
||||
cfg::Serverbound::CustomPayload(p)
|
||||
if p.channel == horizon_protocol::mod_list::CHANNEL =>
|
||||
{
|
||||
client_mods = horizon_protocol::mod_list::decode(&p.data).unwrap_or_default();
|
||||
}
|
||||
cfg::Serverbound::Pong(p) if p.id == 1 => break,
|
||||
cfg::Serverbound::ClientInformation(i) => info = i,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let difference = horizon_protocol::mod_list::compare(&server_mods, &client_mods);
|
||||
if !difference.is_empty() {
|
||||
let text = difference.reason();
|
||||
let reason = horizon_world::nbt::Tag::String(horizon_world::nbt::JavaString::from(
|
||||
text.as_str(),
|
||||
));
|
||||
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
|
||||
lost(shared, &format!("{name} ({})", uuid_string(uuid)), &text);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
if brand == "horizon"
|
||||
&& let Some(hash) = sync.content_identity
|
||||
{
|
||||
|
||||
@@ -82,14 +82,7 @@ pub struct SyncData {
|
||||
pub advancements: super::advancements::ServerAdvancements,
|
||||
}
|
||||
|
||||
/// A resource id with its default namespace filled in.
|
||||
fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
use crate::crafting::qualified;
|
||||
|
||||
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted;
|
||||
/// a file that can't be read or doesn't parse is left out. Through `json_folder`: from the
|
||||
|
||||
@@ -276,6 +276,9 @@ pub struct Guest {
|
||||
/// The shared flags (Entity.DATA_SHARED_FLAGS_ID) the player's client was
|
||||
/// last sent about itself.
|
||||
pub sent_shared: i8,
|
||||
/// The shoulder parrots (Player.DATA_SHOULDER_PARROT_LEFT and _RIGHT) the
|
||||
/// player's client was last sent about itself.
|
||||
pub sent_shoulders: [Option<i32>; 2],
|
||||
/// Where the player last died (Player.lastDeathLocation), sent with the respawn.
|
||||
pub last_death: Option<(String, [i32; 3])>,
|
||||
/// The last action sequence to acknowledge at the next tick (-1: none).
|
||||
@@ -450,6 +453,7 @@ impl Guest {
|
||||
sent_xp: None,
|
||||
sent_air: (300, 0.0),
|
||||
sent_shared: 0,
|
||||
sent_shoulders: [None; 2],
|
||||
last_death: None,
|
||||
ack_up_to: std::cell::Cell::new(-1),
|
||||
sent_selected: 0,
|
||||
@@ -1102,13 +1106,55 @@ fn network_stack(
|
||||
}
|
||||
// Lore lines as loaded or as a server mod wrote them (a list of text
|
||||
// components), so a server menu's items can say more than their name.
|
||||
if let Some(horizon_world::nbt::Tag::List(lines)) =
|
||||
s.data().and_then(|d| d.other("minecraft:lore"))
|
||||
if let Some(lore_tag) = s.data().and_then(|d| d.other("minecraft:lore")) {
|
||||
let lines = match lore_tag {
|
||||
horizon_world::nbt::Tag::List(lines) => Some(lines.clone()),
|
||||
// A string is one literal line, as ComponentSerialization decodes a string tag.
|
||||
horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::String(_) => {
|
||||
Some(vec![lore_tag.clone()])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(lines) = lines {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::LORE,
|
||||
horizon_protocol::item::Component::Lore(lines),
|
||||
));
|
||||
}
|
||||
}
|
||||
if s.data()
|
||||
.is_some_and(|d| d.other("!minecraft:lore").is_some())
|
||||
{
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::LORE,
|
||||
horizon_protocol::item::Component::Lore(lines.clone()),
|
||||
));
|
||||
stack.removed.push(horizon_protocol::item::component::LORE);
|
||||
}
|
||||
// The stack's custom rarity ("common", "uncommon", "rare", "epic" or 0..=3).
|
||||
if let Some(r) = s.data().and_then(|d| d.other("minecraft:rarity")) {
|
||||
let index = match r {
|
||||
horizon_world::nbt::Tag::String(s) => match &*s.to_string_lossy() {
|
||||
"common" => Some(0),
|
||||
"uncommon" => Some(1),
|
||||
"rare" => Some(2),
|
||||
"epic" => Some(3),
|
||||
_ => None,
|
||||
},
|
||||
horizon_world::nbt::Tag::Byte(b) if (0..=3).contains(b) => Some(*b as i32),
|
||||
horizon_world::nbt::Tag::Short(s) if (0..=3).contains(s) => Some(*s as i32),
|
||||
horizon_world::nbt::Tag::Int(i) if (0..=3).contains(i) => Some(*i),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(index) = index {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::RARITY,
|
||||
horizon_protocol::item::Component::Int(index),
|
||||
));
|
||||
}
|
||||
}
|
||||
if s.data()
|
||||
.is_some_and(|d| d.other("!minecraft:rarity").is_some())
|
||||
{
|
||||
stack
|
||||
.removed
|
||||
.push(horizon_protocol::item::component::RARITY);
|
||||
}
|
||||
// The component as loaded, so a translatable name stays one.
|
||||
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
|
||||
@@ -1237,6 +1283,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rarity_reaches_the_client() {
|
||||
let mut item = ItemStack::new("minecraft:stick", 1);
|
||||
item.data_mut()
|
||||
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
|
||||
let sent = stack_to_network(|_| 7, |_, _| None, &Some(item));
|
||||
let mut writer = Writer::new();
|
||||
horizon_protocol::item::Slot::write(&sent, &mut writer);
|
||||
let mut reader = Reader::new(&writer.data);
|
||||
let received = horizon_protocol::item::Slot::read(&mut reader)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
received.component(component::RARITY),
|
||||
Some(&Component::Int(3))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_custom_name_survives_inventory_sync() {
|
||||
let name = Tag::compound([
|
||||
|
||||
@@ -135,6 +135,32 @@ pub enum HostEvent {
|
||||
MainHandChanged(u32, bool),
|
||||
/// A guest changed their client language option.
|
||||
LocaleChanged(u32, String),
|
||||
/// A guest's client finished loading the level around them.
|
||||
PlayerLoaded(u32),
|
||||
/// A guest changed recipe book settings.
|
||||
RecipeBookSettings {
|
||||
conn: u32,
|
||||
book: i32,
|
||||
open: bool,
|
||||
filtering: bool,
|
||||
},
|
||||
/// A guest changed sneaking in a player input packet, already applied.
|
||||
Sneak {
|
||||
conn: u32,
|
||||
sneaking: bool,
|
||||
},
|
||||
/// A loaded guest sent their current player input flags.
|
||||
Input {
|
||||
conn: u32,
|
||||
flags: i32,
|
||||
},
|
||||
/// A guest was kicked, with the reason rendered by the hub.
|
||||
Kicked {
|
||||
conn: u32,
|
||||
player: u128,
|
||||
name: String,
|
||||
reason: String,
|
||||
},
|
||||
/// A guest's client started (`flying`) or stopped flying (their abilities packet), applied to
|
||||
/// the guest already: the game tells the mods, which may refuse it.
|
||||
Flight {
|
||||
@@ -175,6 +201,13 @@ pub enum HostEvent {
|
||||
},
|
||||
}
|
||||
|
||||
/// The criteria `CriteriaTriggers.TICK` granted one player.
|
||||
pub struct TickGrants {
|
||||
pub player: u128,
|
||||
pub name: String,
|
||||
pub grants: Vec<super::advancements::CriterionGrant>,
|
||||
}
|
||||
|
||||
/// What one player started and stopped tracking, in order, for the game to
|
||||
/// tell the mods.
|
||||
pub struct TrackingBatch {
|
||||
@@ -198,9 +231,17 @@ fn take_tracking(g: &mut Guest, out: &mut Vec<TrackingBatch>) {
|
||||
pub struct Hub {
|
||||
/// Clicks on and closings of native mods' menus, for the game.
|
||||
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
|
||||
/// Kicks waiting for the game thread to notify native mods.
|
||||
pending_kicks: Vec<HostEvent>,
|
||||
/// The entities players started and stopped tracking, collected by
|
||||
/// `collect_tracking`, for the game to tell the mods.
|
||||
pub native_tracking: Vec<TrackingBatch>,
|
||||
/// Criteria `CriteriaTriggers.TICK` granted to players, for the game to
|
||||
/// tell the mods.
|
||||
pub native_advancement_grants: Vec<TickGrants>,
|
||||
/// Whether any native mod is loaded: `Input` and `Sneak` events only exist for them, so
|
||||
/// the hub queues none otherwise. The game sets it before each poll.
|
||||
pub mods_loaded: bool,
|
||||
/// Players a native mod hid (`native_hide_player`), by UUID, on or off:
|
||||
/// only players of at least that permission level see them, in the
|
||||
/// player list, the world and their joining and leaving.
|
||||
@@ -346,6 +387,19 @@ fn shoulder_meta(state: &mut EntityState, shoulders: [Option<i32>; 2]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shoulder parrot slots that differ from what the player's client was
|
||||
/// last sent, as entity data: 0 for none, else the variant's id plus one.
|
||||
fn shoulder_changes(now: [Option<i32>; 2], sent: [Option<i32>; 2]) -> Vec<(u8, i32, Value)> {
|
||||
(19..)
|
||||
.zip(now.into_iter().zip(sent))
|
||||
.filter(|(_, (now, sent))| now != sent)
|
||||
.map(|(index, (now, _))| {
|
||||
let id = now.map_or(0, |v| v + 1);
|
||||
(index, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(id))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
/// Player.getDisplayName: the name formatted for the player's team
|
||||
/// (PlayerTeam.formatNameForTeam), suggesting `/tell NAME `, showing the
|
||||
@@ -546,7 +600,10 @@ impl Hub {
|
||||
entity_gone: Vec::new(),
|
||||
mod_commands: (serde_json::Value::Null, None),
|
||||
native_menu_events: Vec::new(),
|
||||
pending_kicks: Vec::new(),
|
||||
native_tracking: Vec::new(),
|
||||
native_advancement_grants: Vec::new(),
|
||||
mods_loaded: false,
|
||||
hidden: Default::default(),
|
||||
teams: Vec::new(),
|
||||
nicknames: std::collections::HashMap::new(),
|
||||
@@ -810,8 +867,15 @@ impl Hub {
|
||||
// The log's "lost connection" line gives the reason, as vanilla's.
|
||||
let text = self.english(&reason);
|
||||
if let Some(g) = self.guest_mut(conn) {
|
||||
g.left_reason = text;
|
||||
g.left_reason = text.clone();
|
||||
let (player, name) = (g.uuid, g.name.clone());
|
||||
g.sender.disconnect(reason);
|
||||
self.pending_kicks.push(HostEvent::Kicked {
|
||||
conn,
|
||||
player,
|
||||
name,
|
||||
reason: text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1510,7 +1574,7 @@ impl Hub {
|
||||
pub fn poll(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
|
||||
self.ticks += 1;
|
||||
self.anticheat_tick(ctx.tick_rate);
|
||||
let mut events = Vec::new();
|
||||
let mut events = std::mem::take(&mut self.pending_kicks);
|
||||
for event in self.lan.poll() {
|
||||
match event {
|
||||
NetEvent::Joined(j) => {
|
||||
@@ -2610,6 +2674,11 @@ impl Hub {
|
||||
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
|
||||
}
|
||||
g.send(&raw(ids::UPDATE_ADVANCEMENTS, first.update_advancements));
|
||||
self.native_advancement_grants.push(TickGrants {
|
||||
player: g.uuid,
|
||||
name: g.name.clone(),
|
||||
grants: first.granted,
|
||||
});
|
||||
g.advancements_visible = sync.advancements.visibility(&g.advancements);
|
||||
g.advancements_changed.clear();
|
||||
} else {
|
||||
@@ -2672,6 +2741,17 @@ impl Hub {
|
||||
values: vec![(0, serializer::BYTE, Value::Byte(shared))],
|
||||
}));
|
||||
}
|
||||
// ServerEntity.sendDirtyEntityData: the parrots on the player's
|
||||
// shoulders go to the player too, whose own model draws them.
|
||||
let g = &mut self.guests[i];
|
||||
if g.shoulders != g.sent_shoulders {
|
||||
let values = shoulder_changes(g.shoulders, g.sent_shoulders);
|
||||
g.sent_shoulders = g.shoulders;
|
||||
g.send(&C::SetEntityData(play::SetEntityData {
|
||||
id: g.entity_id,
|
||||
values,
|
||||
}));
|
||||
}
|
||||
// Sent when the health or the food level changes, or the saturation
|
||||
// reaches or leaves zero; a dead player's health of 0 too.
|
||||
let g = &mut self.guests[i];
|
||||
@@ -2744,6 +2824,7 @@ impl Hub {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64);
|
||||
for g in &mut self.guests {
|
||||
let mut granted = Vec::new();
|
||||
// PlayerAdvancements.reload: isFirstPacket again, the progress
|
||||
// read against the new tree; a player whose advancements were
|
||||
// never loaded does that on their first tick.
|
||||
@@ -2752,12 +2833,20 @@ impl Hub {
|
||||
}
|
||||
g.advancements_visible.clear();
|
||||
g.advancements_changed.clear();
|
||||
if let Some(add) =
|
||||
sync.advancements
|
||||
.reload(&sync, now, &mut g.advancements, &mut g.recipe_book)
|
||||
{
|
||||
if let Some(add) = sync.advancements.reload(
|
||||
&sync,
|
||||
now,
|
||||
&mut g.advancements,
|
||||
&mut g.recipe_book,
|
||||
&mut granted,
|
||||
) {
|
||||
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
|
||||
}
|
||||
self.native_advancement_grants.push(TickGrants {
|
||||
player: g.uuid,
|
||||
name: g.name.clone(),
|
||||
grants: granted,
|
||||
});
|
||||
}
|
||||
// broadcastAll(ClientboundUpdateTagsPacket).
|
||||
self.broadcast(&C::UpdateTags(sync.tags.clone()));
|
||||
@@ -2985,6 +3074,7 @@ impl Hub {
|
||||
g.loaded = true;
|
||||
g.loading = false;
|
||||
g.load_timeout = 0;
|
||||
events.push(HostEvent::PlayerLoaded(conn));
|
||||
}
|
||||
// PlayerChunkSender.onChunkBatchReceivedByClient.
|
||||
S::ChunkBatchReceived(p) => {
|
||||
@@ -3030,8 +3120,18 @@ impl Hub {
|
||||
if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize)
|
||||
{
|
||||
let settings = &mut self.guests[i].recipe_book;
|
||||
let changed = settings.open[book.index()] != p.open
|
||||
|| settings.filtering[book.index()] != p.filtering;
|
||||
settings.open[book.index()] = p.open;
|
||||
settings.filtering[book.index()] = p.filtering;
|
||||
if changed {
|
||||
events.push(HostEvent::RecipeBookSettings {
|
||||
conn,
|
||||
book: p.book_type,
|
||||
open: p.open,
|
||||
filtering: p.filtering,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
S::ClientInformation(info) => {
|
||||
@@ -3068,7 +3168,21 @@ impl Hub {
|
||||
let g = &mut self.guests[i];
|
||||
g.input = p.flags;
|
||||
if g.client_loaded() {
|
||||
g.sneaking = p.flags & 32 != 0;
|
||||
// The events only tell the mods: nothing is queued with none loaded.
|
||||
let listening = self.mods_loaded;
|
||||
if listening {
|
||||
events.push(HostEvent::Input {
|
||||
conn,
|
||||
flags: i32::from(p.flags),
|
||||
});
|
||||
}
|
||||
let sneaking = p.flags & 32 != 0;
|
||||
if g.sneaking != sneaking {
|
||||
g.sneaking = sneaking;
|
||||
if listening {
|
||||
events.push(HostEvent::Sneak { conn, sneaking });
|
||||
}
|
||||
}
|
||||
}
|
||||
// handlePlayerInput only records keys and updates crouching.
|
||||
// Sprinting persists after the sprint key is released (or
|
||||
@@ -3804,6 +3918,7 @@ impl Hub {
|
||||
g.sent_xp = None;
|
||||
g.sent_air = (300, 0.0);
|
||||
g.sent_shared = 0;
|
||||
g.sent_shoulders = [None; 2];
|
||||
let (cx, cz) = g.chunk();
|
||||
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
|
||||
x: cx,
|
||||
@@ -3974,6 +4089,18 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::fast_hash::FxHashSet;
|
||||
|
||||
/// The owner's own shoulder parrots are sent when a slot changes.
|
||||
#[test]
|
||||
fn the_owner_is_sent_the_shoulder_slots_that_changed() {
|
||||
assert!(shoulder_changes([None; 2], [None; 2]).is_empty());
|
||||
let landed = shoulder_changes([Some(1), None], [None; 2]);
|
||||
assert_eq!(landed.len(), 1);
|
||||
assert_eq!((landed[0].0, &landed[0].2), (19, &Value::Id(2)));
|
||||
let left = shoulder_changes([None; 2], [Some(1), Some(4)]);
|
||||
assert_eq!(left.len(), 2);
|
||||
assert_eq!((left[1].0, &left[1].2), (20, &Value::Id(0)));
|
||||
}
|
||||
|
||||
/// The counts of what is stuck in a player are sent when set, and a
|
||||
/// count back at zero is sent as the default.
|
||||
#[test]
|
||||
|
||||
@@ -15,11 +15,22 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// `PlayerDataStorage`'s player directory (`players/data`), created on first use
|
||||
/// as its constructor does with `mkdirs`.
|
||||
pub fn directory(world: &Path) -> PathBuf {
|
||||
let dir = directory_path(world);
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
log::warn!("Horizon: {} is not created: {e}", dir.display());
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
fn directory_path(world: &Path) -> PathBuf {
|
||||
world.join("players").join("data")
|
||||
}
|
||||
|
||||
pub fn path(world: &Path, uuid: u128) -> PathBuf {
|
||||
world
|
||||
.join("players")
|
||||
.join("data")
|
||||
.join(format!("{}.dat", uuid_string(uuid)))
|
||||
directory_path(world).join(format!("{}.dat", uuid_string(uuid)))
|
||||
}
|
||||
|
||||
/// `UUIDUtil.uuidToIntArray`.
|
||||
|
||||
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user