Merge origin/main (picks up #5251's compile fixes)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rdbtCVSandClaude Opus 5.5 committed 2026-10-31 17:04:34 +00:00
commit 7fc284b46a
131 files changed
+3710 -643

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
welcome on [Discord](https://discord.gg/playhorizon).
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
@@ -136,5 +136,5 @@ new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
Generated
+14 -26
View File
@@ -118,15 +118,6 @@ dependencies = [
"libc",
]
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
@@ -605,17 +596,6 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -914,8 +894,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"wasm-bindgen",
]
[[package]]
@@ -3475,6 +3457,12 @@ version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]]
name = "typed-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -4672,18 +4660,18 @@ dependencies = [
[[package]]
name = "zip"
version = "2.4.2"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"getrandom 0.4.3",
"indexmap",
"memchr",
"thiserror 2.0.21",
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
+1 -1
View File
@@ -111,7 +111,7 @@ What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md)
## Contributing
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/HcTeMZxe5E). Before a pull request, run the self-check and paste its output:
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
```sh
git config core.hooksPath .githooks
+1 -1
View File
@@ -44,7 +44,7 @@ unicode-bidi = "0.3.18"
ureq = { version = "2", default-features = false, features = ["tls"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "time"] }
+14
View File
@@ -1074,6 +1074,15 @@ impl Audio {
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
// The device belongs to the first player's output, which looks after it.
#[cfg(feature = "audio")]
default_seen: self.default_seen.clone(),
#[cfg(feature = "audio")]
cards_seen: self.cards_seen.clone(),
#[cfg(feature = "audio")]
devices_seen: self.devices_seen.clone(),
#[cfg(feature = "audio")]
device_ticks: 0,
}
}
/// Sync the volume options, refreshing the categories that changed
@@ -1098,6 +1107,11 @@ impl Audio {
/// at once a second.
#[cfg(feature = "audio")]
fn should_change_device(&mut self) -> bool {
// A splitscreen share plays through the first player's output: it leaves the device,
// and the mixer's lost flag, to that output.
if self.backend.owner != 0 {
return false;
}
let (current, lost) = match self.backend.mixer.lock() {
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
Err(_) => return false,
@@ -1118,9 +1118,10 @@ impl BlockEntityRenderer {
}
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
/// BellRenderer.submit, LecternRenderer.submit (the book) and
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
/// destroy stage.
/// BellRenderer.submit, LecternRenderer.submit (the book),
/// CopperGolemStatueBlockRenderer.submit, ConduitRenderer.submit (the shell
/// or cage) and EnchantTableRenderer.submit (the book): the model drawn
/// again under the destroy stage.
pub fn render_breaking(
&self,
scene: &Scene,
@@ -1138,6 +1139,8 @@ impl BlockEntityRenderer {
| Kind::Bell
| Kind::Lectern { .. }
| Kind::CopperGolem { .. }
| Kind::Conduit
| Kind::EnchantingTable
) {
return;
}
@@ -1683,6 +1686,10 @@ impl BlockEntityRenderer {
* Mat4::from_quat(Quat::from_axis_angle(axis, degrees * (PI / 180.0)));
draw_part(model, "root", m, &none, paint, out);
}
// The crumbling overlay covers the shell and the cage only.
if crumbling {
return;
}
let wind = if phase == 1 {
"entity/conduit/wind_vertical"
} else {
+260 -3
View File
@@ -1,7 +1,13 @@
//! Resident chunk membership, with a token for consumers that cache its keys.
use crate::{chunk::Chunk, fast_hash::FxHashMap};
use crate::{
chunk::Chunk,
fast_hash::{FxHashMap, FxHashSet},
};
use std::{
collections::hash_map::{Iter, ValuesMut},
collections::{
VecDeque,
hash_map::{Iter, ValuesMut},
},
ops::Deref,
sync::{
Arc,
@@ -16,18 +22,46 @@ fn next_revision() -> u64 {
NEXT_REVISION.fetch_add(1, Ordering::Relaxed)
}
/// The membership changes one map keeps for the consumers that read them
/// every tick (`TickChunkMembership.synchronize`); older ones are dropped.
const MAX_RECENT_DELTAS: usize = 256;
/// One membership change of one `ChunkMap`, stamped with the revision it
/// brought the map to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChunkMembershipDelta {
Added(Key),
Removed(Key),
Reset,
}
/// Read access behaves like a map. Structural writes are explicit so edits to
/// blocks or light don't invalidate consumers of the loaded chunk key set.
/// Tokens are process-wide: replacing the entire map also invalidates caches.
pub struct ChunkMap {
columns: Columns,
revision: u64,
/// The oldest revision `recent_deltas` still answers for: the revision of
/// the change it last dropped, or the revision that cleared them. A
/// consumer holding an older one rebuilds, it does not replay.
min_valid_revision: u64,
/// This map's recent membership changes, in revision order.
/// Revisions are process-wide: the numbers missing in between belong to
/// the other maps (a server thread's, another dimension's), which say
/// nothing about this one's keys.
recent_deltas: VecDeque<(u64, ChunkMembershipDelta)>,
/// The resident chunks generation has not placed its animals in yet.
fresh: FxHashSet<Key>,
}
impl Default for ChunkMap {
fn default() -> Self {
let revision = next_revision();
Self {
columns: Columns::default(),
revision: next_revision(),
revision,
min_valid_revision: revision,
recent_deltas: VecDeque::new(),
fresh: FxHashSet::default(),
}
}
}
@@ -35,26 +69,107 @@ impl ChunkMap {
pub fn membership_revision(&self) -> u64 {
self.revision
}
fn record_delta(&mut self, delta: ChunkMembershipDelta) {
if self.recent_deltas.len() >= MAX_RECENT_DELTAS
&& let Some((evicted, _)) = self.recent_deltas.pop_front()
{
self.min_valid_revision = evicted;
}
self.recent_deltas.push_back((self.revision, delta));
}
/// This map's membership changes since `last_revision`, one of its own
/// earlier `membership_revision`s, or `None` when they can no longer be
/// answered: the consumer's revision predates what the ring kept, the map
/// was cleared since (a reset rebuilds everything), or the revision is not
/// this map's, which its own revisions give away: every one of them in the
/// answered range is the opening revision or a change the ring still
/// holds, so a revision another map took in between is refused, not
/// replayed.
pub fn deltas_since(
&self,
last_revision: u64,
) -> Option<impl Iterator<Item = (u64, ChunkMembershipDelta)> + '_> {
if last_revision == 0
|| last_revision < self.min_valid_revision
|| last_revision > self.revision
|| self.recent_deltas.is_empty()
{
return None;
}
let pos = self
.recent_deltas
.partition_point(|(rev, _)| *rev <= last_revision);
// A revision this map never took: another map's, drawing on the same
// process-wide counter. Only the opening revision and the ones the
// ring still holds are this map's.
let is_own_revision = last_revision == self.min_valid_revision
|| (pos > 0 && self.recent_deltas[pos - 1].0 == last_revision);
if !is_own_revision {
return None;
}
let slice = self.recent_deltas.range(pos..);
if slice
.clone()
.any(|(_, d)| *d == ChunkMembershipDelta::Reset)
{
return None;
}
Some(slice.copied())
}
pub fn insert(&mut self, key: Key, chunk: Arc<Chunk>) -> Option<Arc<Chunk>> {
if chunk.fresh {
self.fresh.insert(key);
} else {
self.fresh.remove(&key);
}
let previous = self.columns.insert(key, chunk);
if previous.is_none() {
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Added(key));
}
previous
}
pub fn remove(&mut self, key: &Key) -> Option<Arc<Chunk>> {
self.fresh.remove(key);
let previous = self.columns.remove(key);
if previous.is_some() {
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Removed(*key));
}
previous
}
pub fn clear(&mut self) {
if !self.columns.is_empty() {
self.columns.clear();
self.fresh.clear();
self.revision = next_revision();
self.recent_deltas.clear();
self.recent_deltas
.push_back((self.revision, ChunkMembershipDelta::Reset));
self.min_valid_revision = self.revision;
}
}
pub fn has_fresh(&self) -> bool {
!self.fresh.is_empty()
}
/// The fresh keys in the chunk map's own iteration order, so the spawns
/// they drive run in the order a scan of the columns gave.
pub fn fresh_keys_ordered(&self) -> Vec<Key> {
if self.fresh.is_empty() {
Vec::new()
} else if self.fresh.len() == 1 {
self.fresh.iter().copied().collect()
} else {
self.columns
.keys()
.filter(|k| self.fresh.contains(k))
.copied()
.collect()
}
}
pub fn remove_fresh(&mut self, key: &Key) {
self.fresh.remove(key);
}
pub fn get_mut(&mut self, key: &Key) -> Option<&mut Arc<Chunk>> {
self.columns.get_mut(key)
}
@@ -125,4 +240,146 @@ mod tests {
map = ChunkMap::default();
assert_ne!(cleared, map.membership_revision());
}
/// The deltas' kinds, checking the ring answers in revision order.
fn kinds(deltas: Vec<(u64, ChunkMembershipDelta)>) -> Vec<ChunkMembershipDelta> {
let mut last = 0;
for (rev, _) in &deltas {
assert!(*rev > last, "the ring answers in revision order");
last = *rev;
}
deltas.into_iter().map(|(_, d)| d).collect()
}
/// Revision numbers are process-wide: the ones another map takes in
/// between, and a payload-only rewrite that takes none at all, say nothing
/// about this map's keys.
#[test]
fn deltas_since_ignores_revisions_other_maps_take() {
let mut map = ChunkMap::default();
let mut other = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let seen = map.membership_revision();
for key in [(1, 0), (2, 0), (3, 0)] {
other.insert(key, chunk.clone());
}
map.insert((0, 0), Arc::new(Chunk::empty(0, 0))); // Payload only.
map.insert((4, 0), chunk.clone());
map.remove(&(0, 0));
assert_eq!(
kinds(map.deltas_since(seen).unwrap().collect()),
[
ChunkMembershipDelta::Added((4, 0)),
ChunkMembershipDelta::Removed((0, 0)),
]
);
// The consumer's own revision answers, with nothing new since.
assert!(
map.deltas_since(map.membership_revision())
.unwrap()
.next()
.is_none()
);
// A revision another map took in between: refused, not replayed.
assert!(map.deltas_since(other.membership_revision()).is_none());
}
/// A map built ahead of the live one, its revisions interleaved with it,
/// answering for a revision it never took: refused, so the consumer
/// rebuilds rather than replay another map's changes.
#[test]
fn deltas_since_none_for_interleaved_revision_from_another_map() {
let mut map = ChunkMap::default();
let mut other = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let seen = map.membership_revision();
other.insert((1, 0), chunk.clone());
let foreign = other.membership_revision();
map.insert((2, 0), chunk.clone());
assert!(map.deltas_since(foreign).is_none());
assert_eq!(
kinds(map.deltas_since(seen).unwrap().collect()),
[ChunkMembershipDelta::Added((2, 0))]
);
}
/// The ring answers for the changes it kept, and only those.
#[test]
fn deltas_since_none_for_a_revision_the_ring_dropped() {
let mut map = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
let created = map.membership_revision();
let mut revisions = Vec::new();
let changes = MAX_RECENT_DELTAS as i32 + 1;
for i in 0..changes {
map.insert((i, 0), chunk.clone());
revisions.push(map.membership_revision());
}
let (first, kept) = revisions.split_first().unwrap();
// The oldest change fell out with the consumer's revision: rebuild.
assert!(map.deltas_since(created).is_none());
assert_eq!(map.deltas_since(*first).unwrap().count(), kept.len());
}
/// A reset rebuilds whatever the consumer last saw, and the ring answers
/// for what follows it.
#[test]
fn deltas_since_none_across_a_clear() {
let mut map = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let before = map.membership_revision();
map.clear();
assert!(map.deltas_since(before).is_none());
let cleared = map.membership_revision();
map.insert((1, 0), chunk);
assert_eq!(
kinds(map.deltas_since(cleared).unwrap().collect()),
[ChunkMembershipDelta::Added((1, 0))]
);
}
#[test]
fn fresh_chunks_tracking() {
let mut map = ChunkMap::default();
assert!(!map.has_fresh());
let mut chunk = Chunk::empty(1, 2);
chunk.fresh = true;
map.insert((1, 2), Arc::new(chunk));
assert!(map.has_fresh());
assert_eq!(map.fresh_keys_ordered(), vec![(1, 2)]);
map.remove_fresh(&(1, 2));
assert!(!map.has_fresh());
let mut chunk2 = Chunk::empty(3, 4);
chunk2.fresh = true;
map.insert((3, 4), Arc::new(chunk2));
assert!(map.has_fresh());
map.remove(&(3, 4));
assert!(!map.has_fresh());
}
#[test]
fn fresh_keys_ordered_matches_chunk_map_iteration_order() {
let mut map = ChunkMap::default();
let keys = [(-5, 10), (12, -3), (0, 0), (7, 8), (-1, -1), (4, 4)];
for (i, &key) in keys.iter().enumerate() {
let mut chunk = Chunk::empty(key.0, key.1);
chunk.fresh = i % 2 == 0;
map.insert(key, Arc::new(chunk));
}
let ordered = map.fresh_keys_ordered();
let expected: Vec<Key> = map
.keys()
.copied()
.filter(|k| map.fresh.contains(k))
.collect();
assert_eq!(ordered, expected);
assert_eq!(ordered.len(), 3);
}
}
@@ -658,15 +658,16 @@ mod tests {
f.write("pack.zip", &archive(&files(b"bbb"), false));
f.rebuild_matches(std::slice::from_ref(&pack));
assert_eq!(fs::read(f.0.join("cached").join(name)).unwrap(), b"bbb");
// The ZIP opens and enumerates, then its unsupported method fails after
// mirror has created directories. No reusable marker survives.
// A central directory whose second name runs past the archive fails
// the build (an unreadable entry alone only costs that file). No
// reusable marker survives.
let mut broken = archive(&files(b"ccc"), false);
let central = broken
.windows(4)
.position(|s| s == [0x50, 0x4b, 0x01, 0x02])
.unwrap();
let second = central + 46 + "pack.mcmeta".len();
broken[second + 10..second + 12].copy_from_slice(&99u16.to_le_bytes());
broken[second + 28..second + 30].copy_from_slice(&u16::MAX.to_le_bytes());
f.write("pack.zip", &broken);
let out = f.0.join("cached");
assert!(
+2 -2
View File
@@ -40,7 +40,7 @@ impl Zip {
let Some(name) = archive.name_for_index(i) else {
continue;
};
let name = name.replace('\\', "/");
let name = name.map_err(invalid)?.replace('\\', "/");
match name.strip_suffix('/') {
Some(folder) => folders.insert(folder.to_owned(), i),
None => entries.insert(name, i),
@@ -374,7 +374,7 @@ mod tests {
.unwrap();
let entry = archive.by_index_raw(index).unwrap();
(
entry.data_start() as usize,
entry.data_start().unwrap() as usize,
entry.compressed_size() as usize,
)
};
+1 -1
View File
@@ -56,7 +56,7 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
+10 -2
View File
@@ -927,6 +927,10 @@ pub fn mob_to_nbt(
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
);
}
// Phantom.addAdditionalSaveData.
"minecraft:phantom" => {
map.insert("size".into(), Tag::Int(m.extra.size));
}
"minecraft:slime" | "minecraft:magma_cube" => {
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
map.insert("wasOnGround".into(), Tag::Byte(i8::from(m.extra.flag2)));
@@ -1813,6 +1817,10 @@ pub fn mob_from_nbt(
}
m.extra.flag2 = flag(tag, "wasOnGround");
}
if m.species() == crate::mobs::Species::Phantom {
// Phantom.readAdditionalSaveData: setPhantomSize(size).
crate::mobs::phantom::set_size(&mut m, number(tag.get("size")).unwrap_or(0) as i32);
}
// Zombie, Piglin and Zoglin.readAdditionalSaveData: IsBaby (the baby
// zombies of trial spawners).
if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") {
@@ -3778,8 +3786,8 @@ mod item_component_tests {
else {
return m;
};
let tag = crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None), data);
mob_from_nbt(&tag, &types, 2).unwrap()
let tag = crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None, None), data);
mob_from_nbt(&tag, &types, 2, None).unwrap()
};
// `entity_data={NoAI:1b}`: no id, so the egg's own type holds the data.
assert!(spawn(&egg(Some(Tag::compound([("NoAI", Tag::Byte(1))])))).no_ai);
+73 -25
View File
@@ -1781,8 +1781,23 @@ fn equipment_modifier_applies(group: &str, slot: &str) -> bool {
/// A chunk whose ticking future can complete (`ChunkMap.prepareTickingChunk`):
/// lit, with its neighbours loaded, as `postprocess_chunks` waits for.
fn ticking_chunk(world: &World, x: i32, z: i32) -> bool {
world.chunks.get(&(x, z)).is_some_and(|c| c.lit)
&& (-1..=1).all(|dz| (-1..=1).all(|dx| world.chunks.contains_key(&(x + dx, z + dz))))
let Some(center) = world.chunks.get(&(x, z)) else {
return false;
};
if !center.lit {
return false;
}
for dz in -1..=1 {
for dx in -1..=1 {
if dx == 0 && dz == 0 {
continue;
}
if !world.chunks.contains_key(&(x + dx, z + dz)) {
return false;
}
}
}
true
}
/// Key events carry the scancode's US-layout code, which ignores XKB options like
/// `caps:swapescape`. For the keys whose meaning the keymap may remap, take the code
@@ -2417,6 +2432,11 @@ struct Game<'a> {
/// `remote_handle_input`): picked once, so a creative break does not
/// move the use that follows it past the broken block.
tick_hit: Option<Option<Hit>>,
/// `Player.entityInteractionRange` and `blockInteractionRange`, refreshed
/// each tick from the attribute (a server's reach modifier, creative's
/// +2): the pick helpers' `&self` reach.
entity_reach: f64,
block_reach: f64,
/// The part of a right click a guest's packet asks for (game/lan_actions.rs).
guest_use: Option<GuestUse>,
/// The last `use_hand` that ended the click did so with `FAIL`, which
@@ -2626,8 +2646,9 @@ impl Game<'_> {
}
Some((self.eye_position()?, view_vector(self.yaw, self.pitch)))
}
/// `Player.blockInteractionRange`: the block pick's and mining's reach.
fn reach(&self) -> f64 {
if self.creative { 5.0 } else { 4.5 }
self.block_reach
}
/// Outline shapes that depend on more than the block state, as one box
/// in block space: `LightBlock` and `ScaffoldingBlock.getShape` are a full
@@ -2700,7 +2721,24 @@ impl Game<'_> {
return hit;
}
let (origin, dir) = self.aim()?;
self.clip_ray(origin, dir, self.reach())
// GameRenderer.pick: the block ray runs out to the larger of the two
// interaction ranges; filterHitResult then drops a hit past the
// block's.
self.clip_ray(origin, dir, self.block_reach.max(self.entity_reach))
.filter(|hit| {
let distance: [f64; 3] = std::array::from_fn(|i| hit.point[i] - origin[i]);
distance.iter().map(|v| v * v).sum::<f64>() <= self.block_reach.powi(2)
})
}
/// The same traversal without `filterHitResult`: the block hit that
/// bounds the entity ray in `GameRenderer.pick`, out to the larger
/// interaction range — a wall between the two still hides the entity.
fn pick_unfiltered(&self) -> Option<Hit> {
if let Some(hit) = self.forced_hit {
return Some(hit);
}
let (origin, dir) = self.aim()?;
self.clip_ray(origin, dir, self.block_reach.max(self.entity_reach))
}
/// The first block outline hit within `reach` along the unit `dir`.
fn clip_ray(&self, origin: [f64; 3], dir: [f64; 3], reach: f64) -> Option<Hit> {
@@ -3792,6 +3830,7 @@ impl Game<'_> {
self.sync_tick_chunks();
self.start_ticking_chunks();
let levels = self.tickets.levels();
let bounds = levels.random_tick_bounds();
// Keep the world map's chunk order, including empty chunks
// (tick_chunk_random still draws for precipitation in those).
let ticking: Vec<_> = self
@@ -3799,7 +3838,14 @@ impl Game<'_> {
.chunks
.keys()
.copied()
.filter(|&(x, z)| levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z))
.filter(|&(x, z)| {
if let Some((min_x, min_z, max_x, max_z)) = bounds
&& (x < min_x || x > max_x || z < min_z || z > max_z)
{
return false;
}
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
})
.collect();
// Entity.checkInsideBlocks for the plates, tripwires, detector rails and
// buttons under the players (the host and the LAN guests), the minecarts
@@ -6209,6 +6255,10 @@ impl Game<'_> {
if self.agent.hold_server {
return Ok(());
}
// `Player.entityInteractionRange`/`blockInteractionRange` for the tick's
// picks: the attribute carries creative's and a server's modifiers.
self.entity_reach = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0);
self.block_reach = self.interaction_range("minecraft:block_interaction_range", 4.5, 5.0);
self.tick_debug_keys()?;
self.tick_debug_charts();
self.tick_tutorial();
@@ -6640,9 +6690,12 @@ impl Game<'_> {
// ticks; ServerLevel.tick then runs the scheduled and random block
// ticks before any entity, so what they drop or spawn (dispensed
// items, popped crops) moves on the tick it appears, as in vanilla.
// On the server thread the window is a hub guest: drain its packets
// here too, before falling-block schedules run (#4510).
horizon_client::spans::time("tick_local_hand", || self.tick_local_hand())?;
horizon_client::spans::time("postprocess_chunks", || self.postprocess_chunks())?;
if running {
horizon_client::spans::time("tick_lan_packets", || self.tick_lan_packets());
horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
}
if running {
@@ -7213,6 +7266,11 @@ impl Game<'_> {
+ self.clock.fraction() as f32)
/ 24000.0;
let (w, h) = (batch.width, batch.height);
// HDR calibration ends when its page does, whatever moved the screen
// off Options (a death screen opened by a server, say).
if self.screen != Screen::Options && self.ui.options.calibration_fullscreen.is_some() {
self.show_calibration(h);
}
if self.screen == Screen::Win {
self.draw_win_screen(&mut batch);
return batch;
@@ -9860,13 +9918,15 @@ impl Game<'_> {
}
fn mob_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64)> = None;
for (i, m) in self.mobs.iter().enumerate() {
// ProjectileUtil.getEntityHitResult: the mob the player rides
// shares its root vehicle and is never the hit, nor is the
// camera entity the pick starts from.
if !m.alive() || Some(m.id) == self.riding || Some(m.id) == self.remote_camera_id() {
// camera entity the pick starts from. EntitySelector.CAN_BE_PICKED
// is isPickable — `!isRemoved`, not alive: a health-0 fake player
// still answers a click.
if m.removed || Some(m.id) == self.riding || Some(m.id) == self.remote_camera_id() {
continue;
}
if let Some(t) = ray_box(eye, dir, reach, m.bounds())
@@ -10057,14 +10117,7 @@ impl Game<'_> {
// A painting nearer than the targeted block and mob breaks instead.
if let Some((i, d)) = self.painting_hit()
&& self.mob_hit().is_none_or(|m| d < m.1)
&& self.pick().is_none_or(|h| {
let eye = self.eye_position().unwrap_or_default();
(0..3)
.map(|a| (h.point[a] - eye[a]).powi(2))
.sum::<f64>()
.sqrt()
> d
})
&& self.before_block(d)
{
self.break_painting(i, true);
return true;
@@ -10086,15 +10139,8 @@ impl Game<'_> {
let Some((index, distance)) = self.mob_hit() else {
return false;
};
if let Some(hit) = self.pick() {
let eye = self.eye_position().unwrap_or_default();
let d = ((hit.point[0] - eye[0]).powi(2)
+ (hit.point[1] - eye[1]).powi(2)
+ (hit.point[2] - eye[2]).powi(2))
.sqrt();
if d < distance {
return false;
}
if !self.before_block(distance) {
return false;
}
self.attack_mob(index);
// Minecraft.startAttack swings after the attack.
@@ -16132,6 +16178,8 @@ impl<'a> Game<'a> {
world_icon: None,
forced_hit: None,
tick_hit: None,
entity_reach: 3.0,
block_reach: 4.5,
guest_use: None,
use_failed: false,
lan_events: Vec::new(),
+3 -1
View File
@@ -660,7 +660,9 @@ impl Game<'_> {
list.push((d, v));
}
};
for m in self.mobs.iter().filter(|m| m.alive()) {
// CAN_BE_PICKED's isPickable (`!isRemoved`): a dead-but-tracked mob
// — a fake player the server keeps at health 0 — is still here.
for m in self.mobs.iter().filter(|m| !m.removed) {
let mut v = json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time });
// VillagerData as this game knows it: on a client, what the
// server's synched data says.
@@ -441,7 +441,7 @@ impl Game<'_> {
/// The armor stand under the crosshair, its distance and the hit point.
pub(super) fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, a) in self.armor_stands.iter().enumerate() {
if a.marker {
+1 -1
View File
@@ -832,7 +832,7 @@ impl Game<'_> {
}
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64)> = None;
for (i, b) in self.boats.iter().enumerate() {
if Some(b.id) == self.riding {
@@ -48,8 +48,8 @@ impl Game<'_> {
/// block under a sprinting player's feet streams back from them every
/// tick, for the window's own player and for the other players it sees.
fn sprint_dust(&mut self) {
// Each runner's feet, movement and supporting block.
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new();
// Each runner's feet, movement, supporting block and box width.
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>, f64)> = Vec::new();
// Entity.canSpawnSprintParticle.
if let Some(p) = self.player.as_ref()
&& p.sprinting
@@ -62,7 +62,21 @@ impl Game<'_> {
&& p.fluid_height(LAVA) <= 0.0
{
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
runners.push((p.position, p.velocity, support));
runners.push((p.position, p.velocity, support, f64::from(0.6f32)));
}
// IronGolem.canSpawnSprintParticle replaces the sprinting test: one
// tick in five while it moves at all, in water or not, drawn from the
// golem's own random as its client copy's Entity.baseTick does.
for m in self.mobs.iter_mut() {
if &*m.kind.id != "minecraft:iron_golem" {
continue;
}
let movement = m.position.subtract(m.previous);
let moving =
movement.x * movement.x + movement.z * movement.z > f64::from(2.5000003E-7_f32);
if moving && m.random.next_int_bound(5).unwrap_or(1) == 0 {
runners.push((m.position, movement, None, f64::from(m.width())));
}
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
@@ -72,6 +86,10 @@ impl Game<'_> {
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
if &*m.kind.id == "minecraft:iron_golem" {
continue;
}
let movement = m.position.subtract(m.previous);
if m.extra.sprinting
&& !m.extra.crouching
&& m.alive()
@@ -83,10 +101,10 @@ impl Game<'_> {
.fluid_level
== 0
{
runners.push((m.position, m.position.subtract(m.previous), None));
runners.push((m.position, movement, None, f64::from(0.6f32)));
}
}
for (position, movement, support) in runners {
for (position, movement, support, width) in runners {
// Entity.getOnPosLegacy: getOnPos(0.2).
let below = (position.y - f64::from(0.2f32)).floor() as i32;
let tall = |game: &Self, s: [i32; 3]| {
@@ -111,7 +129,6 @@ impl Game<'_> {
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
continue;
};
let width = f64::from(0.6f32);
let r = self.particles.random();
let mut x = position.x + (r.next_double() - 0.5) * width;
let mut z = position.z + (r.next_double() - 0.5) * width;
+142 -8
View File
@@ -397,6 +397,84 @@ impl Game<'_> {
}
Some(id)
}
/// `Mob.burnUndead` for a mob in daylight with an item on its sun
/// protection slot (the head, or the body for a zombie horse or
/// nautilus): `hurtAndBreak(random.nextInt(2))` on a damageable item, and
/// when it breaks the slot empties and `onEquippedItemBroken` broadcasts
/// the break event.
fn wear_sun_protection(&mut self, id: u32) {
use horizon_world::player_inventory::EquipmentSlot;
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let body = horizon_client::mobs::drops::head_is_body(m.species());
let slot = if body {
EquipmentSlot::Body
} else {
EquipmentSlot::Head
};
let Some(mut stack) = m.item_by_slot(slot) else {
return;
};
if !horizon_client::stations::is_damageable(self.res.items(), &stack) {
return;
}
let at = [m.position.x, m.position.y, m.position.z];
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
let amount = m.random.next_int_bound(2).unwrap_or(0) as u32;
if amount == 0 {
return;
}
let loss = self.durability_loss(&stack, amount);
if loss == 0 {
return;
}
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage);
stack.damage += loss;
let broke = stack.damage >= max;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
match (body, broke) {
(true, false) => m.extra.body_damage = stack.damage as i32,
(true, true) => {
m.extra.head = None;
m.extra.body_damage = 0;
}
(false, false) => m.extra.armor[0] = Some(stack),
(false, true) => {
m.extra.head = None;
m.extra.armor[0] = None;
}
}
if broke {
// LivingEntity.entityEventForEquipmentBreak: head 49, body 65.
self.lan_entity_event(id as i32, if body { 65 } else { 49 }, at);
}
}
/// What a converted frog gets once its tadpole has grown up: the spawn
/// finalization of a conversion, then persistence.
fn finalize_converted(&mut self, id: u32) {
let Some(at) = self.mobs.iter().find(|m| m.id == id).map(|m| m.position) else {
return;
};
let facts = self.biome_facts(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
);
let difficulty = self.local_difficulty([at.x, at.y, at.z]);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
spawning::finalize(m, &facts, &difficulty);
m.persistent = true;
}
}
/// `Mob.convertTo(type, ConversionParams.single(...))` for a mob already
/// removed: a new mob of `kind`, made without spawn finalization, takes
/// its place with what `conversion::single` carries over, and the old
@@ -620,17 +698,21 @@ impl Game<'_> {
/// Generation's animals for the chunks generated since, once lit: chunk
/// generation places them whether the level ticks or not.
pub(super) fn spawn_fresh_chunks(&mut self) {
if !self.world.chunks.has_fresh() {
return;
}
let fresh: Vec<(i32, i32)> = self
.world
.chunks
.iter()
.filter(|(_, c)| c.fresh && c.lit)
.map(|(k, _)| *k)
.fresh_keys_ordered()
.into_iter()
.filter(|k| self.world.chunks.get(k).is_some_and(|c| c.fresh && c.lit))
.collect();
for key in fresh {
if let Some(c) = self.world.chunks.get_mut(&key) {
Arc::make_mut(c).fresh = false;
}
self.world.chunks.remove_fresh(&key);
self.spawn_generation_creatures(key.0, key.1);
}
}
@@ -1406,6 +1488,7 @@ impl Game<'_> {
.map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]);
self.drown_particles(at, v);
}
MobEvent::SunProtectionWear { mob } => self.wear_sun_protection(mob),
MobEvent::EntityEvent { mob, event, at } => {
self.lan_entity_event(mob as i32, event, at)
}
@@ -1801,7 +1884,21 @@ impl Game<'_> {
}
}
MobEvent::Convert { from, kind, params } => {
self.convert_to(&from, kind, params);
let id = self.convert_to(&from, kind, params);
// `Piglin.finishConversion` and `Hoglin.finishConversion`:
// the converted mob is nauseous for 10 seconds.
if matches!(kind, "minecraft:zombified_piglin" | "minecraft:zoglin")
&& let Some(m) = self.mobs.iter_mut().find(|m| Some(m.id) == id)
{
m.add_effect("minecraft:nausea", 200, 0);
}
// `Tadpole.ageUp`: the frog is finalized for the
// conversion and made persistent.
if kind == "minecraft:frog"
&& let Some(id) = id
{
self.finalize_converted(id);
}
}
MobEvent::DropItem { item, count, at } => self.spawn_item(
ItemStack {
@@ -1818,6 +1915,23 @@ impl Game<'_> {
dispenser::new_item_motion(),
10,
),
MobEvent::DropGoatHorn {
mob,
screaming,
at,
motion,
} => {
let instrument =
horizon_client::mobs::goat::create_horn(self.mob_uuid(mob), screaming);
let mut horn = ItemStack::new("minecraft:goat_horn", 1);
horn.data_mut().set_other(
"minecraft:instrument",
Some(horizon_world::nbt::Tag::String(instrument.into())),
);
// Goat.dropHorn's ItemEntity constructor leaves pickupDelay at
// 0 (no setDefaultPickUpDelay).
self.spawn_item(horn, at, motion, 0);
}
MobEvent::DropGift { table, at } => {
let stacks =
self.res
@@ -4239,7 +4353,7 @@ impl Game<'_> {
let Some((index, distance)) = self.mob_hit() else {
return Ok(false);
};
if let Some(hit) = self.pick()
if let Some(hit) = self.pick_unfiltered()
&& let Some(eye) = self.eye_position()
{
let d = ((hit.point[0] - eye[0]).powi(2)
@@ -4912,7 +5026,15 @@ impl Game<'_> {
// ALTERNATIVE_WITH_SPEED with a per-axis maximum speed.
self.particles
.send_particles(&options, at, spread, 0.2, count);
self.lan_send_particles(&options, at, spread, 0.2, count);
self.lan_send_particles(super::lan_particles::QueuedParticle {
dimension: self.dim.id(),
options,
state: None,
at,
spread,
speed: 0.2,
count: count as i32,
});
}
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
if let Some(stack) = &held {
@@ -5376,8 +5498,20 @@ impl Game<'_> {
mob.position.z.floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
if mob.fire_ticks > 0 || matches!(mob.species(), Species::Blaze | Species::MagmaCube) {
packed = (15 * 16) | ((15 * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
// renderers' overrides; the sky light stays the level's.
if mob.fire_ticks > 0
|| matches!(
mob.species(),
Species::Allay
| Species::Blaze
| Species::MagmaCube
| Species::Vex
| Species::Wither
)
{
packed = (packed & 0xFF) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
+1 -1
View File
@@ -146,7 +146,7 @@ impl Game<'_> {
/// The cushion under the crosshair, its distance and the hit point.
pub(super) fn cushion_hit(&self) -> Option<(usize, f64, [f64; 3])> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, c) in self.cushions.iter().enumerate() {
let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else {
+3 -3
View File
@@ -72,7 +72,7 @@ impl Game<'_> {
/// reach, and how far along the view it is.
pub(super) fn redirectable_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
self.creatures
.projectiles
.iter()
@@ -94,7 +94,7 @@ impl Game<'_> {
if self.mob_hit().is_some_and(|(_, d)| d < t) {
return false;
}
if let (Some(hit), Some(eye)) = (self.pick(), self.eye_position()) {
if let (Some(hit), Some(eye)) = (self.pick_unfiltered(), self.eye_position()) {
let d = (0..3)
.map(|a| (hit.point[a] - eye[a]).powi(2))
.sum::<f64>()
@@ -122,7 +122,7 @@ impl Game<'_> {
let d2: f64 = (0..3)
.map(|a| (eye[a] - eye[a].clamp(lo[a], hi[a])).powi(2))
.sum();
let range = if self.creative { 5.0 } else { 3.0 } + 3.0;
let range = self.entity_reach + 3.0;
d2 < range * range
}
+102 -10
View File
@@ -1190,7 +1190,7 @@ impl Game<'_> {
/// The dragon part under the crosshair within reach: its id and distance.
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(u32, f64)> = None;
for (id, b) in self.special_targets() {
let (lo, hi) = (b.min_position(), b.max_position());
@@ -1706,15 +1706,7 @@ impl Game<'_> {
}
}
Phase::Attacking { ticks } => {
if *ticks == 0 {
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio.play(
"minecraft:entity.ender_dragon.growl",
Some([pos.x, pos.y, pos.z]),
2.5,
pitch,
);
}
// The growl is `doClientTick`'s (`remote_dragon_phase_tick`).
*ticks += 1;
if *ticks > 40 {
next = Some(Phase::Flaming { ticks: 0 });
@@ -3656,8 +3648,108 @@ impl Game<'_> {
let d = self.dragon.as_mut().expect("dragon");
d.yaw = wrap(d.yaw);
d.record();
self.remote_dragon_phase_tick();
self.place_dragon_parts();
}
/// `DragonLandingPhase.doClientTick`, `DragonSittingFlamingPhase.doClientTick`,
/// `DragonDeathPhase.doClientTick` and `DragonSittingAttackingPhase.doClientTick`,
/// which `EnderDragon.aiStep` runs on a client. The first two breathe
/// dragon breath from the head (their `look.yRot(...)` results are thrown
/// away in vanilla, so every puff leaves along the same line), the third
/// puffs an explosion every 10 ticks and the last growls on every tick.
fn remote_dragon_phase_tick(&mut self) {
let look = self.dragon_look();
let norm = (look[0] * look[0] + look[1] * look[1] + look[2] * look[2]).sqrt();
let look = if norm < 1.0e-5 {
[0.0; 3]
} else {
[look[0] / norm, look[1] / norm, look[2] / norm]
};
let Some(d) = self.dragon.as_mut() else {
return;
};
let head = d.parts[0].0;
let head_y = head.y + PART_SIZES[0].1 * 0.5;
let motion = d.velocity;
let at = [d.position.x, d.position.y, d.position.z];
let mut puffs: Vec<([f64; 3], [f64; 3])> = Vec::new();
let mut emitter = None;
let mut growl = None;
match &mut d.phase {
Phase::Landing { .. } => {
for _ in 0..8 {
let r = &mut d.random;
let p = [
head.x + r.next_gaussian() / 2.0,
head_y + r.next_gaussian() / 2.0,
head.z + r.next_gaussian() / 2.0,
];
puffs.push((
p,
[
-look[0] * f64::from(0.08_f32) + motion.x,
-look[1] * f64::from(0.3_f32) + motion.y,
-look[2] * f64::from(0.08_f32) + motion.z,
],
));
}
}
Phase::Flaming { ticks } => {
*ticks += 1;
if *ticks % 2 == 0 && *ticks < 10 {
for _ in 0..8 {
let r = &mut d.random;
let p = [
head.x + r.next_gaussian() / 2.0,
head_y + r.next_gaussian() / 2.0,
head.z + r.next_gaussian() / 2.0,
];
for j in 0..6 {
puffs.push((
p,
[
-look[0] * f64::from(0.08_f32) * f64::from(j),
-look[1] * f64::from(0.6_f32),
-look[2] * f64::from(0.08_f32) * f64::from(j),
],
));
}
}
}
}
Phase::Dying { time, .. } => {
if *time % 10 == 0 {
let r = &mut d.random;
let o = [
f64::from((r.next_float() - 0.5) * 8.0),
f64::from((r.next_float() - 0.5) * 4.0),
f64::from((r.next_float() - 0.5) * 8.0),
];
emitter = Some([at[0] + o[0], at[1] + 2.0 + o[1], at[2] + o[2]]);
}
*time += 1;
}
Phase::Attacking { .. } => {
growl = Some(0.8 + d.random.next_float() * 0.3);
}
_ => {}
}
let breath = ParticleOptions::simple(ParticleType::DragonBreath);
for (p, v) in puffs {
self.particles.add(&breath, p, v);
}
if let Some(p) = emitter {
self.particles.add(
&ParticleOptions::simple(ParticleType::ExplosionEmitter),
p,
[0.0; 3],
);
}
if let Some(pitch) = growl {
self.audio
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
}
}
/// The dragon's parts as a server's client attacks them: the server's
/// ids for `EnderDragonPart`s follow the dragon's.
pub(super) fn remote_dragon_part_hit(
+2 -2
View File
@@ -175,7 +175,7 @@ impl Game<'_> {
/// The frame under the crosshair within reach and its distance.
pub(super) fn frame_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64)> = None;
for (i, f) in self.item_frames.iter().enumerate() {
let (lo, hi) = f.bounds();
@@ -201,7 +201,7 @@ impl Game<'_> {
}
/// Nearer than the targeted block?
pub(super) fn before_block(&self, distance: f64) -> bool {
self.pick().is_none_or(|h| {
self.pick_unfiltered().is_none_or(|h| {
let eye = self.aim().map(|(eye, _)| eye).unwrap_or_default();
(0..3)
.map(|a| (h.point[a] - eye[a]).powi(2))
+1 -1
View File
@@ -33,7 +33,7 @@ impl Game<'_> {
let name = names.into_iter().find(|(id, _)| *id == m.id);
name.and_then(|(_, name)| self.score.board.players_team(&name))
} else {
self.mob_team(m.id)
self.mob_team_of(m)
};
Some(team_color(team))
}
+6 -1
View File
@@ -104,7 +104,12 @@ impl Game<'_> {
/// `getAttributeValue` of an interaction range of the player in place (a
/// guest swapped in on a server): creative's modifiers are on it
/// (`update_player_attributes`), as are `/attribute`'s and the items'.
fn interaction_range(&mut self, attribute: &str, survival: f64, creative: f64) -> f64 {
pub(super) fn interaction_range(
&mut self,
attribute: &str,
survival: f64,
creative: f64,
) -> f64 {
let otherwise = if self.creative { creative } else { survival };
self.player
.as_mut()
@@ -66,7 +66,7 @@ impl Game<'_> {
/// and the hit distance along the view.
pub(super) fn interaction_hit(&self) -> Option<(i32, f64)> {
let (eye, dir) = self.aim()?;
let reach = if self.creative { 5.0 } else { 3.0 };
let reach = self.entity_reach;
self.interactions
.iter()
.filter_map(|(&id, i)| ray_box(eye, dir, reach, i.bounds()).map(|d| (id, d)))
+238 -119
View File
@@ -726,6 +726,13 @@ fn mob_meta(
));
}
}
// Phantom.ID_SIZE (after Mob's flags).
"minecraft:phantom" => {
if e.size > 0 {
meta.push((16, serializer::INT, Value::Int(e.size)));
}
defaults.push((16, serializer::INT, Value::Int(0)));
}
// AbstractCubeMob.ID_SIZE; SulfurCube also syncs FROM_BUCKET and
// MAX_FUSE (clients tick the fuse for the swell / white flash).
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
@@ -1348,6 +1355,11 @@ impl Game<'_> {
.get_or_init(|| Arc::new(horizon_client::save::SaveTables::new(&self.pack.assets)))
.clone(),
);
encoder.mod_update_tag = Some(|pos, entity| {
let mut tag = None;
Game::native_block_entity_update_tag(pos, entity, &mut tag);
tag
});
let lan = Lan::start_with(sync.clone(), net)?;
if let Some(uuid) = host {
lan.reserve(uuid);
@@ -1736,12 +1748,226 @@ impl Game<'_> {
self.lan_object_states(host_entity, hub, out);
}
/// Apply gameplay events produced while the hub handles guest packets.
///
/// Both the pre-block packet phase and the normal LAN tick receive the
/// same event stream. Keep their side effects in one place so joins,
/// departures, chat, and queued guest commands cannot drift apart.
fn lan_process_events(
&mut self,
mut host: LanHost,
events: impl IntoIterator<Item = HostEvent>,
state: Option<&HostState>,
entities: &[EntityState],
spawn: [f64; 3],
) -> LanHost {
for event in events {
match event {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
self.lan_log_event(event);
}
HostEvent::PlayerChat { sender, text } => {
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == sender) {
if self.options.dedicated.is_some() {
super::dedicated::events::record(
"chat",
&g.name,
sender,
serde_json::json!({ "message": text }),
);
}
let conn = g.conn;
self.detect_rate_spam(&mut host, conn, false);
}
}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
{
// PlayerList.placeNewPlayer, then the broadcast join message.
super::dedicated::player_joined(g.uuid, &g.name);
super::dedicated::events::record(
"join",
&g.name,
g.uuid,
serde_json::json!({ "address": g.address.ip().to_string() }),
);
let [x, y, z] = g.position;
super::dedicated::log(
"INFO",
&format!(
"{}[/{}] logged in with entity id {} at ({}, {}, {})",
g.name,
g.address,
g.entity_id,
super::dedicated::java_double(x),
super::dedicated::java_double(y),
super::dedicated::java_double(z)
),
);
super::dedicated::log(
"INFO",
&format!("System chat: {} joined the game", g.name),
);
} else if self.player.is_some()
&& let Some(g) = host.hub.guest(conn)
&& g.placed
{
// The host's chat hears of the newcomer as the guests do.
let who = self.player_text(&g.name, g.uuid);
self.system_message(
Text::translate("multiplayer.player.joined", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Left(mut guest) => {
// The container they had open loses an opener.
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
&& !guest.spectator()
{
// In the level they were in.
let back = self.dim;
let theirs = super::Dim::from_id(&guest.dimension);
if self.enter_level(Some(&mut host), theirs).is_ok() {
self.container_openers_changed(pos, false);
}
if let Err(e) = self.enter_level(Some(&mut host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
}
self.lan_crafting_left(&mut host, &mut guest);
if guest.placed {
host.award_leave_game(guest.conn);
}
self.save_guest(&host, &guest);
host.models.remove(&guest.conn);
host.play.remove(&guest.conn);
self.guest_combat.remove(&guest.conn);
if self.options.dedicated.is_some() && guest.placed {
super::dedicated::events::record(
"leave",
&guest.name,
guest.uuid,
serde_json::json!({ "reason": guest.left_reason }),
);
super::dedicated::log(
"INFO",
&format!("{} lost connection: {}", guest.name, guest.left_reason),
);
super::dedicated::log(
"INFO",
&format!("System chat: {} left the game", guest.name),
);
} else if self.player.is_some() && guest.placed {
// ServerGamePacketListenerImpl.removePlayerFromWorld.
let who = self.player_text(&guest.name, guest.uuid);
self.system_message(
Text::translate("multiplayer.player.left", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Packet(conn, packet) => {
self.lan_guest_packet(&mut host, conn, *packet);
if !host.commands.is_empty() {
self.lan = Some(host);
self.run_guest_commands();
host = self.lan.take().expect("the hub is back after the commands");
}
}
}
}
host
}
/// Handle this level's player packets before scheduled block ticks
/// (`ServerGamePacketListenerImpl` ahead of `ServerLevel`'s block phase).
/// A sand place in the same tick as another sand's fall schedule still
/// sees the support (#4510). `tick_lan` then syncs the level; a paused
/// tick that never calls this still drains inside `tick_lan`.
pub(super) fn tick_lan_packets(&mut self) {
if self.level_pass.packets_drained {
return;
}
let Some(mut host) = self.lan.take() else {
return;
};
let holding = lan::Holding::start();
let state = self.lan_host_state(&host);
if state.is_none() && !host.hub.config.dedicated {
self.lan = Some(host);
self.level_pass.packets_drained = true;
return;
}
// Last tick's tracked entities: enough for place collision and joins
// before this tick moves anyone.
let entities = std::mem::take(&mut host.states);
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
let clock_rate = self.network_clock_rate();
let mut ctx = TickCtx {
world: &mut self.world,
host: state.as_ref(),
entities: entities.as_slice(),
next_entity: &mut self.next_entity,
spawn,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: self.level.difficulty_locked,
clock_rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
streaming: Some(&self.streaming),
};
host.hub.movement_checks = (
self.ui.rules.bool("player_movement_check"),
self.ui.rules.bool("elytra_movement_check"),
);
let pass = self.level_pass;
let mut events = if pass.first {
host.hub.poll(&mut ctx)
} else {
Vec::new()
};
events.extend(host.hub.drain_packets(&mut ctx));
events
};
host = self.lan_process_events(host, events, state.as_ref(), entities.as_slice(), spawn);
if !host.quiet.is_empty() {
self.lan = Some(host);
self.run_quiet_commands();
host = self
.lan
.take()
.expect("the hub is back after the quiet commands");
}
if !host.hub.native_menu_events.is_empty() {
self.lan = Some(host);
self.run_native_menu_events();
host = self
.lan
.take()
.expect("the hub is back after the menu events");
}
host.states = entities;
self.level_pass.packets_drained = true;
drop(holding);
self.lan = Some(host);
}
/// Run the hub for this tick and apply what the guests did. While the
/// single player game is `paused` only the connections run
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
/// skips `tickPlayer`, so no player's air, hunger or damage moves on.
pub(super) fn tick_lan(&mut self, paused: bool) {
let Some(mut host) = self.lan.take() else {
if self.level_pass.running.is_none() {
self.level_pass.packets_drained = false;
}
return;
};
// What the tick sends a guest goes to its connection at the end.
@@ -1848,12 +2074,17 @@ impl Game<'_> {
.set_immediate_respawn(self.ui.rules.bool("immediate_respawn"));
// A server with several levels runs a pass for each
// (`tick_server`): the network is read once, before the first.
// Player packets may already have been drained before
// scheduled block ticks (`tick_lan_packets`).
let pass = self.level_pass;
let mut events = if pass.first {
let mut events = if pass.first && !pass.packets_drained {
host.hub.poll(&mut ctx)
} else {
Vec::new()
};
if !pass.packets_drained {
events.extend(host.hub.drain_packets(&mut ctx));
}
events.extend(host.hub.tick_level(&mut ctx));
if pass.last {
host.hub.end_tick();
@@ -1866,124 +2097,7 @@ impl Game<'_> {
host.host_mode = state.game_mode;
host.hub.set_host_game_mode(state.uuid, state.game_mode);
}
for event in events {
match event {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
self.lan_log_event(event);
}
HostEvent::PlayerChat { sender, text } => {
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == sender) {
if self.options.dedicated.is_some() {
super::dedicated::events::record(
"chat",
&g.name,
sender,
serde_json::json!({ "message": text }),
);
}
let conn = g.conn;
self.detect_rate_spam(&mut host, conn, false);
}
}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
{
// PlayerList.placeNewPlayer, then the broadcast join message.
super::dedicated::player_joined(g.uuid, &g.name);
super::dedicated::events::record(
"join",
&g.name,
g.uuid,
serde_json::json!({ "address": g.address.ip().to_string() }),
);
let [x, y, z] = g.position;
super::dedicated::log(
"INFO",
&format!(
"{}[/{}] logged in with entity id {} at ({}, {}, {})",
g.name,
g.address,
g.entity_id,
super::dedicated::java_double(x),
super::dedicated::java_double(y),
super::dedicated::java_double(z)
),
);
super::dedicated::log(
"INFO",
&format!("System chat: {} joined the game", g.name),
);
} else if self.player.is_some()
&& let Some(g) = host.hub.guest(conn)
&& g.placed
{
// The host's chat hears of the newcomer as the guests do.
let who = self.player_text(&g.name, g.uuid);
self.system_message(
Text::translate("multiplayer.player.joined", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Left(mut guest) => {
// The container they had open loses an opener.
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
&& !guest.spectator()
{
// In the level they were in.
let back = self.dim;
let theirs = super::Dim::from_id(&guest.dimension);
if self.enter_level(Some(&mut host), theirs).is_ok() {
self.container_openers_changed(pos, false);
}
if let Err(e) = self.enter_level(Some(&mut host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
}
self.lan_crafting_left(&mut host, &mut guest);
if guest.placed {
host.award_leave_game(guest.conn);
}
self.save_guest(&host, &guest);
host.models.remove(&guest.conn);
host.play.remove(&guest.conn);
self.guest_combat.remove(&guest.conn);
if self.options.dedicated.is_some() && guest.placed {
super::dedicated::events::record(
"leave",
&guest.name,
guest.uuid,
serde_json::json!({ "reason": guest.left_reason }),
);
super::dedicated::log(
"INFO",
&format!("{} lost connection: {}", guest.name, guest.left_reason),
);
super::dedicated::log(
"INFO",
&format!("System chat: {} left the game", guest.name),
);
} else if self.player.is_some() && guest.placed {
// ServerGamePacketListenerImpl.removePlayerFromWorld.
let who = self.player_text(&guest.name, guest.uuid);
self.system_message(
Text::translate("multiplayer.player.left", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Packet(conn, packet) => {
self.lan_guest_packet(&mut host, conn, *packet);
if !host.commands.is_empty() {
self.lan = Some(host);
self.run_guest_commands();
host = self.lan.take().expect("the hub is back after the commands");
}
}
}
}
host = self.lan_process_events(host, events, state.as_ref(), entities, spawn);
host.states = states;
}
if !host.quiet.is_empty() {
@@ -2043,6 +2157,11 @@ impl Game<'_> {
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
self.flush_native_sounds();
// `tick_server` resets the pass after all dimensions. The deprecated
// in-frame path has no outer pass, so make its packet gate per-tick.
if self.level_pass.running.is_none() {
self.level_pass.packets_drained = false;
}
}
fn lan_guest_joined(
@@ -147,6 +147,9 @@ pub(super) struct GuestSwap {
selected: usize,
mode: horizon_client::command::args::GameMode,
creative: bool,
/// The host's cached interaction ranges, restored after the guest turn.
entity_reach: f64,
block_reach: f64,
/// The host's command permission, swapped for the guest's for its turn.
permission: u8,
xp: Experience,
@@ -405,6 +408,7 @@ impl Game<'_> {
std::mem::swap(&mut self.carried, &mut g.carried);
let selected = std::mem::replace(&mut self.selected, g.selected);
let (mode, creative) = (self.mode, self.creative);
let (entity_reach, block_reach) = (self.entity_reach, self.block_reach);
self.set_mode_raw(g.game_mode);
let xp = std::mem::replace(
&mut self.experience,
@@ -516,6 +520,8 @@ impl Game<'_> {
self.apply_effect_status();
}
self.update_totem_ready();
self.entity_reach = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0);
self.block_reach = self.interaction_range("minecraft:block_interaction_range", 4.5, 5.0);
Some(GuestSwap {
conn,
i,
@@ -527,6 +533,8 @@ impl Game<'_> {
selected,
mode,
creative,
entity_reach,
block_reach,
permission,
xp,
host_keys,
@@ -599,6 +607,8 @@ impl Game<'_> {
selected,
mode,
creative,
entity_reach,
block_reach,
permission,
xp,
host_keys,
@@ -795,6 +805,8 @@ impl Game<'_> {
let new_mode = self.mode_id();
self.mode = mode;
self.creative = creative;
self.entity_reach = entity_reach;
self.block_reach = block_reach;
g.selected = std::mem::replace(&mut self.selected, selected);
std::mem::swap(&mut self.inventory, &mut g.inventory);
std::mem::swap(&mut self.armor, &mut g.armor);
@@ -3363,6 +3375,13 @@ impl Game<'_> {
if w.climbable().unwrap_or(false) {
w.player.fall_distance = 0.0;
}
// ServerGamePacketListenerImpl.handlePlayerPositionChange ends the
// impulse context on LivingEntity.hasLandedInLiquid: a move into a
// liquid that does not rise, or a wind charge that lands in water
// would waive the fall after it.
let liquid = w.player.fluids.was_touching_water || w.player.is_in_lava();
let delta_y = w.player.velocity.y;
w.player.impulse.landed_in_liquid(delta_y, liquid);
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
// lit campfires and grown sweet berry bushes hurt (#1305).
w.player.shift_key_down = sneaking;
+1 -1
View File
@@ -83,7 +83,7 @@ impl Game<'_> {
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
let host = self.lan.as_ref()?;
let (eye, dir) = self.aim()?;
let reach = if self.creative { 5.0 } else { 3.0 };
let reach = self.entity_reach;
host.hub
.guests
.iter()
+4
View File
@@ -97,6 +97,8 @@ pub(super) struct Pass {
pub first: bool,
pub last: bool,
pub running: Option<bool>,
/// Set once this level's player packets were handled before block ticks.
pub packets_drained: bool,
}
impl Default for Pass {
@@ -105,6 +107,7 @@ impl Default for Pass {
first: true,
last: true,
running: None,
packets_drained: false,
}
}
}
@@ -400,6 +403,7 @@ impl Game<'_> {
first: k == 0,
last: k + 1 == n,
running,
packets_drained: false,
};
if observe_level {
self.native_level_tick_pre(dim.id());
+11 -4
View File
@@ -1054,7 +1054,7 @@ impl Game<'_> {
}
pub(super) fn cart_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64)> = None;
for (i, c) in self.carts.iter().enumerate() {
if Some(c.id) == self.riding {
@@ -1505,7 +1505,7 @@ impl Game<'_> {
))
* glam::Mat4::from_rotation_y(90f32.to_radians());
if c.kind == CartKind::Chest {
self.cart_chest(state, display, l, out);
self.special_block_model(state, display, l, out);
} else {
// TNT minecarts flash white as the fuse runs out (TntMinecartRenderer).
let swell = if c.kind == CartKind::Tnt && c.fuse > -1 {
@@ -1540,8 +1540,15 @@ impl Game<'_> {
self.cull_entity_vertices(out, entity_start);
}
}
/// The chest inside a chest minecart, drawn by the chest block-entity renderer.
fn cart_chest(&self, state: BlockStateId, m: glam::Mat4, light: u8, out: &mut Vec<Vertex>) {
/// The model of a block drawn by its block-entity renderer, as a chest
/// minecart shows its chest and a block display its special block.
pub(super) fn special_block_model(
&self,
state: BlockStateId,
m: glam::Mat4,
light: u8,
out: &mut Vec<Vertex>,
) {
let lid = |_: [i32; 3]| 0.0;
let scene = horizon_client::block_entity_render::Scene {
camera: glam::DVec3::ZERO,
+5 -3
View File
@@ -463,12 +463,14 @@ impl Game<'_> {
owner.crossbow_charge_duration = 25;
}
use horizon_assets::item_model::DisplayContext as Context;
// PandaHoldsItemLayer and FoxHeldItemLayer show the item as it lies
// on the ground.
// PandaHoldsItemLayer, FoxHeldItemLayer and DolphinCarryingItemLayer
// show the item as it lies on the ground.
let (context, ordinal) = match (mob.species(), left) {
// The spyglass held to the eye is the HEAD display.
_ if spyglass_to_eye => (Context::Head, 5),
(Species::Panda | Species::Fox | Species::Witch, _) => (Context::Ground, 7),
(Species::Panda | Species::Fox | Species::Witch | Species::Dolphin, _) => {
(Context::Ground, 7)
}
(_, true) => (Context::ThirdPersonLeftHand, 1),
(_, false) => (Context::ThirdPersonRightHand, 2),
};
+1 -1
View File
@@ -144,7 +144,7 @@ impl Game<'_> {
let team = if player {
self.score.board.players_team(&name)
} else {
self.mob_team(m.id)
self.mob_team_of(m)
};
// LivingEntityRenderer.shouldShowName (team rules included), then
// MobRenderer: a visible name, or the mob under the crosshair.
+33 -6
View File
@@ -6,6 +6,8 @@
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! API 45 sends mod block entities to clients (`native_block_entity_update_tag`).
//!
//! API 44 adds the server starting and stopping (`native_server_about_to_start`,
//! `native_server_stopping`).
//!
@@ -202,7 +204,7 @@ pub(super) use horizon_world::level::flags;
/// The hook points' API version. The launcher reads this line from the
/// source it builds; keep its form.
#[allow(dead_code)]
pub(crate) const NATIVE_HOOKS_API: u32 = 44;
pub(crate) const NATIVE_HOOKS_API: u32 = 45;
impl GameLevel<'_> {
/// An authoritative server committed a changed state through `Level.setBlock`.
@@ -862,11 +864,26 @@ impl Game<'_> {
/// data slots its menu shows (`data`). It is out of the world during the
/// call (the block entity at `pos` reads as a `Mod` with an empty id, and
/// a save leaves it out); if the call removes or replaces it, `entity` is
/// dropped. Clients, the window's own in single player included, don't
/// get it yet. API 17.
/// dropped. Clients, the window's own in single player included, get
/// what `native_block_entity_update_tag` gives when its chunk is sent or
/// its block changes. API 17.
pub(super) fn native_block_entity_tick(&mut self, pos: [i32; 3], entity: &mut ModBlockEntity) {
let _ = (pos, entity);
}
/// What clients get of the block entity at `pos` when its `id` is
/// yours (`BlockEntity.getUpdateTag`): set `tag` to it, read from
/// `entity` alone. Asked on the server whenever a chunk holding it is
/// sent and whenever its block changes, on the server thread or, for a
/// saved chunk sent past the view distance (`lan::far`), another one; a client's copy of the block
/// entity (made by `native_block_entity` there) merges it into its `nbt`.
/// Leave it unset to send nothing. API 45.
pub(super) fn native_block_entity_update_tag(
pos: [i32; 3],
entity: &ModBlockEntity,
tag: &mut Option<Compound>,
) {
let _ = (pos, entity, tag);
}
/// A menu opens on this client for block `block`, a mod's: set `screen`
/// when the block is yours, to lay its screen out (`native_screen_draw`
/// draws it). The server asks it too: a slot it hides is not in the menu
@@ -1840,7 +1857,7 @@ impl Game<'_> {
/// single-player window included, sees only the content mods' recipes, not
/// the world's data packs'. API 36.
pub(super) fn native_recipes_of_type(&self, kind: &str) -> &[CustomRecipe] {
if self.player.is_none() {
if self.outside_world() {
return &[];
}
self.recipes().custom.get(kind).map_or(&[], Vec::as_slice)
@@ -1851,7 +1868,9 @@ impl Game<'_> {
/// when nothing is left (an unknown or empty tag, for one) and outside a
/// world. API 36.
pub(super) fn native_recipe_ingredient(&self, json: &serde_json::Value) -> Option<Ingredient> {
self.player.as_ref()?;
if self.outside_world() {
return None;
}
let qualify = |s: &str| match s.strip_prefix('#') {
Some(tag) => String::from("#") + &qualified(tag),
None => qualified(s),
@@ -1879,13 +1898,21 @@ impl Game<'_> {
/// without an `id`, for an item the game doesn't know or a count outside
/// that, and outside a world. API 36.
pub(super) fn native_recipe_result(&self, json: &serde_json::Value) -> Option<ItemStack> {
self.player.as_ref()?;
if self.outside_world() {
return None;
}
let mut json = json.clone();
if let Some(id) = json["id"].as_str() {
json["id"] = qualified(id).into();
}
recipe_result(&json).filter(|s| self.items().get(&s.item).is_some())
}
/// No world is open: a client without a player (the title screen). A
/// server's `Game` (the server thread, a dedicated server, a LAN host)
/// has no player of its own, and is always in its world.
fn outside_world(&self) -> bool {
self.player.is_none() && self.options.execution_role.is_client()
}
/// Every item and recipe the game knows, made once (the same for every
/// call); none outside a world. They are the window's own: the world's
/// in single player, vanilla's on a server, which sends a client no
@@ -457,6 +457,11 @@ impl Game<'_> {
/// `Window.updateFullscreenState`: a fullscreen change made by the window
/// system is adopted by the option (`Minecraft.fullscreenStateChanged`).
pub(super) fn observe_fullscreen(&mut self) {
// HDR calibration's own fullscreen is not the player's option: it
// goes back to what it was when the calibration ends.
if self.ui.options.calibration_fullscreen.is_some() {
return;
}
let Some(w) = &self.window else { return };
if let Some(now) = self.fullscreen.observe(w.fullscreen().is_some())
&& self.settings.fullscreen != now
+1 -1
View File
@@ -192,7 +192,7 @@ impl Game<'_> {
/// The painting under the crosshair within reach and its distance.
pub(super) fn painting_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64)> = None;
for (i, p) in self.paintings.iter().enumerate() {
let (lo, hi) = p.bounds();
@@ -126,7 +126,7 @@ impl Game<'_> {
let d2: f64 = (0..3)
.map(|a| (eye[a] - eye[a].clamp(b.0[a], b.1[a])).powi(2))
.sum();
let range = if self.creative { 5.0 } else { 3.0 } + 3.0;
let range = self.entity_reach + 3.0;
d2 < range * range
}
}
+37 -10
View File
@@ -1844,10 +1844,21 @@ impl Game<'_> {
}
return;
}
let Some(entity) = self.world.block_entities.get(&key) else {
let Tag::Compound(fields) = tag else {
return;
};
let Tag::Compound(fields) = tag else {
// A mod block entity's type is named after its block (`mod_content`):
// its update tag merges into what the client keeps of it (`handleUpdateTag`).
// Nothing is drawn from it yet, so its section stays as it is.
let block = self.world.block(pos[0], pos[1], pos[2]);
if let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&key) {
if *self.res.facts().get(block).name == *name {
m.nbt
.extend(fields.iter().map(|(k, v)| (k.clone(), v.clone())));
}
return;
}
let Some(entity) = self.world.block_entities.get(&key) else {
return;
};
if entity.id() != name || fields.is_empty() {
@@ -1946,15 +1957,23 @@ impl Game<'_> {
}
}
/// Keep the renderable block entities (chests, furnaces) in step with blocks.
pub(super) fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) {
/// `previous`: the block's state before, when it changed.
pub(super) fn sync_block_entity(
&mut self,
pos: (i32, i32, i32),
previous: Option<BlockStateId>,
state: BlockStateId,
) {
let name = self.res.facts().get(state).name.clone();
match BlockEntity::for_block(&name) {
// LevelChunk.setBlockState: another block's block entity goes, even
// one of the same type (a mod's blocks may share one).
let other_block = previous.is_some_and(|p| self.res.facts().get(p).name != name);
// A mod block's is the mod's own (`native_block_entity`), as on the server.
match self.new_block_entity(&name) {
Some(entity) => {
let same = self
.world
.block_entities
.get(&pos)
.is_some_and(|e| e.id() == entity.id());
let same = self.world.block_entities.get(&pos).is_some_and(|e| {
e.id() == entity.id() && !(other_block && matches!(e, BlockEntity::Mod(_)))
});
if !same {
self.world.block_entities.insert(pos, entity);
}
@@ -2006,7 +2025,7 @@ impl Game<'_> {
if self.world.loaded(pos[0], pos[2])
&& self.world.set(pos[0], pos[1], pos[2], state).is_ok()
{
self.sync_block_entity((pos[0], pos[1], pos[2]), state);
self.sync_block_entity((pos[0], pos[1], pos[2]), Some(previous), state);
// LevelChunk.setBlockState removes the block entity of a block
// that changes: the moving piston the client's moveBlocks made
// stops when the server places what it moved (#3002).
@@ -3360,6 +3379,10 @@ impl Game<'_> {
if p.event == 21 {
self.start_guardian_sound(p.id as u32);
}
// Witch.handleEntityEvent 15: the magic puffs over its hat.
if p.event == 15 {
self.witch_magic_particles(p.id as u32);
}
// Fox.handleEntityEvent 45: the crumbs of the food in its mouth.
if p.event == 45 {
self.fox_eat_particles(p.id as u32);
@@ -4994,6 +5017,10 @@ impl Game<'_> {
self.border.tick();
self.player_border();
self.tick_remote_entities(running);
// Breeze.tick's block dust: drawn by each client for the breezes it sees.
if running {
self.tick_breeze_effects();
}
self.tick_remote_guardian_eyes(running);
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells,
// and the moving pistons a block event's client-side moveBlocks made
@@ -293,7 +293,7 @@ impl Game<'_> {
z * 16 + i32::from(info.packed_xz as u8 & 15),
);
let state = self.world.block(pos.0, pos.1, pos.2);
self.sync_block_entity(pos, state);
self.sync_block_entity(pos, None, state);
// LevelChunk.replaceWithPacketData: the block entity loads its update tag.
self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag);
}
@@ -6,7 +6,7 @@ use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{
Mob, Species, Who, armadillo, bat, bee, camel, creaking, equine, finalize, illusioner, panda,
sniffer, sync, tame, zombie_villager,
phantom, sniffer, sync, tame, zombie_villager,
};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
@@ -165,6 +165,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
(Species::SulfurCube, 19, Value::Bool(from_bucket)) => {
m.extra.scratch[3] = i32::from(*from_bucket);
}
// Phantom.ID_SIZE: `updatePhantomSizeInfo` grows its box and bite,
// and `PhantomRenderer.scale` its model.
(Species::Phantom, 16, Value::Int(size)) => phantom::set_size(m, *size),
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
// and raised head of an angry enderman.
(Species::Enderman, 17, Value::Bool(creepy)) => m.extra.flag = *creepy,
@@ -889,6 +892,29 @@ mod tests {
assert_eq!(m.profile, Some(profile));
}
/// `LivingEntity.getDimensions`: the pose's box times `minecraft:scale` —
/// a lobby's giant fake player is clickable on its visible body, not only
/// on the unscaled box at its feet.
#[test]
fn a_scaled_fake_player_gets_a_scaled_box() {
for kind in ["minecraft:player", "minecraft:mannequin"] {
let Some(mut m) = mob(kind) else {
continue;
};
let (w, h, e) = m.dims();
assert!(
(w - 0.6).abs() < 1e-6 && (h - 1.8).abs() < 1e-6 && (e - 1.62).abs() < 1e-5,
"{kind}: {w}x{h}x{e}"
);
m.extra.model_scale = 4.0;
let (w, h, e) = m.dims();
assert!(
(w - 2.4).abs() < 1e-6 && (h - 7.2).abs() < 1e-6 && (e - 6.48).abs() < 1e-5,
"{kind} scaled: {w}x{h}x{e}"
);
}
}
/// #4548: SulfurCube.MAX_FUSE copies into the fuse the client ticks for swell.
#[test]
fn sulfur_cube_max_fuse_sets_the_client_fuse() {
@@ -55,6 +55,35 @@ impl Game<'_> {
self.particles.add(&options, p, v);
}
}
/// `Witch.handleEntityEvent(15)`: `nextInt(35) + 10` purple puffs about
/// half a block over its head; the count is drawn again every pass, as
/// the Java loop condition does.
pub(super) fn witch_magic_particles(&mut self, id: u32) {
let Some(m) = self
.mobs
.iter_mut()
.find(|m| m.id == id && m.species() == Species::Witch)
else {
return;
};
let (x, z) = (m.position.x, m.position.z);
let top = m.bounds().max_position().y;
let spread = f64::from(0.13_f32);
let mut spawned = Vec::new();
let mut i = 0;
while i < m.random.next_int_bound(35).unwrap_or(0) + 10 {
spawned.push([
x + m.random.next_gaussian() * spread,
top + 0.5 + m.random.next_gaussian() * spread,
z + m.random.next_gaussian() * spread,
]);
i += 1;
}
let options = ParticleOptions::simple(ParticleType::Witch);
for p in spawned {
self.particles.add(&options, p, [0.0, 0.0, 0.0]);
}
}
/// `Ravager.handleEntityEvent(69)`: forty puffs of smoke from the middle
/// of its box, then `applyRoarKnockbackClient`: this client's own player
/// within 4 blocks is thrown away from it.
@@ -73,7 +73,7 @@ impl Game<'_> {
}
// Interaction.isPickable: an invisible box the server clicks through
// to its scripts, for both uses and attacks.
let reach = if self.creative { 5.0 } else { 3.0 };
let reach = self.entity_reach;
for (&id, i) in &self.interactions {
if let Some(d) = ray_box(eye, dir, reach, i.bounds()) {
let p = i.position;
@@ -84,7 +84,7 @@ impl Game<'_> {
// EndCrystal.isPickable: its 2 x 2 box, with the server's entity
// id, not special_targets' local projectile target id.
// Entity.interact passes on a crystal, so right clicks use the item.
let reach = if self.creative { 5.0 } else { 3.0 };
let reach = self.entity_reach;
for c in self.crystals.iter().filter(|c| c.id != 0) {
let p = c.position;
let bounds = Aabb::new(
+41 -7
View File
@@ -18,7 +18,7 @@ use horizon_client::{
text::Text,
};
use horizon_world::nbt::Tag;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
/// What `ServerPlayer` last recorded for the criteria that follow the player.
#[derive(Default)]
@@ -52,6 +52,10 @@ fn pet_team<'a>(board: &'a Scoreboard, own: &str, owner: Option<&str>) -> Option
.players_team(own)
.or_else(|| board.players_team(owner?))
}
/// A tamed pet's owner (`TamableAnimal.getOwnerReference` while `isTame`).
fn tame_owner(m: &horizon_client::mobs::Mob) -> Option<[i32; 4]> {
m.extra.tame.then_some(m.extra.owner).flatten()
}
/// `TamableAnimal.getRootOwner`: the owner, or that owner's own root owner
/// while it is a tamed pet itself. `pet_owner` is the owner of the tamed pet
/// with a UUID, if it has one.
@@ -496,13 +500,34 @@ impl Game<'_> {
/// `TamableAnimal.getTeam` of a mob or another player: its own team, else,
/// when it is a tamed pet, its root owner's.
pub(super) fn mob_team(&self, id: u32) -> Option<&sb::Team> {
if self.score.board.teams.is_empty() {
return None;
}
// The mob is looked up only when its own name has no team: the
// owner's team is the fallback.
self.team_with_owner(id, || {
self.mobs.iter().find(|m| m.id == id).and_then(tame_owner)
})
}
/// [`mob_team`](Self::mob_team) of a mob at hand, without looking it up.
pub(super) fn mob_team_of(&self, m: &horizon_client::mobs::Mob) -> Option<&sb::Team> {
if self.score.board.teams.is_empty() {
return None;
}
self.team_with_owner(m.id, || tame_owner(m))
}
/// A mob's own team, else its tamed owner's (`owner` is asked only then).
fn team_with_owner(
&self,
id: u32,
owner: impl FnOnce() -> Option<[i32; 4]>,
) -> Option<&sb::Team> {
let uuid = *self.persist.uuids.get(&id)?;
let own = self.scoreboard_name(id, uuid);
let owner = self
.mobs
.iter()
.find(|m| m.id == id && m.extra.tame)
.and_then(|m| self.root_owner_name(m.extra.owner?));
if let Some(team) = self.score.board.players_team(&own) {
return Some(team);
}
let owner = owner().and_then(|o| self.root_owner_name(o));
pet_team(&self.score.board, &own, owner.as_deref())
}
/// `TamableAnimal.getRootOwner().getScoreboardName`: a pet's owner, through
@@ -534,13 +559,22 @@ impl Game<'_> {
if self.score.board.teams.is_empty() {
return Default::default();
}
// One pass over the mobs, not one per mob.
let tame_owners: HashMap<u32, [i32; 4]> = self
.mobs
.iter()
.filter_map(|m| Some((m.id, tame_owner(m)?)))
.collect();
horizon_client::mobs::Teams {
player: self.player_team(),
mobs: self
.persist
.uuids
.keys()
.filter_map(|id| Some((*id, self.mob_team(*id)?)))
.filter_map(|id| {
let owner = || tame_owners.get(id).copied();
Some((*id, self.team_with_owner(*id, owner)?))
})
.collect(),
}
}
@@ -2131,6 +2131,10 @@ impl Game<'_> {
lines.push((random.with_color(horizon_client::text::GRAY), u32::MAX));
}
}
// TropicalFish.Pattern.addToTooltip.
for text in horizon_client::item_tooltip::tropical_fish_lines(stack, &display) {
lines.push((text, gray));
}
// InstrumentComponent.addToTooltip.
if let Some(text) =
horizon_client::item_tooltip::instrument_line(stack, &display, &translate)
@@ -299,6 +299,20 @@ impl Game<'_> {
if let Some(state) = display.block {
// BlockModelResolver.update: model seed 42.
self.block_model_vertices(state, 42, matrix, light, true, out);
// CompositeBlockModel.update: the special model of a chest,
// head, banner and the like comes on top of the block model.
if self
.res
.facts()
.get(state)
.name
.strip_prefix("minecraft:")
.is_some_and(has_special_model)
{
let packed =
((((light & 0xFF) / 16) << 4) | (((light >> 8) & 0xFF) / 16)) as u8;
self.special_block_model(state, matrix, packed, out);
}
} else if let Some(stack) = &display.item {
let context = match display.item_display {
1 => DisplayContext::ThirdPersonLeftHand,
@@ -389,6 +403,29 @@ impl Game<'_> {
}
}
/// Whether vanilla's built-in block models give this block a special model,
/// drawn by its block-entity renderer.
fn has_special_model(block: &str) -> bool {
matches!(
block,
"bell"
| "conduit"
| "decorated_pot"
| "enchanting_table"
| "end_gateway"
| "end_portal"
| "chest"
| "trapped_chest"
| "ender_chest"
| "shulker_box"
) || block.ends_with("_banner")
|| block.ends_with("_shulker_box")
|| block.ends_with("_skull")
|| (block.ends_with("_head") && block != "piston_head")
|| block.contains("copper_chest")
|| block.contains("copper_golem_statue")
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -499,7 +499,7 @@ impl Game<'_> {
eye[1] - eye[1].clamp(lo.y, hi.y),
eye[2] - eye[2].clamp(lo.z, hi.z),
];
let range = if self.creative { 5.0 } else { 3.0 } + 4.0;
let range = self.entity_reach + 4.0;
m.alive() && d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
});
if !valid {
+78
View File
@@ -1,5 +1,6 @@
//! Item tooltip formatting: equipment modifiers with a player's base values,
//! and lines without player context for the recipe book's search tree.
use crate::text::{GRAY, Text};
use crate::{
hud::rgba,
items::{ItemInfo, ItemStack},
@@ -300,6 +301,83 @@ pub fn instrument_line(
Some(translate(&key).unwrap_or(key))
}
/// `TropicalFish.COMMON_VARIANTS`: pattern, base colour and pattern colour of
/// the 22 named fish (`entity.minecraft.tropical_fish.predefined.<index>`).
const COMMON_TROPICAL_FISH: [(&str, &str, &str); 22] = [
("stripey", "orange", "gray"),
("flopper", "gray", "gray"),
("flopper", "gray", "blue"),
("clayfish", "white", "gray"),
("sunstreak", "blue", "gray"),
("kob", "orange", "white"),
("spotty", "pink", "light_blue"),
("blockfish", "purple", "yellow"),
("clayfish", "white", "red"),
("spotty", "white", "yellow"),
("glitter", "white", "gray"),
("clayfish", "white", "orange"),
("dasher", "cyan", "pink"),
("brinely", "lime", "light_blue"),
("betty", "red", "white"),
("snooper", "gray", "red"),
("blockfish", "red", "white"),
("flopper", "white", "yellow"),
("kob", "red", "white"),
("sunstreak", "gray", "white"),
("dasher", "cyan", "yellow"),
("flopper", "yellow", "yellow"),
];
/// `TropicalFish.Pattern.addToTooltip`: a bucket of tropical fish names a
/// common variant, or else shows the pattern and then the base colour (with
/// the pattern colour when it differs), all in italic gray. Only a stack that
/// has the pattern component shows it.
pub fn tropical_fish_lines(stack: &ItemStack, display: &TooltipDisplay) -> Vec<Text> {
if !display.shows("minecraft:tropical_fish/pattern") {
return Vec::new();
}
let Some(data) = stack.data() else {
return Vec::new();
};
let name = |key: &str, default: &'static str| match data.other(key) {
Some(Tag::String(s)) => s
.to_string_lossy()
.trim_start_matches("minecraft:")
.to_owned(),
_ => default.to_owned(),
};
let Some(Tag::String(pattern)) = data.other("minecraft:tropical_fish/pattern") else {
return Vec::new();
};
let pattern = pattern
.to_string_lossy()
.trim_start_matches("minecraft:")
.to_owned();
let base = name("minecraft:tropical_fish/base_color", "white");
let color = name("minecraft:tropical_fish/pattern_color", "white");
let styled = |t: Text| t.italic().with_color(GRAY);
let common = COMMON_TROPICAL_FISH
.iter()
.position(|(p, b, c)| *p == pattern && *b == base && *c == color);
if let Some(index) = common {
let key = format!("entity.minecraft.tropical_fish.predefined.{index}");
return vec![styled(Text::translate(&key, &[]))];
}
let mut colors = Text::translate(&format!("color.minecraft.{base}"), &[]);
if base != color {
colors = colors
.append(", ")
.append(Text::translate(&format!("color.minecraft.{color}"), &[]));
}
vec![
styled(Text::translate(
&format!("entity.minecraft.tropical_fish.type.{pattern}"),
&[],
)),
styled(colors),
]
}
/// `MapId.addToTooltip`: a map whose data is not known (`map` is `None`, else
/// its `locked` flag and `scale`) reads "Unknown Map"; a known one gives its
/// id (unless it has a custom name or is to be locked or scaled), "Locked"
+104 -10
View File
@@ -3,7 +3,7 @@
//! block DataLayers) and the section block-change batches.
use crate::{
chunk::{Chunk, ColumnLayout, LevelShape, Light, Section},
containers::BlockEntity,
containers::{BlockEntity, ModBlockEntity},
fast_hash::FxHashMap,
mobs::spawner::{SpawnDataExt, SpawnerExt},
section_mask::SectionMask,
@@ -16,7 +16,7 @@ use horizon_protocol::{
packets::play::{ChunkBiomeData, LevelChunkWithLight},
registry::{Builtin, ceil_log2},
};
use horizon_world::nbt::Tag;
use horizon_world::nbt::{Compound, Tag};
use std::{
collections::HashMap,
sync::{
@@ -29,6 +29,10 @@ const NON_AIR: u8 = 1;
const FLUID: u8 = 2;
const ENTITY: u8 = 4;
/// A mod block entity's update tag (`BlockEntity.getUpdateTag`), what
/// clients get of it; none sends no tag.
pub type ModUpdateTag = fn([i32; 3], &ModBlockEntity) -> Option<Compound>;
/// The vanilla block entity type of a block (BlockEntityType.validBlocks).
pub fn block_entity_type(name: &str) -> Option<&'static str> {
let n = name.strip_prefix("minecraft:")?;
@@ -95,7 +99,12 @@ pub struct ChunkEncoder {
/// What encoding a section needs, shared with the warm-up threads.
coder: Arc<SectionCoder>,
/// Block entity type id per state (-1 for none).
entity_types: Vec<i16>,
entity_types: Vec<i32>,
/// Whether the state is a mod block's: its type is the block's own, and
/// it has a block entity only when the mod made one.
mod_entities: Vec<bool>,
/// The mods' update tags (`native_block_entity_update_tag`).
pub mod_update_tag: Option<ModUpdateTag>,
/// The block registry id of each state's block, for BlockEvent.
pub block_ids: Vec<i32>,
/// The save's tables, for the heightmaps.
@@ -410,6 +419,7 @@ impl ChunkEncoder {
let blocks = builtin.get("minecraft:block");
let mut flags = Vec::new();
let mut entity_types = Vec::new();
let mut mod_entities = Vec::new();
let mut block_ids = Vec::new();
for (name, has_fluid) in states {
let air = matches!(
@@ -423,14 +433,22 @@ impl ChunkEncoder {
f |= FLUID;
}
}
let t = block_entity_type(&name)
.and_then(|t| types.and_then(|ids| ids.id(t)))
.map_or(-1, |v| v as i16);
// A mod block's block entity type is named after the block
// (`mod_content`: registries.json).
let modded = !name.starts_with("minecraft:");
let t = if modded {
Some(&*name)
} else {
block_entity_type(&name)
}
.and_then(|t| types.and_then(|ids| ids.id(t)))
.unwrap_or(-1);
if t >= 0 {
f |= ENTITY;
}
flags.push(f);
entity_types.push(t);
mod_entities.push(modded && t >= 0);
block_ids.push(blocks.and_then(|b| b.id(&name)).unwrap_or(0));
}
let biome_ids = biome_names
@@ -449,6 +467,8 @@ impl ChunkEncoder {
biome_bits: ceil_log2(biome_registry.len()),
}),
entity_types,
mod_entities,
mod_update_tag: None,
block_ids,
tables: None,
cache: Default::default(),
@@ -563,7 +583,18 @@ impl ChunkEncoder {
self.entity_types
.get(state as usize)
.filter(|t| **t >= 0)
.map(|t| i32::from(*t))
.copied()
}
/// The update tag of the block entity at `pos` (`getUpdateTag`): a
/// mod's from its hook, a vanilla one's from [`entity_tag`].
pub fn update_tag(&self, pos: (i32, i32, i32), entity: &BlockEntity) -> Option<Tag> {
match entity {
BlockEntity::Mod(m) => self
.mod_update_tag
.and_then(|tag| tag([pos.0, pos.1, pos.2], m))
.map(Tag::Compound),
_ => entity_tag(entity),
}
}
/// Whether the state holds a fluid (water, lava, or a waterlogged block).
pub fn has_fluid(&self, state: u32) -> bool {
@@ -651,6 +682,8 @@ impl ChunkEncoder {
Self {
coder: self.coder.clone(),
entity_types: self.entity_types.clone(),
mod_entities: self.mod_entities.clone(),
mod_update_tag: self.mod_update_tag,
block_ids: self.block_ids.clone(),
tables: self.tables.clone(),
cache: Default::default(),
@@ -914,15 +947,21 @@ impl ChunkEncoder {
);
let wy = chunk.min_y() + (i as i32) * 16 + y;
let pos = (chunk.x * 16 + x, wy, chunk.z * 16 + z);
let entity = block_entities.get(&pos);
// A mod block whose mod made no block entity has none to send.
if self.mod_entities[state as usize] && !matches!(entity, Some(BlockEntity::Mod(_)))
{
continue;
}
entity_infos.push(BlockEntityInfo {
packed_xz: ((x << 4) | z) as i8,
y: wy as i16,
kind,
// BlockEntityInfo.create: an empty update tag is not sent.
tag: block_entities
.get(&pos)
.and_then(entity_tag)
tag: entity
.and_then(|e| self.update_tag(pos, e))
.or_else(|| pot_tag(chunk, pos))
.filter(|t| !matches!(t, Tag::Compound(c) if c.is_empty()))
.unwrap_or(Tag::End),
});
}
@@ -1285,6 +1324,7 @@ fn write_light(lights: &[(usize, SectionLight)], sky: bool, w: &mut Writer) {
mod tests {
use super::*;
use crate::chunk::{MIN_Y, SECTIONS};
use horizon_world::nbt::JavaString;
#[test]
fn block_entity_blocks_are_recognised() {
@@ -1370,6 +1410,60 @@ mod tests {
assert_eq!(sent(&chunk), scan(&chunk));
}
/// A mod block's block entity goes out with its block's own type and the
/// tag its mod gives; a mod block whose mod made none is left out.
#[test]
fn mod_block_entities_carry_their_blocks_type_and_the_mods_tag() {
let builtin = Builtin::parse(
r#"{"minecraft:block_entity_type": {"entries": {
"minecraft:chest": {"protocol_id": 1},
"test:machine": {"protocol_id": 49},
"test:casing": {"protocol_id": 50}}}}"#,
)
.unwrap();
let states =
["minecraft:air", "test:machine", "test:casing"].map(|n| (Arc::<str>::from(n), false));
let mut encoder = ChunkEncoder::new(states, &builtin, &[], &[]);
encoder.mod_update_tag = Some(|pos, entity| {
let mut tag = Compound::new();
tag.insert(
"active".into(),
entity.nbt.get(&JavaString::from("active"))?.clone(),
);
tag.insert("x".into(), Tag::Int(pos[0]));
Some(tag)
});
let mut chunk = Chunk::empty(0, 0);
chunk.set(1, MIN_Y + 2, 3, 1);
chunk.set(5, MIN_Y + 2, 5, 2);
let mut machine = ModBlockEntity {
id: "test:machine".into(),
..ModBlockEntity::default()
};
machine.nbt.insert("active".into(), Tag::Byte(1));
machine.nbt.insert("recipe".into(), Tag::Int(7));
let block_entities =
HashMap::from([((1, MIN_Y + 2, 3), BlockEntity::Mod(Box::new(machine)))]);
let infos = encoder.encode(&chunk, &block_entities).chunk.block_entities;
assert_eq!(infos.len(), 1, "the casing has no block entity");
assert_eq!((infos[0].kind, infos[0].y), (49, (MIN_Y + 2) as i16));
let mut expected = Compound::new();
expected.insert("active".into(), Tag::Byte(1));
expected.insert("x".into(), Tag::Int(1));
assert_eq!(infos[0].tag, Tag::Compound(expected));
// As does an encoder for saved chunks past the view distance (`lan::far`).
let far = encoder.detached().encode(&chunk, &block_entities);
assert_eq!(far.chunk.block_entities[0].tag, infos[0].tag);
// An empty tag is not sent either (BlockEntityInfo.create).
encoder.mod_update_tag = Some(|_, _| Some(Compound::new()));
let infos = encoder.encode(&chunk, &block_entities).chunk.block_entities;
assert_eq!(infos[0].tag, Tag::End);
// Without the hook, nothing of it is sent.
encoder.mod_update_tag = None;
let infos = encoder.encode(&chunk, &block_entities).chunk.block_entities;
assert_eq!(infos[0].tag, Tag::End);
}
fn encoder() -> ChunkEncoder {
let states = ["minecraft:air", "minecraft:stone", "minecraft:water"]
.map(|n| (Arc::<str>::from(n), n.ends_with("water")));
+16 -5
View File
@@ -1409,10 +1409,12 @@ impl Hub {
events
}
/// The level in place's part of the tick (`ServerLevel.tick`'s chunk
/// map and the connections of the players in it): their packets, then
/// the chunks, block changes, containers and entities they see.
pub fn tick_level(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
/// Handle the players in this level's packets (`ServerGamePacketListenerImpl`
/// for each), before the level's scheduled block ticks so a place landed in
/// the same tick as a falling block's schedule still sees the support
/// (#4510). `tick_level` then syncs what those packets (and the block ticks)
/// changed.
pub fn drain_packets(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
let mut events = Vec::new();
for i in 0..self.guests.len() {
if self.here(&self.guests[i]) {
@@ -1420,6 +1422,15 @@ impl Hub {
self.tick_floating(i, &mut events);
}
}
events
}
/// The level in place's part of the tick (`ServerLevel.tick`'s chunk
/// map and the connections of the players in it): the chunks, block
/// changes, containers and entities they see. Player packets are drained
/// earlier (`drain_packets`) so scheduled ticks see what they placed.
pub fn tick_level(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
let events = Vec::new();
if !self.guests.iter().any(|g| self.here(g)) {
// ServerTickRateManager broadcasts a change to the players there
// are; one who joins later is sent the state as it is then
@@ -1945,7 +1956,7 @@ impl Hub {
for &(x, y, z) in &positions {
// A decorated pot is no block entity: its tag is the chunk's saved one.
let tag = match world.block_entities.get(&(x, y, z)) {
Some(entity) => super::chunks::entity_tag(entity),
Some(entity) => self.encoder.update_tag((x, y, z), entity),
None => world
.chunks
.get(&(x >> 4, z >> 4))
+59 -2
View File
@@ -192,6 +192,9 @@ impl Replay {
mut run: impl FnMut(&mut PlayerMotion, Keys, bool) -> crate::Result<()>,
) -> Option<Over> {
let mut over = Over::default();
// A tick of this catch-up already counted: the client has been displaced by its lie, so a
// tick that agrees from there must not replace the setback point (#5132).
let mut counted = false;
let mut changes = record.changes_since(self.applied)?.peekable();
let new = usize::try_from(ended.saturating_sub(self.played)).ok()?;
for tick in record.since(self.played)?.take(new) {
@@ -215,9 +218,14 @@ impl Replay {
|| record.unseen_near(tick.seen, |block| near(at, block, slack))
};
if offset <= AGREED {
(self.agreed, self.drift) = (self.said, 0);
if !counted {
self.agreed = self.said;
}
self.drift = 0;
} else if offset <= tolerance {
self.agreed = self.said;
if !counted {
self.agreed = self.said;
}
self.drift = self.drift.saturating_sub(1);
} else if self.drift == 0 && unseen() {
self.excuse();
@@ -225,6 +233,7 @@ impl Replay {
self.drift -= 1;
self.agreed = self.said;
} else {
counted = true;
over.ticks += 1;
over.worst = over.worst.max(offset);
over.weight += (offset / tolerance).min(MOST_WEIGHT) as f32;
@@ -474,6 +483,9 @@ mod tests {
/// From tick `.0` on, the ticks come in a burst that the server only plays at tick `.1`,
/// when the client accepts a teleport back to where the burst began.
teleport: Option<(usize, usize)>,
/// Ticks `.0 .. .1` arrive together: the server plays them all at tick `.1`, in one
/// catch-up, as it does when its tick lags the client's.
burst: Option<(usize, usize)>,
/// Blocks set in the scene after the floor.
blocks: Vec<([i32; 3], &'static str)>,
}
@@ -487,6 +499,7 @@ mod tests {
pitch: 0.0,
glider: false,
teleport: None,
burst: None,
blocks: Vec::new(),
}
}
@@ -564,6 +577,7 @@ mod tests {
let mut replay = Replay::new(&server, &record.take_restart().unwrap());
let mut played = Vec::new();
let run_teleport = run.teleport;
let run_burst = run.burst;
let mut back = start;
for (i, (input, yaw)) in run.script.into_iter().enumerate() {
let tick = i as u64;
@@ -590,6 +604,10 @@ mod tests {
let ground = u8::from(client.ground.on_ground);
record.moved(Some([p.x, p.y, p.z]), Some((yaw, run.pitch)), ground);
record.tick_end(input, client.sprinting);
// A burst: the server hears these ticks now and plays them together at the last one.
if run_burst.is_some_and(|(from, to)| (from..to).contains(&i)) {
continue;
}
let mut run = |body: &mut PlayerMotion, keys, sprinting| {
let mut result = Ok(());
world(body, &mut |w| result = w.replayed_tick(keys, sprinting));
@@ -697,6 +715,44 @@ mod tests {
}
}
/// Ticks that arrive together when the server's tick lags the client's are played in one
/// catch-up: a lie on the first makes the replay stand at the client's spot, and the honest
/// tick after it must not become the setback point (#5132).
#[test]
fn a_burst_keeps_the_setback_before_its_lie() {
let Some(steps) = steps_in(
Run {
burst: Some((20, 21)),
..Run::walk()
},
|i, _, honest| {
if i == 20 {
honest.add_vector(Vec3::new(3.0, 0.0, 0.0))
} else {
honest
}
},
None,
) else {
return;
};
// Ticks 0-19 each play on their own; tick 20's lie arrives with tick 21, so the burst's
// single step is the 21st: the lie counts once, and the setback point stays where the walk
// agreed, before it.
assert_eq!(
steps.len(),
script().len() - 1,
"one catch-up for the burst"
);
let burst = &steps[20];
assert_eq!(burst.over.ticks, 1, "{:?}", burst.over);
assert!(burst.over.worst > 2.9, "{:?}", burst.over);
assert_eq!(
burst.agreed, steps[19].said,
"the setback point must be the last honest spot"
);
}
/// A push the server never sent (another entity's): excused, the replay takes the client's
/// velocity, is back within the tolerance in a few ticks, and nothing counts.
#[test]
@@ -735,6 +791,7 @@ mod tests {
pitch: 10.0,
glider: true,
teleport: None,
burst: None,
blocks: Vec::new(),
};
let Some(steps) = steps_in(run, |_, _, honest| honest, None) else {
+15
View File
@@ -1170,6 +1170,12 @@ pub enum MobEvent {
mob: u32,
at: [f64; 3],
},
/// `Mob.burnUndead` in daylight on a mob wearing something in its
/// `sunProtectionSlot`: the item loses up to 1 durability (the game knows
/// the item data) instead of the mob catching fire.
SunProtectionWear {
mob: u32,
},
/// `Level.broadcastEntityEvent` for a mob: guests run its
/// `handleEntityEvent`.
EntityEvent {
@@ -1449,6 +1455,15 @@ pub enum MobEvent {
count: u32,
at: [f64; 3],
},
/// `Goat.dropHorn`: a horn from goat `mob` (`Goat.createHorn` picks the
/// instrument from its UUID), thrown with `motion` as
/// `new ItemEntity(.., deltaX, deltaY, deltaZ)`.
DropGoatHorn {
mob: u32,
screaming: bool,
at: [f64; 3],
motion: [f64; 3],
},
/// `Mob.dropFromGiftLootTable`: a gift loot table's items dropped at `at`.
DropGift {
table: &'static str,
+14 -6
View File
@@ -593,7 +593,7 @@ pub fn tick(
// Mob.aiStep still runs without AI (only serverAiStep is skipped),
// so undead still burn in daylight and the endermite's life keeps
// counting.
burn_undead(m, w);
burn_undead(m, w, events);
if m.species() == Species::Endermite {
endermite_lifetime(m);
}
@@ -1303,7 +1303,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
}
}
}
burn_undead(m, w);
burn_undead(m, w, events);
species_ai_step(m, w, level_random, events);
}
impl Mob {
@@ -1424,9 +1424,13 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
/// `Mob.aiStep` -> `Mob.burnUndead`: undead burn in daylight. It runs with
/// or without AI (`NoAI` only skips `serverAiStep`).
fn burn_undead(m: &mut Mob, w: &MobWorld) {
if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) && m.extra.head.is_none() {
m.ignite_for_ticks(160);
fn burn_undead(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) {
if m.extra.head.is_some() {
events.push(MobEvent::SunProtectionWear { mob: m.id });
} else {
m.ignite_for_ticks(160);
}
}
}
fn sun_burn_tick(m: &mut Mob, w: &MobWorld) -> bool {
@@ -1554,7 +1558,10 @@ fn armadillo_server_ai_step(m: &mut Mob, mob_drops: bool, events: &mut Vec<MobEv
}
fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
match m.species() {
Species::Rabbit => super::rabbit::server_ai_step(m),
Species::Rabbit => {
let target = m.target_info(cx.w).map(|t| t.position);
super::rabbit::server_ai_step(m, target)
}
Species::Cat | Species::Ocelot => super::tame::feline_server_ai_step(m),
// Armadillo.customServerAiStep: the brain and its activity, then the scute.
Species::Armadillo => {
@@ -1584,6 +1591,7 @@ fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
Species::Frog => super::frog::server_ai_step(m, cx.w),
// Hoglin and Piglin.customServerAiStep: the brain, then updateActivity.
Species::Piglin | Species::Hoglin => super::piglin::brain_step(m, cx),
Species::Zoglin => super::piglin::zoglin_step(m, cx),
_ => {}
}
}
+14 -10
View File
@@ -7,7 +7,7 @@ use super::{
control::MoveKind,
goals::{
Avoid, AvoidEntity, BreathAir, Cx, Goal, HurtByTarget, LookAtPlayer, MeleeAttack, Panic,
RandomLookAround, Stroll, TryFindWater, goal_clone, reduced,
RandomLookAround, Stroll, Tempt, TryFindWater, goal_clone, reduced,
},
nav::NavKind,
};
@@ -121,10 +121,13 @@ pub fn register(m: &mut Mob) {
super::animal_brain::init(m, i64::from(m.id));
}
S::Tadpole => {
// TadpoleAi: panic, swim around and look about.
// `TadpoleAi.initIdleActivity`: panic 2.0, follow a player holding
// frog food at 1.25 and swim around at 0.5 (not 1.0).
let food = m.kind.food.clone();
let g = &mut m.ai.goals;
g.add(0, Panic::new(2.0));
g.add(4, Stroll::swimming(1.0, 40));
g.add(1, Tempt::new(1.25, food, false));
g.add(4, Stroll::swimming(0.5, 40));
m.ai.mov.kind = MoveKind::Custom;
m.ai.nav.kind = NavKind::Water { breach: false };
}
@@ -249,13 +252,14 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::Sound("entity.tadpole.grow_up", at, 0.15, 1.0));
}
events.push(MobEvent::SpawnKind {
kind: "minecraft:frog".into(),
at: [m.position.x, m.position.y, m.position.z],
setup: super::SpawnSetup {
yaw: m.yaw,
persistent: true,
..Default::default()
// `ConversionParams.single(this, false, false)`; the game
// finalizes the frog and keeps it persistent.
events.push(MobEvent::Convert {
from: Box::new(m.clone()),
kind: "minecraft:frog",
params: super::conversion::Params {
keep_equipment: false,
preserve_can_pick_up_loot: false,
},
});
}
+11
View File
@@ -184,6 +184,17 @@ fn body_anchor_y(m: &Mob, front: bool, height: f32, scale: f32) -> f64 {
}
base_sit_offset
}
/// `Camel.getLeashOffset` at the partial tick 0: the lead hangs from the
/// front body anchor (sinking while the camel sits) less 0.2 scaled, and
/// 0.56 of the width ahead.
pub fn leash_offset(m: &Mob) -> [f64; 3] {
let scale = if m.is_baby() { 0.6 } else { 1.0 };
[
0.0,
body_anchor_y(m, true, m.height(), scale) - f64::from(0.2_f32 * scale),
f64::from(m.width() * 0.56),
]
}
/// `Camel.getPassengerAttachmentPoint` of the one rider Horizon seats, before
/// the camel's yaw turns it: half a block ahead (scaled by `getAgeScale`, 0.6
/// for a baby) at the body anchor, which sinks while the camel sits. The
+5 -2
View File
@@ -67,8 +67,11 @@ pub fn single(from: &Mob, to: &mut Mob, params: Params) {
to.extra.hidden_effects = from.extra.hidden_effects.clone();
to.extra.ambient_effects = from.extra.ambient_effects.clone();
if from.is_baby() {
// setBaby(true); an ageable pair keeps the exact age.
to.extra.age = from.extra.age;
super::finalize::set_baby(to, true);
}
// An ageable pair keeps the exact age, an adult's breeding cooldown too.
if super::finalize::ageable(from.species()) && super::finalize::ageable(to.species()) {
super::finalize::set_age(to, from.extra.age);
to.extra.forced_age = from.extra.forced_age;
to.extra.forced_age_timer = from.extra.forced_age_timer;
}
+29 -3
View File
@@ -498,13 +498,39 @@ fn drop_horn(m: &mut Mob, events: &mut Vec<MobEvent>) -> bool {
m.random.next_boolean()
};
m.extra.lost_horns[usize::from(!lose_left)] = true;
events.push(MobEvent::DropItem {
item: "minecraft:goat_horn".into(),
count: 1,
// `Mth.randomBetween(this.random, ..)` for the push, drawn after createHorn.
let mut between = |min: f32, max: f32| f64::from(m.random.next_float() * (max - min) + min);
let motion = [between(-0.2, 0.2), between(0.3, 0.7), between(-0.2, 0.2)];
events.push(MobEvent::DropGoatHorn {
mob: m.id,
screaming: m.extra.screaming,
at: [m.position.x, m.position.y, m.position.z],
motion,
});
true
}
/// `Goat.createHorn`: the instrument is a random pick (by a random seeded
/// with the goat's UUID hash) from `#regular_goat_horns` or, for a screaming
/// goat, `#screaming_goat_horns`.
pub fn create_horn(uuid: [i32; 4], screaming: bool) -> &'static str {
const REGULAR: [&str; 4] = [
"minecraft:ponder_goat_horn",
"minecraft:sing_goat_horn",
"minecraft:seek_goat_horn",
"minecraft:feel_goat_horn",
];
const SCREAMING: [&str; 4] = [
"minecraft:admire_goat_horn",
"minecraft:call_goat_horn",
"minecraft:yearn_goat_horn",
"minecraft:dream_goat_horn",
];
// The UUID's Java hash code: the two halves xored, then folded to an int.
let [a, b, c, d] = uuid;
let mut random = horizon_core::random::LegacyRandom::new(i64::from(a ^ b ^ c ^ d));
let list = if screaming { SCREAMING } else { REGULAR };
list[random.next_int_bound(list.len() as i32).unwrap_or(0) as usize]
}
/// `RamTarget.finishRam`.
fn finish_ram(m: &mut Mob, events: &mut Vec<MobEvent>) {
lower_head(m, events, false);
+5
View File
@@ -454,6 +454,11 @@ pub fn leash_offset(m: &Mob) -> [f64; 3] {
} else if m.species() == super::Species::Allay {
// Allay.getLeashOffset.
[0.0, m.eye_height() * 0.6, f64::from(m.width()) * 0.1]
} else if matches!(
m.species(),
super::Species::Camel | super::Species::CamelHusk
) {
super::camel::leash_offset(m)
} else if m.species() == super::Species::Bee {
// Bee.getLeashOffset: half the eye height, a fifth of the width ahead.
[0.0, 0.5 * m.eye_height(), f64::from(m.width() * 0.2)]
+13 -2
View File
@@ -402,9 +402,16 @@ impl Mob {
/// `Pose.LONG_JUMPING` (what `LongJumpUtil` checks an arc with).
pub fn awake_dims_in(&self, long_jumping: bool) -> (f64, f64, f64) {
let k = &self.kind;
// Avatar.POSES: a player's or mannequin's box follows its pose.
// Avatar.POSES: a player's or mannequin's box follows its pose, and
// LivingEntity.getDimensions scales it by `minecraft:scale`
// (`getScale`): a fake player's giant NPC is clickable on its body.
if matches!(&*k.id, "minecraft:player" | "minecraft:mannequin") {
let d = self.extra.avatar_pose.dimensions(1.0);
let scale = if self.extra.model_scale > 0.0 {
self.extra.model_scale
} else {
1.0
};
let d = self.extra.avatar_pose.dimensions(scale);
return (
f64::from(d.width),
f64::from(d.height),
@@ -444,6 +451,10 @@ impl Mob {
if self.kind.species == super::Species::Pufferfish {
let s = super::aquatic::puff_scale(self.extra.size) as f32;
(w, h, e) = (w * s, h * s, e * s);
} else if self.kind.species == super::Species::Phantom {
// Phantom.getDefaultDimensions: 1 + 0.15 per size.
let s = 1.0 + 0.15 * self.extra.size as f32;
(w, h, e) = (w * s, h * s, e * s);
} else if self.extra.size > 0 {
let s = self.extra.size as f32;
(w, h, e) = (w * s, h * s, e * s);
+11
View File
@@ -34,6 +34,17 @@ pub fn register(m: &mut Mob) {
},
);
}
/// `Phantom.setPhantomSize` with `updatePhantomSizeInfo`: the size, clamped to
/// 0 to 64, and an attack damage base of 6 plus the size. The box and the
/// drawn model grow by `1 + 0.15 * size` (the mob's dimensions, `PhantomRenderer.scale`).
pub fn set_size(m: &mut Mob, size: i32) {
use super::finalize::{Attr, set_base};
let size = size.clamp(0, 64);
if size != m.extra.size {
m.extra.size = size;
set_base(m, Attr::AttackDamage, f64::from(6 + size));
}
}
fn can_attack(w: &MobWorld) -> bool {
w.player
.as_ref()
+31 -12
View File
@@ -291,6 +291,19 @@ pub fn brain_step(m: &mut Mob, cx: &mut Cx) {
}
update_activity(m, cx.w, cx.events);
}
/// `Zoglin.customServerAiStep` after the brain: `Zoglin.updateActivity` plays
/// the angry grunt when the fight activity starts.
pub fn zoglin_step(m: &mut Mob, cx: &mut Cx) {
let now = if m.ai.target.is_some() {
Activity::Fight
} else {
Activity::Idle
};
let before = std::mem::replace(&mut m.extra.piglin.activity, now);
if now == Activity::Fight && before != Activity::Fight {
m.voice("entity.zoglin.angry", cx.events);
}
}
/// `HoglinAi.updateActivity`, `PiglinAi.updateActivity` and
/// `PiglinBruteAi.updateActivity`: take the first valid activity and, when it
/// is not the one it was in, make the sound that goes with the new one; a
@@ -1621,28 +1634,34 @@ pub fn ai_step(
if !w.nether && !m.extra.flag2 && m.alive() {
m.extra.counter2 += 1;
if m.extra.counter2 > CONVERSION {
let (kind, sound) = match m.species() {
Species::Hoglin => ("minecraft:zoglin", "entity.hoglin.converted_to_zombified"),
// `Hoglin.finishConversion` is `ConversionParams.single(this, true,
// false)`, `AbstractPiglin.finishConversion` `(this, true, true)`;
// both leave the new mob nauseous (the game adds it).
let (kind, sound, params) = match m.species() {
Species::Hoglin => (
"minecraft:zoglin",
"entity.hoglin.converted_to_zombified",
super::conversion::Params {
keep_equipment: true,
preserve_can_pick_up_loot: false,
},
),
Species::PiglinBrute => (
"minecraft:zombified_piglin",
"entity.piglin_brute.converted_to_zombified",
super::conversion::Params::KEEP_ALL,
),
_ => (
"minecraft:zombified_piglin",
"entity.piglin.converted_to_zombified",
super::conversion::Params::KEEP_ALL,
),
};
events.push(MobEvent::Sound(sound, m.center(), 1.0, 1.0));
events.push(MobEvent::SpawnKind {
kind: kind.into(),
at: [m.position.x, m.position.y, m.position.z],
setup: super::SpawnSetup {
baby: m.is_baby(),
yaw: m.yaw,
persistent: m.persistent,
nausea: true,
..Default::default()
},
events.push(MobEvent::Convert {
from: Box::new(m.clone()),
kind,
params,
});
m.removed = true;
}
+52 -10
View File
@@ -3,8 +3,8 @@ use super::{
Mob, MobEvent, MobWorld, Species,
control::Operation,
goals::{
Avoid, AvoidEntity, Breed, ClimbOnTopOfPowderSnow, Cx, Float, Goal, LookAtPlayer, Panic,
Stroll, Tempt, goal_clone,
Avoid, AvoidEntity, Breed, ClimbOnTopOfPowderSnow, Cx, Float, Goal, HurtByTarget,
LookAtPlayer, MeleeAttack, NearestTarget, Panic, Stroll, Tempt, goal_clone,
},
};
use horizon_core::{
@@ -104,6 +104,33 @@ pub fn base_tick(m: &mut Mob) {
}
}
/// `Rabbit.facePoint`.
fn face_point(m: &mut Mob, x: f64, z: f64) {
m.yaw = trig::atan2_degrees(z - m.position.z, x - m.position.x) - 90.0;
}
/// `Rabbit.setVariant(EVIL)`, run the first time a killer bunny ticks (its
/// variant comes from `/summon`, a save or a spawn): armor 8, the bite and
/// target goals, attack damage plus 5 and the "Killer Bunny" name.
fn set_evil(m: &mut Mob) {
use super::finalize::{Attr, Op, add_modifier, set_base};
if m.ai.goals.has("MeleeAttack") {
return;
}
set_base(m, Attr::Armor, 8.0);
m.ai.goals.add(4, MeleeAttack::new(1.4, true));
m.ai.targets.add(1, HurtByTarget::new().alert());
m.ai.targets.add(2, NearestTarget::player(true));
m.ai.targets
.add(2, NearestTarget::species(&[Species::Wolf], true));
add_modifier(m, Attr::AttackDamage, "minecraft:evil", 5.0, Op::AddValue);
if m.custom_name.is_none() && m.custom_name_text.is_none() {
let name = crate::text::Text::translate("entity.minecraft.killer_bunny", &[]);
m.custom_name = Some(name.plain());
m.custom_name_text = Some(name);
}
}
fn start_jumping(m: &mut Mob) {
m.ai.jumping = true;
m.extra.ints[JUMP_DURATION] = 15;
@@ -119,8 +146,12 @@ fn start_jumping(m: &mut Mob) {
}
}
/// Rabbit.customServerAiStep: start a hop toward the current path waypoint.
pub fn server_ai_step(m: &mut Mob) {
/// Rabbit.customServerAiStep: start a hop toward the current path waypoint;
/// `target` is where the rabbit's target stands, if it has one.
pub fn server_ai_step(m: &mut Mob, target: Option<Vec3>) {
if m.extra.variant == EVIL {
set_evil(m);
}
// RabbitMoveControl.setWantedPosition applies the water override before landing checks.
if m.fluids.in_water && m.ai.mov.speed_modifier > 0.0 {
m.ai.mov.speed_modifier = 1.5;
@@ -133,13 +164,24 @@ pub fn server_ai_step(m: &mut Mob) {
m.ai.jumping = false;
m.extra.ints[MOVE_DELAY] = if m.ai.mov.speed_modifier < 2.2 { 10 } else { 3 };
}
// A killer bunny within 4 blocks of its target faces it and hops at it.
if m.extra.variant == EVIL
&& m.extra.ints[MOVE_DELAY] == 0
&& let Some(t) = target
&& m.distance_sqr(t) < 16.0
{
face_point(m, t.x, t.z);
let speed = m.ai.mov.speed_modifier;
m.ai.mov.set_wanted(t.x, t.y, t.z, speed);
start_jumping(m);
}
if !m.ai.jump && m.ai.mov.has_wanted() && m.extra.ints[MOVE_DELAY] == 0 {
let mut at = m.ai.mov.wanted;
if let Some(path) = m.ai.nav.path.as_ref().filter(|p| !p.is_done()) {
let p = path.entity_pos_at(path.next, m.width());
at = [p.x, p.y, p.z];
}
m.yaw = trig::atan2_degrees(at[2] - m.position.z, at[0] - m.position.x) - 90.0;
face_point(m, at[0], at[2]);
start_jumping(m);
}
}
@@ -411,14 +453,14 @@ mod tests {
let mut m = rabbit();
m.on_ground = true;
m.ai.mov.set_wanted(0.0, 0.0, 5.0, 0.6);
server_ai_step(&mut m);
server_ai_step(&mut m, None);
assert_eq!(m.extra.ints[MOVE_DELAY], 10);
assert!(!m.ai.jumping);
for _ in 0..9 {
server_ai_step(&mut m);
server_ai_step(&mut m, None);
}
assert!(!m.ai.jumping);
server_ai_step(&mut m);
server_ai_step(&mut m, None);
assert!(m.ai.jumping);
assert_eq!(m.extra.ints[JUMP_DURATION], 15);
assert_eq!(m.anim.states[0].started, None);
@@ -456,7 +498,7 @@ mod tests {
let mut m = rabbit();
m.on_ground = true;
m.ai.mov.set_wanted(0.0, 0.0, 5.0, 2.2);
server_ai_step(&mut m);
server_ai_step(&mut m, None);
assert_eq!(m.extra.ints[MOVE_DELAY], 3);
m.ai.jump = true;
m.jump_control_tick();
@@ -526,7 +568,7 @@ mod tests {
assert!(m.ai.nav.can_float);
m.extra.flag = false;
m.ai.mov.set_wanted(0.0, 0.0, 5.0, 2.2);
server_ai_step(&mut m);
server_ai_step(&mut m, None);
assert_eq!(m.ai.mov.speed_modifier, 1.5);
assert_eq!(m.extra.ints[MOVE_DELAY], 10);
}
+44 -1
View File
@@ -970,9 +970,27 @@ pub fn base_transform(m: &Mob, alpha: f32, camera: glam::DVec3) -> glam::Mat4 {
m.previous.x + (m.position.x - m.previous.x) * a,
m.previous.y + (m.position.y - m.previous.y) * a,
m.previous.z + (m.position.z - m.previous.z) * a,
) - camera;
) - camera
+ enderman_render_offset(m, alpha);
base_transform_at(m, alpha, glam::Mat4::from_translation(p.as_vec3()))
}
/// `EndermanRenderer.getRenderOffset`: a creepy (angry) enderman is moved by
/// a gaussian 0.02 blocks sideways in x and z every frame. Vanilla draws from
/// the renderer's own random; this one is seeded by the mob and the frame's
/// age, so the body, its carried block and its eyes agree within a frame.
fn enderman_render_offset(m: &Mob, alpha: f32) -> glam::DVec3 {
use horizon_core::random::{LegacyRandom, RandomSource};
if m.species() != Species::Enderman || !m.extra.flag {
return glam::DVec3::ZERO;
}
let frame = (m.age as f32 + alpha).to_bits();
let mut random = LegacyRandom::new((i64::from(m.id) << 32) ^ i64::from(frame));
// `0.02 * state.scale`, the enderman's scale being 1.
let d = 0.02;
let x = random.next_gaussian() * d;
let z = random.next_gaussian() * d;
glam::DVec3::new(x, 0.0, z)
}
/// `base_transform` from a pose of the entity's origin (`EntityRenderDispatcher.submit`
/// under a pose stack, as `SpawnerRenderer` draws the mob inside a spawner).
pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4 {
@@ -1915,6 +1933,17 @@ fn hand_base(
0.078_125,
));
}
if m.species() == Species::CopperGolem {
// `CopperGolemModel.translateToHand`: a still golem holds the item
// across the arm, a working one scaled down in front of it.
if m.extra.variant3 == super::copper_golem::IDLE {
t *= glam::Mat4::from_rotation_y((if left { 90.0f32 } else { -90.0 }).to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, 0.125));
} else {
t *= glam::Mat4::from_scale(glam::Vec3::splat(0.55))
* glam::Mat4::from_translation(glam::Vec3::new(-0.125, 0.3125, -0.1875));
}
}
let (ox, oy, oz) = if m.is_baby() {
(0.0, 1.0, -4.5)
} else {
@@ -2050,6 +2079,20 @@ pub fn held_item_transform(
if m.species() == Species::Fox {
return (!left).then(|| fox_item_transform(m, alpha, camera, data));
}
// DolphinCarryingItemLayer: the main hand's item at the snout, lifted and
// pulled in as the dolphin pitches (up or down) toward 60 degrees.
if m.species() == Species::Dolphin {
return (!left).then(|| {
let pitch = anim::state(m, alpha).x_rot;
let percent = pitch.abs() / 60.0;
let offset = if pitch < 0.0 {
glam::Vec3::new(0.0, 1.0 - percent * 0.5, -1.0 + percent * 0.5)
} else {
glam::Vec3::new(0.0, 1.0 + percent * 0.8, -1.0 + percent * 0.2)
};
base_transform(m, alpha, camera) * glam::Mat4::from_translation(offset)
});
}
if !super::illager::shows_item(m) {
return None;
}
+18 -3
View File
@@ -17,8 +17,8 @@ const BOARDING_COOLDOWN: i32 = 60;
/// `Piglin.startRiding`: how many baby piglins stack on one hoglin.
const MAX_PASSENGERS_ON_ONE_HOGLIN: i32 = 3;
/// `LivingEntity.getScale`: the `minecraft:scale` a player or mannequin
/// carries (`extra.model_scale`, 0 when none came), 1 for every other mob.
/// `LivingEntity.getScale`: the `minecraft:scale` the server sent on any
/// living entity (`extra.model_scale`, 0 when none came), 1 when unset.
fn scale(m: &Mob) -> f64 {
if m.extra.model_scale > 0.0 {
f64::from(m.extra.model_scale)
@@ -56,7 +56,11 @@ fn base_attachment(v: &Mob) -> [f64; 3] {
Species::Hoglin => [0.0, 1.49375, 0.0],
Species::Nautilus | Species::ZombieNautilus => [0.0, 1.1375, 0.0],
Species::Camel | Species::CamelHusk => super::camel::passenger_attachment(v),
// The default attachments: the top of the bounding box.
// The default attachments: the top of the bounding box, before
// `passenger_attachment`'s scale — a player's or mannequin's
// `height()` carries `minecraft:scale` since `getDimensions` grew it;
// any other mob's `height()` stays unscaled (`awake_dims_in`).
_ if avatar(v) => [0.0, f64::from(v.height()) / scale(v), 0.0],
_ => [0.0, f64::from(v.height()), 0.0],
}
}
@@ -376,6 +380,17 @@ mod tests {
);
assert!(close(seat.y, 54.1));
}
/// A scaled vehicle that is not an avatar: `remote_mob_attributes` puts
/// `minecraft:scale` on any living entity, but `awake_dims_in` grows only
/// avatars' boxes, so the seat still rides at the scaled box height.
#[test]
fn a_scaled_vehicle_seat_stays_scaled() {
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
let mut cow = Mob::new(1, crate::mobs::sensor_scan_tests::kind(), Vec3::ZERO, 0.0);
assert!(close(passenger_attachment(&cow)[1], 1.4));
cow.extra.model_scale = 2.0;
assert!(close(passenger_attachment(&cow)[1], 2.8));
}
#[test]
fn archers_keep_their_distance_and_brawlers_close_in() {
assert!(ranged(Species::Skeleton) && ranged(Species::Pillager));
+8 -2
View File
@@ -259,8 +259,14 @@ pub fn ai_step(m: &mut Mob, cx_events: &mut Vec<MobEvent>, target_far: bool) {
// healRaidersGoal.decrementCooldown (the attack goal's canAttack reads it).
m.extra.counter2 -= 1;
drink(m, cx_events, target_far);
// The angry-witch particles (entity event 15).
let _ = m.random.next_float();
// `broadcastEntityEvent(this, 15)`: the magic puffs over its hat on every client.
if m.random.next_float() < 7.5E-4 {
cx_events.push(MobEvent::EntityEvent {
mob: m.id,
event: 15,
at: [m.position.x, m.position.y, m.position.z],
});
}
}
fn drink(m: &mut Mob, cx_events: &mut Vec<MobEvent>, target_far: bool) {
if m.extra.flag {
+11 -3
View File
@@ -840,12 +840,16 @@ impl<'a> Plan<'a> {
"registries.json" => {
// Network ids: the game and its server map items, blocks and mob
// effects through this report, so new ones get the ids after
// vanilla's, in plan order.
// vanilla's, in plan order. A block entity type for each block too,
// named after it: a mod names its types only when one is made
// (`native_block_entity`), and the chunk packet carries the type of
// each block's entity.
let new_items = self.items.iter().map(|i| i.rename.to.clone());
let new_blocks = self.blocks.iter().map(|b| b.decl.id.clone());
let new_blocks: Vec<_> = self.blocks.iter().map(|b| b.decl.id.clone()).collect();
let mut registries = vec![
("minecraft:item", new_items.collect::<Vec<_>>()),
("minecraft:block", new_blocks.collect()),
("minecraft:block", new_blocks.clone()),
("minecraft:block_entity_type", new_blocks),
];
if !self.effects.is_empty() {
let new_effects = self.effects.iter().map(|e| e.id.clone());
@@ -855,6 +859,10 @@ impl<'a> Plan<'a> {
let Some(J::Obj(entries)) =
doc.get_mut(registry).and_then(|r| r.get_mut("entries"))
else {
// A report without block entity types gets none.
if registry == "minecraft:block_entity_type" {
continue;
}
return Err(format!("no {registry} entries").into());
};
let next = entries
@@ -178,7 +178,8 @@ fn allocated_ids_follow_mod_and_entry_order_after_vanilla() {
"minecraft:block":{"entries":{"minecraft:stone":{"protocol_id":7},
"minecraft:iron_block":{"protocol_id":11}}},
"minecraft:mob_effect":{"entries":{"minecraft:speed":{"protocol_id":0},
"minecraft:luck":{"protocol_id":25}}}}"#,
"minecraft:luck":{"protocol_id":25}}},
"minecraft:block_entity_type":{"entries":{"minecraft:chest":{"protocol_id":1}}}}"#,
)
.unwrap();
fs::create_dir_all(base.join("assets/minecraft/blockstates")).unwrap();
@@ -260,7 +261,8 @@ fn allocated_ids_follow_mod_and_entry_order_after_vanilla() {
"minecraft:block":{"entries":{"minecraft:stone":{"protocol_id":7},
"minecraft:iron_block":{"protocol_id":11}}},
"minecraft:mob_effect":{"entries":{"minecraft:speed":{"protocol_id":0},
"minecraft:luck":{"protocol_id":25}}}}"#,
"minecraft:luck":{"protocol_id":25}}},
"minecraft:block_entity_type":{"entries":{"minecraft:chest":{"protocol_id":1}}}}"#,
)
.unwrap();
plan.json("registries.json", &mut registry).unwrap();
@@ -281,6 +283,11 @@ fn allocated_ids_follow_mod_and_entry_order_after_vanilla() {
assert_eq!(id("minecraft:block", "minecraft:iron_block"), 11);
assert_eq!(id("minecraft:block", "alpha:zblock"), 12);
assert_eq!(id("minecraft:block", "ruby:zblock"), 13);
// Each mod block names a block entity type, after vanilla's.
assert_eq!(id("minecraft:block_entity_type", "minecraft:chest"), 1);
assert_eq!(id("minecraft:block_entity_type", "alpha:zblock"), 2);
assert_eq!(id("minecraft:block_entity_type", "ruby:zblock"), 3);
assert_eq!(id("minecraft:mob_effect", "minecraft:luck"), 25);
assert_eq!(id("minecraft:mob_effect", "alpha:dull"), 26);
assert_eq!(id("minecraft:mob_effect", "alpha:zap"), 27);
+6 -2
View File
@@ -304,8 +304,12 @@ pub fn save_server_icon(saves: &Path, address: &str, png: &[u8]) {
}
}
/// Horizon's official survival server: a name and an address like any entry.
pub const OFFICIAL_SERVER: (&str, &str) = ("Horizon Official", "horizon.bunchy7s.dev");
/// Horizon's official survival server: a name and an address like any entry. The address is
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
pub const OFFICIAL_SERVER: (&str, &str) = (
"Horizon Official",
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
);
/// Beside servers.dat once the official server was offered, so a player who
/// deletes it never gets it back. The launcher writes the same file.
const OFFICIAL_MARKER: &str = "horizon_official_server.txt";
+32 -10
View File
@@ -2895,18 +2895,34 @@ fn write_saved_data(world: &Path, name: &str, data: Tag) -> Result<()> {
}
pub use horizon_client_core::uuid::{uuid_from_text, uuid_text};
/// A random (version 4) UUID as vanilla's four-int form.
///
/// Every call gets its own: the generator is seeded once from the clock and
/// the process, then each call takes the next step of one shared counter. A
/// seed from the clock alone repeats within its resolution (a microsecond on
/// macOS): a pack of mobs generated together all had the same UUID, and a
/// vanilla client keeps only one entity per UUID (the rest never moved).
pub fn random_uuid() -> [i32; 4] {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as u64);
let mut s = t ^ (std::process::id() as u64).rotate_left(32) ^ 0x9E37_79B9_7F4A_7C15;
let mut next = || {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
s
use std::sync::{
OnceLock,
atomic::{AtomicU64, Ordering},
};
let (a, b) = (next(), next());
static NEXT: OnceLock<AtomicU64> = OnceLock::new();
let next = NEXT.get_or_init(|| {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as u64);
AtomicU64::new(t ^ (std::process::id() as u64).rotate_left(32))
});
// SplitMix64: distinct steps give distinct outputs.
let split = || {
let mut z = next
.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed)
.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
};
let (a, b) = (split(), split());
let a = (a & !0xF000) | 0x4000;
let b = (b & !(0xC000_0000_0000_0000)) | 0x8000_0000_0000_0000;
[(a >> 32) as i32, a as i32, (b >> 32) as i32, b as i32]
@@ -5852,6 +5868,12 @@ mod tests {
assert_eq!((r[1] >> 12) & 0xF, 4, "version 4");
}
#[test]
fn random_uuids_made_at_once_differ() {
let made: std::collections::HashSet<[i32; 4]> =
(0..10_000).map(|_| random_uuid()).collect();
assert_eq!(made.len(), 10_000);
}
#[test]
fn creaking_heart_keeps_its_protector_uuid() {
use crate::containers::{BlockEntity, CreakingHeart};
let heart = CreakingHeart {
@@ -1,18 +1,20 @@
//! Tick scheduler membership, invalidated by resident key changes or resets.
use crate::chunk_map::ChunkMap;
use std::collections::HashSet;
use crate::{
chunk_map::{ChunkMap, ChunkMembershipDelta},
fast_hash::FxHashSet,
};
type Key = (i32, i32);
#[derive(Default)]
pub struct TickChunkMembership {
keys: HashSet<Key>,
keys: FxHashSet<Key>,
revision: Option<u64>,
added: Vec<Key>,
removed: Vec<Key>,
/// Loaded chunks with saved ticks that have not started ticking yet
/// (`LevelChunkTicks.pendingTicks`, unpacked by `startTickingChunk`).
waiting: HashSet<Key>,
waiting: FxHashSet<Key>,
}
impl TickChunkMembership {
@@ -35,6 +37,43 @@ impl TickChunkMembership {
}
self.added.clear();
self.removed.clear();
if let Some(deltas) = self.revision.and_then(|rev| chunks.deltas_since(rev)) {
let mut removed_waiting = FxHashSet::default();
for (_, delta) in deltas {
match delta {
ChunkMembershipDelta::Added(key) => {
if let Some(pos) = self.removed.iter().position(|&k| k == key) {
// The chunk is back where it was: the removal it
// cancels is no news, and it keeps its saved ticks.
self.removed.swap_remove(pos);
self.keys.insert(key);
if removed_waiting.remove(&key) {
self.waiting.insert(key);
}
} else if self.keys.insert(key) {
self.added.push(key);
}
}
ChunkMembershipDelta::Removed(key) => {
if let Some(pos) = self.added.iter().position(|&k| k == key) {
self.added.swap_remove(pos);
self.keys.remove(&key);
self.waiting.remove(&key);
} else if self.keys.remove(&key) {
self.removed.push(key);
if self.waiting.remove(&key) {
removed_waiting.insert(key);
}
}
}
ChunkMembershipDelta::Reset => unreachable!(),
}
}
self.revision = Some(revision);
return true;
}
self.keys.retain(|key| {
if chunks.contains_key(key) {
true
@@ -95,7 +134,7 @@ impl TickChunkMembership {
mod tests {
use super::*;
use crate::chunk::Chunk;
use std::sync::Arc;
use std::{collections::HashSet, sync::Arc};
fn compare(cache: &mut TickChunkMembership, old: &mut HashSet<Key>, map: &ChunkMap) {
let loaded: HashSet<_> = map.keys().copied().collect();
@@ -115,7 +154,7 @@ mod tests {
} else {
assert!(expected_added.is_empty() && expected_removed.is_empty());
}
assert_eq!(cache.keys, loaded);
assert_eq!(cache.keys.iter().copied().collect::<HashSet<_>>(), loaded);
*old = loaded;
}
@@ -268,4 +307,36 @@ mod tests {
}
}
}
/// A removal the next addition cancels puts the chunk back where it was,
/// saved ticks included: the delta replay answers as the full scan does.
#[test]
fn cancel_removal_between_synchronizations_restores_membership_and_waiting() {
let mut map = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
let mut cache = TickChunkMembership::default();
let key = (3, 7);
map.insert(key, chunk.clone());
assert!(cache.synchronize(&map));
assert_eq!(cache.take_added(), vec![key]);
cache.wait(key);
assert!(cache.waiting.contains(&key));
map.remove(&key);
map.insert(key, chunk);
assert!(cache.synchronize(&map));
assert!(cache.take_added().is_empty());
assert!(cache.removed().is_empty());
assert!(cache.keys.contains(&key));
assert!(cache.waiting.contains(&key));
assert_eq!(cache.start_ticking(|k| k == key), vec![key]);
// The unload after that one is still news.
map.remove(&key);
assert!(cache.synchronize(&map));
assert_eq!(cache.removed(), &[key]);
assert!(!cache.keys.contains(&key));
}
}
+78 -3
View File
@@ -54,9 +54,11 @@ pub struct Sources {
/// Each chunk's (loading, simulation) level, for the chunks where either is
/// block ticking or lower; `None` ticks everywhere (a game with no server and
/// no player: the headless fixtures).
/// no player: the headless fixtures). With them sits the box
/// `(min_x, min_z, max_x, max_z)` that holds every chunk that can random tick,
/// so a scan of the residents need not ask each one of them.
#[derive(Clone, Default)]
pub struct Levels(Option<Arc<Map>>);
pub struct Levels(Option<Arc<Map>>, Option<(i32, i32, i32, i32)>);
/// The levels by chunk: a grid over the tickets' bounding box while it is
/// small (players near each other, the usual case), a map otherwise. Every
@@ -120,6 +122,13 @@ impl Levels {
self.get(x, z)
.is_none_or(|(l, s)| l <= BLOCK_TICKING && s <= ENTITY_TICKING)
}
/// The box that holds every chunk `random_ticking` can answer for, or
/// `None` with no tickets at all, where it ticks everywhere. A box whose
/// minimum is past its maximum holds nothing: the tickets hold no player,
/// no forced chunk and no `PORTAL` one, so nothing random ticks.
pub fn random_tick_bounds(&self) -> Option<(i32, i32, i32, i32)> {
self.1
}
/// `ChunkHolder.getFullStatus` is block ticking or more: the chunk's
/// ticking future can complete (its saved ticks unpack then).
pub fn loaded_ticking(&self, x: i32, z: i32) -> bool {
@@ -158,7 +167,15 @@ impl ChunkTickets {
if sources == self.sources {
return;
}
self.levels = Levels(sources.as_ref().map(|s| Arc::new(compute(s))));
self.levels = match sources.as_ref() {
Some(s) => {
let map = Some(Arc::new(compute(s)));
// Tickets with no random tick of their own still get a box, an
// empty one: `None` is for a game with no tickets at all.
Levels(map, Some(random_bounds(s).unwrap_or((1, 1, 0, 0))))
}
None => Levels(None, None),
};
self.sources = sources;
}
pub fn levels(&self) -> Levels {
@@ -187,6 +204,28 @@ impl ChunkTickets {
}
}
/// The box that holds every chunk `random_ticking` can answer for: a player's
/// simulation ticket reaches its simulation distance, a forced chunk only ever
/// random ticks in the chunk itself, and a `PORTAL` ticket inside its whole
/// area. Each is a superset of that ticket's random-ticking chunks, so the box
/// never rejects a chunk that random ticks.
fn random_bounds(s: &Sources) -> Option<(i32, i32, i32, i32)> {
let sim = s.simulation_distance.clamp(0, 32);
let boxes = s
.players
.iter()
.map(|&p| (p, sim))
.chain(s.forced.iter().map(|&f| (f, 0)))
.chain(s.portal.iter().map(|&p| (p, PORTAL_RADIUS)));
boxes.fold(None, |b, ((x, z), r)| {
let (lo_x, lo_z, hi_x, hi_z) = (x - r, z - r, x + r, z + r);
Some(match b {
None => (lo_x, lo_z, hi_x, hi_z),
Some((a, b, c, d)) => (lo_x.min(a), lo_z.min(b), hi_x.max(c), hi_z.max(d)),
})
})
}
/// How far a player's tickets reach, and the simulation ticket's level.
fn reach(s: &Sources) -> (i32, i32, i32) {
let view = s.view_distance.clamp(0, 32);
@@ -445,6 +484,42 @@ mod tests {
assert!(l.entity_ticking(i32::MAX - 2, i32::MIN + 2));
}
/// `tick_blocks` filters its candidates with the bounds box before it asks
/// `random_ticking`: whatever random ticks is inside it, every ticket's
/// own area, a `PORTAL` ticket's with no player near it included.
#[test]
fn random_tick_bounds_cover_every_random_ticking_chunk() {
let mut t = ChunkTickets::default();
// A view distance shorter than the simulation one, two players far
// apart, a forced chunk, and `PORTAL` tickets that outlive them.
let sources = || {
Some(Sources {
players: vec![(0, 0), (60, 40)],
forced: vec![(-30, 17)],
portal: Vec::new(),
view_distance: 4,
simulation_distance: 12,
})
};
t.update(sources());
for portal in [(0, 0), (25, -25), (300, 300), (-80, 95)] {
t.add_portal(portal);
t.update(sources());
let levels = t.levels();
let (min_x, min_z, max_x, max_z) = levels.random_tick_bounds().unwrap();
for z in -100..=310 {
for x in -100..=310 {
if levels.random_ticking(x, z) {
assert!(
x >= min_x && x <= max_x && z >= min_z && z <= max_z,
"({x}, {z}) random ticks outside the box"
);
}
}
}
}
}
/// cargo test --release -p horizon-client --lib ticket_levels::tests::levels_cost -- --ignored --nocapture
#[test]
#[ignore]
+107 -49
View File
@@ -2,21 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -209,12 +194,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
version = "0.2.4"
@@ -231,17 +210,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -318,8 +286,7 @@ version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -396,10 +363,23 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"wasm-bindgen",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -468,6 +448,7 @@ dependencies = [
"toml",
"tower",
"ureq",
"zip",
]
[[package]]
@@ -699,6 +680,16 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.191"
@@ -758,16 +749,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.4"
@@ -840,6 +821,18 @@ dependencies = [
"thiserror",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -907,6 +900,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ring"
version = "0.17.14"
@@ -1325,6 +1324,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "typed-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -1411,6 +1416,51 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasmparser"
version = "0.254.2"
@@ -1653,19 +1703,27 @@ dependencies = [
[[package]]
name = "zip"
version = "2.6.1"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"flate2",
"getrandom 0.4.3",
"indexmap",
"memchr",
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
+2
View File
@@ -30,6 +30,8 @@ http-body-util = "0.1"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
# A native mod's `.hnmod` for the tests (already one of horizon-mods').
zip = { version = "2", default-features = false, features = ["deflate"] }
[profile.release]
strip = true
+47
View File
@@ -285,6 +285,53 @@ impl GitHub {
}
}
/// The address of the open pull request from `branch`, if there is one.
pub fn open_pull(&self, branch: &str) -> Result<Option<String>> {
let owner = self.repo.split('/').next().unwrap_or("");
let pulls = self
.call("GET", "/pulls")
.query("head", &format!("{owner}:{branch}"))
.query("state", "open")
.call()
.map_err(failure)
.and_then(read)?;
Ok(pulls
.as_array()
.and_then(|p| p.first())
.map(|p| p["html_url"].as_str().unwrap_or("").to_owned()))
}
/// The messages of `branch`'s commits that aren't on main.
pub fn commits(&self, branch: &str) -> Result<Vec<String>> {
let compare = self
.call("GET", &format!("/compare/main...{branch}"))
.call()
.map_err(failure)
.and_then(read)?;
Ok(compare["commits"]
.as_array()
.map(|c| {
c.iter()
.map(|c| c["commit"]["message"].as_str().unwrap_or("").to_owned())
.collect()
})
.unwrap_or_default())
}
/// Moves branch `name` back to main, dropping its own commits.
pub fn reset(&self, name: &str) -> Result<()> {
let main = self
.call("GET", "/git/ref/heads/main")
.call()
.map_err(failure)
.and_then(read)?;
let sha = main["object"]["sha"].as_str().unwrap_or("");
self.call("PATCH", &format!("/git/refs/heads/{name}"))
.send_json(json!({"sha": sha, "force": true}))
.map_err(failure)?;
Ok(())
}
/// Writes `path` on `branch` in one commit; `sha` is the blob it replaces.
pub fn put(
&self,
+112 -10
View File
@@ -725,6 +725,33 @@ fn date(secs: u64) -> String {
format!("{y:04}-{m:02}-{d:02}")
}
/// A key for submission tags, separate from the signed-cookie format. Keeping
/// the domains distinct means a public tag can never be repackaged as a
/// session cookie.
fn submission_key(config: &Config) -> [u8; 32] {
let mut mac = Hmac::<Sha256>::new_from_slice(&config.session_key).expect("any key length");
mac.update(b"submission-tag-v1");
mac.finalize().into_bytes().into()
}
/// What a submission branch's commit carries to say the form wrote it for
/// `login`: signed with the service's submission key, so a retry by the same
/// account may start that branch again.
fn submission_tag(config: &Config, branch: &str, login: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(&submission_key(config)).expect("32 bytes");
mac.update(format!("submission\n{branch}\n{}", login.to_lowercase()).as_bytes());
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
}
fn submission_tag_valid(config: &Config, branch: &str, login: &str, tag: &str) -> bool {
let Ok(tag) = URL_SAFE_NO_PAD.decode(tag) else {
return false;
};
let mut mac = Hmac::<Sha256>::new_from_slice(&submission_key(config)).expect("32 bytes");
mac.update(format!("submission\n{branch}\n{}", login.to_lowercase()).as_bytes());
mac.verify_slice(&tag).is_ok()
}
/// The trailer of an owner update's pull request: a comment holding what the
/// update is and an HMAC-SHA256 (hex, with `key`) over it, which the registry
/// repository's `owner-update` workflow verifies with the same secret
@@ -807,6 +834,8 @@ pub fn publish(
};
let existing = gh.entry(&form.id).map_err(github_down)?;
let is_new = existing.is_none();
let turns_native =
kind == Kind::NativeMod && existing.as_ref().is_some_and(|(e, _)| e.kind == Kind::Mod);
// An owner update: an owner's new version and nothing else of the entry
// changes (key and owners included).
let mut owner_update = false;
@@ -849,8 +878,18 @@ pub fn publish(
),
));
}
// A mod may turn native once, never back (`registry::review`,
// docs/native-mods.md): its `.hmod` versions stay published.
if entry.kind != kind {
return Err(bad(format!("{} is a {}", form.id, entry.kind.name())));
if (&entry.kind, &kind) != (&Kind::Mod, &Kind::NativeMod) {
return Err(bad(format!(
"{} is a {}, not a {}: a project's kind never changes (only a mod may turn native-mod, once)",
form.id,
entry.kind.name(),
kind.name()
)));
}
entry.kind = kind.clone();
}
if entry.versions.iter().any(|v| v.version == version.version) {
return Err(Refusal::new(
@@ -881,7 +920,9 @@ pub fn publish(
let (mut a, mut b) = (entry.clone(), before);
a.versions.clear();
b.versions.clear();
// A mod turning native is reviewed by hand, never an owner update.
owner_update = a == b
&& !turns_native
&& (entry.key.is_none() || version.signature.is_some())
&& version
.version
@@ -918,18 +959,46 @@ pub fn publish(
gh.stage(&format!("{sha256}.{extension}"), file)
.map_err(github_down)?;
let branch = format!("submit/{}-{}", form.id, version.version);
// Its commit's record of whose it is: only this service can write it,
// where a commit's text or author can be anyone's who writes to the repository.
let submission = submission_tag(config, &branch, login);
if !gh.branch(&branch).map_err(github_down)? {
return Err(Refusal::new(
StatusCode::CONFLICT,
format!(
"{} {} was already submitted: its pull request is open",
form.id, version.version
),
));
// The branch is there: a pull request, someone else's submission, or
// this submitter's own that GitHub broke off (a timeout past the
// branch's creation). Only the last is taken up again, from main: the
// branch holds no commit yet, or the one this form wrote for them.
if let Some(url) = gh.open_pull(&branch).map_err(github_down)? {
return Err(Refusal::new(
StatusCode::CONFLICT,
format!(
"{} {} was already submitted: its pull request is open ({url})",
form.id, version.version
),
));
}
let commits = gh.commits(&branch).map_err(github_down)?;
let theirs = commits.len() <= 1
&& commits.iter().all(|m| {
m.lines().any(|line| {
line.trim()
.strip_prefix("Submission:")
.is_some_and(|tag| submission_tag_valid(config, &branch, login, tag.trim()))
})
});
if !theirs {
return Err(Refusal::new(
StatusCode::CONFLICT,
format!(
"{} {} was already submitted by someone else: give this file a new version",
form.id, version.version
),
));
}
gh.reset(&branch).map_err(github_down)?;
}
let what = if is_new { "Add" } else { "Update" };
let message = format!(
"{what} {} {}\n\nSubmitted by @{login} through the form.\n\nCo-authored-by: {login} <{user_id}+{login}@users.noreply.github.com>",
"{what} {} {}\n\nSubmitted by @{login} through the form.\n\nSubmission: {submission}\nCo-authored-by: {login} <{user_id}+{login}@users.noreply.github.com>",
form.id, version.version
);
gh.put(
@@ -964,7 +1033,13 @@ pub fn publish(
.map_or(config.public_url.as_str(), |(_, host)| host),
id = form.id,
kind = kind.name(),
new = if is_new { "new" } else { "update" },
new = if is_new {
"new"
} else if turns_native {
"update: turns native, its `.hmod` versions stay published"
} else {
"update"
},
version = v.version,
game = v.game,
file = v.file.name,
@@ -1133,4 +1208,31 @@ mod tests {
);
assert_eq!(unsign(key, &forged), None);
}
#[test]
fn submission_tags_are_not_session_cookies() {
let key = b"0123456789abcdef0123456789abcdef";
let config = Config {
public_url: "https://example.invalid".into(),
session_key: key.to_vec(),
github: GitHub::new(
github::Endpoints::default(),
"owner/repo".into(),
"client".into(),
"secret".into(),
"token".into(),
),
update_key: None,
scratch: PathBuf::from("/tmp/horizon-mod-registry-test"),
database: PathBuf::from(":memory:"),
};
let branch = "submit/example-1.0.0";
let login = "12345678901";
let tag = submission_tag(&config, branch, login);
assert!(submission_tag_valid(&config, branch, login, &tag));
let payload = format!("submission\n{branch}\n{login}");
let cookie = format!("{}.{}", URL_SAFE_NO_PAD.encode(payload), tag);
assert_eq!(unsign(&config.session_key, &cookie), None);
}
}
+237 -4
View File
@@ -6,7 +6,7 @@ use axum::{
body::{Body, Bytes},
extract::{Path as UrlPath, Query, State},
http::{Request, StatusCode, header},
routing::{get, post},
routing::{get, patch, post},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use horizon_mod_registry::{App, Config, github, router};
@@ -38,6 +38,10 @@ struct Mock {
login: String,
/// GitHub takes this long to find the staging release.
slow: Option<std::time::Duration>,
/// GitHub takes this long to open a pull request, and then doesn't.
slow_pull: Option<std::time::Duration>,
/// Branches moved back to main.
resets: Vec<String>,
}
type Shared = Arc<Mutex<Mock>>;
@@ -130,13 +134,54 @@ async fn mock_github() -> (String, Shared) {
Ok(Json(json!({})))
}),
)
.route(
"/repos/o/r/git/refs/heads/{*name}",
patch(|State(s): State<Shared>, UrlPath(name): UrlPath<String>, Json(b): Json<Value>| async move {
assert_eq!((&b["sha"], &b["force"]), (&json!("abc123"), &json!(true)));
let mut s = s.lock().unwrap();
s.commits.retain(|(branch, _), _| *branch != name);
s.resets.push(name);
Json(json!({}))
}),
)
.route(
"/repos/o/r/compare/{*spec}",
get(|State(s): State<Shared>, UrlPath(spec): UrlPath<String>| async move {
let branch = spec.strip_prefix("main...").unwrap();
let s = s.lock().unwrap();
let commits: Vec<Value> = s
.commits
.iter()
.filter(|((b, _), _)| b == branch)
.map(|(_, (_, message, _))| json!({"commit": {"message": message}}))
.collect();
Json(json!({"ahead_by": commits.len(), "commits": commits}))
}),
)
.route(
"/repos/o/r/pulls",
post(|State(s): State<Shared>, Json(b): Json<Value>| async move {
get(|State(s): State<Shared>, Query(q): Query<HashMap<String, String>>| async move {
assert_eq!(q["state"], "open");
let s = s.lock().unwrap();
let open: Vec<Value> = s
.pulls
.iter()
.enumerate()
.filter(|(_, p)| format!("o:{}", p["head"].as_str().unwrap()) == q["head"] && p["state"] != "closed")
.map(|(i, _)| json!({"html_url": format!("https://github.com/o/r/pull/{}", i + 1)}))
.collect();
Json(json!(open))
})
.post(|State(s): State<Shared>, Json(b): Json<Value>| async move {
let slow = s.lock().unwrap().slow_pull;
if let Some(slow) = slow {
tokio::time::sleep(slow).await;
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
let mut s = s.lock().unwrap();
s.pulls.push(b);
let n = s.pulls.len();
Json(json!({"html_url": format!("https://github.com/o/r/pull/{n}"), "number": n}))
Ok(Json(json!({"html_url": format!("https://github.com/o/r/pull/{n}"), "number": n})))
}),
)
.route(
@@ -376,9 +421,16 @@ async fn a_submission_becomes_a_pull_request() {
assert!(body.contains("/mods/submit) by @alice."), "{body}");
assert!(body.contains("may draw text on your screen"), "{body}");
}
// The same version again: its branch is there.
// The same version again: its pull request is open.
let (status, json) = client.submit(&csrf, &form(), &file).await;
assert_eq!(status, StatusCode::CONFLICT, "{json}");
assert!(
json["error"]
.as_str()
.unwrap()
.ends_with("its pull request is open (https://github.com/o/r/pull/1)"),
"{json}"
);
assert_eq!(mock.lock().unwrap().assets.len(), 1, "uploaded once");
}
@@ -698,6 +750,187 @@ async fn a_refusal_waits_for_the_upload() {
assert!(answer.contains("{\"error\":"), "{answer}");
}
/// A submission that GitHub broke off after making its branch: the same
/// submitter's retry takes the branch up again; anyone else's is refused,
/// and so is any retry once the pull request is open.
#[tokio::test(flavor = "multi_thread")]
async fn a_retry_takes_up_its_own_branch() {
let (mut client, mock) =
setup_with(|gh| gh.call_timeout = std::time::Duration::from_millis(300)).await;
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
let branch = "submit/coords_hud-0.1.0".to_owned();
let key = (branch.clone(), "mods/coords_hud.toml".to_owned());
// Someone else's branch, with no pull request: not taken over, even when
// its commit copies the form's words for alice (only the service signs them).
let csrf = client.sign_in().await;
{
let mut m = mock.lock().unwrap();
m.branches.push(branch.clone());
m.commits.insert(
key.clone(),
(
"x".into(),
"Add coords_hud 0.1.0\n\nSubmitted by @alice through the form.\n\nSubmission: forged".into(),
None,
),
);
}
let (status, json) = client.submit(&csrf, &form(), &file).await;
assert_eq!(status, StatusCode::CONFLICT, "{json}");
assert!(
json["error"].as_str().unwrap().contains("by someone else"),
"{json}"
);
{
let mut m = mock.lock().unwrap();
assert!(m.resets.is_empty() && m.pulls.is_empty());
m.branches.clear();
m.commits.clear();
}
// GitHub times out opening the pull request: the branch and its commit are there.
mock.lock().unwrap().slow_pull = Some(std::time::Duration::from_secs(2));
let (status, json) = client.submit(&csrf, &form(), &file).await;
assert_eq!(status, StatusCode::GATEWAY_TIMEOUT, "{json}");
{
let mut m = mock.lock().unwrap();
assert_eq!(m.branches, [branch.as_str()]);
assert!(m.commits.contains_key(&key) && m.pulls.is_empty());
m.slow_pull = None;
}
// The retry starts the branch again from main and opens the pull request.
let (status, json) = client.submit(&csrf, &form(), &file).await;
assert_eq!(status, StatusCode::OK, "{json}");
assert_eq!(json["pull_request"], "https://github.com/o/r/pull/1");
{
let m = mock.lock().unwrap();
assert_eq!(m.resets, [branch.as_str()]);
let message = &m.commits[&key].1;
assert!(
message.contains("Submitted by @alice through the form.\n\nSubmission: "),
"{message}"
);
assert_eq!(m.pulls[0]["head"], branch.as_str());
}
// Now its pull request is open: refused, the branch left alone.
let (status, json) = client.submit(&csrf, &form(), &file).await;
assert_eq!(status, StatusCode::CONFLICT, "{json}");
assert!(
json["error"]
.as_str()
.unwrap()
.contains("pull request is open"),
"{json}"
);
assert_eq!(mock.lock().unwrap().resets.len(), 1);
// A closed one doesn't hold the version: a new pull request.
mock.lock().unwrap().pulls[0]["state"] = json!("closed");
let (status, json) = client.submit(&csrf, &form(), &file).await;
assert_eq!(status, StatusCode::OK, "{json}");
assert_eq!(json["pull_request"], "https://github.com/o/r/pull/2");
// A branch with no commit yet (cut off before the entry was written) is taken up too.
{
let mut m = mock.lock().unwrap();
m.pulls.clear();
m.commits.clear();
m.resets.clear();
}
let (status, json) = client.submit(&csrf, &form(), &file).await;
assert_eq!(status, StatusCode::OK, "{json}");
assert_eq!(mock.lock().unwrap().resets, [branch.as_str()]);
}
/// A native rewrite (`.hnmod`) of `coords_hud` at `version`.
fn hnmod(version: &str) -> Vec<u8> {
use std::io::Write;
let revision = "a".repeat(40);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
zip.start_file("horizon-native.json", options).unwrap();
let manifest = json!({"schema": 1, "id": "coords_hud", "version": version, "game": "26.3", "horizon": revision, "mixins": "b".repeat(40)});
zip.write_all(manifest.to_string().as_bytes()).unwrap();
zip.start_file("mixins/coords_hud/horizon-mixin.toml", options)
.unwrap();
write!(
zip,
"[mod]\nid = \"coords_hud\"\nversion = \"{version}\"\n[compatibility]\nhorizon_revision = \"{revision}\"\n"
)
.unwrap();
zip.finish().unwrap().into_inner()
}
/// A WebAssembly mod's entry turns `native-mod` once, by its owner; its
/// `.hmod` versions stay. No other kind change.
#[tokio::test(flavor = "multi_thread")]
async fn a_mod_turns_native_once() {
let (mut client, mock) = setup().await;
let published = format!(
"id = \"coords_hud\"\nkind = \"mod\"\nname = \"C\"\nsummary = \"s\"\nowners = [\"Alice\"]\nlicense = \"MIT\"\nsource = \"https://x.test\"\n\n[[versions]]\nversion = \"0.1.0\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.1.0.hmod\"\nsha256 = \"{HMOD_SHA}\"\n"
);
mock.lock()
.unwrap()
.main
.insert("mods/coords_hud.toml".into(), published.clone());
let csrf = client.sign_in().await;
let mut native = form();
native["kind"] = json!("native-mod");
native["version"] = json!("0.2.0");
native["key"] = Value::Null;
native["signature"] = Value::Null;
let file = hnmod("0.2.0");
// Not by someone who isn't an owner.
mock.lock().unwrap().main.insert(
"mods/coords_hud.toml".into(),
published.replace("\"Alice\"", "\"bob\""),
);
let (status, json) = client.submit(&csrf, &native, &file).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{json}");
mock.lock()
.unwrap()
.main
.insert("mods/coords_hud.toml".into(), published);
let (status, json) = client.submit(&csrf, &native, &file).await;
assert_eq!(status, StatusCode::OK, "{json}");
let text = {
let m = mock.lock().unwrap();
let (text, _, replaced) = &m.commits[&(
"submit/coords_hud-0.2.0".into(),
"mods/coords_hud.toml".into(),
)];
assert_eq!(replaced.as_deref(), Some("blob1"));
assert!(m.assets[0].0.ends_with(".hnmod"), "{:?}", m.assets);
let body = m.pulls[0]["body"].as_str().unwrap();
assert!(body.contains("(native-mod, update: turns native"), "{body}");
// Reviewed by hand: never merged as an owner update.
assert!(
!body.contains("owner-update") && m.labels.is_empty(),
"{body}"
);
text.clone()
};
let entry: Entry = toml::from_str(&text).unwrap();
assert_eq!(entry.kind.name(), "native-mod");
let files: Vec<&str> = entry.versions.iter().map(|v| v.file.as_str()).collect();
assert_eq!(files, ["coords_hud-0.2.0.hnmod", "coords_hud-0.1.0.hmod"]);
// Once native, never back.
mock.lock()
.unwrap()
.main
.insert("mods/coords_hud.toml".into(), text);
let wasm = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
let (status, json) = client.submit(&csrf, &form(), &wasm).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
assert_eq!(
json["error"],
"coords_hud is a native-mod, not a mod: a project's kind never changes (only a mod may turn native-mod, once)"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_project_is_listed_by_its_repository_and_never_updated_by_the_form() {
let (mut client, mock) = setup().await;
+1 -1
View File
@@ -10,7 +10,7 @@ description = "Horizon's mod files: content mods, modpacks and the registry inde
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1.6"
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
log = "0.4"
# The functions a published WebAssembly mod imports, for its API level (src/api_level.rs): the registry index.
wasmparser = { version = "0.254", optional = true }
+1 -1
View File
@@ -247,7 +247,7 @@ impl Content {
let mut out = Vec::new();
for i in 0..zip.len() {
let entry = zip.by_index_raw(i).map_err(|e| e.to_string())?;
let name = entry.name();
let name = entry.name().map_err(|e| e.to_string())?.into_owned();
if (name.starts_with("assets/") || name.starts_with("data/")) && !entry.is_dir() {
out.push((name.to_owned(), entry.size()));
}
+2 -1
View File
@@ -625,7 +625,8 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
.by_index_raw(i)
.map_err(|e| e.to_string())?
.name()
.to_owned();
.map_err(|e| e.to_string())?
.into_owned();
if name.starts_with('/') || name.split('/').any(|part| part == "..") || name.contains('\\')
{
return Err(format!("{name:?} is not a plain path"));
+1 -1
View File
@@ -103,7 +103,7 @@ impl Bundle {
let mut total = 0u64;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
let name = entry.name().to_owned();
let name = entry.name().map_err(|e| e.to_string())?.into_owned();
if entry.is_dir() {
continue;
}
+1 -1
View File
@@ -128,7 +128,7 @@ impl Modpack {
let (mut overrides, mut total) = (Vec::new(), 0u64);
for i in 0..zip.len() {
let entry = zip.by_index_raw(i).map_err(|e| e.to_string())?;
let name = entry.name();
let name = entry.name().map_err(|e| e.to_string())?.into_owned();
if name == MANIFEST || entry.is_dir() {
continue;
}
+1 -1
View File
@@ -16,7 +16,7 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
sha1 = "0.10"
toml = "1.1.6"
ureq = { version = "2", default-features = false, features = ["tls"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[lints]
workspace = true
+8 -5
View File
@@ -532,8 +532,8 @@ fn extra_namespaces(
pack_mod: &dyn Fn(&str) -> bool,
) -> Vec<String> {
let mut candidates = BTreeSet::new();
for name in zip.file_names() {
if let Some((_, ns, _)) = split(name)
for name in zip.file_names().filter_map(Result::ok) {
if let Some((_, ns, _)) = split(&name)
&& ns != id
&& !NOT_CLAIMED.contains(&ns)
&& !pack_mod(ns)
@@ -550,7 +550,10 @@ fn extra_namespaces(
let Ok(mut entry) = zip.by_index(i) else {
continue;
};
let Some((root, ns, rest)) = split(entry.name()) else {
let Ok(name) = entry.name().map(|n| n.into_owned()) else {
continue;
};
let Some((root, ns, rest)) = split(&name) else {
continue;
};
let ns = match () {
@@ -559,7 +562,7 @@ fn extra_namespaces(
_ => continue,
};
let mut bytes = Vec::new();
if entry.name().ends_with(".json") && entry.read_to_end(&mut bytes).is_ok() {
if name.ends_with(".json") && entry.read_to_end(&mut bytes).is_ok() {
texts.push((ns, bytes));
}
}
@@ -750,7 +753,7 @@ pub fn carry(
.collect();
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
let name = entry.name().to_owned();
let name = entry.name().map_err(|e| e.to_string())?.into_owned();
let Some(keep) = place(&name, &owned) else {
continue;
};
+5 -1
View File
@@ -95,7 +95,11 @@ pub fn check(jar_path: &Path, out: &Path) -> Result<Check, String> {
let info = jar::read(jar_path)?;
let file = fs::File::open(jar_path).map_err(|e| format!("{}: {e}", jar_path.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", jar_path.display()))?;
let names: Vec<String> = zip.file_names().map(str::to_owned).collect();
let names: Vec<String> = zip
.file_names()
.map(|name| name.map(|name| name.into_owned()))
.collect::<Result<_, _>>()
.map_err(|e| format!("{}: {e}", jar_path.display()))?;
let mut bytes = Vec::new();
for n in names.iter().filter(|n| n.ends_with(".class")) {
let mut b = Vec::new();
+7 -4
View File
@@ -92,10 +92,10 @@ pub fn import(path: &Path) -> Result<Pack, String> {
let file = fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", path.display()))?;
if let Some(index) = read_json(&mut zip, "modrinth.index.json")? {
return Ok(mrpack(&index, &names(&zip)));
return Ok(mrpack(&index, &names(&zip)?));
}
if let Some(manifest) = read_json(&mut zip, "manifest.json")? {
let mut pack = curseforge(&manifest, &names(&zip));
let mut pack = curseforge(&manifest, &names(&zip)?);
// The manifest names files only by CurseForge ids, which need a key
// to resolve; the pack's own mod list names them and gives their
// SHA-1s, so Modrinth can answer for most.
@@ -112,8 +112,11 @@ pub fn import(path: &Path) -> Result<Pack, String> {
))
}
fn names(zip: &zip::ZipArchive<fs::File>) -> Vec<String> {
zip.file_names().map(str::to_owned).collect()
fn names(zip: &zip::ZipArchive<fs::File>) -> Result<Vec<String>, String> {
zip.file_names()
.map(|name| name.map(|name| name.into_owned()))
.collect::<Result<_, _>>()
.map_err(|e| e.to_string())
}
fn read_json(zip: &mut zip::ZipArchive<fs::File>, name: &str) -> Result<Option<Value>, String> {
+5 -1
View File
@@ -56,7 +56,11 @@ pub fn read(path: &Path) -> Result<JarInfo, String> {
.and_then(|m| manifest_value(&m, "Implementation-Version"))
.unwrap_or_default();
}
let names: Vec<String> = zip.file_names().map(str::to_owned).collect();
let names: Vec<String> = zip
.file_names()
.map(|name| name.map(|name| name.into_owned()))
.collect::<Result<_, _>>()
.map_err(|e| format!("{}: {e}", path.display()))?;
info.census = census(&names);
if info.mod_id.is_empty() {
info.mod_id = path
+3 -1
View File
@@ -51,7 +51,9 @@ impl ProcessorData {
let Ok(mut entry) = zip.by_index(i) else {
continue;
};
let name = entry.name().to_owned();
let Ok(name) = entry.name().map(|n| n.into_owned()) else {
continue;
};
let Some((ns, rest)) = name
.strip_prefix("data/")
.and_then(|r| r.strip_suffix(".json"))
@@ -393,7 +393,7 @@ pub fn stage(source: &Path, work: &Path, pack: &import::Pack) -> Result<Report,
let mut layer = Files::new();
for i in 0..zip.len() {
let entry = zip.by_index(i).map_err(|e| e.to_string())?;
let raw = entry.name().to_owned();
let raw = entry.name().map_err(|e| e.to_string())?.into_owned();
if raw.starts_with(&format!("{root}/shaderpacks/")) {
report.set(
"shaderpacks/",
+8 -6
View File
@@ -279,14 +279,16 @@ pub fn read_pack(path: &Path) -> Result<BTreeMap<String, Vec<u8>>, String> {
if entry.is_dir() {
continue;
}
// A pack from the internet may name entries `../x` or `/x`.
let name = entry
.enclosed_name()
.filter(|name| is_inside(name))
.ok_or_else(|| at(&format!("entry {:?} leaves the pack", entry.name())))?;
// A pack from the internet may name entries `../x` or `/x`. The
// stored name is checked: zip 9's `enclosed_name` drops a leading
// `..` instead of refusing it.
let raw = String::from_utf8_lossy(entry.name_raw()).replace('\\', "/");
if entry.enclosed_name().is_none() || !is_inside(Path::new(&raw)) {
return Err(at(&format!("entry {raw:?} leaves the pack")));
}
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).map_err(|e| at(&e))?;
files.insert(name.to_string_lossy().replace('\\', "/"), bytes);
files.insert(raw, bytes);
}
}
if !files.contains_key("pack.mcmeta") {
+9 -3
View File
@@ -538,7 +538,9 @@ pub fn piece_counts<R: Read + std::io::Seek>(
let Ok(mut entry) = zip.by_index(i) else {
continue;
};
let name = entry.name().to_owned();
let Ok(name) = entry.name().map(|n| n.into_owned()) else {
continue;
};
let Some((ns, rest)) = name
.strip_prefix("data/")
.and_then(|r| r.strip_suffix(".json"))
@@ -705,7 +707,9 @@ pub fn structure_set_tags<R: Read + std::io::Seek>(
};
let Some(id) = entry
.name()
.strip_prefix("data/")
.ok()
.as_deref()
.and_then(|n| n.strip_prefix("data/"))
.and_then(|r| r.split_once("/tags/worldgen/structure_set/"))
.and_then(|(ns, path)| Some(format!("{ns}:{}", path.strip_suffix(".json")?)))
else {
@@ -778,7 +782,9 @@ pub fn loader_biome_tags(work: &Path) -> BTreeMap<String, Value> {
};
let Some(path) = entry
.name()
.strip_prefix("data/c/tags/worldgen/biome/")
.ok()
.as_deref()
.and_then(|n| n.strip_prefix("data/c/tags/worldgen/biome/"))
.and_then(|p| p.strip_suffix(".json"))
.map(str::to_owned)
else {
+1 -1
View File
@@ -10,7 +10,7 @@ crc32fast = "1"
regex = { version = "1", default-features = false, features = ["std", "unicode-perl"] }
sha1 = "0.10"
sha2 = "0.10"
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
libc = "0.2"
+1 -1
View File
@@ -110,7 +110,7 @@ pub fn inventory(jar: &Path) -> Result<Value, String> {
let mut method_count = 0;
for index in 0..archive.len() {
let mut item = archive.by_index(index).map_err(|e| e.to_string())?;
let name = item.name().to_string();
let name = item.name().map_err(|e| e.to_string())?.into_owned();
if !name.ends_with(".class") {
continue;
}
+9 -7
View File
@@ -236,16 +236,18 @@ pub(crate) fn zip_text(
}
/// The archive's member names in its own order (Python's `namelist`).
fn zip_names(archive: &zip::ZipArchive<fs::File>) -> Vec<String> {
(0..archive.len())
.filter_map(|i| archive.name_for_index(i).map(str::to_string))
.collect()
fn zip_names(archive: &zip::ZipArchive<fs::File>) -> Result<Vec<String>, String> {
archive
.file_names()
.map(|name| name.map(|name| name.into_owned()))
.collect::<Result<_, _>>()
.map_err(|e| e.to_string())
}
/// `{"minecraft:<path>": text}` for every `<prefix><path>.json`, sorted by member name
/// (the comprehension the Python preparer used for tags, worldgen and damage resources).
fn json_resources(archive: &mut zip::ZipArchive<fs::File>, prefix: &str) -> Result<Value, String> {
let mut names: Vec<String> = zip_names(archive)
let mut names: Vec<String> = zip_names(archive)?
.into_iter()
.filter(|n| n.starts_with(prefix) && n.ends_with(".json"))
.collect();
@@ -968,7 +970,7 @@ fn extract_client_resources(
let mut client = open_zip(jar)?;
let version = pyjson::loads(&zip_text(&mut client, "version.json")?)?;
let mut count = 0;
for name in zip_names(&client) {
for name in zip_names(&client)? {
if !(name.starts_with("assets/") || name.starts_with("data/") || name == "pack.png")
|| name.ends_with('/')
{
@@ -1093,7 +1095,7 @@ fn prepare_worldgen(run: &Run) -> Result<(), String> {
// Structure templates: gzip NBT files under structure/<namespace>/<path>.nbt.
let mut server = open_zip(&run.server)?;
let prefix = "data/minecraft/structure/";
for name in zip_names(&server) {
for name in zip_names(&server)? {
if name.starts_with(prefix) && name.ends_with(".nbt") {
let relative = member_path(&name[prefix.len()..], "Invalid structure path")?;
let data = zip_bytes(&mut server, &name)?;
+1 -1
View File
@@ -25,7 +25,7 @@ horizon-release-sign = { path = "../horizon-release-sign" }
sha2 = "0.10"
ureq = { version = "2", default-features = false, features = ["tls"] }
# Backups: one zip anyone can open, Windows included.
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
# SIGINT/SIGTERM/SIGHUP: stop the game cleanly when the terminal or the service manager asks.
+1 -1
View File
@@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
toml = "1.1.6"
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[lints]
workspace = true
+1 -1
View File
@@ -72,7 +72,7 @@ impl Pack {
if entry.is_dir() {
continue;
}
let name = entry.name().to_owned();
let name = entry.name().map_err(|e| e.to_string())?.into_owned();
let mut bytes = Vec::new();
(&mut entry)
.take(MAX_OTHER.max(MAX_WGSL) + 1)
+1 -1
View File
@@ -9,7 +9,7 @@ description = "Shader packs (the shaders/ folder format): reading packs, their .
[dependencies]
horizon-core = { path = "../horizon-core" }
naga = { version = "30.0.1", features = ["glsl-in", "wgsl-in"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[lints]
workspace = true
+15 -7
View File
@@ -84,13 +84,21 @@ impl Pack {
fn zip_files(bytes: Vec<u8>) -> Result<Files, PackError> {
let archive = ZipArchive::new(Cursor::new(Arc::from(bytes)))
.map_err(|e| PackError::Zip(e.to_string()))?;
let entries: BTreeMap<String, usize> = (0..archive.len())
.filter_map(|i| {
let name = archive.name_for_index(i)?.replace('\\', "/");
let inner = name.strip_prefix(ROOT)?.strip_prefix('/')?;
(!inner.is_empty() && !inner.ends_with('/')).then(|| (inner.to_owned(), i))
})
.collect();
let mut entries = BTreeMap::new();
for i in 0..archive.len() {
let Some(name) = archive.name_for_index(i) else {
continue;
};
let name = name
.map_err(|e| PackError::Zip(e.to_string()))?
.replace('\\', "/");
if let Some(inner) = name.strip_prefix(ROOT).and_then(|n| n.strip_prefix('/'))
&& !inner.is_empty()
&& !inner.ends_with('/')
{
entries.insert(inner.to_owned(), i);
}
}
if entries.is_empty() {
return Err(PackError::NoShadersFolder);
}
+3 -2
View File
@@ -17,7 +17,7 @@ It runs at **https://telemetry.playhorizon.dev** (`DEFAULT_ENDPOINT` in `crates/
| `GET /v1/benchmarks/summary` | Players' benchmark uploads grouped by GPU, CPU, render distance and game, for the website's Benchmarks page (below). Cached 10 minutes, same `Access-Control-Allow-Origin`. |
| `POST /v1/live` | A game's "I'm playing" ping (JSON, at most 512 bytes). Returns 204. Returns 400 for a bad ping, 413 for one too large, 429 over the address's limit, 503 when it holds `LIVE_MAX_SESSIONS` already. |
| `GET /v1/live` | How many play now, exactly: `playing`, `lan`, `versions` (the 12 most played, the rest under `other`), `platforms`. Made again at most every 15 seconds, with the same `Access-Control-Allow-Origin`. |
| `GET /v1/live/history?range=24h\|7d\|30d\|all` | The live count over time for playhorizon.dev/stats: `series` of `[start, average, peak]`, the range's `peak` and the `all_time` one with their minute, the average per platform ([docs/telemetry.md](../../docs/telemetry.md)). Made again at most every minute (`Cache-Control: max-age=60`), on the live count's rate limit. 400 for another range. |
| `GET /v1/live/history?range=24h\|7d\|30d\|all&series=players\|official` | The live count (`players`, the default) or the official server's player count (`official`) over time for playhorizon.dev/stats: `series` of `[start, average, peak]`, the range's `peak` and the `all_time` one with their minute, the `latest` minute, the average per platform ([docs/telemetry.md](../../docs/telemetry.md)). Made again at most every minute (`Cache-Control: max-age=60`), on the live count's rate limit. 400 for another range or series. |
| `POST /hub/vote` | `{"item": "<roadmap id>", "voter": "<the browser's random id>", "up": true}`: a 👍 on a roadmap item, one per voter; `"up": false` takes it back. Returns `{"item", "votes"}`. 400 for a bad id, 429 over the address's limit. |
| `GET /hub/votes` | `{"votes": {"<item>": n}}`. Made again at most every minute. |
| `POST /hub/report` | A bug report from the hub's form: `{"title", "did", "happened", "expected", "version"?, "system", "name"?}`. Opens a GitHub issue at once (labels `bug` and `from-website`, the `#horizon-bugs` template, "Reported on playhorizon.dev") and returns 201 `{"id", "issue": "<url>"}`. If GitHub fails: 202 `{"id", "issue": null}`, and the upkeep tries again for a week. The same report twice in a day returns the first one's issue. 400 for an empty or too long field, 429 over the address's limit, 503 past the day's cap or without `GITHUB_TOKEN`. |
@@ -58,7 +58,7 @@ Frame rates are whole numbers, milliseconds have one decimal under 10, megabytes
Measured: **about 190 bytes per play session** on disk, indexes included (`tests/server.rs`, 10 000 sessions). 1 000 players playing twice a day for 180 days is about 70 MB.
The live count's history (`src/history.rs`) is three small tables of totals, never sessions: `live_minute` (one row a minute, kept `LIVE_MINUTE_DAYS`, 7), `live_hour` (sum, sample count, peak and its minute, kept `LIVE_HOUR_DAYS`, 90) and `live_day` (the same per UTC day, kept for good). The server writes one sample a minute, starting a whole window (2 minutes) after it starts so a restart doesn't record a dip. About 1 MB a year.
The live count's history (`src/history.rs`) is three small tables of totals, never sessions: `live_minute` (one row a minute, kept `LIVE_MINUTE_DAYS`, 7), `live_hour` (sum, sample count, peak and its minute, kept `LIVE_HOUR_DAYS`, 90) and `live_day` (the same per UTC day, kept for good). The server writes one sample a minute, starting a whole window (2 minutes) after it starts so a restart doesn't record a dip. About 1 MB a year. The `official_` tables hold the official server's player count the same way, from a server-list status ping every minute (`HORIZON_TELEMETRY_OFFICIAL_SERVER`, default `horizon_telemetry::server_status::OFFICIAL_ADDRESS`; `off` for none); a minute it didn't answer has no row.
The live count is a table in memory: a session ID, when it last pinged, its version, platform and LAN flag, about 100 bytes a session. A session counts while its last ping is under 2 minutes old and is forgotten after that. Nothing of it reaches the disk, so a restart empties it; it is whole again a minute later.
@@ -83,6 +83,7 @@ All are environment variables:
- `HORIZON_TELEMETRY_LIVE_PER_HOUR` per address, pings and reads (600, bursts of 60: ten players behind one address)
- `HORIZON_TELEMETRY_LIVE_WINDOW_SECONDS` (120)
- `HORIZON_TELEMETRY_LIVE_MAX_SESSIONS` (200 000)
- `HORIZON_TELEMETRY_OFFICIAL_SERVER` (`horizon.bunchy7s.dev`; `off` asks no server): the server whose player count is kept as the `official` series
- `HORIZON_TELEMETRY_LIVE_MINUTE_DAYS` (7) and `…_LIVE_HOUR_DAYS` (90, at least 31): how long the history keeps minute and hourly samples; daily ones stay
- `HORIZON_TELEMETRY_HUB_PER_HOUR` votes and vote reads per address (600, bursts of 60: a page load is one read, and several players can share an address)
- `HORIZON_TELEMETRY_HUB_POSTS_PER_HOUR` bug reports per address (20, bursts of 2)
+195 -42
View File
@@ -9,6 +9,10 @@
//! its minute, kept [`crate::Config::live_hour_days`] (90 days).
//! - `live_day`: the same per day (UTC), kept forever.
//!
//! The `official_` tables hold the official server's player count the same way
//! ([`Series::Official`]), from its server-list status ping (`horizon_telemetry::server_status`),
//! playing only: a minute the server didn't answer has no row, so its downtime shows as a gap.
//!
//! The hour and day rows are updated with each minute, so they hold the current hour and day too.
//! Times are Unix seconds (UTC), each row's the start of its minute, hour or day.
use std::collections::BTreeMap;
@@ -36,6 +40,23 @@ CREATE TABLE IF NOT EXISTS live_day (
linux_sum INTEGER NOT NULL, macos_sum INTEGER NOT NULL, windows_sum INTEGER NOT NULL,
other_sum INTEGER NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS official_minute (
t INTEGER PRIMARY KEY, playing INTEGER NOT NULL, lan INTEGER NOT NULL,
linux INTEGER NOT NULL, macos INTEGER NOT NULL, windows INTEGER NOT NULL, other INTEGER NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS official_hour (
t INTEGER PRIMARY KEY, samples INTEGER NOT NULL, peak INTEGER NOT NULL, peak_at INTEGER NOT NULL,
sum INTEGER NOT NULL, lan_peak INTEGER NOT NULL, lan_sum INTEGER NOT NULL,
linux_sum INTEGER NOT NULL, macos_sum INTEGER NOT NULL, windows_sum INTEGER NOT NULL,
other_sum INTEGER NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS official_day (
t INTEGER PRIMARY KEY, samples INTEGER NOT NULL, peak INTEGER NOT NULL, peak_at INTEGER NOT NULL,
sum INTEGER NOT NULL, lan_peak INTEGER NOT NULL, lan_sum INTEGER NOT NULL,
linux_sum INTEGER NOT NULL, macos_sum INTEGER NOT NULL, windows_sum INTEGER NOT NULL,
other_sum INTEGER NOT NULL
) WITHOUT ROWID;
";
/// The per-platform columns, in [`PLATFORMS`]' order.
@@ -44,6 +65,37 @@ const PLATFORM_COLUMNS: [&str; 4] = ["linux", "macos", "windows", "other"];
const HOUR: i64 = 3600;
const DAY: i64 = 86_400;
/// Which count: the games' pings, or the official server's status.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Series {
Players,
Official,
}
impl Series {
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"players" => Self::Players,
"official" => Self::Official,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
Self::Players => "players",
Self::Official => "official",
}
}
fn prefix(self) -> &'static str {
match self {
Self::Players => "live",
Self::Official => "official",
}
}
}
/// One minute's count, as it is stored.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Sample {
@@ -119,12 +171,13 @@ enum Table {
}
impl Table {
fn name(self) -> &'static str {
match self {
Self::Minute => "live_minute",
Self::Hour => "live_hour",
Self::Day => "live_day",
}
fn name(self, series: Series) -> String {
let table = match self {
Self::Minute => "minute",
Self::Hour => "hour",
Self::Day => "day",
};
format!("{}_{table}", series.prefix())
}
/// The SQL for a row's samples, sum, peak and peak's time, and a platform's sum: a minute row
@@ -155,6 +208,8 @@ pub struct Peak {
/// `GET /v1/live/history`'s answer.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct History {
/// `players` or `official`.
pub source: &'static str,
pub range: &'static str,
/// Seconds per point.
pub step: i64,
@@ -168,6 +223,8 @@ pub struct History {
pub peak: Option<Peak>,
/// The highest count ever recorded and its minute.
pub all_time: Option<Peak>,
/// The newest minute's count, at most a few minutes old while the series is recorded.
pub latest: Option<Peak>,
/// The average count per platform over the range.
pub platforms: BTreeMap<String, f64>,
}
@@ -177,22 +234,31 @@ fn tenth(x: f64) -> f64 {
}
impl Store {
/// Writes down `sample` as the count of the minute starting at `minute` (Unix seconds), and
/// adds it to its hour and day. False, and nothing changes, when that minute has one already.
pub fn record_live(&mut self, minute: i64, sample: &Sample) -> rusqlite::Result<bool> {
/// Writes down `sample` as `series`' count of the minute starting at `minute` (Unix seconds),
/// and adds it to its hour and day. False, and nothing changes, when that minute has one
/// already.
pub fn record_live(
&mut self,
series: Series,
minute: i64,
sample: &Sample,
) -> rusqlite::Result<bool> {
let prefix = series.prefix();
let minute = minute - minute.rem_euclid(60);
let tx = self.db.transaction()?;
let [linux, macos, windows, other] = sample.platforms.map(|n| n as i64);
let (playing, lan) = (sample.playing as i64, sample.lan as i64);
let new = tx.execute(
"INSERT OR IGNORE INTO live_minute (t, playing, lan, linux, macos, windows, other)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
&format!(
"INSERT OR IGNORE INTO {prefix}_minute (t, playing, lan, linux, macos, windows, other)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
),
params![minute, playing, lan, linux, macos, windows, other],
)? == 1;
if new {
for (table, start) in [
("live_hour", minute - minute.rem_euclid(HOUR)),
("live_day", minute - minute.rem_euclid(DAY)),
(format!("{prefix}_hour"), minute - minute.rem_euclid(HOUR)),
(format!("{prefix}_day"), minute - minute.rem_euclid(DAY)),
] {
// SQLite evaluates every SET against the row as it was, so `peak_at` sees the old
// peak. An equal count keeps the earlier minute.
@@ -221,30 +287,35 @@ impl Store {
Ok(new)
}
/// Removes minutes older than `minute_days` and hours older than `hour_days` before `now`.
/// Days stay.
/// Removes minutes older than `minute_days` and hours older than `hour_days` before `now`,
/// in every series. Days stay.
pub fn prune_live(
&self,
now: i64,
minute_days: u32,
hour_days: u32,
) -> rusqlite::Result<usize> {
let minutes = self.db.execute(
"DELETE FROM live_minute WHERE t < ?1",
[now - i64::from(minute_days) * DAY],
)?;
let hours = self.db.execute(
"DELETE FROM live_hour WHERE t < ?1",
[now - i64::from(hour_days) * DAY],
)?;
Ok(minutes + hours)
let mut removed = 0;
for series in [Series::Players, Series::Official] {
let prefix = series.prefix();
removed += self.db.execute(
&format!("DELETE FROM {prefix}_minute WHERE t < ?1"),
[now - i64::from(minute_days) * DAY],
)?;
removed += self.db.execute(
&format!("DELETE FROM {prefix}_hour WHERE t < ?1"),
[now - i64::from(hour_days) * DAY],
)?;
}
Ok(removed)
}
/// The count over `range`, up to `now` (Unix seconds).
pub fn live_history(&self, range: Range, now: i64) -> rusqlite::Result<History> {
/// `which` series over `range`, up to `now` (Unix seconds).
pub fn live_history(&self, which: Series, range: Range, now: i64) -> rusqlite::Result<History> {
let (table, step, span) = range.plan();
let (samples, sum, peak, peak_at, suffix) = table.columns();
let name = table.name();
let name = table.name(which);
let day_table = Table::Day.name(which);
let from = span.map_or(0, |span| {
let from = now - span;
from - from.rem_euclid(step)
@@ -284,7 +355,23 @@ impl Store {
let all_time = self
.db
.query_row(
"SELECT peak, peak_at FROM live_day ORDER BY peak DESC, peak_at ASC LIMIT 1",
&format!(
"SELECT peak, peak_at FROM {day_table} ORDER BY peak DESC, peak_at ASC LIMIT 1"
),
[],
|r| {
Ok(Peak {
playing: r.get::<_, i64>(0)?.max(0) as u64,
at: r.get(1)?,
})
},
)
.optional()?;
let minute_table = Table::Minute.name(which);
let latest = self
.db
.query_row(
&format!("SELECT playing, t FROM {minute_table} ORDER BY t DESC LIMIT 1"),
[],
|r| {
Ok(Peak {
@@ -309,6 +396,7 @@ impl Store {
},
)?;
Ok(History {
source: which.name(),
range: range.name(),
step,
from,
@@ -316,6 +404,7 @@ impl Store {
series,
peak: peak_of(from)?,
all_time,
latest,
platforms: PLATFORMS
.iter()
.zip(platforms)
@@ -347,15 +436,31 @@ mod tests {
fn a_minute_is_recorded_once_and_rolls_up() {
let mut store = Store::open(":memory:").unwrap();
let day = 20_000 * DAY;
assert!(store.record_live(day + 10, &sample(4, 1)).unwrap());
assert!(
store
.record_live(Series::Players, day + 10, &sample(4, 1))
.unwrap()
);
// The same minute again (a late tick) changes nothing.
assert!(!store.record_live(day + 59, &sample(9, 9)).unwrap());
store.record_live(day + 60, &sample(8, 2)).unwrap();
store.record_live(day + 120, &sample(8, 8)).unwrap();
store.record_live(day + HOUR, &sample(2, 0)).unwrap();
assert!(
!store
.record_live(Series::Players, day + 59, &sample(9, 9))
.unwrap()
);
store
.record_live(Series::Players, day + 60, &sample(8, 2))
.unwrap();
store
.record_live(Series::Players, day + 120, &sample(8, 8))
.unwrap();
store
.record_live(Series::Players, day + HOUR, &sample(2, 0))
.unwrap();
let now = day + HOUR + 30;
let h = store.live_history(Range::Day, now).unwrap();
let h = store
.live_history(Series::Players, Range::Day, now)
.unwrap();
assert_eq!(h.series, [(day, 6.7, 8), (day + HOUR, 2.0, 2)]);
// The first minute that reached 8.
assert_eq!(
@@ -370,10 +475,14 @@ mod tests {
assert_eq!(h.platforms["macos"], 2.8);
assert!(!h.platforms.contains_key("windows"));
let week = store.live_history(Range::Week, now).unwrap();
let week = store
.live_history(Series::Players, Range::Week, now)
.unwrap();
assert_eq!(week.series, [(day, 6.7, 8), (day + HOUR, 2.0, 2)]);
assert_eq!(week.peak, h.peak);
let all = store.live_history(Range::All, now).unwrap();
let all = store
.live_history(Series::Players, Range::All, now)
.unwrap();
assert_eq!(all.series, [(day, 5.5, 8)]);
assert_eq!(all.step, DAY);
}
@@ -382,15 +491,23 @@ mod tests {
fn old_minutes_and_hours_go_and_days_stay() {
let mut store = Store::open(":memory:").unwrap();
let start = 20_000 * DAY;
store.record_live(start, &sample(30, 0)).unwrap();
store
.record_live(Series::Players, start, &sample(30, 0))
.unwrap();
let later = start + 100 * DAY;
store.record_live(later, &sample(3, 0)).unwrap();
store
.record_live(Series::Players, later, &sample(3, 0))
.unwrap();
assert_eq!(store.prune_live(later, 7, 90).unwrap(), 2);
let all = store.live_history(Range::All, later).unwrap();
let all = store
.live_history(Series::Players, Range::All, later)
.unwrap();
assert_eq!(all.series, [(start, 30.0, 30), (later, 3.0, 3)]);
assert_eq!(all.all_time.unwrap().playing, 30);
// The 24 hours hold only today; the all-time peak is still the old one.
let day = store.live_history(Range::Day, later + 60).unwrap();
let day = store
.live_history(Series::Players, Range::Day, later + 60)
.unwrap();
assert_eq!(day.series, [(later, 3.0, 3)]);
assert_eq!(day.peak.unwrap().playing, 3);
assert_eq!(day.all_time.unwrap().at, start);
@@ -399,11 +516,47 @@ mod tests {
#[test]
fn an_empty_history_has_no_peak() {
let store = Store::open(":memory:").unwrap();
let h = store.live_history(Range::Month, 20_000 * DAY).unwrap();
let h = store
.live_history(Series::Players, Range::Month, 20_000 * DAY)
.unwrap();
assert!(h.series.is_empty());
assert_eq!((h.peak, h.all_time), (None, None));
assert_eq!((h.peak, h.all_time, h.latest), (None, None, None));
assert!(h.platforms.is_empty());
assert_eq!(Range::parse("30d"), Some(Range::Month));
assert_eq!(Range::parse("1y"), None);
assert_eq!(Series::parse("official"), Some(Series::Official));
assert_eq!(Series::parse("lan"), None);
}
#[test]
fn the_official_series_is_kept_apart() {
let mut store = Store::open(":memory:").unwrap();
let day = 20_000 * DAY;
store
.record_live(Series::Players, day, &sample(40, 10))
.unwrap();
let official = Sample {
playing: 6,
..Sample::default()
};
assert!(store.record_live(Series::Official, day, &official).unwrap());
let h = store
.live_history(Series::Official, Range::Day, day + 60)
.unwrap();
assert_eq!(h.series, [(day, 6.0, 6)]);
assert_eq!(h.all_time.unwrap().playing, 6);
assert_eq!(
h.latest,
Some(Peak {
playing: 6,
at: day
})
);
assert!(h.platforms.is_empty());
let players = store
.live_history(Series::Players, Range::Day, day + 60)
.unwrap();
assert_eq!(players.all_time.unwrap().playing, 40);
assert_eq!(store.prune_live(day + 100 * DAY, 7, 90).unwrap(), 4);
}
}
+58 -14
View File
@@ -7,8 +7,9 @@
//! least 3 installs only (`summary`), cached; the website's Benchmarks page reads it.
//! - `POST /v1/live`: a game's anonymous "I'm playing" ping (`live`, in memory only).
//! - `GET /v1/live`: how many play now, exactly, cached briefly.
//! - `GET /v1/live/history?range=24h|7d|30d|all`: that count over time, written down once a minute
//! (`history`: totals only), cached a minute.
//! - `GET /v1/live/history?range=24h|7d|30d|all&series=players|official`: that count, or the
//! official server's (its status ping), over time, written down once a minute (`history`:
//! totals only), cached a minute.
//! - `/hub/…`: the hub's votes (`hub`) and bug reports (`intake`).
//! - `GET /healthz`.
//!
@@ -69,6 +70,9 @@ pub struct Config {
pub live_minute_days: u32,
/// Days its hourly totals are kept; at least 31, for the 30-day view. Daily ones stay.
pub live_hour_days: u32,
/// The official server, asked for its player count every minute (`history`'s `official`
/// series); None asks nothing.
pub official_server: Option<String>,
/// The hub's votes and reads of their counts an IP address may make an hour.
pub hub_per_hour: f64,
/// Bug reports an IP address may send an hour.
@@ -97,6 +101,7 @@ impl Default for Config {
live_max_sessions: 200_000,
live_minute_days: 7,
live_hour_days: 90,
official_server: Some(horizon_telemetry::server_status::OFFICIAL_ADDRESS.into()),
hub_per_hour: 600.0,
hub_posts_per_hour: 20.0,
hub_reports_per_day: 30,
@@ -111,7 +116,7 @@ impl Config {
/// The defaults, with `HORIZON_TELEMETRY_RETENTION_DAYS`, `…_MIN_INSTALL_DAYS`,
/// `…_REQUESTS_PER_HOUR`, `…_ITEMS_PER_DAY`, `…_TRUST_PROXY` (1), `…_ALLOW_ORIGIN`,
/// `…_LIVE_PER_HOUR`, `…_LIVE_WINDOW_SECONDS`, `…_LIVE_MAX_SESSIONS`, `…_LIVE_MINUTE_DAYS`,
/// `…_LIVE_HOUR_DAYS`, `…_HUB_PER_HOUR`,
/// `…_LIVE_HOUR_DAYS`, `…_OFFICIAL_SERVER` (`off` for none), `…_HUB_PER_HOUR`,
/// `…_HUB_POSTS_PER_HOUR`, `…_HUB_REPORTS_PER_DAY`, `…_HUB_KEEP_DAYS`, `…_GITHUB_TOKEN` and
/// `…_GITHUB_REPO`. An empty token counts as unset.
pub fn from_env() -> Self {
@@ -152,6 +157,11 @@ impl Config {
live_hour_days: var("LIVE_HOUR_DAYS")
.and_then(|v| v.parse().ok())
.unwrap_or(d.live_hour_days),
official_server: match var("OFFICIAL_SERVER").as_deref().map(str::trim) {
Some("" | "off") => None,
Some(address) => Some(address.to_owned()),
None => d.official_server,
},
hub_per_hour: var("HUB_PER_HOUR")
.and_then(|v| v.parse().ok())
.unwrap_or(d.hub_per_hour),
@@ -182,7 +192,7 @@ pub struct App {
/// `GET /v1/live`'s answer and when it was made.
live_cache: Mutex<Option<(Instant, String)>>,
/// `GET /v1/live/history`'s answers by range, with when they were made.
history_cache: Mutex<HashMap<history::Range, (Instant, String)>>,
history_cache: Mutex<HashMap<(history::Series, history::Range), (Instant, String)>>,
/// When the server started: the live count is whole only a window later.
started: Instant,
/// `/v1/aggregate` answers by `days`, with when they were made.
@@ -321,13 +331,42 @@ impl App {
return Ok(false);
}
let count = self.live().count(Instant::now());
self.record_sample(unix_now(), &history::Sample::of(&count))
self.record_sample(
history::Series::Players,
unix_now(),
&history::Sample::of(&count),
)
}
/// Writes down `sample` as the count of the minute holding `unix` (seconds). False when that
/// minute has one already.
pub fn record_sample(&self, unix: i64, sample: &history::Sample) -> rusqlite::Result<bool> {
self.store().record_live(unix, sample)
/// Asks the official server for its player count (a status ping, blocking, at most 5 seconds
/// a step) and writes it down as this minute's. False when there is no official server or it
/// didn't answer: that minute stays empty.
pub fn record_official(&self) -> rusqlite::Result<bool> {
let Some(address) = &self.config.official_server else {
return Ok(false);
};
let unix = unix_now();
match horizon_telemetry::server_status::players(address, Duration::from_secs(5)) {
Ok(players) => {
let sample = history::Sample {
playing: players.online,
..history::Sample::default()
};
self.record_sample(history::Series::Official, unix, &sample)
}
Err(_) => Ok(false),
}
}
/// Writes down `sample` as `series`' count of the minute holding `unix` (seconds). False when
/// that minute has one already.
pub fn record_sample(
&self,
series: history::Series,
unix: i64,
sample: &history::Sample,
) -> rusqlite::Result<bool> {
self.store().record_live(series, unix, sample)
}
/// Daily upkeep: totals rebuilt, expired rows removed, idle rate-limit entries and live
@@ -602,13 +641,15 @@ async fn playing(State(app): State<Arc<App>>, req: Request) -> Response {
#[derive(serde::Deserialize)]
struct HistoryQuery {
range: Option<String>,
series: Option<String>,
}
/// How long a history answer is reused: a new sample comes each minute.
const HISTORY_FOR: Duration = Duration::from_secs(60);
/// `GET /v1/live/history?range=24h|7d|30d|all` (default 24h): the live count over time
/// (`history::History`), made again at most every minute. 400 for another range.
/// `GET /v1/live/history?range=24h|7d|30d|all&series=players|official` (default 24h, players):
/// the live count, or the official server's, over time (`history::History`), made again at most
/// every minute. 400 for another range or series.
async fn live_history(
State(app): State<Arc<App>>,
Query(query): Query<HistoryQuery>,
@@ -620,22 +661,25 @@ async fn live_history(
let Some(range) = history::Range::parse(query.range.as_deref().unwrap_or("24h")) else {
return status(StatusCode::BAD_REQUEST, "range: 24h, 7d, 30d or all");
};
let Some(series) = history::Series::parse(query.series.as_deref().unwrap_or("players")) else {
return status(StatusCode::BAD_REQUEST, "series: players or official");
};
let cached = app
.history_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&range)
.get(&(series, range))
.filter(|(at, _)| at.elapsed() < HISTORY_FOR)
.map(|(_, json)| json.clone());
let json = match cached {
Some(json) => json,
None => match app.store().live_history(range, unix_now()) {
None => match app.store().live_history(series, range, unix_now()) {
Ok(history) => {
let json = serde_json::to_string(&history).unwrap_or_default();
app.history_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(range, (Instant::now(), json.clone()));
.insert((series, range), (Instant::now(), json.clone()));
json
}
Err(e) => {
+9 -1
View File
@@ -1,5 +1,6 @@
//! `horizon-telemetry-server`: serves on `PORT` (default 8080), keeps its data in
//! `HORIZON_TELEMETRY_DB` (default `telemetry.sqlite3`), writes the live count down every minute
//! `HORIZON_TELEMETRY_DB` (default `telemetry.sqlite3`), writes the live count and the official
//! server's down every minute
//! and does its upkeep every ten minutes.
use std::net::SocketAddr;
use std::time::Duration;
@@ -43,6 +44,13 @@ async fn main() {
if let Err(e) = recorder.record_live() {
eprintln!("horizon-telemetry-server: could not record the live count: {e}");
}
// The official server's count: a status ping, off the async workers.
let official = recorder.clone();
if let Ok(Err(e)) =
tokio::task::spawn_blocking(move || official.record_official()).await
{
eprintln!("horizon-telemetry-server: could not record the official server: {e}");
}
}
});
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port)))
+77 -11
View File
@@ -6,7 +6,7 @@ use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use horizon_telemetry::live::{self, Count, Ping};
use horizon_telemetry_server::history::Sample;
use horizon_telemetry_server::history::{Sample, Series};
use horizon_telemetry_server::store::Store;
use horizon_telemetry_server::{App, Config, router};
use http_body_util::BodyExt;
@@ -18,6 +18,7 @@ fn app(config: Config) -> Arc<App> {
fn quick() -> Config {
Config {
official_server: None,
live_per_hour: 100_000.0,
live_window: Duration::from_secs(1),
trust_proxy: true,
@@ -191,7 +192,7 @@ async fn the_games_ping_and_the_launchers_read_reach_it_end_to_end() {
}
async fn history(app: &Arc<App>, range: &str) -> (StatusCode, axum::http::HeaderMap, String) {
let req = Request::get(format!("/v1/live/history?range={range}"))
let req = Request::get(format!("/v1/live/history?{range}"))
.header("x-forwarded-for", "198.51.100.2")
.body(Body::empty())
.unwrap();
@@ -217,14 +218,23 @@ async fn the_history_serves_the_recorded_minutes_and_peaks() {
};
// An old peak, three days ago, then today's minutes.
assert!(
app.record_sample(minute - 3 * 86_400, &at(1234, 4))
app.record_sample(Series::Players, minute - 3 * 86_400, &at(1234, 4))
.unwrap()
);
assert!(
app.record_sample(Series::Players, minute - 120, &at(7, 2))
.unwrap()
);
assert!(
app.record_sample(Series::Players, minute - 60, &at(9, 3))
.unwrap()
);
assert!(
!app.record_sample(Series::Players, minute - 30, &at(50, 0))
.unwrap()
);
assert!(app.record_sample(minute - 120, &at(7, 2)).unwrap());
assert!(app.record_sample(minute - 60, &at(9, 3)).unwrap());
assert!(!app.record_sample(minute - 30, &at(50, 0)).unwrap());
let (code, headers, body) = history(&app, "24h").await;
let (code, headers, body) = history(&app, "range=24h").await;
assert_eq!(code, StatusCode::OK, "{body}");
assert_eq!(headers[header::CACHE_CONTROL], "public, max-age=60");
assert_eq!(
@@ -244,16 +254,16 @@ async fn the_history_serves_the_recorded_minutes_and_peaks() {
assert_eq!(day["platforms"]["linux"], 2.5);
assert_eq!(day["platforms"]["windows"], 5.5);
let (_, _, body) = history(&app, "7d").await;
let (_, _, body) = history(&app, "range=7d").await;
let week: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(week["step"], 3600);
assert_eq!(week["peak"]["playing"], 1234);
let (_, _, body) = history(&app, "all").await;
let (_, _, body) = history(&app, "range=all").await;
let all: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(all["step"], 86_400);
assert!(!all["series"].as_array().unwrap().is_empty());
assert_eq!(history(&app, "1y").await.0, StatusCode::BAD_REQUEST);
assert_eq!(history(&app, "range=1y").await.0, StatusCode::BAD_REQUEST);
}
#[tokio::test]
@@ -269,8 +279,64 @@ async fn the_recorder_waits_a_window_then_writes_the_exact_count() {
post(&app, ping(n, "0.2.0", n == 0), "203.0.113.7").await;
}
assert!(app.record_live().unwrap());
let (_, _, body) = history(&app, "24h").await;
let (_, _, body) = history(&app, "range=24h").await;
let day: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(day["peak"]["playing"], 3);
assert_eq!(day["platforms"]["linux"], 3.0);
}
#[tokio::test]
async fn the_official_servers_count_is_pinged_and_served_apart() {
// A stand-in official server answering the status ping with 4 players.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap().to_string();
std::thread::spawn(move || {
use std::io::{Read, Write};
let (mut socket, _) = listener.accept().unwrap();
let mut buf = [0; 256];
let _ = socket.read(&mut buf).unwrap();
let json = br#"{"players":{"online":4,"max":50}}"#;
let mut packet = vec![0x00, json.len() as u8];
packet.extend_from_slice(json);
let mut out = vec![packet.len() as u8];
out.extend(packet);
socket.write_all(&out).unwrap();
});
let app = app(Config {
official_server: Some(address),
..quick()
});
let recorded = tokio::task::spawn_blocking({
let app = app.clone();
move || app.record_official()
})
.await
.unwrap()
.unwrap();
assert!(recorded);
let (code, _, body) = history(&app, "range=24h&series=official").await;
assert_eq!(code, StatusCode::OK, "{body}");
let day: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(day["source"], "official");
assert_eq!(day["peak"]["playing"], 4);
// The players' series has nothing: no game pinged.
let (_, _, body) = history(&app, "range=24h").await;
let players: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(players["source"], "players");
assert!(players["peak"].is_null());
assert_eq!(history(&app, "series=lan").await.0, StatusCode::BAD_REQUEST);
// Nobody at the address: nothing is written.
let gone = app_with_official("127.0.0.1:1");
assert!(
!tokio::task::spawn_blocking(move || gone.record_official().unwrap())
.await
.unwrap()
);
}
fn app_with_official(address: &str) -> Arc<App> {
app(Config {
official_server: Some(address.into()),
..quick()
})
}
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more