mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 02:29:50 -10:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b691ea10b | ||
|
|
64353830de | ||
|
|
2dd69539cb | ||
|
|
f9673db9a5 | ||
|
|
0c60c83bcd | ||
|
|
7659d540f4 | ||
|
|
0492680f66 | ||
|
|
8c438cc3c7 | ||
|
|
c15ab62594 | ||
|
|
cfcf8489a6 | ||
|
|
2183fc4a18 | ||
|
|
77cee50f49 | ||
|
|
5b8dc6d632 | ||
|
|
dc4e681d37 | ||
|
|
28b8b6e232 | ||
|
|
6b4e3f59f8 | ||
|
|
3fa7bcdd69 | ||
|
|
599ed14267 | ||
|
|
3303f4f1cc | ||
|
|
bb7652f721 | ||
|
|
f9e704effb | ||
|
|
ac0e2cb6be | ||
|
|
54034799d2 | ||
|
|
8a1e413e1f | ||
|
|
ce5571cac2 | ||
|
|
55ec392c64 | ||
|
|
edef540e08 | ||
|
|
6187e0745d | ||
|
|
8f616098e8 | ||
|
|
fb05f85337 | ||
|
|
1e3b7a1ce8 | ||
|
|
2f2c59c53b | ||
|
|
934d6a185f | ||
|
|
8b3e3b9177 | ||
|
|
4f0c8a4b37 | ||
|
|
4c96fd0ca9 | ||
|
|
0f901e30a1 | ||
|
|
0fa1ee3a3c | ||
|
|
2b42fac0cc | ||
|
|
f9964c0cdf | ||
|
|
0cf703b72b | ||
|
|
65f5c93f41 | ||
|
|
f36db58664 | ||
|
|
61876b21de | ||
|
|
a7c2266c95 | ||
|
|
36c40227dc | ||
|
|
cfc164308f | ||
|
|
2499cd929a | ||
|
|
4b11e24b12 | ||
|
|
a57a829572 | ||
|
|
f3dc477663 | ||
|
|
706b936a77 | ||
|
|
3908880998 | ||
|
|
baa73603e9 | ||
|
|
f2543c9c55 | ||
|
|
b607e0e0fd | ||
|
|
a6cd0a67a7 | ||
|
|
ff59d29516 | ||
|
|
9ba2f28221 | ||
|
|
e5817a0217 | ||
|
|
1a451f670f | ||
|
|
d56cbb35f2 | ||
|
|
a8e0fab5aa | ||
|
|
8331f8cad8 | ||
|
|
48d52eba1b | ||
|
|
273883af4c | ||
|
|
6d05e015d2 | ||
|
|
5119526d69 | ||
|
|
d78b319c35 | ||
|
|
c59ebc38c4 |
No files matched your search
@@ -1,7 +1,10 @@
|
||||
//! The `MobEffects` and `Potions` registries (code in vanilla, ported as
|
||||
//! tables): each effect's colour and category, each potion's effects, and the
|
||||
//! potion colour mix. horizon-client re-exports them from `brewing`.
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{OnceLock, RwLock},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Category {
|
||||
@@ -18,6 +21,22 @@ pub struct EffectDef {
|
||||
pub category: Category,
|
||||
pub instant: bool,
|
||||
}
|
||||
/// The colours resource packs give effects (Polytone's `effect.<id>`), by the table's id.
|
||||
static PACK_COLORS: RwLock<Option<HashMap<String, u32>>> = RwLock::new(None);
|
||||
/// Takes the packs' effect colours (none: vanilla's).
|
||||
pub fn set_pack_colors(colors: Vec<(String, u32)>) {
|
||||
let colors = (!colors.is_empty()).then(|| colors.into_iter().collect());
|
||||
*PACK_COLORS.write().unwrap_or_else(|e| e.into_inner()) = colors;
|
||||
}
|
||||
impl EffectDef {
|
||||
/// `MobEffect.getColor` as the packs show it: potions, tipped arrows and effect particles.
|
||||
pub fn shown_color(&self) -> u32 {
|
||||
let pack = PACK_COLORS.read().unwrap_or_else(|e| e.into_inner());
|
||||
pack.as_ref()
|
||||
.and_then(|colors| colors.get(self.id))
|
||||
.map_or(self.color, |c| c & 0xFF_FFFF)
|
||||
}
|
||||
}
|
||||
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
|
||||
EffectDef {
|
||||
id,
|
||||
@@ -309,9 +328,10 @@ pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
|
||||
for (e, amp) in effects {
|
||||
let Some(e) = effect(e) else { continue };
|
||||
let w = amp as u32 + 1;
|
||||
r += w * (e.color >> 16 & 255);
|
||||
g += w * (e.color >> 8 & 255);
|
||||
b += w * (e.color & 255);
|
||||
let color = e.shown_color();
|
||||
r += w * (color >> 16 & 255);
|
||||
g += w * (color >> 8 & 255);
|
||||
b += w * (color & 255);
|
||||
total += w;
|
||||
}
|
||||
match (
|
||||
|
||||
@@ -575,14 +575,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Kind {
|
||||
"banner" => {
|
||||
let segment: f32 = prop("rotation").and_then(|r| r.parse().ok()).unwrap_or(0.0);
|
||||
return Kind::Banner {
|
||||
color: crate::pack_dyes::diffuse(dye),
|
||||
color: crate::pack_palette::diffuse(dye),
|
||||
wall: false,
|
||||
angle: segment * 22.5,
|
||||
};
|
||||
}
|
||||
"wall_banner" => {
|
||||
return Kind::Banner {
|
||||
color: crate::pack_dyes::diffuse(dye),
|
||||
color: crate::pack_palette::diffuse(dye),
|
||||
wall: true,
|
||||
angle: y_rot(facing()),
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::{collections::HashSet, path::Path};
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
|
||||
description_id, effect, is_mod_effect, mod_effects, namespaced, potion, potion_color,
|
||||
register_mod_effects,
|
||||
register_mod_effects, set_pack_colors,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
|
||||
@@ -110,10 +110,15 @@ impl FogState {
|
||||
}
|
||||
/// `LocalPlayer.getWaterVision`.
|
||||
pub fn water_vision(&self, eye_in_water: bool) -> f32 {
|
||||
self.water_vision_over(eye_in_water, 600.0)
|
||||
}
|
||||
/// The water vision a pack's `water_fog_brightening_time` gives: vanilla's ramp over that
|
||||
/// many ticks, of the 600 the game counts (more never reaches full).
|
||||
pub fn water_vision_over(&self, eye_in_water: bool, ticks: f32) -> f32 {
|
||||
if !eye_in_water {
|
||||
return 0.0;
|
||||
}
|
||||
let t = self.water_vision_time as f32;
|
||||
let t = self.water_vision_time as f32 * 600.0 / ticks;
|
||||
if t >= 600.0 {
|
||||
return 1.0;
|
||||
}
|
||||
@@ -226,6 +231,8 @@ pub struct FogInputs<'a> {
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
/// The packs' Polytone `environment` fog settings.
|
||||
pub environment: horizon_polytone::colors::Environment,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -241,7 +248,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
// Base colour of the applicable environment.
|
||||
let mut color = match input.fog_type {
|
||||
FogType::Lava => rgb(0x99_1A00),
|
||||
FogType::PowderSnow => rgb(0x9F_BACC),
|
||||
FogType::PowderSnow => rgb(input.environment.powder_snow_fog.unwrap_or(0x9F_BACC)),
|
||||
FogType::Water => input.water_fog_color,
|
||||
FogType::Atmospheric => {
|
||||
let mut fog = sky.fog_color;
|
||||
@@ -299,8 +306,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
color[1] += (color[1] * 0.6 - color[1]) * t;
|
||||
color[2] += (color[2] * 0.6 - color[2]) * t;
|
||||
}
|
||||
let brighten = if eye_in_water {
|
||||
let env = &input.environment;
|
||||
let brighten = if eye_in_water && input.full_water_vision {
|
||||
water_vision
|
||||
} else if eye_in_water {
|
||||
state.water_vision_over(true, env.water_fog_brightening_time) * env.water_fog_brightening
|
||||
} else {
|
||||
input.night_vision
|
||||
};
|
||||
@@ -342,10 +352,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
_ if input.blindness.is_some() => {
|
||||
let (duration, infinite) = input.blindness.unwrap_or((0, true));
|
||||
let end = env.blindness_fog_distance;
|
||||
let distance = if infinite {
|
||||
5.0
|
||||
end
|
||||
} else {
|
||||
rd + (5.0 - rd) * (duration as f32 / 20.0).min(1.0)
|
||||
rd + (end - rd) * (duration as f32 / 20.0).min(1.0)
|
||||
};
|
||||
fog.environmental_start = distance * 0.25;
|
||||
fog.environmental_end = distance;
|
||||
@@ -355,7 +366,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
_ if input.darkness.is_some() => {
|
||||
// DarknessFogEnvironment.setupFog: closing in to 15 blocks.
|
||||
let blend = input.darkness.unwrap_or(0.0);
|
||||
let distance = rd + (15.0 - rd) * blend;
|
||||
let distance = rd + (env.darkness_fog_distance - rd) * blend;
|
||||
fog.environmental_start = distance * 0.75;
|
||||
fog.environmental_end = distance;
|
||||
fog.sky_end = distance;
|
||||
@@ -380,7 +391,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
fog.cloud_end = ((input.cloud_range_chunks * 16) as f32).min(sky.cloud_fog_end);
|
||||
if input.boss_world_fog {
|
||||
fog.environmental_start = fog.environmental_start.min(10.0);
|
||||
fog.environmental_end = fog.environmental_end.min(96.0);
|
||||
fog.environmental_end = fog.environmental_end.min(env.boss_fog_distance);
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
@@ -483,6 +494,7 @@ mod tests {
|
||||
fire_resistance: false,
|
||||
full_water_vision: false,
|
||||
night_vision: 0.0,
|
||||
environment: Default::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -560,6 +572,55 @@ mod tests {
|
||||
assert!(fog.environmental_start >= 1e9 && fog.render_distance_start >= 1e9);
|
||||
}
|
||||
#[test]
|
||||
fn a_packs_environment_moves_effect_fog_and_its_colours() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let environment = horizon_polytone::colors::Environment {
|
||||
powder_snow_fog: Some(0xFF0000),
|
||||
blindness_fog_distance: 40.0,
|
||||
water_fog_brightening: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.blindness = Some((-1, true));
|
||||
input.environment = environment;
|
||||
assert_eq!(
|
||||
setup_fog(&tables, &FogState::default(), &input).environmental_end,
|
||||
40.0
|
||||
);
|
||||
input.blindness = None;
|
||||
input.fog_type = FogType::PowderSnow;
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.color, [1.0, 0.0, 0.0, 1.0]);
|
||||
// Under water for the full 600 ticks: vanilla washes the fog out to a full channel; a
|
||||
// brightening of 0 keeps the colour given.
|
||||
let mut state = FogState::default();
|
||||
for _ in 0..600 {
|
||||
state.tick(true, false);
|
||||
}
|
||||
input.fog_type = FogType::Water;
|
||||
input.water_fog_color = [0.1, 0.2, 0.4];
|
||||
let washed = setup_fog(&tables, &state, &inputs_with(&input, Default::default())).color;
|
||||
assert_eq!(washed[2], 1.0);
|
||||
let kept = setup_fog(&tables, &state, &input).color;
|
||||
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: horizon_polytone::colors::Environment,
|
||||
) -> FogInputs<'a> {
|
||||
FogInputs {
|
||||
environment,
|
||||
..*input
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn fog_off_keeps_fluid_and_effect_fog() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
|
||||
@@ -86,8 +86,8 @@ pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
pub mod overlay;
|
||||
pub mod pack_browser;
|
||||
pub mod pack_dyes;
|
||||
pub mod pack_options_ui;
|
||||
pub mod pack_palette;
|
||||
pub mod pack_screen;
|
||||
pub mod pack_tags;
|
||||
pub mod packs;
|
||||
|
||||
@@ -766,7 +766,10 @@ fn stem_color(age: u8) -> u32 {
|
||||
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
|
||||
}
|
||||
fn redstone_color(power: u8) -> u32 {
|
||||
// RedstoneWireBlock.COLORS
|
||||
// RedstoneWireBlock.COLORS, or a pack's.
|
||||
if let Some(color) = crate::pack_palette::redstone(power) {
|
||||
return color;
|
||||
}
|
||||
let f = f32::from(power) / 15.0;
|
||||
let r = f * 0.6 + if f > 0.0 { 0.4 } else { 0.3 };
|
||||
let g = (f * f * 0.7 - 0.5).clamp(0.0, 1.0);
|
||||
|
||||
+14
-4
@@ -1,6 +1,7 @@
|
||||
//! Polytone's dye and sheep colours (`dye.<name>.diffuse|text`, `sheep.<name>`), for drawing only:
|
||||
//! the pack recolours collars, tropical fish, banners, wool and sign text, while gameplay (crafting,
|
||||
//! item data, the beacon) keeps vanilla's `DyeColor`. Lock-free: the draw paths read it per entity.
|
||||
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
|
||||
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
|
||||
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
|
||||
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
@@ -11,6 +12,7 @@ use horizon_polytone::colors::PackColors;
|
||||
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
|
||||
/// Takes the colours of the packs just loaded (vanilla's where they set none).
|
||||
pub fn set(colors: &PackColors) {
|
||||
@@ -24,6 +26,7 @@ pub fn set(colors: &PackColors) {
|
||||
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
|
||||
store(&TEXT, i, colors.dye(name, "text"));
|
||||
store(&SHEEP, i, colors.sheep(name));
|
||||
store(&REDSTONE, i, colors.redstone(i as u8));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +45,11 @@ pub fn text(dye: usize) -> u32 {
|
||||
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
}
|
||||
|
||||
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
|
||||
pub fn redstone(power: u8) -> Option<u32> {
|
||||
read(&REDSTONE, usize::from(power))
|
||||
}
|
||||
|
||||
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
|
||||
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
|
||||
pub fn sheep_wool(dye: u8) -> u32 {
|
||||
@@ -69,7 +77,7 @@ mod tests {
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}}"#,
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
set(&PackColors::read(&dir));
|
||||
@@ -81,7 +89,9 @@ mod tests {
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
set(&PackColors::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
@@ -168,6 +168,15 @@ impl Registry {
|
||||
let mut modifiers = Vec::new();
|
||||
let mut entity_modifiers = Vec::new();
|
||||
let mut particle_modifiers = Vec::new();
|
||||
// Custom colours' `particle.<id>`: a modifier that sets only the colour, below the files'.
|
||||
for (id, color) in horizon_polytone::colors::PackColors::read(pack_root).particles() {
|
||||
let json = serde_json::json!({"targets": [id], "color": color});
|
||||
match ParticleModifier::parse(&json, None) {
|
||||
Ok(Some(m)) => particle_modifiers.push((m, None, None)),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("colors particle.{id}: {e}"),
|
||||
}
|
||||
}
|
||||
let namespaces = std::fs::read_dir(pack_root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -2104,6 +2113,23 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_colours_recolour_their_particle_type() {
|
||||
let root = pack(&[(
|
||||
"assets/obs/polytone/colors.json",
|
||||
serde_json::json!({"particle": {"minecraft:flame": "0x0000ff"}}),
|
||||
)]);
|
||||
let registry = Registry::read(&root, Default::default(), blocks());
|
||||
let colour = |kind: &str| {
|
||||
let mut p = Particle::blank(ParticleType::Flame, [0.0; 3]);
|
||||
modify(®istry, kind, None, &mut p);
|
||||
p.rgba
|
||||
};
|
||||
assert_eq!(colour("minecraft:flame"), [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(colour("minecraft:smoke"), [1.0; 4]);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_modifiers_scale_power_and_recolour_their_particles_once() {
|
||||
let root = pack(&[
|
||||
|
||||
@@ -85,7 +85,7 @@ impl SignText {
|
||||
}
|
||||
/// `DyeColor.getTextColor` of this side.
|
||||
pub fn text_color(&self) -> u32 {
|
||||
crate::pack_dyes::text(usize::from(self.color.min(15)))
|
||||
crate::pack_palette::text(usize::from(self.color.min(15)))
|
||||
}
|
||||
/// `SignText.hasAnyClickCommands`: a line whose style has a `run_command`
|
||||
/// click event.
|
||||
|
||||
@@ -51,6 +51,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("missing cauldron")?,
|
||||
)?;
|
||||
let mut item = ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0; 2],
|
||||
join: Default::default(),
|
||||
health: 5,
|
||||
@@ -73,6 +74,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
no_gravity: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
output.push_str(f[0]);
|
||||
for _ in 0..2 {
|
||||
|
||||
@@ -276,8 +276,11 @@ fn concrete_powder_color(dye: &str) -> u32 {
|
||||
_ => 0x191919,
|
||||
}
|
||||
}
|
||||
/// `RedstoneWireBlock.COLORS`.
|
||||
/// `RedstoneWireBlock.COLORS`, or a pack's.
|
||||
fn redstone_color(power: u8) -> [f32; 3] {
|
||||
if let Some(c) = crate::pack_palette::redstone(power) {
|
||||
return [16, 8, 0].map(|shift| ((c >> shift) & 255) as f32 / 255.0);
|
||||
}
|
||||
let f = f32::from(power) / 15.0;
|
||||
let r = f * 0.6 + if f > 0.0 { 0.4 } else { 0.3 };
|
||||
let g = (f * f * 0.7 - 0.5).clamp(0.0, 1.0);
|
||||
|
||||
@@ -190,12 +190,12 @@ pub fn banner_color(item: &str) -> Option<&'static str> {
|
||||
DYES.iter().map(|d| d.0).find(|d| *d == name)
|
||||
}
|
||||
/// `DyeColor.getTextureDiffuseColor` (RGB) by dye name, as the resource
|
||||
/// packs draw it (`pack_dyes`): banner layers, banner and shield items and the
|
||||
/// packs draw it (`pack_palette`): banner layers, banner and shield items and the
|
||||
/// loom follow a pack's dye colours as the banner base does.
|
||||
pub fn dye_rgb(name: &str) -> u32 {
|
||||
DYES.iter()
|
||||
.position(|d| d.0 == name)
|
||||
.map_or(0xFFFFFF, crate::pack_dyes::diffuse)
|
||||
.map_or(0xFFFFFF, crate::pack_palette::diffuse)
|
||||
}
|
||||
/// The layers of a banner or shield stack.
|
||||
pub fn layers(stack: &ItemStack) -> &[BannerLayer] {
|
||||
|
||||
@@ -19,8 +19,8 @@ pub use horizon_client_render::{
|
||||
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_dyes, pack_screen, packs,
|
||||
pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors, polytone_post,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
|
||||
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors, polytone_post,
|
||||
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw,
|
||||
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
|
||||
shader_pack_list, signs, skins, social, sound_engine, sound_events, sound_preview, spans,
|
||||
|
||||
@@ -82,6 +82,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
|
||||
}
|
||||
|
||||
mod fluids;
|
||||
mod item_fall;
|
||||
mod item_no_physics;
|
||||
pub use fluids::{fluid_flow, update_entity_fluids, water_clip};
|
||||
|
||||
@@ -234,6 +235,12 @@ pub struct ItemEntity {
|
||||
pub vertical_collision: bool,
|
||||
/// `Entity.horizontalCollision`: the last move was stopped on x or z.
|
||||
pub horizontal_collision: bool,
|
||||
/// `Entity.random`, after the constructor's draws: `moveTowardsClosestSpace`
|
||||
/// takes an item's escape speed from it.
|
||||
pub random: horizon_core::random::LegacyRandom,
|
||||
/// `Entity.fallDistance` (`fall_distance` in its save): what the item has
|
||||
/// fallen since it last touched ground or water.
|
||||
pub fall_distance: f64,
|
||||
}
|
||||
/// What `Entity.doWaterSplashEffect` asks the level to do for a dropped item.
|
||||
/// Particles are drawn on the client (`Level.addParticle` is empty on the
|
||||
@@ -365,6 +372,10 @@ pub struct ItemStep {
|
||||
did_move: bool,
|
||||
/// The contact block for a clipped vertical move; idle ticks have none.
|
||||
pub vertical_contact: Option<BlockPos>,
|
||||
/// `Entity.move` with physics: where it started, what it moved, and
|
||||
/// whether the move was applied. `None` while idle or without physics,
|
||||
/// where `checkFallDamage` does not run.
|
||||
pub fall_move: Option<(Vec3, Vec3, bool)>,
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl ItemEntity {
|
||||
@@ -384,6 +395,7 @@ impl ItemEntity {
|
||||
) -> bool {
|
||||
let (water, lava) = self.update_fluids(world, fluids);
|
||||
self.water_entry(water > 0.0, self.ticks == 0, &mut |_| {});
|
||||
self.lava_fall(lava > 0.0);
|
||||
let step = self.tick_move(
|
||||
world,
|
||||
facts,
|
||||
@@ -547,14 +559,17 @@ impl ItemEntity {
|
||||
false,
|
||||
)
|
||||
}
|
||||
/// `Entity.updateFluidInteraction`: entering water after the first tick
|
||||
/// runs `doWaterSplashEffect`.
|
||||
/// `Entity.updateFluidInteraction`: water resets the fall, and entering
|
||||
/// it after the first tick runs `doWaterSplashEffect`.
|
||||
pub fn water_entry<F: FnMut(ItemSplash)>(
|
||||
&mut self,
|
||||
in_water: bool,
|
||||
first_tick: bool,
|
||||
splash: &mut F,
|
||||
) {
|
||||
if in_water {
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
if in_water && !self.was_touching_water && !first_tick {
|
||||
let mut random = horizon_core::random::LegacyRandom::new(
|
||||
(self.id as i64)
|
||||
@@ -684,6 +699,7 @@ impl ItemEntity {
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let from = self.position;
|
||||
let mut wanted = self.velocity;
|
||||
// Entity.move: a block the item sits in (cobweb, powder snow)
|
||||
// scales this move once and stops it.
|
||||
@@ -713,7 +729,8 @@ impl ItemEntity {
|
||||
)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
if move_applies(wanted, moved) {
|
||||
let applied = move_applies(wanted, moved);
|
||||
if applied {
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
@@ -755,6 +772,7 @@ impl ItemEntity {
|
||||
old_velocity,
|
||||
did_move: true,
|
||||
vertical_contact,
|
||||
fall_move: (!no_physics).then_some((from, moved, applied)),
|
||||
};
|
||||
}
|
||||
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
|
||||
@@ -765,6 +783,7 @@ impl ItemEntity {
|
||||
old_velocity,
|
||||
did_move: false,
|
||||
vertical_contact: None,
|
||||
fall_move: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,7 +847,14 @@ impl ItemEntity {
|
||||
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
self.apply_honey_slide(world, facts);
|
||||
self.stuck = self.stuck_multiplier(world, facts).unwrap_or(Vec3::ZERO);
|
||||
self.stuck = match self.stuck_multiplier(world, facts) {
|
||||
// Entity.makeStuckInBlock resets the fall.
|
||||
Some(stuck) => {
|
||||
self.fall_distance = 0.0;
|
||||
stuck
|
||||
}
|
||||
None => Vec3::ZERO,
|
||||
};
|
||||
}
|
||||
}
|
||||
/// The rest of `ItemEntity.tick`, after the bubble column's push: slime,
|
||||
@@ -2766,35 +2792,7 @@ impl ArmorStand {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod experience_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn death_reward_uses_levels_and_caps_the_recoverable_points() {
|
||||
for (level, reward) in [(0, 0), (1, 7), (14, 98), (15, 100), (30, 100)] {
|
||||
let xp = Experience {
|
||||
level,
|
||||
progress: 0.75,
|
||||
total: 10_000,
|
||||
};
|
||||
assert_eq!(xp.death_reward(false), reward);
|
||||
assert_eq!(xp.death_reward(true), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respawn_clears_all_xp_unless_it_is_kept() {
|
||||
let mut xp = Experience::default();
|
||||
xp.give(155);
|
||||
let before = (xp.level, xp.progress, xp.total);
|
||||
xp.respawn(true);
|
||||
assert_eq!((xp.level, xp.progress, xp.total), before);
|
||||
xp.respawn(false);
|
||||
assert_eq!((xp.level, xp.progress, xp.total), (0, 0.0, 0));
|
||||
xp.give(7);
|
||||
assert_eq!((xp.level, xp.progress, xp.total), (1, 0.0, 7));
|
||||
}
|
||||
}
|
||||
mod experience_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fire_environment_tests {
|
||||
@@ -2823,34 +2821,19 @@ mod fire_environment_tests {
|
||||
assets.registry.default_state("minecraft:lava").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let make = |id: &str| ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
thrower: None,
|
||||
target: None,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from(id),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
was_touching_water: false,
|
||||
let make = |id: &str| {
|
||||
ItemEntity::new(
|
||||
1,
|
||||
ItemStack {
|
||||
item: std::sync::Arc::from(id),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
at,
|
||||
Vec3::ZERO,
|
||||
0.0,
|
||||
)
|
||||
};
|
||||
let mut wood = make("minecraft:oak_planks");
|
||||
let mut metal = make("minecraft:netherite_ingot");
|
||||
@@ -2958,30 +2941,7 @@ mod destroyed_bundle_environment_tests {
|
||||
ItemStack::new("minecraft:stone", 3),
|
||||
ItemStack::new("minecraft:diamond", 1),
|
||||
];
|
||||
let mut bundle = ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
thrower: None,
|
||||
target: None,
|
||||
stack,
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
was_touching_water: false,
|
||||
};
|
||||
let mut bundle = ItemEntity::new(1, stack, at, Vec3::ZERO, 0.0);
|
||||
// 5 health against 4 a tick: the first tick leaves 1 and nothing out.
|
||||
let first = bundle.environment_tick(&world, &facts, assets.registry, &items);
|
||||
assert!(first.alive && first.spilled.is_empty());
|
||||
@@ -3165,6 +3125,7 @@ mod merge_tests {
|
||||
fn item(id: &str, count: u32, x: f64, age: i32) -> ItemEntity {
|
||||
let at = Vec3::new(x, 64.0, 0.5);
|
||||
ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -3185,6 +3146,7 @@ mod merge_tests {
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
age,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -3684,6 +3646,7 @@ mod honey_slide_tests {
|
||||
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -3704,6 +3667,7 @@ mod honey_slide_tests {
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
age: 10,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -3835,6 +3799,7 @@ mod slime_bounce_tests {
|
||||
fn grounded_item() -> ItemEntity {
|
||||
let at = Vec3::new(0.5, 2.0, 0.5);
|
||||
ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -3855,6 +3820,7 @@ mod slime_bounce_tests {
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
age: 10,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -3906,6 +3872,7 @@ mod slime_bounce_tests {
|
||||
old_velocity: item.velocity,
|
||||
did_move: true,
|
||||
vertical_contact: None,
|
||||
fall_move: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(item.velocity.x.to_bits(), 0x3fb9_1687_3333_3334);
|
||||
@@ -4747,6 +4714,7 @@ mod item_destroyed_tests {
|
||||
.map(|(id, count)| ItemStack::new(id, *count))
|
||||
.collect();
|
||||
ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -4762,6 +4730,7 @@ mod item_destroyed_tests {
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -4982,6 +4951,7 @@ mod dropped_item_light_tests {
|
||||
|
||||
fn item(previous_y: f64, y: f64) -> ItemEntity {
|
||||
ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -5002,6 +4972,7 @@ mod dropped_item_light_tests {
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -5044,6 +5015,7 @@ mod dropped_item_light_tests {
|
||||
assert_eq!(falling.light_probe(1.0)[1].floor(), 65.0);
|
||||
// Across x and z too, not just down.
|
||||
let sliding = ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
position: Vec3::new(1.5, 64.0, 0.5),
|
||||
previous: Vec3::new(0.5, 64.0, 0.5),
|
||||
..item(64.0, 64.0)
|
||||
@@ -5061,6 +5033,7 @@ mod dropped_item_light_tests {
|
||||
assert_eq!(cold.render_light(light(15, 0)), (15 * 16) as u32);
|
||||
assert_eq!(cold.render_light(light(0, 9)), ((9 * 16) << 8) as u32);
|
||||
let burning = ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
fire_ticks: 160,
|
||||
..item(64.0, 64.0)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn death_reward_uses_levels_and_caps_the_recoverable_points() {
|
||||
for (level, reward) in [(0, 0), (1, 7), (14, 98), (15, 100), (30, 100)] {
|
||||
let xp = Experience {
|
||||
level,
|
||||
progress: 0.75,
|
||||
total: 10_000,
|
||||
};
|
||||
assert_eq!(xp.death_reward(false), reward);
|
||||
assert_eq!(xp.death_reward(true), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respawn_clears_all_xp_unless_it_is_kept() {
|
||||
let mut xp = Experience::default();
|
||||
xp.give(155);
|
||||
let before = (xp.level, xp.progress, xp.total);
|
||||
xp.respawn(true);
|
||||
assert_eq!((xp.level, xp.progress, xp.total), before);
|
||||
xp.respawn(false);
|
||||
assert_eq!((xp.level, xp.progress, xp.total), (0, 0.0, 0));
|
||||
xp.give(7);
|
||||
assert_eq!((xp.level, xp.progress, xp.total), (1, 0.0, 7));
|
||||
}
|
||||
@@ -338,6 +338,7 @@ mod tests {
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let item = ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -358,6 +359,7 @@ mod tests {
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! A dropped item's construction and its fall distance (`Entity.fallDistance`).
|
||||
use super::{ItemEntity, ItemStep};
|
||||
use crate::items::ItemStack;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
impl ItemEntity {
|
||||
/// A fresh `ItemEntity` at `position` moving at `velocity`: full health,
|
||||
/// no age, delay or owner, not yet ticked.
|
||||
pub fn new(id: u32, stack: ItemStack, position: Vec3, velocity: Vec3, bob_offset: f32) -> Self {
|
||||
Self {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
thrower: None,
|
||||
target: None,
|
||||
id,
|
||||
stack,
|
||||
position,
|
||||
previous: position,
|
||||
velocity,
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset,
|
||||
ticks: 0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
was_touching_water: false,
|
||||
// The caller gives it the own random that drew its UUID and bobOffs.
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
}
|
||||
}
|
||||
/// `Entity.move`'s fall bookkeeping after the item's move: `reset_clip`
|
||||
/// is the cast of a move of a block or more over fall-resetting blocks
|
||||
/// and water (the fall it leaves), then `checkFallDamage` adds the drop,
|
||||
/// narrowed to a float, outside water and ends the fall on the ground.
|
||||
pub fn check_fall(&mut self, step: &ItemStep, reset_clip: impl FnOnce(f64, Vec3, Vec3) -> f64) {
|
||||
let Some((from, moved, applied)) = step.fall_move else {
|
||||
return;
|
||||
};
|
||||
if applied {
|
||||
self.fall_distance = reset_clip(self.fall_distance, from, moved);
|
||||
}
|
||||
if !self.was_touching_water && moved.y < 0.0 {
|
||||
self.fall_distance -= f64::from(moved.y as f32);
|
||||
}
|
||||
if self.on_ground {
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
/// `Entity.baseTick`: lava halves the fall each tick.
|
||||
pub fn lava_fall(&mut self, in_lava: bool) {
|
||||
if in_lava {
|
||||
self.fall_distance *= 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ use horizon_world::{
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
mod projectile_fields;
|
||||
use projectile_fields::{load_projectile_entity_fields, put_projectile_entity_fields};
|
||||
mod saved_attributes;
|
||||
|
||||
/// Saved data carried by `ConversionType.convertCommon` outside the modelled
|
||||
@@ -1362,14 +1364,7 @@ pub fn mob_to_nbt(
|
||||
map.insert("StunTick".into(), Tag::Int(m.extra.counter2));
|
||||
map.insert("RoarTick".into(), Tag::Int(m.extra.anim_ticks));
|
||||
}
|
||||
"minecraft:vex" => {
|
||||
if let Some(h) = m.extra.home {
|
||||
map.insert("bound_pos".into(), Tag::IntArray(h.to_vec()));
|
||||
}
|
||||
if m.extra.egg_time > 0 {
|
||||
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
}
|
||||
"minecraft:vex" => crate::mobs::vex::write_nbt(m, map, &mut uuid_of),
|
||||
"minecraft:endermite" => {
|
||||
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
|
||||
}
|
||||
@@ -1562,11 +1557,9 @@ pub fn mob_to_nbt(
|
||||
.iter_mut()
|
||||
.find(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
|
||||
{
|
||||
// The model owns these bases: a ravager's speed, a wolf's health.
|
||||
if matches!(
|
||||
(species, attr),
|
||||
(S::Ravager, Attr::MovementSpeed) | (S::Wolf, Attr::MaxHealth)
|
||||
) && let Tag::Compound(c) = existing
|
||||
// The model owns a wolf's health base.
|
||||
if (species, attr) == (S::Wolf, Attr::MaxHealth)
|
||||
&& let Tag::Compound(c) = existing
|
||||
{
|
||||
c.insert("base".into(), Tag::Double(base.to_bits()));
|
||||
}
|
||||
@@ -2642,22 +2635,12 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
|
||||
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
|
||||
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
|
||||
if crate::mobs::ravager::immobile(&m)
|
||||
&& !matches!(tag.get("attributes"), Some(Tag::List(l)) if l.iter().any(|a| text(a.get("id")).as_deref() == Some("minecraft:movement_speed")))
|
||||
{
|
||||
m.attrs.speed = 0.0;
|
||||
}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Endermite {
|
||||
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Vex {
|
||||
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
|
||||
&& v.len() == 3
|
||||
{
|
||||
m.extra.home = Some([v[0], v[1], v[2]]);
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
|
||||
crate::mobs::vex::read_nbt(&mut m, tag);
|
||||
}
|
||||
// Bogged.readAdditionalSaveData: unsheared when absent.
|
||||
if m.species() == crate::mobs::Species::Bogged {
|
||||
@@ -2822,7 +2805,7 @@ fn item_entity_to_nbt_saved(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>, i
|
||||
motion: e.velocity,
|
||||
rotation: e.rotation,
|
||||
on_ground: e.on_ground,
|
||||
fall_distance: 0.0,
|
||||
fall_distance: e.fall_distance,
|
||||
};
|
||||
let mut tag = base.non_mob_tag(raw);
|
||||
for (key, value) in [
|
||||
@@ -2998,6 +2981,8 @@ 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,
|
||||
// Entity.load: getDoubleOr, any number widened.
|
||||
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
|
||||
age: number(tag.get("Age")).unwrap_or(0) as i32,
|
||||
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0),
|
||||
bob_offset,
|
||||
@@ -3005,6 +2990,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
was_touching_water: false,
|
||||
// The caller gives it the own random that drew its UUID and bobOffs.
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3285,7 +3272,7 @@ pub fn fireball_to_nbt(
|
||||
motion: p.velocity,
|
||||
rotation: [p.yaw, p.pitch],
|
||||
on_ground: p.on_ground,
|
||||
fall_distance: 0.0,
|
||||
fall_distance: p.fall_distance,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put(
|
||||
@@ -3312,27 +3299,6 @@ pub fn fireball_to_nbt(
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
/// `Entity.load`: fire is read as a short, air as an int and the visual
|
||||
/// fire flag as a byte-valued boolean. Portal state lives on the level.
|
||||
fn load_projectile_entity_fields(p: &mut Projectile, tag: &Tag) {
|
||||
p.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
|
||||
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(p.air_supply);
|
||||
p.has_visual_fire = flag(tag, "HasVisualFire");
|
||||
}
|
||||
|
||||
/// `Entity.saveWithoutId`: save the current fire and air as shorts, and
|
||||
/// only write the optional visual fire flag when it is true.
|
||||
fn put_projectile_entity_fields(tag: &mut Tag, p: &Projectile) {
|
||||
put(tag, "Fire", Tag::Short(p.fire_ticks as i16));
|
||||
put(tag, "Air", Tag::Short(p.air_supply as i16));
|
||||
if let Tag::Compound(map) = tag {
|
||||
map.remove(&"HasVisualFire".into());
|
||||
}
|
||||
if p.has_visual_fire {
|
||||
put(tag, "HasVisualFire", Tag::Byte(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Projectile.addAdditionalSaveData`: Owner, LeftOwner only when set, and
|
||||
/// the explicit HasBeenShot flag.
|
||||
fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
|
||||
@@ -3436,7 +3402,7 @@ pub fn throwable_to_nbt(
|
||||
motion: p.velocity,
|
||||
rotation: [p.yaw, p.pitch],
|
||||
on_ground: p.on_ground,
|
||||
fall_distance: 0.0,
|
||||
fall_distance: p.fall_distance,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put_projectile(&mut tag, p, owner);
|
||||
@@ -3622,6 +3588,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
load_projectile_entity_fields(&mut p, tag);
|
||||
}
|
||||
p.on_ground = flag(tag, "OnGround");
|
||||
p.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
|
||||
if let Some(item) = tag.get("Item").and_then(item_from_nbt) {
|
||||
p.set_item(item);
|
||||
@@ -4541,74 +4508,7 @@ mod tnt_power_tests {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod spell_ticks_nbt_tests {
|
||||
use super::*;
|
||||
use crate::mobs::Species;
|
||||
|
||||
fn caster(species: Species, name: &str) -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.species = species;
|
||||
ty.id = format!("minecraft:{name}");
|
||||
Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
|
||||
}
|
||||
|
||||
/// `SpellcasterIllager.readAdditionalSaveData`: bind loaded values to the
|
||||
/// counter already used by casting goals and customServerAiStep.
|
||||
#[test]
|
||||
fn spell_ticks_load_into_the_live_counter() {
|
||||
for (species, name) in [
|
||||
(Species::Evoker, "evoker"),
|
||||
(Species::Illusioner, "illusioner"),
|
||||
] {
|
||||
let m = caster(species, name);
|
||||
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
|
||||
for value in [None, Some(30), Some(0), Some(-2)] {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string(&m.kind.id)),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
]);
|
||||
if let Some(value) = value {
|
||||
put(&mut raw, "SpellTicks", Tag::Int(value));
|
||||
}
|
||||
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
assert_eq!(
|
||||
loaded.extra.counter,
|
||||
value.unwrap_or(0),
|
||||
"{name}: {value:?}"
|
||||
);
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value.unwrap_or(0))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpellcasterIllager.addAdditionalSaveData`: live state wins even when
|
||||
/// no raw input exists or the original raw counter has become stale.
|
||||
#[test]
|
||||
fn spell_ticks_save_the_mutated_model_without_a_raw_fallback() {
|
||||
for (species, name) in [
|
||||
(Species::Evoker, "evoker"),
|
||||
(Species::Illusioner, "illusioner"),
|
||||
] {
|
||||
let mut m = caster(species, name);
|
||||
let raw = Tag::compound([
|
||||
("SpellTicks", Tag::Int(30)),
|
||||
("audit:unrelated", Tag::Int(7)),
|
||||
]);
|
||||
for value in [29, 27, 20, 0, -2] {
|
||||
m.extra.counter = value;
|
||||
for raw in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], raw, None, |_| None);
|
||||
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value)), "{name}");
|
||||
if raw.is_some() {
|
||||
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mod spell_ticks_nbt_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -5721,33 +5621,25 @@ mod tests {
|
||||
#[test]
|
||||
fn item_entities_round_trip() {
|
||||
let e = ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
health: 2,
|
||||
fire_ticks: 73,
|
||||
thrower: Some([5, 6, 7, 8]),
|
||||
target: None,
|
||||
id: 7,
|
||||
stack: ItemStack {
|
||||
item: Arc::from("minecraft:diamond_pickaxe"),
|
||||
count: 1,
|
||||
damage: 5,
|
||||
extra: None,
|
||||
},
|
||||
position: Vec3::new(100.5, 64.0, -31.5),
|
||||
previous: Vec3::ZERO,
|
||||
velocity: Vec3::new(0.0, -0.04, 0.0),
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 120,
|
||||
pickup_delay: 10,
|
||||
ticks: 0,
|
||||
bob_offset: 0.0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
was_touching_water: false,
|
||||
..ItemEntity::new(
|
||||
7,
|
||||
ItemStack {
|
||||
item: Arc::from("minecraft:diamond_pickaxe"),
|
||||
count: 1,
|
||||
damage: 5,
|
||||
extra: None,
|
||||
},
|
||||
Vec3::new(100.5, 64.0, -31.5),
|
||||
Vec3::new(0.0, -0.04, 0.0),
|
||||
0.0,
|
||||
)
|
||||
};
|
||||
let tag = item_entity_to_nbt(&e, [1, 2, 3, 4], None);
|
||||
assert_eq!(kind(&tag).as_deref(), Some("minecraft:item"));
|
||||
@@ -10511,25 +10403,21 @@ mod equine_temper_preservation_tests {
|
||||
);
|
||||
Arc::make_mut(&mut kind).species = Species::Ravager;
|
||||
let types = [("minecraft:ravager".into(), kind)].into();
|
||||
let mut ravager = mob_from_nbt(
|
||||
&Tag::compound([
|
||||
("id", string("minecraft:ravager")),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
("RoarTick", Tag::Int(10)),
|
||||
]),
|
||||
&types,
|
||||
2,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
ravager.age = 1;
|
||||
let saved = mob_to_nbt(&ravager, [0; 4], None, None, |_| None);
|
||||
assert_eq!(
|
||||
saved.get("attributes"),
|
||||
Some(&Tag::List(vec![Tag::compound([
|
||||
("id", string("minecraft:movement_speed")),
|
||||
("base", Tag::Double(0.0f64.to_bits()))
|
||||
])]))
|
||||
);
|
||||
// Before its first aiStep a roaring ravager has no movement_speed
|
||||
// instance; a saved base (mid-ramp after a roar) loads back as is.
|
||||
let speed = Tag::List(vec![Tag::compound([
|
||||
("id", string("minecraft:movement_speed")),
|
||||
("base", Tag::Double(0.10317f64.to_bits())),
|
||||
])]);
|
||||
for (key, value, saved) in [
|
||||
("RoarTick", Tag::Int(10), Tag::List(vec![])),
|
||||
("attributes", speed.clone(), speed),
|
||||
] {
|
||||
let pos = ("Pos", doubles(&[0.0, 0.0, 0.0]));
|
||||
let tag = Tag::compound([("id", string("minecraft:ravager")), pos, (key, value)]);
|
||||
let ravager = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
let nbt = mob_to_nbt(&ravager, [0; 4], None, None, |_| None);
|
||||
assert_eq!(nbt.get("attributes"), Some(&saved));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! The `Entity` fields a projectile keeps beside `Projectile`'s own: fire,
|
||||
//! air, the visual fire flag and the fall distance.
|
||||
use super::{flag, float, int_value, put};
|
||||
use crate::projectiles::Projectile;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `Entity.load`: fire is read as a short, air as an int and the visual
|
||||
/// fire flag as a byte-valued boolean. Portal state lives on the level.
|
||||
pub(super) fn load_projectile_entity_fields(p: &mut Projectile, tag: &Tag) {
|
||||
p.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
|
||||
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(p.air_supply);
|
||||
p.has_visual_fire = flag(tag, "HasVisualFire");
|
||||
// Entity.load: getDoubleOr, any number widened.
|
||||
p.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
}
|
||||
|
||||
/// `Entity.saveWithoutId`: save the current fire and air as shorts, and
|
||||
/// only write the optional visual fire flag when it is true.
|
||||
pub(super) fn put_projectile_entity_fields(tag: &mut Tag, p: &Projectile) {
|
||||
put(tag, "Fire", Tag::Short(p.fire_ticks as i16));
|
||||
put(tag, "Air", Tag::Short(p.air_supply as i16));
|
||||
if let Tag::Compound(map) = tag {
|
||||
map.remove(&"HasVisualFire".into());
|
||||
}
|
||||
if p.has_visual_fire {
|
||||
put(tag, "HasVisualFire", Tag::Byte(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use super::*;
|
||||
use crate::mobs::Species;
|
||||
|
||||
fn caster(species: Species, name: &str) -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.species = species;
|
||||
ty.id = format!("minecraft:{name}");
|
||||
Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
|
||||
}
|
||||
|
||||
/// `SpellcasterIllager.readAdditionalSaveData`: bind loaded values to the
|
||||
/// counter already used by casting goals and customServerAiStep.
|
||||
#[test]
|
||||
fn spell_ticks_load_into_the_live_counter() {
|
||||
for (species, name) in [
|
||||
(Species::Evoker, "evoker"),
|
||||
(Species::Illusioner, "illusioner"),
|
||||
] {
|
||||
let m = caster(species, name);
|
||||
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
|
||||
for value in [None, Some(30), Some(0), Some(-2)] {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string(&m.kind.id)),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
]);
|
||||
if let Some(value) = value {
|
||||
put(&mut raw, "SpellTicks", Tag::Int(value));
|
||||
}
|
||||
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
assert_eq!(
|
||||
loaded.extra.counter,
|
||||
value.unwrap_or(0),
|
||||
"{name}: {value:?}"
|
||||
);
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value.unwrap_or(0))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpellcasterIllager.addAdditionalSaveData`: live state wins even when
|
||||
/// no raw input exists or the original raw counter has become stale.
|
||||
#[test]
|
||||
fn spell_ticks_save_the_mutated_model_without_a_raw_fallback() {
|
||||
for (species, name) in [
|
||||
(Species::Evoker, "evoker"),
|
||||
(Species::Illusioner, "illusioner"),
|
||||
] {
|
||||
let mut m = caster(species, name);
|
||||
let raw = Tag::compound([
|
||||
("SpellTicks", Tag::Int(30)),
|
||||
("audit:unrelated", Tag::Int(7)),
|
||||
]);
|
||||
for value in [29, 27, 20, 0, -2] {
|
||||
m.extra.counter = value;
|
||||
for raw in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], raw, None, |_| None);
|
||||
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value)), "{name}");
|
||||
if raw.is_some() {
|
||||
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3893,8 +3893,7 @@ impl Game<'_> {
|
||||
velocity: Option<[f64; 3]>,
|
||||
pickup_delay: u32,
|
||||
) -> [f64; 3] {
|
||||
let item = stack.item.clone();
|
||||
let count = stack.count as i32;
|
||||
let (item, count) = (stack.item.clone(), stack.count as i32);
|
||||
self.next_entity += 1;
|
||||
let p = Vec3::new(position[0], position[1], position[2]);
|
||||
// The item's own random, in vanilla's order: Entity's UUID, ItemEntity's
|
||||
@@ -3910,27 +3909,15 @@ impl Game<'_> {
|
||||
});
|
||||
self.dropped.push(ItemEntity {
|
||||
rotation: [yaw, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
thrower: None,
|
||||
target: None,
|
||||
id: self.next_entity,
|
||||
stack,
|
||||
position: p,
|
||||
previous: p,
|
||||
velocity: Vec3::new(velocity[0], velocity[1], velocity[2]),
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: i64::from(pickup_delay),
|
||||
bob_offset: bob,
|
||||
ticks: 0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
was_touching_water: false,
|
||||
random: own,
|
||||
..ItemEntity::new(
|
||||
self.next_entity,
|
||||
stack,
|
||||
p,
|
||||
Vec3::new(velocity[0], velocity[1], velocity[2]),
|
||||
bob,
|
||||
)
|
||||
});
|
||||
// ServerLevel.addFreshEntity.
|
||||
if self.native_joins_flush(false, self.next_entity) {
|
||||
@@ -5583,20 +5570,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.trigger_bee_nest_destroyed(broken, held.as_ref(), bees);
|
||||
}
|
||||
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
|
||||
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
|
||||
if facts.name.starts_with("minecraft:infested_")
|
||||
&& !silk
|
||||
&& self.ui.rules.bool("block_drops")
|
||||
{
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
if let Some(id) = self.spawn_mob_raw("minecraft:silverfish", at, false) {
|
||||
self.mob_poof(id);
|
||||
}
|
||||
// InfestedBlock.spawnAfterBreak, unless the tool has silk touch
|
||||
// (#prevents_infested_spawns).
|
||||
if facts.name.starts_with("minecraft:infested_") && !silk {
|
||||
self.spawn_infestation(pos);
|
||||
}
|
||||
// Block.tryDropExperience samples before Silk Touch removes the XP.
|
||||
// SpawnerBlock.spawnAfterBreak ignores Silk Touch. Both sample even
|
||||
|
||||
@@ -677,7 +677,7 @@ impl Game<'_> {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
/// `Entity.move`'s fall reset for a vehicle that keeps a fall and moves on
|
||||
/// `Entity.move`'s fall reset for an entity that keeps a fall and moves on
|
||||
/// by `moved` from `from`: a move of a block or more casts up to 8 blocks
|
||||
/// of it (`ClipContext.Block.FALLDAMAGE_RESETTING`, `ClipContext.Fluid.WATER`)
|
||||
/// and a hit resets the fall, before the move's own drop counts.
|
||||
|
||||
@@ -7,8 +7,15 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Exercise the actual Game keyboard/mouse handlers and generated mod dispatch.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and a fresh HORIZON_HOME; run in isolation"]
|
||||
#[ignore = "needs prepared resources and a fresh HORIZON_HOME"]
|
||||
fn chat_completion_reaches_mods_from_keyboard_and_mouse() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::chat::suggestion_completion_tests::chat_completion_reaches_mods_from_keyboard_and_mouse",
|
||||
chat_completion_reaches_mods_from_keyboard_and_mouse_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn chat_completion_reaches_mods_from_keyboard_and_mouse_in_own_process() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let scratch = PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("suggestion-game");
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
|
||||
@@ -471,9 +471,10 @@ impl Game<'_> {
|
||||
Target::Item(id) => {
|
||||
let new = entity_nbt::item_entity_from_nbt(&tag, id, 0.0).ok_or_else(stale)?;
|
||||
if let Some(slot) = self.dropped.iter_mut().find(|e| e.id == id) {
|
||||
let bob = slot.bob_offset;
|
||||
*slot = new;
|
||||
slot.bob_offset = bob;
|
||||
// `/data` loads into the same entity: its bobOffs and own random stay.
|
||||
let kept = std::mem::replace(slot, new);
|
||||
slot.bob_offset = kept.bob_offset;
|
||||
slot.random = kept.random;
|
||||
}
|
||||
}
|
||||
Target::Fangs(id) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::sync::Arc;
|
||||
|
||||
fn item(id: u32, at: Vec3) -> ItemEntity {
|
||||
ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: true,
|
||||
@@ -33,6 +34,7 @@ fn item(id: u32, at: Vec3) -> ItemEntity {
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
age: 97,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
|
||||
@@ -592,6 +592,7 @@ impl Game<'_> {
|
||||
let heights = item.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
// Entity.baseTick splashes before moving, except on the first tick.
|
||||
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
|
||||
item.lava_fall(heights.1 > 0.0);
|
||||
let step = item.tick_move_before_block_effects(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
@@ -603,6 +604,9 @@ impl Game<'_> {
|
||||
Some(self.border.geometry()),
|
||||
Some(&self.res.motion().blocks),
|
||||
);
|
||||
item.check_fall(&step, |fall, from, moved| {
|
||||
self.vehicle_fall_reset(fall, from, moved)
|
||||
});
|
||||
if let Some(pos) = step.vertical_contact {
|
||||
let state = self.world.block(pos.x, pos.y, pos.z);
|
||||
let position = item.position;
|
||||
|
||||
@@ -303,6 +303,43 @@ impl Game<'_> {
|
||||
}
|
||||
hits as f32 / count as f32
|
||||
}
|
||||
/// What a ray's step through the block at `pos` takes from its power
|
||||
/// (`ExplosionDamageCalculator.getBlockExplosionResistance`), and whether
|
||||
/// the block is spared.
|
||||
fn blast_step<S: std::hash::BuildHasher>(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
cap: Option<f32>,
|
||||
wither_immune: Option<&std::collections::HashSet<String, S>>,
|
||||
) -> (f32, bool) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let f = self.res.facts().get(state);
|
||||
let fluid = if f.fluid != Fluid::None {
|
||||
100.0f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// A primed TNT minecart's rails and the blocks they
|
||||
// stand on: no resistance, and never blown up.
|
||||
let spared = self.combat.rail_proof_blast
|
||||
&& (self.rail(pos[0], pos[1], pos[2]).is_some()
|
||||
|| self.rail(pos[0], pos[1] + 1, pos[2]).is_some());
|
||||
let taken = match resists {
|
||||
Some((at, resistance)) if at == pos => (resistance + 0.3) * 0.3,
|
||||
_ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => {
|
||||
let base = if spared {
|
||||
0.0
|
||||
} else {
|
||||
f.explosion_resistance.max(fluid)
|
||||
};
|
||||
let immune = wither_immune.is_some_and(|t| t.contains(&*f.name));
|
||||
(blast_resistance(base, cap, immune) + 0.3) * 0.3
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
(taken, spared)
|
||||
}
|
||||
/// `ServerExplosion.calculateExplodedPositions`; `resists` overrides one
|
||||
/// block's resistance (`ExplosionDamageCalculator.getBlockExplosionResistance`).
|
||||
fn exploded_positions(
|
||||
@@ -341,6 +378,11 @@ impl Game<'_> {
|
||||
zd /= d;
|
||||
let mut power = radius * (0.7 + self.world.random.next_float() * 0.6);
|
||||
let (mut xp, mut yp, mut zp) = (center[0], center[1], center[2]);
|
||||
// A ray's steps are 0.3 long, about three to a block: a
|
||||
// step in the block the last one was in takes what that
|
||||
// one took (the level does not change while rays cast),
|
||||
// and that block is in `set` already if it is to be.
|
||||
let mut last: Option<([i32; 3], f32, bool)> = None;
|
||||
while power > 0.0 {
|
||||
let pos = [xp.floor() as i32, yp.floor() as i32, zp.floor() as i32];
|
||||
if pos[1] < min_y
|
||||
@@ -350,35 +392,17 @@ impl Game<'_> {
|
||||
{
|
||||
break;
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let f = self.res.facts().get(state);
|
||||
let fluid = if f.fluid != Fluid::None {
|
||||
100.0f32
|
||||
} else {
|
||||
0.0
|
||||
let (taken, spared, again) = match last {
|
||||
Some((at, taken, spared)) if at == pos => (taken, spared, true),
|
||||
_ => {
|
||||
let (taken, spared) =
|
||||
self.blast_step(pos, resists, cap, wither_immune.as_ref());
|
||||
last = Some((pos, taken, spared));
|
||||
(taken, spared, false)
|
||||
}
|
||||
};
|
||||
// A primed TNT minecart's rails and the blocks they
|
||||
// stand on: no resistance, and never blown up.
|
||||
let spared = self.combat.rail_proof_blast
|
||||
&& (self.rail(pos[0], pos[1], pos[2]).is_some()
|
||||
|| self.rail(pos[0], pos[1] + 1, pos[2]).is_some());
|
||||
match resists {
|
||||
Some((at, resistance)) if at == pos => {
|
||||
power -= (resistance + 0.3) * 0.3;
|
||||
}
|
||||
_ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => {
|
||||
let base = if spared {
|
||||
0.0
|
||||
} else {
|
||||
f.explosion_resistance.max(fluid)
|
||||
};
|
||||
let immune =
|
||||
wither_immune.as_ref().is_some_and(|t| t.contains(&*f.name));
|
||||
power -= (blast_resistance(base, cap, immune) + 0.3) * 0.3;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if power > 0.0 && !spared {
|
||||
power -= taken;
|
||||
if power > 0.0 && !spared && !again {
|
||||
set.put(Key(pos), ());
|
||||
}
|
||||
xp += xd * f64::from(0.3f32);
|
||||
@@ -1047,6 +1071,22 @@ impl Game<'_> {
|
||||
self.lan_explode(center, small, radius, block_count, pushed);
|
||||
Ok(())
|
||||
}
|
||||
/// `InfestedBlock.spawnAfterBreak` without a tool that prevents it: while
|
||||
/// blocks drop, its silverfish (`spawnInfestation`) comes out at the
|
||||
/// bottom centre of the block, with its spawn animation.
|
||||
pub(super) fn spawn_infestation(&mut self, pos: [i32; 3]) {
|
||||
if !self.ui.rules.bool("block_drops") {
|
||||
return;
|
||||
}
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
if let Some(id) = self.spawn_mob_raw("minecraft:silverfish", at, false) {
|
||||
self.mob_poof(id);
|
||||
}
|
||||
}
|
||||
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
|
||||
fn explosion_hit_block(
|
||||
&mut self,
|
||||
@@ -1091,6 +1131,10 @@ impl Game<'_> {
|
||||
} else if let Some(tag) = super::spill::saved_entity(&self.world, pos) {
|
||||
self.pending.record_saved(pos, &tag);
|
||||
}
|
||||
// Block.onExplosionHit runs spawnAfterBreak before the drops.
|
||||
if !is_tnt && name.starts_with("minecraft:infested_") {
|
||||
self.spawn_infestation(pos);
|
||||
}
|
||||
if !is_tnt {
|
||||
let dynamic = self.pending.dynamic(pos);
|
||||
// LootContextParams.EXPLOSION_RADIUS under DESTROY_WITH_DECAY: the
|
||||
@@ -1260,6 +1304,107 @@ fn blast_resistance(base: f32, cap: Option<f32>, wither_immune: bool) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `exploded_positions` before rays reused their last block's lookup:
|
||||
/// every step looked its block up.
|
||||
fn exploded_positions_stepwise(
|
||||
game: &mut Game<'_>,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
) -> Vec<[i32; 3]> {
|
||||
let (min_y, max_y) = game.with_level(|_, level, _| {
|
||||
use horizon_world::level::LevelAccess;
|
||||
(level.min_y(), level.max_y())
|
||||
});
|
||||
let mut set: JavaHashMap<Key, ()> = JavaHashMap::default();
|
||||
for xx in 0..16 {
|
||||
for yy in 0..16 {
|
||||
for zz in 0..16 {
|
||||
if !(xx == 0 || xx == 15 || yy == 0 || yy == 15 || zz == 0 || zz == 15) {
|
||||
continue;
|
||||
}
|
||||
let mut xd = f64::from(xx as f32 / 15.0 * 2.0 - 1.0);
|
||||
let mut yd = f64::from(yy as f32 / 15.0 * 2.0 - 1.0);
|
||||
let mut zd = f64::from(zz as f32 / 15.0 * 2.0 - 1.0);
|
||||
let d = (xd * xd + yd * yd + zd * zd).sqrt();
|
||||
(xd, yd, zd) = (xd / d, yd / d, zd / d);
|
||||
let mut power = radius * (0.7 + game.world.random.next_float() * 0.6);
|
||||
let (mut xp, mut yp, mut zp) = (center[0], center[1], center[2]);
|
||||
while power > 0.0 {
|
||||
let pos = [xp.floor() as i32, yp.floor() as i32, zp.floor() as i32];
|
||||
if pos[1] < min_y || pos[1] >= max_y {
|
||||
break;
|
||||
}
|
||||
let state = game.world.block(pos[0], pos[1], pos[2]);
|
||||
let f = game.res.facts().get(state);
|
||||
let fluid = if f.fluid != Fluid::None {
|
||||
100.0f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
match resists {
|
||||
Some((at, resistance)) if at == pos => {
|
||||
power -= (resistance + 0.3) * 0.3;
|
||||
}
|
||||
_ if !game.res.facts().is_air(state) || f.fluid != Fluid::None => {
|
||||
let base = f.explosion_resistance.max(fluid);
|
||||
power -= (blast_resistance(base, None, false) + 0.3) * 0.3;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if power > 0.0 {
|
||||
set.put(Key(pos), ());
|
||||
}
|
||||
xp += xd * f64::from(0.3f32);
|
||||
yp += yd * f64::from(0.3f32);
|
||||
zp += zd * f64::from(0.3f32);
|
||||
power -= 0.225_000_01;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
set.entries().map(|(k, _)| k.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn rays_reusing_their_last_block_explode_what_each_step_did() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
let registry = &game.pack.assets.registry;
|
||||
let states: Vec<BlockStateId> = [
|
||||
"minecraft:stone",
|
||||
"minecraft:air",
|
||||
"minecraft:water",
|
||||
"minecraft:dirt",
|
||||
"minecraft:obsidian",
|
||||
"minecraft:air",
|
||||
"minecraft:oak_planks",
|
||||
"minecraft:glass",
|
||||
]
|
||||
.iter()
|
||||
.map(|n| registry.default_state(n).unwrap())
|
||||
.collect();
|
||||
// A jumble of blocks of every resistance around the blast, in the
|
||||
// loaded chunk at the origin.
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
for y in 56..74 {
|
||||
let i = ((x * 7 + y * 3 + z * 5) as usize) % states.len();
|
||||
game.world.set(x, y, z, states[i]).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let center = [8.3, 64.6, 7.9];
|
||||
for (radius, resists) in [(4.0, None), (6.0, None), (4.0, Some(([8, 64, 8], 1200.0)))] {
|
||||
game.world.random = horizon_core::random::LegacyRandom::new(7);
|
||||
let expected = exploded_positions_stepwise(&mut game, center, radius, resists);
|
||||
game.world.random = horizon_core::random::LegacyRandom::new(7);
|
||||
let got = game.exploded_positions(center, radius, resists);
|
||||
assert!(expected.len() > 20, "{radius}: {}", expected.len());
|
||||
assert_eq!(got, expected, "radius {radius}, {resists:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources; tools/test_explosion_items_parity.py"]
|
||||
fn explosion_items_parity_fixture() {
|
||||
|
||||
@@ -105,8 +105,10 @@ impl Game<'_> {
|
||||
);
|
||||
let on_ground = wanted.y < 0.0 && movement.y != wanted.y;
|
||||
f.on_ground = on_ground;
|
||||
if next.y < f.position.y {
|
||||
f.fall_distance += f.position.y - next.y;
|
||||
// Entity.checkFallDamage: each step down, narrowed to a float (a
|
||||
// falling block never runs baseTick, so it is never in water).
|
||||
if movement.y < 0.0 {
|
||||
f.fall_distance -= f64::from(movement.y as f32);
|
||||
}
|
||||
let from = f.position;
|
||||
f.position = next;
|
||||
|
||||
@@ -2176,6 +2176,7 @@ mod tests {
|
||||
#[test]
|
||||
fn reeling_an_item_requests_an_immediate_motion_update() {
|
||||
let mut item = ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -2191,6 +2192,7 @@ mod tests {
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
|
||||
@@ -37,8 +37,15 @@ const FISH: [&str; 4] = [
|
||||
];
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::fishing::medium_tests::a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium",
|
||||
a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium_in_own_process() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
|
||||
@@ -26,8 +26,15 @@ impl Hooks for Arc<Probe> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_changed_inventory_is_heard_once_with_its_41_slots() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::advancements::inventory_changed_tests::a_changed_inventory_is_heard_once_with_its_41_slots",
|
||||
a_changed_inventory_is_heard_once_with_its_41_slots_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn a_changed_inventory_is_heard_once_with_its_41_slots_in_own_process() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
|
||||
@@ -168,6 +168,7 @@ mod tests {
|
||||
fn item_at(x: f64, y: f64, z: f64) -> ItemEntity {
|
||||
let p = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
health: 5,
|
||||
@@ -190,6 +191,7 @@ mod tests {
|
||||
no_gravity: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,15 @@ impl Hooks for Arc<Probe> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::item_contact_tests::the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity",
|
||||
the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity_in_own_process() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
|
||||
@@ -82,7 +82,8 @@ impl Game<'_> {
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
let escape_speed = no_physics.then(|| self.world.random.next_float() * 0.2 + 0.1);
|
||||
// `moveTowardsClosestSpace` draws the speed from the item's own random.
|
||||
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
|
||||
let mut entered = Vec::new();
|
||||
let (alive, discarded) = self.tick_item_with_block_contacts(
|
||||
item,
|
||||
@@ -573,3 +574,47 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod escape_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_core::random::RandomSource;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `Entity.moveTowardsClosestSpace` takes an item's escape speed from the
|
||||
/// item's own random, the one its constructor drew from, and leaves the
|
||||
/// level's random as it was (#13043).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_item_escapes_a_block_at_its_own_randoms_speed() {
|
||||
let mut game = server_game();
|
||||
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
|
||||
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.entity_ticking_area()(8, 8));
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
game.world.set(8, 64, 8, stone).unwrap();
|
||||
let stack = ItemStack::new("minecraft:stone", 2);
|
||||
game.spawn_item(stack, [8.5, 64.4, 8.5], [0.0, 0.0, 0.0], 10);
|
||||
let item = game.dropped.last().unwrap();
|
||||
let expected = f64::from(item.random.clone().next_float() * 0.2 + 0.1);
|
||||
let mut level = game.world.random.clone();
|
||||
game.ticked_entities_through = u32::MAX;
|
||||
game.tick_items();
|
||||
let item = game.dropped.last().unwrap();
|
||||
// Up is the nearest open side from the middle of the block.
|
||||
// The push replaces the fall's velocity; then the 0.98 drag.
|
||||
assert_eq!(item.velocity.y, expected * 0.98);
|
||||
assert_eq!(game.world.random.next_float(), level.next_float());
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,15 @@ fn target(id: i32) -> Option<InteractTarget> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "private prepared resources; process-wide mods, run this test alone"]
|
||||
#[ignore = "private prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn actual_item_interact_mob_fallback_priority_and_filters() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::item_interact_mob_tests::actual_item_interact_mob_fallback_priority_and_filters",
|
||||
actual_item_interact_mob_fallback_priority_and_filters_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn actual_item_interact_mob_fallback_priority_and_filters_in_own_process() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
|
||||
@@ -35,8 +35,15 @@ fn porkchops(game: &Game<'_>) -> usize {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run alone with prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::kill_die_cancel_tests::a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once",
|
||||
a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once_in_own_process() {
|
||||
let probe = Arc::new(Probe::default());
|
||||
assert!(
|
||||
mod_api::MODS
|
||||
|
||||
@@ -485,10 +485,93 @@ impl Game<'_> {
|
||||
e.pitch = o.pitch;
|
||||
e.head_yaw = o.yaw;
|
||||
if let Some(tag) = self.persist.raw.get(&o.id) {
|
||||
self.lan_display_data(tag, hub, e);
|
||||
self.lan_display_data_kept(o.id, tag, hub, e);
|
||||
}
|
||||
}
|
||||
// Only this level's displays: the others are kept for their own level's pass.
|
||||
KEPT_DISPLAYS.with(|kept| {
|
||||
let mut kept = kept.borrow_mut();
|
||||
let Some(kept) = kept.get_mut(&self.dim) else {
|
||||
return;
|
||||
};
|
||||
if kept.len() > self.others.len() + 64 {
|
||||
let ids: horizon_client::fast_hash::FxHashSet<u32> =
|
||||
self.others.iter().map(|o| o.id).collect();
|
||||
kept.retain(|id, _| ids.contains(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
/// [`Self::lan_display_data`] for the display `id`, built again only when
|
||||
/// its compound changed: a server's holograms and menus are text displays
|
||||
/// whose text, item and transformation were parsed again every tick.
|
||||
fn lan_display_data_kept(&self, id: u32, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
|
||||
if !matches!(
|
||||
e.kind.as_str(),
|
||||
"minecraft:text_display" | "minecraft:block_display" | "minecraft:item_display"
|
||||
) {
|
||||
return self.lan_display_data(tag, hub, e);
|
||||
}
|
||||
// Block states are read through the pack's registry: a kept build is
|
||||
// only the same for the same one.
|
||||
let registry = std::ptr::from_ref(self.pack.assets.registry) as usize;
|
||||
KEPT_DISPLAYS.with(|kept| {
|
||||
let mut kept = kept.borrow_mut();
|
||||
let kept = kept.entry(self.dim).or_default();
|
||||
let same =
|
||||
|k: &KeptDisplay| k.registry == registry && k.kind == e.kind && k.tag == *tag;
|
||||
if let Some(k) = kept.get(&id).filter(|k| same(k)) {
|
||||
e.meta.clone_from(&k.meta);
|
||||
e.defaults.clone_from(&k.defaults);
|
||||
// The stack's ids are the hub's: asked each time, as a build does.
|
||||
if let Some((at, item)) = &k.item
|
||||
&& let Some(slot) = e.meta.get_mut(*at)
|
||||
{
|
||||
slot.2 = Value::Item(hub.stack(item));
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.lan_display_data(tag, hub, e);
|
||||
let item = (e.kind == "minecraft:item_display").then(|| {
|
||||
let at = e
|
||||
.meta
|
||||
.iter()
|
||||
.position(|m| m.0 == 23 && m.1 == serializer::ITEM_STACK);
|
||||
let item = tag
|
||||
.get("item")
|
||||
.and_then(horizon_client::save::item_from_nbt);
|
||||
(at.unwrap_or(usize::MAX), item)
|
||||
});
|
||||
kept.insert(
|
||||
id,
|
||||
KeptDisplay {
|
||||
registry,
|
||||
kind: e.kind.clone(),
|
||||
tag: tag.clone(),
|
||||
meta: e.meta.clone(),
|
||||
defaults: e.defaults.clone(),
|
||||
item,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A display's synched data as [`Game::lan_display_data`] built it from
|
||||
/// `tag`, kept for the display's id on the thread that tracks entities, per
|
||||
/// level: a server thread tracks each dimension's `others` in turn.
|
||||
struct KeptDisplay {
|
||||
registry: usize,
|
||||
kind: String,
|
||||
tag: Tag,
|
||||
meta: Vec<(u8, i32, Value)>,
|
||||
defaults: Vec<(u8, i32, Value)>,
|
||||
/// An item display's item and the index in `meta` its stack goes in.
|
||||
item: Option<(usize, Option<horizon_client::items::ItemStack>)>,
|
||||
}
|
||||
thread_local! {
|
||||
static KEPT_DISPLAYS: std::cell::RefCell<
|
||||
horizon_client::fast_hash::FxHashMap<super::Dim, horizon_client::fast_hash::FxHashMap<u32, KeptDisplay>>,
|
||||
> = Default::default();
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -935,6 +1018,85 @@ fn push_arrow_meta(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn kept_display_data_is_what_a_build_gives() {
|
||||
let game = crate::game::native_machines::tests::server_game();
|
||||
let sync = std::sync::Arc::new(horizon_client::lan::data::SyncData::empty());
|
||||
let net = horizon_client::lan::NetConfig {
|
||||
listen: false,
|
||||
..horizon_client::lan::NetConfig::loopback(0, String::new(), 2)
|
||||
};
|
||||
let lan = horizon_client::lan::Lan::start_with(sync.clone(), net).unwrap();
|
||||
let encoder = horizon_client::lan::chunks::ChunkEncoder::new(
|
||||
[] as [(std::sync::Arc<str>, bool); 0],
|
||||
&horizon_protocol::registry::Builtin::default(),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let config = horizon_client::lan::hub::HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 10,
|
||||
simulation_distance: 10,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 20,
|
||||
integrated: true,
|
||||
levels: Default::default(),
|
||||
};
|
||||
let mut hub = Hub::new(lan, sync, encoder, config);
|
||||
let snbt = |s: &str| horizon_client::command::snbt::parse_fully(s).unwrap();
|
||||
let displays: [(&str, Tag, Tag); 3] = [
|
||||
(
|
||||
"minecraft:text_display",
|
||||
snbt(r#"{text:{text:"Shop",color:"gold"},billboard:"center",line_width:120}"#),
|
||||
snbt(r#"{text:{text:"Sold out",bold:1b},billboard:"center",background:0}"#),
|
||||
),
|
||||
(
|
||||
"minecraft:block_display",
|
||||
snbt(r#"{block_state:{Name:"minecraft:oak_stairs",Properties:{facing:"east"}}}"#),
|
||||
snbt(r#"{block_state:{Name:"minecraft:glass"},brightness:{sky:15,block:3}}"#),
|
||||
),
|
||||
(
|
||||
"minecraft:item_display",
|
||||
snbt(r#"{item:{id:"minecraft:diamond",count:3},item_display:"gui"}"#),
|
||||
snbt(
|
||||
r#"{item:{id:"minecraft:apple",count:1},item_display:"head",glow_color_override:255}"#,
|
||||
),
|
||||
),
|
||||
];
|
||||
let state = |kind: &str| EntityState {
|
||||
kind: kind.into(),
|
||||
..Default::default()
|
||||
};
|
||||
for (id, (kind, first, second)) in displays.iter().enumerate() {
|
||||
let id = 900 + id as u32;
|
||||
// Ticks with the first compound, then the second, then the first
|
||||
// again: each answer is a fresh build's.
|
||||
for tag in [first, first, first, second, second, first] {
|
||||
let mut built = state(kind);
|
||||
game.lan_display_data(tag, &mut hub, &mut built);
|
||||
assert!(!built.meta.is_empty(), "{kind}");
|
||||
let mut kept = state(kind);
|
||||
game.lan_display_data_kept(id, tag, &mut hub, &mut kept);
|
||||
assert_eq!(
|
||||
format!("{:?}", kept.meta),
|
||||
format!("{:?}", built.meta),
|
||||
"{kind}"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{:?}", kept.defaults),
|
||||
format!("{:?}", built.defaults),
|
||||
"{kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summoned_ids_are_unique_and_survive_the_tracking_pass() {
|
||||
let mut next = 40;
|
||||
|
||||
@@ -12,12 +12,31 @@
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::lan::{hub::Hub, tracking};
|
||||
|
||||
/// Per level, each idle entity's place and the tracking pass it was last described in.
|
||||
/// Per level, the idle entities the last pass noted (`Kept`).
|
||||
#[derive(Default)]
|
||||
pub(super) struct IdleStates {
|
||||
levels: FxHashMap<String, FxHashMap<i32, (u32, Place)>>,
|
||||
levels: FxHashMap<String, Kept>,
|
||||
}
|
||||
|
||||
/// One level's idle entities as the last tracking pass noted them, in the
|
||||
/// order it met them, and that pass. The game meets its entities in the same
|
||||
/// order every tick (its lists keep their order), so a pass finds each one's
|
||||
/// note at or just past where it found the last one: no lookup by id. One it
|
||||
/// does not find there is described in full, which is always right.
|
||||
#[derive(Default)]
|
||||
struct Kept {
|
||||
pass: u32,
|
||||
ids: Vec<i32>,
|
||||
places: Vec<Place>,
|
||||
/// This pass's notes, which replace them when it finishes.
|
||||
next_ids: Vec<i32>,
|
||||
next_places: Vec<Place>,
|
||||
}
|
||||
|
||||
/// How many notes past the last one found a pass looks: entities that left
|
||||
/// the level since are skipped over.
|
||||
const LOOK_AHEAD: usize = 4;
|
||||
|
||||
/// Where an entity is: its position, rotation (yaw, pitch, head yaw), whether
|
||||
/// it is on the ground, and its velocity.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -30,16 +49,24 @@ pub(super) struct Place {
|
||||
|
||||
/// The idle entities of the level in place, for one pass.
|
||||
pub(super) struct IdlePass<'a> {
|
||||
kept: &'a mut FxHashMap<i32, (u32, Place)>,
|
||||
kept: &'a mut Kept,
|
||||
/// Whether the kept notes are the last pass's.
|
||||
last: bool,
|
||||
/// Where the next note is looked for.
|
||||
cursor: usize,
|
||||
/// The level's tracking passes so far (`Hub::tracking_ticks`). An entity
|
||||
/// described one pass earlier was in the tracker's last pass.
|
||||
/// noted one pass earlier was in the tracker's last pass.
|
||||
now: u32,
|
||||
/// The players the level's tracker runs for (`Hub::watches`: their
|
||||
/// index), where they are horizontally, and their view distance in blocks:
|
||||
/// the clients that could pair.
|
||||
/// the clients that could pair. Sorted by x, so an entity looks only at
|
||||
/// the ones within its range along x.
|
||||
viewers: Vec<(usize, [f64; 2], i32)>,
|
||||
/// `entity-broadcast-range-percentage`, as the tracker scales ranges.
|
||||
percent: i32,
|
||||
/// The last type asked about and its scaled tracking range: the
|
||||
/// entities of a type come in runs (a farm, a pile of items).
|
||||
last_kind: Option<(String, i32)>,
|
||||
}
|
||||
|
||||
impl IdleStates {
|
||||
@@ -51,16 +78,46 @@ impl IdleStates {
|
||||
.map(|(i, g)| (i, [g.position[0], g.position[2]], g.view_distance * 16))
|
||||
.collect();
|
||||
let kept = self.levels.entry(hub.config.dimension.clone()).or_default();
|
||||
Some(IdlePass {
|
||||
kept,
|
||||
now,
|
||||
viewers,
|
||||
percent,
|
||||
})
|
||||
Some(IdlePass::new(kept, now, viewers, percent))
|
||||
}
|
||||
}
|
||||
|
||||
impl IdlePass<'_> {
|
||||
impl<'a> IdlePass<'a> {
|
||||
fn new(
|
||||
kept: &'a mut Kept,
|
||||
now: u32,
|
||||
mut viewers: Vec<(usize, [f64; 2], i32)>,
|
||||
percent: i32,
|
||||
) -> Self {
|
||||
// A player somewhere not finite is within no range: the distance
|
||||
// test below fails for it.
|
||||
viewers.retain(|(_, [x, z], _)| x.is_finite() && z.is_finite());
|
||||
viewers.sort_by(|a, b| a.1[0].total_cmp(&b.1[0]));
|
||||
kept.next_ids.clear();
|
||||
kept.next_places.clear();
|
||||
IdlePass {
|
||||
last: kept.pass.wrapping_add(1) == now,
|
||||
kept,
|
||||
cursor: 0,
|
||||
now,
|
||||
viewers,
|
||||
percent,
|
||||
last_kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The type's tracking range in blocks, scaled as the tracker scales it.
|
||||
fn range(&mut self, kind: &str) -> i32 {
|
||||
match &mut self.last_kind {
|
||||
Some((last, range)) if last.as_str() == kind => *range,
|
||||
last => {
|
||||
let range = self.percent * (i32::from(tracking::settings(kind).0) * 16) / 100;
|
||||
*last = Some((kind.to_owned(), range));
|
||||
range
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the idle entity `id` of type `kind`, now at `at`, can be
|
||||
/// described bare: it was in the tracker's last pass at the same place,
|
||||
/// carries no passengers (whose ranges widen its own), and every client
|
||||
@@ -70,6 +127,16 @@ impl IdlePass<'_> {
|
||||
&& hub.riding.passengers(id).is_empty()
|
||||
}
|
||||
|
||||
/// The place the last pass noted for `id`, if it is found where the
|
||||
/// pass's order says it would be.
|
||||
fn noted(&mut self, id: i32) -> Option<Place> {
|
||||
let ids = &self.kept.ids;
|
||||
let end = ids.len().min(self.cursor + LOOK_AHEAD);
|
||||
let k = (self.cursor..end).find(|&k| ids[k] == id)?;
|
||||
self.cursor = k + 1;
|
||||
Some(self.kept.places[k])
|
||||
}
|
||||
|
||||
/// [`Self::bare`] but for the passengers, with whether the player `i`
|
||||
/// tracks the entity.
|
||||
fn unchanged(
|
||||
@@ -79,27 +146,36 @@ impl IdlePass<'_> {
|
||||
at: &Place,
|
||||
tracks: impl Fn(usize) -> bool,
|
||||
) -> bool {
|
||||
// A new one reads as noted two passes ago.
|
||||
let now = self.now;
|
||||
let noted = self.kept.entry(id).or_insert((now.wrapping_sub(2), *at));
|
||||
let still = noted.0.wrapping_add(1) == now && noted.1 == *at;
|
||||
*noted = (now, *at);
|
||||
if !still {
|
||||
let noted = if self.last { self.noted(id) } else { None };
|
||||
self.kept.next_ids.push(id);
|
||||
self.kept.next_places.push(*at);
|
||||
if noted != Some(*at) {
|
||||
return false;
|
||||
}
|
||||
// The tracker's `sees`, but for the chunk it also asks the client to have.
|
||||
let range = self.percent * (i32::from(tracking::settings(kind).0) * 16) / 100;
|
||||
!self.viewers.iter().any(|&(i, [x, z], view)| {
|
||||
let range = f64::from(range.min(view));
|
||||
let (dx, dz) = (x - at.position[0], z - at.position[2]);
|
||||
dx * dx + dz * dz <= range * range && !tracks(i)
|
||||
})
|
||||
let range = self.range(kind);
|
||||
// Only a client within `range` along x can be within it: a block
|
||||
// more spares the rounding of the squares.
|
||||
let reach = f64::from(range) + 1.0;
|
||||
let x = at.position[0];
|
||||
let from = self.viewers.partition_point(|v| v.1[0] < x - reach);
|
||||
!self.viewers[from..]
|
||||
.iter()
|
||||
.take_while(|v| v.1[0] <= x + reach)
|
||||
.any(|&(i, [vx, vz], view)| {
|
||||
let range = f64::from(range.min(view));
|
||||
let (dx, dz) = (vx - at.position[0], vz - at.position[2]);
|
||||
dx * dx + dz * dz <= range * range && !tracks(i)
|
||||
})
|
||||
}
|
||||
|
||||
/// Forget the entities this pass did not note: they left, or tick again.
|
||||
/// The notes of this pass replace the last one's: the entities it did not
|
||||
/// note left, or tick again.
|
||||
pub(super) fn finish(self) {
|
||||
let now = self.now;
|
||||
self.kept.retain(|_, (stamp, _)| *stamp == now);
|
||||
let kept = self.kept;
|
||||
std::mem::swap(&mut kept.ids, &mut kept.next_ids);
|
||||
std::mem::swap(&mut kept.places, &mut kept.next_places);
|
||||
kept.pass = self.now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,14 +192,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn pass(kept: &mut FxHashMap<i32, (u32, Place)>, now: u32) -> IdlePass<'_> {
|
||||
/// One pass `now` over `entities` (id, type, place), each answered with
|
||||
/// whether the player `i` tracks it; what each could be described as.
|
||||
fn pass(
|
||||
kept: &mut Kept,
|
||||
now: u32,
|
||||
entities: &[(i32, &str, Place)],
|
||||
tracks: impl Fn(i32, usize) -> bool,
|
||||
) -> Vec<bool> {
|
||||
// One client at the origin, one 400 blocks away; both see 10 chunks.
|
||||
IdlePass {
|
||||
kept,
|
||||
now,
|
||||
viewers: vec![(0, [0.0, 0.0], 160), (1, [400.0, 0.0], 160)],
|
||||
percent: 100,
|
||||
}
|
||||
let viewers = vec![(1, [400.0, 0.0], 160), (0, [0.0, 0.0], 160)];
|
||||
let mut pass = IdlePass::new(kept, now, viewers, 100);
|
||||
let bare = (entities.iter())
|
||||
.map(|(id, kind, at)| pass.unchanged(*id, kind, at, |i| tracks(*id, i)))
|
||||
.collect();
|
||||
pass.finish();
|
||||
bare
|
||||
}
|
||||
|
||||
/// An idle cow (tracked 10 chunks away) is described bare while it stays
|
||||
@@ -131,24 +215,54 @@ mod tests {
|
||||
/// there could pair with it, when it moved, and after a pass it was not in.
|
||||
#[test]
|
||||
fn an_idle_entity_is_bare_until_a_client_could_pair_or_it_moves() {
|
||||
let mut kept = FxHashMap::default();
|
||||
let cow = "minecraft:cow";
|
||||
let mut kept = Kept::default();
|
||||
let cow = |at| [(7, "minecraft:cow", at)];
|
||||
let at = place(100.0);
|
||||
// Its first pass describes it in full.
|
||||
assert!(!pass(&mut kept, 5).unchanged(7, cow, &at, |_| true));
|
||||
assert_eq!(pass(&mut kept, 5, &cow(at), |_, _| true), [false]);
|
||||
// Tracked by the client in range; the far one can't pair.
|
||||
let tracked = |i| i == 0;
|
||||
assert!(pass(&mut kept, 6).unchanged(7, cow, &at, tracked));
|
||||
let tracked = |_, i| i == 0;
|
||||
assert_eq!(pass(&mut kept, 6, &cow(at), tracked), [true]);
|
||||
// The client in range doesn't track it yet: it could pair.
|
||||
assert!(!pass(&mut kept, 7).unchanged(7, cow, &at, |_| false));
|
||||
assert_eq!(pass(&mut kept, 7, &cow(at), |_, _| false), [false]);
|
||||
// 100 blocks is past an item's 6 chunks: no client could pair.
|
||||
assert!(pass(&mut kept, 8).unchanged(7, "minecraft:item", &at, |_| false));
|
||||
let item = [(7, "minecraft:item", at)];
|
||||
assert_eq!(pass(&mut kept, 8, &item, |_, _| false), [true]);
|
||||
// It moved.
|
||||
assert!(!pass(&mut kept, 9).unchanged(7, cow, &place(100.5), tracked));
|
||||
assert_eq!(pass(&mut kept, 9, &cow(place(100.5)), tracked), [false]);
|
||||
// A pass went by without it.
|
||||
assert!(!pass(&mut kept, 11).unchanged(7, cow, &place(100.5), tracked));
|
||||
// Only what a pass noted stays.
|
||||
pass(&mut kept, 12).finish();
|
||||
assert!(kept.is_empty());
|
||||
assert_eq!(pass(&mut kept, 11, &cow(place(100.5)), tracked), [false]);
|
||||
// The client 400 blocks away is in range of a cow next to it.
|
||||
let far = [(7, "minecraft:cow", place(300.0))];
|
||||
pass(&mut kept, 12, &far, |_, _| true);
|
||||
assert_eq!(pass(&mut kept, 13, &far, |_, i| i == 0), [false]);
|
||||
assert_eq!(pass(&mut kept, 14, &far, |_, i| i == 1), [true]);
|
||||
}
|
||||
|
||||
/// Each entity's note is found in the last pass's order, past the ones
|
||||
/// that left since and after one that joined.
|
||||
#[test]
|
||||
fn notes_are_found_in_the_last_pass_order() {
|
||||
let mut kept = Kept::default();
|
||||
let item = |id| (id, "minecraft:item", place(1000.0 + f64::from(id)));
|
||||
let all = |ids: &[i32]| ids.iter().map(|&id| item(id)).collect::<Vec<_>>();
|
||||
let first = all(&(1..=20).collect::<Vec<_>>());
|
||||
pass(&mut kept, 1, &first, |_, _| false);
|
||||
// 2 to 4 left, 30 joined in the middle, 12 to 14 left.
|
||||
let ids = [1, 5, 6, 30, 7, 8, 9, 10, 11, 15, 16, 17, 18, 19, 20];
|
||||
let bare = pass(&mut kept, 2, &all(&ids), |_, _| false);
|
||||
let full: Vec<i32> = ids
|
||||
.iter()
|
||||
.zip(&bare)
|
||||
.filter(|(_, b)| !**b)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
assert_eq!(full, [30]);
|
||||
// One that moved out of order beyond the look-ahead is described in
|
||||
// full, which is always right, and the next pass finds it again.
|
||||
let moved = [20, 1, 5, 6, 7];
|
||||
let bare = pass(&mut kept, 3, &all(&moved), |_, _| false);
|
||||
assert_eq!(bare, [false, true, true, true, true]);
|
||||
assert_eq!(pass(&mut kept, 4, &all(&moved), |_, _| false), [true; 5]);
|
||||
}
|
||||
}
|
||||
@@ -1732,6 +1732,7 @@ mod item_column_tests {
|
||||
fn item(id: u32, x: f64, y: f64, z: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -1747,6 +1748,7 @@ mod item_column_tests {
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
fall_distance: 0.0,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
|
||||
@@ -731,8 +731,15 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "prepared resources in HORIZON_TEST_RESOURCES; run alone to isolate the mod registry"]
|
||||
#[ignore = "prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_standard_helpers_route_to_native_owned_storage() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::machines::native_fluids::tests::the_standard_helpers_route_to_native_owned_storage",
|
||||
the_standard_helpers_route_to_native_owned_storage_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn the_standard_helpers_route_to_native_owned_storage_in_own_process() {
|
||||
use crate::game::{Audio, Options, Progress, Settings, World, load_resources};
|
||||
use crate::mod_api::{
|
||||
self, CustomBlockFluidsEvent, CustomFluidDrainEvent, CustomFluidFillEvent,
|
||||
|
||||
@@ -1334,6 +1334,21 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// `LivingEntity.tick` ticks `EntityEquipment` (the armour slots) as
|
||||
// well as the inventory, so a compass worn there loses its target too.
|
||||
let gone = self.armor.each_ref().map(|stack| {
|
||||
stack
|
||||
.as_ref()
|
||||
.is_some_and(|s| lodestone_target_gone(self, s, dim))
|
||||
});
|
||||
for (stack, gone) in self.armor.iter_mut().zip(gone) {
|
||||
if gone && let Some(stack) = stack {
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:lodestone_tracker",
|
||||
Some(Tag::Compound(Default::default())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn give_or_drop_stack(&mut self, stack: ItemStack) {
|
||||
if let Some(rest) = self.give(stack)
|
||||
|
||||
@@ -24,8 +24,15 @@ impl Hooks for Arc<Probe> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mod_toast_queues_unless_a_mod_cancels_it() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::advancements::mod_toast_tests::a_mod_toast_queues_unless_a_mod_cancels_it",
|
||||
a_mod_toast_queues_unless_a_mod_cancels_it_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn a_mod_toast_queues_unless_a_mod_cancels_it_in_own_process() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
|
||||
@@ -655,13 +655,13 @@ impl Game<'_> {
|
||||
/// A saved dropped item.
|
||||
pub(super) fn load_item(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
|
||||
self.next_entity += 1;
|
||||
// `ItemEntity(EntityType, Level)` before the load: the item's own random
|
||||
// makes its UUID (kept unless the tag has one) and bobOffs, not the level's.
|
||||
// Its yaw, the next draw, is always replaced by the saved rotation.
|
||||
// `ItemEntity(EntityType, Level)` first: its own random draws UUID, bobOffs and yaw.
|
||||
let mut own = random_source::entity();
|
||||
let fresh = horizon_client::save::insecure_uuid(&mut own);
|
||||
let bob = own.next_float() * std::f32::consts::PI * 2.0;
|
||||
if let Some(e) = entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob) {
|
||||
own.next_float();
|
||||
if let Some(mut e) = entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob) {
|
||||
e.random = own;
|
||||
self.remember(e.id, Some(uuid.unwrap_or(fresh)), tag);
|
||||
self.dropped.push(e);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Needs private prepared resources: installs process-wide trait mods, so it runs in a process of its
|
||||
//! own (`in_own_process`) and passes whatever else runs beside it.
|
||||
//! own (`mod_api::in_own_process`) and passes whatever else runs beside it.
|
||||
use super::super::*;
|
||||
use crate::mod_api::{
|
||||
self, Hooks, Host, NativeRecipeCategory, NativeRecipeLookup, NativeStack,
|
||||
@@ -45,36 +45,11 @@ fn stack(item: &str) -> NativeStack {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `body` in a fresh process of this test binary: `mod_api::MODS` is set once per process, and
|
||||
/// another test that set it first would make this one's `set` fail. The parent starts the binary again
|
||||
/// for just this test (`--exact`, `--ignored`) and requires that exactly one test ran and passed; the
|
||||
/// child sees `OWN_PROCESS` and runs `body` itself.
|
||||
fn in_own_process(name: &str, body: impl FnOnce()) {
|
||||
const OWN_PROCESS: &str = "HORIZON_TEST_OWN_PROCESS";
|
||||
if std::env::var_os(OWN_PROCESS).is_some_and(|v| v == name) {
|
||||
return body();
|
||||
}
|
||||
let out = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([name, "--exact", "--ignored", "--test-threads=1"])
|
||||
.env(OWN_PROCESS, name)
|
||||
.output()
|
||||
.unwrap();
|
||||
let report = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
out.status.success() && report.contains("test result: ok. 1 passed;"),
|
||||
"{name} failed in its own process:\n{report}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn lookups_wait_for_the_next_frame_and_stop_at_the_viewer_that_takes_them() {
|
||||
const NAME: &str = "game::native_recipes::recipe_viewer_tests::lookups_wait_for_the_next_frame_and_stop_at_the_viewer_that_takes_them";
|
||||
in_own_process(NAME, lookups_wait_for_the_next_frame);
|
||||
mod_api::in_own_process(NAME, lookups_wait_for_the_next_frame);
|
||||
}
|
||||
|
||||
fn lookups_wait_for_the_next_frame() {
|
||||
|
||||
@@ -6256,32 +6256,11 @@ impl Game<'_> {
|
||||
};
|
||||
let at = Vec3::new(p[0], p[1], p[2]);
|
||||
// ItemEntity's constructor draws bobOffs from the item's own random.
|
||||
let bob_offset = horizon_client::mobs::entity_random(id as u32, at).next_float()
|
||||
* std::f32::consts::PI
|
||||
* 2.0;
|
||||
let mut random = horizon_client::mobs::entity_random(id as u32, at);
|
||||
let bob_offset = random.next_float() * std::f32::consts::PI * 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
thrower: None,
|
||||
target: None,
|
||||
id: id as u32,
|
||||
stack,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset,
|
||||
ticks: 0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
was_touching_water: false,
|
||||
random,
|
||||
..ItemEntity::new(id as u32, stack, at, velocity, bob_offset)
|
||||
});
|
||||
}
|
||||
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
|
||||
|
||||
@@ -167,6 +167,7 @@ impl Game<'_> {
|
||||
|| self.settings.extras.pvp.tweaks.clear_lava,
|
||||
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
|
||||
night_vision: self.fog_night_vision(),
|
||||
environment: self.pack.polytone_colors.env,
|
||||
};
|
||||
let mut fog = horizon_client::fog::setup_fog(tables, &self.fog, &input);
|
||||
if !self.settings.horizon.fog {
|
||||
|
||||
@@ -294,6 +294,7 @@ fn pack_particles(
|
||||
/// The packs' custom colours; their dye and sheep colours go to the draw paths' table.
|
||||
fn read_colors(root: &std::path::Path) -> horizon_client::polytone_colors::PackColors {
|
||||
let colors = horizon_client::polytone_colors::PackColors::read(root);
|
||||
horizon_client::pack_dyes::set(&colors);
|
||||
horizon_client::pack_palette::set(&colors);
|
||||
horizon_client::brewing::set_pack_colors(colors.effects());
|
||||
colors
|
||||
}
|
||||
@@ -952,7 +952,7 @@ pub struct Entities {
|
||||
/// moves. One the entities' own ticks added waits for the next pass, as
|
||||
/// vanilla's waits for the next tick.
|
||||
pub synced: Vec<(i32, i32)>,
|
||||
states: crate::fast_hash::FxHashMap<i32, ServerEntity>,
|
||||
states: slab::ServerEntities,
|
||||
/// The level tick, stamped on every entity still in the level.
|
||||
tick: u32,
|
||||
/// This tick's entities as the range checks read them, the chunks they
|
||||
@@ -1072,7 +1072,7 @@ impl Entities {
|
||||
self.states.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.states.is_empty()
|
||||
self.states.len() == 0
|
||||
}
|
||||
|
||||
/// How many passes the level's tracking has run.
|
||||
@@ -1160,12 +1160,29 @@ impl Entities {
|
||||
self.quiet.resize(entities.len(), false);
|
||||
// The states stamped this tick: when that is all of them, none left.
|
||||
let mut stamped = 0;
|
||||
self.states.begin();
|
||||
for (index, e) in entities.iter().enumerate() {
|
||||
let mut added = false;
|
||||
let s = self.states.entry(e.id).or_insert_with(|| {
|
||||
added = true;
|
||||
ServerEntity::new(e, kind(&e.kind), riding)
|
||||
});
|
||||
let s = match self.states.meet(e.id) {
|
||||
Some((slot, by_id)) => {
|
||||
let s = self.states.at(slot);
|
||||
// Found out of order: the next one is looked for after
|
||||
// where this one was in the last tick's order.
|
||||
if by_id && s.seen == now.wrapping_sub(1) {
|
||||
let last = s.index;
|
||||
self.states.resume_after(last);
|
||||
}
|
||||
self.states.at_mut(slot)
|
||||
}
|
||||
None => {
|
||||
added = true;
|
||||
let slot = self
|
||||
.states
|
||||
.insert(e.id, ServerEntity::new(e, kind(&e.kind), riding));
|
||||
self.states.met_new(e.id, slot);
|
||||
self.states.at_mut(slot)
|
||||
}
|
||||
};
|
||||
if s.seen != now {
|
||||
stamped += 1;
|
||||
}
|
||||
@@ -1217,6 +1234,7 @@ impl Entities {
|
||||
hidden: e.hidden,
|
||||
});
|
||||
}
|
||||
self.states.end();
|
||||
// TrackedEntity.getEffectiveRange: a vehicle is tracked as far as its
|
||||
// widest indirect passenger, so a rider never leaves before its mount.
|
||||
for (&vehicle, passengers) in &riding.passengers {
|
||||
@@ -1390,9 +1408,7 @@ impl Entities {
|
||||
if self.quiet[index] && !self.pairs[index] {
|
||||
continue;
|
||||
}
|
||||
let Some(s) = self.states.get_mut(&e.id) else {
|
||||
continue;
|
||||
};
|
||||
let s = self.states.at_mut(self.states.slot_of_index(index));
|
||||
self.packets.clear();
|
||||
// Paired as it joined, then its first changes go to everyone
|
||||
// tracking it, those just paired with it included. Arrows wait for the
|
||||
@@ -1466,13 +1482,14 @@ impl Entities {
|
||||
}
|
||||
// ChunkMap.removeEntity for the ones no longer in the level.
|
||||
if self.states.len() > stamped {
|
||||
self.states.retain(|_, s| s.seen == now);
|
||||
self.states.retain(|s| s.seen == now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod late_pair_tests;
|
||||
mod slab;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -42,7 +42,7 @@ fn late_pairing_does_not_duplicate_or_spend_the_next_arrow_update() {
|
||||
let mut e = trident();
|
||||
level.pair_after_tick(std::slice::from_ref(&e), &riding, &mut viewers, |_| Some(0));
|
||||
assert_eq!(level.tick, 1);
|
||||
assert_eq!(level.states[&37].tick, 0);
|
||||
assert_eq!(level.states.get(&37).unwrap().tick, 0);
|
||||
assert!(viewers[0].tracker.is_tracking(37));
|
||||
assert_eq!(
|
||||
viewers[0]
|
||||
@@ -57,14 +57,14 @@ fn late_pairing_does_not_duplicate_or_spend_the_next_arrow_update() {
|
||||
level.pair_after_tick(std::slice::from_ref(&e), &riding, &mut viewers, |_| Some(0));
|
||||
assert!(viewers[0].out.is_empty());
|
||||
assert!(viewers[0].tracker.take_events().is_empty());
|
||||
assert_eq!(level.states[&37].tick, 0);
|
||||
assert_eq!(level.states.get(&37).unwrap().tick, 0);
|
||||
// It hits a block between its introduction and the next tracking pass.
|
||||
e.position[0] += 0.25;
|
||||
e.velocity = [0.0; 3];
|
||||
e.meta.push((10, serializer::BOOLEAN, Value::Bool(true)));
|
||||
level.tick(&[&e], &riding, &mut viewers, |_| Some(0));
|
||||
assert_eq!(level.tick, 2);
|
||||
assert_eq!(level.states[&37].tick, 1);
|
||||
assert_eq!(level.states.get(&37).unwrap().tick, 1);
|
||||
assert!(
|
||||
!viewers[0]
|
||||
.out
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! A level's `ServerEntity`s by id (`ChunkMap.entityMap`), found without hashing on most ticks.
|
||||
//!
|
||||
//! A level tracks tens of thousands of entities, most of them items and mobs frozen past the
|
||||
//! simulation distance, and looked each one up by id on every tick: one probe of a large table
|
||||
//! for each, missing the cache nearly every time. The game describes its entities in the same
|
||||
//! order every tick (its lists keep their order), so each tick notes the order it met them in,
|
||||
//! and the next one finds each entity at or just past where it found the one before. Only an
|
||||
//! entity that joined, or one met out of order, is looked up by id.
|
||||
|
||||
use super::ServerEntity;
|
||||
use crate::fast_hash::FxHashMap;
|
||||
|
||||
/// How many entries past the last one found an entity is looked for: the entities that left
|
||||
/// the level since are skipped over.
|
||||
const LOOK_AHEAD: usize = 4;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ServerEntities {
|
||||
/// The entities and their ids; None in a slot that is free.
|
||||
slots: Vec<Option<(i32, ServerEntity)>>,
|
||||
free: Vec<u32>,
|
||||
by_id: FxHashMap<i32, u32>,
|
||||
/// The last tick's entities in the order it met them (id, slot), this tick's so far, and
|
||||
/// where the next one is looked for.
|
||||
order: Vec<(i32, u32)>,
|
||||
next_order: Vec<(i32, u32)>,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl ServerEntities {
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.by_id.len()
|
||||
}
|
||||
|
||||
pub(super) fn contains_key(&self, id: &i32) -> bool {
|
||||
self.by_id.contains_key(id)
|
||||
}
|
||||
|
||||
pub(super) fn get(&self, id: &i32) -> Option<&ServerEntity> {
|
||||
self.by_id.get(id).map(|&slot| self.at(slot))
|
||||
}
|
||||
|
||||
pub(super) fn at(&self, slot: u32) -> &ServerEntity {
|
||||
&self.slots[slot as usize].as_ref().expect("a live slot").1
|
||||
}
|
||||
|
||||
pub(super) fn at_mut(&mut self, slot: u32) -> &mut ServerEntity {
|
||||
&mut self.slots[slot as usize].as_mut().expect("a live slot").1
|
||||
}
|
||||
|
||||
/// Adds the entity `id` (not tracked yet), returning its slot.
|
||||
pub(super) fn insert(&mut self, id: i32, s: ServerEntity) -> u32 {
|
||||
debug_assert!(!self.by_id.contains_key(&id));
|
||||
let slot = match self.free.pop() {
|
||||
Some(slot) => {
|
||||
self.slots[slot as usize] = Some((id, s));
|
||||
slot
|
||||
}
|
||||
None => {
|
||||
self.slots.push(Some((id, s)));
|
||||
(self.slots.len() - 1) as u32
|
||||
}
|
||||
};
|
||||
self.by_id.insert(id, slot);
|
||||
slot
|
||||
}
|
||||
|
||||
/// Starts meeting a tick's entities in order.
|
||||
pub(super) fn begin(&mut self) {
|
||||
self.cursor = 0;
|
||||
self.next_order.clear();
|
||||
}
|
||||
|
||||
/// The slot of the entity `id`, met next in this tick's order, and whether it was found by
|
||||
/// id (not where the last tick's order had it). Notes it in this tick's order.
|
||||
pub(super) fn meet(&mut self, id: i32) -> Option<(u32, bool)> {
|
||||
let end = self.order.len().min(self.cursor + LOOK_AHEAD);
|
||||
let found = match (self.cursor..end).find(|&k| self.order[k].0 == id && self.holds(k, id)) {
|
||||
Some(k) => {
|
||||
self.cursor = k + 1;
|
||||
Some((self.order[k].1, false))
|
||||
}
|
||||
None => self.by_id.get(&id).map(|&slot| (slot, true)),
|
||||
};
|
||||
if let Some((slot, _)) = found {
|
||||
self.next_order.push((id, slot));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Whether the last tick's `k`-th slot still holds the entity `id`.
|
||||
fn holds(&self, k: usize, id: i32) -> bool {
|
||||
(self.slots[self.order[k].1 as usize].as_ref()).is_some_and(|(held, _)| *held == id)
|
||||
}
|
||||
|
||||
/// Notes an entity met this tick that was not tracked before it.
|
||||
pub(super) fn met_new(&mut self, id: i32, slot: u32) {
|
||||
self.next_order.push((id, slot));
|
||||
}
|
||||
|
||||
/// The next entity is looked for after the last tick's `index`-th: where the one just
|
||||
/// found by id was then.
|
||||
pub(super) fn resume_after(&mut self, index: usize) {
|
||||
self.cursor = index + 1;
|
||||
}
|
||||
|
||||
/// This tick's order is the one the next tick looks in; the slot of the tick's
|
||||
/// `index`-th entity, until then.
|
||||
pub(super) fn end(&mut self) {
|
||||
std::mem::swap(&mut self.order, &mut self.next_order);
|
||||
}
|
||||
|
||||
pub(super) fn slot_of_index(&self, index: usize) -> u32 {
|
||||
self.order[index].1
|
||||
}
|
||||
|
||||
/// Keeps only the entities `keep` says to.
|
||||
pub(super) fn retain(&mut self, mut keep: impl FnMut(&ServerEntity) -> bool) {
|
||||
for (slot, entry) in self.slots.iter_mut().enumerate() {
|
||||
if let Some((id, s)) = entry
|
||||
&& !keep(s)
|
||||
{
|
||||
self.by_id.remove(id);
|
||||
*entry = None;
|
||||
self.free.push(slot as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{EntityState, Riding};
|
||||
use super::*;
|
||||
|
||||
fn entity(id: i32) -> ServerEntity {
|
||||
let e = EntityState {
|
||||
id,
|
||||
kind: "minecraft:item".into(),
|
||||
..Default::default()
|
||||
};
|
||||
ServerEntity::new(&e, Some(1), &Riding::default())
|
||||
}
|
||||
|
||||
/// One tick meeting `ids` in order: each one's slot and whether it was
|
||||
/// found by id; the new ones are added.
|
||||
fn tick(all: &mut ServerEntities, ids: &[i32]) -> Vec<(u32, bool)> {
|
||||
all.begin();
|
||||
let met = (ids.iter())
|
||||
.map(|&id| {
|
||||
all.meet(id).unwrap_or_else(|| {
|
||||
let slot = all.insert(id, entity(id));
|
||||
all.met_new(id, slot);
|
||||
(slot, true)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
all.end();
|
||||
met
|
||||
}
|
||||
|
||||
/// The entities met in last tick's order are found there, past the ones
|
||||
/// that left; the rest by id, each in its own slot, freed ones reused.
|
||||
#[test]
|
||||
fn entities_are_found_in_last_ticks_order_or_by_id() {
|
||||
let mut all = ServerEntities::default();
|
||||
let first = tick(&mut all, &[10, 11, 12, 13, 14, 15]);
|
||||
assert_eq!(
|
||||
first.iter().map(|m| m.0).collect::<Vec<_>>(),
|
||||
[0, 1, 2, 3, 4, 5]
|
||||
);
|
||||
// 11 and 12 left; 20 joined between 13 and 14.
|
||||
let met = tick(&mut all, &[10, 13, 20, 14, 15]);
|
||||
assert_eq!(
|
||||
met,
|
||||
[(0, false), (3, false), (6, true), (4, false), (5, false)]
|
||||
);
|
||||
// 15 out of order is found by id; the next ones in order again.
|
||||
let met = tick(&mut all, &[15, 10, 20]);
|
||||
assert_eq!(met, [(5, true), (0, false), (6, false)]);
|
||||
// Only those three are left in the level: the others' slots go to
|
||||
// the next ones to join.
|
||||
for (slot, _) in &met {
|
||||
all.at_mut(*slot).seen = 1;
|
||||
}
|
||||
all.retain(|s| s.seen == 1);
|
||||
assert_eq!(all.len(), 3);
|
||||
assert!(!all.contains_key(&13) && all.get(&20).is_some());
|
||||
let met = tick(&mut all, &[15, 10, 20, 30, 31, 32]);
|
||||
let slots: Vec<u32> = met.iter().map(|m| m.0).collect();
|
||||
assert_eq!(slots, [5, 0, 6, 4, 3, 2]);
|
||||
assert_eq!(all.slot_of_index(4), 3);
|
||||
}
|
||||
}
|
||||
@@ -3318,7 +3318,7 @@ impl Hub {
|
||||
continue;
|
||||
};
|
||||
let alpha = if ambient { 38 } else { 255 };
|
||||
let argb = (alpha << 24) | def.color as i32;
|
||||
let argb = (alpha << 24) | def.shown_color() as i32;
|
||||
("entity_effect", Some(ParticleData::Color(argb)))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1173,7 +1173,7 @@ pub struct Extra {
|
||||
pub sitting: bool,
|
||||
/// `Parrot.partyParrot` and `jukebox`: the playing jukebox it dances to.
|
||||
pub party_jukebox: Option<[i32; 3]>,
|
||||
/// The owner's UUID (tamed animals).
|
||||
/// The owner's UUID: a tamed animal's, or the reference a vex was loaded with.
|
||||
pub owner: Option<[i32; 4]>,
|
||||
/// `Vindicator.isJohnny`: a custom name that was ever `Johnny`, which
|
||||
/// `setCustomName` latches and nothing clears.
|
||||
|
||||
@@ -510,9 +510,6 @@ pub fn base_value(m: &Mob, a: Attr) -> f64 {
|
||||
/// [`base_value`] with the type's default given (`default_value`), for a
|
||||
/// caller that already looked it up for the mob's type.
|
||||
pub fn base_value_with(m: &Mob, a: Attr, default: impl FnOnce() -> Option<f64>) -> f64 {
|
||||
if a == Attr::MovementSpeed && m.species() == Species::Ravager {
|
||||
return m.attrs.speed;
|
||||
}
|
||||
m.extra
|
||||
.attributes
|
||||
.as_ref()
|
||||
@@ -532,8 +529,7 @@ pub fn base_value_with(m: &Mob, a: Attr, default: impl FnOnce() -> Option<f64>)
|
||||
/// growing up) is saved apart; these are the rest:
|
||||
/// - `movement_speed`: Zombie and Piglin.setBaby (the load calls it even for an
|
||||
/// adult), `Enderman.setTarget` (the load ends in `readPersistentAngerSaveData`,
|
||||
/// which calls it), a Ravager whose load leaves it immobile, and the first tick of
|
||||
/// any mob (`LivingEntity.removeFrost`).
|
||||
/// which calls it), and the first tick of any mob (`LivingEntity.removeFrost`).
|
||||
/// - `max_health`: `Wolf.applyTamingSideEffects`, through `setTame(false, true)`
|
||||
/// when the load finds no owner (a wolf that is tamed in game makes it too).
|
||||
/// - `attack_damage`: `Rabbit.setVariant`, and a baby Goat or Hoglin, whose load's
|
||||
@@ -541,11 +537,7 @@ pub fn base_value_with(m: &Mob, a: Attr, default: impl FnOnce() -> Option<f64>)
|
||||
pub fn made_at_load(m: &Mob) -> Vec<Attr> {
|
||||
let s = m.species();
|
||||
let mut made = Vec::new();
|
||||
if super::doors::is_zombie(s)
|
||||
|| matches!(s, Species::Piglin | Species::Enderman)
|
||||
|| (s == Species::Ravager && super::ravager::immobile(m))
|
||||
|| m.age > 0
|
||||
{
|
||||
if super::doors::is_zombie(s) || matches!(s, Species::Piglin | Species::Enderman) || m.age > 0 {
|
||||
made.push(Attr::MovementSpeed);
|
||||
}
|
||||
if s == Species::Wolf {
|
||||
|
||||
@@ -95,18 +95,16 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if !m.alive() {
|
||||
return;
|
||||
}
|
||||
let max = if immobile(m) {
|
||||
0.0
|
||||
} else if m.ai.target.is_some() {
|
||||
0.35
|
||||
} else {
|
||||
0.3
|
||||
};
|
||||
m.attrs.speed = if max == 0.0 {
|
||||
// The speed lives in the attribute's base, which the save writes and the
|
||||
// load restores (AttributeMap.pack and apply).
|
||||
let speed = if immobile(m) {
|
||||
0.0
|
||||
} else {
|
||||
m.attrs.speed + (max - m.attrs.speed) * 0.1
|
||||
let max = if m.ai.target.is_some() { 0.35 } else { 0.3 };
|
||||
let base = super::finalize::base_value(m, super::finalize::Attr::MovementSpeed);
|
||||
base + (max - base) * 0.1
|
||||
};
|
||||
super::finalize::set_base(m, super::finalize::Attr::MovementSpeed, speed);
|
||||
if m.horizontal_collision && w.mob_griefing {
|
||||
let b = m
|
||||
.bounds()
|
||||
|
||||
@@ -152,7 +152,7 @@ pub fn armor_tint(undyed: Option<u32>, stack: Option<&crate::items::ItemStack>)
|
||||
.unwrap_or(undyed);
|
||||
abgr(dyed)
|
||||
}
|
||||
pub use crate::pack_dyes::{diffuse as dye, sheep_wool as sheep_wool_rgb};
|
||||
pub use crate::pack_palette::{diffuse as dye, sheep_wool as sheep_wool_rgb};
|
||||
/// `ColorLerper.getLerpedColor(ColorLerper.Type.SHEEP, ageInTicks)`: the 16
|
||||
/// wool colours in DyeColor order, one per 25 ticks, lerped in between. The
|
||||
/// colour a `jeb_` sheep's wool takes.
|
||||
|
||||
@@ -143,6 +143,21 @@ impl Goal for RandomMove {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Vex.getOwner`: the mob the vex is bound to. `counter2` holds its id while
|
||||
/// the evoker that summoned it is in the level; a vex loaded from a save knows
|
||||
/// only the UUID it was saved with, and vanilla's `EntityReference.getEntity`
|
||||
/// asks the level for that entity every time it is called
|
||||
/// (`MobWorld::who_by_uuid`), so an owner that loaded later is found too.
|
||||
pub fn owner(m: &Mob, lookup: Option<&dyn super::WhoLookup>) -> Option<u32> {
|
||||
if m.extra.counter2 != 0 {
|
||||
return Some(m.extra.counter2 as u32);
|
||||
}
|
||||
match lookup?.who_by_uuid(m.extra.owner?)? {
|
||||
Who::Mob(id) => Some(id),
|
||||
// `Vex.setOwner` takes a `Mob`: a UUID naming the player is no owner.
|
||||
Who::Player => None,
|
||||
}
|
||||
}
|
||||
/// `VexCopyOwnerTargetGoal`: the evoker's target.
|
||||
#[derive(Clone, Debug)]
|
||||
struct CopyOwnerTarget;
|
||||
@@ -152,26 +167,24 @@ impl Goal for CopyOwnerTarget {
|
||||
super::goals::TARGET
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
let owner = m.extra.counter2 as u32;
|
||||
owner != 0
|
||||
&& cx
|
||||
.w
|
||||
.others
|
||||
let seen = owner(m, cx.w.who_lookup).and_then(|owner| {
|
||||
cx.w.others
|
||||
.iter()
|
||||
.find(|s| s.id == owner)
|
||||
.and_then(|s| s.target)
|
||||
.is_some_and(|t| t != Who::Mob(m.id))
|
||||
});
|
||||
seen.is_some_and(|t| t != Who::Mob(m.id))
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
m.target_info(cx.w).is_some_and(|t| t.alive)
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
let owner = m.extra.counter2 as u32;
|
||||
m.ai.target =
|
||||
m.ai.target = owner(m, cx.w.who_lookup).and_then(|owner| {
|
||||
cx.w.others
|
||||
.iter()
|
||||
.find(|s| s.id == owner)
|
||||
.and_then(|s| s.target);
|
||||
.and_then(|s| s.target)
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `VexMoveControl`: accelerate straight at the wanted point.
|
||||
@@ -275,3 +288,149 @@ pub fn texture(m: &Mob) -> &'static str {
|
||||
"entity/illager/vex"
|
||||
}
|
||||
}
|
||||
|
||||
/// `Vex.addAdditionalSaveData`: the bound origin, the limited life and the
|
||||
/// owner reference's UUID (`EntityReference.store`, left out when there is no
|
||||
/// owner). The level answers for an owner still in it; a reference the vex was
|
||||
/// loaded with stands when the owner is gone.
|
||||
pub fn write_nbt(
|
||||
m: &Mob,
|
||||
map: &mut horizon_world::nbt::Compound,
|
||||
uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>,
|
||||
) {
|
||||
use horizon_world::nbt::Tag;
|
||||
if let Some(h) = m.extra.home {
|
||||
map.insert("bound_pos".into(), Tag::IntArray(h.to_vec()));
|
||||
}
|
||||
if m.extra.egg_time > 0 {
|
||||
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
let resolved = if m.extra.counter2 != 0 {
|
||||
crate::entity_nbt::who_uuid(Who::Mob(m.extra.counter2 as u32), uuid_of)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match resolved.or(m.extra.owner) {
|
||||
Some(uuid) => {
|
||||
map.insert("owner".into(), Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
None => {
|
||||
map.remove(&"owner".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Vex.readAdditionalSaveData`: the bound origin, the life left (-1 when
|
||||
/// absent) and the owner's UUID, kept as the UUID it is - an id would name
|
||||
/// whatever mob has it now - and resolved through the level when the vex asks
|
||||
/// for its owner (`owner`).
|
||||
pub fn read_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
|
||||
use horizon_world::nbt::Tag;
|
||||
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
|
||||
&& v.len() == 3
|
||||
{
|
||||
m.extra.home = Some([v[0], v[1], v[2]]);
|
||||
}
|
||||
m.extra.egg_time = crate::save::number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("owner"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod owner_save_tests {
|
||||
use super::*;
|
||||
use crate::entity_nbt::{mob_from_nbt, mob_to_nbt};
|
||||
use crate::mobs::WhoLookup;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A vex with the id `EVOKER` in the level: what `Vex.setOwner` records
|
||||
/// when an evoker summons it.
|
||||
const EVOKER: u32 = 7;
|
||||
|
||||
fn vex() -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = "minecraft:vex".into();
|
||||
ty.species = Species::Vex;
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// `Level.getEntity(uuid)` for the test: `Game` answers this with
|
||||
/// `Game::who_by_uuid`.
|
||||
struct Level(HashMap<[i32; 4], Who>);
|
||||
impl WhoLookup for Level {
|
||||
fn who_by_uuid(&self, uuid: [i32; 4]) -> Option<Who> {
|
||||
self.0.get(&uuid).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Vex.addAdditionalSaveData` stores its owner reference's UUID
|
||||
/// (`EntityReference.store`, nothing at all without an owner) and
|
||||
/// `readAdditionalSaveData` reads it back (`EntityReference.read`), so a
|
||||
/// summoned vex keeps the evoker whose target it copies across a chunk
|
||||
/// save. Horizon models the owner as the owner's mob id and wrote neither,
|
||||
/// so a reloaded vex had no owner to copy a target from.
|
||||
#[test]
|
||||
fn a_vexs_owner_uuid_survives_the_chunk_save() {
|
||||
let evoker_uuid = [3, 1, 4, 1];
|
||||
let mut summoned = vex();
|
||||
assert_eq!(owner(&summoned, None), None, "a vex nobody summoned");
|
||||
summoned.extra.counter2 = EVOKER as i32;
|
||||
assert_eq!(owner(&summoned, None), Some(EVOKER), "the evoker's id");
|
||||
let uuid_of = |id: u32| (id == EVOKER).then_some(evoker_uuid);
|
||||
let saved = mob_to_nbt(&summoned, [0; 4], None, None, uuid_of);
|
||||
assert_eq!(
|
||||
saved.get("owner"),
|
||||
Some(&Tag::IntArray(evoker_uuid.to_vec()))
|
||||
);
|
||||
// The loaded vex knows the UUID: no id survives a save, and its owner
|
||||
// is found again only through the level (`EntityReference.getEntity`).
|
||||
let types = [("minecraft:vex".into(), summoned.kind.clone())].into();
|
||||
let back = mob_from_nbt(&saved, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.owner, Some(evoker_uuid));
|
||||
assert_eq!(back.extra.counter2, 0);
|
||||
let level = Level(HashMap::from([(evoker_uuid, Who::Mob(42))]));
|
||||
assert_eq!(owner(&back, Some(&level)), Some(42), "the evoker, found");
|
||||
// An owner the level does not have, and a UUID naming the player, are
|
||||
// no owner: `Vex.setOwner` takes a `Mob`.
|
||||
assert_eq!(owner(&back, Some(&Level(HashMap::new()))), None);
|
||||
let player = Level(HashMap::from([(evoker_uuid, Who::Player)]));
|
||||
assert_eq!(owner(&back, Some(&player)), None);
|
||||
assert_eq!(owner(&back, None), None, "a level with no UUIDs");
|
||||
// A save read again keeps the reference even when nothing in the level
|
||||
// answers for it, as `EntityReference` does.
|
||||
let again = mob_to_nbt(&back, [0; 4], None, None, |_| None);
|
||||
assert_eq!(
|
||||
again.get("owner"),
|
||||
Some(&Tag::IntArray(evoker_uuid.to_vec()))
|
||||
);
|
||||
}
|
||||
|
||||
/// `EntityReference.store` writes nothing for a vex with no owner, and a
|
||||
/// save that names none loads with none - neither the `owner` key a loaded
|
||||
/// compound carried nor an id left over may stand.
|
||||
#[test]
|
||||
fn a_vex_with_no_owner_saves_no_owner_key() {
|
||||
let mut loose = vex();
|
||||
let stale = Tag::compound([
|
||||
("id", Tag::String("minecraft:vex".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.5_f64.to_bits()),
|
||||
Tag::Double(64.0_f64.to_bits()),
|
||||
Tag::Double(0.5_f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("owner", Tag::IntArray(vec![9, 9, 9, 9])),
|
||||
]);
|
||||
let types = [("minecraft:vex".into(), loose.kind.clone())].into();
|
||||
let loaded = mob_from_nbt(&stale, &types, 3, None).unwrap();
|
||||
assert_eq!(loaded.extra.owner, Some([9, 9, 9, 9]));
|
||||
loose.extra.owner = None;
|
||||
loose.extra.counter2 = 0;
|
||||
let saved = mob_to_nbt(&loose, [0; 4], Some(&stale), None, |_| None);
|
||||
assert_eq!(saved.get("owner"), None, "no owner, no key");
|
||||
}
|
||||
}
|
||||
@@ -1221,6 +1221,32 @@ pub use generated::Hooks;
|
||||
#[doc(hidden)]
|
||||
pub use generated::*;
|
||||
|
||||
/// Runs `body` in a fresh process of this test binary: `mod_api::MODS` is set once per process, and
|
||||
/// another test that set it first would make this one's `set` fail. The parent starts the binary again
|
||||
/// for just this test (`--exact`, `--include-ignored`) and requires that exactly one test ran and passed; the
|
||||
/// child sees `OWN_PROCESS` and runs `body` itself.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn in_own_process(name: &str, body: impl FnOnce()) {
|
||||
const OWN_PROCESS: &str = "HORIZON_TEST_OWN_PROCESS";
|
||||
if std::env::var_os(OWN_PROCESS).is_some_and(|v| v == name) {
|
||||
return body();
|
||||
}
|
||||
let out = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([name, "--exact", "--include-ignored", "--test-threads=1"])
|
||||
.env(OWN_PROCESS, name)
|
||||
.output()
|
||||
.unwrap();
|
||||
let report = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
out.status.success() && report.contains("test result: ok. 1 passed;"),
|
||||
"{name} failed in its own process:\n{report}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1233,6 +1259,13 @@ mod tests {
|
||||
/// turned on; a test's own list is never switched.
|
||||
#[test]
|
||||
fn a_mod_turned_off_is_skipped_until_turned_on() {
|
||||
crate::mod_api::in_own_process(
|
||||
"mod_api::tests::a_mod_turned_off_is_skipped_until_turned_on",
|
||||
a_mod_turned_off_is_skipped_until_turned_on_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn a_mod_turned_off_is_skipped_until_turned_on_in_own_process() {
|
||||
name_mods(&["quiet", "broken"]);
|
||||
let _ = MODS.set(Arc::from(vec![
|
||||
Box::new(Quiet) as Box<dyn Hooks>,
|
||||
|
||||
@@ -255,6 +255,9 @@ pub struct Projectile {
|
||||
pub air_supply: i32,
|
||||
/// `Entity.hasVisualFire`: an optional saved flag independent of burning.
|
||||
pub has_visual_fire: bool,
|
||||
/// `Entity.fallDistance` (`fall_distance` in its save): a projectile sets
|
||||
/// its position without `Entity.move`, so it keeps what it was given.
|
||||
pub fall_distance: f64,
|
||||
/// `Entity.DATA_SHARED_FLAGS_ID`'s fire bit, set by the server's base tick.
|
||||
pub synced_fire: bool,
|
||||
/// `Fireball.DATA_ITEM_STACK`: the item a fireball is drawn as, a fire
|
||||
@@ -473,6 +476,7 @@ impl Projectile {
|
||||
fire_ticks: 0,
|
||||
air_supply: 300,
|
||||
has_visual_fire: false,
|
||||
fall_distance: 0.0,
|
||||
synced_fire: false,
|
||||
item,
|
||||
arrow: kind_is_arrow.then(|| {
|
||||
@@ -675,7 +679,7 @@ impl Projectile {
|
||||
return -1;
|
||||
}
|
||||
let rgb = match (effect, &self.arrow) {
|
||||
(Some((id, _)), _) => crate::brewing::effect(id).map(|e| e.color),
|
||||
(Some((id, _)), _) => crate::brewing::effect(id).map(|e| e.shown_color()),
|
||||
(None, Some(a)) if &*a.pickup_item.item == "minecraft:tipped_arrow" => a
|
||||
.pickup_item
|
||||
.potion()
|
||||
|
||||
@@ -8,10 +8,41 @@ use std::path::Path;
|
||||
|
||||
use serde_json::Value as Json;
|
||||
|
||||
/// The `environment` fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Environment {
|
||||
/// `powder_snow_fog`: the fog colour inside powder snow (RGB).
|
||||
pub powder_snow_fog: Option<u32>,
|
||||
/// `blindness_fog_distance`, `darkness_fog_distance`, `boss_fog_distance`: the fog ends of
|
||||
/// Blindness (5), Darkness (15) and bosses that add world fog (96).
|
||||
pub blindness_fog_distance: f32,
|
||||
pub darkness_fog_distance: f32,
|
||||
pub boss_fog_distance: f32,
|
||||
/// `water_fog_brightening`: how much the underwater fog colour washes out (1 vanilla, 0 none).
|
||||
pub water_fog_brightening: f32,
|
||||
/// `water_fog_brightening_time`: the ticks under water it takes (vanilla's 600).
|
||||
pub water_fog_brightening_time: f32,
|
||||
}
|
||||
|
||||
impl Default for Environment {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
powder_snow_fog: None,
|
||||
blindness_fog_distance: 5.0,
|
||||
darkness_fog_distance: 15.0,
|
||||
boss_fog_distance: 96.0,
|
||||
water_fog_brightening: 1.0,
|
||||
water_fog_brightening_time: 600.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pack's custom colours, by dotted key.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PackColors {
|
||||
entries: BTreeMap<String, Json>,
|
||||
/// [`Self::environment`], read once with the packs: the fog reads it every frame.
|
||||
pub env: Environment,
|
||||
}
|
||||
|
||||
impl PackColors {
|
||||
@@ -38,6 +69,7 @@ impl PackColors {
|
||||
}
|
||||
}
|
||||
}
|
||||
colors.env = colors.environment();
|
||||
colors
|
||||
}
|
||||
|
||||
@@ -93,6 +125,91 @@ impl PackColors {
|
||||
self.color(&format!("sheep.{name}"))
|
||||
}
|
||||
|
||||
/// `redstone.<power>`: the wire's and its dust particles' colour at that power (0 to 15).
|
||||
pub fn redstone(&self, power: u8) -> Option<u32> {
|
||||
self.color(&format!("redstone.{power}"))
|
||||
}
|
||||
|
||||
/// `effect.<id>` or `effect.<id>.color` (the deprecated `potion.<id>` too): mob effects'
|
||||
/// colours by id without vanilla's namespace, as the effects table names them. `water` and
|
||||
/// `empty` are left out: they change nothing on the real mod.
|
||||
pub fn effects(&self) -> Vec<(String, u32)> {
|
||||
let mut out = Vec::new();
|
||||
for (key, value) in &self.entries {
|
||||
let Some(rest) = key
|
||||
.strip_prefix("effect.")
|
||||
.or_else(|| key.strip_prefix("potion."))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let id = rest.strip_suffix(".color").unwrap_or(rest);
|
||||
if id.ends_with(".particle") || matches!(id, "water" | "empty" | "minecraft:water") {
|
||||
continue;
|
||||
}
|
||||
if let Some(color) = parse_color(value) {
|
||||
let id = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
// `effect.<id>.color` wins over the plain key whichever comes first.
|
||||
out.retain(|(other, _)| other != id || !key.ends_with(".color"));
|
||||
if !out.iter().any(|(other, _)| other == id) {
|
||||
out.push((id.to_owned(), color));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `particle.<id>`: a colour or an expression each new particle of that type takes, by
|
||||
/// namespaced type id.
|
||||
pub fn particles(&self) -> Vec<(String, String)> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let id = key.strip_prefix("particle.")?;
|
||||
let id = if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
let value = match value {
|
||||
Json::String(s) => s.trim().to_owned(),
|
||||
Json::Number(n) => n.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some((id, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `environment.*`: the fog settings a pack replaces.
|
||||
pub fn environment(&self) -> Environment {
|
||||
let number = |key: &str, default: f32| {
|
||||
match self.entries.get(&format!("environment.{key}")) {
|
||||
Some(Json::Number(n)) => n.as_f64().map(|v| v as f32),
|
||||
Some(Json::String(s)) => s.trim().parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or(default)
|
||||
};
|
||||
let vanilla = Environment::default();
|
||||
Environment {
|
||||
powder_snow_fog: self
|
||||
.color("environment.powder_snow_fog")
|
||||
.map(|c| c & 0xFF_FFFF),
|
||||
blindness_fog_distance: number(
|
||||
"blindness_fog_distance",
|
||||
vanilla.blindness_fog_distance,
|
||||
),
|
||||
darkness_fog_distance: number("darkness_fog_distance", vanilla.darkness_fog_distance),
|
||||
boss_fog_distance: number("boss_fog_distance", vanilla.boss_fog_distance),
|
||||
water_fog_brightening: number("water_fog_brightening", vanilla.water_fog_brightening),
|
||||
water_fog_brightening_time: number(
|
||||
"water_fog_brightening_time",
|
||||
vanilla.water_fog_brightening_time,
|
||||
)
|
||||
.max(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// `entity_shadows`: false hides every entity's round shadow, whatever the video option says.
|
||||
pub fn entity_shadows(&self) -> bool {
|
||||
match self.entries.get("entity_shadows") {
|
||||
@@ -161,6 +278,55 @@ mod tests {
|
||||
assert_eq!(p.xp_bar(), [Some(0x00ff22), Some(u32::MAX)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_particles_and_redstone_by_id() {
|
||||
let mut c = PackColors::default();
|
||||
c.add_json(
|
||||
"",
|
||||
&json!({
|
||||
"effect": {"minecraft:speed": {"color": "ff0000", "particle": "minecraft:poof"}, "water": "00ff00", "poison": 7},
|
||||
"particle": {"minecraft:flame": "0x0000ff", "poof": 0},
|
||||
"redstone": {"15": "00ff00"}
|
||||
}),
|
||||
);
|
||||
let mut effects = c.effects();
|
||||
effects.sort();
|
||||
assert_eq!(
|
||||
effects,
|
||||
[("poison".to_owned(), 7), ("speed".to_owned(), 0xff0000)]
|
||||
);
|
||||
let mut particles = c.particles();
|
||||
particles.sort();
|
||||
assert_eq!(
|
||||
particles,
|
||||
[
|
||||
("minecraft:flame".to_owned(), "0x0000ff".to_owned()),
|
||||
("minecraft:poof".to_owned(), "0".to_owned())
|
||||
]
|
||||
);
|
||||
assert_eq!((c.redstone(15), c.redstone(0)), (Some(0x00ff00), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_keeps_vanillas_where_unset() {
|
||||
let mut c = PackColors::default();
|
||||
assert_eq!(c.environment(), Environment::default());
|
||||
c.add_json(
|
||||
"",
|
||||
&json!({"environment": {"powder_snow_fog": "#c0c0d0", "blindness_fog_distance": 40, "water_fog_brightening_time": "200"}}),
|
||||
);
|
||||
let e = c.environment();
|
||||
assert_eq!(e.powder_snow_fog, Some(0xc0c0d0));
|
||||
assert_eq!(
|
||||
(e.blindness_fog_distance, e.darkness_fog_distance),
|
||||
(40.0, 15.0)
|
||||
);
|
||||
assert_eq!(
|
||||
(e.water_fog_brightening, e.water_fog_brightening_time),
|
||||
(1.0, 200.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dyes_name_their_colour_kind() {
|
||||
let mut c = PackColors::default();
|
||||
|
||||
@@ -76,11 +76,34 @@ impl Launcher {
|
||||
}
|
||||
// Pictures line up: a world without one gets a plain tile when another has one.
|
||||
let pictures = list.iter().any(|w| w.icon.is_some());
|
||||
for w in list {
|
||||
let row = self.glass_world_row(&w, pictures, cx);
|
||||
body = body.child(row).children(self.world_packs_panel(&w, cx));
|
||||
}
|
||||
Some(body)
|
||||
// A long saves folder: only the rows near the view are built (`cull`). The world being
|
||||
// worked on and the one whose ⋯ menu is open stay built, wherever the page is scrolled.
|
||||
let kept: Vec<bool> = list
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let key = format!("world-{}", w.folder);
|
||||
self.worlds.busy.as_ref() == Some(&w.folder)
|
||||
|| self.worlds.confirm_delete.as_ref() == Some(&w.folder)
|
||||
|| self.list_menu.as_ref().is_some_and(|(k, _)| **k == *key)
|
||||
})
|
||||
.collect();
|
||||
let (cull, scroll) = (self.worlds.cull.clone(), self.content_scroll.handle.clone());
|
||||
let rows = cull.column(
|
||||
&scroll,
|
||||
list.len(),
|
||||
0.,
|
||||
|i| kept[i],
|
||||
|i| {
|
||||
let w = &list[i];
|
||||
let row = self.glass_world_row(w, pictures, cx);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(row)
|
||||
.children(self.world_packs_panel(w, cx))
|
||||
},
|
||||
);
|
||||
Some(body.child(rows))
|
||||
}
|
||||
|
||||
fn glass_world_row(&mut self, w: &World, pictures: bool, cx: &mut Context<Self>) -> Div {
|
||||
|
||||
+77
-69
@@ -421,8 +421,13 @@ impl Launcher {
|
||||
.child(line);
|
||||
}
|
||||
let picked = self.mods_picked().map(|p| p.id.clone());
|
||||
let mut list = div().flex().flex_col();
|
||||
for i in 0..self.mods.results.len() {
|
||||
// Registry pages add up behind More: only the rows near the view are built (`cull`).
|
||||
let (rows, scroll) = (
|
||||
self.mods.results_list.clone(),
|
||||
self.content_scroll.handle.clone(),
|
||||
);
|
||||
let total = self.mods.results.len();
|
||||
let row = |i: usize| {
|
||||
let p = self.mods.results[i].clone();
|
||||
let icon = self.mods_icon(&p, 40.);
|
||||
let m = &self.mods;
|
||||
@@ -484,73 +489,76 @@ impl Launcher {
|
||||
.child(tr(support.label()))
|
||||
};
|
||||
let id = p.id.clone();
|
||||
list = list.child(
|
||||
ui::row(
|
||||
SharedString::from(format!("mods-row-{}", p.id)),
|
||||
picked.as_deref() == Some(p.id.as_str()),
|
||||
12.,
|
||||
&t,
|
||||
)
|
||||
.px(px(12.))
|
||||
.py(px(10.))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.size(px(40.))
|
||||
.rounded(px(8.))
|
||||
.overflow_hidden()
|
||||
.child(icon),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap(px(8.))
|
||||
.min_w_0()
|
||||
.child(
|
||||
ui::text(14., FontWeight::MEDIUM, ink::FULL)
|
||||
.truncate()
|
||||
.child(p.title.clone()),
|
||||
)
|
||||
.child(ui::text(13., FontWeight::NORMAL, 0.5).truncate().child(
|
||||
match p.kind {
|
||||
PackKind::Mod | PackKind::Shader => p.author.clone(),
|
||||
_ => tr(kind_name(p.kind)).to_string(),
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
ui::text(13., FontWeight::NORMAL, 0.6)
|
||||
.truncate()
|
||||
.child(p.summary.clone()),
|
||||
)
|
||||
.children(
|
||||
m.works
|
||||
.get(&p.id)
|
||||
.filter(|_| p.source == SourceId::Horizon)
|
||||
.map(|(works, line)| {
|
||||
ui::text(12., FontWeight::NORMAL, ink::FULL)
|
||||
.truncate()
|
||||
.text_color(if *works { ui::live() } else { white(0.4) })
|
||||
.child(line.clone())
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(right.flex_none())
|
||||
.child(action)
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.mods.picked = Some(id.clone());
|
||||
this.mods.confirm_remove = None;
|
||||
cx.notify();
|
||||
})),
|
||||
);
|
||||
}
|
||||
ui::row(
|
||||
SharedString::from(format!("mods-row-{}", p.id)),
|
||||
picked.as_deref() == Some(p.id.as_str()),
|
||||
12.,
|
||||
&t,
|
||||
)
|
||||
.px(px(12.))
|
||||
.py(px(10.))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.size(px(40.))
|
||||
.rounded(px(8.))
|
||||
.overflow_hidden()
|
||||
.child(icon),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap(px(8.))
|
||||
.min_w_0()
|
||||
.child(
|
||||
ui::text(14., FontWeight::MEDIUM, ink::FULL)
|
||||
.truncate()
|
||||
.child(p.title.clone()),
|
||||
)
|
||||
.child(ui::text(13., FontWeight::NORMAL, 0.5).truncate().child(
|
||||
match p.kind {
|
||||
PackKind::Mod | PackKind::Shader => p.author.clone(),
|
||||
_ => tr(kind_name(p.kind)).to_string(),
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
ui::text(13., FontWeight::NORMAL, 0.6)
|
||||
.truncate()
|
||||
.child(p.summary.clone()),
|
||||
)
|
||||
.children(
|
||||
m.works
|
||||
.get(&p.id)
|
||||
.filter(|_| p.source == SourceId::Horizon)
|
||||
.map(|(works, line)| {
|
||||
ui::text(12., FontWeight::NORMAL, ink::FULL)
|
||||
.truncate()
|
||||
.text_color(if *works { ui::live() } else { white(0.4) })
|
||||
.child(line.clone())
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(right.flex_none())
|
||||
.child(action)
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.mods.picked = Some(id.clone());
|
||||
this.mods.confirm_remove = None;
|
||||
cx.notify();
|
||||
}))
|
||||
};
|
||||
let mut list =
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(rows.column(&scroll, total, 0., |_| false, row));
|
||||
if self.mods.more {
|
||||
let searching = self.mods.searching;
|
||||
let offset = self.mods.results.len();
|
||||
|
||||
@@ -11,11 +11,13 @@ pub(super) struct Worlds {
|
||||
/// read again, so the page doesn't jump.
|
||||
pub(super) home: Option<PathBuf>,
|
||||
pub(super) list: Vec<World>,
|
||||
/// Which of the list's rows Home's glass Worlds tab has built and measured (`cull`).
|
||||
pub(super) cull: cull::Cull,
|
||||
fresh: bool,
|
||||
reading: bool,
|
||||
/// The world being backed up, copied or deleted now.
|
||||
pub(super) busy: Option<String>,
|
||||
confirm_delete: Option<String>,
|
||||
pub(super) confirm_delete: Option<String>,
|
||||
/// What the last action did (a backup's zip), or why it failed.
|
||||
pub(super) done: Option<Result<(String, Option<PathBuf>), String>>,
|
||||
}
|
||||
@@ -73,6 +75,7 @@ impl Launcher {
|
||||
// Another instance may have been picked meanwhile.
|
||||
if this.worlds.home.as_ref() == Some(&home) {
|
||||
this.worlds.list = list;
|
||||
this.worlds.cull.reset();
|
||||
this.worlds.fresh = true;
|
||||
this.worlds.reading = false;
|
||||
}
|
||||
|
||||
@@ -1105,6 +1105,41 @@ def vehicle_saved_ground_and_fall():
|
||||
f'/forceload remove {x0} {z0}')
|
||||
|
||||
|
||||
def ravager_saved_movement_speed():
|
||||
"""Ravager.aiStep sets the movement_speed base to 0.0 while biting, stunned or roaring, then lerps it back
|
||||
toward 0.3. The instance exists only from that first aiStep (AttributeMap.getInstance makes it lazily), and
|
||||
AttributeMap.pack saves it with the current base, so a reloaded ravager keeps its speed. Read after
|
||||
summoning, after one and five ticks, after an unload and reload (frozen), and two ticks later.
|
||||
"""
|
||||
x0, y0, z0 = 3904, 300, 3904
|
||||
chunk = [[x0 >> 4, z0 >> 4]]
|
||||
subjects = ['RoarTick:7', 'RoarTick:1', 'StunTick:3', 'AttackTick:5', 'AttackTick:0']
|
||||
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': chunk},
|
||||
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} stone', expect_result=256)]
|
||||
for n, body in enumerate(subjects):
|
||||
ops.append(cmd(f'/summon ravager {x0 + 2 + 3 * n}.5 {y0} {z0 + 8}.5 '
|
||||
f'{{Tags:["rav_speed_{n}"],PersistenceRequired:1b,{body}}}', expect_result=1))
|
||||
|
||||
def reads():
|
||||
for n in range(len(subjects)):
|
||||
ops.append(cmd(f'/data get entity @e[tag=rav_speed_{n},limit=1] attributes'))
|
||||
|
||||
reads()
|
||||
ops.append({'op': 'tick', 'n': 1})
|
||||
reads()
|
||||
ops.append({'op': 'tick', 'n': 4})
|
||||
reads()
|
||||
ops += [cmd('/tick freeze', False), cmd(f'/forceload remove {x0} {z0}', False), {'op': 'tick', 'n': 60},
|
||||
cmd(f'/execute unless loaded {x0} {y0} {z0}', expect_result=1),
|
||||
cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': chunk}]
|
||||
reads()
|
||||
ops += [cmd('/tick unfreeze', False), {'op': 'tick', 'n': 2}]
|
||||
reads()
|
||||
return ops + setup(f'/kill @e[type=!player,x={x0},y={y0 - 8},z={z0},dx=16,dy=24,dz=16]',
|
||||
f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} air',
|
||||
f'/forceload remove {x0} {z0}')
|
||||
|
||||
|
||||
def vehicle_ground_and_fall():
|
||||
"""Entity.load reads OnGround (getBooleanOr) and fall_distance (getDoubleOr) for every entity,
|
||||
and Entity.saveWithoutId writes the live values: boats, rafts and minecarts keep both. Frozen
|
||||
@@ -6527,6 +6562,52 @@ def primed_tnt_fall():
|
||||
return ops
|
||||
|
||||
|
||||
def item_fall_distance():
|
||||
"""#13203: a dropped item is an `Entity`: `checkFallDamage` adds each step down,
|
||||
narrowed to a float, to its double `fall_distance` outside water, the ground and water
|
||||
reset it, and `Entity.save` / `load` keep it. A fast fall past a vine on a wall resets it
|
||||
(`Entity.move`'s FALLDAMAGE_RESETTING cast) and a cobweb makes it stuck, which resets it."""
|
||||
sel = '@e[type=minecraft:item,tag=item_fall,limit=1]'
|
||||
ops = [cmd('/forceload add 32 32', False), {'op': 'wait_chunks', 'chunks': [[2, 2]]}]
|
||||
|
||||
def track(ticks):
|
||||
out = []
|
||||
for _ in range(ticks):
|
||||
out.append({'op': 'tick', 'n': 1})
|
||||
for path in ('Pos', 'Motion', 'fall_distance'):
|
||||
out.append(cmd(f'/data get entity {sel} {path}'))
|
||||
return out
|
||||
|
||||
item = 'Item:{id:"minecraft:stone",count:1},PickupDelay:32767,Tags:["item_fall"]'
|
||||
# block at 32 210 32, summon y, fields, ticks recorded. The landing is read once: the
|
||||
# resting item's next Motion still differs (a separate gap).
|
||||
for block, y, fields, ticks in (
|
||||
('air', 216, 'Motion:[0.0d,-0.3d,0.0d]', 4),
|
||||
('stone', 211.1, 'fall_distance:3.0d,Motion:[0.0d,-1.0d,0.0d]', 1),
|
||||
('water', 213, 'fall_distance:2.0d,Motion:[0.0d,-0.5d,0.0d]', 6),
|
||||
('cobweb', 212, 'Motion:[0.0d,-0.5d,0.0d]', 5)):
|
||||
ops += setup('/kill @e[type=!player]', '/fill 28 205 28 36 220 36 air',
|
||||
'/fill 30 209 30 34 209 34 stone', f'/setblock 32 210 32 {block}',
|
||||
f'/summon minecraft:item 32.5 {y} 32.5 {{{fields},{item}}}')
|
||||
ops += track(ticks)
|
||||
# Fast past a vine on the wall beside it: the cast of the move resets the fall first.
|
||||
ops += setup('/kill @e[type=!player]', '/fill 28 205 28 36 220 36 air',
|
||||
'/fill 30 209 30 34 209 34 stone', '/fill 31 212 32 31 219 32 stone',
|
||||
'/setblock 32 215 32 vine[west=true]',
|
||||
f'/summon minecraft:item 32.5 218 32.5 {{fall_distance:5.0d,Motion:[0.0d,-1.5d,0.0d],{item}}}')
|
||||
ops += track(3)
|
||||
# Load and /data write the field; a frozen item keeps it.
|
||||
ops += setup('/kill @e[type=!player]', '/fill 28 205 28 36 220 36 air',
|
||||
f'/summon minecraft:item 32.5 216 32.5 {{fall_distance:4.5f,NoGravity:1b,{item}}}')
|
||||
ops.append(cmd(f'/data get entity {sel} fall_distance'))
|
||||
ops.append(cmd(f'/data modify entity {sel} fall_distance set value 2.25d'))
|
||||
ops.append(cmd(f'/data get entity {sel} fall_distance'))
|
||||
ops.append(cmd(f'/data merge entity {sel} {{NoGravity:0b,Motion:[0.0d,-0.5d,0.0d]}}'))
|
||||
ops += track(2)
|
||||
return ops + setup('/kill @e[type=!player]', '/fill 28 205 28 36 220 36 air', '/forceload remove 32 32')
|
||||
|
||||
|
||||
|
||||
def loaded_dead_mob():
|
||||
"""#13199: `LivingEntity.readAdditionalSaveData` reads `Health` through `setHealth`, which
|
||||
clamps a negative value to 0 and keeps the body: a mob summoned dead is there, `/data` reads
|
||||
@@ -6556,6 +6637,54 @@ def loaded_dead_mob():
|
||||
return ops + setup(f'/kill @e[type=!player,x={x0},y={y0 - 8},z={z0},dx=16,dy=32,dz=16]',
|
||||
f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} air',
|
||||
f'/forceload remove {x0} {z0}')
|
||||
def falling_block_fall_float():
|
||||
"""#13701: `Entity.checkFallDamage` adds each step down narrowed to a float (`fallDistance -= (float)ya`);
|
||||
a falling block never runs `baseTick`, so it is never in water and every step counts."""
|
||||
x0, y0, z0 = 4096 - 64, 260, 3648
|
||||
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': [[x0 >> 4, z0 >> 4]]},
|
||||
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} stone', False)]
|
||||
sel = '@e[type=minecraft:falling_block,tag=fb_fall,limit=1]'
|
||||
for block, y in (('sand', y0 + 6.5), ('anvil', y0 + 3.5)):
|
||||
ops.append(cmd(f'/summon falling_block {x0 + 8}.5 {y} {z0 + 8}.5 '
|
||||
f'{{BlockState:{{Name:"minecraft:{block}"}},Time:1,Tags:["fb_fall"]}}', expect_result=1))
|
||||
for _ in range(6):
|
||||
ops.append({'op': 'tick', 'n': 1})
|
||||
ops.append(cmd(f'/data get entity {sel} fall_distance'))
|
||||
ops.append(cmd(f'/data get entity {sel} Pos[1]'))
|
||||
ops.append(cmd(f'/kill @e[type=minecraft:falling_block,tag=fb_fall]', False))
|
||||
ops.append(cmd(f'/fill {x0} {y0} {z0} {x0 + 15} {y0 + 8} {z0 + 15} air', False))
|
||||
return ops + setup(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 + 8} {z0 + 15} air', f'/forceload remove {x0} {z0}')
|
||||
|
||||
|
||||
def explosion_infested_blocks():
|
||||
"""#11330: `Block.onExplosionHit` calls `spawnAfterBreak` before the drops, so a blast lets an
|
||||
infested block's silverfish out (`InfestedBlock.spawnInfestation`) while `block_drops` is on. The
|
||||
difficulty is not peaceful, where the silverfish would despawn on their first tick. The blast without
|
||||
drops goes first: the explosion's rays draw from the level's random, and a mob's spawn draws differ."""
|
||||
x0, y0, z0 = 3968, 260, 3648
|
||||
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': [[x0 >> 4, z0 >> 4]]},
|
||||
cmd('/difficulty normal', False)]
|
||||
for drops in ('false', 'true'):
|
||||
ops += [cmd(f'/gamerule block_drops {drops}', False),
|
||||
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 + 6} {z0 + 15} air', False),
|
||||
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} bedrock', False),
|
||||
# Four blocks right under the TNT, which every ray breaks: how many edge blocks resist
|
||||
# depends on each ray's random intensity.
|
||||
cmd(f'/fill {x0 + 7} {y0} {z0 + 7} {x0 + 8} {y0} {z0 + 7} infested_stone', expect_result=2),
|
||||
cmd(f'/fill {x0 + 7} {y0} {z0 + 8} {x0 + 8} {y0} {z0 + 8} infested_cobblestone', expect_result=2),
|
||||
cmd(f'/summon tnt {x0 + 8} {y0 + 1} {z0 + 8} {{fuse:0}}', expect_result=1),
|
||||
cmd(f'/execute if entity @e[type=tnt,x={x0},y={y0 - 2},z={z0},dx=16,dy=12,dz=16]'),
|
||||
{'op': 'tick', 'n': 1},
|
||||
cmd(f'/execute if entity @e[type=tnt,x={x0},y={y0 - 2},z={z0},dx=16,dy=12,dz=16]'),
|
||||
cmd(f'/execute if entity @e[type=silverfish,x={x0},y={y0 - 2},z={z0},dx=16,dy=12,dz=16]'),
|
||||
cmd(f'/execute if block {x0 + 7} {y0} {z0 + 7} infested_stone'),
|
||||
{'op': 'tick', 'n': 3},
|
||||
cmd(f'/execute if entity @e[type=silverfish,x={x0},y={y0 - 2},z={z0},dx=16,dy=12,dz=16]'),
|
||||
cmd(f'/kill @e[type=!player,x={x0},y={y0 - 2},z={z0},dx=16,dy=12,dz=16]', False)]
|
||||
return ops + setup('/gamerule block_drops true',
|
||||
f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 + 6} {z0 + 15} air', f'/forceload remove {x0} {z0}')
|
||||
|
||||
|
||||
def jigsaw_default_joint():
|
||||
"""#13654: `JigsawBlockEntity.loadAdditional` reads `joint`, else `StructureTemplate.getDefaultJointType`:
|
||||
`aligned` when the block's front faces sideways, `rollable` when it faces up or down."""
|
||||
@@ -6646,6 +6775,29 @@ def zombie_villager_summoned_data():
|
||||
f'/forceload remove {x0} {z0}')
|
||||
|
||||
|
||||
def projectile_fall_distance():
|
||||
"""#13702: `Entity.load` reads `fall_distance` for every entity and `saveWithoutId` writes it; a projectile
|
||||
moves by `setPos`, never through `Entity.move`, so in the air it keeps what it was given."""
|
||||
# Away from the cases at z 3648: unloading this chunk at the end would keep vanilla's chunks two
|
||||
# columns away from entity ticking for a few ticks (the explosion and falling block cases next).
|
||||
x0, y0, z0 = 4096 - 96, 280, 3392
|
||||
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': [[x0 >> 4, z0 >> 4]]},
|
||||
cmd('/tick freeze', False)]
|
||||
kinds = [('fireball', ''), ('wither_skull', ''), ('small_fireball', ''), ('snowball', ''),
|
||||
('fireball', ',fall_distance:2.5f')]
|
||||
for n, (kind, extra) in enumerate(kinds, 1):
|
||||
fields = extra.lstrip(',') if extra else 'fall_distance:1.0d'
|
||||
uuid = f'000035f0-0000-0000-0000-{n:04x}00000001'
|
||||
ops += [cmd(f'/summon {kind} {x0 + 2 * n}.5 {y0} {z0 + 8}.5 {{UUID:[I;13808,0,{n},1],{fields},'
|
||||
f'Motion:[0.0d,0.0d,0.0d],acceleration_power:0.0d}}', expect_result=1),
|
||||
cmd(f'/data get entity {uuid} fall_distance')]
|
||||
ops += [cmd('/tick unfreeze', False), {'op': 'tick', 'n': 1}, cmd('/tick freeze', False)]
|
||||
for n in range(1, len(kinds) + 1):
|
||||
ops.append(cmd(f'/data get entity 000035f0-0000-0000-0000-{n:04x}00000001 fall_distance'))
|
||||
return ops + setup('/tick unfreeze', f'/kill @e[type=!player,x={x0},y={y0 - 8},z={z0},dx=16,dy=32,dz=16]',
|
||||
f'/forceload remove {x0} {z0}')
|
||||
|
||||
|
||||
def loaded_ticket_removal(blocks=False):
|
||||
"""Entity-ticking command visibility after forced tickets leave; FULL is separate."""
|
||||
targets = [(d, x, z) for d in range(2, 7)
|
||||
@@ -7224,9 +7376,13 @@ def groups():
|
||||
('piston entity tick order', piston_entity_tick_order()),
|
||||
('primed tnt box', primed_tnt_box()),
|
||||
('primed tnt fall', primed_tnt_fall()),
|
||||
('projectile fall distance', projectile_fall_distance()),
|
||||
('item fall distance', item_fall_distance()),
|
||||
('beacon levels load', beacon_levels_load()),
|
||||
('zombie villager summoned data', zombie_villager_summoned_data()),
|
||||
('loaded dead mob', loaded_dead_mob()),
|
||||
('falling block fall float', falling_block_fall_float()),
|
||||
('explosion infested blocks', explosion_infested_blocks()),
|
||||
('jigsaw default joint', jigsaw_default_joint()),
|
||||
('command block auto load', command_block_auto_load()),
|
||||
('item block escape', item_block_escape()),
|
||||
@@ -7323,6 +7479,7 @@ def groups():
|
||||
('rocket data merge', rocket_data_merge()),
|
||||
('vehicle ground and fall', vehicle_ground_and_fall()),
|
||||
('vehicle saved ground and fall', vehicle_saved_ground_and_fall()),
|
||||
('ravager saved movement speed', ravager_saved_movement_speed()),
|
||||
# Last: it leaves the player in other food than it found.
|
||||
(player_food.GROUP, player_food.cases(cmd, setup)),
|
||||
]
|
||||
|
||||
@@ -17,6 +17,8 @@ Strider.StriderPathNavigation.createPathFinder port crates/horizon-client/src/mo
|
||||
Strider.StriderPathNavigation.isStableDestination port crates/horizon-client/src/mobs/nav.rs::Mob::stable_destination lava counts as stable for lava walkers
|
||||
Vex.registerGoals port crates/horizon-client/src/mobs/vex.rs::register float, charge, random move, look goals, owner and player targets
|
||||
Vex.restoreFrom missing no mob `restoreFrom`: a vex that changes dimension keeps no owner link
|
||||
Vex.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt owner is written as the owner's UUID, nothing when the vex has no owner; bound_pos only when bound, life_ticks only with a limited life
|
||||
Vex.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt bound_pos, life_ticks (no limit when absent) and owner, kept as the UUID the save names
|
||||
Vex.getVexFlag inline crates/horizon-client/src/mobs/vex.rs::Charge::can_continue the charging bit is the plain extra.flag; there is no flags byte
|
||||
Vex.setVexFlag inline crates/horizon-client/src/mobs/vex.rs::Charge::start start sets extra.flag; stop and tick clear it
|
||||
Vex.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize Vex arm: populate (sword) then enchant
|
||||
|
||||
|
@@ -1,5 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Saved LodestoneTracker default with stationary real item entities, no world steps.
|
||||
"""Saved LodestoneTracker default with stationary real item entities, and world
|
||||
steps for a compass worn in the player's armour slots.
|
||||
|
||||
The saved-default corpus has no world steps. The armour family adds one player
|
||||
tick: a compass in the head armour slot whose lodestone is removed must lose
|
||||
its target, as `ItemStack.inventoryTick` / `LodestoneTracker.tick` do from
|
||||
`LivingEntity.tick`'s `EntityEquipment` pass. More ticks cover a loaded
|
||||
target, an untracked tracker and a target in another dimension. `--no-armor`
|
||||
leaves that family out and reproduces the corpus before it.
|
||||
|
||||
--full-diagnostic records whole entity bodies and absolute clocks too. Those
|
||||
commands remain traced in the scoped program. All saved-component, typed identity,
|
||||
@@ -13,11 +21,11 @@ import sys
|
||||
import test_command_exec_parity as driver
|
||||
|
||||
|
||||
def program(full=False):
|
||||
def program(full=False, armor=True):
|
||||
ops = []
|
||||
|
||||
def cmd(text, label, result=None, callback=None, admission=True, diagnostic=False):
|
||||
op = {'op': 'cmd', 'cmd': text, 'label': label, 'admission': admission}
|
||||
def cmd(text, label, result=None, callback=None, admission=True, diagnostic=False, record=True):
|
||||
op = {'op': 'cmd', 'cmd': text, 'label': label, 'admission': admission, 'record': record}
|
||||
if result is not None:
|
||||
op['expect_result'] = result
|
||||
if callback is not None:
|
||||
@@ -94,6 +102,51 @@ def program(full=False):
|
||||
cmd('/execute store result score now h329 run time query gametime', 'clock-end', diagnostic=True)
|
||||
cmd('/scoreboard players operation now h329 -= start h329', 'clock-delta-operation', 0)
|
||||
cmd('/scoreboard players get now h329', 'clock-delta', 0)
|
||||
if armor:
|
||||
def equip(label, pos, tracked=None, dim='minecraft:overworld'):
|
||||
# The equippable component is what lets a non-armour item into an
|
||||
# armour slot; the compass itself still ticks its tracker. The
|
||||
# command's own feedback names the item, which is another suite's
|
||||
# subject, so only the reads below are compared.
|
||||
tracker = f'target:{{dimension:"{dim}",pos:[I;{pos},200,12]}}'
|
||||
if tracked is not None:
|
||||
tracker += f',tracked:{tracked}'
|
||||
item = (f'minecraft:compass[minecraft:lodestone_tracker={{{tracker}}},'
|
||||
'minecraft:equippable={slot:"head"},minecraft:custom_data={armor:1}]')
|
||||
cmd(f'/item replace entity @s armor.head with {item} 1', label + '-equip',
|
||||
admission=False, record=False)
|
||||
|
||||
def tick(label):
|
||||
ops.append({'op': 'player_loop', 'n': 1, 'label': label, 'record': True})
|
||||
|
||||
def tracker(label):
|
||||
cmd('/data get entity @s equipment.head.components."minecraft:lodestone_tracker"', label)
|
||||
cmd('/execute if data entity @s equipment.head.components."minecraft:lodestone_tracker".target',
|
||||
label + '-target')
|
||||
cmd('/data get entity @s equipment.head.id', label + '-id', 17)
|
||||
|
||||
cmd('/setblock 14 200 12 lodestone', 'armor-stone-setup', 1)
|
||||
equip('armor-air', 12)
|
||||
tracker('armor-air-before')
|
||||
cmd('/setblock 12 200 12 air', 'armor-air-remove', 1)
|
||||
tick('armor-air-tick')
|
||||
tracker('armor-air-after')
|
||||
cmd('/setblock 14 200 12 stone', 'armor-stone-replace', 1)
|
||||
equip('armor-stone', 14)
|
||||
tracker('armor-stone-before')
|
||||
tick('armor-stone-tick')
|
||||
tracker('armor-stone-after')
|
||||
cmd('/setblock 12 200 12 lodestone', 'armor-loaded-setup', 1)
|
||||
equip('armor-loaded', 12)
|
||||
tick('armor-loaded-tick')
|
||||
tracker('armor-loaded-after')
|
||||
equip('armor-untracked', 12, tracked='false')
|
||||
cmd('/setblock 12 200 12 air', 'armor-untracked-remove', 1)
|
||||
tick('armor-untracked-tick')
|
||||
tracker('armor-untracked-after')
|
||||
equip('armor-dimension', 14, dim='minecraft:the_nether')
|
||||
tick('armor-dimension-tick')
|
||||
tracker('armor-dimension-after')
|
||||
return ops
|
||||
|
||||
|
||||
@@ -108,12 +161,16 @@ def main():
|
||||
del sys.argv[i:i + 2]
|
||||
if '--work-dir' not in sys.argv:
|
||||
sys.argv += ['--work-dir', str(driver.lab.LOCAL / 'lodestone-default-parity')]
|
||||
armor = '--no-armor' not in sys.argv
|
||||
if not armor:
|
||||
sys.argv.remove('--no-armor')
|
||||
build = Path(sys.argv[sys.argv.index('--work-dir') + 1]).resolve()
|
||||
ops = program(full)
|
||||
ops = program(full, armor)
|
||||
driver.corpus_module.groups = lambda: [('saved lodestone tracker default', ops)]
|
||||
sys.argv.append('--allow-gaps')
|
||||
driver.main()
|
||||
recorded = [op for op in ops if op['op'] == 'cmd' and op.get('record', True)]
|
||||
recorded = [op for op in ops if (op['op'] == 'cmd' and op.get('record', True))
|
||||
or (op['op'] in ('tick', 'player_loop') and op.get('record'))]
|
||||
java = [json.loads(s) for s in (build / 'vanilla.jsonl').read_text().splitlines()]
|
||||
for op, row in zip(recorded, java):
|
||||
if 'expect_results' in op:
|
||||
@@ -132,11 +189,16 @@ def main():
|
||||
'reference_sha256': hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
'comparison': 'complete raw records, no masks/tolerances', 'differences': differences}
|
||||
(build / 'strict-report.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
if not full:
|
||||
for op, a, b in zip(recorded, expected, native):
|
||||
if op['admission']:
|
||||
assert a == b, ('actual fixture admission', op, a, b)
|
||||
if armor:
|
||||
slots = ('air', 'stone')
|
||||
matched = sum(1 for slot in slots
|
||||
if not any(item['label'].startswith(f'armor-{slot}-') for item in differences))
|
||||
print(f'Armor tracking: {matched}/{len(slots)} cases match')
|
||||
print(f'Strict raw records: {report["passed"]}/{report["records"]}')
|
||||
if not full and not differences:
|
||||
for op, a, b in zip(recorded, expected, native):
|
||||
if op.get('admission'):
|
||||
assert a == b, ('actual fixture admission', op, a, b)
|
||||
if differences:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
Reference in new issue
Block a user