mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge origin/main (picks up #5251's compile fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
7fc284b46a
131 files changed
+3710
-643
No files matched your search
+2
-2
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Generated
+107
-49
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
Reference in new issue
Block a user