Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 8406d2c586 horizon-threads: the stack dump test samples only once the busy loop runs
The worker sent its handle and went straight into the loop, so the signal could land
while it was still waking the receiver (futex_wake in the mpsc send), and the busy
frame was not yet on its stack. It failed that way on CI (run 43194576407).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 14:38:30 +01:00
229 changed files with 2533 additions and 9590 deletions

No files matched your search

+1 -1
View File
@@ -214,7 +214,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs` + `extras/` (each extra's glue: `mouse_tweaks.rs`, `sorting.rs`, `pvp.rs`, `reconnect.rs`), `options.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
## How `Game` is organised
-2
View File
@@ -62,8 +62,6 @@ The GLSL frontend also resets the common type for each comma-separated declarato
variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
The shared interpolation defaults also compile with `glsl-in` alone, without needing the
SPIR-V or WGSL frontend to enable them.
## DirectX Shader Compiler
+1 -33
View File
@@ -206,7 +206,7 @@ impl Config {
Ok(file.anticheat)
}
/// Thresholds can't be negative, and neither can the timer's milliseconds.
/// Thresholds can't be negative.
pub fn check(&self) -> Result<(), String> {
for &c in Check::ALL {
let a = self.actions(c);
@@ -220,18 +220,6 @@ impl Config {
));
}
}
// A negative tolerance flags a client on time every tick, and so does a cap below minus the
// tolerance (the balance rests at the cap); NaN never flags, or never caps the balance. No
// negative number of milliseconds means anything, so all are refused.
if [self.timer.tolerance_ms, self.timer.max_lag_ms]
.iter()
.any(|v| !v.is_finite() || *v < 0.0)
{
return Err(
"anticheat.timer: tolerance_ms and max_lag_ms are numbers of milliseconds from 0 up"
.into(),
);
}
let tolerance = self.prediction.tolerance;
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
@@ -373,26 +361,6 @@ mod tests {
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
}
/// A negative `tolerance_ms` flags a client on time every tick, and so does a `max_lag_ms` below
/// `-tolerance_ms` (the balance rests at the cap); a NaN turns the check (or its cap) off without a
/// word. Every negative or non-finite value is refused, as a negative threshold is.
#[test]
fn refuses_timer_settings_that_are_not_milliseconds_from_0_up() {
for bad in [
"tolerance_ms = -1",
"tolerance_ms = nan",
"tolerance_ms = inf",
"max_lag_ms = -1",
"max_lag_ms = nan",
] {
let text = format!("[anticheat.timer]\n{bad}\n");
let e = Config::from_server_toml(&text).expect_err(bad);
assert!(e.starts_with("anticheat.timer:"), "{bad}: {e}");
}
let edge = "[anticheat.timer]\ntolerance_ms = 0\nmax_lag_ms = 0\n";
assert!(Config::from_server_toml(edge).is_ok());
}
#[test]
fn levels_rise_decay_and_act_in_order() {
let mut c = on();
+1 -25
View File
@@ -45,11 +45,6 @@ mod tests {
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
run_at(b, 20.0, server_ms, per_tick, seconds)
}
/// [`run`] at the tick rate `rate` (`/tick rate`), which the client's ticks follow.
fn run_at(b: &mut Balance, rate: f32, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
let c = TimerConfig::default();
let mut owed = 0.0;
let mut flags = 0;
@@ -58,31 +53,12 @@ mod tests {
owed += per_tick;
while owed >= 1.0 {
owed -= 1.0;
flags += usize::from(b.tick_end(rate, &c).is_some());
flags += usize::from(b.tick_end(20.0, &c).is_some());
}
}
flags
}
/// At another tick rate a vanilla client ticks at the rate the server sent it, and each of its
/// ticks is worth `1000 / rate` ms: never flagged on time, flagged again and again 1.5 times as fast.
#[test]
fn the_balance_follows_the_tick_rate() {
for rate in [10.0_f32, 60.0] {
let tick_ms = 1000.0 / f64::from(rate);
assert_eq!(
run_at(&mut Balance::default(), rate, tick_ms, 1.0, 600.0),
0,
"on time at {rate}"
);
// Half a tick ahead every server tick: 75 flags in a minute at 10 a second, 94 at 60.
let flags = run_at(&mut Balance::default(), rate, tick_ms, 1.5, 60.0);
assert!(flags >= 50, "1.5 times as fast at {rate}: {flags} flags");
}
// The rate counts: at 10 a second, a client sending 20 ticks a second is twice as fast.
assert!(run_at(&mut Balance::default(), 10.0, 100.0, 2.0, 60.0) >= 50);
}
#[test]
fn a_vanilla_client_is_never_flagged() {
let mut b = Balance::default();
@@ -1442,8 +1442,6 @@ impl Audio {
} else {
Err("no output on a dedicated server".into())
};
// Without `audio` a stream can't exist (`Infallible`), so the `Ok` arm never runs.
#[cfg_attr(not(feature = "audio"), allow(unreachable_code))]
let (stream, live) = match opened {
Ok(v) => (Some(v), true),
Err(e) => {
@@ -465,9 +465,6 @@ pub struct Jukebox {
/// reference exists. Keep this separate from legacy playback adoption.
pub song_clock_present: bool,
pub playing: bool,
/// A clock a save brought back, not playing yet: `setSongWithoutPlaying`
/// keeps the song and its clock without the sound's level event 1010.
pub loaded_clock: bool,
}
impl Jukebox {
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
+177 -3
View File
@@ -210,7 +210,6 @@ mod hdr_icc;
mod hdr_start;
mod hdr_stats;
pub(crate) mod hud_coverage;
mod in_flight;
pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
mod hold;
pub use hold::WorldHold;
@@ -12352,6 +12351,133 @@ pub(crate) fn copy_frame(
);
buffer
}
impl Renderer {
/// The frame's readbacks to start right after its submission (on the submit thread when
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
let cull = self.cull.as_mut().and_then(|c| c.submitted());
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
match (cull, gpu) {
(Some(c), Some(g)) => Some(Box::new(move || {
c();
g();
})),
(Some(c), None) => Some(Box::new(c)),
(None, Some(g)) => Some(Box::new(g)),
(None, None) => None,
}
}
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
if frame > self.stats_frame {
self.stats_frame = frame;
(
self.drawn_sections,
self.section_draws,
self.section_draw_calls,
) = (sections, draws, calls);
}
}
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
pub fn stats_lag(&self) -> u64 {
self.frame_serial - self.stats_frame
}
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
/// The frame on the submit thread submitted: a surface reconfigure waits for the
/// device and breaks if a frame is submitted while it waits, and the compositor
/// copies views after them. (The end of a frame already lands the frames of the
/// turn before; this is the turn's own.)
pub fn wait_submissions(&mut self) {
self.queue.wait();
}
fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
let format = self.config.format.remove_srgb_suffix();
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
// Other 16-bit frames keep their clipped 8-bit copy.
let copy = self
.hdr
.as_mut()
.and_then(|hdr| {
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
})
.or_else(|| {
(texture.format() != format && texture.format() == self.output.frame.format)
.then(|| {
self.readback
.unorm_copy(&self.device, encoder, texture, format)
})
});
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
@@ -12457,8 +12583,56 @@ fn block_entities_within_reach<'a>(
})
.flatten()
}
mod view_frustum;
pub use view_frustum::Frustum;
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
planes: [Vec4; 6],
}
impl Frustum {
/// Whether the camera-relative box `min..max` is at least partly inside.
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
!self.planes.iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether a camera-relative sphere is at least partly inside. Frustum
/// planes are normalized, so each test needs only one dot product and a
/// radius comparison.
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
return true;
}
!self
.planes
.iter()
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
}
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
return true;
}
!self.planes[4..].iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether the camera-relative box `min..max` is entirely inside. Then
/// `intersects` accepts every box within it: each plane's distance only
/// grows from this box's nearest corner to the inner box's farthest, and
/// rounding keeps that order.
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.planes.iter().all(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, min, max);
p.truncate().dot(v) + p.w >= 0.0
})
}
}
/// Vanilla's `Projection`: reversed depth (near maps to 1, far to 0, tests
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
@@ -1,132 +0,0 @@
//! Frames in flight: the readbacks a submission starts, landing frames the submit thread
//! submitted, keeping offscreen frames at most two ahead of the GPU, and a screenshot's copy.
use super::{Renderer, ScreenshotRequest, copy_frame, screenshot_save, submit};
impl Renderer {
/// The frame's readbacks to start right after its submission (on the submit thread when
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
pub(super) fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
let cull = self.cull.as_mut().and_then(|c| c.submitted());
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
match (cull, gpu) {
(Some(c), Some(g)) => Some(Box::new(move || {
c();
g();
})),
(Some(c), None) => Some(Box::new(c)),
(None, Some(g)) => Some(Box::new(g)),
(None, None) => None,
}
}
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
pub(super) fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
if frame > self.stats_frame {
self.stats_frame = frame;
(
self.drawn_sections,
self.section_draws,
self.section_draw_calls,
) = (sections, draws, calls);
}
}
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
pub fn stats_lag(&self) -> u64 {
self.frame_serial - self.stats_frame
}
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
pub(super) fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
/// The frame on the submit thread submitted: a surface reconfigure waits for the
/// device and breaks if a frame is submitted while it waits, and the compositor
/// copies views after them. (The end of a frame already lands the frames of the
/// turn before; this is the turn's own.)
pub fn wait_submissions(&mut self) {
self.queue.wait();
}
pub(super) fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
pub(super) fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
let format = self.config.format.remove_srgb_suffix();
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
// Other 16-bit frames keep their clipped 8-bit copy.
let copy = self
.hdr
.as_mut()
.and_then(|hdr| {
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
})
.or_else(|| {
(texture.format() != format && texture.format() == self.output.frame.format)
.then(|| {
self.readback
.unorm_copy(&self.device, encoder, texture, format)
})
});
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
}
@@ -1,252 +0,0 @@
//! The camera's view frustum, as camera-relative clip planes.
use glam::{Vec3, Vec4};
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
pub(super) planes: [Vec4; 6],
}
impl Frustum {
/// Whether the camera-relative box `min..max` is at least partly inside.
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
!self.planes.iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether a camera-relative sphere is at least partly inside. Frustum
/// planes are normalized, so each test needs only one dot product and a
/// radius comparison.
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
return true;
}
!self
.planes
.iter()
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
}
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
return true;
}
!self.planes[4..].iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether the camera-relative box `min..max` is entirely inside. Then
/// `intersects` accepts every box within it: each plane's distance only
/// grows from this box's nearest corner to the inner box's farthest, and
/// rounding keeps that order.
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.planes.iter().all(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, min, max);
p.truncate().dot(v) + p.w >= 0.0
})
}
/// Whether the camera-relative `point` is on or inside every plane (vanilla's
/// `Frustum.pointInFrustum`). A point that is not finite is kept: a test that cannot
/// tell must not hide the particle.
pub fn contains_point(&self, point: Vec3) -> bool {
if !point.is_finite() {
return true;
}
self.planes
.iter()
.all(|plane| plane.truncate().dot(point) + plane.w >= 0.0)
}
/// The same frustum seen from a camera moved `blocks` back along the view direction
/// (vanilla's `Frustum.offset`): the sides open up near the camera and the near plane
/// moves behind it, so what sits just behind or beside the camera stays inside.
///
/// The view direction is the normal of the depth planes (the two are opposite), signed
/// towards where the side planes point. A frustum that has no direction to pull along
/// comes back unchanged: a box with no depth planes (the shadow pass's), and a box whose
/// sides are parallel to the view direction (an orthographic projection), where the side
/// normals cancel and say nothing about which way is forward.
pub fn pulled_back(&self, blocks: f32) -> Frustum {
let sides: Vec3 = self.planes[..4].iter().map(|p| p.truncate()).sum();
let depth = self.planes[4].truncate();
let along = depth.dot(sides);
// A perspective frustum's four side normals each lean forward, so this is well above 1.
if along.abs() < 1.0e-3 {
return *self;
}
let toward_sides = if along > 0.0 { depth } else { -depth };
let axis = toward_sides.normalize_or_zero();
let mut planes = self.planes;
for plane in &mut planes {
plane.w += blocks * plane.truncate().dot(axis);
}
Frustum { planes }
}
/// The frustum of a camera-relative `view_proj`, for tests outside this module.
#[cfg(test)]
pub(crate) fn from_view_projection(view_proj: glam::Mat4) -> Frustum {
Frustum {
planes: super::frustum(view_proj),
}
}
}
#[cfg(test)]
mod tests {
use super::{Frustum, Vec3};
use crate::gfx::world_projection;
use glam::Mat4;
const FOV: f32 = 70.0;
const ASPECT: f32 = 16.0 / 9.0;
const FAR: f32 = 512.0;
const EPS: f32 = 0.01;
fn looking_down_minus_z() -> Frustum {
Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR))
}
fn tangents() -> (f32, f32) {
let up = (FOV / 2.0).to_radians().tan();
(up * ASPECT, up)
}
/// Each of the six planes of the unmoved frustum, a point just inside and one just outside,
/// from the field of view worked out by hand.
#[test]
fn a_point_is_inside_only_within_all_six_planes() {
let view = looking_down_minus_z();
let (tan_x, tan_y) = tangents();
let depth = 10.0;
let inside = [
Vec3::new(0.0, 0.0, -depth),
Vec3::new(depth * tan_x - EPS, 0.0, -depth),
Vec3::new(-(depth * tan_x - EPS), 0.0, -depth),
Vec3::new(0.0, depth * tan_y - EPS, -depth),
Vec3::new(0.0, -(depth * tan_y - EPS), -depth),
Vec3::new(0.0, 0.0, -0.06),
Vec3::new(0.0, 0.0, -(FAR - 0.1)),
];
let outside = [
Vec3::new(depth * tan_x + EPS, 0.0, -depth),
Vec3::new(-(depth * tan_x + EPS), 0.0, -depth),
Vec3::new(0.0, depth * tan_y + EPS, -depth),
Vec3::new(0.0, -(depth * tan_y + EPS), -depth),
Vec3::new(0.0, 0.0, -0.04),
Vec3::new(0.0, 0.0, -(FAR + 0.1)),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(0.0, 0.0, 0.0),
];
for p in inside {
assert!(view.contains_point(p), "{p} should be inside");
}
for p in outside {
assert!(!view.contains_point(p), "{p} should be outside");
}
assert!(view.contains_point(Vec3::NAN), "an unknown point is kept");
}
/// Camera 3 blocks back: its apex is at z = 3, so the sides open by 3 * tan, the near
/// plane is at z = 3 - 0.05 and the far plane at z = 3 - FAR.
#[test]
fn pulling_the_camera_back_moves_every_plane() {
let (tan_x, tan_y) = tangents();
let back = looking_down_minus_z().pulled_back(3.0);
let inside = [
Vec3::new(0.0, 0.0, 2.9),
Vec3::new(3.0 * tan_x - EPS, 0.0, 0.0),
Vec3::new(-(3.0 * tan_x - EPS), 0.0, 0.0),
Vec3::new(0.0, 3.0 * tan_y - EPS, 0.0),
Vec3::new(0.0, -(3.0 * tan_y - EPS), 0.0),
Vec3::new(0.0, 0.0, 3.0 - FAR + 0.1),
];
let outside = [
Vec3::new(0.0, 0.0, 3.0),
Vec3::new(3.0 * tan_x + EPS, 0.0, 0.0),
Vec3::new(-(3.0 * tan_x + EPS), 0.0, 0.0),
Vec3::new(0.0, 3.0 * tan_y + EPS, 0.0),
Vec3::new(0.0, -(3.0 * tan_y + EPS), 0.0),
Vec3::new(0.0, 0.0, 3.0 - FAR - 0.1),
];
for p in inside {
assert!(back.contains_point(p), "{p} should be inside");
}
for p in outside {
assert!(!back.contains_point(p), "{p} should be outside");
}
// The unmoved frustum does not have the point that only the offset one has.
assert!(!looking_down_minus_z().contains_point(Vec3::new(0.0, 0.0, 2.9)));
// An offset of nothing changes no verdict.
let same = looking_down_minus_z();
let unmoved = same.pulled_back(0.0);
for p in [
Vec3::new(1.0, 2.0, -3.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(40.0, 0.0, -5.0),
] {
assert_eq!(same.contains_point(p), unmoved.contains_point(p));
}
}
/// The offset follows the camera's own view direction, whatever way it looks.
#[test]
fn the_offset_is_along_the_view_direction() {
for (yaw, pitch) in [(0.3_f32, 0.0_f32), (2.0, -0.5), (-1.2, 0.9), (3.0, 1.2)] {
let pose = Mat4::from_rotation_x(pitch) * Mat4::from_rotation_y(yaw);
let view = Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR) * pose);
let forward = pose.inverse().transform_vector3(-Vec3::Z);
let back = view.pulled_back(3.0);
assert!(
back.contains_point(forward * -2.9),
"{yaw} {pitch}: 2.9 behind"
);
assert!(
!back.contains_point(forward * -3.0),
"{yaw} {pitch}: 3 behind"
);
assert!(
!view.contains_point(forward * -2.9),
"{yaw} {pitch}: unmoved"
);
assert!(back.contains_point(forward * 20.0) && view.contains_point(forward * 20.0));
}
}
/// An orthographic box has no apex to pull back from: its side normals cancel, so there is
/// no direction, and the frustum must come back as it was.
#[test]
fn an_orthographic_frustum_stays_where_it_is() {
let ortho =
Frustum::from_view_projection(Mat4::orthographic_rh(-8.0, 8.0, -4.5, 4.5, 100.0, 0.05));
let moved = ortho.pulled_back(3.0);
for p in [
Vec3::new(0.0, 0.0, 2.0),
Vec3::new(0.0, 0.0, -9.0),
Vec3::new(7.9, 4.4, -9.0),
Vec3::new(8.1, 0.0, -9.0),
Vec3::new(0.0, 0.0, -101.0),
] {
assert_eq!(ortho.contains_point(p), moved.contains_point(p), "{p}");
}
}
/// The shadow pass's box has no depth planes, so no direction: it must not move.
#[test]
fn a_frustum_without_depth_planes_stays_where_it_is() {
let mut flat = looking_down_minus_z();
flat.planes[4] = glam::Vec4::W;
flat.planes[5] = glam::Vec4::W;
let moved = flat.pulled_back(3.0);
for p in [
Vec3::new(0.0, 0.0, 2.0),
Vec3::new(0.0, 0.0, -9.0),
Vec3::new(80.0, 0.0, -9.0),
] {
assert_eq!(flat.contains_point(p), moved.contains_point(p));
}
}
}
@@ -853,7 +853,7 @@ impl H {
fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
let g = &s.horizon;
match self {
H::Preset => labelled("Horizon Preset", g.preset.label()),
H::Preset => labelled("Preset", g.preset.label()),
H::LodDistance => match g.lod_distance {
0 => labelled("LOD Distance", "Off"),
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
@@ -1753,14 +1753,6 @@ mod tests {
ui.set_search("tearing");
let found = ui.listed(&s, SIZE);
assert!(found.iter().any(|(_, i)| *i == Item::Vanilla(Opt::Vsync)));
// Both presets answer "preset"; Horizon's own, named apart from vanilla's, answers its name.
ui.set_search("preset");
let found = ui.listed(&s, SIZE);
let general = |item| (Page::General, item);
assert!(found.contains(&general(Item::Horizon(H::Preset))));
assert!(found.contains(&general(Item::Vanilla(Opt::Preset))));
ui.set_search("horizon preset");
assert_eq!(ui.listed(&s, SIZE), [general(Item::Horizon(H::Preset))]);
ui.set_search("hdr");
let [x, y, w, h] = cols.page(Page::ALL.len() - 1);
press(&mut ui, &mut s, (x + w / 2.0, y + h / 2.0));
+2 -2
View File
@@ -55,7 +55,7 @@ pub struct TextureFlags {
impl TextureFlags {
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
pub fn from_mcmeta(json: &str) -> Option<Self> {
let meta: serde_json::Value = crate::packs::first_json(json.as_bytes()).ok()?;
let meta = crate::packs::first_json(json.as_bytes())?;
let section = meta.get("texture")?;
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
let flags = Self {
@@ -388,7 +388,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
}
/// The `animation` section of a texture's `.mcmeta`.
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
let meta: serde_json::Value = crate::packs::first_json(mcmeta?.as_bytes()).ok()?;
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
}
/// An animated strip's first frame: the region the mcmeta's frame size picks
@@ -39,13 +39,14 @@ pub fn text_with_backdrop(
background: u32,
) {
if background != 0 {
// GuiGraphicsExtractor.textWithBackdrop adds two pixels around the
// text's advance and the font's nine-pixel line height.
// Font.PreparedTextBuilder.markBackground: one pixel above and left of
// the glyphs, the glyph advance to the right and nine high: not a
// fixed two-pixel frame.
out.push(fill(
x - 2,
y - 2,
x + w + 2,
y + 11,
x - 1,
y - 1,
x + w,
y + 9,
multiply(background, color),
));
}
@@ -1490,10 +1491,10 @@ mod boss_bar_room_tests {
mod backdrop_tests {
use super::*;
/// `GuiGraphicsExtractor.textWithBackdrop`: two pixels around the advance
/// and the nine-pixel font line, independently of glyph-background drawing.
/// `markBackground`: the backdrop reaches one pixel above and left of the
/// glyphs, their advance to the right and nine down, not 2 on every side.
#[test]
fn the_backdrop_frames_the_text_by_two_pixels() {
fn the_backdrop_frames_the_glyphs_by_one_pixel() {
let mut out = Vec::new();
text_with_backdrop(
&mut out,
@@ -1507,6 +1508,6 @@ mod backdrop_tests {
let Some(GuiDraw::Fill { x0, y0, x1, y1, .. }) = out.first() else {
panic!("a backdrop is a fill first");
};
assert_eq!((*x0, *y0, *x1, *y1), (8, 18, 18, 31));
assert_eq!((*x0, *y0, *x1, *y1), (9, 19, 16, 29));
}
}
+46 -34
View File
@@ -1594,9 +1594,9 @@ pub struct OptionsUi {
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
/// Shaders: the shader packs and the picked one's options, on the settings shell.
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
/// Pack Options, on the settings shell, and the packs' Polytone config entries by pack id
/// (the game fills them beside `pack_infos`).
pack_options: shell::Shell<crate::pack_options_ui::PackOptionsScreen>,
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
/// fills them beside `pack_infos`).
pub pack_options: crate::pack_options_ui::PackOptionsUi,
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
@@ -3401,6 +3401,10 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if self.page() == Page::PackOptions {
self.pack_options.draw(batch, atlas, cursor, self.in_world);
return;
}
let waiting = self.waiting();
let in_world = self.in_world;
if self.draw_radial_name(batch, atlas, cursor, in_world) {
@@ -3782,6 +3786,20 @@ impl OptionsUi {
});
return self.shell_events(s, outputs);
}
if self.page() == Page::PackOptions {
let mut events = Vec::new();
for action in self
.pack_options
.click((x, y), size, drag, right || self.shift)
{
match action {
crate::pack_options_ui::Action::ButtonClick => events.push(Event::ButtonClick),
crate::pack_options_ui::Action::SliderDrag => {}
crate::pack_options_ui::Action::Done => events.extend(self.back(s)),
}
}
return events;
}
if self.page() == Page::Packs {
let mut events = Vec::new();
if drag {
@@ -3811,7 +3829,14 @@ impl OptionsUi {
crate::pack_screen::Action::BrowsePick(i) => {
events.push(Event::DownloadPack(i));
}
crate::pack_screen::Action::Options(id) => self.open_pack_options(&id),
crate::pack_screen::Action::Options(id) => {
let mut configs = horizon_polytone::pack::Configs::default();
if let Some(own) = self.pack_configs.get(&id) {
configs.merge(own);
}
self.pack_options = crate::pack_options_ui::PackOptionsUi::open(configs);
self.push(Page::PackOptions);
}
crate::pack_screen::Action::None => {}
}
return events;
@@ -4049,6 +4074,7 @@ impl OptionsUi {
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some();
let shell_slider = self.shell_mut().and_then(|shell| shell.release());
self.pack_options.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || shell_slider.is_some() || calibration_slider {
@@ -4142,6 +4168,10 @@ impl OptionsUi {
self.studio.wheel(amount);
return Vec::new();
}
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return Vec::new();
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
if !plain {
@@ -4225,6 +4255,10 @@ impl OptionsUi {
self.studio.wheel(amount);
return;
}
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return;
}
match &mut self.pack_screen {
Some(p) if self.stack.last() == Some(&Page::Packs) => {
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
@@ -4359,8 +4393,14 @@ impl OptionsUi {
self.can_change_value = false;
match page {
Page::PackOptions => {
let screen = std::mem::take(&mut self.pack_options.screen);
events.extend(screen.finished());
let ui = std::mem::take(&mut self.pack_options);
if ui.changed {
let entries = ui.configs.entries().keys().cloned().collect();
events.push(Event::PackOptions {
entries,
chosen: ui.configs.chosen(),
});
}
}
Page::ShaderOptions | Page::ShaderPacks => {
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
@@ -4920,34 +4960,6 @@ mod tests {
/// A renderer whose destination offers nothing and whose display reports nothing.
const NONE: EffectiveOutput = EffectiveOutput::NONE;
#[test]
fn pack_options_leave_with_a_change_and_with_nothing_after_its_undo() {
use crate::settings_shell::Screen as _;
let mut s = Settings::default();
let atlas = HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"));
let atlas = atlas.unwrap();
let mut ui = OptionsUi::default();
let mut configs = horizon_polytone::pack::Configs::default();
let birds = serde_json::json!({"default_value": true});
configs.add_entry("p:birds".into(), &birds);
ui.pack_configs.insert("pack".into(), configs);
for undone in [false, true] {
ui.open_pack_options("pack");
assert!(ui.shell().is_some(), "Pack Options is on the shell");
let birds = crate::pack_options_ui::Item::Entry(0);
ui.pack_options.screen.press(&mut s, birds, false);
if undone {
let before = s.clone();
ui.pack_options.screen.undo(&mut s, &before);
}
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None);
let sent = events
.iter()
.any(|e| matches!(e, Event::PackOptions { .. }));
assert_eq!(sent, !undone, "undone: {undone}");
}
}
/// The window allows the input method only while `typing` says a search
/// box has the focus (`Game::text_field_focused`), so a page without one
/// must not claim the keys: the movement and menu keys keep working there.
@@ -23,7 +23,6 @@ impl OptionsUi {
Page::ControllerRadialPick => Some(&self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
Page::PackOptions => Some(&self.pack_options),
_ => None,
}
}
@@ -36,20 +35,9 @@ impl OptionsUi {
Page::ControllerRadialPick => Some(&mut self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
Page::PackOptions => Some(&mut self.pack_options),
_ => None,
}
}
/// Pack Options for the pack `id`, from the pack screen: its Polytone config entries.
pub(super) fn open_pack_options(&mut self, id: &str) {
let mut configs = horizon_polytone::pack::Configs::default();
if let Some(own) = self.pack_configs.get(id) {
configs.merge(own);
}
// Opening a shell starts it afresh: the entries go in after.
self.push(Page::PackOptions);
self.pack_options.screen = crate::pack_options_ui::PackOptionsScreen::open(configs);
}
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
/// action), for a controller's focus to land there.
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
@@ -65,14 +53,13 @@ impl OptionsUi {
return;
}
// Every shell: the click may have changed the page.
let shells: [&mut dyn shell::Hosted; 7] = [
let shells: [&mut dyn shell::Hosted; 6] = [
&mut self.extras,
&mut self.graphics,
&mut self.controller,
&mut self.radial,
&mut self.radial_pick,
&mut self.shader,
&mut self.pack_options,
];
for shell in shells {
shell.clicks_step(false);
@@ -1,36 +1,45 @@
//! The Pack Options screen (#11486), on the settings shell (`settings_shell`): the config entries
//! of the selected resource packs' Polytone `config_entries` (wiki "Polytone Configs"). Each
//! `section` is a page, named by the packs' own translations; numbers are sliders that stop at
//! the entry's `step`, value lists are choices, booleans switch and free text reads. The first
//! page starts with the pack-wide presets and Reset all. The shell gives it the keyboard, a
//! controller's focus, the search, the strip's descriptions and Undo; leaving keeps what differs
//! from how the screen opened, and the packs load again with it.
//! The Pack Options page (#11486): the config entries of the selected resource packs' Polytone
//! `config_entries` (wiki "Polytone Configs"), in the frame of Horizon's settings screens
//! (`settings_shell`). Numbers are sliders, texts cycle, booleans switch; the entries sit in
//! their `section`s, named by the packs' own translations. Leaving with Done keeps what changed,
//! and the packs load again with it.
use crate::widgets::Action as Declared;
use crate::{
graphics_style as style,
hud::{HudAtlas, HudBatch},
lang,
settings::Settings,
settings_shell::{self as shell, Group, Output, Row, Sheet, Value},
settings_shell::{self as shell, Columns, Line, Lines},
text::Text,
ui,
};
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
/// A row: the pack-wide presets, Reset all, or the entry at an index of `ids`.
/// What a click asks of the options screen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Item {
Presets,
ResetAll,
Entry(u16),
pub enum Action {
ButtonClick,
SliderDrag,
/// Done: leave, keeping what changed.
Done,
}
const TITLE: &str = "horizon.pack.options.title";
/// The first page's group of rows for the whole pack, and every page's group of entries.
const WHOLE_PACK: &str = "Whole pack";
const PACK_OPTIONS: &str = "Pack settings";
const RESET_ALL: &str = "Reset all";
const RESET_ABOUT: &str = "Puts every option of this pack back to its default.";
const AT_DEFAULTS: &str = "Every option is at its default already.";
const PRESETS_ABOUT: &str = "Sets many options at once, as the pack groups them.";
const PACK_SETTINGS: &str = "Settings made by the pack's author: they change only this pack.";
/// A pack's tooltips, with the performance line after them, run longer than Horizon's own.
const NOTE_LINES: usize = 3;
/// The page's state: the entries being edited, how they are listed, and the pointer's hold.
#[derive(Default)]
pub struct PackOptionsUi {
/// The selected packs' entries and the values being picked.
pub configs: Configs,
/// Entry ids, by the row index the list uses.
ids: Vec<String>,
/// The sections in order: the `section` id (none for entries without one) and its rows.
sections: Vec<(Option<String>, Vec<u16>)>,
scroll: ui::Scroll,
dragging: Option<u16>,
/// A value changed since the page opened.
pub changed: bool,
}
/// The presets' row (and its section): past every entry's index.
const PRESETS: u16 = u16::MAX;
/// A translation, or `fallback` where no language has the key.
fn translated(key: &str, fallback: impl FnOnce() -> String) -> String {
@@ -56,34 +65,9 @@ fn readable(path: &str) -> String {
.unwrap_or_default()
}
/// A number as its entry shows it: whole with a whole step, else up to two decimals.
fn number_text(n: f64, step: f64) -> String {
if step >= 1.0 && step.fract() == 0.0 {
format!("{n:.0}")
} else {
let text = format!("{n:.2}");
text.trim_end_matches('0').trim_end_matches('.').to_owned()
}
}
/// The screen: the entries being edited, as they were when it opened (for Undo and for whether
/// leaving changes anything), and how they are paged.
#[derive(Default)]
pub(crate) struct PackOptionsScreen {
/// The selected packs' entries and the values being picked.
pub(crate) configs: Configs,
opened: Configs,
/// Entry ids, by `Item::Entry`'s index.
ids: Vec<String>,
/// The sections in order: the `section` id (none for entries without one) and its entries.
sections: Vec<(Option<String>, Vec<u16>)>,
/// Bumped on every change, for `stamp`: the values are floats, which don't hash.
revision: u64,
}
impl PackOptionsScreen {
/// The screen opens on `configs`, the selected packs' entries with the player's values.
pub(crate) fn open(configs: Configs) -> Self {
impl PackOptionsUi {
/// The page opens on `configs`, the selected packs' entries with the player's values.
pub fn open(configs: Configs) -> Self {
let mut ids: Vec<(String, Option<String>, i64)> = configs
.entries()
.iter()
@@ -112,21 +96,25 @@ impl PackOptionsScreen {
(None, None) => a.0.cmp(&b.0),
});
Self {
opened: configs.clone(),
configs,
ids: ids.into_iter().map(|(id, ..)| id).collect(),
sections: sections.into_iter().map(|(s, _, rows)| (s, rows)).collect(),
revision: 0,
..Self::default()
}
}
/// What leaving sends: the entries and the player's choices, if they differ from how the
/// screen opened (an Undo, or a value stepped back, leaves nothing to send).
pub(crate) fn finished(&self) -> Option<crate::options_ui::Event> {
(self.configs != self.opened).then(|| crate::options_ui::Event::PackOptions {
entries: self.configs.entries().keys().cloned().collect(),
chosen: self.configs.chosen(),
})
fn layout(&self, w: f32, h: f32) -> (Columns, Vec<Line<u16, u16>>, f32) {
let cols = Columns::of(w, h);
let mut lines = Lines::new();
// The pack-wide presets first, when the entries have some.
if !self.configs.presets().is_empty() {
lines.titled(PRESETS, vec![PRESETS]);
}
for (i, (_, rows)) in self.sections.iter().enumerate() {
lines.titled(i as u16, rows.clone());
}
let (lines, content) = lines.finish();
(cols, lines, content)
}
/// The namespace the presets' translations are under: of the first entry with presets.
@@ -178,14 +166,21 @@ impl PackOptionsScreen {
None if back => n - 1,
None => 0,
};
let before = self.configs.clone();
self.configs.apply_preset(&presets[next]);
self.revision += 1;
if self.configs != before {
self.changed = true;
}
}
/// A section's page name: `config.<namespace>.section.<id>`, by its first entry's namespace.
fn section_name(&self, section: usize) -> String {
let Some((id, rows)) = self.sections.get(section) else {
return lang::get("horizon.pack.options.general");
/// A section's heading: `config.<namespace>.section.<id>`, by its first entry's namespace.
fn section_name(&self, section: u16) -> String {
if section == PRESETS {
let key = format!("{}.presets", self.presets_namespace());
return translated(&key, || lang::get("horizon.pack.options.presets"));
}
let Some((id, rows)) = self.sections.get(usize::from(section)) else {
return String::new();
};
let Some(id) = id else {
return lang::get("horizon.pack.options.general");
@@ -197,218 +192,367 @@ impl PackOptionsScreen {
translated(&format!("config.{namespace}.section.{id}"), || readable(id))
}
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance line after it.
fn description(&self, id: &str) -> String {
/// An entry's name (`config.<namespace>.<name>`) and its value's label.
fn parts(&self, row: u16) -> (String, Option<String>) {
if row == PRESETS {
let label = self.preset_label(self.current_preset().as_deref());
return (self.section_name(PRESETS), Some(label));
}
let Some(id) = self.ids.get(usize::from(row)) else {
return (String::new(), None);
};
let (namespace, path) = split(id);
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
let entry = &self.configs.entries()[id];
let value = match self.configs.get(id) {
Some(ConfigValue::Number(n)) => {
let step = match entry.kind {
ConfigKind::Number { step, .. } => step,
_ => 1.0,
};
let text = if step >= 1.0 && step.fract() == 0.0 {
format!("{n:.0}")
} else {
format!("{n:.2}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
};
Some(text)
}
Some(ConfigValue::Text(t)) => Some(t.clone()),
Some(ConfigValue::Bool(_)) | None => None,
};
let value = value.map(|v| match &entry.value_translation {
Some(key) if lang::has(key) => lang::tr(key, &[&v]),
_ => v,
});
(name, value)
}
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance hint.
fn description(&self, row: u16) -> String {
let Some(id) = self.ids.get(usize::from(row)) else {
return String::new();
};
let (namespace, path) = split(id);
let key = format!("config.{namespace}.{path}.tooltip");
let tooltip = if lang::has(&key) {
let mut text = if lang::has(&key) {
lang::get(&key)
} else {
String::new()
};
let impact = self.configs.entries()[id]
.performance_impact
.as_ref()
.map(|impact| {
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
lang::tr("horizon.pack.options.impact", &[&level])
});
match impact {
Some(line) if tooltip.is_empty() => line,
Some(line) => format!("{tooltip} {line}"),
None => tooltip,
if let Some(impact) = &self.configs.entries()[id].performance_impact {
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
let line = lang::tr("horizon.pack.options.impact", &[&level]);
text = if text.is_empty() {
line
} else {
format!("{text} {line}")
};
}
text
}
/// A slider's place (0 to 1) for a number entry.
fn slider(&self, row: u16) -> Option<f32> {
let id = self.ids.get(usize::from(row))?;
let ConfigKind::Number { min, max, .. } = self.configs.entries()[id].kind else {
return None;
};
let Some(ConfigValue::Number(n)) = self.configs.get(id) else {
return None;
};
Some(if max > min {
((n - min) / (max - min)) as f32
} else {
0.0
})
}
fn switch(&self, row: u16) -> Option<bool> {
match self.configs.get(self.ids.get(usize::from(row))?)? {
ConfigValue::Bool(b) => Some(*b),
_ => None,
}
}
/// Entry `i`'s row: a switch, a slider in its steps, a choice, or text to read.
fn entry_row(&self, i: u16) -> Option<Row<Item>> {
let id = self.ids.get(usize::from(i))?;
let (namespace, path) = split(id);
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
let entry = &self.configs.entries()[id];
let shown = |text: String| match &entry.value_translation {
Some(key) if lang::has(key) => lang::tr(key, &[&text]),
_ => text,
};
let value = match (&entry.kind, self.configs.get(id)?) {
(_, ConfigValue::Bool(on)) => Value::Switch(*on),
(&ConfigKind::Number { min, max, step }, &ConfigValue::Number(n)) => {
let span = max - min;
let stops = if step > 0.0 {
(span / step).round()
} else {
0.0
};
Value::Slider {
position: if span > 0.0 {
((n - min) / span) as f32
} else {
0.0
},
steps: (stops >= 1.0).then_some(stops as u32),
text: shown(number_text(n, step)),
pub fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
let (cols, lines, content) = self.layout(w, h);
self.scroll.clamp(cols.top, cols.bottom, content);
shell::backdrop(batch, atlas, in_world);
shell::mark(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
shell::plate(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
let mut described = None;
shell::list(
batch,
atlas,
&cols,
&lines,
self.scroll.amount,
|section| self.section_name(section),
|batch, row: u16, y| {
let [rx, ry, rw, rh] = cols.row(y);
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
if hover || self.dragging == Some(row) {
described = Some(row);
}
let (name, value) = self.parts(row);
let label = match &value {
Some(v) => format!("{name}: {v}"),
None => name.clone(),
};
let view = shell::RowView {
label: &label,
name: &name,
value: value.as_deref(),
switch: self.switch(row),
slider: self.slider(row),
live: true,
hover,
focused: false,
held: self.dragging == Some(row),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
},
);
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
// The strip: the pointed-at entry's tooltip.
let [x, y, ..] = cols.dock;
style::panel(batch, atlas, cols.dock);
if let Some(row) = described {
let name = Text::literal(self.parts(row).0);
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
shell::dock_note(batch, atlas, &cols, self.description(row), style::GREY);
}
let mut foot = vec![(cols.button(0), "gui.done", Some(Declared::Done))];
if self
.configs
.chosen()
.iter()
.any(|(id, _)| self.ids.contains(id))
{
foot.push((cols.button(1), "controls.reset", None));
}
for (rect, key, declared) in foot {
if let Some(declared) = declared {
crate::widgets::declare_action(declared, rect, true);
}
(ConfigKind::Text { allowed }, ConfigValue::Text(t)) if !allowed.is_empty() => {
Value::Choice(shown(t.clone()))
}
(_, value) => Value::Read(shown(match value {
ConfigValue::Number(n) => number_text(*n, 1.0),
ConfigValue::Text(t) => t.clone(),
ConfigValue::Bool(b) => b.to_string(),
})),
};
Some(Row::new(Item::Entry(i), name, value).about(self.description(id)))
let label = Text::literal(lang::get(key));
shell::foot_button(batch, atlas, rect, &label, cursor, (false, true));
}
}
/// The rows for the whole pack: its presets, when it has some, and Reset all.
fn whole_pack(&self) -> Group<Item> {
let mut rows = Vec::new();
if !self.configs.presets().is_empty() {
let key = format!("{}.presets", self.presets_namespace());
let name = translated(&key, || lang::get("horizon.pack.options.presets"));
let label = self.preset_label(self.current_preset().as_deref());
rows.push(
Row::new(Item::Presets, name, Value::Choice(label)).about(lang::get(PRESETS_ABOUT)),
);
/// A press (or a drag with `drag`) at `(x, y)`; `back` turns a cycle backwards.
pub fn click(
&mut self,
(x, y): (f32, f32),
(w, h): (f32, f32),
drag: bool,
back: bool,
) -> Vec<Action> {
let (cols, lines, content) = self.layout(w, h);
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
if drag {
if self.scroll.held() {
self.scroll.drag(y, cols.top, cols.bottom, content);
} else if let Some(row) = self.dragging {
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
return Vec::new();
}
let at_defaults = self.configs.chosen().is_empty();
let reset = Row::new(Item::ResetAll, lang::get(RESET_ALL), Value::Press)
.about(lang::get(RESET_ABOUT))
.blocked(at_defaults.then(|| lang::get(AT_DEFAULTS)));
rows.push(reset);
Group {
name: WHOLE_PACK,
rows,
if cols.scrolls()
&& self
.scroll
.press((x, y), cols.bar_x(), cols.top, cols.bottom, content)
{
return Vec::new();
}
if at(cols.button(0)) {
return vec![Action::ButtonClick, Action::Done];
}
if at(cols.button(1)) {
self.reset();
return vec![Action::ButtonClick];
}
if y < cols.top || y >= cols.bottom {
return Vec::new();
}
let Some(row) = lines.iter().find_map(|line| {
let row = line.setting?;
at(cols.row(cols.top + line.y - self.scroll.amount)).then_some(row)
}) else {
return Vec::new();
};
if row == PRESETS {
self.cycle_preset(back);
return vec![Action::ButtonClick];
}
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return Vec::new();
};
let entry = self.configs.entries()[&id].clone();
match (&entry.kind, self.configs.get(&id).cloned()) {
(ConfigKind::Number { .. }, _) => {
self.dragging = Some(row);
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
(ConfigKind::Bool, Some(ConfigValue::Bool(b))) => {
self.set(&id, ConfigValue::Bool(!b));
}
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
let i = allowed.iter().position(|a| *a == t).unwrap_or(0);
let n = allowed.len();
let next = if back { (i + n - 1) % n } else { (i + 1) % n };
self.set(&id, ConfigValue::Text(allowed[next].clone()));
}
_ => return Vec::new(),
}
vec![Action::ButtonClick]
}
fn set(&mut self, id: &str, value: ConfigValue) {
let before = self.configs.get(id).cloned();
self.configs.set(id, value);
self.revision += 1;
if self.configs.get(id).cloned() != before {
self.changed = true;
}
}
/// Number entry `row` at slider place `at` (0 to 1).
fn set_slider(&mut self, row: u16, at: f32) {
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return;
};
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
self.set(&id, ConfigValue::Number(min + f64::from(at) * (max - min)));
}
}
/// Every entry back to its default.
fn reset(&mut self) {
let defaults: Vec<_> = (self.configs.entries().iter())
let defaults: Vec<_> = self
.configs
.entries()
.iter()
.map(|(id, e)| (id.clone(), e.default.clone()))
.collect();
for (id, value) in defaults {
self.set(&id, value);
}
}
}
impl shell::Screen for PackOptionsScreen {
type Page = u16;
type Row = Item;
fn title(&self) -> &'static str {
TITLE
/// The pointer lets go.
pub fn release(&mut self) {
self.scroll.release();
self.dragging = None;
}
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read only the screen's own entries, which change through it alone.
fn stamp(&self, _s: &Settings) -> u64 {
self.revision
}
/// A page per section; the first starts with the rows for the whole pack.
fn sheets(&self, _s: &Settings) -> Vec<Sheet<u16, Item>> {
let about = lang::get(PACK_SETTINGS);
let mut sheets = Vec::new();
for (i, (_, entries)) in self.sections.iter().enumerate() {
let rows = entries.iter().filter_map(|&e| self.entry_row(e)).collect();
let mut groups = vec![Group {
name: PACK_OPTIONS,
rows,
}];
if i == 0 {
groups.insert(0, self.whole_pack());
}
sheets.push(Sheet::new(
i as u16,
self.section_name(i),
about.clone(),
groups,
));
}
if sheets.is_empty() {
let groups = vec![self.whole_pack()];
sheets.push(Sheet::new(0, self.section_name(0), about, groups));
}
sheets
}
/// A switch flips, a choice and the presets step (back with Shift, or Left), Reset all
/// resets.
fn press(&mut self, _s: &mut Settings, item: Item, back: bool) -> Vec<Output> {
let i = match item {
Item::Presets => {
self.cycle_preset(back);
return Vec::new();
}
Item::ResetAll => {
self.reset();
return Vec::new();
}
Item::Entry(i) => i,
};
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
return Vec::new();
};
let kind = self.configs.entries()[&id].kind.clone();
match (kind, self.configs.get(&id).cloned()) {
(_, Some(ConfigValue::Bool(on))) => self.set(&id, ConfigValue::Bool(!on)),
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
let at = allowed.iter().position(|a| *a == t).unwrap_or(0);
let n = allowed.len();
let next = if back { (at + n - 1) % n } else { (at + 1) % n };
self.set(&id, ConfigValue::Text(allowed[next].clone()));
}
_ => {}
}
Vec::new()
}
/// A slider moves its entry to the step nearest it (`Configs::set` snaps it).
fn slide(&mut self, _s: &mut Settings, item: Item, position: f32) {
let Item::Entry(i) = item else { return };
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
return;
};
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
let value = min + f64::from(position.clamp(0.0, 1.0)) * (max - min);
self.set(&id, ConfigValue::Number(value));
}
}
/// With the "Scroll wheel changes settings" extra, the wheel steps a choice and the presets.
fn wheel_step(&mut self, s: &mut Settings, item: Item, back: bool) -> Option<Vec<Output>> {
let steps = match item {
Item::Presets => true,
Item::Entry(i) => self
.entry_row(i)
.is_some_and(|row| matches!(row.value, Value::Choice(_))),
Item::ResetAll => false,
};
if !steps {
return None;
}
let mut outputs = vec![Output::Event(crate::options_ui::Event::ButtonClick)];
outputs.extend(self.press(s, item, back));
Some(outputs)
}
fn undoes(&self) -> bool {
true
}
/// The entries as the screen opened with them.
fn undo(&mut self, _s: &mut Settings, _before: &Settings) -> Vec<Output> {
self.configs = self.opened.clone();
self.revision += 1;
Vec::new()
}
/// Undo shows exactly while an entry differs from how the screen opened.
fn differs(&self, _s: &Settings, _before: &Settings) -> Option<bool> {
Some(self.configs != self.opened)
/// The mouse wheel scrolls the list.
pub fn scroll(&mut self, (w, h): (f32, f32), amount: f32) {
let (cols, _, content) = self.layout(w, h);
self.scroll
.wheel(amount, 18.0, cols.top, cols.bottom, content);
}
}
#[cfg(test)]
mod tests;
mod tests {
use super::*;
fn configs() -> Configs {
let mut c = Configs::default();
c.add_entry(
"p:fog".into(),
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
);
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
c.add_entry(
"p:birds".into(),
&serde_json::json!({"default_value": true}),
);
c
}
#[test]
fn sections_sort_by_their_order_and_the_unsectioned_last() {
let ui = PackOptionsUi::open(configs());
let order: Vec<_> = ui.sections.iter().map(|(s, _)| s.as_deref()).collect();
assert_eq!(order, [Some("weather"), Some("sky"), None]);
}
#[test]
fn rows_switch_cycle_and_slide() {
let mut ui = PackOptionsUi::open(configs());
let size = (480.0, 270.0);
let (cols, lines, _) = ui.layout(size.0, size.1);
let ids = ui.ids.clone();
let point = |id: &str| {
let row = ids.iter().position(|i| i == id).unwrap() as u16;
let line = lines.iter().find(|l| l.setting == Some(row)).unwrap();
let [x, y, w, h] = cols.row(cols.top + line.y);
(x + w - 4.0, y + h / 2.0)
};
let (birds, rain) = (point("p:birds"), point("p:rain"));
ui.click(birds, size, false, false);
assert_eq!(ui.configs.get("p:birds"), Some(&ConfigValue::Bool(false)));
ui.click(rain, size, false, false);
assert_eq!(
ui.configs.get("p:rain"),
Some(&ConfigValue::Text("streaks".into()))
);
// The slider's far right end is its maximum.
let fog = point("p:fog");
let (tx, tw) = cols.track();
ui.click((tx + tw, fog.1), size, false, false);
ui.release();
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(1.0)));
assert!(ui.changed);
assert_eq!(ui.configs.chosen().len(), 3);
ui.reset();
assert!(ui.configs.chosen().is_empty());
}
#[test]
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
let mut c = Configs::default();
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
let mut ui = PackOptionsUi::open(c);
// The defaults are no preset.
assert_eq!(ui.current_preset(), None);
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("pretty"));
ui.cycle_preset(true);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
// One value off the preset: custom again.
ui.set("p:dust", ConfigValue::Number(0.3));
assert_eq!(ui.current_preset(), None);
assert!(ui.changed);
}
}
@@ -1,281 +0,0 @@
use super::*;
use crate::options_ui::Event;
use crate::settings_shell::{Columns, Hosted, Screen as _, Shell, Target};
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
/// Fog (a number) under Sky, rain (a list of values) under Weather, birds (a boolean) in no
/// section.
fn configs() -> Configs {
let mut c = Configs::default();
c.add_entry(
"p:fog".into(),
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
);
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
c.add_entry(
"p:birds".into(),
&serde_json::json!({"default_value": true}),
);
c
}
/// Two entries with the pack-wide presets `fast` and `pretty`.
fn with_presets() -> Configs {
let mut c = Configs::default();
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
c
}
fn shell_of(configs: Configs) -> Shell<PackOptionsScreen> {
let mut shell = Shell::default();
shell.screen = PackOptionsScreen::open(configs);
shell
}
/// The row of entry `id`, and the page it is on.
fn entry(shell: &Shell<PackOptionsScreen>, id: &str) -> (u16, Item) {
let i = shell.screen.ids.iter().position(|e| e == id).unwrap() as u16;
let page = shell.screen.sections.iter().position(|s| s.1.contains(&i));
(page.unwrap() as u16, Item::Entry(i))
}
fn press(shell: &mut Shell<PackOptionsScreen>, s: &mut Settings, item: Item) -> Vec<Output> {
let at = shell.point_at(s, SIZE, item);
shell.click(s, at, SIZE, false, false, false)
}
fn value<'a>(shell: &'a Shell<PackOptionsScreen>, id: &str) -> Option<&'a ConfigValue> {
shell.screen.configs.get(id)
}
#[test]
fn sections_sort_by_their_order_and_the_unsectioned_last() {
let screen = PackOptionsScreen::open(configs());
let order: Vec<_> = screen.sections.iter().map(|(s, _)| s.as_deref()).collect();
assert_eq!(order, [Some("weather"), Some("sky"), None]);
// Each section is a page, the first one holding Reset all before its entries.
let sheets = screen.sheets(&Settings::default());
let names: Vec<_> = sheets.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Weather", "Sky", "horizon.pack.options.general"]);
let first: Vec<Vec<Item>> = (sheets[0].groups.iter())
.map(|g| g.rows.iter().map(|r| r.id).collect())
.collect();
let rain = screen.ids.iter().position(|e| e == "p:rain").unwrap() as u16;
assert_eq!(first, [vec![Item::ResetAll], vec![Item::Entry(rain)]]);
}
#[test]
fn rows_switch_cycle_and_slide() {
let mut s = Settings::default();
let mut shell = shell_of(configs());
let (page, birds) = entry(&shell, "p:birds");
shell.show(page);
assert_eq!(
press(&mut shell, &mut s, birds)[0],
Output::Event(Event::ButtonClick)
);
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(false)));
let (page, rain) = entry(&shell, "p:rain");
shell.show(page);
press(&mut shell, &mut s, rain);
let streaks = ConfigValue::Text("streaks".into());
assert_eq!(value(&shell, "p:rain"), Some(&streaks));
// The slider's far right end is its maximum.
let (page, fog) = entry(&shell, "p:fog");
shell.show(page);
let at = shell.point_at(&s, SIZE, fog);
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
shell.click(&mut s, (track + width, at.1), SIZE, false, false, false);
shell.release();
assert_eq!(value(&shell, "p:fog"), Some(&ConfigValue::Number(1.0)));
assert_eq!(shell.screen.configs.chosen().len(), 3);
// Reset all, on the first page, puts them all back, and is greyed once they are.
shell.show(0);
press(&mut shell, &mut s, Item::ResetAll);
assert!(shell.screen.configs.chosen().is_empty());
let sheets = shell.screen.sheets(&s);
assert!(!sheets[0].groups[0].rows[0].live);
}
#[test]
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
let mut screen = PackOptionsScreen::open(with_presets());
// The defaults are no preset.
assert_eq!(screen.current_preset(), None);
screen.cycle_preset(false);
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
assert_eq!(screen.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
screen.cycle_preset(false);
assert_eq!(screen.current_preset().as_deref(), Some("pretty"));
screen.cycle_preset(true);
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
// One value off the preset: custom again.
screen.set("p:dust", ConfigValue::Number(0.3));
assert_eq!(screen.current_preset(), None);
let presets = &screen.sheets(&Settings::default())[0].groups[0].rows[0];
assert_eq!(presets.id, Item::Presets);
let custom = lang::get("horizon.pack.options.presets.custom");
assert_eq!(presets.value, Value::Choice(custom));
}
#[test]
fn left_and_right_step_the_presets() {
let mut s = Settings::default();
let mut shell = shell_of(with_presets());
shell.set_focus(Some(Target::Row(Item::Presets)));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("pretty"));
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
assert_eq!(shell.focused_row(), Some(Item::Presets));
}
#[test]
fn sliders_stop_at_the_entrys_step() {
let mut c = Configs::default();
let json = serde_json::json!({"default_value": 4.0, "min": 0.0, "max": 10.0, "step": 2.0});
c.add_entry("p:clouds".into(), &json);
let mut s = Settings::default();
let mut shell = shell_of(c);
let row = &shell.screen.sheets(&s)[0].groups[1].rows[0];
let stops = Value::Slider {
position: 0.4,
steps: Some(5),
text: "4".into(),
};
assert_eq!(row.value, stops);
// A press a little past the middle of the track lands on the step nearest it.
let at = shell.point_at(&s, SIZE, Item::Entry(0));
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
shell.click(
&mut s,
(track + width * 0.55, at.1),
SIZE,
false,
false,
false,
);
shell.release();
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(6.0)));
// Held by the press, Right goes one step on and Left one back.
shell.set_focus(Some(Target::Row(Item::Entry(0))));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(8.0)));
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(4.0)));
}
/// A language with the pack's own strings: a name, a section and a tooltip.
struct PackLang;
impl lang::ResourceSource for PackLang {
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/minecraft/lang/en_us.json").then(|| {
let strings = serde_json::json!({
"config.minecraft.haze": "Haze",
"config.minecraft.haze.tooltip": "Thickens the distant air.",
"config.minecraft.section.sky": "Sky and fog",
});
serde_json::to_vec(&strings).unwrap()
})
}
}
fn haze() -> Configs {
let mut c = configs();
let json =
serde_json::json!({"default_value": false, "section": "sky", "performance_impact": "high"});
c.add_entry("minecraft:haze".into(), &json);
c
}
#[test]
fn the_search_finds_an_entry_by_its_tooltip_and_the_strip_has_its_impact() {
let language = lang::Language::load(&[&PackLang], "en_us");
lang::scoped(language, || {
let s = Settings::default();
let mut shell = shell_of(haze());
let (page, haze) = entry(&shell, "minecraft:haze");
shell.set_search("distant air");
assert_eq!(shell.listed(&s, SIZE), [(page, haze)]);
let sheets = shell.screen.sheets(&s);
assert_eq!(sheets[usize::from(page)].name, "Sky and fog");
let row = sheets.iter().flat_map(|p| &p.groups).flat_map(|g| &g.rows);
let row = row.into_iter().find(|r| r.id == haze).unwrap();
assert_eq!(row.name, "Haze");
assert_eq!(
row.about,
"Thickens the distant air. Performance impact: high"
);
});
}
#[test]
fn tab_reaches_every_row_of_every_page() {
let mut s = Settings::default();
let mut shell = shell_of(haze());
let pages = shell.screen.sheets(&s).len() as u16;
let mut seen = Vec::new();
for page in 0..pages {
shell.show(page);
shell.set_focus(None);
for _ in 0..8 {
shell.key(&mut s, K::Tab, None, false, SIZE);
if let Some(row) = shell.focused_row()
&& !seen.contains(&row)
{
seen.push(row);
}
}
}
// Reset all is greyed at the defaults; every entry is reached.
let mut entries: Vec<Item> = (0..shell.screen.ids.len() as u16)
.map(Item::Entry)
.collect();
seen.sort_by_key(|item| format!("{item:?}"));
entries.sort_by_key(|item| format!("{item:?}"));
assert_eq!(seen, entries);
}
#[test]
fn undo_puts_the_entries_back_and_then_leaving_sends_nothing() {
let mut s = Settings::default();
let mut shell = shell_of(configs());
let (page, birds) = entry(&shell, "p:birds");
shell.show(page);
press(&mut shell, &mut s, birds);
assert!(shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
assert!(shell.screen.finished().is_some());
let undo = shell
.foot_at(&s, SIZE)
.into_iter()
.find(|f| f.0 == Target::Undo);
let [x, y, w, h] = undo.unwrap().1;
shell.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
SIZE,
false,
false,
false,
);
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(true)));
assert!(!shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
assert_eq!(shell.screen.finished(), None);
// Stepped there and back by hand, it sends nothing either.
press(&mut shell, &mut s, birds);
press(&mut shell, &mut s, birds);
assert_eq!(shell.screen.finished(), None);
press(&mut shell, &mut s, birds);
let Some(Event::PackOptions { entries, chosen }) = shell.screen.finished() else {
panic!("a change is sent");
};
assert_eq!(entries.len(), 3);
assert_eq!(chosen.get("p:birds"), Some(&ConfigValue::Bool(false)));
}
+16 -232
View File
@@ -468,20 +468,17 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
/// serde_json rejects it.
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
first_json(bytes).ok()
first_json(bytes)
}
/// The first JSON value of a `.mcmeta` file, as `ResourceMetadata.fromJsonStream` reads it:
/// Gson's `TypeAdapter.read` stops after the value, so text after it (packs leave `//` notes
/// there) is never read, and a UTF-8 byte order mark is skipped.
pub(crate) fn first_json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> serde_json::Result<T> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
match serde_json::Deserializer::from_slice(bytes)
/// Reads the first JSON value from resource metadata, matching Gson's
/// `JsonReader` (which ignores a trailing signature or comment). Resource
/// packs commonly append such text to texture metadata as well as pack.mcmeta.
pub(crate) fn first_json(bytes: &[u8]) -> Option<serde_json::Value> {
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
.into_iter()
.next()
{
Some(value) => value,
None => Err(serde::de::Error::custom("empty metadata")),
}
.next()?
.ok()
}
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
/// in the pack's base, not its overlays) at their default values.
@@ -830,6 +827,13 @@ fn build_overlay_uncached(
}
// Vanilla sits below every pack, so filters hide its files too.
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
let touched_dirs: BTreeSet<String> = files
.keys()
.flat_map(|f| {
let parts: Vec<&str> = f.split('/').collect();
(1..parts.len()).map(move |n| parts[..n].join("/"))
})
.collect();
let zips: Vec<Option<Zip>> = packs
.iter()
.map(|p| {
@@ -878,21 +882,6 @@ fn build_overlay_uncached(
merged.insert(name.clone(), bytes);
}
}
add_sprite_renames(
vanilla,
&merged,
&filters,
&mut files,
&mut hidden,
dependencies,
);
let touched_dirs: BTreeSet<String> = files
.keys()
.flat_map(|f| {
let parts: Vec<&str> = f.split('/').collect();
(1..parts.len()).map(move |n| parts[..n].join("/"))
})
.collect();
/// A zip entry to write: its pack, its name in the zip and where it goes.
type Extract<'a> = (usize, &'a str, PathBuf);
#[allow(clippy::too_many_arguments)]
@@ -1043,123 +1032,6 @@ fn build_overlay_uncached(
}
extracted
}
/// `SingleFile` with a `sprite`: the atlas holds the resource's pixels, and the resource's
/// `.mcmeta`, under the sprite's id. Every other sprite of the blocks and items atlases is its
/// texture's own id, and the loaders read a model's sprite from `textures/<sprite>.png`, so the
/// overlay gives a renamed sprite that file. ItemsAdder and Nexo packs name their models'
/// textures only through these renames (#14485).
///
/// A sprite gets its resource's file only when the whole source list, in order, ends on the
/// rename (`SpriteSourceList.list`: a later source of the same id replaces it), when the other
/// atlas, which reads the same file, lists it from that resource too or not at all, and when it
/// has no texture of its own. A texture that is there may be read as itself (another source's
/// resource, an unstitched or paletted sprite's input), so no alias ever replaces one; such a
/// sprite draws its own texture, as before aliases.
fn add_sprite_renames(
vanilla: &Path,
merged: &BTreeMap<String, Vec<u8>>,
filters: &[&Filter],
files: &mut BTreeMap<String, (usize, Provided)>,
hidden: &mut BTreeSet<String>,
dependencies: &mut cache::Dependencies,
) {
use crate::atlas_sources::{
SpriteOrigin, SpriteSource, decode, list, list_textures, texture_path,
};
let png = |id: &str| {
texture_path(Path::new(""), id, "textures")
.to_string_lossy()
.replace('\\', "/")
};
let atlases: Vec<_> = ["blocks", "items"]
.into_iter()
.filter_map(|atlas| merged.get(&format!("assets/minecraft/atlases/{atlas}.json")))
.filter_map(|bytes| decode(std::str::from_utf8(bytes).ok()?))
.collect();
let renamed = |source: &SpriteSource| matches!(source, SpriteSource::Single { resource, sprite: Some(sprite) } if sprite != resource);
if !atlases.iter().flatten().any(renamed) {
return;
}
// The textures a source can list: the packs' and vanilla's the packs leave visible.
let mut textures: Vec<String> = list_textures(vanilla)
.into_iter()
.filter(|id| {
let name = png(id);
!hidden.contains(&name) && !filters.iter().any(|f| f.blocks(&name))
})
.chain(files.keys().filter_map(|name| {
let (namespace, rest) = name.strip_prefix("assets/")?.split_once('/')?;
let path = rest.strip_prefix("textures/")?.strip_suffix(".png")?;
Some(format!("{namespace}:{path}"))
}))
.collect();
textures.sort();
textures.dedup();
// Each sprite's resource, `None` where an atlas lists it any other way.
let mut renames: BTreeMap<String, Option<String>> = BTreeMap::new();
for sources in &atlases {
for (sprite, origin) in list(sources, &textures) {
let from = match origin {
SpriteOrigin::File(resource) if resource != sprite => Some(resource),
_ => None,
};
renames
.entry(sprite)
.and_modify(|seen| {
if *seen != from {
*seen = None;
}
})
.or_insert(from);
}
}
let renames = renames
.into_iter()
.filter(|(sprite, _)| textures.binary_search(sprite).is_err())
.filter_map(|(sprite, resource)| Some((png(&resource?), png(&sprite))));
// Each resource is the file the packs give, never another rename's sprite.
let mut found = Vec::new();
for (from, to) in renames {
// `texture_path` names an id that would leave the root as a file that is never there.
if !stays_inside(&from) || !stays_inside(&to) {
continue;
}
let from_meta = format!("{from}.mcmeta");
// A pack's texture with its own `.mcmeta`, or vanilla's below every pack.
let (file, meta) = match files.get(&from) {
Some(file) => (file.clone(), files.get(&from_meta).cloned()),
None => {
let (path, meta_path) = (vanilla.join(&from), vanilla.join(&from_meta));
dependencies.file(&path);
dependencies.file(&meta_path);
if !path.is_file() || filters.iter().any(|f| f.blocks(&from)) {
continue;
}
// `createStackMetadataFinder`: a pack's filter hides vanilla's `.mcmeta` too.
let meta = files.get(&from_meta).cloned().or_else(|| {
(meta_path.is_file() && !filters.iter().any(|f| f.blocks(&from_meta)))
.then(|| (0, (Some(meta_path), from_meta.clone())))
});
((0, (Some(path), from.clone())), meta)
}
};
found.push((to, file, meta));
}
for (to, file, meta) in found {
let to_meta = format!("{to}.mcmeta");
files.insert(to, file);
match meta {
Some(meta) => {
hidden.remove(&to_meta);
files.insert(to_meta, meta);
}
None => {
files.remove(&to_meta);
hidden.insert(to_meta);
}
}
}
}
/// The most the zip packs in `packs` may unpack to in one overlay: 16 times their size on disk
/// (real packs are mostly PNGs and JSON, a few times smaller zipped at most), at least 64 MiB
/// for a small pack of text, and never past 8 GiB.
@@ -2034,94 +1906,6 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn a_renamed_atlas_sprite_reads_its_resource() {
let base =
std::env::temp_dir().join(format!("horizon-sprite-rename-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let write = |path: PathBuf, bytes: &[u8]| {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
};
let vanilla = base.join("vanilla");
let textures = vanilla.join("assets/minecraft/textures");
let animation = b"{\"animation\":{}}";
write(textures.join("block/sea_lantern.png"), b"lantern");
write(textures.join("block/sea_lantern.png.mcmeta"), animation);
write(textures.join("item/stick.png"), b"vanilla stick");
write(textures.join("item/stick.png.mcmeta"), animation);
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
let pack = base.join("packs/IA");
write(pack.join("pack.mcmeta"), meta);
write(pack.join("assets/iageneric/textures/item/box.png"), b"box");
// An ItemsAdder pack's items atlas: its models name only the `ia:` sprites.
write(
pack.join("assets/minecraft/atlases/items.json"),
br#"{"sources":[
{"type":"single","resource":"iageneric:item/box","sprite":"ia:0"},
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:1199"},
{"type":"single","resource":"iageneric:item/box","sprite":"minecraft:item/stick"},
{"type":"single","resource":"minecraft:item/stick","sprite":"ia:3"},
{"type":"single","resource":"iageneric:item/gone","sprite":"ia:2"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:../../../escape"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:7"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:8"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:9"}
]}"#,
);
// A pack above it: later sources of three of those sprites, a blocks atlas that
// disagrees about another, and a filter hiding vanilla's animation of the stick.
let higher = base.join("packs/Top");
write(
higher.join("pack.mcmeta"),
br#"{"pack":{"description":"d","pack_format":97},
"filter":{"block":[{"path":"textures/item/stick\\.png\\.mcmeta"}]}}"#,
);
write(
higher.join("assets/minecraft/atlases/items.json"),
br#"{"sources":[
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:8"},
{"type":"filter","pattern":{"namespace":"ia","path":"7"}}
]}"#,
);
write(
higher.join("assets/minecraft/atlases/blocks.json"),
br#"{"sources":[
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:9"}
]}"#,
);
let packs = discover(&vanilla, &base.join("packs"));
let out = base.join("overlay");
build_overlay(&vanilla, &packs, &out).unwrap();
let read = |rel: &str| fs::read(out.join(rel)).ok();
let texture = |id: &str| read(&format!("assets/ia/textures/{id}.png"));
assert_eq!(texture("0").as_deref(), Some(&b"box"[..]));
assert_eq!(read("assets/ia/textures/0.png.mcmeta"), None);
// A vanilla resource comes with its animation.
assert_eq!(texture("1199").as_deref(), Some(&b"lantern"[..]));
assert_eq!(
read("assets/ia/textures/1199.png.mcmeta").as_deref(),
Some(&animation[..])
);
// A texture that is there stays itself: other sources may read it (here as `ia:3`'s
// resource; an unstitched or paletted sprite's input alike).
assert_eq!(
read("assets/minecraft/textures/item/stick.png").as_deref(),
Some(&b"vanilla stick"[..])
);
// ...with its `.mcmeta` only where no filter hides it.
assert_eq!(texture("3").as_deref(), Some(&b"vanilla stick"[..]));
assert_eq!(read("assets/ia/textures/3.png.mcmeta"), None);
// A missing resource adds nothing, and no rename leaves the overlay.
assert_eq!(texture("2"), None);
assert!(!out.join("escape.png").exists());
// The last source of a sprite wins: a filter drops it, a later rename replaces it.
assert_eq!(texture("7"), None);
assert_eq!(texture("8").as_deref(), Some(&b"lantern"[..]));
// The two atlases read one file, and disagree about `ia:9`: it gets neither.
assert_eq!(texture("9"), None);
let _ = fs::remove_dir_all(base);
}
#[test]
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
@@ -13,8 +13,7 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
// Version 7 stacks blockstate files (every pack's definition, bottom first).
// Version 8 left an atlas `single` source's renamed sprite without its texture file (#14485).
const VERSION: u32 = 9;
const VERSION: u32 = 8;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
+105 -119
View File
@@ -1402,8 +1402,100 @@ impl ParticleLightCache {
}
}
mod view_cull;
use view_cull::{PARTICLE_FRUSTUM_OFFSET, ParticleSectionFrustumCache, SectionFrustumResult};
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SectionFrustumResult {
Outside,
Intersects,
Inside,
}
#[derive(Clone, Copy)]
struct CachedSectionFrustum {
section: [i32; 3],
radius_quarters: i32,
result: SectionFrustumResult,
}
struct ParticleSectionFrustumCache {
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
}
impl Default for ParticleSectionFrustumCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
}
}
}
impl ParticleSectionFrustumCache {
fn result(
&mut self,
world: [f64; 3],
camera: DVec3,
radius: f64,
frustum: &crate::gfx::Frustum,
) -> SectionFrustumResult {
let Some(section) = world_section(world) else {
return SectionFrustumResult::Intersects;
};
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
return SectionFrustumResult::Intersects;
}
let radius_quarters = (radius * 4.0).ceil() as i32;
let hash = (section[0] as u32).wrapping_mul(73_856_093)
^ (section[1] as u32).wrapping_mul(19_349_663)
^ (section[2] as u32).wrapping_mul(83_492_791)
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.section == section
&& entry.radius_quarters == radius_quarters
{
return entry.result;
}
let pad = f64::from(radius_quarters) * 0.25;
let camera = camera.to_array();
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
SectionFrustumResult::Intersects
} else {
let min = FVec3::from_array(min.map(|v| v as f32));
let max = FVec3::from_array(max.map(|v| v as f32));
if !min.is_finite() || !max.is_finite() {
SectionFrustumResult::Intersects
} else if frustum.contains(min, max) {
SectionFrustumResult::Inside
} else if frustum.intersects(min, max) {
SectionFrustumResult::Intersects
} else {
SectionFrustumResult::Outside
}
};
self.entries[slot] = Some(CachedSectionFrustum {
section,
radius_quarters,
result,
});
result
}
}
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
let mut block = [0; 3];
for axis in 0..3 {
let value = position[axis].floor();
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return None;
}
block[axis] = value as i32;
}
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
}
fn below_half_pixel(radius: f64, distance_squared: f64, pixel_focal_length: f32) -> bool {
let projected_limit = 4.0 * radius * f64::from(pixel_focal_length);
@@ -4611,7 +4703,16 @@ impl ParticleEngine {
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_in_view(camera, base, partial, None, light_at, out);
self.render_inner::<false>(
camera,
base,
partial,
0.0,
None,
&mut |_, _| false,
light_at,
out,
);
}
/// Render particles after rejecting view-frustum misses and any particles
@@ -4629,7 +4730,7 @@ impl ParticleEngine {
out: &mut Vec<Vertex>,
) {
if !culling_enabled {
self.render_in_view(camera, base, partial, frustum, light_at, out);
self.render(camera, base, partial, light_at, out);
return;
}
self.render_inner::<true>(
@@ -4660,16 +4761,10 @@ impl ParticleEngine {
additive.clear();
let mut section_cache =
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
let view = frustum.map(|f| f.pulled_back(PARTICLE_FRUSTUM_OFFSET));
for p in &self.particles {
if p.no_render {
continue;
}
if let Some(view) = &view
&& !view_cull::in_view(view, p, camera)
{
continue;
}
// SparkParticle.extract: twinkling sparks blink after a third of their life.
if let Behaviour::Spark { twinkle: true, .. } = p.behaviour
&& p.age >= p.lifetime / 3
@@ -5104,115 +5199,6 @@ mod tests {
assert_eq!(attributes(&disabled), attributes(&regular));
}
/// An engine whose smoke particles stand at `positions`, camera-relative (the camera is at
/// the origin looking down -z, like `Mat4::IDENTITY` as a view).
fn smoke_at(positions: &[[f64; 3]]) -> ParticleEngine {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let sprite = "test:smoke".to_owned();
engine.atlas.insert(
sprite.clone(),
(
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
false,
),
);
let mut random = LegacyRandom::new(7);
for &at in positions {
let mut particle = Particle::moving(&mut random, ParticleType::Smoke, at, [0.0; 3]);
particle.sprite = Sprite::Named(sprite.clone());
engine.particles.push(particle);
}
engine
}
fn quad_vertices(engine: &ParticleEngine, view: Option<&crate::gfx::Frustum>) -> usize {
let mut out = Vec::new();
engine.render_in_view(DVec3::ZERO, Quat::IDENTITY, 0.5, view, &|_| 0xf0, &mut out);
out.len()
}
#[test]
fn particles_outside_the_pulled_back_frustum_make_no_quads() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
let kept = [
[0.0, 0.0, -5.0], // straight ahead
[0.0, 0.0, 2.5], // behind the camera, inside the 3 blocks of offset
[0.0, 0.0, -300.0], // far, inside the far plane
];
let dropped = [
[0.0, 0.0, 10.0], // well behind the camera
[0.0, 0.0, 3.5], // just past the offset
[100.0, 0.0, -5.0], // far to the side
[0.0, 60.0, -5.0], // far above
[0.0, 0.0, -600.0], // beyond the far plane
];
let all: Vec<_> = kept.iter().chain(&dropped).copied().collect();
let engine = smoke_at(&all);
let only_kept = quad_vertices(&smoke_at(&kept), None);
assert!(only_kept > 0);
assert_eq!(quad_vertices(&engine, Some(&view)), only_kept);
// Without a view every particle is drawn, as before.
assert_eq!(
quad_vertices(&engine, None),
only_kept * all.len() / kept.len()
);
// `render` is the same as no view.
let mut regular = Vec::new();
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &|_| 0xf0, &mut regular);
assert_eq!(regular.len(), quad_vertices(&engine, None));
}
#[test]
fn the_culled_path_applies_the_same_frustum_test() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
let engine = smoke_at(&[[0.0, 0.0, -5.0], [0.0, 0.0, 10.0]]);
let mut out = Vec::new();
// The disabled setting still keeps vanilla's always-on test.
engine.render_culled(
DVec3::ZERO,
Quat::IDENTITY,
0.5,
900.0,
false,
Some(&view),
|_, _| panic!("disabled culling must not query particle visibility"),
&|_| 0xf0,
&mut out,
);
assert_eq!(
out.len(),
quad_vertices(&smoke_at(&[[0.0, 0.0, -5.0]]), None)
);
}
#[test]
fn a_particle_is_tested_at_its_position_this_tick_not_an_interpolated_one() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
// The frame is drawn half way (0.5) between `previous` and `position`.
// Ahead now, far behind a tick ago (half way: 12.5 behind, outside): kept.
let mut coming = smoke_at(&[[0.0, 0.0, -5.0]]);
coming.particles[0].previous = [0.0, 0.0, 30.0];
assert!(quad_vertices(&coming, None) > 0);
assert_eq!(
quad_vertices(&coming, Some(&view)),
quad_vertices(&coming, None)
);
// Behind now, ahead a tick ago (half way: 5 ahead, inside): dropped.
let mut going = smoke_at(&[[0.0, 0.0, 10.0]]);
going.particles[0].previous = [0.0, 0.0, -20.0];
assert_eq!(quad_vertices(&going, Some(&view)), 0);
}
#[test]
fn quads_face_the_rotation_they_are_given() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -1,138 +0,0 @@
//! The camera's view frustum for particles: vanilla's always-on point test, and the section
//! cache behind the opt-in Particle Culling setting.
use super::{Particle, ParticleEngine, Vertex};
use glam::{DVec3, Quat, Vec3 as FVec3};
use std::array;
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
/// How far back, in blocks, the camera's frustum is pulled for the particle test (vanilla's
/// `Frustum.offset` in `ParticleEngine.extract`).
pub(super) const PARTICLE_FRUSTUM_OFFSET: f32 = 3.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SectionFrustumResult {
Outside,
Intersects,
Inside,
}
#[derive(Clone, Copy)]
struct CachedSectionFrustum {
section: [i32; 3],
radius_quarters: i32,
result: SectionFrustumResult,
}
pub(super) struct ParticleSectionFrustumCache {
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
}
impl Default for ParticleSectionFrustumCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
}
}
}
impl ParticleSectionFrustumCache {
pub(super) fn result(
&mut self,
world: [f64; 3],
camera: DVec3,
radius: f64,
frustum: &crate::gfx::Frustum,
) -> SectionFrustumResult {
let Some(section) = world_section(world) else {
return SectionFrustumResult::Intersects;
};
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
return SectionFrustumResult::Intersects;
}
let radius_quarters = (radius * 4.0).ceil() as i32;
let hash = (section[0] as u32).wrapping_mul(73_856_093)
^ (section[1] as u32).wrapping_mul(19_349_663)
^ (section[2] as u32).wrapping_mul(83_492_791)
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.section == section
&& entry.radius_quarters == radius_quarters
{
return entry.result;
}
let pad = f64::from(radius_quarters) * 0.25;
let camera = camera.to_array();
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
SectionFrustumResult::Intersects
} else {
let min = FVec3::from_array(min.map(|v| v as f32));
let max = FVec3::from_array(max.map(|v| v as f32));
if !min.is_finite() || !max.is_finite() {
SectionFrustumResult::Intersects
} else if frustum.contains(min, max) {
SectionFrustumResult::Inside
} else if frustum.intersects(min, max) {
SectionFrustumResult::Intersects
} else {
SectionFrustumResult::Outside
}
};
self.entries[slot] = Some(CachedSectionFrustum {
section,
radius_quarters,
result,
});
result
}
}
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
let mut block = [0; 3];
for axis in 0..3 {
let value = position[axis].floor();
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return None;
}
block[axis] = value as i32;
}
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
}
/// Whether `QuadParticleGroup`'s test keeps `particle`: its position this tick (camera-relative, not
/// interpolated) is on or inside `view`, the camera's frustum pulled back [`PARTICLE_FRUSTUM_OFFSET`] blocks.
pub(super) fn in_view(view: &crate::gfx::Frustum, particle: &Particle, camera: DVec3) -> bool {
let at = DVec3::from_array(particle.position) - camera;
view.contains_point(at.as_vec3())
}
impl ParticleEngine {
/// [`Self::render`], keeping only the particles inside `view`, the camera's frustum pulled
/// back [`PARTICLE_FRUSTUM_OFFSET`] blocks (vanilla's `ParticleEngine.extract` and
/// `QuadParticleGroup`): the test runs whatever the Particle Culling setting says. With
/// `None`, every particle is drawn.
pub fn render_in_view(
&self,
camera: DVec3,
base: Quat,
partial: f32,
view: Option<&crate::gfx::Frustum>,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_inner::<false>(
camera,
base,
partial,
0.0,
view,
&mut |_, _| false,
light_at,
out,
);
}
}
@@ -222,6 +222,11 @@ impl<P: Copy, S: Copy> Lines<P, S> {
}
self.y += style::GROUP_GAP;
}
/// A group of rows under its page's own name (`list`'s `page_name`), for pages named at run
/// time, such as a resource pack's config entries (`pack_options_ui`).
pub(crate) fn titled(&mut self, page: P, rows: Vec<S>) {
self.group(page, "", rows);
}
/// The lines and the content's height.
pub(crate) fn finish(self) -> (Vec<Line<P, S>>, f32) {
(self.lines, (self.y - style::GROUP_GAP).max(0.0))
@@ -347,7 +352,8 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
}
/// The list, clipped to its view: each group's panel, then its names and rows on it. `row`
/// draws a setting at its `y`; `page_name` names a page, in the player's language.
/// draws a setting at its `y`; `page_name` names a page, and a group without a name of its own
/// (`Lines::titled`), in the player's language.
pub(crate) fn list<P: Copy, S: Copy>(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -380,6 +386,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
None => (page_name(line.page), style::accent(), 4.0),
};
@@ -945,10 +952,22 @@ pub(crate) fn dock_note(
}
}
/// Done, Undo or Apply at the foot of the page list, registered for a controller. Its label
/// leaves `room` at its left for a controller's glyph: a button that declared its job gets one,
/// as vanilla's do (`ui::action_button`), which a half-width Undo or Apply needs.
/// Done or Undo at the foot of the page list.
pub(crate) fn foot_button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
cursor: (f32, f32),
flags: (bool, bool),
) {
foot_button_with_room(batch, atlas, rect, label, cursor, flags, 0.0);
}
/// A foot button whose label leaves `room` at its left for a controller's glyph: a button that
/// declared its job gets one, as vanilla's do (`ui::action_button`), which a half-width Undo or
/// Apply needs.
fn foot_button_with_room(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
@@ -2395,7 +2414,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
}
None => 0.0,
};
foot_button(batch, atlas, rect, &label, cursor, flags, room);
foot_button_with_room(batch, atlas, rect, &label, cursor, flags, room);
}
}
fn click(
+17 -11
View File
@@ -93,17 +93,18 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[impl AsRef<str>])
signed: signature == Signature::Signed,
..empty.clone()
};
// Authlib's MinecraftServicesSessionService validates every URL in
// unpackTextures before returning any textures. A bad cape rejects the skin too.
// Each texture is loaded independently: an invalid or disallowed URL for one
// texture (cape, elytra) must not drop another (the skin). Vanilla's
// SkinManager works the same way.
for (kind, texture) in textures {
let Some(url) = texture.get("url").and_then(|u| u.as_str()) else {
return empty;
continue;
};
let allowed = valid_uris
.iter()
.any(|u| url.starts_with(&u.as_ref().replace("{textureId}", "")));
if !allowed {
return empty;
continue;
}
if kind == "SKIN" {
out.slim = texture
@@ -1387,9 +1388,10 @@ mod tests {
&super::TEXTURE_URIS,
);
assert_eq!(textures.slim, slim && !url.contains("evil"));
// Authlib rejects the whole payload if any URL is invalid,
// including the allowed cape when the skin is disallowed.
assert_eq!(textures.cape.is_some(), !url.contains("evil"));
// Textures are judged per URL (vanilla's SkinManager): the
// cape's URL is always allowed here, so it is kept even when
// the skin's URL is not.
assert!(textures.cape.is_some());
assert_eq!(parsed.skin.is_some(), !url.contains("evil"));
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
}
@@ -1542,14 +1544,18 @@ mod tests {
assert!(t.slim);
assert!(!unpack(wide, Signature::Signed, &TEXTURE_URIS).slim);
}
/// Authlib's `MinecraftServicesSessionService` rejects the whole profile in
/// `unpackTextures` when any texture URL is outside the allowed origins.
/// `unpack` keeps each texture independently: a cape from another origin
/// must not drop the skin.
#[test]
fn unpack_rejects_the_skin_when_another_texture_is_rejected() {
fn unpack_keeps_the_skin_when_another_texture_is_rejected() {
use super::{Signature, TEXTURE_URIS, unpack};
let mixed = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL3NraW4xIn0sICJDQVBFIjogeyJ1cmwiOiAiaHR0cHM6Ly9ldmlsLmV4YW1wbGUvdGV4dHVyZS9jYXBlMSJ9fX0=";
let t = unpack(mixed, Signature::Unsigned, &TEXTURE_URIS);
assert_eq!(t, super::Textures::default());
assert_eq!(
t.skin.as_deref(),
Some("https://textures.minecraft.net/texture/skin1")
);
assert_eq!(t.cape, None);
}
#[test]
fn profiles_parse_like_resolvable_profiles() {
+4 -4
View File
@@ -478,9 +478,9 @@ impl Sprites {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(crate::packs::first_json::<
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(text.as_bytes())?),
>(&text)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
@@ -589,9 +589,9 @@ impl Sprites {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(crate::packs::first_json::<
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(text.as_bytes())?),
>(&text)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
@@ -1438,7 +1438,7 @@ impl Streaming {
let entities = std::mem::take(&mut chunk.entities);
world.entities_pending.insert((chunk.x, chunk.z));
if !entities.is_empty() {
world.queue_entity_nbt((chunk.x, chunk.z), entities);
world.loaded_entities.push(((chunk.x, chunk.z), entities));
}
self.generated_total += 1;
// A chunk saved with its light needs no light job; its unlit
+1 -23
View File
@@ -104,7 +104,7 @@ pub fn read_metadata(root: &Path) -> UiMetadata {
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
continue;
};
let Some(nine_slice) = crate::packs::first_json(text.as_bytes())
let Some(nine_slice) = serde_json::from_str(&text)
.ok()
.as_ref()
.and_then(parse_nine_slice)
@@ -2025,28 +2025,6 @@ impl EditBox {
mod tests {
use super::*;
#[test]
fn notes_after_a_sprites_metadata_keep_its_nine_slice() {
// Gson stops reading after the first value, so vanilla keeps this nine slice
// (CozyUI+'s highlighted button, #3046).
let root = std::env::temp_dir().join(format!("horizon-ui-meta-{}", std::process::id()));
let widget = root.join("assets/minecraft/textures/gui/sprites/widget");
std::fs::create_dir_all(&widget).unwrap();
std::fs::write(
widget.join("button_highlighted.png.mcmeta"),
"{\"gui\": {\"scaling\": {\"type\": \"nine_slice\", \"width\": 80, \"height\": 20,\n\
\"border\": {\"left\": 34, \"top\": 8, \"right\": 34, \"bottom\": 8}}}}\n\n// 0 10\n// 1 8\n",
)
.unwrap();
let metadata = read_metadata(&root);
std::fs::remove_dir_all(&root).unwrap();
let slice = metadata.0["gui/sprites/widget/button_highlighted"];
assert_eq!(
(slice.width, slice.height, slice.border),
(80, 20, [34, 8, 34, 8])
);
}
/// The quads' destination rectangles and their UVs.
fn quads(batch: &HudBatch) -> Vec<([f32; 4], [f32; 4])> {
batch
+1 -10
View File
@@ -9,9 +9,6 @@ use horizon_core::random::{LegacyRandom, LevelRandom};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
mod pending_entity;
pub use pending_entity::{EntityLoadReason, PendingEntity};
mod modified;
pub use modified::ModifiedChunks;
@@ -84,7 +81,7 @@ pub struct World {
/// incrementally by streaming.
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<PendingEntity>)>,
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks loaded since the level's last pass, their entity sections not
/// ready yet (`PersistentEntitySectionManager`'s PENDING), empty ones too:
/// the pass ends by readying them (`processPendingLoads`).
@@ -218,12 +215,6 @@ impl Weather {
}
}
impl World {
/// Queue ordinary NBT; transient transfer state is never decoded from it.
pub fn queue_entity_nbt(&mut self, key: (i32, i32), tags: Vec<horizon_world::nbt::Tag>) {
self.loaded_entities
.push((key, tags.into_iter().map(PendingEntity::from).collect()));
}
pub fn new(air: BlockStateId, seed: i64) -> Self {
Self {
shape: LevelShape::OVERWORLD,
@@ -1,89 +0,0 @@
//! Pending entity NBT and the runtime context that must not be stored inside it.
use horizon_world::nbt::Tag;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntityLoadReason {
Ordinary,
TeleportedTnt,
}
#[derive(Clone, Debug)]
pub struct PendingEntity {
tag: Tag,
reason: EntityLoadReason,
}
impl From<Tag> for PendingEntity {
fn from(tag: Tag) -> Self {
Self {
tag,
reason: EntityLoadReason::Ordinary,
}
}
}
impl PendingEntity {
/// Only an in-memory TNT transfer supplies this context, never saved NBT.
pub fn after_tnt_portal(tag: Tag) -> Self {
Self {
tag,
reason: EntityLoadReason::TeleportedTnt,
}
}
pub fn into_parts(self) -> (Tag, EntityLoadReason) {
(self.tag, self.reason)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_nbt_cannot_request_transient_portal_state() {
for marker in [0, 1] {
let tag = Tag::compound([("horizon:tnt_used_portal", Tag::Byte(marker))]);
let (loaded, reason) = PendingEntity::from(tag.clone()).into_parts();
assert_eq!(loaded, tag);
assert_eq!(reason, EntityLoadReason::Ordinary);
}
}
#[test]
fn queue_swaps_and_appends_keep_each_entitys_context_and_order() {
let ordinary = Tag::compound([("id", Tag::String("ordinary".into()))]);
let transfer = Tag::compound([("id", Tag::String("transfer".into()))]);
let mut batches = vec![(
(1, 2),
vec![
ordinary.clone().into(),
PendingEntity::after_tnt_portal(transfer.clone()),
],
)];
let mut waiting = std::mem::take(&mut batches);
batches.push(((3, 4), vec![ordinary.clone().into()]));
waiting.append(&mut batches);
assert!(batches.is_empty());
let mut batches = waiting.into_iter();
let (key, entities) = batches.next().unwrap();
assert_eq!(key, (1, 2));
let mut entities = entities.into_iter();
assert_eq!(
entities.next().unwrap().into_parts(),
(ordinary.clone(), EntityLoadReason::Ordinary)
);
assert_eq!(
entities.next().unwrap().into_parts(),
(transfer, EntityLoadReason::TeleportedTnt)
);
assert!(entities.next().is_none());
let (key, entities) = batches.next().unwrap();
assert_eq!(key, (3, 4));
assert_eq!(
entities.into_iter().next().unwrap().into_parts(),
(ordinary, EntityLoadReason::Ordinary)
);
assert!(batches.next().is_none());
}
}
@@ -316,7 +316,6 @@
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)",
"Preset": "Preset",
"Horizon Preset": "Horizon Preset",
"LOD Distance": "LOD Distance",
"Far Detail": "Far Detail",
"Cave Culling": "Cave Culling",
@@ -708,9 +707,6 @@
"Pick a shader pack. Picking the one in use turns shaders off.": "Pick a shader pack. Picking the one in use turns shaders off.",
"Changed options apply with Apply, or when you leave.": "Changed options apply with Apply, or when you leave.",
"Pack settings": "Pack settings",
"Whole pack": "Whole pack",
"Reset all": "Reset all",
"Every option is at its default already.": "Every option is at its default already.",
"Built-in settings": "Built-in settings",
"%s settings": "%s settings",
"Settings made by the pack's author: they change only this pack.": "Settings made by the pack's author: they change only this pack.",
-14
View File
@@ -21,19 +21,5 @@ fn main() {
if !staged.is_empty() {
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
}
// Whether a native mod can run in a mob's turn (`game/creatures/mob_islands.rs`). The
// launcher adds trait packages to the workspace's members.
println!(
"cargo:rerun-if-changed={}",
root.join("Cargo.toml").display()
);
for dir in ["native-api", "native-crates"] {
if root.join(dir).is_dir() {
println!("cargo:rerun-if-changed={}", root.join(dir).display());
}
}
if let Some(found) = horizon_mod_api_gen::mob_turn(&root) {
println!("cargo:rustc-env=HORIZON_MOB_TURN={found}");
}
windows_exe::resources("horizon-client");
}
@@ -318,8 +318,6 @@ pub fn main() -> horizon_client::Result<()> {
Ok(())
}
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
// The star's `setExtendedLifetime` is no part of the loot.
drops::Drop::Aged(s, _) => writeln!(out, "{}", line(&s)),
};
}
}
@@ -237,7 +237,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
"RestrictSun" => "RestrictSunGoal",
"FleeSun" => "FleeSunGoal",
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
"AvoidEntity" | "CatAvoid" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" => "AvoidEntityGoal",
"BowAttack" => "RangedBowAttackGoal",
"Swell" => "SwellGoal",
+14 -14
View File
@@ -1517,8 +1517,10 @@ fn predicate_references(
.unwrap_or("");
match &*full(kind) {
"minecraft:inverted" => predicate_references(&v["term"], w, active),
"minecraft:all_of" | "minecraft:any_of" => terms(v)
.iter()
"minecraft:all_of" | "minecraft:any_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.try_for_each(|v| predicate_references(v, w, active)),
_ => Ok(()),
}
@@ -1527,16 +1529,6 @@ fn predicate_references(
}
}
/// `terms` as `HolderSetCodec` reads it: a list, or one holder alone; a `#tag` is not expanded.
fn terms(v: &Value) -> &[Value] {
match &v["terms"] {
Value::Array(list) => list,
Value::Null => &[],
Value::String(tag) if tag.starts_with('#') => &[],
one => std::slice::from_ref(one),
}
}
/// The existing condition traversal with a caller-owned leaf context.
/// Leaves run synchronously, in order, with normal boolean short-circuiting.
pub fn conditions_with(
@@ -1555,8 +1547,16 @@ pub fn conditions_with(
.unwrap_or("");
match &*full(ty) {
"minecraft:inverted" => !conditions_with(&v["term"], ctx, test),
"minecraft:all_of" => terms(v).iter().all(|t| conditions_with(t, ctx, test)),
"minecraft:any_of" => terms(v).iter().any(|t| conditions_with(t, ctx, test)),
"minecraft:all_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.all(|t| conditions_with(t, ctx, test)),
"minecraft:any_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.any(|t| conditions_with(t, ctx, test)),
_ => test(v, ctx),
}
}
+1 -15
View File
@@ -71,10 +71,6 @@ pub fn trample_allowed(entity: i32, kind: &str, position: BlockPos, distance: f6
let (Some(dimension), Some(hook)) = (LEVEL.get(), TRAMPLE.get()) else {
return true;
};
// A grouped mob turn: no mod hooks it (`native_client_hooks::Deferred`).
if crate::native_client_hooks::deferring() {
return true;
}
let mut cancel = false;
let at = [position.x, position.y, position.z];
hook(dimension, entity, kind, at, distance, &mut cancel);
@@ -91,8 +87,7 @@ fn dispatch(event: Event<'_>, damage: &mut f32) {
let contact = match event {
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
};
// A grouped mob turn reaches no mod's hook (`native_client_hooks::Deferred`).
if contact.block.starts_with("minecraft:") || crate::native_client_hooks::deferring() {
if contact.block.starts_with("minecraft:") {
return;
}
if let Some(dimension) = LEVEL.get()
@@ -218,15 +213,6 @@ mod tests {
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
swept_inside(17, c.kind, at, at, bounds, block);
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
// A grouped mob turn, on any thread: no mod hears its contacts or tramples.
let ((), _) = crate::native_client_hooks::deferring_turn(|| {
emit(Event::StepOn(c));
assert_eq!(fall(c, 12.0, 10.0), 10.0);
let _ = TRAMPLE.set(|_, _, _, _, _, cancel| *cancel = true);
assert!(trample_allowed(17, c.kind, c.position, 12.0));
});
assert_eq!(SEEN.get(), 8, "a grouped turn reaches no contact hook");
assert!(!trample_allowed(17, c.kind, c.position, 12.0));
});
let _ =
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
+7 -21
View File
@@ -123,32 +123,18 @@ pub fn spawn_egg_entity_data<'a>(
/// `EntityType.appendDefaultStackConfig` for a hanging entity or armor stand an
/// item places: `Entity.applyImplicitComponents` stores the item's
/// `minecraft:custom_name` as the entity's `CustomName` and its
/// `minecraft:custom_data` as its `data`, then `updateCustomEntityTag`
/// `minecraft:custom_data` as the entity's `data`, then `updateCustomEntityTag`
/// (`TypedEntityData.loadInto`) merges the item's `minecraft:entity_data` over
/// the entity's saved tag `own` when its type is `kind`.
pub fn stack_configured_entity(stack: &crate::items::ItemStack, kind: &str, own: &Tag) -> Tag {
stack_configured_entity_by(stack, kind, own, false)
}
/// [`stack_configured_entity`], `op` saying whether the user may load the
/// data of an op-only entity type (`EntityType.onlyOpCanSetNbt`).
pub fn stack_configured_entity_by(
stack: &crate::items::ItemStack,
kind: &str,
own: &Tag,
op: bool,
) -> Tag {
let mut tag = own.clone();
if let Tag::Compound(map) = &mut tag {
if let Some(name) = stack.data().and_then(|d| d.custom_name.clone()) {
map.insert("CustomName".into(), name);
}
if let Some(custom) = stack.data().and_then(|d| d.other("minecraft:custom_data")) {
map.insert("data".into(), custom.clone());
}
if let (Tag::Compound(map), Some(custom)) = (
&mut tag,
stack.data().and_then(|d| d.other("minecraft:custom_data")),
) {
map.insert("data".into(), custom.clone());
}
match spawn_egg_entity_data(stack, kind, op) {
match spawn_egg_entity_data(stack, kind, false) {
Some(data) => crate::data_cmd::merged(&tag, data),
None => tag,
}
-22
View File
@@ -75,12 +75,6 @@ fn grid_state(count: u32, mut x: i32, mut z: i32) -> u32 {
impl ChunkGenerator for DebugTerrain {
type Worker = ();
fn worker(&self) {}
/// DebugLevelSource's proto is already its decorated column. Keep a copy
/// for lighting a finished neighbour outside the kept area, matching the
/// vanilla FEATURES neighbour path.
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
proto.downcast_ref::<Chunk>().cloned()
}
/// `DebugLevelSource.buildTerrain` builds nothing; `applyBiomeDecoration`
/// writes the barriers and the grid.
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
@@ -134,20 +128,4 @@ mod tests {
assert_eq!(grid_state(1000, 65, 1), 0);
assert_eq!(grid_state(1000, 61, 3), 30 * 32 + 1);
}
#[test]
fn light_view_keeps_a_debug_proto_column() {
let terrain = DebugTerrain {
shape: LevelShape::OVERWORLD,
biome: 0,
barrier: 0,
states: 0,
};
let mut chunk = Chunk::empty(-2, 5);
chunk.set(0, 0, 0, 42);
let proto: crate::streaming::Proto = Box::new(chunk);
let view = terrain.light_view(&proto).expect("debug proto light view");
assert_eq!((view.x, view.z), (-2, 5));
assert_eq!(view.block(0, 0, 0).0, 42);
}
}
+4 -93
View File
@@ -1652,7 +1652,9 @@ pub fn mob_to_nbt(
if let Some(saddle) = m.saddle_item() {
equipment.insert("saddle".into(), crate::save::item_to_nbt(&saddle));
}
// Body slot: harness, armour, a cube's block, a wolf's armor; emptied, it leaves the earlier save.
// A happy ghast wears its harness in the body slot (and a nautilus
// its armour, a sulfur cube its swallowed block, a wolf its armor,
// with the durability it lost).
if crate::mobs::species::body_slot(m.species())
&& let Some(id) = &m.extra.body
{
@@ -1662,8 +1664,6 @@ pub fn mob_to_nbt(
stack.extra = m.extra.body_extra.clone();
}
equipment.insert("body".into(), crate::save::item_to_nbt(&stack));
} else if crate::mobs::species::body_slot(m.species()) {
equipment.remove(&"body".into());
}
// Vanilla saves `equipment` only when something is equipped.
if equipment.is_empty() {
@@ -2867,7 +2867,7 @@ pub fn evoker_fangs_to_nbt(
motion: raw.map(motion).unwrap_or(Vec3::ZERO),
rotation: [yaw, raw.map(rotation).unwrap_or([0.0; 2])[1]],
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
fall_distance: 0.0,
}
.tag(raw);
put(&mut tag, "Warmup", Tag::Int(warmup));
@@ -4163,28 +4163,6 @@ mod tests {
);
}
/// `EvokerFangs` extends `Entity` and never moves: `Entity.saveWithoutId`
/// writes the `fall_distance` it loaded, and 0 for fangs a spell made.
#[test]
fn evoker_fangs_keep_their_loaded_fall_distance() {
let raw = horizon_world::nbt::parse_snbt(
"{id:\"minecraft:evoker_fangs\",Pos:[1d,200d,1d],fall_distance:5.0d,Warmup:100}",
)
.unwrap()
.0;
let at = super::Vec3::new(1.0, 200.0, 1.0);
let fall = |raw: Option<&super::Tag>| match super::evoker_fangs_to_nbt(
at, 0.0, 99, None, [1; 4], raw,
)
.get("fall_distance")
{
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
other => panic!("no fall_distance: {other:?}"),
};
assert_eq!(fall(Some(&raw)), 5.0);
assert_eq!(fall(None), 0.0);
}
/// `Shulker` reads `AttachFace` as a byte before
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
@@ -4571,37 +4549,6 @@ mod tests {
assert_eq!(id("head"), "minecraft:iron_helmet");
}
/// A body item taken off (`/item replace entity <cube> armor.body with minecraft:air`)
/// is not saved again from the entity's earlier save: vanilla writes `equipment.body` only
/// while the slot holds something (#12038).
#[test]
fn an_emptied_body_slot_is_not_saved_from_the_earlier_nbt() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:sulfur_cube".into();
ty.species = crate::mobs::Species::SulfurCube;
let types = [("minecraft:sulfur_cube".into(), kind)].into();
let raw = Tag::compound([
("id", string("minecraft:sulfur_cube")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
(
"equipment",
Tag::compound([(
"body",
Tag::compound([
("id", string("minecraft:oak_planks")),
("count", Tag::Int(1)),
]),
)]),
),
]);
let mut m = mob_from_nbt(&raw, &types, 1, None).unwrap();
assert_eq!(m.extra.body.as_deref(), Some("minecraft:oak_planks"));
m.extra.body = None;
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None, |_| None);
assert!(saved.get("equipment").is_none(), "{saved:?}");
}
/// A wolf loads its armor (`equipment.body`) into the body slot with the
/// durability it lost, and a helmet next to it into the head slot.
#[test]
@@ -5567,34 +5514,6 @@ mod tests {
}
}
/// `Goat` reads `HasLeftHorn` and `HasRightHorn` with `getBooleanOr(key,
/// true)`: a number is true when its byte value is not 0, so an int 256
/// (byte 0) is a lost horn, as `0b` is.
#[test]
fn a_goats_horn_flags_are_read_as_bytes() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:goat".into();
ty.species = crate::mobs::Species::Goat;
let types = [("minecraft:goat".into(), kind)].into();
let lost = |left: Option<Tag>| {
let mut fields = vec![
("id", string("minecraft:goat")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
];
fields.extend(left.map(|v| ("HasLeftHorn", v)));
mob_from_nbt(&Tag::compound(fields), &types, 1, None)
.unwrap()
.extra
.lost_horns
};
assert_eq!(lost(None), [false, false]);
assert_eq!(lost(Some(Tag::Byte(1))), [false, false]);
assert_eq!(lost(Some(Tag::Byte(0))), [true, false]);
assert_eq!(lost(Some(Tag::Int(256))), [true, false]);
assert_eq!(lost(Some(Tag::Int(257))), [false, false]);
}
#[test]
fn non_mob_saves_drop_mob_flags_without_losing_entity_fields() {
let uuid = [104, 0, 0, 1];
@@ -8084,14 +8003,6 @@ mod fireball_nbt_tests {
("Facing", Tag::Byte(1)),
]);
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
// BlockAttachedEntity.readAdditionalSaveData: a block_pos 16 blocks or
// more from the Pos block is refused for that block.
let mut far = painting.clone();
put(&mut far, "block_pos", Tag::IntArray(vec![28, 80, 22]));
assert_eq!(painting_from_nbt(&far).map(|p| p.0), Some([28, 80, 6]));
let mut near = painting.clone();
put(&mut near, "block_pos", Tag::IntArray(vec![28, 80, 21]));
assert_eq!(painting_from_nbt(&near).map(|p| p.0), Some([28, 80, 21]));
}
#[test]
+21 -68
View File
@@ -13,8 +13,7 @@ fn loaded_pitch(raw: Option<&Tag>) -> f32 {
0.0
}
}
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`, after
/// Entity.saveWithoutId's common fields.
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`.
pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let (lo, hi) = p.bounds();
let base = Base {
@@ -27,12 +26,8 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
),
motion: Vec3::ZERO,
rotation: [f32::from(p.facing) * 90.0, loaded_pitch(raw)],
// BlockAttachedEntity.tick never moves a painting (nor runs baseTick), so its
// onGround and fallDistance stay the ones Entity.load read.
on_ground: raw.is_some_and(|raw| flag(raw, "OnGround")),
fall_distance: raw
.and_then(|raw| float(raw.get("fall_distance")))
.unwrap_or(0.0),
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(&mut tag, "facing", Tag::Byte(p.facing as i8));
@@ -40,37 +35,21 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
put(&mut tag, "block_pos", Tag::IntArray(p.pos.to_vec()));
tag
}
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved and
/// closer than 16 blocks to the block holding `Pos`; else (a summoned one
/// has none) `setPos` puts it at that block.
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved; a
/// summoned one has none, and `setPos` puts its block at the block holding
/// the summon position.
fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
let stored = match tag.get("block_pos") {
match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => Some([v[0], v[1], v[2]]),
_ => None,
};
let Some(at) = position(tag) else {
return stored;
};
let here = [
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
];
// Vec3i.closerThan(blockPosition(), 16): the squared distance under 256.
let near = |p: &[i32; 3]| {
(0..3)
.map(|i| (f64::from(p[i]) - f64::from(here[i])).powi(2))
.sum::<f64>()
< 256.0
};
Some(stored.filter(near).unwrap_or(here))
}
/// The block position and facing of a saved painting (`HangingEntity`'s
/// `facing`, a 2D direction).
pub fn painting_placement_from_nbt(tag: &Tag) -> Option<([i32; 3], u8)> {
let pos = attached_block_pos(tag)?;
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
Some((pos, facing))
_ => {
let at = position(tag)?;
Some([
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
])
}
}
}
/// `Direction.from3DDataValue` / `from2DDataValue`: the byte read by
/// `LEGACY_ID_CODEC` indexes the table as `abs(data % modulus)`, never clamped.
@@ -80,7 +59,8 @@ fn wrap_data_value(raw: impl TryInto<i64> + Copy, modulus: i64) -> u8 {
}
/// The block position, facing and variant (if saved) of a painting.
pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, Option<String>)> {
let (pos, facing) = painting_placement_from_nbt(tag)?;
let pos = attached_block_pos(tag)?;
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
Some((pos, facing, text(tag.get("variant"))))
}
/// ItemFrame.addAdditionalSaveData.
@@ -94,14 +74,10 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
),
// BlockAttachedEntity.tick never runs super.tick, so the Motion, onGround and
// fallDistance that Entity.load read stay.
motion: raw.map_or(Vec3::ZERO, loaded_motion),
motion: Vec3::ZERO,
rotation: [0.0, 0.0],
on_ground: raw.is_some_and(|raw| flag(raw, "OnGround")),
fall_distance: raw
.and_then(|raw| float(raw.get("fall_distance")))
.unwrap_or(0.0),
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
// `Entity.saveWithoutId`: `CustomName` before the frame's own fields.
@@ -172,27 +148,4 @@ mod tests {
]);
assert_eq!(painting_from_nbt(&tag).unwrap().1, 1);
}
#[test]
fn item_frame_keeps_loaded_motion_on_ground_and_fall_distance() {
let tag = Tag::compound([
("id", string("minecraft:item_frame")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Facing", Tag::Byte(2)),
("Motion", doubles(&[0.5, 20.0, -0.25])),
("OnGround", Tag::Byte(1)),
("fall_distance", Tag::Double(3.5f64.to_bits())),
]);
let frame = item_frame_from_nbt(&tag, 1).unwrap();
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], Some(&tag));
// A component beyond 10 loads as 0, the others stay.
assert_eq!(motion(&saved), Vec3::new(0.5, 0.0, -0.25));
assert!(flag(&saved, "OnGround"));
assert_eq!(float(saved.get("fall_distance")), Some(3.5));
// A frame made in play has nothing loaded: zeros.
let made = item_frame_to_nbt(&frame, [1, 2, 3, 4], None);
assert_eq!(motion(&made), Vec3::ZERO);
assert!(!flag(&made, "OnGround"));
assert_eq!(float(made.get("fall_distance")), Some(0.0));
}
}
-23
View File
@@ -317,12 +317,6 @@ pub fn settings_from_json(
impl ChunkGenerator for FlatTerrain {
type Worker = ();
fn worker(&self) {}
/// FlatLevelSource's proto is already its decorated column. Keep a copy
/// for lighting a finished neighbour outside the kept area, just as the
/// vanilla pipeline does for FEATURES neighbours.
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
proto.downcast_ref::<Chunk>().cloned()
}
fn lod_surface(&self, _: &mut (), _: i32, _: i32) -> Option<crate::lod::Surface> {
self.surface
}
@@ -616,21 +610,4 @@ mod tests {
.is_none()
);
}
#[test]
fn light_view_keeps_a_flat_proto_column() {
let terrain = FlatTerrain {
shape: LevelShape::OVERWORLD,
layers: Vec::new(),
biome: 0,
surface: None,
void_platform: None,
};
let mut chunk = Chunk::empty(3, -4);
chunk.set(0, 0, 0, 42);
let proto: crate::streaming::Proto = Box::new(chunk);
let view = terrain.light_view(&proto).expect("flat proto light view");
assert_eq!((view.x, view.z), (3, -4));
assert_eq!(view.block(0, 0, 0).0, 42);
}
}
+19 -44
View File
@@ -476,6 +476,7 @@ mod trial_spawner;
mod trial_visuals;
mod trident;
mod tutorial;
mod tweaks;
mod ui_control;
mod use_hand;
mod using_item;
@@ -2229,7 +2230,7 @@ struct Game<'a> {
/// `AbstractContainerScreen`'s quick craft and skip-release state.
inv_clicks: horizon_client::container_screen::Clicks,
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
tweaks: extras::mouse_tweaks::Tweaks,
tweaks: tweaks::Tweaks,
/// The wheel and hovered slot of `BundleMouseActions`.
bundle_mouse: bundles::BundleMouse,
/// The last inventory click, for the double click that gathers a stack.
@@ -7210,14 +7211,14 @@ impl Game<'_> {
laps.lap("eg_objects");
// Particles (ParticleEngine.extract): camera-facing quads.
self.mark_stage(out.len(), "PARTICLES");
let world = &self.world;
let particle_mode = self.settings.horizon.particle_culling;
if particle_mode == horizon_client::graphics::ParticleCullingMode::Off {
self.particles.render_in_view(
self.particles.render(
camera,
self.particle_rotation(),
alpha as f32,
self.view_frustum.as_ref(),
&|b| self.world.light(b[0], b[1], b[2]),
&|b| world.light(b[0], b[1], b[2]),
&mut out,
);
} else {
@@ -7240,7 +7241,7 @@ impl Game<'_> {
true,
frustum.as_ref(),
&mut |min, max| particle_culling.hidden(min, max),
&|b| self.world.light(b[0], b[1], b[2]),
&|b| world.light(b[0], b[1], b[2]),
&mut out,
);
}
@@ -8188,16 +8189,19 @@ impl Game<'_> {
.ok_or("missing lit block state")?;
state.properties.insert("lit".into(), "true".into());
self.level_set(hit.pos, registry.resolve(&state)?)?;
let sound = if item == "fire_charge" {
"minecraft:item.firecharge.use"
} else {
"minecraft:item.flintandsteel.use"
};
let pitch = horizon_client::items::fire_starter_pitch(
item == "fire_charge",
&mut self.world.random,
self.audio.play(
if item == "fire_charge" {
"minecraft:item.firecharge.use"
} else {
"minecraft:item.flintandsteel.use"
},
Some(at(hit.pos)),
1.0,
horizon_client::items::fire_starter_pitch(
item == "fire_charge",
&mut self.world.random,
),
);
self.play_block_use_sound(sound, at(hit.pos), pitch);
// FlintAndSteelItem / FireChargeItem.useOn: BLOCK_CHANGE at the lit block,
// and ItemStack.useOn's used statistic.
self.post_block_game_event(
@@ -8228,7 +8232,7 @@ impl Game<'_> {
item == "fire_charge",
&mut self.world.random,
);
self.play_block_use_sound(sound, at(target), pitch);
self.audio.play(sound, Some(at(target)), 1.0, pitch);
// BLOCK_PLACE: flint and steel names the clicked block, a fire charge the
// fire. Then ItemStack.useOn's used statistic.
let placed_at = if item == "fire_charge" {
@@ -12586,35 +12590,6 @@ mod subtitles;
#[cfg(test)]
#[path = "game_tests.rs"]
mod tests;
#[cfg(test)]
mod test_neighbourhood {
//! Shared fixture step for tests that tick entities on prepared data.
use super::*;
impl Game<'_> {
/// Loads the chunks within 2 columns of `(cx, cz)` that are not loaded yet.
/// Entity ticking waits for every chunk in that 5x5 neighbourhood
/// (`ChunkMap.prepareEntityTickingChunk`), so a fixture that ticks mobs,
/// items or TNT in a chunk calls this after setting up its own chunks.
pub(crate) fn load_ticking_neighbourhood(&mut self, cx: i32, cz: i32) {
for z in cz - 2..=cz + 2 {
for x in cx - 2..=cx + 2 {
if self.world.chunks.contains_key(&(x, z)) {
continue;
}
self.world.chunks.insert(
(x, z),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(x, z)),
);
// A chunk that is already registered keeps its ticks.
let _ = self
.block_ticks
.add_empty_chunk(horizon_core::position::ChunkPos::new(x, z));
}
}
}
}
}
fn write_level_for_role(
level: &mut Level,
@@ -51,7 +51,6 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
for command in [
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
@@ -2709,7 +2709,6 @@ mod postbaseline_lodestone_controls {
game.block_ticks
.add_empty_chunk(BlockPos::new(64, 0, 64).chunk())
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("fill 64 199 64 79 199 79 stone"));
assert!(game.run_command(&format!("summon armor_stand 65.5 200.0 65.5 {{UUID:[I;42321,0,0,102],Health:20f,NoGravity:1b,equipment:{{mainhand:{}}}}}",item("minecraft:compass",tracked,100,1))));
let id = |n| {
+2 -2
View File
@@ -1508,6 +1508,7 @@ impl Game<'_> {
Vec3::new(b.position.x, top, b.position.z)
}
}
/// `BoatItem.use`: aim at a block or the water surface.
/// `Entity.setYRot` on a boat just made (its old rotation moves with it).
pub(super) fn set_boat_yaw(&mut self, id: u32, yaw: f32) {
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
@@ -1515,7 +1516,6 @@ impl Game<'_> {
b.previous_yaw = yaw;
}
}
/// `BoatItem.use`: aim at a block or the water surface.
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some(boat) = self.boat_to_place(stack) else {
return false;
@@ -1528,7 +1528,7 @@ impl Game<'_> {
self.world.modified.insert(key);
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
self.boats.push(boat);
self.apply_item_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
// BoatItem.use: setYRot(player.getYRot()) after the stack's config.
self.set_boat_yaw(id, yaw);
if self.serving.is_some() || self.options.dedicated.is_some() {
@@ -18,7 +18,7 @@ impl Game<'_> {
/// `Entity.checkBelowWorld` for the boat at `i`: true when it fell out of
/// the world and is gone.
pub(in crate::game) fn boat_below_world(&mut self, i: usize) -> bool {
if !below_world(self.boats[i].position.y, self.world.shape.min_y) {
if !below_world(self.boats[i].position.y, self.world.bottom_y()) {
return false;
}
self.eject_boat_mobs(i, true);
@@ -41,7 +41,7 @@ impl Game<'_> {
kind: &str,
) -> Option<usize> {
let i = cart_at(&self.carts, id, slot)?;
if !below_world(self.carts[i].position.y, self.world.shape.min_y) {
if !below_world(self.carts[i].position.y, self.world.bottom_y()) {
return Some(i);
}
self.eject_cart_mobs(i);
@@ -68,51 +68,44 @@ mod tests {
assert!(below_world(-64.5, 0));
}
/// A tick discards vehicles below each dimension's own floor, even when
/// its chunk storage extends lower, and spills the container's contents.
/// Vehicles on the line or above it survive this tick.
/// A tick discards the boat and the chest minecart under -128 in the
/// overworld, before they move, and spills the cart's slots; the two on
/// the line or above it stay, whatever their fall this tick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tick_discards_the_vehicles_under_the_world() {
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
for min_y in [-64, 0] {
let mut g = crate::game::native_machines::tests::server_game();
g.world.shape.min_y = min_y;
g.build_registries();
g.set_allow_commands(true);
g.world
.chunks
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
assert!(g.run_command("forceload add 0 0"));
let line = min_y - 64;
for summon in [
format!("summon oak_boat 0.5 {line} 0.5"),
format!("summon oak_boat 4.5 {} 4.5", f64::from(line) - 0.5),
format!("summon minecart 6.5 {} 6.5", line + 1),
format!(
"summon chest_minecart 2.5 {} 2.5 {{Items:[{{Slot:0b,id:\"minecraft:diamond\",count:3}}]}}",
line - 1
),
] {
assert!(g.run_command(&summon), "{summon}");
}
g.sync_tick_chunks();
g.tick_boats();
g.tick_carts();
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
assert_eq!(boats, [0.5]);
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
assert_eq!(carts, [6.5]);
let spilled: u32 = g
.dropped
.iter()
.filter(|i| &*i.stack.item == "minecraft:diamond")
.map(|i| i.stack.count)
.sum();
assert_eq!(spilled, 3);
let mut g = crate::game::native_machines::tests::server_game();
g.build_registries();
g.set_allow_commands(true);
g.world
.chunks
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
assert!(g.run_command("forceload add 0 0"));
for summon in [
"summon oak_boat 0.5 -128 0.5",
"summon oak_boat 4.5 -128.5 4.5",
"summon minecart 6.5 -127 6.5",
"summon chest_minecart 2.5 -129 2.5 {Items:[{Slot:0b,id:\"minecraft:diamond\",count:3}]}",
] {
assert!(g.run_command(summon), "{summon}");
}
g.sync_tick_chunks();
g.tick_boats();
g.tick_carts();
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
assert_eq!(boats, [0.5]);
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
assert_eq!(carts, [6.5]);
let spilled: u32 = g
.dropped
.iter()
.filter(|i| &*i.stack.item == "minecraft:diamond")
.map(|i| i.stack.count)
.sum();
assert_eq!(spilled, 3);
}
#[test]
@@ -54,9 +54,6 @@ fn game() -> Game<'static> {
.chunks
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
}
for x in 0..2 {
game.load_ticking_neighbourhood(x, 0);
}
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 6.5)).unwrap());
let net = NetConfig {
+125 -9
View File
@@ -4,7 +4,6 @@ mod admin;
pub(super) mod compute;
mod data;
mod display_names;
mod target_position;
pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
@@ -452,7 +451,9 @@ impl Game<'_> {
// the server's own source, "Server".
let who = match self.command_source.and_then(|s| s.entity) {
Some(entity) => self.target_name(entity),
None if self.console_broadcast.is_some() => self.server_name(),
None if self.console_broadcast.is_some() => {
Text::literal(if self.rcon_source { "Rcon" } else { "Server" }.to_owned())
}
None => self.player_display_name(),
};
let line = Text::translate("chat.type.admin", &[who, message])
@@ -537,6 +538,118 @@ impl Game<'_> {
conn.and_then(|c| self.command_guest(c))
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
}
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
match t {
Target::Player => self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]),
Target::Guest(conn) => self.command_guest(conn).map(|g| g.position),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| [m.position.x, m.position.y, m.position.z]),
Target::Item(id) => self
.dropped
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(id) => self
.orbs
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Frame(id) => self.item_frames.iter().find(|f| f.id == id).map(|f| {
let (lo, hi) = f.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Painting(id) => self.paintings.iter().find(|p| p.id == id).map(|p| {
let (lo, hi) = p.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Boat(id) => self
.boats
.iter()
.find(|b| b.id == id)
.map(|b| [b.position.x, b.position.y, b.position.z]),
Target::Cart(id) => self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Other(id) => self
.others
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Cushion(id) => self
.cushions
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Falling(id) => self
.falling
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
Target::Rocket(id) => self
.rockets
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
Target::Trident(id) => self
.tridents
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos),
Target::Spawner(id) => self
.ominous_spawners
.iter()
.find(|s| s.id == id)
.map(|s| s.position),
Target::Knot(id) => self.natural_knot(id).map(|fence| {
let p = horizon_client::mobs::leash::knot_state(fence).position;
[p.x, p.y, p.z]
}),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
match t {
Target::Player | Target::Guest(_) => "minecraft:player".into(),
@@ -1738,7 +1851,7 @@ impl Game<'_> {
}
Target::Crystal(id) => {
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
(c.position, c.fall.0) = (v, true); // performTeleport: setOnGround(true)
c.position = v;
}
}
Target::Dragon(id) => {
@@ -1749,8 +1862,6 @@ impl Game<'_> {
}
Target::Tnt(id) => {
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
// `PrimedTnt.teleport` includes same-level command teleports.
t.used_portal = true;
t.position = v;
t.previous = v;
if let Some(r) = rotation {
@@ -2524,9 +2635,14 @@ impl Game<'_> {
self.next_entity += 1;
let at = Vec3::new(pos[0], pos[1], pos[2]);
let drawn = self.world.random.next_int_bound(100_000).unwrap_or(0);
let time = super::dragon::Crystal::start_time(at, drawn, self.clock.still());
let crystal = super::dragon::Crystal::new(self.next_entity, at, true, time);
self.crystals.push(crystal);
self.crystals.push(super::dragon::Crystal {
id: self.next_entity,
position: at,
show_bottom: true,
time: super::dragon::Crystal::start_time(at, drawn, self.clock.still()),
beam: None,
invulnerable: false,
});
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_joins_flush(false, self.next_entity) {
return Err(err("commands.summon.failed.uuid", &[]));
@@ -4296,7 +4412,7 @@ impl Game<'_> {
.map(|cart| cart.command.name())
})
})
.unwrap_or_else(|| self.server_name()),
.unwrap_or_else(|| Text::literal("Server")),
},
None => self.player_display_name(),
};
@@ -697,27 +697,6 @@ impl Game<'_> {
self.carts[i] = cart;
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Painting(id) => {
// Painting.readAdditionalSaveData over HangingEntity's: the
// facing, the block it hangs on and a known variant
// (`VariantUtils.readVariant(..).ifPresent`: an unknown or
// unreadable one keeps the painting's).
let uuid = self.uuid_for(id);
let (pos, facing) =
entity_nbt::painting_placement_from_nbt(&tag).ok_or_else(stale)?;
let known = entity_nbt::painting_from_nbt(&tag)
.and_then(|(_, _, v)| self.painting_variants.get(&v?).cloned());
let p = self
.paintings
.iter_mut()
.find(|p| p.id == id)
.ok_or_else(stale)?;
(p.pos, p.facing) = (pos, facing);
if let Some(v) = known {
(p.variant, p.width, p.height) = (v.id, v.width, v.height);
}
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Other(id) => {
let uuid = self.uuid_for(id);
let marker = self
@@ -769,7 +748,6 @@ impl Game<'_> {
| Target::Item(id)
| Target::Stand(id)
| Target::Frame(id)
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Tnt(id)
@@ -1567,7 +1545,7 @@ impl Game<'_> {
let before = self.next_entity;
let mut tag = tag;
crate::game::persist::mark_fresh(&mut tag);
self.world.queue_entity_nbt(key, vec![tag]);
self.world.loaded_entities.push((key, vec![tag]));
self.spawn_loaded_entities();
waiting.append(&mut self.world.loaded_entities);
self.world.loaded_entities = waiting;
@@ -61,12 +61,6 @@ impl Game<'_> {
.unwrap_or(Target::Player)
}
/// The name of the server's own source: "Rcon" for a command an RCON client sent
/// (`RconConsoleSource`), "Server" otherwise (`MinecraftServer.createCommandSourceStack`).
pub(in crate::game) fn server_name(&self) -> Text {
Text::literal(if self.rcon_source { "Rcon" } else { "Server" })
}
/// `PlayerList.getPlayerByName`: names are not case sensitive. Horizon:
/// on a server where a mod gave players nicknames
/// (`native_set_nickname`), a name no player has finds the player going
@@ -1,116 +0,0 @@
use super::{Game, Target};
impl Game<'_> {
pub(in crate::game) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
match t {
Target::Player => self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]),
Target::Guest(conn) => self.command_guest(conn).map(|g| g.position),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| [m.position.x, m.position.y, m.position.z]),
Target::Item(id) => self
.dropped
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(id) => self
.orbs
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Frame(id) => self.item_frames.iter().find(|f| f.id == id).map(|f| {
let (lo, hi) = f.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Painting(id) => self.paintings.iter().find(|p| p.id == id).map(|p| {
let (lo, hi) = p.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Boat(id) => self
.boats
.iter()
.find(|b| b.id == id)
.map(|b| [b.position.x, b.position.y, b.position.z]),
Target::Cart(id) => self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Other(id) => self
.others
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Cushion(id) => self
.cushions
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Falling(id) => self
.falling
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
Target::Rocket(id) => self
.rockets
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
Target::Trident(id) => self
.tridents
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos),
Target::Spawner(id) => self
.ominous_spawners
.iter()
.find(|s| s.id == id)
.map(|s| s.position),
Target::Knot(id) => self.natural_knot(id).map(|fence| {
let p = horizon_client::mobs::leash::knot_state(fence).position;
[p.x, p.y, p.z]
}),
}
}
}
+14 -14
View File
@@ -1080,7 +1080,7 @@ impl Game<'_> {
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
dimension_type: self.dimension_type(),
world_seed: self.level.seed,
sea_level: horizon_world::level::sea_level_of(self.level_key()),
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.items()),
lid_openers: &self.lid_openers,
@@ -1280,9 +1280,10 @@ impl Game<'_> {
.is_some()
.then_some(self.level.player_uuid)
.flatten(),
mods: round.listening() || !horizon_client::mod_api::mods().is_empty(),
ticking: &|| Box::new(self.entity_ticking_area()),
};
let mut group = match islands.plan(&mobs, &snapshot, &turns, &start) {
let mut group = match islands.plan(&mobs, &snapshot, &turns) {
Some(plan) => islands.run(plan, mobs.into(), snapshot, start, &mut turns, &shared),
None => {
let mut group = mob_group::Group::new(mobs.into(), snapshot, start);
@@ -1877,9 +1878,10 @@ impl Game<'_> {
for item in stacks {
// Every death drop is its own ItemEntity, and the drop entity's
// own random (not the level's) gives it the constructor's
// horizontal push (its motion otherwise stays [0, 0.2, 0] and the
// drops land stacked at the mob's centre, without disturbing the
// level random that the orbs' merge ids and block ticks read next).
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
// the drops land stacked at the mob's centre, without disturbing
// the level random that the orbs' merge ids and block ticks read
// next).
self.spawn_item_own(item, pos, 10);
}
// LivingEntity.dropExperience: a mod may change the mob's experience
@@ -1919,9 +1921,8 @@ impl Game<'_> {
self.spawn_item_own(item, pos, 10);
}
}
drops::Drop::Aged(s, age) => self.spawn_item_aged(from_loot(&s), pos, age),
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
_ => {}
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
}
}
}
@@ -3233,7 +3234,11 @@ impl Game<'_> {
MobEvent::DolphinTreasure { mob } => {
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
let Some(at) = self.mobs.iter().find(|m| m.id == mob).map(|m| {
[m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i32)
[
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
]
}) else {
continue;
};
@@ -3244,11 +3249,7 @@ impl Game<'_> {
.filter(|_| !names.is_empty())
.and_then(|t| t.locate(&names, at, 50));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.extra.home = found.as_ref().map(|(_, p)| *p);
}
// DolphinSwimToTreasureGoal.start: entity event 38 once it has a treasure.
if found.is_some() {
self.mob_event_particles(mob, 38);
m.extra.home = found.map(|(_, p)| p);
}
}
MobEvent::EffectPlayer {
@@ -9050,7 +9051,6 @@ mod h169_glide_travel_tests {
.unwrap();
}
}
game.load_ticking_neighbourhood(0, 0);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
@@ -64,9 +64,6 @@ pub(super) struct Group<M: MobSlot> {
pub corpses: Vec<Mob>,
pub gone: Vec<Gone>,
pub guest_events: Vec<(u32, u32, MobEvent)>,
/// The mod hooks its turns kept for the join, with each turn's place in the whole list (a
/// group of the parallel tick with mods loaded: `native_client_hooks::Deferred`).
pub deferred: Vec<(usize, horizon_client::native_client_hooks::Deferred)>,
}
/// What decides each turn besides the group, read at the turn as the serial loop reads it.
@@ -80,7 +77,7 @@ pub(super) struct Turns<'t, 'w> {
/// The one player's position, when the level has one: every mob's nearest.
pub single_player: Option<Option<Vec3>>,
/// Whether a mod canceled a mob's tick.
pub canceled: &'t (dyn Fn(u32) -> bool + Sync),
pub canceled: &'t dyn Fn(u32) -> bool,
/// `getCurrentDifficultyAt` a position.
pub difficulty: &'t (dyn Fn([f64; 3]) -> LocalDifficulty + Sync),
/// The last chunk's difficulty: mobs come in runs of one chunk.
@@ -140,7 +137,6 @@ impl<M: MobSlot> Group<M> {
corpses: Vec::new(),
gone: Vec::new(),
guest_events: Vec::new(),
deferred: Vec::new(),
}
}
@@ -177,17 +173,7 @@ impl<M: MobSlot> Group<M> {
foreign.reader.set(at);
}
if let Some(foreign) = foreign {
foreign.in_seed_order(at, || {
if foreign.defers {
let ((), kept) =
horizon_client::native_client_hooks::deferring_turn(|| {
self.turn(i, turns, shared);
});
self.deferred.extend(kept.into_iter().map(|d| (at, d)));
} else {
self.turn(i, turns, shared);
}
});
foreign.in_seed_order(at, || self.turn(i, turns, shared));
} else {
self.turn(i, turns, shared);
}
@@ -377,10 +363,7 @@ impl<M: MobSlot> Group<M> {
shared.guest_ids,
self.player.as_ref(),
);
// A grouped turn runs while no mod hooks the change (`horizon_mod_api_gen::mob_turn`).
if let Some((player, mob)) = named
&& !horizon_client::native_client_hooks::deferring()
{
if let Some((player, mob)) = named {
let kind = super::super::native_hooks::mob_kind(m);
let mut cancel = false;
Game::native_living_change_target(
File diff suppressed because it is too large. Load diff
@@ -5,14 +5,9 @@ use super::super::{
};
use horizon_client::{mobs::Species, scoreboard::teams_allow_damage};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::{Clientbound, GameEvent};
/// `ClientboundGameEventPacket.PUFFER_FISH_STING`.
const PUFFER_FISH_STING: u8 = 9;
impl Game<'_> {
/// `Pufferfish.playerTouch`: accepted player damage, the sting game event, then poison with
/// this fish as source.
/// `Pufferfish.playerTouch`: accepted player damage, then poison with this fish as source.
pub(in crate::game) fn touch_pufferfish(&mut self) {
if self.remote.is_some() || self.spectator() {
return;
@@ -51,11 +46,10 @@ impl Game<'_> {
m.extra.size,
[m.position.x, m.position.y, m.position.z],
self.mob_entity(m),
m.silent,
)
})
.collect();
for (id, puff, at, source, silent) in touching {
for (id, puff, at, source) in touching {
if !self.hurt_player_from(
(1 + puff) as f32,
Some(at),
@@ -64,10 +58,6 @@ impl Game<'_> {
) {
continue;
}
// `if (!this.isSilent())` inside the `hurtServer` branch: before the poison.
if !silent {
self.puffer_fish_sting();
}
let changes = self.potions.effects.changes;
self.give_player_effect("minecraft:poison", 60 * puff, 0);
if self.potions.effects.changes != changes {
@@ -75,17 +65,4 @@ impl Game<'_> {
}
}
}
/// `ServerPlayer.connection.send(ClientboundGameEventPacket(PUFFER_FISH_STING, 0.0F))`: the
/// stung player's own client plays it (`ClientPacketListener.handleGameEvent`). A server has
/// no player of its own, so the packet is the only way to reach it.
fn puffer_fish_sting(&mut self) {
self.guest_packets.push(Clientbound::GameEvent(GameEvent {
event: PUFFER_FISH_STING,
param: 0.0,
}));
}
}
#[cfg(test)]
#[path = "pufferfish_sting_tests.rs"]
mod pufferfish_sting_tests;
@@ -1,95 +0,0 @@
//! `Pufferfish.playerTouch`: the sting game event (`ClientboundGameEventPacket.PUFFER_FISH_STING`)
//! goes out only inside the `hurtServer` branch, and only when the fish is not
//! silent; the poison follows it.
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
use horizon_client::mobs::Mob;
use horizon_client::motion::PlayerMotion;
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::Clientbound;
/// A game whose window player touches one puffed (state 2) pufferfish. The
/// server acts for the guest `lan_acting`: `playerTouch` runs there, and the
/// event it sends reaches that player's own client.
fn game_with(silent: bool) -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
false,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.creative = false;
game.lan_acting = Some(7);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(8.5, 66.0, 8.5)).unwrap());
let kind = game.res.mob_types()["minecraft:pufferfish"].clone();
let mut fish = Mob::new(9001, kind, Vec3::new(8.5, 66.0, 8.5), 0.0);
fish.extra.size = 2;
fish.silent = silent;
game.mobs.push(fish);
game
}
/// The game events the stung player's client was sent, as `(event, parameter)`.
fn stings(game: &Game<'_>) -> Vec<(u8, f32)> {
game.guest_packets
.iter()
.filter_map(|packet| match packet {
Clientbound::GameEvent(e) => Some((e.event, e.param)),
_ => None,
})
.collect()
}
fn health(game: &Game<'_>) -> f32 {
game.player.as_ref().unwrap().health.health()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_sting_event_goes_out_once_per_accepted_hit() {
let mut g = game_with(false);
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "1 + PuffState 2 of damage landed");
assert_eq!(stings(&g), vec![(9, 0.0)], "PUFFER_FISH_STING, parameter 0");
let poison = g.potions.effects.get("poison").expect("poisoned");
assert_eq!((poison.duration, poison.amplifier), (120, 0));
// A second touch inside the hurt cooldown is refused (`hurtServer` is
// false): no damage and no event.
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "the cooldown refused the second hit");
assert_eq!(stings(&g).len(), 1, "a refused hit sends nothing");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_silent_fish_damages_and_poisons_without_the_sting() {
let mut g = game_with(true);
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "the damage still lands");
assert!(g.potions.effects.get("poison").is_some(), "the poison too");
assert!(stings(&g).is_empty(), "a silent fish sends no event");
}
+26 -271
View File
@@ -308,17 +308,6 @@ fn overload_warning(behind: Duration, tick: Duration) -> String {
)
}
/// `ServerGamePacketListenerImpl.tick`'s idle kick: a player whose last action is more than
/// `minutes` (`player-idle-timeout`, none when 0 or less) behind, counted here in ticks, is kicked,
/// unless the End poem is showing (`ServerPlayer.wonGame`): its client sends no action until it
/// asks to respawn.
fn idles_out(idle_ticks: u64, minutes: i32, won_game: bool) -> bool {
let Ok(minutes @ 1..) = u64::try_from(minutes) else {
return false;
};
!won_game && idle_ticks > minutes * 1200
}
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
pub(super) fn log(level: &str, message: &str) {
log_from("Server thread", level, message);
@@ -539,28 +528,6 @@ fn parse_whitelist_bypass(text: &str) -> std::result::Result<Vec<String>, String
Ok(out)
}
impl Lists {
/// The console's `/ban` of one player (`BanPlayerCommands.banPlayers`): whether they are banned now.
/// One banned already (`isBanned`) is left as they are, their first reason kept.
fn ban(&mut self, user: &NameAndId, reason: Option<String>) -> bool {
!self.bans.contains(user)
&& self.bans.add(BanEntry::new(
user.clone(),
Some("Server".into()),
None,
reason,
))
}
/// The console's `/ban-ip` (`BanIpCommands.banIp`): whether the address is banned now. One banned
/// already (`isBanned`) is left as it is.
fn ban_ip(&mut self, ip: &str, reason: Option<String>) -> bool {
let ip = ip.to_owned();
!self.ip_bans.contains(&ip)
&& self
.ip_bans
.add(BanEntry::new(ip, Some("Server".into()), None, reason))
}
fn load() -> Self {
let mut l = Self {
ops: OpList::new("ops.json"),
@@ -897,28 +864,6 @@ fn auto_save(state: &ServerState) -> bool {
state.lists.lock().unwrap().autosave
}
/// `MinecraftServer.tickServer`'s pause-when-empty count. `pause_after` is its
/// `pauseWhenEmptySeconds() * 20`; while that is above 0, a tick with no player and no
/// `/tick sprint` running (`ServerTickRateManager.isSprinting`) adds one to `empty_ticks`, and any
/// other tick sets it back to 0. A `/pregen` task keeps the server busy as a player would: paused,
/// nothing would post-process its chunks. Returns whether the server is paused this tick.
fn count_empty_tick(
empty_ticks: &mut i32,
pause_after: i32,
players: usize,
pregen: bool,
sprinting: bool,
) -> bool {
if pause_after > 0 {
if players == 0 && !pregen && !sprinting {
*empty_ticks += 1;
} else {
*empty_ticks = 0;
}
}
pause_after > 0 && *empty_ticks >= pause_after
}
/// `MinecraftServer.setAutoSave`: enable or disable periodic saves and report whether it changed.
pub(super) fn set_auto_save(lists: &Mutex<Lists>, enabled: bool) -> bool {
let mut l = lists.lock().unwrap();
@@ -1103,17 +1048,9 @@ impl Game<'_> {
// now rather than at the first tick (reading parses functions; it runs none), and a broken
// predicate graph stops the start before anything listens (a partial check: predicate_graph.rs).
self.ensure_datapacks();
let store = &self.fns.store;
let tag = |id: &str| {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
store
.files
.contains_key(&format!("tags/{ns}:predicate/{path}"))
};
if let Err(error) = predicate_graph::check(&store.predicates, tag) {
if let Err(error) = predicate_graph::check(&self.fns.store.predicates) {
let level = match error {
predicate_graph::GraphError::Unbound(_)
| predicate_graph::GraphError::UnboundTags(_) => "ERROR",
predicate_graph::GraphError::Unbound(_) => "ERROR",
predicate_graph::GraphError::Recursive(_) => "WARN",
};
for line in error.lines() {
@@ -1434,13 +1371,16 @@ impl Game<'_> {
// Vanilla reads pauseWhenEmptySeconds on every tick, so management changes take effect
// without restarting the server.
let pause_after = state.pause_when_empty_seconds.wrapping_mul(20);
let paused = count_empty_tick(
&mut state.empty_ticks,
pause_after,
players,
self.pregen_level().is_some(),
self.clock.sprinting(),
);
if pause_after > 0 {
// A `/pregen` task keeps the server busy as a player would:
// paused, nothing would post-process its chunks.
if players == 0 && self.pregen_level().is_none() {
state.empty_ticks += 1;
} else {
state.empty_ticks = 0;
}
}
let paused = pause_after > 0 && state.empty_ticks >= pause_after;
state.paused = paused;
if paused && state.empty_ticks == pause_after {
log(
@@ -1638,15 +1578,12 @@ impl Game<'_> {
let Some(host) = self.lan.as_mut() else {
return;
};
let limit = u64::try_from(minutes).unwrap_or(0) * 1200;
let idle: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| {
let idle_ticks = host.hub.ticks.saturating_sub(g.last_action_tick);
let won_game = host.play.get(&g.conn).is_some_and(|play| play.won_game);
idles_out(idle_ticks, minutes, won_game)
})
.filter(|g| host.hub.ticks.saturating_sub(g.last_action_tick) > limit)
.map(|g| g.conn)
.collect();
for conn in idle {
@@ -1755,17 +1692,9 @@ impl Game<'_> {
/// bound to the overworld, whichever level the last server tick left in
/// place (the last one ticked, often the nether). Its commands run there
/// and the level in place is put back afterwards.
/// The source is the server's own: no entity, at the world spawn, its feedback heard
/// (the console's and an RCON client's always is).
fn as_console_in_overworld<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
let back = console_level(self.dim).and_then(|dim| self.enter_command_level(dim));
let source = super::commands::execute::Source {
silent: false,
..self.server_source()
};
let outer = self.command_source.replace(source);
let out = self.as_console(f);
self.command_source = outer;
if let Some(back) = back {
self.enter_command_level(back);
}
@@ -1983,7 +1912,12 @@ impl Game<'_> {
return;
};
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
let added = state.lists.lock().unwrap().ban(&user, reason.clone());
let added = state.lists.lock().unwrap().bans.add(BanEntry::new(
user.clone(),
Some("Server".into()),
None,
reason.clone(),
));
if added {
self.console_reply(Text::translate(
"commands.ban.success",
@@ -2023,7 +1957,12 @@ impl Game<'_> {
return;
};
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
let added = state.lists.lock().unwrap().ban_ip(&ip, reason.clone());
let added = state.lists.lock().unwrap().ip_bans.add(BanEntry::new(
ip.clone(),
Some("Server".into()),
None,
reason.clone(),
));
if !added {
self.console_reply(Text::translate("commands.banip.failed", &[]));
return;
@@ -2641,23 +2580,6 @@ mod tests {
assert!(parse_whitelist_bypass("not-a-uuid-at-all").is_err());
}
/// ServerGamePacketListenerImpl.tick: a player idle past `player-idle-timeout` minutes is kicked,
/// unless the End poem is showing (`ServerPlayer.wonGame`), which sends no action.
#[test]
fn a_player_watching_the_end_credits_is_not_kicked_for_idling() {
use super::idles_out;
// 5 minutes: 6000 ticks. One more is over the limit.
assert!(!idles_out(6000, 5, false));
assert!(idles_out(6001, 5, false));
assert!(!idles_out(6001, 5, true));
assert!(!idles_out(1_000_000, 5, true));
// The credits are over (won_game false again): the same idle time kicks.
assert!(idles_out(1_000_000, 5, false));
// 0 or less: no timeout.
assert!(!idles_out(1_000_000, 0, false));
assert!(!idles_out(1_000_000, -3, false));
}
/// `SaveAllCommand.saveAll`: a save that throws is the `ERROR_FAILED` the
/// source is told about, not a log line the caller never sees.
#[test]
@@ -2682,51 +2604,6 @@ mod tests {
);
}
/// MinecraftServer.tickServer: a tick only counts toward pause-when-empty with no player and
/// no `/tick sprint` running (`ServerTickRateManager.isSprinting`); a sprint on an empty server
/// keeps the count at 0, so it is never paused, and the count starts again after it.
#[test]
fn a_sprint_on_an_empty_server_does_not_count_toward_the_pause() {
let pause_after = 60 * 20;
let mut empty_ticks = 0;
// One server tick: (players, a `/pregen` task, a `/tick sprint`) -> paused.
let tick = |empty_ticks: &mut i32, players, pregen, sprinting| {
super::count_empty_tick(empty_ticks, pause_after, players, pregen, sprinting)
};
for _ in 0..2000 {
assert!(!tick(&mut empty_ticks, 0, false, true));
}
assert_eq!(empty_ticks, 0);
// The sprint ended: 1200 empty ticks later the server pauses, not before.
for n in 1..=pause_after {
assert_eq!(
tick(&mut empty_ticks, 0, false, false),
n == pause_after,
"tick {n}"
);
}
// A sprint that starts while paused resumes the server at once.
assert!(!tick(&mut empty_ticks, 0, false, true));
assert_eq!(empty_ticks, 0);
// A player, or a `/pregen` task, resets the count too.
empty_ticks = 5;
assert!(!tick(&mut empty_ticks, 1, false, false));
assert_eq!(empty_ticks, 0);
empty_ticks = 5;
assert!(!tick(&mut empty_ticks, 0, true, false));
assert_eq!(empty_ticks, 0);
// pause-when-empty-seconds=0 never pauses and leaves the count alone.
empty_ticks = 5;
assert!(!super::count_empty_tick(
&mut empty_ticks,
0,
0,
false,
false
));
assert_eq!(empty_ticks, 5);
}
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
/// named as unknown, so the crash line is never empty.
#[test]
@@ -2836,46 +2713,6 @@ mod tests {
}
}
/// `BanPlayerCommands.banPlayers` skips a player who `isBanned`, and `BanIpCommands.banIp` refuses an
/// address that is: the console's `/ban` and `/ban-ip` of one banned already change nothing (the first
/// reason stays, and the console hears "Nothing changed"), whatever reason they give.
#[test]
fn the_console_does_not_ban_again_who_is_banned_already() {
use super::{BanList, Lists, NameAndId};
let dir = std::env::temp_dir().join(format!("horizon-console-ban-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let mut lists = Lists {
ops: super::OpList::new(dir.join("ops.json")),
white: super::WhiteList::new(dir.join("whitelist.json")),
bans: BanList::new(dir.join("banned-players.json")),
ip_bans: super::IpBanList::new(dir.join("banned-ips.json")),
use_whitelist: false,
enforce_whitelist: false,
op_level: 4,
player_idle_timeout: 0,
stop: false,
autosave: true,
whitelist_bypass: Vec::new(),
};
let alpha = NameAndId {
id: 0x5ca1ab1e,
name: "Alpha".into(),
};
assert!(lists.ban(&alpha, Some("cheating".into())));
assert!(!lists.ban(&alpha, Some("again".into())));
assert!(!lists.ban(&alpha, None));
let reason = lists.bans.get(&alpha).and_then(|b| b.reason.clone());
assert_eq!(reason.as_deref(), Some("cheating"));
assert!(lists.ban_ip("1.2.3.4", Some("spam".into())));
assert!(!lists.ban_ip("1.2.3.4", Some("more spam".into())));
let reason = lists
.ip_bans
.get(&"1.2.3.4".to_owned())
.and_then(|b| b.reason.clone());
assert_eq!(reason.as_deref(), Some("spam"));
let _ = fs::remove_dir_all(&dir);
}
/// A temporary ban (`/tempban`, the chat filter's) keeps the player out and says when it ends;
/// once it has ended, or after `/pardon`, they come in.
#[test]
@@ -2959,85 +2796,3 @@ mod tests {
fs::remove_dir_all(dir).unwrap();
}
}
#[cfg(test)]
mod console_source_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
/// Runs `command` as the console does and returns whether it worked and every message it printed.
fn console(game: &mut Game<'_>, command: &str) -> (bool, String) {
game.chat_capture = Some(Vec::new());
let ok = game.as_console_in_overworld(|g| g.run_command(command));
let said = game.chat_capture.take().unwrap_or_default();
let said = said
.iter()
.map(|t| t.component().to_string())
.collect::<Vec<_>>()
.join("\n");
(ok, said)
}
/// `MinecraftServer.createCommandSourceStack`: the console's source has no entity and is named "Server"
/// (an RCON client's, `RconConsoleSource`, "Rcon"): `/me` says so, and `teammsg`, which needs an entity,
/// refuses it (`permissions.requires.entity`) instead of running as the player in place.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn the_console_runs_as_the_server_not_the_player_in_place() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
let (ok, said) = console(&mut game, "me waves");
assert!(
ok && said.contains("chat.type.emote") && said.contains("\"Server\""),
"{said}"
);
game.rcon_source = true;
let (ok, said) = console(&mut game, "me waves");
assert!(ok && said.contains("\"Rcon\""), "{said}");
game.rcon_source = false;
let (ok, said) = console(&mut game, "teammsg hi");
assert!(
!ok && said.contains("permissions.requires.entity"),
"{said}"
);
// The console's source is put back after each line.
assert!(game.command_source.is_none());
}
}
@@ -1,16 +1,13 @@
//! A partial check of the world's predicate graph before a dedicated server is Ready.
//!
//! Vanilla refuses to start a world whose packs' predicates reference a predicate no pack defines
//! (`MappedRegistry.freeze`: "Unbound values in registry"), name a predicate tag no pack defines
//! ("Unbound tags in registry"), or reference each other in a cycle
//! (`MappedRegistry.freeze`: "Unbound values in registry"), or reference each other in a cycle
//! (`ReloadableServerRegistries.validateLootRegistries`: a reference "is recursively called").
//!
//! This is new, partial validation, not a port of `RegistryDataLoader` or `ValidationContext`. It
//! follows a predicate's holders only where the condition codecs hold one: `all_of` and `any_of`
//! `terms`, and `inverted` `term`. A holder is an identifier (a reference) or an inline condition,
//! which is followed in the same way. `terms` is a `HolderSetCodec`: a list of holders, one holder
//! alone (`compactListCodec`, at `.terms[0]`), or a `#tag`. A tag must be defined; the entries of a
//! defined tag are not followed. Every other condition type and field, an identifier that does
//! which is followed in the same way. Every other condition type and field, an identifier that does
//! not parse, and the holders of loot tables, advancements and item modifiers are not checked.
use horizon_client_core::identifier::parse_identifier;
use serde_json::Value;
@@ -23,8 +20,6 @@ const REGISTRY: &str = "minecraft:predicate";
pub(super) enum GraphError {
/// Referenced ids that no predicate binds.
Unbound(BTreeSet<String>),
/// Predicate tags that a `terms` names and no pack defines.
UnboundTags(BTreeSet<String>),
/// References that re-enter a predicate while it is being validated.
Recursive(Vec<Recursion>),
}
@@ -44,10 +39,6 @@ impl GraphError {
"Unbound values in registry ResourceKey[minecraft:root / {REGISTRY}]: [{}]",
ids.iter().cloned().collect::<Vec<_>>().join(", ")
)],
GraphError::UnboundTags(ids) => vec![format!(
"Unbound tags in registry ResourceKey[minecraft:root / {REGISTRY}]: [{}]",
ids.iter().cloned().collect::<Vec<_>>().join(", ")
)],
GraphError::Recursive(found) => found
.iter()
.map(|r| {
@@ -71,16 +62,14 @@ fn holders(condition: &Value) -> Vec<(String, &Value)> {
return Vec::new();
};
match ty.as_str() {
"minecraft:all_of" | "minecraft:any_of" => match condition.get("terms") {
Some(Value::Array(terms)) => terms
.iter()
.enumerate()
.map(|(i, term)| (format!(".terms[{i}]"), term))
.collect(),
Some(Value::String(tag)) if tag.starts_with('#') => Vec::new(),
Some(term) => vec![(".terms[0]".to_owned(), term)],
None => Vec::new(),
},
"minecraft:all_of" | "minecraft:any_of" => condition
.get("terms")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.map(|(i, term)| (format!(".terms[{i}]"), term))
.collect(),
"minecraft:inverted" => condition
.get("term")
.map(|term| vec![(".term".to_owned(), term)])
@@ -89,22 +78,12 @@ fn holders(condition: &Value) -> Vec<(String, &Value)> {
}
}
/// The predicate tag an `all_of` or `any_of` names as its `terms` (`#ns:path`).
fn tag(condition: &Value) -> Option<String> {
let ty = parse_identifier(condition.get("type")?.as_str()?)?;
if !matches!(ty.as_str(), "minecraft:all_of" | "minecraft:any_of") {
return None;
}
parse_identifier(condition.get("terms")?.as_str()?.strip_prefix('#')?)
}
/// Every reference and tag a condition holds, inline conditions included.
fn references(condition: &Value, out: &mut Vec<String>, tags: &mut Vec<String>) {
tags.extend(tag(condition));
/// Every reference a condition holds, inline conditions included.
fn references(condition: &Value, out: &mut Vec<String>) {
for (_, holder) in holders(condition) {
match holder {
Value::String(name) => out.extend(parse_identifier(name)),
Value::Object(_) => references(holder, out, tags),
Value::Object(_) => references(holder, out),
_ => {}
}
}
@@ -146,17 +125,12 @@ fn walk<'v>(
}
}
/// The world's predicates (by normalized id, after pack precedence) and whether a pack defines a
/// predicate tag: missing references first, then missing tags, as the registry freezes before
/// anything is validated, then recursive references.
pub(super) fn check(
predicates: &BTreeMap<String, Value>,
has_tag: impl Fn(&str) -> bool,
) -> Result<(), GraphError> {
/// The world's predicates (by normalized id, after pack precedence): missing references first, as
/// the registry freezes before anything is validated, then recursive ones.
pub(super) fn check(predicates: &BTreeMap<String, Value>) -> Result<(), GraphError> {
let mut referenced = Vec::new();
let mut tags = Vec::new();
for condition in predicates.values() {
references(condition, &mut referenced, &mut tags);
references(condition, &mut referenced);
}
let unbound: BTreeSet<String> = referenced
.into_iter()
@@ -165,10 +139,6 @@ pub(super) fn check(
if !unbound.is_empty() {
return Err(GraphError::Unbound(unbound));
}
let unbound: BTreeSet<String> = tags.into_iter().filter(|id| !has_tag(id)).collect();
if !unbound.is_empty() {
return Err(GraphError::UnboundTags(unbound));
}
let mut found = Vec::new();
for (id, condition) in predicates {
let mut stack = vec![id.as_str()];
@@ -223,7 +193,7 @@ mod tests {
// An identifier that does not parse is not checked here.
("a:v", json!({"type": "inverted", "term": "Not An Id"})),
]);
assert_eq!(check(&predicates, |_| false), Ok(()));
assert_eq!(check(&predicates), Ok(()));
}
#[test]
@@ -235,7 +205,7 @@ mod tests {
),
("a:c", json!({"type": "inverted", "term": "a:c"})),
]);
let error = check(&predicates, |_| false).unwrap_err();
let error = check(&predicates).unwrap_err();
assert_eq!(
error,
GraphError::Unbound(["a:gone".to_owned(), "b:none".to_owned()].into())
@@ -264,7 +234,7 @@ mod tests {
json!({"type": "any_of", "terms": [{"type": "inverted", "term": "prg:self"}]}),
),
]);
let mut lines = check(&predicates, |_| false).unwrap_err().lines();
let mut lines = check(&predicates).unwrap_err().lines();
lines.sort();
// The two cycle lines are the ones vanilla 26.3 logged for this pack.
assert_eq!(
@@ -276,74 +246,4 @@ mod tests {
]
);
}
#[test]
fn a_lone_terms_holder_is_terms_0_and_a_tag_must_be_defined() {
let target = json!({"type": "minecraft:all_of", "terms": []});
// Vanilla 26.3 starts a world whose `terms` is one bound holder alone.
let valid = graph(&[
("prg:target", target.clone()),
("prg:ref", json!({"type": "all_of", "terms": "prg:target"})),
(
"prg:inline",
json!({"type": "any_of", "terms": {"type": "inverted", "term": "prg:target"}}),
),
(
"prg:tagged",
json!({"type": "any_of", "terms": "#prg:some"}),
),
]);
assert_eq!(check(&valid, |tag| tag == "prg:some"), Ok(()));
let missing = graph(&[
("prg:target", target.clone()),
(
"prg:ref",
json!({"type": "minecraft:all_of", "terms": "prg:missing"}),
),
(
"prg:tagged",
json!({"type": "all_of", "terms": "#prg:none"}),
),
]);
// Missing values first, then missing tags: the lines vanilla 26.3 logged for each.
assert_eq!(
check(&missing, |_| false).unwrap_err().lines(),
[
"Unbound values in registry ResourceKey[minecraft:root / minecraft:predicate]: [prg:missing]"
]
);
let tags = graph(&[
("prg:target", target.clone()),
(
"prg:ref",
json!({"type": "minecraft:all_of", "terms": "#prg:none"}),
),
]);
assert_eq!(
check(&tags, |_| false).unwrap_err().lines(),
[
"Unbound tags in registry ResourceKey[minecraft:root / minecraft:predicate]: [prg:none]"
]
);
let cycle = graph(&[
("prg:target", target),
(
"prg:ref",
json!({"type": "minecraft:any_of", "terms": "prg:loop"}),
),
(
"prg:loop",
json!({"type": "minecraft:inverted", "term": "prg:ref"}),
),
]);
let mut lines = check(&cycle, |_| false).unwrap_err().lines();
lines.sort();
assert_eq!(
lines,
[
"Found loot table element validation problem in {prg:loop@minecraft:predicate}.term->{prg:ref@minecraft:predicate}.terms[0]: prg:loop of type minecraft:predicate is recursively called",
"Found loot table element validation problem in {prg:ref@minecraft:predicate}.terms[0]->{prg:loop@minecraft:predicate}.term: prg:ref of type minecraft:predicate is recursively called",
]
);
}
}
@@ -982,7 +982,6 @@ impl Game<'_> {
items: [stack],
ticks: 0,
playing: false,
loaded_clock: false,
}),
);
}
@@ -1443,9 +1442,6 @@ impl Game<'_> {
ProjectileKind::Egg => "minecraft:egg",
ProjectileKind::SmallFireball => "minecraft:small_fireball",
ProjectileKind::WindCharge => "minecraft:wind_charge",
ProjectileKind::Potion(_, true) => "minecraft:lingering_potion",
ProjectileKind::Potion(..) => "minecraft:splash_potion",
ProjectileKind::ExperienceBottle => "minecraft:experience_bottle",
_ => "minecraft:unknown_projectile",
};
let (p, v) = (shot.position, shot.velocity);
+16 -14
View File
@@ -12,6 +12,7 @@ use horizon_client::{
mobs::{Mob, Species, sulfur_cube},
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
random_source,
};
// Its old home, for mixin code that names it here.
pub(super) use horizon_client::mod_api::NativeDispensed;
@@ -1197,9 +1198,9 @@ impl Game<'_> {
"minecraft:experience_bottle"
| "minecraft:splash_potion"
| "minecraft:lingering_potion" => {
let mut shot = super::potions::thrown_projectile(one, at, false);
shot.shoot_own(dir, power, uncertainty);
self.add_projectile(shot);
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut random_source::entity(), dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity, None);
}
_ => {
let kind = match item.as_str() {
@@ -1266,18 +1267,14 @@ impl Game<'_> {
self.next_entity += 1;
let (_, uuid) = horizon_client::mobs::living_random();
self.persist.uuids.insert(self.next_entity, uuid);
let stand =
let mut stand =
horizon_client::entities::ArmorStand::new(self.next_entity, foot, y_rot(facing));
// Entity.applyImplicitComponents: a name from an anvil names the stand.
if let Some(name) = stack.custom_name() {
stand.custom_name = Some(horizon_client::text::Text::literal(name));
}
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.armor_stands.push(stand);
// EntityType.appendDefaultStackConfig: the dispenser's yaw first, then
// the item's createDefaultStackConfig with no user.
self.apply_item_stack_config(
super::commands::Target::Stand(self.next_entity),
"minecraft:armor_stand",
&stack,
false,
);
(shrink(stack), CLICK)
}
/// A tame donkey, mule or llama in front without a chest gets it.
@@ -1346,7 +1343,7 @@ impl Game<'_> {
self.boats.push(boat);
// BoatDispenseItemBehavior: createDefaultStackConfig with no user,
// then setYRot(direction.toYRot()).
self.apply_item_stack_config(super::commands::Target::Boat(id), &kind, &stack, false);
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, &stack, false);
self.set_boat_yaw(id, yaw);
if self.serving.is_some() || self.options.dedicated.is_some() {
let boat = self.boats.last().unwrap();
@@ -1396,7 +1393,12 @@ impl Game<'_> {
self.carts.push(cart);
// AbstractMinecart.createMinecart: createDefaultStackConfig with no user.
let id = self.next_entity;
self.apply_item_stack_config(super::commands::Target::Cart(id), kind.id(), &stack, false);
self.apply_vehicle_stack_config(
super::commands::Target::Cart(id),
kind.id(),
&stack,
false,
);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
+35 -14
View File
@@ -191,12 +191,22 @@ pub(super) struct Crystal {
pub beam: Option<[i32; 3]>,
/// `setPermanentlyInvulnerable` (respawned spike crystals).
pub invulnerable: bool,
/// `onGround` and `fallDistance`: a crystal never moves itself, so they stay
/// as loaded until `/tp` grounds it or a cobweb or powder snow resets the
/// fall (`crystal.rs`).
pub fall: (bool, f64),
}
impl Crystal {
/// The start of `time` (the spin and bob) for a crystal at `position`: the random draw
/// vanilla makes, or under `HORIZON_STILL` one seeded from the position alone. Which crystal
/// gets which draw, and a server's entity ids, follow the order chunks load in, which differs
/// between launches; the position is the same in every copy of the world. The draw is made
/// either way, so the random stays where it would be.
pub(super) fn start_time(position: Vec3, drawn: i32, still: bool) -> i32 {
if still {
horizon_client::mobs::entity_random(0, position)
.next_int_bound(100_000)
.unwrap_or(0)
} else {
drawn
}
}
/// `EntityType.END_CRYSTAL`: 2 x 2.
pub(super) fn bounds(&self) -> Aabb {
let p = self.position;
@@ -1281,8 +1291,14 @@ impl Game<'_> {
let time = Crystal::start_time(at, drawn, self.clock.still());
// EndCrystal's constructor takes an id from the entity counter.
self.next_entity += 1;
self.crystals
.push(Crystal::new(self.next_entity, at, true, time));
self.crystals.push(Crystal {
id: self.next_entity,
position: at,
show_bottom: true,
time,
beam: None,
invulnerable: false,
});
self.world
.modified
.insert((at.x.floor() as i32 >> 4, at.z.floor() as i32 >> 4));
@@ -1312,7 +1328,6 @@ impl Game<'_> {
.flatten();
for i in 0..self.crystals.len() {
self.crystals[i].time += 1;
self.crystal_block_effects(i);
let p = self.crystals[i].position;
let b = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
if let Some(fire) = fire
@@ -3651,7 +3666,7 @@ impl Crystal {
self.position,
Vec3::ZERO,
[0.0, 0.0],
self.fall,
(false, 0.0),
uuid,
raw,
);
@@ -3679,7 +3694,6 @@ impl Crystal {
_ => None,
},
invulnerable: horizon_client::entity_nbt::flag(tag, "Invulnerable"),
fall: horizon_client::entity_nbt::ground_from_nbt(tag),
})
}
}
@@ -3902,8 +3916,14 @@ impl Game<'_> {
// EndCrystal's constructor takes an id from the entity counter, so
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
self.next_entity += 1;
self.crystals
.push(Crystal::new(self.next_entity, at, false, time));
self.crystals.push(Crystal {
id: self.next_entity,
position: at,
show_bottom: false,
time,
beam: None,
invulnerable: false,
});
// EndCrystalItem.useOn: ENTITY_PLACE at the block above, by the player.
self.post_block_game_event("minecraft:entity_place", above, self.sculk_actor(), None);
self.world.modified.insert((above[0] >> 4, above[2] >> 4));
@@ -4199,9 +4219,12 @@ impl Game<'_> {
let drawn = self.world.random.next_int_bound(100_000).unwrap_or(0);
let time = Crystal::start_time(at, drawn, self.clock.still());
self.crystals.push(Crystal {
id: 0,
position: at,
show_bottom: true,
time,
beam: Some([0, 128, 0]),
invulnerable: true,
..Crystal::new(0, at, true, time)
});
let _ = self.set_state_named([cx, h, cz], "minecraft:bedrock", &[]);
let _ = self.set_state_named([cx, h + 1, cz], "minecraft:fire", &[]);
@@ -4746,7 +4769,6 @@ impl Game<'_> {
}
}
mod crystal;
mod death_render;
use death_render::{dissolve_color, dissolve_mask, dragon_rays};
@@ -5711,7 +5733,6 @@ mod dragon_fireball_spawn_tests {
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("forceload add 0 0"));
game
}
@@ -1,60 +0,0 @@
//! An end crystal's construction and the blocks its tick reads.
use super::*;
impl Crystal {
/// `new EndCrystal(level, x, y, z)`: no beam, not invulnerable, on no
/// ground with no fall (what `Entity`'s constructor leaves).
pub(in crate::game) fn new(id: u32, position: Vec3, show_bottom: bool, time: i32) -> Self {
Self {
id,
position,
show_bottom,
time,
beam: None,
invulnerable: false,
fall: (false, 0.0),
}
}
/// The start of `time` (the spin and bob) for a crystal at `position`: the random draw
/// vanilla makes, or under `HORIZON_STILL` one seeded from the position alone. Which crystal
/// gets which draw, and a server's entity ids, follow the order chunks load in, which differs
/// between launches; the position is the same in every copy of the world. The draw is made
/// either way, so the random stays where it would be.
pub(in crate::game) fn start_time(position: Vec3, drawn: i32, still: bool) -> i32 {
if still {
horizon_client::mobs::entity_random(0, position)
.next_int_bound(100_000)
.unwrap_or(0)
} else {
drawn
}
}
}
impl Game<'_> {
/// `EndCrystal.tick`'s `applyEffectsFromBlocks` for crystal `i`, which never
/// moves: a cobweb or powder snow in its box (2 x 2 x 2, deflated by
/// `1.0E-5F`) calls `makeStuckInBlock`, which resets its fall distance.
/// Powder snow's inside shape for a fall over 2.5 is only 0.9 tall
/// (`FALLING_COLLISION_SHAPE`). A bubble column's reset and `stepOn` for a
/// grounded crystal are not ported.
pub(super) fn crystal_block_effects(&mut self, i: usize) {
let (p, fall) = (self.crystals[i].position, self.crystals[i].fall.1);
let e = f64::from(1.0e-5_f32);
let lo = [p.x - 1.0 + e, p.y + e, p.z - 1.0 + e].map(|v| v.floor() as i32);
let hi = [p.x + 1.0 - e, p.y + 2.0 - e, p.z + 1.0 - e].map(|v| v.floor() as i32);
let facts = self.res.facts();
let stuck = (lo[0]..=hi[0]).any(|x| {
(lo[1]..=hi[1]).any(|y| {
(lo[2]..=hi[2]).any(|z| match &*facts.get(self.world.block(x, y, z)).name {
"minecraft:cobweb" => true,
"minecraft:powder_snow" => fall <= 2.5 || p.y + e < f64::from(y) + 0.9,
_ => false,
})
})
});
if stuck {
self.crystals[i].fall.1 = 0.0;
}
}
}
@@ -47,7 +47,6 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
game
}
@@ -68,7 +68,6 @@ fn items_past_the_simulation_distance_enter_no_end_gateway() {
game.world
.chunks
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
game.load_ticking_neighbourhood(cx, 0);
game.world.set(x, 64, 8, gateway).unwrap();
game.dropped
.push(item(id, Vec3::new(f64::from(x) + 0.5, 64.2, 8.5)));
@@ -36,7 +36,6 @@ fn a_mob_presses_a_plate_only_inside_the_simulation_distance() {
game.world
.chunks
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
game.load_ticking_neighbourhood(cx, 0);
game.world.set(x, 63, 8, stone).unwrap();
game.world.set(x, 64, 8, plate).unwrap();
game.mobs.push(Mob::new(
+1 -73
View File
@@ -862,36 +862,13 @@ impl Game<'_> {
let (mut items_kept, mut orbs_kept) = (0, 0);
let (mut boats_gone, mut carts_gone) = (0, 0);
let mut destroyed_contents = Vec::new();
// The entities in reach, less those a mod spares (`native_explosion_entities`); no list
// without native mods, which are the only ones to spare any.
let spared: std::collections::HashSet<u32> = if horizon_client::mod_api::mods().is_empty() {
Default::default()
} else {
let reach: Vec<u32> = self
.entity_handles()
.filter_map(|handle| {
with_entity!(self, handle, e => {
let feet = e.position();
let dist = blast_distance(center, [feet.x, feet.y, feet.z]);
(dist <= diameter).then(|| e.id())
})
})
.collect();
let mut kept = reach.clone();
self.native_explosion_entities(self.dim.id(), center, &mut kept);
let kept: std::collections::HashSet<u32> = kept.into_iter().collect();
reach.into_iter().filter(|id| !kept.contains(id)).collect()
};
for handle in self.entity_handles() {
let handle = match handle {
EntityHandle::Boat(i) => EntityHandle::Boat(i - boats_gone),
EntityHandle::Cart(i) => EntityHandle::Cart(i - carts_gone),
other => other,
};
let spare =
!spared.is_empty() && spared.contains(&with_entity!(self, handle, e => e.id()));
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast))
.filter(|_| !spare);
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast));
let mut destroyed = false;
if let Some(Hit {
dist,
@@ -1851,55 +1828,6 @@ mod tests {
})
}
/// Spares one entity from every blast (`native_explosion_entities`).
struct Spare(u32);
impl crate::mod_api::Hooks for Spare {
fn explosion_entities(
&self,
_: &mut dyn crate::mod_api::Host,
event: &mut crate::mod_api::ExplosionEntitiesEvent<'_>,
) {
event.entities.retain(|&id| id != self.0);
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_mod_spares_an_entity_from_a_blast() {
crate::mod_api::in_own_process(
"game::explosion::tests::a_mod_spares_an_entity_from_a_blast",
a_mod_spares_an_entity_from_a_blast_in_own_process,
);
}
/// Two husks either side of a blast: the one a mod takes out of the list is neither hurt nor
/// pushed, the other is both.
fn a_mod_spares_an_entity_from_a_blast_in_own_process() {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
let spare: Box<dyn crate::mod_api::Hooks> = Box::new(Spare(2));
assert!(crate::mod_api::MODS.set(vec![spare].into()).is_ok());
let kind = game.res.mob_types().get("minecraft:husk").unwrap().clone();
for (id, x) in [(1, 6.5), (2, 10.5)] {
let husk =
horizon_client::mobs::Mob::new(id, kind.clone(), Vec3::new(x, 64.0, 8.5), 0.0);
game.mobs.push(husk);
}
let full = game.mobs[0].health;
game.server_explode_from_cause(
[8.5, 64.0, 8.5],
2.0,
BlockInteraction::Keep,
false,
ExplosionSource::default().with_silence(true),
None,
)
.unwrap();
let (hit, spared) = (&game.mobs[0], &game.mobs[1]);
assert!(hit.health < full && hit.velocity != Vec3::ZERO);
assert_eq!((spared.health, spared.velocity), (full, Vec3::ZERO));
}
#[test]
fn a_mob_that_refuses_the_hit_is_still_pushed() {
use horizon_client::mobs::{Damage, DamageKind, Mob, Species};
+1 -4
View File
@@ -4,12 +4,9 @@
//! Each hook does nothing while its extra is off and no mod uses it.
//!
//! Each Horizon extra's game glue is a child module here, so it leaves the engine in one move
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options),
//! `mouse_tweaks` and `sorting` (the inventory extras).
pub(in crate::game) mod mouse_tweaks;
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options).
pub(in crate::game) mod pvp;
mod reconnect;
mod sorting;
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
use horizon_client::{
@@ -1,155 +0,0 @@
//! Sorting (`sort`, `sortKey`; #332, `docs/extras.md`), off by default: a small button by a
//! menu's storage slots and one by the inventory's (an optional key sorts the part under the
//! cursor). The clicks come from `inv_sort::plan` and go through the Mouse Tweaks' ordinary slot
//! clicks (`mouse_tweaks.rs`), so they work wherever vanilla's do.
use crate::game::Game;
use horizon_client::{
hud::{HudBatch, rgba},
inv_sort,
items::ItemStack,
keybinds,
menu::Input,
stations::hover_name,
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// A sort button's side in GUI pixels.
const SORT_BUTTON: f32 = 9.0;
impl Game<'_> {
/// The sort buttons of the open screen, with the extra on: their
/// corner (from the screen's) and the slots each sorts. Any menu screen
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
/// right of its storage slots (a chest's title line) when it has some,
/// one by the main inventory's (the "Inventory" label's line).
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
use crate::game::NativeSlotRole as Role;
if !self.settings.extras.sort {
return Vec::new();
}
let roles = self.native_slot_roles();
let of = |role| -> Vec<usize> {
roles
.iter()
.filter(|(_, r)| *r == role)
.map(|(s, _)| *s)
.collect()
};
// Over the section's last column: (160, 5) over a chest's slots.
let right = |slots: &[usize]| {
slots
.iter()
.map(|&s| self.slot_position(s).0 + 8.0)
.fold(f32::MIN, f32::max)
};
let mut buttons = Vec::new();
let storage = of(Role::Storage);
if !storage.is_empty() {
let top = storage
.iter()
.map(|&s| self.slot_position(s).1 - 13.0)
.fold(f32::MAX, f32::min);
buttons.push(((right(&storage), top), storage));
}
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
// above the first row): a mod's screen places its inventory itself.
let main = of(Role::Inventory);
if !main.is_empty() {
let y = self
.native_screen_inventory_y()
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
buttons.push(((right(&main), y), main));
}
buttons
}
/// The sort buttons: a bordered square with three bars, lighter under
/// the cursor.
pub(in crate::game) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
let atlas = &self.gui.atlas;
for ((x, y), _) in self.sort_buttons() {
let (x, y) = (ox + x, oy + y);
let (cx, cy) = self.cursor;
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
let face = if hovered {
rgba(220, 220, 220, 255)
} else {
rgba(160, 160, 160, 255)
};
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
SORT_BUTTON - 2.0,
SORT_BUTTON - 2.0,
face,
);
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
let by = y + 2.0 + 2.0 * i as f32;
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
}
}
}
/// A left press on a sort button sorts its slots (true).
pub(in crate::game) fn sort_press(&mut self) -> bool {
let Some(r) = self.renderer.as_ref() else {
return false;
};
let batch = HudBatch::new([r.config.width, r.config.height]);
let (ox, oy) = self.inventory_origin(&batch);
let (cx, cy) = self.cursor;
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
let (x, y) = (ox + x, oy + y);
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
});
let Some((_, slots)) = hit else {
return false;
};
self.sort_slots(&slots);
true
}
/// The sort key: sorts the part under the cursor (the container when
/// the cursor is over no slot).
pub(in crate::game) fn sort_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
}
pub(in crate::game) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
fn sort_input(&mut self, name: &str) -> bool {
let e = &self.settings.extras;
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
return false;
}
let buttons = self.sort_buttons();
let hovered = self.inventory_target().0;
let slots = buttons
.iter()
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
.or(buttons.first())
.map(|(_, slots)| slots.clone());
let Some(slots) = slots else {
return false;
};
self.sort_slots(&slots);
true
}
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
fn sort_slots(&mut self, slots: &[usize]) {
if self.carried.is_some() {
return;
}
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
let clicks = {
let rank = |s: &ItemStack| {
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
(order, hover_name(&self.pack.names, s))
};
let max = |s: &ItemStack| self.max_stack(s);
inv_sort::plan(&views, &rank, &max)
};
for i in clicks {
self.tweak_click(slots[i], 0, Input::Pickup);
}
}
}
@@ -44,7 +44,6 @@ fn loaded_air_is_discarded_on_first_actual_game_tick() {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.res.facts().is_air(game.world.block(4, 204, 4)));
@@ -67,9 +67,6 @@ fn game() -> Game<'static> {
.add_empty_chunk(ChunkPos::new(x, 0))
.unwrap();
}
for x in 0..3 {
game.load_ticking_neighbourhood(x, 0);
}
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(24.5, 201.0, 12.5)).unwrap());
let net = NetConfig {
announce: false,
-1
View File
@@ -273,7 +273,6 @@ mod item_collision_tests {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("summon minecart 4.5 200 4.5"));
assert!(game.run_command("summon oak_boat 10.5 200 10.5"));
@@ -55,9 +55,6 @@ fn game() -> Game<'static> {
.chunks
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
}
for x in 0..2 {
game.load_ticking_neighbourhood(x, 0);
}
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(28.5, 100.0, 10.5)).unwrap());
let net = NetConfig {
@@ -56,9 +56,6 @@ fn game() -> Game<'static> {
.add_empty_chunk(ChunkPos::new(cx, 0))
.unwrap();
}
for cx in 0..2 {
game.load_ticking_neighbourhood(cx, 0);
}
for command in [
"difficulty normal",
"forceload add 0 0 31 15",
@@ -49,7 +49,6 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
for command in [
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
+10 -25
View File
@@ -694,7 +694,7 @@ impl Game<'_> {
position: [f64; 3],
velocity: [f64; 3],
) {
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0, 0);
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0);
}
pub(super) fn add_item_entity(
@@ -704,21 +704,7 @@ impl Game<'_> {
velocity: Option<[f64; 3]>,
pickup_delay: u32,
) -> [f64; 3] {
self.add_item_entity_drawn(
stack,
position,
velocity,
velocity.is_none(),
pickup_delay,
0,
)
}
/// `spawnAtLocation` for a drop that then calls
/// `ItemEntity.setExtendedLifetime`: the item joins the world at `age`
/// instead of 0, so the wither's nether star lives 12000 ticks, not 6000.
pub(super) fn spawn_item_aged(&mut self, stack: ItemStack, position: [f64; 3], age: i32) {
self.add_item_entity_drawn(stack, position, None, true, 10, age);
self.add_item_entity_drawn(stack, position, velocity, velocity.is_none(), pickup_delay)
}
/// `add_item_entity`; `constructor_motion` draws the five-argument
@@ -730,7 +716,6 @@ impl Game<'_> {
velocity: Option<[f64; 3]>,
constructor_motion: bool,
pickup_delay: u32,
age: i32,
) -> [f64; 3] {
let (item, count) = (stack.item.clone(), stack.count as i32);
self.next_entity += 1;
@@ -753,7 +738,6 @@ impl Game<'_> {
rotation: [yaw, 0.0],
pickup_delay: i64::from(pickup_delay),
random: own,
age,
..ItemEntity::new(
self.next_entity,
stack,
@@ -1033,10 +1017,14 @@ mod escape_tests {
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.load_ticking_neighbourhood(0, 0);
// Entity-ticking chunks also need the surrounding loaded chunks.
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(8, 8));
let stone = game
@@ -1068,6 +1056,3 @@ mod escape_tests {
#[cfg(test)]
#[path = "item_void_tests.rs"]
mod item_void_tests;
#[cfg(test)]
#[path = "wither_star_tests.rs"]
mod wither_star_tests;
+49 -117
View File
@@ -8,7 +8,7 @@ use horizon_client::{
containers::{BlockEntity, Jukebox},
first_person::Hand,
items::ItemStack,
mobs::{Species, Who},
mobs::Species,
particles::{ParticleOptions, ParticleType},
text::Text,
};
@@ -53,49 +53,33 @@ impl Game<'_> {
_ => None,
}
}
fn set_has_record(&mut self, pos: [i32; 3], has: bool) -> bool {
self.set_has_record_notify(pos, has, true)
fn set_has_record(&mut self, pos: [i32; 3], has: bool) {
self.set_has_record_notify(pos, has, true);
}
/// The state change alone (`level.setBlock` with flag 2); `notify` adds
/// `notifyItemChangedInJukebox`'s `block_change`, which
/// `JukeboxBlock.setPlacedBy` does not post.
fn set_has_record_notify(&mut self, pos: [i32; 3], has: bool, notify: bool) -> bool {
fn set_has_record_notify(&mut self, pos: [i32; 3], has: bool, notify: bool) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let registry = self.pack.assets.registry;
let Some(mut s) = registry.state(state).cloned() else {
return false;
return;
};
s.properties.insert(
"has_record".into(),
if has { "true" } else { "false" }.into(),
);
let Some(new) = registry.id(&s) else {
return false;
};
let p = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
});
if notify {
let state = self.world.block(pos[0], pos[1], pos[2]);
self.post_block_game_event("minecraft:block_change", pos, None, Some(state));
if let Some(new) = registry.id(&s) {
let p = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
});
// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming
// or going is a block change the sculk sensors hear.
if notify && self.world.block(pos[0], pos[1], pos[2]) != state {
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
}
true
}
/// `JukeboxBlock.HAS_RECORD`, the state both clicks are decided by.
fn jukebox_has_record(&self, pos: [i32; 3]) -> bool {
let state = self.world.block(pos[0], pos[1], pos[2]);
self.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("has_record"))
.is_some_and(|v| v == "true")
}
/// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the block's
/// `has_record`, then `BLOCK_CHANGE` with the state it now has.
fn jukebox_record_changed(&mut self, pos: [i32; 3], has: bool) {
self.set_has_record(pos, has);
}
/// `JukeboxBlock.setPlacedBy`: a jukebox placed from an item whose
/// `block_entity_data` carries a `RecordItem` keeps that record, so
@@ -137,10 +121,9 @@ impl Game<'_> {
{
j.playing = true;
j.song_clock_present = true;
j.loaded_clock = false;
j.ticks = 0;
}
self.jukebox_record_changed(pos, true);
self.set_has_record(pos, true);
self.start_song(pos, &song);
self.jukebox_changed(pos);
}
@@ -158,17 +141,15 @@ impl Game<'_> {
was_playing = j.playing;
j.playing = false;
j.song_clock_present = false;
j.loaded_clock = false;
j.ticks = 0;
}
// `setTheItem(EMPTY)` first tells the block, then stops the song.
self.jukebox_record_changed(pos, false);
// `JukeboxSongPlayer.stop` posts only while a song plays.
if was_playing {
self.stop_song_heard(pos);
} else {
self.stop_song(pos);
}
self.set_has_record(pos, false);
self.jukebox_changed(pos);
}
pub(super) fn jukebox_set_item(&mut self, pos: [i32; 3], stack: Option<ItemStack>) {
@@ -183,9 +164,8 @@ impl Game<'_> {
j.items[0] = stack;
j.playing = song.is_some();
j.song_clock_present = song.is_some();
j.loaded_clock = false;
j.ticks = 0;
self.jukebox_record_changed(pos, has_record);
self.set_has_record(pos, has_record);
match song {
Some(song) => self.start_song(pos, &song),
None if was_playing => self.stop_song_heard(pos),
@@ -204,10 +184,7 @@ impl Game<'_> {
{
return false;
}
let state = self.world.block(pos[0], pos[1], pos[2]);
// The state, not the slot, decides the click: `JukeboxBlock` with a
// record pops it out, without one takes a disc.
if self.jukebox_has_record(pos) {
if self.jukebox(pos).is_some_and(|j| j.record().is_some()) {
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
if self.hand != Hand::Main {
return false;
@@ -222,7 +199,7 @@ impl Game<'_> {
let Some(song) = jukebox_playable(stack, self.res.items()) else {
return false;
};
// JukeboxPlayable.tryInsertIntoJukebox: the one disc the player gives up.
// JukeboxPlayable.tryInsertIntoJukebox
let record = ItemStack {
count: 1,
..stack.clone()
@@ -230,9 +207,7 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
// `JukeboxBlockEntity.setTheItem`: the slot takes the disc, the block
// tells its listeners, and the song plays (`play`); a disc already in
// the slot (under a state that lost its record) is replaced.
self.award_custom_stat("play_record", 1);
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
@@ -240,23 +215,13 @@ impl Game<'_> {
ticks: 0,
playing: true,
song_clock_present: true,
loaded_clock: false,
}),
);
self.jukebox_record_changed(pos, true);
self.set_has_record(pos, true);
if let Some(song) = song {
self.start_song(pos, &song);
}
self.jukebox_changed(pos);
// `tryInsertIntoJukebox`: the same change again, against the state this
// click found, with the player as its source.
self.post_block_game_event(
"minecraft:block_change",
pos,
Some(Who::Player),
Some(state),
);
self.award_custom_stat("play_record", 1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
@@ -270,8 +235,14 @@ impl Game<'_> {
if &*self.res.facts().get(state).name != "minecraft:jukebox" {
return false;
}
self.jukebox_has_record(pos)
|| held.is_some_and(|stack| jukebox_playable(stack, self.res.items()).is_some())
let has_record = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("has_record"))
.is_some_and(|v| v == "true");
has_record || held.is_some_and(|stack| jukebox_playable(stack, self.res.items()).is_some())
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
fn start_song(&mut self, pos: [i32; 3], song: &str) {
@@ -346,28 +317,20 @@ impl Game<'_> {
m.extra.party_jukebox = playing.then_some(pos);
}
}
/// `popOutTheItem`: the disc above the jukebox, and the song stops. An
/// empty slot pops nothing out (`if (!itemBeforePoppingOut.isEmpty())`),
/// whatever the state says.
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
fn eject_record(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
let (record, was_playing) = match self.world.block_entities.get_mut(&key) {
let mut was_playing = false;
let record = match self.world.block_entities.get_mut(&key) {
Some(BlockEntity::Jukebox(j)) => {
if j.items[0].is_none() {
return;
}
let was_playing = j.playing;
was_playing = j.playing;
j.playing = false;
j.song_clock_present = false;
j.loaded_clock = false;
j.ticks = 0;
(j.items[0].take(), was_playing)
j.items[0].take()
}
_ => return,
_ => None,
};
// `removeTheItem` -> `setTheItem(EMPTY)`: the block loses its record,
// then the song stops.
self.jukebox_record_changed(pos, false);
// `JukeboxSongPlayer.stop` posts only while a song plays: a disc
// taken out after its song ended is silent.
if was_playing {
@@ -384,6 +347,7 @@ impl Game<'_> {
);
self.spawn_item_own(record, at, 10);
}
self.set_has_record(pos, false);
self.jukebox_changed(pos);
}
/// A broken jukebox stops its song (its disc drops with the contents).
@@ -395,11 +359,11 @@ impl Game<'_> {
let playing = match self.world.block_entities.get(&key) {
Some(BlockEntity::Jukebox(j)) if j.playing => j.record().and_then(|r| {
let song = song_of_record(r, self.items())?;
Some((song, j.ticks, j.loaded_clock))
Some((song, j.ticks))
}),
_ => None,
};
let Some((song, ticks, loaded)) = playing else {
let Some((song, ticks)) = playing else {
return;
};
let pos = [key.0, key.1, key.2];
@@ -410,26 +374,13 @@ impl Game<'_> {
if let Some(BlockEntity::Jukebox(j)) = self.world.block_entities.get_mut(&key) {
j.playing = false;
j.song_clock_present = false;
j.loaded_clock = false;
j.ticks = 0;
}
// `setSongWithoutPlaying` never adopted a clock past its song's
// end: loading such a jukebox is silent, while a song that ends
// here stops out loud.
if loaded {
return;
}
self.stop_song_heard(pos);
self.jukebox_changed(pos);
return;
}
if loaded {
// The song a save kept goes on without its sound: vanilla starts
// one only from `play` (level event 1010).
if let Some(BlockEntity::Jukebox(j)) = self.world.block_entities.get_mut(&key) {
j.loaded_clock = false;
}
} else if !self.jukebox_sounds.contains_key(&key) && ticks == 0 {
if !self.jukebox_sounds.contains_key(&key) && ticks == 0 {
self.start_song(pos, &song);
}
if ticks % 20 == 0 {
@@ -501,19 +452,12 @@ pub(super) fn jukebox_signals(
) -> Option<(i32, i32)> {
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
Some(BlockEntity::Jukebox(j)) => {
let song = j.record().and_then(|r| song_of_record(r, items));
let comparator = song
.as_deref()
.and_then(|s| items.songs.get(s).map(|song| song.comparator))
let comparator = j
.record()
.and_then(|r| song_of_record(r, items))
.and_then(|s| items.songs.get(&s).map(|song| song.comparator))
.unwrap_or(0);
// `JukeboxSongPlayer.isPlaying`, less a clock a save brought back
// past its song's end: the first tick drops that one.
let stale = j.loaded_clock
&& song
.as_deref()
.and_then(|s| items.songs.get(s))
.is_some_and(|s| s.finished(j.ticks));
Some((if j.playing && !stale { 15 } else { 0 }, comparator))
Some((if j.playing { 15 } else { 0 }, comparator))
}
_ => None,
}
@@ -634,24 +578,18 @@ mod jukebox_play_tests {
);
// The song is still playing after 41 ticks.
assert!(
!lines
.iter()
.any(|l| l.ends_with("minecraft:jukebox_stop_play")),
lines.iter().all(|l| l.ends_with("minecraft:jukebox_play")),
"{lines:?}"
);
}
/// A jukebox whose song is stopped posts no play event: only the twenty
/// ticks of a playing song do. Its insert's `BLOCK_CHANGE` events are the
/// only ones there.
/// ticks of a playing song do.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stopped_jukebox_posts_no_play_event() {
let Some(lines) = playing(0) else { return };
assert!(
lines.iter().all(|l| l.ends_with("minecraft:block_change")),
"{lines:?}"
);
assert!(lines.is_empty(), "{lines:?}");
}
}
@@ -718,8 +656,7 @@ mod saved_song_clock_tests {
assert!(game.run_command("setblock 8 199 4 stone"));
assert!(game.run_command("setblock 8 200 4 jukebox[has_record=true]{RecordItem:{id:\"minecraft:music_disc_cat\",count:1,components:{\"minecraft:custom_data\":{h032:1}}}}"));
let j = game.jukebox([8, 200, 4]).unwrap();
// `setSongWithoutPlaying`: a record whose save had no clock is silent.
assert!(!j.playing, "a record without a clock does not play on load");
assert!(j.playing, "preserve legacy record-only adoption");
assert_eq!(j.ticks, 0);
let saved = game.block_entity_nbt([8, 200, 4]).unwrap();
assert_eq!(
@@ -755,7 +692,6 @@ mod jukebox_component_tests {
ticks: 0,
song_clock_present: true,
playing: true,
loaded_clock: false,
}),
);
jukebox_signals(
@@ -839,10 +775,6 @@ mod jukebox_component_tests {
}
}
#[cfg(test)]
#[path = "jukebox_tests.rs"]
mod jukebox_tests;
#[cfg(test)]
mod block_change_tests {
use super::*;
@@ -1,288 +0,0 @@
//! Jukeboxes against `JukeboxBlock`, `JukeboxBlockEntity` and `JukeboxPlayable`:
//! what a load brings back of a saved song, the `BLOCK_CHANGE` of an insert and
//! of an eject, and which click the `has_record` state allows.
use super::Game;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
containers::{BlockEntity, Jukebox},
items::ItemStack,
mobs::Who,
};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// The game events posted while `f` runs, as `(event, position, source)`.
fn record(f: impl FnOnce()) -> Vec<(&'static str, [f64; 3], Option<Who>)> {
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
f();
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| e.borrow_mut().take().unwrap_or_default())
}
/// A windowless integrated-server game from the prepared resources with a
/// resident chunk; `None` without them.
fn with_game<R>(f: impl FnOnce(&mut Game<'static>) -> R) -> Option<R> {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES")?);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.ok()?;
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
Some(f(&mut game))
}
const AT: [f64; 3] = [0.5, 64.5, 0.5];
/// The jukebox at (0, 64, 0) in the state `record` names; its state id.
fn place_jukebox(game: &mut Game<'_>, record: bool) -> BlockStateId {
let registry = game.pack.assets.registry;
let mut state = registry
.state(registry.default_state("minecraft:jukebox").unwrap())
.unwrap()
.clone();
state.properties.insert(
"has_record".into(),
if record { "true" } else { "false" }.into(),
);
let id = registry.id(&state).unwrap();
game.world.set(0, 64, 0, id).unwrap();
id
}
/// A jukebox block entity as a save writes it, loaded back into the world at
/// (0, 64, 0): `RecordItem` and, with a `clock`, `ticks_since_song_started`.
fn load_saved(game: &mut Game<'_>, clock: Option<i64>) {
let clock = clock.map_or(String::new(), |n| format!("ticks_since_song_started:{n}L,"));
let (tag, _) = horizon_world::nbt::parse_snbt(&format!(
"{{id:\"minecraft:jukebox\",x:0,y:64,z:0,\
RecordItem:{{id:\"minecraft:music_disc_11\",count:1,}},{clock}}}"
))
.unwrap();
let (pos, model) = horizon_client::save::decode_block_entity(&tag).unwrap();
game.world.block_entities.insert(pos, model);
}
/// `JukeboxBlockEntity.setTheItem` posts `BLOCK_CHANGE` with the state the
/// block now has, and `JukeboxPlayable.tryInsertIntoJukebox` then posts the
/// same change again with the player as its source (against the state the
/// click found). `popOutTheItem` -> `setTheItem(EMPTY)` posts one on the way
/// out, before the song's own `JUKEBOX_STOP_PLAY`. A sculk sensor beside a
/// jukebox heard neither insert nor eject: only the song's end.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_insert_posts_two_block_changes_and_an_eject_one() {
let Some((inserted, popped)) = with_game(|game| {
place_jukebox(game, false);
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
let inserted = record(|| {
assert!(
game.use_jukebox([0, 64, 0], Some(&disc)),
"the disc goes in"
);
});
let popped = record(|| {
assert!(
game.use_jukebox([0, 64, 0], None),
"the empty hand pops it out"
);
});
(inserted, popped)
}) else {
return;
};
assert_eq!(
inserted,
vec![
("minecraft:block_change", AT, None),
("minecraft:block_change", AT, Some(Who::Player)),
],
"the slot's change, then the player's"
);
assert_eq!(
popped,
vec![
("minecraft:block_change", AT, None),
("minecraft:jukebox_stop_play", AT, None),
],
"the record leaves the block before its song stops"
);
}
/// `JukeboxBlock.useWithoutItem` only pops when the state says a record is
/// there, and `popOutTheItem` pops nothing from an empty slot; `useItemOn`
/// only inserts when it says there is none, and replacing the slot's item is
/// `setTheItem`'s own doing. Horizon decided both by the slot.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_has_record_state_decides_both_clicks() {
let Some((ignored, replaced)) = with_game(|game| {
// has_record with nothing in the slot: the click pops nothing and the
// disc stays in hand, so the state stays as it is.
let recorded = place_jukebox(game, true);
game.world
.block_entities
.insert((0, 64, 0), BlockEntity::Jukebox(Jukebox::default()));
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
let ignored = record(|| {
assert!(
game.use_jukebox([0, 64, 0], Some(&disc)),
"the click is consumed"
);
});
assert_eq!(game.world.block(0, 64, 0), recorded, "keeps its record");
assert!(
game.jukebox([0, 64, 0]).unwrap().record().is_none(),
"the held disc is not inserted"
);
// No record in the state with a disc in the slot: the held one replaces
// it (`JukeboxBlockEntity.setTheItem`), it does not eject the old one.
place_jukebox(game, false);
let old = ItemStack::new("minecraft:music_disc_13", 1);
game.world.block_entities.insert(
(0, 64, 0),
BlockEntity::Jukebox(Jukebox {
items: [Some(old)],
..Default::default()
}),
);
let replaced = record(|| {
assert!(
game.use_jukebox([0, 64, 0], Some(&disc)),
"the disc goes in"
);
});
let j = game.jukebox([0, 64, 0]).unwrap();
assert_eq!(&*j.record().unwrap().item, "minecraft:music_disc_cat");
assert!(j.playing, "the new disc plays");
(ignored, replaced)
}) else {
return;
};
assert!(ignored.is_empty(), "nothing happened: {ignored:?}");
assert_eq!(
replaced,
vec![
("minecraft:block_change", AT, None),
("minecraft:block_change", AT, Some(Who::Player)),
],
"the old disc is replaced, not popped out"
);
}
/// `JukeboxBlockEntity.loadAdditional` hands a saved clock to
/// `JukeboxSongPlayer.setSongWithoutPlaying`: a song under its length comes
/// back playing, without `play`'s level event 1010 and its "Now Playing", a
/// clock past the song's end is dropped without a `JUKEBOX_STOP_PLAY`, and a
/// record saved without a clock is no song at all.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_saved_song_comes_back_without_its_sound() {
let Some(cases) = with_game(|game| {
place_jukebox(game, true);
let signal = |game: &Game<'_>| {
let p = horizon_core::position::BlockPos::new(0, 64, 0);
super::jukebox_signals(&game.world, game.items(), p)
.unwrap()
.0
};
let mut cases = Vec::new();
for clock in [Some(41), Some(10_000), None, Some(0)] {
load_saved(game, clock);
let events = record(|| game.tick_jukebox((0, 64, 0)));
let j = game.jukebox([0, 64, 0]).unwrap();
cases.push((
clock,
(j.playing, j.ticks, j.song_clock_present),
signal(game),
game.titles.overlay.is_some(),
events,
));
}
cases
}) else {
return;
};
let at = |clock: Option<i64>| cases.iter().find(|c| c.0 == clock).unwrap();
// Mid-song and at its start: the clock goes on, at 15, quietly; only the
// twenty-tick chatter of a song that is running.
assert_eq!(at(Some(41)).1, (true, 42, true), "the clock goes on");
assert_eq!(at(Some(41)).2, 15);
assert!(!at(Some(41)).3, "nothing says \"Now Playing\" on a load");
assert!(at(Some(41)).4.is_empty(), "{:?}", at(Some(41)).4);
assert_eq!(at(Some(0)).1, (true, 1, true));
assert!(!at(Some(0)).3, "a clock saved at its start stays quiet");
assert_eq!(
at(Some(0)).4,
vec![("minecraft:jukebox_play", AT, None)],
"the first tick's game event is vanilla's"
);
// Past the end: gone, and silent about going.
assert_eq!(at(Some(10_000)).1, (false, 0, false));
assert_eq!(at(Some(10_000)).2, 0, "a dropped song powers nothing");
assert!(at(Some(10_000)).4.is_empty(), "{:?}", at(Some(10_000)).4);
// A record saved without a clock: no song, no signal, no event.
assert_eq!(at(None).1, (false, 0, false));
assert_eq!(at(None).2, 0);
assert!(!at(None).3, "a record alone starts no song");
assert!(at(None).4.is_empty(), "{:?}", at(None).4);
}
/// The same three cases on the load path itself, which needs no resources: only
/// a saved clock brings a song back, and it comes back unstarted.
#[test]
fn only_a_saved_clock_brings_a_song_back() {
let load = |clock: Option<i64>| {
let clock = clock.map_or(String::new(), |n| format!("ticks_since_song_started:{n}L,"));
let (tag, _) = horizon_world::nbt::parse_snbt(&format!(
"{{id:\"minecraft:jukebox\",x:0,y:64,z:0,\
RecordItem:{{id:\"minecraft:music_disc_11\",count:1,}},{clock}}}"
))
.unwrap();
match horizon_client::save::decode_block_entity(&tag).unwrap().1 {
BlockEntity::Jukebox(j) => j,
other => panic!("not a jukebox: {other:?}"),
}
};
let mid = load(Some(41));
assert_eq!(mid.ticks, 41);
assert!(
mid.playing && mid.song_clock_present,
"a saved clock brings the song back"
);
let unclocked = load(None);
assert_eq!(
unclocked.record().map(|r| &*r.item),
Some("minecraft:music_disc_11")
);
assert_eq!(unclocked.ticks, 0);
assert!(
!unclocked.playing && !unclocked.song_clock_present,
"a record alone is no song"
);
}
@@ -47,7 +47,6 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
for command in [
"forceload add 0 0",
"fill 0 63 0 15 63 15 stone",
+1 -1
View File
@@ -1161,7 +1161,7 @@ impl Game<'_> {
let scale = 1i32 << m.scale;
let start_x = m.center[0] / scale - 64;
let start_z = m.center[1] / scale - 64;
let sea_level = horizon_world::level::sea_level_of(self.level_key());
let sea_level = 63;
let quarts: Vec<[i32; 3]> = (0..128)
.flat_map(|row| {
(0..128).map(move |column| {
@@ -656,16 +656,10 @@ impl Game<'_> {
self.station_sound(sound);
}
} else if kind == Kind::Stonecutter {
// `StonecutterMenu.container`'s listener runs on every change of the
// input slot (`slotsChanged` and `containerChanged` both hang off
// it): a recipe button or an inventory click is not one.
let input_changed = before[0] != menu.slot(0).cloned();
if let Some(s) = self.station.as_mut() {
s.selected = usize::try_from(menu.data[0]).ok();
s.stone = std::mem::take(&mut menu.stone);
if input_changed {
s.stonecutter_changed();
}
s.stonecutter_changed();
}
for sound in sounds.into_iter().filter(|_| !predicting) {
self.station_sound(sound);
+47 -40
View File
@@ -233,19 +233,6 @@ fn pickup_instead_of_push(species: Species, is_passenger: bool, cart_is_vehicle:
!cart_is_vehicle && !is_passenger && species != Species::IronGolem
}
/// `EntitySelector.pushableBy` asks `isPushable`: `Shulker` takes no push
/// (`Mob::pushable` leaves it out) but still counts, so a cart offers it
/// `startRiding` like any other living entity.
fn offered_pickup(pushable: bool, species: Species, alive: bool) -> bool {
pushable || species == Species::Shulker && alive
}
/// `Shulker.push(Entity)` is empty, so a shulker that is not picked up (it
/// already rides something) neither moves nor slows the cart.
fn takes_push_from_cart(species: Species) -> bool {
species != Species::Shulker
}
/// `Entity.canRide` for a cart: the boarding cooldown, and a warden or the
/// wither never rides (`canRide` false).
fn can_ride_cart(species: Species, boarding_cooldown: i32) -> bool {
@@ -1807,10 +1794,7 @@ impl Game<'_> {
let (teams, own) = (self.mob_teams(), self.team_with_owner(id, || None));
near.iter()
.map(|&j| &self.mobs[j as usize])
.filter(|m| {
offered_pickup(m.pushable(), m.species(), m.alive())
&& pushable_by(own, teams.of(Who::Mob(m.id)), true)
})
.filter(|m| m.pushable() && pushable_by(own, teams.of(Who::Mob(m.id)), true))
.map(|m| m.id)
.collect()
};
@@ -1843,9 +1827,6 @@ impl Game<'_> {
}
continue;
}
if !takes_push_from_cart(species) {
continue;
}
let other = self.mobs[j].position;
let Some(v) = super::boat_passengers::push_impulse(pos, other) else {
continue;
@@ -2163,6 +2144,51 @@ impl Game<'_> {
p.velocity = Vec3::ZERO;
}
}
/// `EntityType.createDefaultStackConfig` on a vehicle an item made: the
/// item's custom name and custom data (`applyComponentsFromItemStack`:
/// `Entity.applyImplicitComponent`'s two, saved as `CustomName` and
/// `data`), then its
/// `minecraft:entity_data` loaded over the vehicle (`updateCustomEntityTag`)
/// when it names this type. An op-only type (command block and spawner
/// minecarts) takes it from an op player only, never from a dispenser
/// (`by_player` false).
pub(super) fn apply_vehicle_stack_config(
&mut self,
target: super::commands::Target,
kind: &str,
stack: &ItemStack,
by_player: bool,
) {
use horizon_world::nbt::Tag;
let name = stack.data().and_then(|d| d.custom_name.clone());
let custom = stack
.data()
.and_then(|d| d.other("minecraft:custom_data"))
.cloned();
let op = by_player && self.acting_player_is_op();
let data =
horizon_client::block_entity_item::spawn_egg_entity_data(stack, kind, op).cloned();
if name.is_none() && custom.is_none() && data.is_none() {
return;
}
let Some(mut own) = self.entity_nbt(target) else {
return;
};
if let Tag::Compound(map) = &mut own {
if let Some(name) = name {
map.insert("CustomName".into(), name);
}
if let Some(custom) = custom {
map.insert("data".into(), custom);
}
}
let tag = match &data {
Some(data) => horizon_client::data_cmd::merged(&own, data),
None => own,
};
// The vehicle's own compound, its name and data merged in: it loads back.
let _ = self.apply_entity_nbt(target, tag);
}
/// `MinecartItem.useOn`: a cart on the clicked rail.
pub(super) fn place_minecart(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let Some(kind) = CartKind::from_id(&stack.item) else {
@@ -2184,7 +2210,7 @@ impl Game<'_> {
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
self.carts.push(cart);
let id = self.next_entity;
self.apply_item_stack_config(super::commands::Target::Cart(id), kind.id(), stack, true);
self.apply_vehicle_stack_config(super::commands::Target::Cart(id), kind.id(), stack, true);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
@@ -3356,25 +3382,6 @@ mod tests {
assert!(!can_ride_cart(Species::Warden, 0));
}
/// A shulker beside the rail is picked up although `Mob::pushable` leaves it out.
#[test]
fn a_cart_picks_up_a_shulker_but_not_a_dead_one() {
assert!(offered_pickup(false, Species::Shulker, true));
assert!(!offered_pickup(false, Species::Shulker, false));
assert!(!offered_pickup(false, Species::Bat, true));
assert!(offered_pickup(true, Species::Cow, true));
assert!(pickup_instead_of_push(Species::Shulker, false, false));
assert!(can_ride_cart(Species::Shulker, 0));
}
/// A shulker already riding a boat is no pickup candidate and `Shulker.push` is a no-op.
#[test]
fn a_mounted_shulker_does_not_push_a_cart() {
assert!(!pickup_instead_of_push(Species::Shulker, true, false));
assert!(!takes_push_from_cart(Species::Shulker));
assert!(takes_push_from_cart(Species::Cow));
}
/// `WitherBoss.canRide` returns false, so a moving cart cannot scoop the boss up.
#[test]
fn a_wither_never_rides_a_cart() {
@@ -268,40 +268,3 @@ fn efficiency_applies_to_a_same_item_swap() {
}
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stacks_attribute_modifiers_replace_its_items_attack_damage() {
with_survival_game(|game| {
// Player.attack reads getAttributeValue(ATTACK_DAMAGE): the base 1
// plus what detectEquipmentUpdates gave the player for the held stack
// (ItemStack.forEachModifier, the stack's ATTRIBUTE_MODIFIERS). An
// iron pickaxe's own modifier is +3 (ToolMaterial.IRON's 2 plus the
// pickaxe's 1); the stack's +20 replaces it, so the hit is 21 (#14641).
for (stack, damage) in [
("minecraft:iron_pickaxe", 4.0),
(
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"minecraft:attack_damage\",id:\"minecraft:base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"mainhand\"}]]",
21.0,
),
// Identifier.CODEC reads bare names as `minecraft:`'s: the same
// modifier, so the attribute the host applies it to is found.
(
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"attack_damage\",id:\"base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"mainhand\"}]]",
21.0,
),
// In the off hand slot group, it does not apply to the main hand.
(
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"minecraft:attack_damage\",id:\"minecraft:base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"offhand\"}]]",
1.0,
),
("minecraft:iron_pickaxe[!attribute_modifiers]", 1.0),
] {
assert!(game.run_command(&format!(
"item replace entity @s weapon.mainhand with {stack}"
)));
game.tick_hand();
assert_eq!(game.attack_damage(), damage, "{stack}");
}
});
}
@@ -44,13 +44,18 @@ fn game() -> Game<'static> {
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
// Entities tick once every chunk within 2 columns is loaded
// (ChunkMap.prepareEntityTickingChunk, #13953).
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(x, z))
.unwrap();
}
}
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
game
}
+107 -14
View File
@@ -992,8 +992,6 @@
use super::native_data_maps::NativeDataMapType;
use super::native_guards::{box_meets, finite, finite_params, mod_image_fits};
use super::native_item_energy::ItemEnergyBuffer;
use super::native_level_effects::{mod_particles_ok, mod_sound_ok, sound_id};
use super::native_menus::viewer_changes;
use super::native_world_edit::{property_change, with_property};
use super::{
Game, GameLevel, LevelDimension, Screen,
@@ -8506,18 +8504,6 @@ impl Game<'_> {
blocks,
);
}
/// An explosion at `center` in `dimension` is about to hurt and push `entities`, the ids of
/// the entities in its reach (players aside: `native_living_incoming_damage` hears them),
/// after `native_explosion_detonate`: `ServerExplosion.hurtEntities`. Take out the ids to
/// spare: they are neither hurt nor pushed. Server-side. API @API:explosion_entities@.
pub(super) fn native_explosion_entities(
&mut self,
dimension: &str,
center: [f64; 3],
entities: &mut Vec<u32>,
) {
mod_api::dispatch_explosion_entities(self, mod_api::mods(), dimension, center, entities);
}
/// An explosion at `center` in `dimension`, about to break `blocks`, pushes
/// `entity` (None for a player, whom `native_acting_player` names):
/// `ServerExplosion.hurtEntities`, after the knockback is calculated. Change `knockback_velocity` to change
@@ -10005,6 +9991,113 @@ fn mob_source(damage: &Damage) -> HurtSource<'static> {
}
}
/// Whether `id` is a sound event's id, `namespace:path` as `Identifier`
/// allows: the namespace of `[a-z0-9_.-]`, the path also with `/`.
/// Whether a native mod's sound can be sent: a namespaced event, a sound
/// source, a position inside the world's range and a finite, non-negative
/// volume and pitch.
pub(super) fn mod_sound_ok(
sound: &str,
at: [f64; 3],
source: usize,
volume: f32,
pitch: f32,
) -> bool {
let position_ok = at
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX) / 8.0);
let gain_ok = volume.is_finite() && volume >= 0.0 && pitch.is_finite() && pitch >= 0.0;
sound_id(sound)
&& source < horizon_client::settings::SOUND_SOURCES.len()
&& position_ok
&& gain_ok
}
/// Whether a native mod's particles can be sent. Block particle types need
/// a known state (`state_ok`); item, trail and vibration particles aren't
/// supported by the network dispatch; a mod's own type must be namespaced.
pub(super) fn mod_particles_ok(
options: &ParticleOptions,
state: Option<BlockStateId>,
state_ok: impl Fn(BlockStateId) -> bool,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
) -> bool {
use ParticleType as T;
// A native mod's own type: the name has to be namespaced, and a client
// draws it only when its pack has the sprite set.
if let Some(name) = &options.mod_particle {
let namespaced = name
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
if !namespaced {
return false;
}
}
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(state_ok)
}
T::Item | T::Trail | T::Vibration => false,
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
.geyser
.is_some_and(|(_, impulse)| impulse.is_finite()),
_ => true,
};
supported
&& at.into_iter().all(f64::is_finite)
&& spread
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
&& speed.is_finite()
&& speed.abs() <= f32::MAX as f64
&& options.color.into_iter().all(f32::is_finite)
&& options.to_color.into_iter().all(f32::is_finite)
&& options.scale.is_finite()
}
fn sound_id(id: &str) -> bool {
let Some((namespace, path)) = id.split_once(':') else {
return false;
};
let allowed = |c: char, slash: bool| {
c.is_ascii_lowercase() || c.is_ascii_digit() || "_.-".contains(c) || (slash && c == '/')
};
!namespace.is_empty()
&& !path.is_empty()
&& namespace.chars().all(|c| allowed(c, false))
&& path.chars().all(|c| allowed(c, true))
}
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
pub(super) fn dimension_id(dimension: LevelDimension) -> &'static str {
match dimension {
LevelDimension::Overworld => "minecraft:overworld",
@@ -3,9 +3,9 @@
//! Every player in range hears or sees them, as `ServerLevel.playSound` and
//! `sendParticles` send them. They return false on a client, for a handle of
//! an earlier open, or for arguments the network can't carry.
use super::native_hooks::{mod_particles_ok, mod_sound_ok};
use super::{Dim, Game, creatures::NativeSound, lan_particles::QueuedParticle};
use horizon_client::mod_api::{LevelRef, ParticleOptions};
use horizon_client::particles::ParticleType;
use horizon_world::registry::BlockStateId;
impl Game<'_> {
@@ -85,82 +85,3 @@ impl Game<'_> {
true
}
}
/// Whether `id` is a sound event's id, `namespace:path` as `Identifier`
/// allows: the namespace of `[a-z0-9_.-]`, the path also with `/`.
/// Whether a native mod's sound can be sent: a namespaced event, a sound
/// source, a position inside the world's range and a finite, non-negative
/// volume and pitch.
pub(super) fn mod_sound_ok(
sound: &str,
at: [f64; 3],
source: usize,
volume: f32,
pitch: f32,
) -> bool {
let position_ok = at
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX) / 8.0);
let gain_ok = volume.is_finite() && volume >= 0.0 && pitch.is_finite() && pitch >= 0.0;
sound_id(sound)
&& source < horizon_client::settings::SOUND_SOURCES.len()
&& position_ok
&& gain_ok
}
/// Whether a native mod's particles can be sent. Block particle types need
/// a known state (`state_ok`); item, trail and vibration particles aren't
/// supported by the network dispatch; a mod's own type must be namespaced.
pub(super) fn mod_particles_ok(
options: &ParticleOptions,
state: Option<BlockStateId>,
state_ok: impl Fn(BlockStateId) -> bool,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
) -> bool {
use ParticleType as T;
// A native mod's own type: the name has to be namespaced, and a client
// draws it only when its pack has the sprite set.
if let Some(name) = &options.mod_particle {
let namespaced = name
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
if !namespaced {
return false;
}
}
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(state_ok)
}
T::Item | T::Trail | T::Vibration => false,
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
.geyser
.is_some_and(|(_, impulse)| impulse.is_finite()),
_ => true,
};
supported
&& at.into_iter().all(f64::is_finite)
&& spread
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
&& speed.is_finite()
&& speed.abs() <= f32::MAX as f64
&& options.color.into_iter().all(f32::is_finite)
&& options.to_color.into_iter().all(f32::is_finite)
&& options.scale.is_finite()
}
pub(super) fn sound_id(id: &str) -> bool {
let Some((namespace, path)) = id.split_once(':') else {
return false;
};
let allowed = |c: char, slash: bool| {
c.is_ascii_lowercase() || c.is_ascii_digit() || "_.-".contains(c) || (slash && c == '/')
};
!namespace.is_empty()
&& !path.is_empty()
&& namespace.chars().all(|c| allowed(c, false))
&& path.chars().all(|c| allowed(c, true))
}
@@ -560,34 +560,6 @@ impl Game<'_> {
}
}
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut std::collections::HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: std::collections::HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
#[cfg(test)]
mod tests {
use super::*;
@@ -208,9 +208,6 @@ fn not_drawn(m: &Manifest, switch: bool) -> Option<String> {
.into(),
);
}
if m.entity.is_some() {
return Some("the [entity] stage isn't drawn yet: leave it out".into());
}
(m.api != Api::V0_1 && !switch).then(|| {
format!(
"it is written for shader API {}, which is experimental and drawn only under \
@@ -362,8 +359,6 @@ mod tests {
why.contains("primaries = \"bt2020\" isn't served yet"),
"{why}"
);
let why = not_drawn(&manifest("0.2", "[entity]\nfile = \"e.wgsl\"\n"), switch);
assert!(why.unwrap().contains("[entity] stage isn't drawn yet"));
let named = manifest("0.2", "[frame]\nprimaries = \"bt709\"\n");
assert_eq!(not_drawn(&named, true), None);
}
+12 -56
View File
@@ -13,7 +13,6 @@ use super::entity::{Entity as _, entity_lists_of, for_each_entity, with_entity};
use super::{Dim, Game, Result, Screen, level_flags};
use horizon_client::fast_hash::FxHashMap;
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
use horizon_client::world::PendingEntity;
use horizon_core::{
geometry::{Aabb, Axis, Vec3},
position::BlockPos,
@@ -75,22 +74,10 @@ impl Plan {
/// An entity on its way to a dimension that isn't loaded.
#[derive(Clone, Debug)]
struct Arrival {
/// Teleport-only state; deliberately absent from arrival_tag.
tnt_used_portal: bool,
plan: Plan,
tag: Tag,
}
impl Arrival {
fn new(plan: Plan, tag: Tag, tnt_used_portal: bool) -> Self {
Self {
plan,
tag,
tnt_used_portal,
}
}
}
/// The nether portal state of the world (`Game::portals`).
#[derive(Default)]
pub(super) struct Portals {
@@ -270,7 +257,6 @@ fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
return None;
};
Some(Arrival {
tnt_used_portal: false,
plan: Plan {
dim,
approximate: BlockPos::new(x, y, z),
@@ -545,7 +531,6 @@ impl Game<'_> {
self.tickets.add_portal(chunk);
}
}
/// `NetherPortalBlock.createDimensionTransition`: the arrival position
/// and the yaw turned by.
pub(super) fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) {
@@ -1113,7 +1098,7 @@ impl Game<'_> {
);
self.remove_dropped(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.tnts.iter().position(|t| t.id == id) {
let t = &self.tnts[i];
@@ -1121,13 +1106,10 @@ impl Game<'_> {
if !self.may_enter(plan.dim) {
return;
}
self.tnts[i].used_portal = true;
let tag = self.tnt_nbt(i);
let transferred = self.tnts.remove(i);
self.tnts.remove(i);
self.forget_entity(id);
self.portals
.arrivals
.push(Arrival::new(plan, tag, transferred.used_portal));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.tridents.iter().position(|t| t.id == id) {
let plan = self.entity_plan(entry, self.tridents[i].position, 0.5, 0.5, false);
@@ -1139,7 +1121,7 @@ impl Game<'_> {
self.tridents[i].to_nbt(uuid, self.pack.assets.registry, self.persist_raw(id));
self.tridents.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.rockets.iter().position(|r| r.id == id) {
let plan = self.entity_plan(entry, self.rockets[i].position, 0.25, 0.25, false);
@@ -1150,7 +1132,7 @@ impl Game<'_> {
let tag = self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist_raw(id));
self.rockets.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.orbs.iter().position(|o| o.id == id) {
let plan = self.entity_plan(entry, self.orbs[i].position, 0.5, 0.5, false);
@@ -1161,7 +1143,7 @@ impl Game<'_> {
let tag = horizon_client::entity_nbt::orb_to_nbt(&self.orbs[i], uuid);
self.orbs.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let m = &self.mobs[i];
@@ -1180,7 +1162,7 @@ impl Game<'_> {
for g in gone {
self.detach_entity(g);
}
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
@@ -1200,7 +1182,7 @@ impl Game<'_> {
}
self.boats.remove(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let c = &self.carts[i];
@@ -1231,7 +1213,7 @@ impl Game<'_> {
}
self.carts.remove(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self
.creatures
@@ -1266,7 +1248,7 @@ impl Game<'_> {
};
self.creatures.projectiles.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
}
if self.portals.arrivals.len() > arrivals {
@@ -1341,11 +1323,7 @@ impl Game<'_> {
i += 1;
continue;
}
let Arrival {
plan,
mut tag,
tnt_used_portal,
} = self.portals.arrivals.remove(i);
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
self.portals.dirty = true;
let motion = horizon_client::entity_nbt::motion(&tag);
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
@@ -1381,11 +1359,6 @@ impl Game<'_> {
preserve_arrival_cooldown(&mut tag);
// ServerLevel.addDuringTeleport: a fresh add, not loaded from disk.
super::persist::mark_fresh(&mut tag);
let tag = if tnt_used_portal {
PendingEntity::after_tnt_portal(tag)
} else {
tag.into()
};
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
@@ -1450,7 +1423,7 @@ impl Game<'_> {
if f.id != 0 {
self.forget_entity(f.id);
}
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
Some(plan.dim)
}
@@ -1625,20 +1598,6 @@ impl Game<'_> {
mod tests {
use super::*;
#[test]
fn portal_transfer_pending_arrival_does_not_save_transient_tnt_immunity() {
let arrival = Arrival {
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.98, 0.98),
tag: Tag::compound([("id", Tag::String("minecraft:tnt".into()))]),
tnt_used_portal: true,
};
assert!(
!arrival_from_tag(&arrival_tag(&arrival))
.unwrap()
.tnt_used_portal
);
}
#[test]
fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() {
let spawn = BlockPos::new(-7, 70, 12);
@@ -1718,7 +1677,6 @@ mod tests {
let arrival = Arrival {
plan: Plan::end_portal(Dim::Overworld, BlockPos::new(0, 64, 0), 1.375, 0.5625),
tag,
tnt_used_portal: false,
};
let restored = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
let expected = old.filter(|n| *n > 0).unwrap_or(ENTITY_DELAY);
@@ -1734,7 +1692,6 @@ mod tests {
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] {
let arrival = Arrival {
tnt_used_portal: false,
plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4),
tag: tag.clone(),
};
@@ -1746,7 +1703,6 @@ mod tests {
}
// Arrivals saved before End portals were Nether portal plans.
let mut old = arrival_tag(&Arrival {
tnt_used_portal: false,
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25),
tag,
});
-2
View File
@@ -126,8 +126,6 @@ impl Game<'_> {
.map(super::native_hooks::mob_kind)
.unwrap_or_default();
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
// Animal.setInLove: entity event 18, the love hearts.
self.mob_event_particles(id, 18);
}
self.consume_held();
if let Some(stack) = dropped {
+22 -38
View File
@@ -7,7 +7,6 @@ mod display;
mod hurt_by_refs;
use super::{FallingBlock, Game};
use horizon_client::world::EntityLoadReason;
use horizon_client::{
Result,
chunk::Chunk,
@@ -535,7 +534,7 @@ impl Game<'_> {
let player_uuid = self.level.player_uuid;
for (key, tags) in loaded {
let count = tags.len();
for (mut tag, reason) in tags.into_iter().map(|pending| pending.into_parts()) {
for mut tag in tags {
// A world older builds named by the offline profile names the player that
// way in its entities too: migrate them as they load, so the next save of
// the chunk keeps the account's UUID (`playerdata::migrate_owner`).
@@ -592,16 +591,7 @@ impl Game<'_> {
}
// Kinds outside `entity_lists!` first, then the listed kinds
// (`Entity::load_nbt`), then whatever else is a compound.
let loaded = if reason == EntityLoadReason::TeleportedTnt
&& kind == "minecraft:tnt"
&& !is_mob
{
self.load_tnt_with_portal(tag, uuid, true);
Ok(())
} else {
self.load_unlisted(key, &kind, uuid, tag)
};
if let Err(tag) = loaded
if let Err(tag) = self.load_unlisted(key, &kind, uuid, tag)
&& let Err(tag) = self.load_listed(&kind, uuid, player_uuid, tag)
{
if !kind.is_empty() && entity_nbt::position(&tag).is_some() {
@@ -862,9 +852,6 @@ impl Game<'_> {
/// `PrimedTnt.readAdditionalSaveData`: its numeric short fuse (80 when absent) and motion.
// Its Passengers ride it (EntityType.loadEntityRecursive forces them on).
pub(super) fn load_tnt(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
self.load_tnt_with_portal(tag, uuid, false);
}
fn load_tnt_with_portal(&mut self, tag: Tag, uuid: Option<[i32; 4]>, used_portal: bool) {
if uuid.is_some_and(|u| self.target_with_uuid(u).is_some()) {
return;
}
@@ -881,7 +868,6 @@ impl Game<'_> {
// to int before short narrowing; integer tags narrow directly.
let fuse = tnt_fuse(&tag);
self.tnts.push(super::tnt::PrimedTnt {
used_portal,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: entity_nbt::flag(&tag, "NoGravity"),
@@ -1384,7 +1370,7 @@ impl Game<'_> {
{
entity_nbt::fill_common_defaults(&mut tag);
}
with_raw(tag, None) // Entity.saveWithoutId's flags, as booleans.
tag
}
/// The root of a stack of riders (`Entity.getRootVehicle`).
fn mob_root(&self, mut i: usize) -> usize {
@@ -1514,7 +1500,7 @@ impl Game<'_> {
(at.z.floor() as i32).div_euclid(16),
);
self.persist.restored.insert(root_uuid);
self.world.queue_entity_nbt(key, vec![tag]);
self.world.loaded_entities.push((key, vec![tag]));
self.spawn_loaded_entities();
}
self.persist.restored.insert(uuid);
@@ -3012,7 +2998,7 @@ mod tests {
}
// The level is a server's, whose attachments a mod reaches.
game.options.dedicated = Some(Vec::new());
game.world.queue_entity_nbt((0, 0), tags);
game.world.loaded_entities.push(((0, 0), tags));
game.spawn_loaded_entities();
let saved = game.entity_chunks(&HashSet::from([(0, 0)]), false);
let saved: Vec<&Tag> = saved.iter().flat_map(|(_, tags)| tags).collect();
@@ -3102,7 +3088,7 @@ mod tests {
.filter(|c| c.potion.is_some())
.count()
};
game.world.queue_entity_nbt((0, 0), vec![cloud]);
game.world.loaded_entities.push(((0, 0), vec![cloud]));
game.spawn_loaded_entities();
assert_eq!(clouds(&game), 1, "the cloud loads");
assert_eq!(game.breath_clouds[0].motion, [0.6, 0.0, 0.0]);
@@ -3114,7 +3100,7 @@ mod tests {
Vec3::new(0.6, 0.0, 0.0)
);
assert_eq!(clouds(&game), 0, "and takes it out of the level");
game.world.queue_entity_nbt((0, 0), written);
game.world.loaded_entities.push(((0, 0), written));
game.spawn_loaded_entities();
assert_eq!(clouds(&game), 1, "loading the chunk again gives one cloud");
assert_eq!(game.breath_clouds[0].motion, [0.6, 0.0, 0.0]);
@@ -4272,7 +4258,6 @@ mod attribute_instance_save_tests {
assert!(game.run_command("forceload add 0 0"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn mob(game: &Game<'_>, n: i32) -> usize {
@@ -5291,7 +5276,6 @@ mod h262_enderman_absolute_anger_tests {
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;262,0,0,99],NoAI:1b,NoGravity:1b}")
);
game.world.random.set_seed(262);
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn load(game: &mut Game<'_>, n: i32, end: Option<i64>) -> (u32, usize) {
@@ -5527,7 +5511,6 @@ mod sulfur_body_attribute_tests {
assert!(game.run_command("scoreboard objectives add h192 dummy"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
/// `/attribute <cube> <attribute> get 1000`, read back through a score.
@@ -5553,9 +5536,9 @@ mod sulfur_body_attribute_tests {
(2, ""),
] {
assert!(game.run_command(&format!(
"summon minecraft:sulfur_cube {}.5 200 4.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
3 * n
)));
"summon minecraft:sulfur_cube {}.5 200 4.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
3 * n
)));
}
let attrs = [
"knockback_resistance",
@@ -5599,8 +5582,8 @@ mod sulfur_body_attribute_tests {
fn a_bodiless_cube_keeps_its_command_knockback_resistance_effect() {
with_game(|game| {
assert!(game.run_command(
"summon minecraft:sulfur_cube 4.5 200 8.5 {UUID:[I;192,0,0,9],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1}"
));
"summon minecraft:sulfur_cube 4.5 200 8.5 {UUID:[I;192,0,0,9],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1}"
));
let sel = "@e[type=minecraft:sulfur_cube,nbt={UUID:[I;192,0,0,9]},limit=1]";
let cached = |game: &Game<'_>| {
let m = game
@@ -5667,9 +5650,9 @@ mod sulfur_body_attribute_tests {
(11, ",equipment:{body:{id:\"minecraft:oak_planks\"}}"),
] {
assert!(game.run_command(&format!(
"summon minecraft:sulfur_cube {}.5 200 8.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
n - 8
)));
"summon minecraft:sulfur_cube {}.5 200 8.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
n - 8
)));
}
game.tick_world(true).unwrap();
let cached = |game: &Game<'_>, n: i32| {
@@ -5782,7 +5765,6 @@ mod summoned_knot_tests {
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;129,0,0,99],NoAI:1b,NoGravity:1b}")
);
game.world.random.set_seed(129);
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn knot_id(game: &Game<'_>, n: i32) -> u32 {
@@ -5913,8 +5895,12 @@ mod summoned_knot_tests {
#[ignore = "prepared resources; negative fractional registered position"]
fn negative_fractional_knot_position_uses_floor_before_centering() {
with_game(|game| {
// Chunk (-1, -1) is already loaded by the fixture's neighbourhood.
assert!(game.world.chunks.contains_key(&(-1, -1)));
game.world
.chunks
.insert((-1, -1), Arc::new(Chunk::empty(-1, -1)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(-1, -1))
.unwrap();
assert!(game.run_command("setblock -2 199 -3 stone strict"));
assert!(game.run_command("setblock -2 200 -3 oak_fence strict"));
assert!(game.run_command("summon leash_knot -1.2 200.8 -2.1 {UUID:[I;129,0,0,5]}"));
@@ -6141,9 +6127,7 @@ mod camel_pose_saved_tests {
.into_iter()
.flat_map(|(_, tags)| tags)
.collect();
game.world
.loaded_entities
.push(((0, 0), written.into_iter().map(Into::into).collect()));
game.world.loaded_entities.push(((0, 0), written));
game.spawn_loaded_entities();
for step in 0..8 {
for number in 1..=cases.len() as i32 {
@@ -1498,7 +1498,6 @@ mod h255_mob_piston_ground_tests {
.unwrap();
}
}
game.load_ticking_neighbourhood(0, 0);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
@@ -112,21 +112,19 @@ impl Game<'_> {
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
equipped.push((self.offhand.as_ref(), "offhand"));
for (stack, slot) in equipped {
// ItemStack.forEachModifier: the stack's own ATTRIBUTE_MODIFIERS
// (its item's unless a command or a mod replaced them, #14641).
let Some(stack) = stack else {
continue;
};
for (attribute, id, amount, op, group) in world_items!(self).attribute_modifiers(stack)
{
if !equipment_modifier_applies(&group, slot) {
let modifiers = world_items!(self).attribute_modifiers(stack);
for (attribute, id, amount, op, group) in &modifiers {
if !equipment_modifier_applies(group, slot) {
continue;
}
if player
.set_modifier_unclamped(&attribute, &id, amount, &op, true)
.set_modifier_unclamped(attribute, id, *amount, op, true)
.is_ok()
{
self.applied_modifiers.push((attribute, id));
self.applied_modifiers.push((attribute.clone(), id.clone()));
}
}
}
@@ -35,7 +35,6 @@ fn mobs_past_the_simulation_distance_neither_enter_portals_nor_cool_down() {
game.world
.chunks
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
game.load_ticking_neighbourhood(cx, 0);
for z in [8, 12] {
game.world.set(x, 63, z, stone).unwrap();
}
+60 -31
View File
@@ -385,21 +385,31 @@ impl Game<'_> {
.play("minecraft:entity.silverfish.hurt", Some(at), 1.0, 1.0);
}
}
}
/// A splash potion, lingering potion or experience bottle about to fly (the
/// player's throws and dispensers both make one): `ThrownSplashPotion`,
/// `ThrownLingeringPotion` or `ThrownExperienceBottle`, which
/// `level.addFreshEntity` makes a level entity like a summoned one, so
/// selectors and `/data` find it (#13201).
pub(super) fn thrown_projectile(stack: ItemStack, position: Vec3, by_player: bool) -> Projectile {
let kind = match &*stack.item {
"minecraft:experience_bottle" => ProjectileKind::ExperienceBottle,
item => {
let lingering = item == "minecraft:lingering_potion";
ProjectileKind::Potion(Box::new(stack), lingering)
}
};
Projectile::new(kind, position, by_player.then_some(Who::Player))
/// A splash potion, lingering potion or experience bottle in flight (the
/// player's throws and dispensers both end up here).
pub(super) fn launch_thrown(
&mut self,
stack: ItemStack,
position: Vec3,
velocity: Vec3,
owner: Option<Text>,
) {
let id = self.take_entity_id();
self.potions.thrown.push(ThrownPotion {
id,
stack,
previous: position,
position,
velocity,
age: 0,
thrower: owner.is_some().then_some(Who::Player),
guest: self.lan_acting.filter(|_| owner.is_some()),
owner,
left_owner: false,
last_deflected_by: None,
random: LegacyRandom::new(i64::from(id)),
});
}
}
/// What a player's body was last given for its effects and enchantments:
/// a guest keeps their own while the host's are in `PotionState`.
@@ -576,21 +586,43 @@ impl Game<'_> {
_ => ("minecraft:entity.lingering_potion.throw", 0.5),
};
self.audio.play(sound, Some(eye), 0.5, pitch);
// Projectile.shootFromRotation(xRot, yRot, -20, power, 1.0), with
// what the thrower last moved (getKnownMovement).
let velocity = self.known_player_movement();
// Projectile.shootFromRotation(xRot, yRot, -20, 0.5, 1.0).
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let dir = [
-yaw.sin() * pitch.cos(),
-(self.pitch - 20.0).to_radians().sin(),
yaw.cos() * pitch.cos(),
];
let l = dir
.iter()
.map(|c| f64::from(*c).powi(2))
.sum::<f64>()
.sqrt()
.max(1e-9);
// getMovementToShoot: normalize, add triangle(0, 0.0172275 * uncertainty), scale.
let r = &mut self.world.random;
let mut jitter = || r.triangle(0.0, 0.0172275);
let v = Vec3::new(
(f64::from(dir[0]) / l + jitter()) * power,
(f64::from(dir[1]) / l + jitter()) * power,
(f64::from(dir[2]) / l + jitter()) * power,
);
let pv = self.known_player_movement();
let on_ground = self.player.as_ref().is_some_and(|p| p.ground.on_ground);
let one = ItemStack {
count: 1,
..stack.clone()
};
let mut shot =
thrown_projectile(one, Vec3::new(eye[0], eye[1] - 0.1, eye[2]), true);
shot.shoot_from_rotation(
self.pitch, self.yaw, -20.0, power, 1.0, velocity, on_ground,
let v = Vec3::new(
v.x + pv.x,
v.y + if on_ground { 0.0 } else { pv.y },
v.z + pv.z,
);
self.launch_thrown(
ItemStack {
count: 1,
..stack.clone()
},
Vec3::new(eye[0], eye[1] - 0.1, eye[2]),
v,
Some(self.player_display_text()),
);
shot.guest = self.lan_acting;
self.add_projectile(shot);
// ThrowablePotionItem.use / ExperienceBottleItem.use: awardStat(ITEM_USED).
self.award_stat("used", &stack.item, 1);
self.swing_arm();
@@ -2635,7 +2667,6 @@ mod mob_absorption_max_tests {
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("scoreboard objectives add h203 dummy"));
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn pig(game: &Game<'_>, n: i32) -> usize {
@@ -2805,7 +2836,6 @@ mod mob_health_boost_tests {
assert!(game.run_command("forceload add 0 0"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn pig(game: &Game<'_>, n: i32) -> usize {
@@ -3051,7 +3081,6 @@ mod depleted_absorption_tests {
assert!(game.run_command("forceload add 0 0"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
/// The mob in the level with UUID word `n`.
+26 -10
View File
@@ -3029,12 +3029,11 @@ impl Game<'_> {
d.on_ground = on_ground;
return;
}
// A newly spawned block or TNT is at the back of its list.
if let Some(f) = self.falling.iter_mut().rev().find(|f| f.id == id as u32) {
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.position = at;
return;
}
if let Some(t) = self.tnts.iter_mut().rev().find(|t| t.id == id as u32) {
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
t.position = at;
t.on_ground = on_ground;
return;
@@ -3410,7 +3409,20 @@ impl Game<'_> {
self.capture(true);
}
C::PlayerPosition(p) => self.remote_teleport(&p)?,
C::PlayerRotation(p) => self.remote_rotation(&p),
C::PlayerRotation(p) => {
self.yaw = if p.relative_yaw {
self.yaw + p.yaw
} else {
p.yaw
};
self.pitch = (if p.relative_pitch {
self.pitch + p.pitch
} else {
p.pitch
})
.clamp(-90.0, 90.0);
self.snap_player_model_pitch();
}
C::PlayerLookAt(p) => self.remote_look_at(&p),
// ClientCommonPacketListenerImpl.handlePostEffects.
C::PostEffects(p) => self.post_effects = p.effects,
@@ -3564,7 +3576,6 @@ impl Game<'_> {
p.id as u32,
horizon_client::lan::playerdata::uuid_ints(p.uuid),
);
let replace = self.tracks_remote_entity(p.id);
self.spawn_entity(p.id, &kind, p.position, rotation);
if let Some(r) = self.remote.as_mut() {
r.cart_lerps.remove(&p.id);
@@ -3577,7 +3588,14 @@ impl Game<'_> {
(rotation[0], rotation[1]),
p.data,
) {
self.spawn_added_projectile(&p, &kind, (rotation[0], rotation[1]), replace);
self.spawn_remote_projectile(
p.id,
&kind,
p.position,
(rotation[0], rotation[1]),
p.velocity,
p.data,
);
}
// Entity.recreateFromPacket: objects must exist before applying motion.
self.remote_motion(p.id, p.velocity);
@@ -5880,16 +5898,14 @@ impl Game<'_> {
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
// `falling` and `tnts` are searched from the back: AddEntity calls this right after
// appending the block or TNT, so a spawn doesn't walk the whole list.
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
// Squid.aiStep derives its body pitch and roll from network motion.
m.velocity = v;
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.velocity = v;
} else if let Some(f) = self.falling.iter_mut().rev().find(|f| f.id == id as u32) {
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.velocity = v;
} else if let Some(t) = self.tnts.iter_mut().rev().find(|t| t.id == id as u32) {
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
t.velocity = v;
} else if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id as u32) {
o.velocity = v;
@@ -34,29 +34,6 @@ impl Game<'_> {
pub(in crate::game) fn remote_walking_speed(&self) -> f32 {
walking_speed(self.remote.as_ref().and_then(|r| r.abilities.as_ref()))
}
/// ClientPacketListener.handleRotatePlayer: the server's yaw and pitch, each added to the
/// current one when its bit says relative, then sent back as a `Rot` move with both
/// flags false.
pub(super) fn remote_rotation(&mut self, p: &play::PlayerRotation) {
self.yaw = if p.relative_yaw {
self.yaw + p.yaw
} else {
p.yaw
};
self.pitch = (if p.relative_pitch {
self.pitch + p.pitch
} else {
p.pitch
})
.clamp(-90.0, 90.0);
self.snap_player_model_pitch();
self.send(S::MovePlayerRot(play::MovePlayerRot {
yaw: self.yaw,
pitch: self.pitch,
flags: 0,
}));
}
}
#[cfg(test)]
@@ -132,14 +132,6 @@ impl Game<'_> {
.unwrap_or_default();
nautilus_takes(nautilus::is_food(m, item, &taming), m.extra.tame, baby)
}
// Dolphin.mobInteract: a #minecraft:fishes item is SUCCESS on both
// sides (the server feeds it), so the client swings and keeps the fish.
Species::Dolphin => self
.res
.items()
.item_tags
.get("minecraft:fishes")
.is_some_and(|t| t.contains(item)),
// IronGolem.mobInteract.
Species::IronGolem => iron_golem_takes(item, m.health, m.attrs.max_health),
// CopperGolem.mobInteract.
@@ -262,15 +254,7 @@ fn copper_golem_takes(item: &str, holding: bool, shearable: bool) -> bool {
/// `handleEating` gates both on `isBaby() && !isAgeLocked()` — a locked baby
/// does not grow, and the particle goes with the growth.
pub(super) fn ages_on_food(m: &Mob, item: &str) -> bool {
if m.extra.age_locked || item.is_empty() {
return false;
}
// Tadpole.feed draws the same particle on both sides; a tadpole is never
// `isBaby`, its age counts up to the frog.
if m.species() == Species::Tadpole {
return m.kind.food.iter().any(|f| f.as_str() == item);
}
if !m.is_baby() {
if !m.is_baby() || m.extra.age_locked || item.is_empty() {
return false;
}
match m.species() {
@@ -510,18 +494,4 @@ mod tests {
assert!(!ages_on_food(&camel, "minecraft:cactus"), "locked camel");
}
}
/// `Tadpole.feed` draws its particle on the client too, though a tadpole
/// is never a baby; an age-locked one is not fed (#8554).
#[test]
fn a_tadpole_fed_a_slime_ball_draws_happy_villager() {
let Some(mut tadpole) = mob("minecraft:tadpole") else {
return;
};
assert!(!tadpole.is_baby());
assert!(ages_on_food(&tadpole, "minecraft:slime_ball"));
assert!(!ages_on_food(&tadpole, "minecraft:wheat"), "not frog food");
tadpole.extra.age_locked = true;
assert!(!ages_on_food(&tadpole, "minecraft:slime_ball"), "locked");
}
}
@@ -316,58 +316,15 @@ impl Game<'_> {
rotation: (f32, f32),
velocity: [f64; 3],
data: i32,
) -> bool {
self.remove_remote_projectile(id as u32);
self.spawn_remote_projectile_inner(id, kind, position, rotation, velocity, data)
}
/// Whether the client tracks `id` already: asked by `AddEntity` before it tracks the new one.
pub(super) fn tracks_remote_entity(&self, id: i32) -> bool {
self.remote
.as_ref()
.is_some_and(|r| r.tracked_entity(id).is_some())
}
/// `AddEntity`'s `spawn_remote_projectile`. Only an id the client already tracked (`replace`)
/// can still be in `falling` or `tnts`, so a new id skips those passes; the crystals and the
/// small lists are filtered every time, since the client adds to some of those itself.
pub(super) fn spawn_added_projectile(
&mut self,
p: &horizon_protocol::packets::play::AddEntity,
kind: &str,
rotation: (f32, f32),
replace: bool,
) {
if replace {
self.spawn_remote_projectile(p.id, kind, p.position, rotation, p.velocity, p.data);
} else {
self.crystals.retain(|c| c.id != p.id as u32);
self.remove_remote_small_projectiles(p.id as u32);
self.spawn_remote_projectile_inner(
p.id, kind, p.position, rotation, p.velocity, p.data,
);
}
}
/// `spawn_remote_projectile` without first removing an entity under the same id: for
/// `AddEntity` of an id the client does not track yet, which no list holds.
pub(super) fn spawn_remote_projectile_inner(
&mut self,
id: i32,
kind: &str,
position: [f64; 3],
rotation: (f32, f32),
velocity: [f64; 3],
data: i32,
) -> bool {
let id = id as u32;
let at = Vec3::new(position[0], position[1], position[2]);
let velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
let (yaw, pitch) = rotation;
self.remove_remote_projectile(id);
match kind {
"minecraft:evoker_fangs" => self.insert_fangs(id, position, yaw, 0, 0),
"minecraft:tnt" => self.tnts.push(super::tnt::PrimedTnt {
used_portal: false,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -443,8 +400,14 @@ impl Game<'_> {
.next_int_bound(100_000)
.unwrap_or(0);
let time = super::dragon::Crystal::start_time(at, drawn, self.clock.still());
self.crystals
.push(super::dragon::Crystal::new(id, at, true, time));
self.crystals.push(super::dragon::Crystal {
id,
position: at,
show_bottom: true,
time,
beam: None,
invulnerable: false,
});
}
_ => {
let Some(k) = projectile_kind(kind) else {
@@ -183,93 +183,6 @@ mod tests {
assert_eq!(game.falling.iter().map(|e| e.id).collect::<Vec<_>>(), [45]);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn add_entity_replaces_falling_block_without_removing_other_ids() {
use crate::game::{
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use horizon_protocol::packets::play::{self, Clientbound as C};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = ExecutionRole::Client;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let (client, _server) = horizon_protocol::connection::Pipe::pair();
game.connect_local(client, "SpawnTest").unwrap();
let kind = game
.remote
.as_ref()
.unwrap()
.builtin
.get("minecraft:entity_type")
.unwrap()
.id("minecraft:falling_block")
.unwrap();
for (id, position) in [
(40, [1.0, 64.0, 2.0]),
(41, [3.0, 65.0, 4.0]),
(40, [5.0, 66.0, 6.0]),
(42, [7.0, 67.0, 8.0]),
] {
game.handle_packet(C::AddEntity(play::AddEntity {
id,
uuid: id as u128,
kind,
position,
velocity: [0.0, -0.1, 0.0],
pitch: 0,
yaw: 0,
head_yaw: 0,
data: 0,
}))
.unwrap();
assert_eq!(game.falling.iter().filter(|f| f.id == id as u32).count(), 1);
let block = game.falling.iter().find(|f| f.id == id as u32).unwrap();
assert_eq!(
[block.position.x, block.position.y, block.position.z],
position
);
assert_eq!(block.velocity.y, -0.1);
}
let blocks: Vec<_> = game
.falling
.iter()
.map(|f| (f.id, [f.position.x, f.position.y, f.position.z]))
.collect();
assert_eq!(
blocks,
[
(41, [3.0, 65.0, 4.0]),
(40, [5.0, 66.0, 6.0]),
(42, [7.0, 67.0, 8.0]),
]
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn remove_entities_filters_mixed_entity_lists() {
@@ -67,16 +67,6 @@ impl Game<'_> {
menu.set_slot(env, i, stack);
Some(true)
});
if internal == super::stations::INPUT
&& let Some(s) = self
.station
.as_mut()
.filter(|s| s.kind == Kind::Stonecutter)
{
// The client menu's `Slot.set` on the input runs the screen's
// listener (`StonecutterMenu.container.setChanged`).
s.stonecutter_changed();
}
// AnvilScreen.slotChanged: the box shows the new input's name, which
// its responder sends.
self.remote_anvil_rename();
@@ -57,9 +57,6 @@ fn game() -> Game<'static> {
.add_empty_chunk(ChunkPos::new(x, 0))
.unwrap();
}
for x in 0..9 {
game.load_ticking_neighbourhood(x, 0);
}
for command in [
"forceload add 0 0 143 15",
"tick freeze",
-31
View File
@@ -484,37 +484,6 @@ impl Game<'_> {
.unwrap_or(self.ui.registries.permission)
> 0
}
/// `EntityType.createDefaultStackConfig` on an entity an item made (a
/// vehicle, a hanging entity, an armor stand): the item's custom name and
/// custom data (`applyComponentsFromItemStack`:
/// `Entity.applyImplicitComponent`'s two, saved as `CustomName` and
/// `data`), then its `minecraft:entity_data` merged over the entity's own
/// compound and loaded (`updateCustomEntityTag`) when it names this type.
/// An op-only type (command block and spawner minecarts) takes it from an
/// op player only, never from a dispenser (`by_player` false).
pub(super) fn apply_item_stack_config(
&mut self,
target: super::commands::Target,
kind: &str,
stack: &horizon_client::items::ItemStack,
by_player: bool,
) {
let op = by_player && self.acting_player_is_op();
let configures = stack
.data()
.is_some_and(|d| d.custom_name.is_some() || d.other("minecraft:custom_data").is_some())
|| horizon_client::block_entity_item::spawn_egg_entity_data(stack, kind, op).is_some();
if !configures {
return;
}
let Some(own) = self.entity_nbt(target) else {
return;
};
let tag =
horizon_client::block_entity_item::stack_configured_entity_by(stack, kind, &own, op);
// The entity's own compound, its name and data merged in: it loads back.
let _ = self.apply_entity_nbt(target, tag);
}
/// Ticks spawners, trial spawners and vaults near the player on a remote
/// client (`tick_remote`).
pub(super) fn tick_remote_spawners(&mut self) {
@@ -48,7 +48,6 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
game
}
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff. Show more