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
242 changed files with 3150 additions and 10834 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));
}
}
-7
View File
@@ -26,13 +26,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates' text in the player's language (`horizon_hooks::tr`).
horizon_hooks::engine::set_language(|key| {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
});
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
#[cfg(feature = "friends")]
game::register_friends();
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
-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);
}
}
+27 -50
View File
@@ -359,7 +359,6 @@ mod pad;
mod pad_actions;
mod pad_craft;
mod pad_menu;
mod page_screen;
mod paintings;
mod pandas;
mod particle_culling;
@@ -429,7 +428,6 @@ mod server_links;
mod server_packs;
mod server_travel;
mod shader_packs;
mod share_view;
mod sheep_dye;
mod shelf;
mod shield;
@@ -478,6 +476,7 @@ mod trial_spawner;
mod trial_visuals;
mod trident;
mod tutorial;
mod tweaks;
mod ui_control;
mod use_hand;
mod using_item;
@@ -607,8 +606,6 @@ use resource_loader::{
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
run_loading, write_report,
};
#[cfg(feature = "friends")]
pub(crate) use share_view::register_friends;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -661,7 +658,8 @@ enum Screen {
Advancements,
/// WorldOptionsScreen (game/lan_screen.rs).
WorldOptions,
/// Play with Friends, the page at `Place::PauseFriends` (game/page_screen.rs).
/// Play with Friends, from the pause menu's Friends button
/// (game/friends.rs, game/friends/screen.rs).
Friends,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
@@ -2232,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.
@@ -2911,6 +2909,9 @@ struct Game<'a> {
level_shapes: horizon_client::chunk::LevelShapes,
/// This game is that server thread's (game/integrated.rs).
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
#[cfg(feature = "friends")]
friends: Option<friends::Sharing>,
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
social: social::Social,
world_options: Option<lan_screen::WorldOptions>,
@@ -5740,6 +5741,7 @@ impl Game<'_> {
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
span("tick_friends", || self.tick_friends());
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
span("sleep_status", || self.announce_sleep_status());
@@ -7209,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 {
@@ -7239,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,
);
}
@@ -8187,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(
@@ -8227,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" {
@@ -11100,7 +11105,6 @@ impl Game<'_> {
// the world's own ticks.
horizon_client::social::set_presence_status(self.presence_status());
horizon_client::social::tick();
self.share_tick();
// Minecraft.tick: ClientTickEvent.Pre before any of it.
self.native_client_tick_pre();
if self.title.is_none() && !self.pause {
@@ -11829,6 +11833,8 @@ impl<'a> Game<'a> {
test_defs_snapshot: false,
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
#[cfg(feature = "friends")]
friends: None,
social: Default::default(),
debug_keys: Default::default(),
debug_charts: Default::default(),
@@ -12584,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,
+237 -668
View File
@@ -1,5 +1,5 @@
//! Play with friends, host side: "Start Sharing" on the Play with Friends
//! page (`PAGE`, what the pause menu's Friends icon opens) opens the world to LAN if
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
//! it isn't (with Open to LAN's defaults) and shares that port behind an
//! invite code (horizon-friends). The code holds this computer's addresses
//! and the key, so friends join with no server of ours; a relay the player
@@ -7,32 +7,18 @@
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address. The joining side is horizon-friends' (`game`), registered by
//! horizon-official.
//!
//! It sees the game only through `horizon_hooks` (the `ShareView` its `Hooks::share` tick
//! gets, `Hooks::lan_closed`, its page, `tr`) and keeps what it shares in its own statics, so
//! it can leave the engine for horizon-friends (spec #14167). The `horizon-ctl friends` command
//! below still reaches it from `Game` until then.
use super::Game;
use horizon_client::Result;
use super::{Game, lan::LanSettings};
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_hooks::{
Hooks,
pages::{Act, Page, Place, Row},
share::{LanOptions, ShareView},
};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
pub const TITLE: &str = "Play with Friends";
/// The title in the current language.
pub fn title() -> String {
tr("horizon.friends.title", TITLE)
}
mod screen;
struct Sharing {
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
pub(super) struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
opened_lan: bool,
@@ -45,112 +31,12 @@ struct Sharing {
paths: (u32, u32),
}
/// The world shared, if one is. The game has one window player who can share (the engine
/// calls the hooks for it only), so one is all there is.
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
static ON: AtomicBool = AtomicBool::new(false);
/// What the page left for the next [`Hooks::share`] tick, which has the game's view.
#[derive(Default)]
struct PageState {
/// Chat lines (new codes, the relay stopping), said in order.
chat: Vec<String>,
/// A button pressed on the page.
pressed: Option<Action>,
/// The page was drawn since the last tick: its guests are read again.
open: bool,
/// The other players, as the last tick while the page was open read them.
guests: Vec<String>,
/// The action of each row the page last showed.
acts: Vec<Option<Action>>,
}
static PAGE_STATE: Mutex<PageState> = Mutex::new(PageState {
chat: Vec::new(),
pressed: None,
open: false,
guests: Vec::new(),
acts: Vec::new(),
});
/// Whether [`PAGE_STATE`] has something for the tick.
static WORK: AtomicBool = AtomicBool::new(false);
fn page() -> MutexGuard<'static, PageState> {
PAGE_STATE.lock().unwrap_or_else(PoisonError::into_inner)
}
/// A button of the page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
Share,
Copy,
Stop,
/// Open the troubleshooting page.
Help,
}
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
fn take() -> Option<Sharing> {
let taken = sharing().take();
ON.store(false, Ordering::Relaxed);
taken
}
/// Registered by the engine's entry while this module is part of it.
pub struct Friends;
pub static HOOKS: Friends = Friends;
impl Hooks for Friends {
/// What the page left for the game: chat lines, a button's press, the guests while it is
/// open. With nothing left, one relaxed load.
fn share(&self, world: &mut dyn ShareView) {
if !WORK.swap(false, Ordering::Relaxed) {
return;
}
let (lines, pressed, open) = {
let mut page = page();
let open = std::mem::take(&mut page.open);
(std::mem::take(&mut page.chat), page.pressed.take(), open)
};
for line in lines {
world.chat(&line);
}
if let Some(action) = pressed {
press(action, world);
}
if open {
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
page().guests = guests;
}
}
/// The LAN server is closing: the code goes with it.
fn lan_closed(&self) {
if ON.load(Ordering::Relaxed)
&& let Some(mut sharing) = take()
{
sharing.host.stop();
}
}
}
pub const HELP: &[&str] = &[
"friends host share the open world behind an invite code",
"friends stop stop sharing",
"friends status the code, how friends reach this host, the guests",
];
/// A string in the player's language, or the English one.
fn tr(key: &str, english: &str) -> String {
horizon_hooks::tr(key).unwrap_or_else(|| english.to_owned())
}
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
fn stun_servers() -> Vec<String> {
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
@@ -167,98 +53,101 @@ fn env_flag(name: &str, default: bool) -> bool {
std::env::var(name).map_or(default, |v| v != "0")
}
/// Start sharing the open world.
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
if ON.load(Ordering::Relaxed) {
return Ok(());
}
if !world.can_share() {
return Err("open a singleplayer world first".into());
}
let (port, opened_lan) = match world.lan_port() {
Some(port) => (port, false),
// ShareToLanScreen's defaults, as the world options screen offers them.
None => (world.open_lan(LanOptions::default())?, true),
};
let host = Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
});
let host = match host {
Ok(h) => h,
Err(e) => {
if opened_lan {
world.close_lan();
}
return Err(e.to_string());
impl Game<'_> {
/// Start sharing the open world.
pub(super) fn friends_start(&mut self) -> Result<()> {
if self.friends.is_some() {
return Ok(());
}
};
*sharing() = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
ON.store(true, Ordering::Relaxed);
Ok(())
}
// Single player on its server thread shares that server, as Open to
// LAN publishes it; another server can't be shared.
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan_port().is_none();
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: false,
force_game_mode: true,
})?;
}
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
let host = match Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
}) {
Ok(h) => h,
Err(e) => {
if opened_lan {
self.lan_close(true);
}
return Err(e.into());
}
};
self.friends = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
Ok(())
}
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
fn stop(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(mut sharing) = take() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
world.close_lan();
} else {
let text = tr(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
);
world.chat(&text);
}
}
/// New codes and sessions, while the page is drawn and when `friends status` asks. Its chat
/// lines wait for the next tick ([`Hooks::share`]), or for [`say`].
fn poll() {
if !ON.load(Ordering::Relaxed) {
return;
}
let mut announce = false;
let mut stopped = None;
{
let mut guard = sharing();
let Some(sharing) = guard.as_mut() else {
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
pub(super) fn friends_stop(&mut self) {
let Some(mut sharing) = self.friends.take() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
self.lan_close(true);
} else {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
)));
}
}
/// The LAN server is closing: the code goes with it.
pub(super) fn friends_lan_closed(&mut self) {
if let Some(mut sharing) = self.friends.take() {
sharing.host.stop();
}
}
/// New codes and sessions, once a tick and while the pause menu is drawn.
pub(super) fn tick_friends(&mut self) {
let Some(sharing) = self.friends.as_mut() else {
return;
};
let mut announce = false;
let mut stopped = None;
for event in sharing.host.poll() {
match event {
HostEvent::Code(code) => {
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
sharing.notice = Some(tr(
sharing.notice = Some(lang::get_or(
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
));
@@ -269,7 +158,7 @@ fn poll() {
}
HostEvent::WrongCode { count } => {
sharing.wrong = count;
sharing.notice = Some(tr(
sharing.notice = Some(lang::get_or(
"horizon.friends.wrong_code",
"Someone tried a wrong code.",
));
@@ -278,7 +167,7 @@ fn poll() {
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
HostEvent::Disconnected { .. } => {}
HostEvent::RelayStopped => {
let text = tr(
let text = lang::get_or(
"horizon.friends.relay_stopped",
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
);
@@ -287,484 +176,164 @@ fn poll() {
}
}
}
if let Some(text) = stopped {
self.system_message(Text::literal(text));
}
if announce {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
)));
}
}
let mut page = page();
page.chat.extend(stopped);
if announce {
page.chat.push(tr(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
));
}
if !page.chat.is_empty() {
WORK.store(true, Ordering::Relaxed);
}
}
/// The chat lines waiting, said now.
fn say(world: &mut dyn ShareView) {
let lines = std::mem::take(&mut page().chat);
for line in lines {
world.chat(&line);
pub(super) fn friends_copy(&mut self) {
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
return;
};
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&code);
if let Some(s) = self.friends.as_mut() {
s.copied = Some(Instant::now());
}
}
}
/// A button of the page, on the tick after its press.
fn press(action: Action, world: &mut dyn ShareView) {
match action {
Action::Share => {
if let Err(e) = start(world) {
let text = tr(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e, 1);
world.chat(&text);
/// Play with Friends. The world is paused under it in singleplayer, so
/// new codes and sessions are read while it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
self.tick_friends();
let view = self.friends_view();
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
});
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
}
pub(super) fn friends_screen_click(&mut self) {
let view = self.friends_view();
let Some(action) =
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
else {
return;
};
self.audio.play_button_click();
match action {
Action::Share => {
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(
lang::get_or(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e.to_string(), 1),
));
}
}
}
Action::Copy => copy(world),
Action::Stop => stop(world),
// The system browser, as the folder buttons open their folders.
Action::Help => world.open_url(reach::HELP_URL),
}
}
fn copy(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
return;
};
world.copy(&code);
if let Some(s) = sharing().as_mut() {
s.copied = Some(Instant::now());
}
}
/// What Play with Friends shows.
#[derive(Clone, Debug, Default)]
struct View {
sharing: bool,
code: Option<String>,
/// Who can join and what to do when friends on the internet can't (or,
/// before the code, what the game is doing).
status: String,
/// Friends on the internet can reach the host.
reachable: bool,
/// The players in the world besides the host.
guests: Vec<String>,
notice: Option<String>,
/// The code was just copied.
copied: bool,
}
/// What Play with Friends shows; `guests` is asked only while sharing.
fn view(guests: impl FnOnce() -> Vec<String>) -> View {
let guard = sharing();
let Some(s) = guard.as_ref() else {
return View::default();
};
let state = s.host.state();
let mut status = if s.code.is_none() {
tr(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
View {
sharing: true,
code: s.code.clone(),
status,
reachable,
guests: guests(),
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
}
/// The page's rows for `view`, each with the button's action: the layout today's Friends
/// screen had (the engine draws it, `game/page_screen.rs`).
fn rows(view: &View, rows: &mut Vec<Row>, acts: &mut Vec<Option<Action>>) {
let mut row = |row: Row, act: Option<Action>| {
rows.push(row);
acts.push(act);
};
let hint = || {
tr(
"horizon.friends.hint",
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
)
};
if !view.sharing {
row(
Row::text(tr(
"horizon.friends.share_intro",
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
)),
None,
);
row(Row::Space(4), None);
row(Row::text(hint()).quiet(), None);
row(Row::Space(8), None);
row(
Row::button("horizon.friends.start_sharing"),
Some(Action::Share),
);
return;
}
row(
Row::text(tr("horizon.multiplayer.invite_code", "Invite Code")).quiet(),
None,
);
row(Row::Space(2), None);
match &view.code {
Some(code) => row(Row::text(code.clone()).groups(), None),
None => row(Row::text(view.status.clone()), None),
}
row(Row::Space(6), None);
let copy = if view.copied {
"horizon.friends.copied"
} else {
"horizon.friends.copy_code"
};
row(
Row::button(copy).half().active(view.code.is_some()),
Some(Action::Copy),
);
row(
Row::button("horizon.friends.stop_sharing").half(),
Some(Action::Stop),
);
if view.code.is_some() {
// Who can join: grey when friends anywhere can, white (it asks
// for something) when only the LAN and VPNs can.
let status = Row::text(view.status.clone());
row(
if view.reachable {
status.quiet()
} else {
status
},
None,
);
row(Row::Space(4), None);
}
let here = if view.guests.is_empty() {
tr("horizon.friends.none_yet", "No friends here yet.")
} else {
tr("horizon.friends.here_now", "Here now: %s").replacen("%s", &view.guests.join(", "), 1)
};
row(Row::text(here).quiet(), None);
if let Some(notice) = &view.notice {
row(Row::text(notice.clone()), None);
}
// Where friends paste the code, while it fits above the footer.
row(Row::text(hint()).quiet(), None);
// Troubleshooting beside Done when friends on the internet can't reach the host.
if !view.reachable && view.code.is_some() {
row(
Row::button("horizon.friends.troubleshooting").footer(),
Some(Action::Help),
);
}
}
/// Play with Friends' page: what the pause menu's Friends icon opens. Its rows are read when
/// it is drawn; a press waits for the next tick, which has the game's view.
pub struct FriendsPage;
pub static PAGE: FriendsPage = FriendsPage;
impl Page for FriendsPage {
fn id(&self) -> &'static str {
"horizon_friends.share"
}
fn title(&self) -> &'static str {
"horizon.friends.title"
}
fn place(&self) -> Place {
Place::PauseFriends
}
fn rows(&self, out: &mut Vec<Row>) {
// The world is paused under the page in singleplayer, so new codes and sessions are
// read while it is drawn.
poll();
let guests = std::mem::take(&mut page().guests);
let view = view(|| guests);
let mut acts = Vec::new();
rows(&view, out, &mut acts);
let mut page = page();
page.guests = view.guests;
page.acts = acts;
page.open = true;
WORK.store(true, Ordering::Relaxed);
}
fn act(&self, row: usize, act: Act) {
let mut page = page();
if act == Act::Press
&& let Some(Some(action)) = page.acts.get(row).copied()
{
page.pressed = Some(action);
WORK.store(true, Ordering::Relaxed);
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the folder buttons open their folders.
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_uri(url);
}
}
Action::Done => self.screen = super::Screen::Paused,
}
}
}
/// `horizon-ctl friends host|stop|status`.
fn control(
word: Option<&str>,
world: &mut dyn ShareView,
) -> std::result::Result<serde_json::Value, String> {
use serde_json::json;
match word {
Some("host") => {
start(world)?;
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
}
Some("stop") => {
stop(world);
Ok(json!({ "ok": true }))
}
Some("status") => {
poll();
say(world);
let view = if world.can_share() {
view(|| {
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
guests
})
} else {
View::default()
/// Whether the pause menu offers Play with Friends (a singleplayer world).
pub(super) fn friends_shown(&self) -> bool {
self.friends_view().shown
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = match &self.lan {
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
// On the server thread: the other players the server lists.
None if self.integrated.is_some() => {
let me = self.options.player_name();
let mut names = self.remote_online_names().unwrap_or_default();
names.retain(|n| *n != me);
names
}
None => Vec::new(),
};
let Some(s) = &self.friends else {
return FriendsView {
shown: self.remote.is_none() || self.integrated.is_some(),
..FriendsView::default()
};
let guard = sharing();
let sharing = guard.as_ref().filter(|_| view.sharing);
let state = sharing.map(|s| s.host.state());
Ok(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": sharing.map_or(0, |s| s.host.sessions()),
"direct": sharing.map_or(0, |s| s.paths.0),
"relayed": sharing.map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": world.lan_port(),
}))
};
let state = s.host.state();
let mut status = if s.code.is_none() {
lang::get_or(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
FriendsView {
shown: true,
sharing: true,
code: s.code.clone(),
status,
reachable,
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
guests,
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
_ => Err("friends host|stop|status".into()),
}
}
impl Game<'_> {
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
use serde_json::json;
if words[0] != "friends" {
return Ok(None);
}
Ok(Some(control(
words.get(1).copied(),
&mut self.share_view(),
)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A world as the engine shows it, recording what the crate did to it.
#[derive(Default)]
struct Fake {
can_share: bool,
lan: Option<u16>,
refuse_lan: bool,
did: Vec<String>,
}
impl ShareView for Fake {
fn can_share(&self) -> bool {
self.can_share
}
fn lan_port(&self) -> Option<u16> {
self.lan
}
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
self.did.push(format!("open_lan {options:?}"));
if self.refuse_lan {
return Err("no free port".into());
match words.get(1).copied() {
Some("host") => {
self.friends_start()?;
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
}
self.lan = Some(25565);
Ok(25565)
}
fn close_lan(&mut self) {
self.did.push("close_lan".into());
self.lan = None;
}
fn players(&self, each: &mut dyn FnMut(&str)) {
each("Alex");
}
fn chat(&mut self, text: &str) {
self.did.push(format!("chat {text}"));
}
fn copy(&mut self, text: &str) {
self.did.push(format!("copy {text}"));
}
fn open_url(&mut self, url: &str) {
self.did.push(format!("open_url {url}"));
}
fn tr(&self, _key: &str) -> Option<String> {
None
}
}
#[test]
fn a_world_that_cannot_be_shared_is_left_alone() {
// Another server, or a splitscreen guest in P1's world.
let mut guest = Fake {
lan: Some(25565),
..Fake::default()
};
assert_eq!(
control(Some("host"), &mut guest).unwrap_err(),
"open a singleplayer world first"
);
stop(&mut guest);
copy(&mut guest);
assert!(guest.did.is_empty(), "{:?}", guest.did);
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
assert_eq!(start(&mut world).unwrap_err(), "no free port");
assert_eq!(
world.did,
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
);
// Offered, not shared: the guests aren't asked for.
let view = view(|| unreachable!("the guests of a world not shared"));
assert!(!view.sharing && view.guests.is_empty());
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn the_page_shows_todays_screen_and_a_press_runs_on_the_next_tick() {
// Sharing, with a code friends on the internet can't reach yet.
let shared = View {
sharing: true,
code: Some("K7QM2-AAAAA".into()),
status: "Friends on your network can join.".into(),
..View::default()
};
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(&shared, &mut out, &mut acts);
let buttons: Vec<_> = out
.iter()
.zip(&acts)
.filter_map(|(row, act)| match row {
Row::Button {
label,
active,
half,
footer,
..
} => Some((*label, *active, *half, *footer, *act)),
_ => None,
})
.collect();
assert_eq!(
buttons,
[
(
"horizon.friends.copy_code",
true,
true,
false,
Some(Action::Copy)
),
(
"horizon.friends.stop_sharing",
true,
true,
false,
Some(Action::Stop)
),
(
"horizon.friends.troubleshooting",
true,
false,
true,
Some(Action::Help)
),
]
);
assert!(out.iter().any(
|row| matches!(row, Row::Text { text, groups: true, .. } if text == "K7QM2-AAAAA")
));
// Before the code there is nothing to copy.
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(
&View {
code: None,
..shared
},
&mut out,
&mut acts,
);
assert!(out.iter().any(|row| matches!(
row,
Row::Button {
label: "horizon.friends.copy_code",
active: false,
..
Some("stop") => {
self.friends_stop();
Ok(Some(json!({ "ok": true })))
}
)));
// Not sharing: Start Sharing. Its press waits for the tick, which has the world.
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(&View::default(), &mut out, &mut acts);
let share = acts.iter().position(|a| *a == Some(Action::Share)).unwrap();
page().acts = acts;
PAGE.act(share, Act::Press);
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
HOOKS.share(&mut world);
assert_eq!(
world.did,
[
"open_lan LanOptions { guest_commands: false, force_game_mode: true }",
"chat Couldn't share the world: no free port",
]
);
// Pressed once, run once.
HOOKS.share(&mut world);
assert_eq!(world.did.len(), 2);
Some("status") => {
self.tick_friends();
let view = self.friends_view();
let state = self.friends.as_ref().map(|s| s.host.state());
Ok(Some(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": self.lan_port(),
})))
}
_ => Err("friends host|stop|status".into()),
}
}
}
@@ -0,0 +1,340 @@
//! Play with Friends: Horizon's own sharing of the open world behind an
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
//! a screen to vanilla: the menu background, a centred title and text,
//! vanilla's buttons and Done in the footer.
use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
ui,
};
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
pub const TITLE: &str = "Play with Friends";
/// A string of this screen: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
horizon_client::lang::get_or(key, english)
}
/// The title in the current language.
pub fn title() -> String {
tr("horizon.friends.title", TITLE)
}
/// What the screen shows (game/friends.rs fills it).
#[derive(Clone, Debug, Default)]
pub struct FriendsView {
/// A singleplayer world (not a server): sharing is offered.
pub shown: bool,
pub sharing: bool,
pub code: Option<String>,
/// Who can join and what to do when friends on the internet can't (or,
/// before the code, what the game is doing).
pub status: String,
/// Friends on the internet can reach the host.
pub reachable: bool,
/// Where the troubleshooting is, when they can't.
pub help: Option<String>,
/// The players in the world besides the host.
pub guests: Vec<String>,
pub notice: Option<String>,
/// The code was just copied.
pub copied: bool,
}
/// A button's action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Share,
Copy,
Stop,
/// Open the troubleshooting page.
Help,
Done,
}
fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
fn hint() -> String {
tr(
"horizon.friends.hint",
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
)
}
struct Button {
x: f32,
y: f32,
w: f32,
label: String,
action: Action,
active: bool,
}
/// The centred lines (text, colour, y) and the buttons.
fn layout(
atlas: &HudAtlas,
(w, h): (f32, f32),
view: &FriendsView,
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
let width = (w - 40.0).min(310.0);
let cx = (w / 2.0).floor();
let mut lines = Vec::new();
let mut y = 40.0;
let add = |lines: &mut Vec<(String, u32, f32)>, text: &str, color: u32, y: &mut f32| {
for line in wrap(text, width, atlas) {
lines.push((line, color, *y));
*y += 10.0;
}
};
let mut buttons = Vec::new();
if view.sharing {
lines.push((
tr("horizon.multiplayer.invite_code", "Invite Code"),
grey(),
y,
));
y += 12.0;
match &view.code {
Some(code) => {
for line in code_lines(atlas, code, width) {
lines.push((line, WHITE, y));
y += 10.0;
}
}
None => add(&mut lines, &view.status, WHITE, &mut y),
}
y += 6.0;
buttons.push(Button {
x: cx - 154.0,
y,
w: 150.0,
label: if view.copied {
tr("horizon.friends.copied", "Copied")
} else {
tr("horizon.friends.copy_code", "Copy Code")
},
action: Action::Copy,
active: view.code.is_some(),
});
buttons.push(Button {
x: cx + 4.0,
y,
w: 150.0,
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
action: Action::Stop,
active: true,
});
y += 30.0;
if view.code.is_some() {
// Who can join: grey when friends anywhere can, white (it asks
// for something) when only the LAN and VPNs can.
let tint = if view.reachable { grey() } else { WHITE };
add(&mut lines, &view.status, tint, &mut y);
y += 4.0;
}
let here = if view.guests.is_empty() {
tr("horizon.friends.none_yet", "No friends here yet.")
} else {
tr("horizon.friends.here_now", "Here now: %s").replacen(
"%s",
&view.guests.join(", "),
1,
)
};
add(&mut lines, &here, grey(), &mut y);
if let Some(notice) = &view.notice {
add(&mut lines, notice, WHITE, &mut y);
}
// Where friends paste the code, while it fits above the footer.
let hint = hint();
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
add(&mut lines, &hint, grey(), &mut y);
}
} else {
add(
&mut lines,
&tr(
"horizon.friends.share_intro",
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
),
WHITE,
&mut y,
);
y += 4.0;
add(&mut lines, &hint(), grey(), &mut y);
buttons.push(Button {
x: cx - 100.0,
y: y + 8.0,
w: 200.0,
label: tr("horizon.friends.start_sharing", "Start Sharing"),
action: Action::Share,
active: view.shown,
});
}
// HeaderAndFooterLayout's footer: Done, beside Troubleshooting when
// friends on the internet can't reach the host.
if view.sharing && view.help.is_some() {
buttons.push(Button {
x: cx - 154.0,
y: h - 27.0,
w: 150.0,
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
action: Action::Help,
active: true,
});
buttons.push(Button {
x: cx + 4.0,
y: h - 27.0,
w: 150.0,
label: "gui.done".to_owned(),
action: Action::Done,
active: true,
});
} else {
buttons.push(Button {
x: cx - 100.0,
y: h - 27.0,
w: 200.0,
label: "gui.done".to_owned(),
action: Action::Done,
active: true,
});
}
(lines, buttons)
}
/// The code's groups packed into lines of at most `width`.
fn code_lines(atlas: &HudAtlas, code: &str, width: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for group in code.split('-') {
match lines.last_mut() {
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
l.push('-');
l.push_str(group);
}
_ => lines.push(group.to_owned()),
}
}
lines
}
fn label(key: &str) -> Text {
if key.starts_with("gui.") {
Text::translate(key, &[])
} else {
Text::literal(key)
}
}
pub fn draw(
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
view: &FriendsView,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(title());
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), view);
for (line, color, y) in lines {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if matches!(b.action, Action::Done) {
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
ui::button(
batch,
atlas,
b.x,
b.y,
b.w,
20.0,
&label(&b.label),
b.active,
hover,
);
}
}
/// The button under a click, if it is active.
pub fn click(
atlas: &HudAtlas,
size: (f32, f32),
cursor: (f32, f32),
view: &FriendsView,
) -> Option<Action> {
layout(atlas, size, view)
.1
.into_iter()
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
.map(|b| b.action)
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn share_then_copy_and_stop() {
let atlas = atlas();
let size = (427.0, 240.0);
let idle = FriendsView {
shown: true,
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &idle);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert_eq!(
click(&atlas, size, (share.x + 1.0, share.y + 1.0), &idle),
Some(Action::Share)
);
let sharing = FriendsView {
shown: true,
sharing: true,
status: "Finding how friends can reach you…".into(),
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &sharing);
let copy = buttons.iter().find(|b| b.action == Action::Copy).unwrap();
assert!(!copy.active, "nothing to copy before the code");
let with_code = FriendsView {
code: Some("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0".into()),
status: "Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address).".into(),
help: Some("https://example.invalid/help".into()),
..sharing
};
let (lines, buttons) = layout(&atlas, size, &with_code);
assert!(
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
"the text stays above the footer"
);
for action in [Action::Copy, Action::Stop, Action::Help, Action::Done] {
let b = buttons.iter().find(|b| b.action == action).unwrap();
assert_eq!(
click(&atlas, size, (b.x + 1.0, b.y + 1.0), &with_code),
Some(action)
);
}
}
#[test]
fn a_server_offers_no_sharing() {
let atlas = atlas();
let view = FriendsView::default();
let (_, buttons) = layout(&atlas, (427.0, 240.0), &view);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert!(!share.active);
}
}
+17 -2
View File
@@ -1,8 +1,8 @@
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
use super::Game;
use horizon_client::Result;
use super::{Game, Screen};
use horizon_client::{Result, hud::HudBatch};
pub const HELP: &[&str] = &[];
@@ -13,6 +13,21 @@ pub fn title() -> String {
}
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
pub(super) fn tick_friends(&mut self) {}
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
self.screen = Screen::Paused;
}
pub(super) fn friends_screen_click(&mut self) {}
pub(super) fn friends_shown(&self) -> bool {
false
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
if words[0] != "friends" {
-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"
);
}
+2 -2
View File
@@ -1490,7 +1490,7 @@ impl Game<'_> {
None => self.unpublish(),
};
if closed {
self.lan_closed_hooks();
self.friends_lan_closed();
}
if closed && announce && self.integrated.is_some() {
self.system_message(Text::translate(
@@ -1500,7 +1500,7 @@ impl Game<'_> {
}
return;
}
self.lan_closed_hooks();
self.friends_lan_closed();
self.native_players_leaving();
let Some(mut host) = self.lan.take() else {
return;
@@ -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,
});
@@ -1,321 +0,0 @@
//! A page drawn as its own screen (`horizon_hooks::pages::Place::PauseFriends`, what the pause
//! menu's vanilla Friends icon opens), the way a mod adds a screen to vanilla: the menu
//! background over the paused world, the title centred at the top, the rows centred down the
//! middle, footer buttons beside Done at the bottom (`HeaderAndFooterLayout`).
use super::{Game, Screen};
use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
ui,
};
use horizon_hooks::pages::{self, Act, Page, Place, Row};
/// What a click on the screen hit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Hit {
/// The page's row with this index.
Row(usize),
Done,
}
struct Button {
x: f32,
y: f32,
w: f32,
label: &'static str,
hit: Hit,
active: bool,
}
fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
/// The centred lines (text, colour, y) and the buttons.
fn layout(
atlas: &HudAtlas,
(w, h): (f32, f32),
rows: &[Row],
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
let width = (w - 40.0).min(310.0);
let cx = (w / 2.0).floor();
let mut lines = Vec::new();
let mut buttons = Vec::new();
let mut footer = Vec::new();
let mut y = 40.0;
// A half-width button waiting for the next to sit beside it.
let mut half_open = false;
for (r, row) in rows.iter().enumerate() {
let beside = std::mem::take(&mut half_open);
let (text, quiet, groups) = match row {
Row::Text {
text,
quiet,
groups,
..
} => (text.clone(), *quiet, *groups),
Row::Note(text) => (text.clone(), true, false),
Row::Heading(key) => (horizon_client::lang::get(key), true, false),
Row::Space(px) => {
y += f32::from(*px);
continue;
}
Row::Button {
label,
active,
footer: true,
..
} => {
footer.push((r, *label, *active));
continue;
}
Row::Button {
label,
active,
half,
..
} => {
let (x, w) = match (*half, beside) {
(true, true) => {
y -= 30.0;
(cx + 4.0, 150.0)
}
(true, false) => {
half_open = true;
(cx - 154.0, 150.0)
}
(false, _) => (cx - 100.0, 200.0),
};
buttons.push(Button {
x,
y,
w,
label,
hit: Hit::Row(r),
active: *active,
});
y += 30.0;
continue;
}
// Settings rows belong in the options shell.
_ => continue,
};
let wrapped = if groups {
groups_lines(atlas, &text, width)
} else {
wrap(&text, width, atlas)
};
// Text that would run into the footer is left out.
if y + 10.0 * wrapped.len() as f32 > h - 33.0 {
continue;
}
let color = if quiet { grey() } else { WHITE };
for line in wrapped {
lines.push((line, color, y));
y += 10.0;
}
}
// The footer: the page's buttons, then Done; Done alone is wider.
footer.push((usize::MAX, "gui.done", true));
let (each, gap) = if footer.len() == 1 {
(200.0, 0.0)
} else {
(150.0, 8.0)
};
let total = footer.len() as f32 * (each + gap) - gap;
let mut x = cx - total / 2.0;
for (r, label, active) in footer {
let hit = if r == usize::MAX {
Hit::Done
} else {
Hit::Row(r)
};
buttons.push(Button {
x,
y: h - 27.0,
w: each,
label,
hit,
active,
});
x += each + gap;
}
(lines, buttons)
}
/// `-`-separated groups packed into lines of at most `width`, never broken inside a group.
fn groups_lines(atlas: &HudAtlas, text: &str, width: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for group in text.split('-') {
match lines.last_mut() {
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
l.push('-');
l.push_str(group);
}
_ => lines.push(group.to_owned()),
}
}
lines
}
fn draw(batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), title: &str, rows: &[Row]) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let title = Text::translate(title, &[]);
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), rows);
for (line, color, y) in lines {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if b.hit == Hit::Done {
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
let label = Text::translate(b.label, &[]);
ui::button(batch, atlas, b.x, b.y, b.w, 20.0, &label, b.active, hover);
}
}
/// The active button under a click.
fn click(atlas: &HudAtlas, size: (f32, f32), cursor: (f32, f32), rows: &[Row]) -> Option<Hit> {
layout(atlas, size, rows)
.1
.into_iter()
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
.map(|b| b.hit)
}
/// The page the Friends icon opens: the first registered there.
fn friends_page() -> Option<&'static dyn Page> {
pages::engine::all()
.iter()
.copied()
.find(|page| page.place() == Place::PauseFriends)
}
impl Game<'_> {
/// Whether the pause menu's Friends icon is active: a page is registered for it and this
/// window plays a singleplayer world (another server's world can't be shared).
pub(super) fn friends_shown(&self) -> bool {
self.can_share() && friends_page().is_some()
}
/// The Friends icon's page. Its rows are asked each time it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
let Some(page) = friends_page() else {
// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
self.screen = Screen::Paused;
return;
};
let mut rows = Vec::new();
page.rows(&mut rows);
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", super::friends::TITLE)
});
draw(batch, &self.gui.atlas, self.cursor, page.title(), &rows);
}
pub(super) fn friends_screen_click(&mut self) {
let Some(page) = friends_page() else {
return;
};
let mut rows = Vec::new();
page.rows(&mut rows);
let Some(hit) = click(&self.gui.atlas, self.gui_size(), self.cursor, &rows) else {
return;
};
self.audio.play_button_click();
match hit {
Hit::Row(r) => page.act(r, Act::Press),
Hit::Done => self.screen = Screen::Paused,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
fn at(buttons: &[Button], hit: Hit) -> &Button {
buttons.iter().find(|b| b.hit == hit).unwrap()
}
#[test]
fn halves_sit_side_by_side_and_footer_buttons_beside_done() {
let atlas = atlas();
let size = (427.0, 240.0);
let rows = [
Row::text("Invite Code").quiet(),
Row::Space(2),
Row::text("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0").groups(),
Row::Space(6),
Row::button("copy").half().active(false),
Row::button("stop").half(),
Row::text("Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address)."),
Row::button("help").footer(),
];
let (lines, buttons) = layout(&atlas, size, &rows);
let (copy, stop) = (at(&buttons, Hit::Row(4)), at(&buttons, Hit::Row(5)));
// 213 is the middle: two 150-wide halves 8 apart around it, as vanilla's pairs.
assert_eq!(
(copy.x, copy.w, stop.x, stop.w),
(59.0, 150.0, 217.0, 150.0)
);
assert_eq!(copy.y, stop.y);
let (help, done) = (at(&buttons, Hit::Row(7)), at(&buttons, Hit::Done));
assert_eq!(
(help.x, done.x, help.y, done.y),
(59.0, 217.0, 213.0, 213.0)
);
// The code breaks between its groups only.
let code: Vec<_> = lines
.iter()
.filter(|(_, c, _)| *c == WHITE)
.map(|(l, ..)| l)
.collect();
assert!(code[0].starts_with("0B33K-") && code.iter().all(|l| !l.ends_with('-')));
assert!(
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
"above the footer"
);
// An inactive button takes no click; Done alone is 200 wide.
assert_eq!(
click(&atlas, size, (copy.x + 1.0, copy.y + 1.0), &rows),
None
);
assert_eq!(
click(&atlas, size, (stop.x + 1.0, stop.y + 1.0), &rows),
Some(Hit::Row(5))
);
let (_, alone) = layout(&atlas, size, &[Row::button("share")]);
assert_eq!(at(&alone, Hit::Done).w, 200.0);
assert_eq!(at(&alone, Hit::Row(0)).x, 113.0);
}
#[test]
fn text_that_would_reach_the_footer_is_left_out() {
let atlas = atlas();
let rows = [
Row::Space(150),
Row::text("first"),
Row::text("too low").quiet(),
];
let (lines, _) = layout(&atlas, (427.0, 240.0), &rows);
// 40 + 150 = 190: one line fits above 207, the next (200..210) doesn't.
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].0, "first");
}
}
-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`.
+27 -11
View File
@@ -1574,7 +1574,7 @@ impl Game<'_> {
self.left = false;
self.right = false;
// The shared world is closing: its invite code ends with it.
self.lan_closed_hooks();
self.friends_lan_closed();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -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");
}
}
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more