mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge latest main and preserve surface-height and consumable changes
This commit is contained in:
commit
de6f15e664
23 files changed
+877
-124
No files matched your search
Generated
+1
@@ -1435,6 +1435,7 @@ dependencies = [
|
||||
"naga",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -51,6 +51,10 @@ horizon-dualsense = { path = "../horizon-dualsense" }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
# The icon resource in the .exe (build.rs).
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -66,7 +66,6 @@ fn fixture(per_chunk: usize) -> (Vec<Arc<Chunk>>, Entities, Names) {
|
||||
for s in &mut template.sections[..8] {
|
||||
Arc::make_mut(s).blocks = PalettedContainer::new(block_strategy(), 1);
|
||||
}
|
||||
template.heights = Arc::new([64; 256]);
|
||||
for i in 0..256 {
|
||||
let x = i % 16 - 8;
|
||||
let z = i / 16 - 8;
|
||||
|
||||
@@ -215,7 +215,12 @@ impl Session {
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(limit.max(0.0));
|
||||
loop {
|
||||
let status = self.game("status")?;
|
||||
if !status["player"].is_null() && status["pending_chunks"] == 0 {
|
||||
// On a server (single player's server thread too), also the
|
||||
// level around the player.
|
||||
if !status["player"].is_null()
|
||||
&& status["pending_chunks"] == 0
|
||||
&& status["server"]["loaded"] != false
|
||||
{
|
||||
return Ok(status);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! (copy-on-write through `Arc::make_mut`).
|
||||
use horizon_core::paletted::{PalettedContainer, Strategy};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub const MIN_Y: i32 = -64;
|
||||
pub const HEIGHT: i32 = 384;
|
||||
@@ -45,6 +45,10 @@ impl Light {
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn column(x: i32, z: i32) -> usize {
|
||||
((z & 15) << 4 | (x & 15)) as usize
|
||||
}
|
||||
#[inline]
|
||||
pub fn index(x: i32, y: i32, z: i32) -> usize {
|
||||
((y & 15) << 8 | (z & 15) << 4 | (x & 15)) as usize
|
||||
}
|
||||
@@ -118,8 +122,10 @@ pub struct Chunk {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
pub sections: Vec<Arc<Section>>,
|
||||
/// Highest non-air, light-blocking-or-fluid Y per column (MOTION_BLOCKING-like), for rain/sky.
|
||||
pub heights: Arc<[i16; 256]>,
|
||||
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
|
||||
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
|
||||
/// `set` that can move a column's surface drops it.
|
||||
surface: OnceLock<Box<[i16; 256]>>,
|
||||
pub lit: bool,
|
||||
/// Light read from a save with `isLightOn`: no light job is needed.
|
||||
pub light_loaded: bool,
|
||||
@@ -148,7 +154,7 @@ impl Chunk {
|
||||
sections: (0..SECTIONS)
|
||||
.map(|_| Arc::new(Section::default()))
|
||||
.collect(),
|
||||
heights: Arc::new([MIN_Y as i16; 256]),
|
||||
surface: OnceLock::new(),
|
||||
lit: false,
|
||||
light_loaded: false,
|
||||
fresh: false,
|
||||
@@ -190,7 +196,52 @@ impl Chunk {
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
return v;
|
||||
}
|
||||
Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v)
|
||||
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
|
||||
// `Heightmap.update`: a write two or more below the surface can't
|
||||
// move it. Any other change rebuilds the map on the next read.
|
||||
if previous != v
|
||||
&& let Some(surface) = self.surface.get()
|
||||
&& y >= i32::from(surface[column(x, z)]) - 1
|
||||
{
|
||||
self.surface = OnceLock::new();
|
||||
}
|
||||
previous
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
|
||||
/// Sections must change only through `set` once this has been read.
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
|
||||
let surface = self.surface.get_or_init(|| self.prime_surface(air));
|
||||
i32::from(surface[column(x, z)])
|
||||
}
|
||||
/// `Heightmap.primeHeightmaps` for WORLD_SURFACE: each section from the
|
||||
/// top, skipping those of one air state, until every column is found.
|
||||
fn prime_surface(&self, air: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
|
||||
let mut surface = Box::new([MIN_Y as i16; 256]);
|
||||
let mut found = [false; 256];
|
||||
let mut left = 256;
|
||||
let mut values = [0u32; 4096];
|
||||
for (s, section) in self.sections.iter().enumerate().rev() {
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
if section.blocks.uniform().is_some_and(&air) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut values);
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
for i in 0..256 {
|
||||
if found[i] {
|
||||
continue;
|
||||
}
|
||||
if let Some(dy) = (0..16).rev().find(|&dy| !air(values[dy << 8 | i])) {
|
||||
surface[i] = (base + dy as i32 + 1) as i16;
|
||||
found[i] = true;
|
||||
left -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
surface
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
@@ -261,4 +312,50 @@ mod tests {
|
||||
);
|
||||
assert_eq!(Chunk::empty(0, 0).highest(air, |_| unreachable!()), None);
|
||||
}
|
||||
/// The kept WORLD_SURFACE heights match a scan of every column after
|
||||
/// each of a few thousand writes (air, cave air, stone, whole sections).
|
||||
#[test]
|
||||
fn world_surface_follows_writes() {
|
||||
let (air, cave_air, stone) = (0, 7, 1);
|
||||
let is_air = |v: u32| v == air || v == cave_air;
|
||||
let scan = |c: &Chunk, x: i32, z: i32| {
|
||||
(MIN_Y..MIN_Y + HEIGHT)
|
||||
.rev()
|
||||
.find(|&y| !is_air(c.get(x, y, z)))
|
||||
.map_or(MIN_Y, |y| y + 1)
|
||||
};
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
assert_eq!(chunk.world_surface(5, 5, is_air), MIN_Y);
|
||||
for y in MIN_Y..MIN_Y + 70 {
|
||||
for i in 0..256 {
|
||||
chunk.set(i & 15, y, i >> 4, stone);
|
||||
}
|
||||
}
|
||||
for i in 0..4096 {
|
||||
Arc::make_mut(&mut chunk.sections[8]).set(i, cave_air);
|
||||
}
|
||||
let mut random = horizon_core::random::LegacyRandom::new(9);
|
||||
use horizon_core::random::RandomSource;
|
||||
for step in 0..4000 {
|
||||
let x = random.next_int_bound(16).unwrap();
|
||||
let z = random.next_int_bound(16).unwrap();
|
||||
let y = MIN_Y + 40 + random.next_int_bound(120).unwrap();
|
||||
let v = [air, cave_air, stone][random.next_int_bound(3).unwrap() as usize];
|
||||
chunk.set(x, y, z, v);
|
||||
if step % 7 == 0 {
|
||||
for i in 0..256 {
|
||||
assert_eq!(
|
||||
chunk.world_surface(i & 15, i >> 4, is_air),
|
||||
scan(&chunk, i & 15, i >> 4)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Out-of-range writes and unchanged states keep the map.
|
||||
let before = chunk.world_surface(3, 3, is_air);
|
||||
chunk.set(3, MIN_Y + HEIGHT, 3, stone);
|
||||
chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3));
|
||||
assert!(chunk.surface.get().is_some());
|
||||
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ mod command_blocks;
|
||||
mod command_harness;
|
||||
mod commands;
|
||||
mod composter;
|
||||
mod consume;
|
||||
mod control;
|
||||
mod copper_golems;
|
||||
mod creaking;
|
||||
@@ -1400,6 +1401,21 @@ impl Dim {
|
||||
_ => Self::Overworld,
|
||||
}
|
||||
}
|
||||
/// `Level.getMinY`: the dimension type's minimum y.
|
||||
fn min_y(self) -> i32 {
|
||||
match self {
|
||||
Self::Overworld => MIN_Y,
|
||||
Self::Nether | Self::End => 0,
|
||||
}
|
||||
}
|
||||
/// `DimensionType.logicalHeight`.
|
||||
fn logical_height(self) -> i32 {
|
||||
match self {
|
||||
Self::Overworld => HEIGHT,
|
||||
Self::Nether => 128,
|
||||
Self::End => 256,
|
||||
}
|
||||
}
|
||||
/// `Level.getMaxY`: the dimension type's minimum y plus height, less one.
|
||||
fn max_y(self) -> i32 {
|
||||
match self {
|
||||
@@ -2197,6 +2213,11 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.dragon_egg_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2017,
|
||||
pos,
|
||||
data,
|
||||
} => self.consume_teleport_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2013,
|
||||
pos,
|
||||
@@ -3059,15 +3080,15 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `Consumable.startConsuming` for held food the player can eat
|
||||
/// (`Player.canEat`): the right click is held for `consume_seconds`.
|
||||
/// `Consumable.startConsuming` for a held consumable: food needs
|
||||
/// `Player.canEat` (`Consumable.canConsume`), anything else is always
|
||||
/// consumed. The right click is held for `consume_seconds`.
|
||||
fn start_eating(&mut self, info: &horizon_client::items::ItemInfo) -> bool {
|
||||
if !info.consumable
|
||||
|| info.food.is_none()
|
||||
|| !self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| info.can_always_eat || p.health.food.food < 20 || self.creative)
|
||||
if info.consumable.is_none()
|
||||
|| (info.food.is_some()
|
||||
&& !self.player.as_ref().is_some_and(|p| {
|
||||
info.can_always_eat || p.health.food.food < 20 || self.creative
|
||||
}))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -3076,18 +3097,17 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
fn apply_consume_effect(
|
||||
&mut self,
|
||||
effect: &horizon_client::items::ConsumeEffect,
|
||||
eye: [f64; 3],
|
||||
) {
|
||||
fn apply_consume_effect(&mut self, effect: &horizon_client::items::ConsumeEffect) {
|
||||
use horizon_client::items::ConsumeEffect;
|
||||
match effect {
|
||||
ConsumeEffect::ApplyEffects {
|
||||
effects,
|
||||
probability,
|
||||
} => {
|
||||
if *probability >= 1.0 || self.world.random.next_float() <= *probability {
|
||||
// ApplyStatusEffectsConsumeEffect.apply: the user's random
|
||||
// (the level's stands in, as for every player draw) is drawn
|
||||
// even at probability 1.
|
||||
if self.world.random.next_float() < *probability {
|
||||
for eff in effects {
|
||||
if let Some(def) = horizon_client::brewing::effect(&eff.id) {
|
||||
if def.instant {
|
||||
@@ -3120,9 +3140,14 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_effect_status();
|
||||
}
|
||||
ConsumeEffect::TeleportRandomly => {}
|
||||
ConsumeEffect::TeleportRandomly(teleport) => self.teleport_randomly(teleport),
|
||||
ConsumeEffect::PlaySound(sound) => {
|
||||
self.audio.play(sound, Some(eye), 1.0, 1.0);
|
||||
// PlaySoundConsumeEffect: at the user's block position.
|
||||
let at = self.player.as_ref().map(|p| {
|
||||
let b = horizon_core::position::BlockPos::containing(p.position);
|
||||
[b.x, b.y, b.z].map(|v| f64::from(v) + 0.5)
|
||||
});
|
||||
self.audio.play(sound, at, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3204,49 +3229,21 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let duration = 32;
|
||||
// Consumable.shouldEmitParticlesAndSounds(useItemRemainingTicks), with
|
||||
// the remaining ticks before this tick counts down: every 4 ticks once
|
||||
// more than 21.875% of the time has passed (none on the last tick).
|
||||
let Some(consumable) = info.consumable.as_ref() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
// ItemStack.onUseTick: Consumable.shouldEmitParticlesAndSounds with
|
||||
// the remaining ticks before this tick counts them down.
|
||||
let duration = consumable.ticks().max(0) as u32;
|
||||
let remaining = duration - ticks.min(duration);
|
||||
let ticks = ticks + 1;
|
||||
let eye = self.eye_position().unwrap_or_default();
|
||||
if duration - remaining > (duration as f32 * 0.21875) as u32 && remaining % 4 == 0 {
|
||||
let pitch =
|
||||
(self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.eat",
|
||||
Some(eye),
|
||||
0.5 + 0.5 * self.world.random.next_int_bound(2).unwrap_or(0) as f32,
|
||||
pitch,
|
||||
);
|
||||
self.eat_particles(&stack, 5);
|
||||
if consumable.should_emit(remaining as i32) {
|
||||
self.emit_consume(consumable, &stack, 5);
|
||||
}
|
||||
if ticks >= duration {
|
||||
self.using = None;
|
||||
// Consumable.onConsume: emitParticlesAndSounds(..., 16).
|
||||
self.eat_particles(&stack, 16);
|
||||
// Consumable.onConsume.
|
||||
self.post_entity_event("minecraft:eat", horizon_client::mobs::Who::Player);
|
||||
if let Some((nutrition, saturation)) = info.food {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
// FoodData.eat(nutrition, saturation)
|
||||
let food = &mut p.health.food;
|
||||
food.food = (food.food + nutrition as i32).clamp(0, 20);
|
||||
food.saturation = (food.saturation + saturation).clamp(0.0, food.food as f32);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.player.burp",
|
||||
Some(eye),
|
||||
0.5,
|
||||
self.world.random.next_float() * 0.1 + 0.9,
|
||||
);
|
||||
for effect in &info.consume_effects {
|
||||
self.apply_consume_effect(effect, eye);
|
||||
}
|
||||
self.trigger_item("minecraft:consume_item", &stack);
|
||||
self.consume_held();
|
||||
}
|
||||
self.finish_consuming(&info, consumable, &stack);
|
||||
} else {
|
||||
self.using = Some((slot, ticks));
|
||||
}
|
||||
|
||||
@@ -99,11 +99,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `getHeight(WORLD_SURFACE)`: above the highest non-air block.
|
||||
pub(super) fn world_surface(&self, x: i32, z: i32) -> i32 {
|
||||
let (min, max) = (super::MIN_Y, super::MIN_Y + super::HEIGHT);
|
||||
(min..max)
|
||||
.rev()
|
||||
.find(|&y| !self.res.facts().is_air(self.world.block(x, y, z)))
|
||||
.map_or(min, |y| y + 1)
|
||||
self.world
|
||||
.world_surface(x, z, |id| self.res.facts().is_air(id))
|
||||
}
|
||||
/// `BeaconBlockEntity.tick` of the beacon at `key`.
|
||||
pub(super) fn tick_beacon_at(&mut self, key: (i32, i32, i32)) {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Finishing a consumable (`Consumable.onConsume` and the use remainder),
|
||||
//! its particles and sounds, and `TeleportRandomlyConsumeEffect` (chorus
|
||||
//! fruit) for the local player. The level random stands in for the player's
|
||||
//! own, as for every other player draw.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
items::{ConsumeEffect, ItemInfo, ItemStack},
|
||||
mobs::Who,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3, position::BlockPos, random::LegacyRandom, random::RandomSource,
|
||||
};
|
||||
use horizon_world::{
|
||||
consumable::{Consumable, EmissionWorld, UseAnimation},
|
||||
teleport_consume::{TeleportConsume, TeleportConsumeWorld},
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
|
||||
type ItemConsumable = Consumable<String, ConsumeEffect>;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`.
|
||||
pub(super) fn emit_consume(
|
||||
&mut self,
|
||||
consumable: &ItemConsumable,
|
||||
stack: &ItemStack,
|
||||
count: i32,
|
||||
) {
|
||||
let Ok(()) = consumable.emit(&mut Emitter(self), (), stack.clone(), count);
|
||||
}
|
||||
/// `LivingEntity.completeUsingItem` for a consumable: `Consumable.onConsume`,
|
||||
/// then `ItemStack.applyAfterUseComponentSideEffects`' use remainder.
|
||||
pub(super) fn finish_consuming(
|
||||
&mut self,
|
||||
info: &ItemInfo,
|
||||
consumable: &ItemConsumable,
|
||||
stack: &ItemStack,
|
||||
) {
|
||||
self.emit_consume(consumable, stack, 16);
|
||||
// ServerPlayer: the used statistic and the consume_item trigger.
|
||||
self.stats.add("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 {
|
||||
let at = self.feet();
|
||||
let pitch = self.world.random.triangle_float(1.0, 0.4);
|
||||
self.audio.play(&consumable.sound, at, 1.0, pitch);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
// FoodData.eat(nutrition, saturation)
|
||||
let food = &mut p.health.food;
|
||||
food.food = (food.food + nutrition as i32).clamp(0, 20);
|
||||
food.saturation = (food.saturation + saturation).clamp(0.0, food.food as f32);
|
||||
}
|
||||
// Mth.randomBetween(random, 0.9, 1.0)
|
||||
let pitch = self.world.random.next_float() * (1.0f32 - 0.9f32) + 0.9;
|
||||
self.audio
|
||||
.play("minecraft:entity.player.burp", self.feet(), 0.5, pitch);
|
||||
}
|
||||
for effect in &consumable.effects {
|
||||
self.apply_consume_effect(effect);
|
||||
}
|
||||
let event = if consumable.animation == UseAnimation::Drink {
|
||||
"minecraft:drink"
|
||||
} else {
|
||||
"minecraft:eat"
|
||||
};
|
||||
self.post_entity_event(event, Who::Player);
|
||||
// ItemStack.consume, then UseRemainder.convertIntoRemainder: nothing
|
||||
// with infinite materials, the hand when emptied, else the inventory
|
||||
// (ServerPlayer.handleExtraItemsCreatedOnUse) or a drop.
|
||||
self.consume_held();
|
||||
if let Some(remainder) = &info.use_remainder
|
||||
&& !self.creative
|
||||
{
|
||||
let remainder = ItemStack::new(remainder, 1);
|
||||
if self.held().is_none() {
|
||||
*self.held_mut() = Some(remainder);
|
||||
} else if let Some(rest) = self.give(remainder) {
|
||||
self.player_drop(rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn feet(&self) -> Option<[f64; 3]> {
|
||||
self.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z])
|
||||
}
|
||||
/// `TeleportRandomlyConsumeEffect.apply` for the local player.
|
||||
pub(super) fn teleport_randomly(&mut self, teleport: &TeleportConsume) {
|
||||
if self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
let dim = self.dim;
|
||||
let Ok(_) = teleport.apply(&mut Teleporter(self), dim);
|
||||
}
|
||||
/// `LivingEntity.randomTeleport(x, y, z, true, avoid)`: down to the first
|
||||
/// block entities can teleport onto, inside the border, in a loaded chunk.
|
||||
fn random_teleport(&mut self, target: Vec3, avoid: &str) -> bool {
|
||||
let [min_x, min_z, max_x, max_z] = self.border.bounds();
|
||||
// WorldBorder.clampVec3ToBound (Mth.clamp, 1.0E-5F inside the far edge).
|
||||
let clamp = |v: f64, low: f64, high: f64| if v < low { low } else { v.min(high) };
|
||||
let x = clamp(target.x, min_x, max_x - f64::from(1.0e-5_f32));
|
||||
let z = clamp(target.z, min_z, max_z - f64::from(1.0e-5_f32));
|
||||
let mut y = target.y;
|
||||
let mut pos = BlockPos::containing(Vec3::new(x, y, z));
|
||||
if !self.world.loaded(pos.x, pos.z) {
|
||||
return false;
|
||||
}
|
||||
while pos.y > self.dim.min_y() {
|
||||
pos.y -= 1;
|
||||
let state = self.world.block(pos.x, pos.y, pos.z);
|
||||
if self.state_in_tag("minecraft:entities_can_teleport_to", state) {
|
||||
return self.check_position_and_teleport(Vec3::new(x, y, z), state, avoid);
|
||||
}
|
||||
y -= 1.0;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `LivingEntity.checkPositionAndTeleport`, then `ServerPlayer.teleportTo`
|
||||
/// (the motion and rotation kept) and entity event 46.
|
||||
fn check_position_and_teleport(
|
||||
&mut self,
|
||||
at: Vec3,
|
||||
ground: horizon_world::registry::BlockStateId,
|
||||
avoid: &str,
|
||||
) -> bool {
|
||||
if self.state_in_tag(avoid, ground) {
|
||||
return false;
|
||||
}
|
||||
let entities = self.happy_ghast_player_collisions();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let bounds = player.dimensions.bounds(at);
|
||||
let free = horizon_client::motion::MotionWorld {
|
||||
entities: &entities,
|
||||
player,
|
||||
scene: &mut self.world,
|
||||
assets: &self.pack.assets,
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
}
|
||||
.no_player_collision(bounds)
|
||||
.unwrap_or(false);
|
||||
// LevelReader.containsAnyLiquid and findBlocksIn: the blocks from the
|
||||
// box's minimum corner to the one holding its maximum corner.
|
||||
let (lo, hi) = (
|
||||
BlockPos::containing(bounds.min_position()),
|
||||
BlockPos::containing(bounds.max_position()),
|
||||
);
|
||||
let blocks = (lo.x..=hi.x)
|
||||
.flat_map(|x| (lo.y..=hi.y).flat_map(move |y| (lo.z..=hi.z).map(move |z| (x, y, z))));
|
||||
let states: Vec<_> = blocks.map(|(x, y, z)| self.world.block(x, y, z)).collect();
|
||||
let liquid = states
|
||||
.iter()
|
||||
.any(|&s| self.res.facts().get(s).fluid != horizon_client::blocks::Fluid::None);
|
||||
if !free || liquid || states.iter().any(|&s| self.state_in_tag(avoid, s)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = at;
|
||||
}
|
||||
// The position packet moves the old position along (absolute xyz).
|
||||
self.previous_position = at;
|
||||
self.portal_particles();
|
||||
true
|
||||
}
|
||||
fn state_in_tag(&self, tag: &str, state: horizon_world::registry::BlockStateId) -> bool {
|
||||
let name = &self.res.facts().get(state).name;
|
||||
self.res
|
||||
.items()
|
||||
.block_tags
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(&**name))
|
||||
}
|
||||
/// `LivingEntity.handleEntityEvent(46)`: 128 portal particles from the old
|
||||
/// to the new position, from the client player's random (the particle
|
||||
/// engine's here).
|
||||
fn portal_particles(&mut self) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (from, to) = (self.previous_position, p.position);
|
||||
let (width, height) = (
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
let options = ParticleOptions::simple(ParticleType::Portal);
|
||||
for i in 0..128 {
|
||||
let d = f64::from(i) / 127.0;
|
||||
let r = self.particles.random();
|
||||
let v = [0; 3].map(|_| f64::from((r.next_float() - 0.5) * 0.2));
|
||||
let x = from.x + d * (to.x - from.x) + (r.next_double() - 0.5) * width * 2.0;
|
||||
let y = from.y + d * (to.y - from.y) + r.next_double() * height;
|
||||
let z = from.z + d * (to.z - from.z) + (r.next_double() - 0.5) * width * 2.0;
|
||||
self.particles.add(&options, [x, y, z], v);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.stopRiding`.
|
||||
pub(super) fn stop_riding(&mut self) {
|
||||
let Some(id) = self.riding else {
|
||||
return;
|
||||
};
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.dismount(i);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.dismount_cart(i);
|
||||
} else {
|
||||
self.dismount_mount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Consumable.emitParticlesAndSounds` against the local player.
|
||||
struct Emitter<'a, 'g>(&'a mut Game<'g>);
|
||||
impl EmissionWorld for Emitter<'_, '_> {
|
||||
type Error = Infallible;
|
||||
type Stack = ItemStack;
|
||||
type Sound = String;
|
||||
type Random = ();
|
||||
type Source = LegacyRandom;
|
||||
fn random_source(&mut self, (): ()) -> &mut LegacyRandom {
|
||||
&mut self.0.world.random
|
||||
}
|
||||
/// `LivingEntity.spawnItemParticles`.
|
||||
fn spawn_item_particles(&mut self, stack: ItemStack, count: i32) -> Result<(), Infallible> {
|
||||
self.0.eat_particles(&stack, count.max(0) as u32);
|
||||
Ok(())
|
||||
}
|
||||
fn overrides_consume_sound(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn consume_sound(&mut self, _: ItemStack) -> Result<String, Infallible> {
|
||||
unreachable!("players play the consumable's own sound")
|
||||
}
|
||||
/// `Entity.playSound`: at the player's position.
|
||||
fn play_sound(&mut self, sound: String, volume: f32, pitch: f32) -> Result<(), Infallible> {
|
||||
let at = self.0.feet();
|
||||
self.0.audio.play(&sound, at, volume, pitch);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The local player as `TeleportRandomlyConsumeEffect`'s user.
|
||||
struct Teleporter<'a, 'g>(&'a mut Game<'g>);
|
||||
impl Teleporter<'_, '_> {
|
||||
fn at(&self) -> Vec3 {
|
||||
self.0.player.as_ref().map_or(Vec3::ZERO, |p| p.position)
|
||||
}
|
||||
}
|
||||
impl TeleportConsumeWorld for Teleporter<'_, '_> {
|
||||
type Error = Infallible;
|
||||
type Level = Dim;
|
||||
type Random = ();
|
||||
type Source = LegacyRandom;
|
||||
fn position_field(&self) -> Vec3 {
|
||||
self.at()
|
||||
}
|
||||
fn get_random(&mut self) -> Result<(), Infallible> {
|
||||
Ok(())
|
||||
}
|
||||
fn random_source(&mut self, (): ()) -> &mut LegacyRandom {
|
||||
&mut self.0.world.random
|
||||
}
|
||||
fn min_y(&mut self, level: Dim) -> Result<i32, Infallible> {
|
||||
Ok(level.min_y())
|
||||
}
|
||||
fn logical_height(&mut self, level: Dim) -> Result<i32, Infallible> {
|
||||
Ok(level.logical_height())
|
||||
}
|
||||
fn is_passenger(&mut self) -> Result<bool, Infallible> {
|
||||
Ok(self.0.riding.is_some())
|
||||
}
|
||||
fn stop_riding(&mut self) -> Result<(), Infallible> {
|
||||
self.0.stop_riding();
|
||||
Ok(())
|
||||
}
|
||||
fn position(&mut self) -> Result<Vec3, Infallible> {
|
||||
Ok(self.at())
|
||||
}
|
||||
fn random_teleport(&mut self, target: Vec3) -> Result<bool, Infallible> {
|
||||
Ok(self
|
||||
.0
|
||||
.random_teleport(target, "minecraft:consumable_does_not_teleport_to"))
|
||||
}
|
||||
fn teleport_event(&mut self, _: Dim, old: Vec3) -> Result<(), Infallible> {
|
||||
let ctx = self.0.who_context(Some(Who::Player), None);
|
||||
self.0
|
||||
.post_game_event("minecraft:teleport", [old.x, old.y, old.z], ctx);
|
||||
Ok(())
|
||||
}
|
||||
fn is_fox(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn teleport_sound(&mut self, _: Dim, at: Vec3, _: bool) -> Result<(), Infallible> {
|
||||
self.0.audio.play(
|
||||
"minecraft:item.chorus_fruit.teleport",
|
||||
Some([at.x, at.y, at.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn block_position(&mut self) -> Result<BlockPos, Infallible> {
|
||||
Ok(BlockPos::containing(self.at()))
|
||||
}
|
||||
fn directional_particles(
|
||||
&mut self,
|
||||
_: Dim,
|
||||
origin: BlockPos,
|
||||
offset: i32,
|
||||
) -> Result<(), Infallible> {
|
||||
self.0
|
||||
.level_event_at(2017, [origin.x, origin.y, origin.z], offset);
|
||||
Ok(())
|
||||
}
|
||||
fn reset_fall_distance(&mut self) -> Result<(), Infallible> {
|
||||
if let Some(p) = self.0.player.as_mut() {
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn reset_impulse(&mut self) -> Result<(), Infallible> {
|
||||
if let Some(p) = self.0.player.as_mut() {
|
||||
p.impulse.reset();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -684,7 +684,7 @@ impl Game<'_> {
|
||||
self.ticks = self.ticks.max(1);
|
||||
}
|
||||
// MinecraftServer.tickServer: while paused only the connections run.
|
||||
self.tick_server(paused)?;
|
||||
self.tick_server_timed(paused)?;
|
||||
if idle_timeout > 0 {
|
||||
self.kick_idle(idle_timeout);
|
||||
}
|
||||
|
||||
@@ -317,6 +317,7 @@ impl Game<'_> {
|
||||
// The owner is an operator when the world allows commands
|
||||
// (`IntegratedPlayerList`, `MinecraftServer.getProfilePermissions`).
|
||||
host.hub.ops.insert(owner, if commands { 4 } else { 0 });
|
||||
host.hub.singleplayer_owner = Some(owner);
|
||||
host.owner = Some(Owner {
|
||||
uuid: owner,
|
||||
saved: Some(saved),
|
||||
@@ -336,11 +337,11 @@ impl Game<'_> {
|
||||
self.run_tasks();
|
||||
if flags.paused.load(Ordering::Relaxed) {
|
||||
// Only the connections run.
|
||||
self.tick_server(true)?;
|
||||
self.tick_server_timed(true)?;
|
||||
} else {
|
||||
self.follow_distances(&flags);
|
||||
let started = Instant::now();
|
||||
self.tick_server(false)?;
|
||||
self.tick_server_timed(false)?;
|
||||
self.debug_charts.log_tick(started, started.elapsed());
|
||||
flags.smoothed_tick_ms.store(
|
||||
self.debug_charts.smoothed_tick_ms.to_bits(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Level events whose client handler (`LevelEventHandler`) spawns particles
|
||||
//! on a block: dragon egg teleportation (2015), electric sparks (3002),
|
||||
//! waxing (3003), wax off (3004) and scraping (3005).
|
||||
//! on a block: dragon egg teleportation (2015), a consumable's teleport
|
||||
//! (2017), electric sparks (3002), waxing (3003), wax off (3004) and
|
||||
//! scraping (3005).
|
||||
use super::Game;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
use horizon_core::position::BlockPos;
|
||||
@@ -9,7 +10,12 @@ use horizon_world::level::LevelEvent;
|
||||
impl Game<'_> {
|
||||
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, self.settings.particles);
|
||||
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
|
||||
}
|
||||
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
|
||||
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [127, 127, 127], self.settings.particles);
|
||||
}
|
||||
/// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client
|
||||
/// level's random.
|
||||
|
||||
@@ -23,7 +23,7 @@ use horizon_client::{
|
||||
save::SaveDir, streaming::Streaming, tick_chunk_membership::TickChunkMembership, world::World,
|
||||
};
|
||||
use horizon_world::level::{LevelEvent, Ticks};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{collections::HashMap, sync::Arc, time::Instant};
|
||||
|
||||
/// Server ticks a level other than the Overworld stays loaded with no player
|
||||
/// in it, before it is saved and closed (vanilla unloads a level's chunks
|
||||
@@ -287,6 +287,37 @@ impl Game<'_> {
|
||||
self.close_idle_levels()
|
||||
}
|
||||
|
||||
/// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a
|
||||
/// tick longer than MS is logged with its thread CPU time and spans.
|
||||
pub(super) fn tick_server_timed(&mut self, paused: bool) -> Result<()> {
|
||||
let Some(threshold) = self.options.spikes else {
|
||||
return self.tick_server(paused);
|
||||
};
|
||||
let (started, cpu) = (Instant::now(), horizon_client::spans::thread_cpu());
|
||||
let result = self.tick_server(paused);
|
||||
let wall = started.elapsed().as_secs_f32() * 1000.0;
|
||||
// Taken every tick: nothing else takes this thread's spans.
|
||||
let spans = horizon_client::spans::take();
|
||||
if wall > threshold {
|
||||
let cpu = horizon_client::spans::thread_cpu()
|
||||
.zip(cpu)
|
||||
.map_or_else(String::new, |(end, start)| {
|
||||
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
|
||||
});
|
||||
let list: Vec<String> = spans
|
||||
.iter()
|
||||
.filter(|(_, ms)| *ms >= 0.5)
|
||||
.map(|(n, ms)| format!("{n} {ms:.1}"))
|
||||
.collect();
|
||||
log::info!(
|
||||
"Horizon: slow tick {wall:.1} ms{cpu} ({} mobs): {}",
|
||||
self.mobs.len(),
|
||||
list.join(", ")
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The levels this server tick runs, in `MinecraftServer.levels` order
|
||||
/// (the Overworld, the Nether, the End): every level with a player in
|
||||
/// it, or the Overworld while nobody is online.
|
||||
|
||||
@@ -198,10 +198,14 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
fn spawn_mobs(&self) -> bool {
|
||||
self.game.ui.rules.bool("spawn_mobs")
|
||||
}
|
||||
/// Scanned down the column: sections of air alone are skipped (air counts
|
||||
/// for no heightmap), and an unloaded column reads as air.
|
||||
/// WORLD_SURFACE (every spawn attempt's) is the chunk's kept heightmap.
|
||||
/// The others are scanned down the column: sections of air alone are
|
||||
/// skipped (air counts for no heightmap). An unloaded column reads as air.
|
||||
fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 {
|
||||
let world = self.w.world;
|
||||
if kind == Heightmap::WorldSurface {
|
||||
return self.w.world_surface(x, z);
|
||||
}
|
||||
world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
|
||||
@@ -1336,6 +1336,9 @@ impl Game<'_> {
|
||||
C::LevelEvent(p) if p.kind == 2015 => {
|
||||
self.dragon_egg_particles(p.pos, p.data);
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2017 => {
|
||||
self.consume_teleport_particles(p.pos, p.data);
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2001 => {
|
||||
// LevelEvent.PARTICLES_DESTROY_BLOCK: someone else broke a block.
|
||||
let state = BlockStateId(p.data.max(0) as u32);
|
||||
@@ -2426,6 +2429,17 @@ impl Game<'_> {
|
||||
(player.position.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !self.streaming.is_lit(cx, cz) {
|
||||
// Moved where nothing is loaded by horizon-ctl (`tp`, `fly`):
|
||||
// the server hears of it, and sends the chunks there. A player
|
||||
// who walked there has already sent that move.
|
||||
let pos = [player.position.x, player.position.y, player.position.z];
|
||||
if let Some(r) = self.remote.as_ref()
|
||||
&& r.loaded_sent
|
||||
&& r.last_pos != pos
|
||||
{
|
||||
let input = r.last_input;
|
||||
self.remote_send_state(input);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
|
||||
@@ -3086,7 +3100,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` for food on a server client: the
|
||||
/// `LivingEntity.updatingUsingItem` for a consumable on a server client: the
|
||||
/// eating animation and its crumbs. The server eats it and sends the
|
||||
/// stack, the hunger and the sounds.
|
||||
fn remote_tick_using(&mut self) {
|
||||
@@ -3094,11 +3108,11 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let stack = self.stack_in(slot).cloned();
|
||||
let food = stack
|
||||
let consumable = stack
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.is_some_and(|i| i.consumable && i.food.is_some());
|
||||
let (Some(stack), true) = (stack, food) else {
|
||||
.and_then(|i| i.consumable.clone());
|
||||
let (Some(stack), Some(consumable)) = (stack, consumable) else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
@@ -3107,14 +3121,16 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item.
|
||||
let duration = 32;
|
||||
let duration = consumable.ticks().max(0) as u32;
|
||||
let remaining = duration - ticks.min(duration);
|
||||
if duration - remaining > (duration as f32 * 0.21875) as u32 && remaining % 4 == 0 {
|
||||
if consumable.particles && consumable.should_emit(remaining as i32) {
|
||||
self.eat_particles(&stack, 5);
|
||||
}
|
||||
if ticks + 1 >= duration {
|
||||
self.using = None;
|
||||
self.eat_particles(&stack, 16);
|
||||
if consumable.particles {
|
||||
self.eat_particles(&stack, 16);
|
||||
}
|
||||
} else {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
}
|
||||
@@ -3718,6 +3734,9 @@ impl Game<'_> {
|
||||
"address": horizon_client::net::shown_address(&r.session.address),
|
||||
"name": r.session.name,
|
||||
"joined": r.joined,
|
||||
// ServerboundPlayerLoadedPacket sent: the level around the
|
||||
// player is ready, and the client ticks and sends its moves.
|
||||
"loaded": r.loaded_sent,
|
||||
"status": r.status,
|
||||
"entity_id": r.entity_id,
|
||||
"dimension": r.dimension.name,
|
||||
|
||||
@@ -54,7 +54,8 @@ pub enum ConsumeEffect {
|
||||
},
|
||||
ClearAllEffects,
|
||||
RemoveEffects(Vec<String>),
|
||||
TeleportRandomly,
|
||||
/// `TeleportRandomlyConsumeEffect`.
|
||||
TeleportRandomly(horizon_world::teleport_consume::TeleportConsume),
|
||||
PlaySound(String),
|
||||
}
|
||||
|
||||
@@ -72,8 +73,10 @@ pub struct ItemInfo {
|
||||
pub tool_damage_per_block: Option<u32>,
|
||||
pub food: Option<(u32, f32)>,
|
||||
pub can_always_eat: bool,
|
||||
pub consumable: bool,
|
||||
pub consume_effects: Vec<ConsumeEffect>,
|
||||
/// `consumable`: its timing, sound, particles and `on_consume_effects`.
|
||||
pub consumable: Option<horizon_world::consumable::Consumable<String, ConsumeEffect>>,
|
||||
/// `use_remainder`: what a finished use leaves (`UseRemainder`).
|
||||
pub use_remainder: Option<String>,
|
||||
pub model: String,
|
||||
/// Main-hand `attack_damage` / `attack_speed` modifier amounts.
|
||||
pub attack_damage: f32,
|
||||
@@ -507,7 +510,18 @@ impl Items {
|
||||
consume_effects.push(ConsumeEffect::RemoveEffects(targets));
|
||||
}
|
||||
Some("minecraft:teleport_randomly") => {
|
||||
consume_effects.push(ConsumeEffect::TeleportRandomly);
|
||||
consume_effects.push(ConsumeEffect::TeleportRandomly(
|
||||
horizon_world::teleport_consume::TeleportConsume {
|
||||
diameter: effect
|
||||
.get("diameter")
|
||||
.and_then(Value::as_f64)
|
||||
.map_or(16.0, |d| d as f32),
|
||||
directional_particles: effect
|
||||
.get("directional_particles")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
},
|
||||
));
|
||||
}
|
||||
Some("minecraft:play_sound") => {
|
||||
if let Some(sound) = effect.get("sound").and_then(|s| s.as_str()) {
|
||||
@@ -536,8 +550,22 @@ impl Items {
|
||||
.map(|t| t["damage_per_block"].as_u64().unwrap_or(1) as u32),
|
||||
food,
|
||||
can_always_eat,
|
||||
consumable: c.get("minecraft:consumable").is_some(),
|
||||
consume_effects,
|
||||
// Consumable.CODEC's defaults.
|
||||
consumable: c.get("minecraft:consumable").map(|cons| {
|
||||
horizon_world::consumable::Consumable {
|
||||
seconds: cons["consume_seconds"].as_f64().map_or(1.6, |s| s as f32),
|
||||
animation: crate::first_person::consumable_animation(cons),
|
||||
sound: cons["sound"]
|
||||
.as_str()
|
||||
.unwrap_or("minecraft:entity.generic.eat")
|
||||
.to_owned(),
|
||||
particles: cons["has_consume_particles"].as_bool().unwrap_or(true),
|
||||
effects: std::mem::take(&mut consume_effects),
|
||||
}
|
||||
}),
|
||||
use_remainder: c["minecraft:use_remainder"]["id"]
|
||||
.as_str()
|
||||
.map(str::to_owned),
|
||||
model: c["minecraft:item_model"]
|
||||
.as_str()
|
||||
.unwrap_or(name)
|
||||
@@ -1617,18 +1645,63 @@ mod tests {
|
||||
let apple = items.get("minecraft:golden_apple").expect("golden apple");
|
||||
assert!(apple.can_always_eat);
|
||||
assert!(apple.food.is_some());
|
||||
assert!(!apple.consume_effects.is_empty());
|
||||
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
|
||||
|
||||
let enchanted = items
|
||||
.get("minecraft:enchanted_golden_apple")
|
||||
.expect("enchanted golden apple");
|
||||
assert!(enchanted.can_always_eat);
|
||||
assert!(enchanted.food.is_some());
|
||||
assert!(!enchanted.consume_effects.is_empty());
|
||||
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
|
||||
|
||||
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
|
||||
assert!(!beef.can_always_eat);
|
||||
assert!(beef.food.is_some());
|
||||
|
||||
// Consumables without food, their style and their remainders.
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
|
||||
let drink = milk.consumable.as_ref().unwrap();
|
||||
assert!(milk.food.is_none());
|
||||
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
|
||||
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
|
||||
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
|
||||
assert!(!drink.particles);
|
||||
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
|
||||
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
|
||||
let sip = honey.consumable.as_ref().unwrap();
|
||||
assert_eq!(sip.ticks(), 40);
|
||||
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
|
||||
assert_eq!(
|
||||
sip.effects,
|
||||
[ConsumeEffect::RemoveEffects(vec![
|
||||
"minecraft:poison".into()
|
||||
])]
|
||||
);
|
||||
assert_eq!(
|
||||
honey.use_remainder.as_deref(),
|
||||
Some("minecraft:glass_bottle")
|
||||
);
|
||||
let kelp = items.get("minecraft:dried_kelp").unwrap();
|
||||
let bite = kelp.consumable.as_ref().unwrap();
|
||||
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
|
||||
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
|
||||
assert!(bite.particles && kelp.use_remainder.is_none());
|
||||
let chorus = items.get("minecraft:chorus_fruit").unwrap();
|
||||
assert_eq!(
|
||||
chorus.consumable.as_ref().unwrap().effects,
|
||||
[ConsumeEffect::TeleportRandomly(
|
||||
horizon_world::teleport_consume::TeleportConsume {
|
||||
diameter: 16.0,
|
||||
directional_particles: true,
|
||||
}
|
||||
)]
|
||||
);
|
||||
let chicken = items.get("minecraft:chicken").unwrap();
|
||||
assert!(matches!(
|
||||
chicken.consumable.as_ref().unwrap().effects[..],
|
||||
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,9 @@ pub struct Hub {
|
||||
/// The `player_movement_check` and `elytra_movement_check` game rules,
|
||||
/// set by the game before each tick.
|
||||
pub movement_checks: (bool, bool),
|
||||
/// An integrated server's owner (`MinecraftServer.isSingleplayerOwner`),
|
||||
/// whose moves are never checked.
|
||||
pub singleplayer_owner: Option<u128>,
|
||||
/// Who rides what, set by the game before each tick.
|
||||
pub riding: super::entity::Riding,
|
||||
last_clock: i64,
|
||||
@@ -265,6 +268,7 @@ impl Hub {
|
||||
sync,
|
||||
ticks: 0,
|
||||
movement_checks: (true, true),
|
||||
singleplayer_owner: None,
|
||||
riding: Default::default(),
|
||||
last_clock: i64::MIN,
|
||||
last_rate: 0.0,
|
||||
@@ -1893,6 +1897,7 @@ impl Hub {
|
||||
) {
|
||||
let (frozen, (check, elytra_check)) = (self.ticking.1, self.movement_checks);
|
||||
let g = &mut self.guests[i];
|
||||
let owner = self.singleplayer_owner == Some(g.uuid);
|
||||
let to = [
|
||||
to[0].clamp(-3.0E7, 3.0E7),
|
||||
to[1].clamp(-2.0E7, 2.0E7),
|
||||
@@ -1916,9 +1921,10 @@ impl Hub {
|
||||
if packets > 5 {
|
||||
packets = 1;
|
||||
}
|
||||
// shouldCheckPlayerMovement. The server holds no velocity for a
|
||||
// guest (getDeltaMovement), so nothing is expected.
|
||||
if check && (!g.fall_flying || elytra_check) {
|
||||
// shouldCheckPlayerMovement (never the single-player owner).
|
||||
// The server holds no velocity for a guest (getDeltaMovement),
|
||||
// so nothing is expected.
|
||||
if !owner && check && (!g.fall_flying || elytra_check) {
|
||||
let limit = if g.fall_flying { 300.0 } else { 100.0 };
|
||||
if moved > limit * f64::from(packets) {
|
||||
events.push(HostEvent::Warn(format!(
|
||||
|
||||
@@ -589,11 +589,7 @@ impl SpawnLevel for super::MobWorld<'_> {
|
||||
self.sea_level
|
||||
}
|
||||
fn world_surface(&self, x: i32, z: i32) -> i32 {
|
||||
let top = crate::chunk::MIN_Y + crate::chunk::HEIGHT;
|
||||
(crate::chunk::MIN_Y..top)
|
||||
.rev()
|
||||
.find(|&y| !self.facts.is_air(self.world.block(x, y, z)))
|
||||
.map_or(crate::chunk::MIN_Y, |y| y + 1)
|
||||
self.world.world_surface(x, z, |id| self.facts.is_air(id))
|
||||
}
|
||||
fn peaceful(&self) -> bool {
|
||||
self.difficulty.effective == 0.0
|
||||
|
||||
@@ -649,7 +649,8 @@ impl MotionWorld<'_, '_, '_> {
|
||||
entities: self.entities,
|
||||
}
|
||||
}
|
||||
pub(crate) fn no_player_collision(&mut self, bounds: Aabb) -> Result<bool> {
|
||||
/// `Level.noCollision(player, bounds)`: blocks and the host's entity boxes.
|
||||
pub fn no_player_collision(&mut self, bounds: Aabb) -> Result<bool> {
|
||||
let context = self.context();
|
||||
Ok(world_movement::no_world_collision(
|
||||
&mut self.movement_collisions(),
|
||||
|
||||
@@ -1264,12 +1264,21 @@ impl ParticleEngine {
|
||||
}
|
||||
self.create(options, p, v);
|
||||
}
|
||||
/// `LevelEventHandler.singleBlockTeleportationParticles` (event 2015).
|
||||
pub fn block_teleportation(&mut self, from: [i32; 3], data: i32, particle_level: u8) {
|
||||
/// `LevelEventHandler.singleBlockTeleportationParticles` towards
|
||||
/// `BlockUtil.unpackDifferenceInPosition(from, data, radius)`: the dragon
|
||||
/// egg's event 2015 packs offsets within (16, 8, 16), a consumable's
|
||||
/// teleport (2017) within (127, 127, 127).
|
||||
pub fn block_teleportation(
|
||||
&mut self,
|
||||
from: [i32; 3],
|
||||
data: i32,
|
||||
radius: [i32; 3],
|
||||
particle_level: u8,
|
||||
) {
|
||||
let to = [
|
||||
from[0].wrapping_add(((data >> 16) & 255) - 16),
|
||||
from[1].wrapping_add(((data >> 8) & 255) - 8),
|
||||
from[2].wrapping_add((data & 255) - 16),
|
||||
from[0].wrapping_add(((data >> 16) & 255) - radius[0]),
|
||||
from[1].wrapping_add(((data >> 8) & 255) - radius[1]),
|
||||
from[2].wrapping_add((data & 255) - radius[2]),
|
||||
]
|
||||
.map(f64::from);
|
||||
let from = from.map(f64::from);
|
||||
@@ -3798,7 +3807,7 @@ mod tests {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
|
||||
engine.seed_randoms(0, 0, 0);
|
||||
// Destination offset (-15, 7, 15); the unused top byte is ignored.
|
||||
engine.block_teleportation([4, 12, -5], 0xff01_0f1fu32 as i32, 0);
|
||||
engine.block_teleportation([4, 12, -5], 0xff01_0f1fu32 as i32, [16, 8, 16], 0);
|
||||
assert_eq!(engine.pending.len(), 128);
|
||||
assert!(engine.pending.iter().all(|p| p.ty == ParticleType::Portal));
|
||||
let first = &engine.pending[0];
|
||||
@@ -3818,6 +3827,25 @@ mod tests {
|
||||
assert!(engine.sounds.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consume_teleport_unpacks_offsets_within_127() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
|
||||
engine.seed_randoms(0, 0, 0);
|
||||
// TeleportRandomlyConsumeEffect: BlockUtil.clampedPackDifferenceInPosition
|
||||
// of the move (-7, -3, 9) with radii 127, from (4, 12, -5) to (-3, 9, 4).
|
||||
let data = (-7 + 127) << 16 | (-3 + 127) << 8 | (9 + 127);
|
||||
engine.block_teleportation([4, 12, -5], data, [127, 127, 127], 0);
|
||||
assert_eq!(engine.pending.len(), 128);
|
||||
for p in &engine.pending {
|
||||
let [x, y, z] = p.position;
|
||||
assert!((-3.0..5.0).contains(&x), "{x}");
|
||||
assert!((8.5..12.5).contains(&y), "{y}");
|
||||
assert!((-5.0..5.0).contains(&z), "{z}");
|
||||
}
|
||||
// The same draws as the egg's: only the unpacking differs.
|
||||
assert_eq!(engine.level_random.state(), 99_413_408_083_437);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egg_teleport_options_roll_before_distance_rejection() {
|
||||
for (option, count, state) in [
|
||||
@@ -3831,7 +3859,7 @@ mod tests {
|
||||
if distant {
|
||||
engine.camera = [1000.0; 3];
|
||||
}
|
||||
engine.block_teleportation([4, 12, -5], 0x010f1f, option);
|
||||
engine.block_teleportation([4, 12, -5], 0x010f1f, [16, 8, 16], option);
|
||||
assert_eq!(engine.pending.len(), if distant { 0 } else { count });
|
||||
assert_eq!(engine.level_random.state(), state);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Named timings within a frame, for finding what makes a frame slow:
|
||||
//! `HORIZON_SPIKES=MS` prints the spans of every frame whose CPU time
|
||||
//! exceeds MS milliseconds. Costs one clock read per span when enabled and
|
||||
//! a relaxed load otherwise.
|
||||
//! exceeds MS milliseconds, and of every server tick (dedicated or
|
||||
//! integrated) longer than MS. Costs one clock read per span when enabled
|
||||
//! and a relaxed load otherwise.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::Instant,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
@@ -28,6 +29,18 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
SPANS.with(|s| s.borrow_mut().push((name, ms)));
|
||||
out
|
||||
}
|
||||
/// The CPU time this thread has used, where the platform tells it: a tick
|
||||
/// with more wall time than CPU time waited (for a lock, or for a core on a
|
||||
/// busy machine).
|
||||
pub fn thread_cpu() -> Option<Duration> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let t = rustix::time::clock_gettime(rustix::time::ClockId::ThreadCPUTime);
|
||||
Some(Duration::new(t.tv_sec as u64, t.tv_nsec as u32))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
None
|
||||
}
|
||||
/// The frame's spans, summed by name, slowest first; clears them.
|
||||
pub fn take() -> Vec<(&'static str, f32)> {
|
||||
let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()));
|
||||
@@ -41,3 +54,29 @@ pub fn take() -> Vec<(&'static str, f32)> {
|
||||
sums.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
sums
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The thread's CPU clock moves with work done on it, not with time
|
||||
/// spent asleep.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn thread_cpu_counts_work_not_sleep() {
|
||||
let start = super::thread_cpu().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
let slept = super::thread_cpu().unwrap() - start;
|
||||
// Busy until the clock shows 10 ms of work (a loaded machine gives
|
||||
// the thread less than its wall time).
|
||||
let mut x = 0u64;
|
||||
let busy = std::time::Instant::now();
|
||||
let worked = loop {
|
||||
x = std::hint::black_box(x.wrapping_mul(31).wrapping_add(7));
|
||||
let worked = super::thread_cpu().unwrap() - start - slept;
|
||||
if worked.as_millis() >= 10 || busy.elapsed().as_secs() >= 10 {
|
||||
break worked;
|
||||
}
|
||||
};
|
||||
assert!(slept.as_millis() < 25, "{slept:?} while asleep");
|
||||
assert!(worked.as_millis() >= 10, "{worked:?} while busy");
|
||||
}
|
||||
}
|
||||
@@ -287,6 +287,13 @@ impl World {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)`: one above the highest
|
||||
/// block that isn't `air`, or `MIN_Y` (also where the chunk isn't loaded).
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(BlockStateId) -> bool) -> i32 {
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
|
||||
chunk.world_surface(x, z, |v| air(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
self.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
|
||||
@@ -17,7 +17,7 @@ use std::collections::BTreeMap;
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, OnceLock,
|
||||
Arc, Condvar, Mutex, OnceLock, RwLock,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
@@ -30,7 +30,7 @@ pub(super) struct StructureState {
|
||||
possible_sets: Vec<usize>,
|
||||
/// Structure indices of each decoration step, in registry order.
|
||||
pub(super) by_step: Vec<Vec<usize>>,
|
||||
starts: Mutex<HashMap<(i32, i32), Starts>>,
|
||||
starts: StartCache,
|
||||
/// Concentric ring positions, computed on first use and shared with
|
||||
/// later generators of the same seed and dimension.
|
||||
rings: Arc<OnceLock<Rings>>,
|
||||
@@ -108,6 +108,45 @@ impl RingWork {
|
||||
}
|
||||
}
|
||||
const START_CACHE_LIMIT: usize = 65536;
|
||||
const START_SHARDS: usize = 64;
|
||||
|
||||
/// The built starts by chunk, in shards: a lookup locks one shard for
|
||||
/// reading. Each generated chunk reads the 289 start chunks around it
|
||||
/// (`references`) and the server thread reads the starts of the chunks its
|
||||
/// mobs and players are in; with one lock for all, the server thread waited
|
||||
/// on the workers.
|
||||
struct StartCache {
|
||||
shards: Box<[RwLock<HashMap<(i32, i32), Starts>>]>,
|
||||
}
|
||||
impl StartCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
shards: (0..START_SHARDS).map(|_| RwLock::default()).collect(),
|
||||
}
|
||||
}
|
||||
fn shard(&self, key: (i32, i32)) -> &RwLock<HashMap<(i32, i32), Starts>> {
|
||||
let hash =
|
||||
(key.0 as u32).wrapping_mul(0x9E37_79B9) ^ (key.1 as u32).wrapping_mul(0x85EB_CA6B);
|
||||
&self.shards[(hash >> (32 - START_SHARDS.trailing_zeros())) as usize]
|
||||
}
|
||||
fn get(&self, key: (i32, i32)) -> Option<Starts> {
|
||||
self.shard(key).read().unwrap().get(&key).cloned()
|
||||
}
|
||||
/// Keeps `starts` unless another thread built the same chunk's first:
|
||||
/// everyone keeps the first, so placement state on its pieces is shared.
|
||||
fn insert_first(&self, key: (i32, i32), starts: Starts) -> Starts {
|
||||
let mut shard = self.shard(key).write().unwrap();
|
||||
if shard.len() >= START_CACHE_LIMIT / START_SHARDS {
|
||||
shard.clear();
|
||||
}
|
||||
shard.entry(key).or_insert(starts).clone()
|
||||
}
|
||||
fn clear(&self) {
|
||||
for shard in &self.shards {
|
||||
shard.write().unwrap().clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn biome_set(data: &GeneratorData, value: &Value) -> Result<BTreeSet<BiomeId>, super::DecodeError> {
|
||||
let names: Vec<&str> = match value {
|
||||
@@ -167,7 +206,7 @@ impl StructureState {
|
||||
bindings,
|
||||
possible_sets,
|
||||
by_step,
|
||||
starts: Mutex::new(HashMap::new()),
|
||||
starts: StartCache::new(),
|
||||
rings,
|
||||
ring_work: OnceLock::new(),
|
||||
}))
|
||||
@@ -199,7 +238,7 @@ impl StructureState {
|
||||
/// Drop the cached starts, so that later chunks build fresh pieces (the
|
||||
/// pieces keep per-start placement state such as a shipwreck's height).
|
||||
pub(super) fn forget_starts(&self) {
|
||||
self.starts.lock().unwrap().clear();
|
||||
self.starts.clear();
|
||||
}
|
||||
/// The (cached) structure starts owned by a chunk.
|
||||
pub(super) fn starts_at(
|
||||
@@ -207,8 +246,8 @@ impl StructureState {
|
||||
context: &mut impl super::structure::start::StartContext,
|
||||
chunk: ChunkPos,
|
||||
) -> Starts {
|
||||
if let Some(found) = self.starts.lock().unwrap().get(&(chunk.x, chunk.z)) {
|
||||
return found.clone();
|
||||
if let Some(found) = self.starts.get((chunk.x, chunk.z)) {
|
||||
return found;
|
||||
}
|
||||
let rings = self.rings(context);
|
||||
let starts: Starts = Arc::new(
|
||||
@@ -225,13 +264,7 @@ impl StructureState {
|
||||
.map(Arc::new)
|
||||
.collect(),
|
||||
);
|
||||
let mut cache = self.starts.lock().unwrap();
|
||||
if cache.len() >= START_CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
// Another thread may have built the same starts meanwhile: everyone
|
||||
// keeps the first, so placement state on its pieces is shared.
|
||||
cache.entry((chunk.x, chunk.z)).or_insert(starts).clone()
|
||||
self.starts.insert_first((chunk.x, chunk.z), starts)
|
||||
}
|
||||
/// ChunkGenerator.findNearestMapStructure: the nearest start of one of
|
||||
/// `names` from block `from` (rings: every ring chunk; random spreads:
|
||||
@@ -385,6 +418,39 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::worldgen::structure::stronghold::{ring_positions, ring_searches};
|
||||
|
||||
/// The start cache keeps the first starts built for a chunk, spreads a
|
||||
/// chunk's 289 reference lookups over the shards, and forgets on clear.
|
||||
#[test]
|
||||
fn start_cache_keeps_first_and_spreads_neighbours() {
|
||||
let cache = StartCache::new();
|
||||
let first: Starts = Arc::new(Vec::new());
|
||||
let second: Starts = Arc::new(Vec::new());
|
||||
assert!(cache.get((3, -4)).is_none());
|
||||
assert!(Arc::ptr_eq(
|
||||
&cache.insert_first((3, -4), first.clone()),
|
||||
&first
|
||||
));
|
||||
assert!(Arc::ptr_eq(&cache.insert_first((3, -4), second), &first));
|
||||
assert!(Arc::ptr_eq(&cache.get((3, -4)).unwrap(), &first));
|
||||
for (cx, cz) in [(0, 0), (-1000, 77), (i32::MAX - 8, i32::MIN + 8)] {
|
||||
let mut used = vec![0; START_SHARDS];
|
||||
for x in cx - 8..=cx + 8 {
|
||||
for z in cz - 8..=cz + 8 {
|
||||
let shard = cache.shard((x, z)) as *const _;
|
||||
let i = cache
|
||||
.shards
|
||||
.iter()
|
||||
.position(|s| std::ptr::eq(s, shard))
|
||||
.unwrap();
|
||||
used[i] += 1;
|
||||
}
|
||||
}
|
||||
assert!(used.iter().all(|&n| n > 0 && n < 20), "{used:?}");
|
||||
}
|
||||
cache.clear();
|
||||
assert!(cache.get((3, -4)).is_none());
|
||||
}
|
||||
|
||||
/// Searches shared between threads give the positions of one thread
|
||||
/// running them in order, set by set.
|
||||
#[test]
|
||||
|
||||
@@ -7,7 +7,8 @@ runs through the CLI (one horizon-ctl process per command) and through `horizon-
|
||||
small JSON-RPC client, and saves a screenshot of each run. The ui scenario drives the menus by
|
||||
widget id instead: the title screen, the options (a slider set by id) and the inventory's slots;
|
||||
the options scenario reads and sets options.txt values by key; the events scenario waits for
|
||||
states (wait-for) and reads the event log.
|
||||
states (wait-for) and reads the event log; the consume scenario eats and drinks consumables (a
|
||||
golden apple, milk, honey, chorus fruit) and checks their effects, remainders and teleport.
|
||||
|
||||
python3 tools/agent_control_e2e.py # both modes
|
||||
python3 tools/agent_control_e2e.py --mode mcp # one of them
|
||||
@@ -378,7 +379,52 @@ def events_scenario(d, out):
|
||||
return {'events': len(events)}
|
||||
|
||||
|
||||
SCENARIOS = {'survival': scenario, 'ui': ui_scenario, 'options': options_scenario, 'events': events_scenario}
|
||||
def consume_scenario(d, out):
|
||||
# Consumables finish as vanilla's Consumable.onConsume: always-edible food at full hunger with its
|
||||
# effects, milk and honey (no food or drink sounds) with their effects and use remainders, and
|
||||
# chorus fruit's random teleport.
|
||||
d('click', id='singleplayer')
|
||||
if 'game' not in widgets(d):
|
||||
d('click', id='create-new-world')
|
||||
d('type', field='name', replace=True, text='Consume')
|
||||
d('click', id='create-new-world')
|
||||
d('wait_for', condition='loaded', timeout=400)
|
||||
|
||||
def consume(item, seconds):
|
||||
d('command', command='clear @s')
|
||||
d('give', item=item, count=1)
|
||||
d('use_item', hold=seconds)
|
||||
d('wait', ticks=5)
|
||||
state = d('state')
|
||||
return state, {e['id'] for e in state['player']['effects']}
|
||||
|
||||
def held(state):
|
||||
return state['held'] and state['held']['item']
|
||||
|
||||
state, effects = consume('golden_apple', 2.2)
|
||||
assert state['player']['food'] == 20 and {'regeneration', 'absorption'} <= effects, effects
|
||||
assert held(state) is None, state['held']
|
||||
d('command', command='effect give @s minecraft:speed 60')
|
||||
state, effects = consume('milk_bucket', 2.2)
|
||||
assert effects == set(), effects
|
||||
assert held(state) == 'minecraft:bucket', state['held']
|
||||
d('command', command='effect give @s minecraft:poison 60')
|
||||
d('command', command='effect give @s minecraft:speed 60')
|
||||
state, effects = consume('honey_bottle', 2.6)
|
||||
assert effects == {'speed'}, effects
|
||||
assert held(state) == 'minecraft:glass_bottle', state['held']
|
||||
before = d('state')['player']['position']
|
||||
state, _ = consume('chorus_fruit', 2.2)
|
||||
after = state['player']['position']
|
||||
moved = [b - a for a, b in zip(before, after)]
|
||||
assert held(state) is None, state['held']
|
||||
assert moved != [0, 0, 0] and abs(moved[0]) <= 8 and abs(moved[2]) <= 8, moved
|
||||
d('screenshot', path=str(out / f'consume-{d.name}.png'))
|
||||
return {'teleported': [round(m, 2) for m in moved]}
|
||||
|
||||
|
||||
SCENARIOS = {'survival': scenario, 'ui': ui_scenario, 'options': options_scenario, 'events': events_scenario,
|
||||
'consume': consume_scenario}
|
||||
|
||||
|
||||
def run(mode, ctl, out, resources, which='survival'):
|
||||
|
||||
Reference in new issue
Block a user