Merge main into #7191

management_methods.rs keeps both helpers (allowlist_setting, main's autosave). rest-017.tsv keeps both rows. The management harness keeps main's autosave requests at ids 65-74 and moves this PR's allowlist requests to 75-92 and the restarted reads to 93-94.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 13:45:11 +01:00
commit da4e5bb604
90 files changed
+2986 -590

No files matched your search

+6 -1
View File
@@ -1206,8 +1206,13 @@ impl Audio {
/// `SoundManager.reload` (resource packs changed): stop every sound, then
/// read sound events and sound files from the new resource root.
pub fn reload(&mut self, root: &Path) {
self.reload_with(root, SoundRegistry::load(root));
}
/// [`Self::reload`] with the root's sound events already read
/// (`SoundRegistry::load`, which a pack reload runs off the render thread).
pub fn reload_with(&mut self, root: &Path, registry: SoundRegistry) {
self.stop_all();
self.engine.registry = SoundRegistry::load(root);
self.engine.registry = registry;
self.backend.root = root.to_owned();
let mut cache = self.backend.cache.lock().unwrap();
cache.entries.clear();
+16 -15
View File
@@ -182,12 +182,24 @@ where
movement: Vec3,
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = CollisionContext::position(bounds.min_position().y);
self.resolve_with_context(bounds, movement, max_step, on_ground, context)
}
/// [`Self::resolve`] with the entity's captured collision context.
pub fn resolve_with_context(
&mut self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
context: CollisionContext<'_>,
) -> Result<Vec3> {
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
context,
bounds,
movement,
max_step,
@@ -223,23 +235,12 @@ impl SceneCollisions<'_, '_> {
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
let context = self.context(bounds);
let mut scene = EntityCollisions {
EntityCollisions {
blocks: self,
entities: &[],
border,
};
Ok(resolve_world_movement(
&mut scene,
MovementRequest {
entity: EntityIdentity(0),
context,
bounds,
movement,
max_step,
on_ground,
candidate_budget: 1_000_000,
},
)?)
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
}
/// Resolve movement with the entity's captured collision context. Most
/// callers only need the position context; entities such as falling blocks
+13 -1
View File
@@ -50,6 +50,18 @@ fn parse_nine_slice(v: &serde_json::Value) -> Option<NineSlice> {
}
/// Read `gui.scaling` metadata for every GUI sprite under `root`.
pub fn load_metadata(root: &Path) {
set_metadata(read_metadata(root));
}
/// The GUI sprites' nine-slice metadata of a resource root, read off the
/// render thread by a pack reload ([`read_metadata`]) and put in use with
/// [`set_metadata`].
pub struct UiMetadata(HashMap<String, NineSlice>);
/// Puts read metadata in use ([`load_metadata`] in two halves).
pub fn set_metadata(metadata: UiMetadata) {
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(metadata.0);
}
/// Reads the metadata [`load_metadata`] puts in use, without putting it in use.
pub fn read_metadata(root: &Path) -> UiMetadata {
let mut map = HashMap::new();
let base = root.join("assets/minecraft/textures");
let mut stack = vec![base.join("gui/sprites")];
@@ -84,7 +96,7 @@ pub fn load_metadata(root: &Path) {
map.insert(key, nine_slice);
}
}
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
UiMetadata(map)
}
/// The sprite's nine-slice metadata, when its `.png.mcmeta` has any.
fn nine_slice_metadata(sprite: &str) -> Option<NineSlice> {
+82 -21
View File
@@ -91,6 +91,11 @@ pub struct Creative {
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
/// SessionSearchTrees.creativeByNameSearch: the lower-cased tooltip lines
/// of every search item in one language (keyed by the `itemGroup.search`
/// string), built on the first search and kept until the language or the
/// item list changes.
lines_cache: Mutex<Option<(String, Arc<[Vec<String>]>)>>,
}
/// The grid's stacks for one tab, search text and language.
struct Shown {
@@ -373,6 +378,7 @@ impl Creative {
exported_search,
permissions: false,
shown: Mutex::new(None),
lines_cache: Mutex::new(None),
};
creative.rebuild();
creative
@@ -398,6 +404,7 @@ impl Creative {
})
})
.collect();
*self.lines_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
(self.search_items, self.search_index) = self
.exported_search
.iter()
@@ -531,6 +538,46 @@ impl Creative {
/// the name tree, both over vanilla's suffix arrays (substring matches,
/// results in the tab's order).
pub fn search_results(&self, term: &str, names: &Names) -> Vec<usize> {
let english =
names.display_get("itemGroup.search").as_deref() == names.get("itemGroup.search");
let local = if term.is_empty() || term.starts_with('#') {
Arc::from([])
} else if english && self.search_index.iter().all(|e| !e.lines.is_empty()) {
// The exported English lines are the whole index.
Arc::from([])
} else {
let language = names.display_get("itemGroup.search").unwrap_or_default();
let mut cache = self.lines_cache.lock().unwrap_or_else(|e| e.into_inner());
match cache.as_ref().filter(|(l, _)| *l == language) {
Some((_, lines)) => lines.clone(),
None => {
let lines = self.localized_lines(&|k| names.display_get(k));
*cache = Some((language, lines.clone()));
lines
}
}
};
self.search_results_in(term, names, &local, english)
}
/// Every search item's tooltip lines through `translate`, lower-cased.
fn localized_lines(&self, translate: &dyn Fn(&str) -> Option<String>) -> Arc<[Vec<String>]> {
self.search_items
.iter()
.map(|s| {
crate::item_tooltip::lines(s, None, translate)
.iter()
.map(|l| java_lower(l))
.collect()
})
.collect()
}
fn search_results_in(
&self,
term: &str,
names: &Names,
local: &[Vec<String>],
english: bool,
) -> Vec<usize> {
let all = 0..self.search_index.len();
if term.is_empty() {
return all.collect();
@@ -552,18 +599,13 @@ impl Creative {
})
.collect();
}
// FullTextSearchTree: tooltip lines, and the item id after a colon.
// The export's lines are English (they come from the vanilla jars);
// the hover name in the selected language is matched too, so a
// localized search finds the item. It is the whole name (a tipped
// arrow's potion, the ominous banner), as the first line is.
// FullTextSearchTree: the creative tooltip lines in the selected
// language, and the item id after a colon. The export's lines are
// English (they come from the vanilla jars), so they stand in only
// while the language is English; otherwise every line (name, lore,
// effects, ...) is rebuilt through the language, as vanilla's
// getTooltipLines does.
let text = java_lower(term);
let localized = |i: usize| {
java_lower(&crate::item_tooltip::name_line(
&self.search_items[i],
&|k| names.display_get(k),
))
};
all.filter(|&i| {
let e = &self.search_index[i];
let fallback;
@@ -573,16 +615,17 @@ impl Creative {
} else {
&e.lines
};
let exported = english || e.lines.is_empty();
let local = local.get(i).map_or(&[][..], Vec::as_slice);
let hit = |t: &str| {
(exported && any(lines.iter().map(String::as_str), t))
|| any(local.iter().map(String::as_str), t)
};
match text.split_once(':') {
None => {
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
}
None => hit(&text),
Some((ns, path)) => {
let (ns, path) = (java_trim(ns), java_trim(path));
e.namespace.contains(ns)
&& (e.path.contains(path)
|| any(lines.iter().map(String::as_str), path)
|| localized(i).contains(path))
e.namespace.contains(ns) && (e.path.contains(path) || hit(path))
}
}
})
@@ -1396,15 +1439,33 @@ mod tests {
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
]),
};
let q = |s: &str| c.search_results(s, &names);
let tr = |k: &str| names.get(k).map(str::to_owned);
let local = c.localized_lines(&tr);
let q = |s: &str| c.search_results_in(s, &names, &local, false);
assert_eq!(q("铁砧"), [0]);
assert_eq!(q("木锄"), [2]);
// The English tooltip lines the export indexed still match.
assert_eq!(q("anvil"), [0]);
// Only the selected language's lines count outside English.
assert!(q("anvil").is_empty());
assert_eq!(c.search_results_in("anvil", &names, &local, true), [0]);
// A colon's path searches the display name too.
assert_eq!(q("minecraft:铁砧"), [0]);
}
#[test]
fn search_matches_every_localized_tooltip_line() {
// Vanilla's FullTextSearchTree indexes the selected language's
// tooltip lines; the export's English lines do not apply.
let c = creative(0);
let names = Names::default();
let ru: HashMap<&str, &str> = HashMap::from([("item.minecraft.anvil", "Наковальня")]);
let tr = |k: &str| ru.get(k).map(|v| (*v).to_owned());
let local = c.localized_lines(&tr);
let q = |s: &str, english| c.search_results_in(s, &names, &local, english);
assert_eq!(q("наков", false), [0]);
assert!(q("anvil", false).is_empty());
assert_eq!(q("anvil", true), [0]);
}
#[test]
fn scrollbar_drag_follows_the_mouse_while_held() {
let mut c = creative(90);
+83 -9
View File
@@ -12,7 +12,7 @@ use crate::{
world::World,
};
use horizon_core::{
geometry::{Aabb, Direction, Vec3, VoxelShape},
geometry::{Aabb, Direction, Vec3, VoxelShape, border::BorderGeometry},
position::BlockPos,
};
use horizon_world::{
@@ -261,6 +261,7 @@ impl ItemEntity {
no_physics: bool,
fluids: &crate::motion::FluidResources,
entity_boxes: &[Aabb],
border: Option<&BorderGeometry>,
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -292,7 +293,7 @@ impl ItemEntity {
// (`applyEffectsFromBlocksForLastMovements`), so lift builds up and
// the item moves on the next wake-up (#4558).
if !self.on_ground || horizontal > 1.0e-5 || (self.age + self.id).is_multiple_of(4) {
let mut scene = SceneCollisions {
let scene = SceneCollisions {
world,
binding: collision,
subject: None,
@@ -308,17 +309,14 @@ impl ItemEntity {
// Entity.move with noPhysics: straight through, the ground as it was.
let moved = if no_physics {
wanted
} else if entity_boxes.is_empty() {
scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
} else {
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
// too (a boat, a minecart, a shulker, a happy ghast held still).
// too (a boat, a minecart, a shulker, a happy ghast held still), and
// so does the world border (`Entity.collectColliders`).
crate::collisions::EntityCollisions {
blocks: scene,
entities: entity_boxes,
border: None,
border: border.map(|b| (b, [self.position.x, self.position.z])),
}
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
@@ -852,6 +850,7 @@ impl ExperienceOrb {
player: Option<[f64; 3]>,
server: bool,
next_float: &mut dyn FnMut() -> f32,
border: Option<&BorderGeometry>,
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -916,8 +915,10 @@ impl ExperienceOrb {
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
// push and before the move.
let wanted = self.velocity;
// Entity.collide: the world border's walls stop the orb too.
let border = border.map(|b| (b, [self.position.x, self.position.z]));
let moved = scene
.resolve(bounds, wanted, 0.0, self.on_ground)
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
.unwrap_or(Vec3::ZERO);
self.position = Vec3::new(
self.position.x + moved.x,
@@ -1954,6 +1955,73 @@ mod merge_tests {
}
}
#[cfg(test)]
mod world_border_tests {
use super::*;
/// `Entity.collectColliders` (#7080): the world border's wall stops an
/// item and an experience orb as it stops the player. A border of size
/// 10 around (20, 20) has its east wall at x = 25; each body starts at
/// x = 24 on the flat ground of the test scene, moving 1.5 east.
#[test]
fn the_world_border_stops_items_and_experience_orbs() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let world = World::test_scene(&assets, &render, 3).unwrap();
let border = BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984).unwrap();
for bordered in [false, true] {
let border = bordered.then_some(&border);
let mut item = super::honey_slide_tests::falling_item(24.0, 4.0, 20.5, 1.5, 0.0, 0.0);
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
border,
);
let mut orb = ExperienceOrb {
no_gravity: false,
id: 2,
position: Vec3::new(24.0, 4.0, 20.5),
previous: Vec3::new(24.0, 4.0, 20.5),
velocity: Vec3::new(1.5, 0.0, 0.0),
value: 1,
age: 0,
on_ground: true,
count: 1,
ticks: 0,
health: 5,
};
orb.tick(&world, &facts, &collision, None, true, &mut || 0.0, border);
for (what, east) in [
("item", item.bounds().max_position().x),
("orb", orb.bounds().max_position().x),
] {
if bordered {
assert!(
(east - 25.0).abs() < 1.0e-7,
"the {what} stops on the wall at x = 25, got {east}"
);
} else {
assert!(east > 25.0, "no border: the {what} goes on, got {east}");
}
}
}
}
}
#[cfg(test)]
mod honey_slide_tests {
use super::*;
@@ -1995,6 +2063,7 @@ mod honey_slide_tests {
false,
&fluids,
&[],
None,
);
ys.push(item.velocity.y);
}
@@ -2059,6 +2128,7 @@ mod honey_slide_tests {
false,
&fluids,
&[],
None,
);
}
assert!(
@@ -2355,6 +2425,7 @@ mod slime_bounce_tests {
false,
&fluids,
&[],
None,
);
assert_eq!(falling.position.y, 4.0);
assert!(falling.velocity.y > 0.0);
@@ -2373,6 +2444,7 @@ mod slime_bounce_tests {
false,
&fluids,
&[],
None,
);
assert!(!falling.needs_sync);
}
@@ -2465,6 +2537,7 @@ mod entity_collision_tests {
false,
&fluids,
boxes,
None,
);
}
item.position.y
@@ -2513,6 +2586,7 @@ mod entity_collision_tests {
false,
&fluids,
&[],
None,
);
}
item.position.x
+5 -2
View File
@@ -262,7 +262,8 @@ mod tests {
&collision,
false,
&fluids,
&[]
&[],
None
));
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
@@ -275,7 +276,8 @@ mod tests {
&collision,
false,
&fluids,
&[]
&[],
None
));
// First push is damped by buoyancy before motion, second follows drag.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
@@ -289,6 +291,7 @@ mod tests {
false,
&fluids,
&[],
None,
);
}
assert!(carried.position.x > at.x + 0.5);
+120 -27
View File
@@ -677,10 +677,10 @@ pub fn mob_to_nbt(
| "minecraft:trader_llama" => {
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
for key in ["EatingHaystack", "Bred"] {
map.entry(key.into()).or_insert(Tag::Byte(0));
}
map.insert("Bred".into(), Tag::Byte(i8::from(m.extra.bred)));
// AbstractHorse.addAdditionalSaveData; Horizon does not model the
// eating haystack yet.
map.entry("EatingHaystack".into()).or_insert(Tag::Byte(0));
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
}
@@ -1608,6 +1608,7 @@ pub fn mob_from_nbt(
if crate::mobs::equine::is_equine(m.species()) {
m.extra.tame = flag(tag, "Tame");
m.extra.variant3 = number(tag.get("Temper")).unwrap_or(0).clamp(0, 100) as u8;
m.extra.bred = flag(tag, "Bred");
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
let v = number(tag.get("Variant")).unwrap_or(0) as i32;
m.extra.variant = (v & 0xFF).clamp(0, 6) as u8;
@@ -2412,7 +2413,7 @@ const THROWABLE_ITEMS: [&str; 5] = [
fn throwable_item(kind: &ProjectileKind) -> Option<&str> {
match kind {
ProjectileKind::Thrown(t) => Some(t.item),
ProjectileKind::Potion(stack) => Some(&stack.item),
ProjectileKind::Potion(stack, _) => Some(&stack.item),
_ => THROWABLE_ITEMS.iter().find(|id| **id == kind.id()).copied(),
}
}
@@ -2428,20 +2429,37 @@ pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
"minecraft:snowball" => Some(ProjectileKind::Snowball),
"minecraft:egg" => Some(ProjectileKind::Egg),
"minecraft:ender_pearl" => Some(ProjectileKind::EnderPearl),
_ => Some(ProjectileKind::Potion(Box::new(
crate::items::ItemStack::new(id, 1),
))),
// The entity type is the id; the `Item` read later may be another potion.
_ => Some(ProjectileKind::Potion(
Box::new(crate::items::ItemStack::new(id, 1)),
id == "minecraft:lingering_potion",
)),
}
}
/// Whether its chunk saves it: the kinds `arrow_to_nbt`, `fireball_to_nbt`
/// and [`throwable_to_nbt`] write.
pub fn saved_with_chunk(p: &Projectile) -> bool {
matches!(
p.kind,
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
) || (p.arrow.is_some() && matches!(p.kind, ProjectileKind::Arrow { .. }))
|| throwable_item(&p.kind).is_some()
// No wildcard: a kind added to `ProjectileKind` must say here whether its
// chunk saves it (`Entity.shouldBeSaved` is true for every projectile in
// vanilla), and a `true` needs a writer in `entity_chunks`.
match &p.kind {
ProjectileKind::LargeFireball { .. }
| ProjectileKind::SmallFireball
| ProjectileKind::WindCharge
| ProjectileKind::BreezeWindCharge
| ProjectileKind::Snowball
| ProjectileKind::Egg
| ProjectileKind::EnderPearl
| ProjectileKind::Potion(..)
| ProjectileKind::Thrown(_) => true,
ProjectileKind::Arrow { .. } => p.arrow.is_some(),
// Not written yet: tridents save from their own list.
ProjectileKind::Trident
| ProjectileKind::LlamaSpit
| ProjectileKind::ShulkerBullet { .. }
| ProjectileKind::WitherSkull { .. } => false,
}
}
/// `ThrowableItemProjectile.addAdditionalSaveData`: `Projectile`'s keys and
@@ -2472,7 +2490,7 @@ pub fn throwable_to_nbt(
// effects); when there is none, `getItem`'s default is written.
if tag.get("Item").is_none() {
let stack = match &p.kind {
ProjectileKind::Potion(stack) => (**stack).clone(),
ProjectileKind::Potion(stack, _) => (**stack).clone(),
_ => crate::items::ItemStack::new(item, 1),
};
put(&mut tag, "Item", item_to_nbt(&stack));
@@ -2486,7 +2504,7 @@ pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Project
let mut kind = throwable_kind(&kind(tag)?)?;
// `readAdditionalSaveData` sets the `Item` it flies as, potion effects
// and all; a tag without one keeps `getItem`'s default.
if let ProjectileKind::Potion(stack) = &mut kind
if let ProjectileKind::Potion(stack, _) = &mut kind
&& let Some(item) = tag.get("Item").and_then(item_from_nbt)
{
**stack = item;
@@ -2745,12 +2763,25 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
put(&mut tag, "block_pos", Tag::IntArray(p.pos.to_vec()));
tag
}
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved; a
/// summoned one has none, and `setPos` puts its block at the block holding
/// the summon position.
fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => Some([v[0], v[1], v[2]]),
_ => {
let at = position(tag)?;
Some([
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
])
}
}
}
/// The block position, facing and variant of a saved painting.
pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> {
let pos = match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
_ => return None,
};
let pos = attached_block_pos(tag)?;
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
Some((pos, facing, text(tag.get("variant"))?))
}
@@ -2863,10 +2894,7 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
tag
}
pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
let pos = match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
_ => return None,
};
let pos = attached_block_pos(tag)?;
Some(ItemFrame {
id,
pos,
@@ -4902,7 +4930,10 @@ mod fireball_nbt_tests {
assert_eq!(p.kind.id(), id);
assert_eq!(
p.kind,
ProjectileKind::Potion(Box::new(crate::items::ItemStack::new(id, 1)))
ProjectileKind::Potion(
Box::new(crate::items::ItemStack::new(id, 1)),
id == "minecraft:lingering_potion"
)
);
assert!(saved_with_chunk(&p));
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
@@ -4924,7 +4955,7 @@ mod fireball_nbt_tests {
),
]);
let p = throwable_from_nbt(&harming, None).unwrap();
let ProjectileKind::Potion(stack) = &p.kind else {
let ProjectileKind::Potion(stack, _) = &p.kind else {
panic!("a potion");
};
assert_eq!(stack.potion(), Some("minecraft:harming"));
@@ -4948,6 +4979,67 @@ mod fireball_nbt_tests {
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
}
#[test]
fn wind_charges_in_flight_are_saved_and_read_back() {
// vanilla 26.3 saves them (`Entity.shouldBeSaved` is true; the round
// trip of a vanilla world lost all four before this).
for id in ["minecraft:wind_charge", "minecraft:breeze_wind_charge"] {
let saved = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[4.5, 80.0, 10.5])),
("NoGravity", Tag::Byte(1)),
("acceleration_power", Tag::Double(0.1_f64.to_bits())),
]);
let p = fireball_from_nbt(&saved, None).unwrap();
assert_eq!(p.kind.id(), id);
assert!(saved_with_chunk(&p), "{id}");
let tag = fireball_to_nbt(&p, [1; 4], None, Some(&saved)).unwrap();
assert_eq!(kind(&tag).as_deref(), Some(id));
assert_eq!(tag.get("NoGravity"), Some(&Tag::Byte(1)));
}
}
#[test]
fn a_lingering_potion_keeps_its_type_whatever_item_it_flies_as() {
// `/summon minecraft:lingering_potion ~ ~ ~ {Item:{id:"minecraft:splash_potion"}}`
// is a `ThrownLingeringPotion` in vanilla and stays one when saved.
let tag = Tag::compound([
("id", Tag::String("minecraft:lingering_potion".into())),
("Pos", doubles(&[24.5, 80.0, 8.5])),
(
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:splash_potion", 1)),
),
]);
let p = throwable_from_nbt(&tag, None).unwrap();
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
let back = throwable_to_nbt(&p, [1; 4], None, Some(&tag)).unwrap();
assert_eq!(kind(&back).as_deref(), Some("minecraft:lingering_potion"));
let item = back.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(&*item.item, "minecraft:splash_potion");
}
#[test]
fn a_summoned_painting_or_frame_has_no_block_pos_and_sits_at_its_position() {
// BlockAttachedEntity.setPos: the block holding the summon position.
let painting = Tag::compound([
("id", Tag::String("minecraft:painting".into())),
("Pos", doubles(&[28.5, 80.0, 6.5])),
("variant", Tag::String("minecraft:kebab".into())),
("facing", Tag::Byte(3)),
]);
assert_eq!(
painting_from_nbt(&painting),
Some(([28, 80, 6], 3, "minecraft:kebab".to_string()))
);
let frame = Tag::compound([
("id", Tag::String("minecraft:item_frame".into())),
("Pos", doubles(&[-0.5, 80.0, 6.5])),
("Facing", Tag::Byte(1)),
]);
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
@@ -4981,11 +5073,12 @@ mod fireball_nbt_tests {
let resaved = throwable_to_nbt(&away, [1; 4], away.owner_uuid, None).unwrap();
assert_eq!(uuid_from_tag(resaved.get("Owner")), player);
}
// the chunk saves arrows, fireballs and these, and so takes them with it.
// the chunk saves arrows, fireballs, wind charges and these, and so takes them with it.
let kinds = [
(ProjectileKind::SmallFireball, true),
(ProjectileKind::Snowball, true),
(ProjectileKind::WindCharge, false),
(ProjectileKind::WindCharge, true),
(ProjectileKind::BreezeWindCharge, true),
(ProjectileKind::Trident, false),
];
for (kind, saved) in kinds {
+60 -39
View File
@@ -3828,9 +3828,7 @@ impl Game<'_> {
stuck: Vec3::ZERO,
});
// ServerLevel.addFreshEntity.
if !self.native_entity_joins(self.next_entity, "minecraft:item", position, false) {
self.dropped.pop();
} else {
if self.native_joins_flush(false, self.next_entity) {
self.native_item_spawn(self.next_entity, &item, count, position);
}
}
@@ -3943,8 +3941,8 @@ impl Game<'_> {
// THROW and a creative tab's drop included.
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
// Player.drop: the drop statistics.
self.stats.add("dropped", &thrown.item, thrown.count as i32);
self.stats.custom("drop", 1);
self.award_stat("dropped", &thrown.item, thrown.count as i32);
self.award_custom_stat("drop", 1);
let player = self.native_acting_id();
self.native_item_dropped(player, &thrown.item, thrown.count);
self.throw_from_eyes(thrown, eye, true);
@@ -4062,6 +4060,7 @@ impl Game<'_> {
no_physics,
&self.res.motion().fluids,
&nearby,
Some(self.border.geometry()),
);
cells.moved(i, item.previous, item.position);
// Entity.handleOnAboveBubbleColumn: the server sends the particles of
@@ -4171,7 +4170,7 @@ impl Game<'_> {
let thrower = item.thrower;
match self.give(item.stack.clone()) {
None => {
self.stats.add("picked_up", &kind, before as i32);
self.award_stat("picked_up", &kind, before as i32);
if let Some(thrower) = thrower {
self.trigger_thrown_picked_up_by_player(&item.stack, thrower);
}
@@ -4190,8 +4189,7 @@ impl Game<'_> {
}
Some(rest) => {
if rest.count < before {
self.stats
.add("picked_up", &kind, (before - rest.count) as i32);
self.award_stat("picked_up", &kind, (before - rest.count) as i32);
if let Some(thrower) = thrower {
self.trigger_thrown_picked_up_by_player(&rest, thrower);
}
@@ -4422,7 +4420,7 @@ impl Game<'_> {
});
// NoteBlock.attack awards the statistic.
if note {
self.stats.custom("play_noteblock", 1);
self.award_custom_stat("play_noteblock", 1);
}
self.process_level_events()
}
@@ -5207,7 +5205,7 @@ impl Game<'_> {
PlayerEffect::DropItem(_) => {}
PlayerEffect::EatCake => {
// CakeBlock.eat
self.stats.custom("eat_cake_slice", 1);
self.award_custom_stat("eat_cake_slice", 1);
if let Some(p) = self.player.as_mut() {
let food = &mut p.health.food;
food.food = (food.food + 2).min(20);
@@ -5422,7 +5420,7 @@ impl Game<'_> {
} else if &*name == "minecraft:ender_chest" {
self.open_ender_chest(key);
// EnderChestBlock.useWithoutItem
self.stats.custom("open_enderchest", 1);
self.award_custom_stat("open_enderchest", 1);
self.anger_piglins(true);
if self.container_opener_event(key, true) {
self.container_edge_sound(key, true);
@@ -5461,7 +5459,7 @@ impl Game<'_> {
_ => "",
};
if !stat.is_empty() {
self.stats.custom(stat, 1);
self.award_custom_stat(stat, 1);
}
// Opening a piglin-guarded container angers the piglins who see it.
if self.guarded_by_piglins(&name) {
@@ -5538,7 +5536,7 @@ impl Game<'_> {
f.stuck = Vec3::ZERO;
f.velocity = Vec3::ZERO;
}
let mut scene = collisions::SceneCollisions {
let scene = collisions::SceneCollisions {
world: &self.world,
binding: self.collision,
subject: Some(collisions::PowderSnowSubject {
@@ -5557,7 +5555,13 @@ impl Game<'_> {
false,
);
// On an error the caller keeps the block as it was before this tick.
let movement = scene.resolve_with_context(
// Entity.collide: the world border's walls stop it too.
let movement = collisions::EntityCollisions {
blocks: scene,
entities: &[],
border: Some((self.border.geometry(), [f.position.x, f.position.z])),
}
.resolve_with_context(
horizon_client::falling_block::bounds(f.position),
wanted,
0.0,
@@ -5742,7 +5746,7 @@ impl Game<'_> {
if wears && per_block > 0 && max > 0 {
self.damage_held(per_block);
}
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
}
fn break_block(&mut self, pos: [i32; 3]) -> Result<()> {
self.lan_break_pos = Some(pos);
@@ -5851,7 +5855,7 @@ impl Game<'_> {
.correct_for_drops(held.as_ref(), &facts.name);
if harvest {
// Block.playerDestroy: the mined statistic, the exhaustion and the drops.
self.stats.add("mined", &facts.name, 1);
self.award_stat("mined", &facts.name, 1);
if let Some(p) = self.player.as_mut() {
p.cause_food_exhaustion(0.005);
}
@@ -6230,7 +6234,7 @@ impl Game<'_> {
&& result == horizon_world::level::InteractionResult::Success
&& let Some(used) = held.as_ref()
{
self.stats.add("used", &used.item, 1);
self.award_stat("used", &used.item, 1);
self.consume_held();
}
// FlowerPotBlock.useItemOn: the plant is potted, used up, and counted.
@@ -6238,8 +6242,8 @@ impl Game<'_> {
&& result == horizon_world::level::InteractionResult::Success
&& let Some(used) = held.as_ref()
{
self.stats.add("used", &used.item, 1);
self.stats.custom("pot_flower", 1);
self.award_stat("used", &used.item, 1);
self.award_custom_stat("pot_flower", 1);
self.consume_held();
if let (Some(player), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
@@ -6258,8 +6262,8 @@ impl Game<'_> {
.name
.clone();
match &*name {
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
"minecraft:bell" => self.stats.custom("bell_ring", 1),
"minecraft:note_block" => self.award_custom_stat("tune_noteblock", 1),
"minecraft:bell" => self.award_custom_stat("bell_ring", 1),
"minecraft:lever" => self.lever_puff(hit.pos),
_ => {}
}
@@ -6499,7 +6503,7 @@ impl Game<'_> {
// BlockItem.useOn: a consumable that could not be placed is used,
// which ItemStack.useOn counts as a use of the item.
if self.start_eating(&info) {
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
} else {
// BlockItem.place's FAIL.
self.use_failed = true;
@@ -6546,7 +6550,7 @@ impl Game<'_> {
}
self.process_level_events()?;
// BlockItem.place: the used statistic and the placed_block trigger.
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.trigger_block("minecraft:placed_block", target, &stack);
self.native_place_block(&stack, target, state);
self.check_wither_spawn(target)?;
@@ -6640,7 +6644,7 @@ impl Game<'_> {
let group = horizon_client::cooldowns::group(stack, None);
self.add_cooldown(group, instrument.use_ticks());
}
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
}
fn apply_consume_effect(&mut self, effect: &horizon_client::items::ConsumeEffect) {
use horizon_client::items::ConsumeEffect;
@@ -6874,7 +6878,7 @@ impl Game<'_> {
0.8,
pitch,
);
self.stats.add("used", &item, 1);
self.award_stat("used", &item, 1);
}
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
@@ -7277,6 +7281,8 @@ impl Game<'_> {
/// What the tick runs after the local player's own update: effects, the
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
// Entities added since the last tick join the level before it ticks.
self.native_joins_flush(false, 0);
self.share_spawn_conditions();
horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes"));
self.update_chunk_tickets();
@@ -10297,7 +10303,7 @@ impl Game<'_> {
// (native_item_crafted).
let player = self.native_acting_id();
self.native_item_crafted(player, &format!("minecraft:{recipe}"), &taken);
self.stats.add("crafted", &made.0, made.1 as i32);
self.award_stat("crafted", &made.0, made.1 as i32);
self.award_crafted(&[(format!("minecraft:{recipe}"), ingredients)]);
if !shift || crafted >= 64 {
break;
@@ -11365,8 +11371,7 @@ impl Game<'_> {
mob.kind.width,
mob.kind.height,
);
self.stats
.custom("damage_dealt", (actual * 10.0).round() as i32);
self.award_custom_stat("damage_dealt", (actual * 10.0).round() as i32);
if crit {
self.critical_hit(
self.lan_acting,
@@ -12080,7 +12085,7 @@ impl Game<'_> {
}
self.close_container();
if self.remote.is_none() {
self.stats.custom("leave_game", 1);
self.award_custom_stat("leave_game", 1);
}
self.lan_close(false);
self.free_player_beds()?;
@@ -12482,7 +12487,7 @@ impl Game<'_> {
return Ok(false);
}
if let Some(stat) = result.stat {
self.stats.custom(stat, 1);
self.award_custom_stat(stat, 1);
}
if let Some(block) = result.block
&& let Ok(new) = registry.resolve(&block)
@@ -12617,7 +12622,7 @@ impl Game<'_> {
self.audio.play(&sound, Some(at), 1.0, 1.0);
}
// ItemStack.useOn: a use that acted counts as one of the item.
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.damage_held(damage);
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), Some(new));
Ok(true)
@@ -12714,7 +12719,7 @@ impl Game<'_> {
let broken = stack.item.clone();
let gone = stack.clone();
*cell = None;
self.stats.add("broken", &broken, 1);
self.award_stat("broken", &broken, 1);
// LivingEntity.onEquippedItemBroken: the sound and the crumbs.
self.player_equipment_broke(
horizon_world::player_inventory::EquipmentSlot::MainHand,
@@ -12805,7 +12810,7 @@ impl Game<'_> {
self.process_level_events()?;
// `player.awardStat(Stats.ITEM_USED.get(this))`,
// `getPickupSound` and `level.gameEvent(player, FLUID_PICKUP)`.
self.stats.add("used", "minecraft:bucket", 1);
self.award_stat("used", "minecraft:bucket", 1);
let sound = if filled == "minecraft:lava_bucket" {
"minecraft:item.bucket.fill_lava"
} else {
@@ -12925,7 +12930,7 @@ impl Game<'_> {
self.sculk_actor(),
Some(state),
);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
if !self.creative {
self.consume_held();
}
@@ -13126,9 +13131,7 @@ impl Game<'_> {
health: 5,
});
// ServerLevel.addFreshEntity.
if !self.native_entity_joins(self.next_entity, "minecraft:experience_orb", at, false) {
self.orbs.pop();
}
self.native_joins_flush(false, 0);
}
fn can_place_used_fire(&mut self, target: [i32; 3]) -> bool {
let pos = BlockPos::new(target[0], target[1], target[2]);
@@ -13247,6 +13250,7 @@ impl Game<'_> {
target,
self.remote.is_none(),
&mut next_float,
Some(self.border.geometry()),
)
});
self.orbs = orbs;
@@ -14128,7 +14132,7 @@ impl Game<'_> {
self.snap_player_model_pitch();
}
self.occupy_bed(head);
self.stats.custom("sleep_in_bed", 1);
self.award_custom_stat("sleep_in_bed", 1);
self.trigger_player("minecraft:slept_in_bed");
self.stats.reset("custom", "minecraft:time_since_rest");
}
@@ -14810,10 +14814,19 @@ impl Game<'_> {
if self.remote.is_some() {
return Ok(());
}
// `DedicatedServer.saveAllChunks` tells the management protocol's clients when a save starts and when
// it ends without failing, for every save: the command, the autosave, the pause.
let dedicated = self.options.dedicated.is_some();
if dedicated {
dedicated::events::record_server("saving");
}
self.save_level(wait)?;
self.save_parked_levels(wait)?;
self.save_game_metadata()?;
self.native_world_saved(wait);
if dedicated {
dedicated::events::record_server("saved");
}
Ok(())
}
fn save_game_metadata(&mut self) -> Result<()> {
@@ -14829,12 +14842,17 @@ impl Game<'_> {
self.save_schedule();
self.save_warden_tracker();
self.sync_level();
self.store_owner();
let stored_owner = self.store_owner();
write_level_for_role(
&mut self.level,
&self.world_root,
self.options.execution_role,
)?;
// PlayerEvent.SaveToFile for the owner, once level.dat is written.
if let Some((uuid, name)) = stored_owner {
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
self.native_player_save_to_file(uuid, &name, &directory);
}
// PlayerList.saveAll: every placed player saves with the server, to
// their own file (`PlayerDataStorage.save`); the owner is also in the
// file level.dat names, which is the one it loads from
@@ -14846,9 +14864,11 @@ impl Game<'_> {
// The vehicle tree goes into a player's file when they leave
// (`take_parent_vehicle`); here it stays in the chunk saves.
let mut host = host;
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
self.guest_attachments_into(g);
horizon_client::lan::playerdata::save(g, &self.world_root);
self.native_player_save_to_file(g.uuid, &g.name, &directory);
}
self.lan = Some(host);
}
@@ -18978,6 +18998,7 @@ impl Game<'_> {
if let Some(host) = self.lan.as_mut() {
host.hub.reload_resources(sync);
}
self.run_native_advancement_grants();
}
Err(e) => {
log::warn!("Horizon: the players are not sent the reloaded data: {e}")
+41 -4
View File
@@ -611,13 +611,40 @@ impl Game<'_> {
for (ai, ci) in hits {
let a = &tree.list[ai];
let was = self.adv.progress.done(a);
self.adv.progress.grant(&a.id, &a.criteria[ci].name, &date);
let criterion = &a.criteria[ci].name;
if self.adv.progress.grant(&a.id, criterion, &date) {
self.advancement_progress(&a.id, criterion, true);
}
self.progress_changed(ai);
if !was && self.adv.progress.done(a) {
self.award(ai);
}
}
}
/// `PlayerAdvancements.award` / `revoke` after a criterion's progress changed.
fn advancement_progress(&mut self, advancement: &str, criterion: &str, granted: bool) {
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_advancement_progress(player, &name, advancement, criterion, granted);
}
}
/// The criteria `CriteriaTriggers.TICK` granted network players in the hub
/// (their first tick and a reload), told to the mods.
pub(super) fn run_native_advancement_grants(&mut self) {
let Some(host) = self.lan.as_mut() else {
return;
};
for batch in std::mem::take(&mut host.hub.native_advancement_grants) {
for grant in batch.grants {
self.native_player_advancement_progress(
batch.player,
&batch.name,
&grant.advancement,
&grant.criterion,
true,
);
}
}
}
/// `PlayerAdvancements.progressChanged`, kept for a network player's client.
fn progress_changed(&mut self, index: usize) {
if self.lan_acting.is_some() {
@@ -692,7 +719,7 @@ impl Game<'_> {
match result {
Ok(unlocked) => {
self.recipe_unlocked(&unlocked);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
true
}
@@ -1962,6 +1989,7 @@ impl Game<'_> {
let was = self.adv.progress.done(a);
if self.adv.progress.grant(&a.id, c, &date) {
changed = true;
self.advancement_progress(&a.id, c, true);
self.progress_changed(index);
if !was && self.adv.progress.done(a) {
self.award(index);
@@ -1976,16 +2004,25 @@ impl Game<'_> {
let a = &tree.list[index];
self.progress_changed(index);
match criterion {
Some(c) => self.adv.progress.revoke(&a.id, c),
Some(c) => self.revoke_criterion(&a.id, c),
None => {
let mut any = false;
for c in &a.criteria {
any |= self.adv.progress.revoke(&a.id, &c.name);
any |= self.revoke_criterion(&a.id, &c.name);
}
any
}
}
}
/// `PlayerAdvancements.revoke(holder, criterion)`: the progress change and
/// the hook for it.
fn revoke_criterion(&mut self, advancement: &str, criterion: &str) -> bool {
let revoked = self.adv.progress.revoke(advancement, criterion);
if revoked {
self.advancement_progress(advancement, criterion, false);
}
revoked
}
}
impl Game<'_> {
/// `ClientAdvancements.update` (ClientboundUpdateAdvancementsPacket):
+1 -1
View File
@@ -272,7 +272,7 @@ impl Game<'_> {
}
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
}
self.stats.add("used", &held.item, 1);
self.award_stat("used", &held.item, 1);
let smokey = beehive::smokey(&self.mob_world_base(&[]), pos);
let key = (pos[0], pos[1], pos[2]);
if !smokey
+1 -1
View File
@@ -873,7 +873,7 @@ impl Game<'_> {
let Some(book) = &l.book else { return false };
let pages = view_pages(book.data());
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
self.stats.custom("interact_with_lectern", 1);
self.award_custom_stat("interact_with_lectern", 1);
self.open_book(BookScreen::View {
pages,
page,
+1 -1
View File
@@ -51,7 +51,7 @@ impl Game<'_> {
self.native_campfire_start(pos, &held.item);
// ItemStack.consumeAndReturn: creative keeps the item.
self.consume_held();
self.stats.custom("interact_with_campfire", 1);
self.award_custom_stat("interact_with_campfire", 1);
self.post_block_game_event(
"minecraft:block_change",
pos,
@@ -66,7 +66,7 @@ impl Game<'_> {
{
// addBook: one book goes in (a creative player keeps theirs).
let book = book.clone();
self.stats.add("used", &book.item, 1);
self.award_stat("used", &book.item, 1);
let sound = if &*book.item == "minecraft:enchanted_book" {
"minecraft:block.chiseled_bookshelf.insert.enchanted"
} else {
+4 -5
View File
@@ -265,9 +265,9 @@ impl Game<'_> {
// hit), in tenths.
MotionEvent::Health(HealthEvent::Statistic { name, amount }) => {
match *name {
"resisted" => self.stats.custom("damage_resisted", *amount),
"absorbed" => self.stats.custom("damage_absorbed", *amount),
"blocked" => self.stats.custom("damage_blocked_by_shield", *amount),
"resisted" => self.award_custom_stat("damage_resisted", *amount),
"absorbed" => self.award_custom_stat("damage_absorbed", *amount),
"blocked" => self.award_custom_stat("damage_blocked_by_shield", *amount),
_ => {}
}
continue;
@@ -308,8 +308,7 @@ impl Game<'_> {
}
}
// Player.actuallyHurt: damage_taken in tenths.
self.stats
.custom("damage_taken", (amount * 10.0).round() as i32);
self.award_custom_stat("damage_taken", (amount * 10.0).round() as i32);
self.combat.entries.push(Entry {
damage_type: name,
damage: amount,
+1 -7
View File
@@ -2202,13 +2202,7 @@ impl Game<'_> {
health: 5,
});
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_entity_joins(
self.next_entity,
"minecraft:experience_orb",
pos,
false,
) {
self.orbs.pop();
if !self.native_joins_flush(false, self.next_entity) {
return Err(err("commands.summon.failed.uuid", &[]));
}
name = Text::translate("entity.minecraft.experience_orb", &[]);
+1 -1
View File
@@ -60,7 +60,7 @@ impl Game<'_> {
if level < 7 {
self.composter_add(pos, &layers);
self.native_compost_item(pos, &stack.item);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
// ServerPlayerGameMode.useItemOn: the action was consumed.
+1 -1
View File
@@ -49,7 +49,7 @@ impl Game<'_> {
) {
self.emit_consume(consumable, stack, 16);
// ServerPlayer: the used statistic and the consume_item trigger.
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.trigger_item("minecraft:consume_item", stack);
// FoodProperties.onConsume, the player's one ConsumableListener.
if let Some((nutrition, saturation)) = info.food {
+36 -8
View File
@@ -1290,9 +1290,9 @@ impl Game<'_> {
// entity, so a kill by a mob (a tame wolf credited to its owner)
// leaves it out; ServerPlayer.awardKillScore counts the rest.
if !killed_by_mob(m) {
self.stats.add("killed", &m.kind.id, 1);
self.award_stat("killed", &m.kind.id, 1);
}
self.stats.custom("mob_kills", 1);
self.award_custom_stat("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
@@ -2103,6 +2103,34 @@ impl Game<'_> {
}
}
}
MobEvent::PufferfishSting {
mob,
target,
amount,
source,
poison_ticks,
} => {
// Pufferfish.touch: the poison and
// `playSound(PUFFER_FISH_STING, 1, 1)` sit inside the
// `hurtServer` branch, so only a hit that landed poisons
// and stings, at the fish's own position as
// `Entity.playSound` puts it, and not when it is silent.
let landed = self.mob_hits_mob(target, amount, source);
if landed && let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
t.add_effect("minecraft:poison", poison_ticks, 0);
}
if landed && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
let at = [m.position.x, m.position.y, m.position.z];
if !m.silent {
self.handle_mob_events(vec![MobEvent::Sound(
"entity.puffer_fish.sting",
at,
1.0,
1.0,
)])?;
}
}
}
MobEvent::SonicBoom {
target,
attacker,
@@ -2946,7 +2974,7 @@ impl Game<'_> {
super::commands::Target::Guest,
);
self.for_player(who, |g| {
g.stats.custom("animals_bred", 1);
g.award_custom_stat("animals_bred", 1);
g.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
});
}
@@ -3883,7 +3911,7 @@ impl Game<'_> {
if rang {
let _ = self.process_level_events();
if p.owner == Some(Who::Player) {
self.stats.custom("bell_ring", 1);
self.award_custom_stat("bell_ring", 1);
}
}
}
@@ -4101,7 +4129,7 @@ impl Game<'_> {
let _ = self.process_level_events();
self.native_target_hit(self.dim.id(), pos, strength);
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
self.stats.custom("target_hit", 1);
self.award_custom_stat("target_hit", 1);
let kind = if p
.arrow
.as_ref()
@@ -4734,7 +4762,7 @@ impl Game<'_> {
// flies as: the splash happens where the hit was, a lingering
// potion's cloud sits at the mob it hit (ThrownLingeringPotion),
// and the potion that lands names the hurt it deals.
(ProjectileKind::Potion(stack), hit) => {
(ProjectileKind::Potion(stack, _), hit) => {
let block = match hit {
Hit::Block { pos, face, .. } => Some((
pos,
@@ -6111,7 +6139,7 @@ impl Game<'_> {
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
if done {
self.swing_arm();
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
return Ok(true);
@@ -6142,7 +6170,7 @@ impl Game<'_> {
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
@@ -119,7 +119,7 @@ impl Game<'_> {
};
let stack = stack.clone();
self.block_entity_event(pos, 1, WOBBLE_POSITIVE);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
// consumeAndReturn(1): one item goes in (a creative player keeps theirs).
let inserted = match &mut pot_item {
Some(p) => {
+22 -8
View File
@@ -641,6 +641,18 @@ struct ServerState {
force_game_mode: bool,
}
/// `MinecraftServer.isAutoSave`: all of Horizon's server levels share this save flag.
fn auto_save(state: &ServerState) -> bool {
state.autosave
}
/// `MinecraftServer.setAutoSave`: enable or disable periodic saves and report whether it changed.
fn set_auto_save(state: &mut ServerState, enabled: bool) -> bool {
let changed = state.autosave != enabled;
state.autosave = enabled;
changed
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
/// (`sendSuccess(..., true)`): the operators hear of them.
const BROADCAST: &[&str] = &[
@@ -1063,8 +1075,12 @@ impl Game<'_> {
self.save_game()?;
}
if !paused && !state.autosave {
// `save-off`: the periodic save inside the tick is skipped.
self.ticks = self.ticks.max(1);
// `MinecraftServer.autoSave` keeps its schedule while every level's
// `noSave` flag is set, but writes nothing. The tick below counts down
// once, so reset one tick early when this save would have been due.
if self.ticks_until_autosave <= 1 {
self.ticks_until_autosave = self.next_autosave_interval() + 1;
}
}
// MinecraftServer.tickServer: while paused only the connections run.
// `MinecraftServer.runServer` catches what the tick throws, writes the crash
@@ -1372,19 +1388,17 @@ impl Game<'_> {
}
}
"save-off" => {
if state.autosave {
state.autosave = false;
if set_auto_save(state, false) {
self.console_reply(Text::translate("commands.save.disabled", &[]));
} else {
self.console_reply(Text::translate("commands.save.alreadyOff", &[]));
}
}
"save-on" => {
if state.autosave {
self.console_reply(Text::translate("commands.save.alreadyOn", &[]));
} else {
state.autosave = true;
if set_auto_save(state, true) {
self.console_reply(Text::translate("commands.save.enabled", &[]));
} else {
self.console_reply(Text::translate("commands.save.alreadyOn", &[]));
}
}
"list" => {
@@ -21,11 +21,20 @@ static EVENTS: Mutex<Events> = Mutex::new(Events {
/// Records one event: its kind, the player's name and UUID, and what else it says.
pub(in crate::game) fn record(kind: &str, name: &str, uuid: u128, mut fields: Value) {
fields["name"] = name.into();
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
push(kind, fields);
}
/// Records an event of the server itself, with no player (`saving` and `saved`, around a world save).
pub(in crate::game) fn record_server(kind: &str) {
push(kind, json!({}));
}
fn push(kind: &str, mut fields: Value) {
let mut events = EVENTS.lock().unwrap_or_else(|e| e.into_inner());
fields["seq"] = events.next.into();
fields["type"] = kind.into();
fields["name"] = name.into();
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
fields["time"] = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
@@ -52,6 +61,7 @@ pub(super) fn since(since: u64) -> Value {
mod tests {
use super::*;
// One test: the events are one process-wide list, so two tests recording at once would count each other's.
#[test]
fn events_are_numbered_and_read_from_a_number() {
let first = since(u64::MAX)["next"].as_u64().unwrap();
@@ -65,5 +75,11 @@ mod tests {
assert_eq!(events[1]["message"], "hi");
assert_eq!(events[1]["uuid"], "00000000-0000-0000-0000-000000000001");
assert_eq!(since(first + 1)["events"].as_array().unwrap().len(), 1);
// An event of the server itself (a world save) carries no player.
record_server("saving");
let event = &since(first + 2)["events"][0];
assert_eq!(event["type"], "saving");
assert_eq!(event["seq"].as_u64(), Some(first + 3));
assert!(event.get("name").is_none() && event.get("uuid").is_none());
}
}
@@ -3,7 +3,7 @@
//! `AllowlistService`, `BanlistService`, `IpBanlistService` and `OperatorService` on the running server's lists. Players are found as
//! `MinecraftPlayerListService.getUser` finds them, each change is logged as `JsonRpcLogger` logs it, and the
//! players a change shuts out are disconnected. The supervisor answers with the lists, from their files.
use super::{GAME_MODES, ServerState, log};
use super::{GAME_MODES, ServerState, auto_save, log, set_auto_save};
use crate::game::Game;
use horizon_client::{
command::vanilla::DIFFICULTIES,
@@ -49,6 +49,21 @@ impl Game<'_> {
let entries = body["entries"].as_array().cloned().unwrap_or_default();
let lists = state.lists.clone();
match body["method"].as_str().unwrap_or_default() {
"serversettings/autosave" => {
return json!({ "value": auto_save(state) });
}
"serversettings/autosave/set" => {
let Some(enabled) = body["value"].as_bool() else {
return json!({ "error": "autosave must be a boolean" });
};
rpc(format!(
"Update autosave from {} to {}",
auto_save(state),
enabled
));
set_auto_save(state, enabled);
return json!({ "value": auto_save(state) });
}
"serversettings/motd" => {
return json!({
"value": self.lan.as_ref().map(|lan| lan.hub.lan.shared.motd()).unwrap_or_default()
+2 -2
View File
@@ -1316,8 +1316,8 @@ impl Game<'_> {
view: &horizon_client::advancements::EntityView,
blow: Option<(DamageKind, bool)>,
) {
self.stats.add("killed", "minecraft:ender_dragon", 1);
self.stats.custom("mob_kills", 1);
self.award_stat("killed", "minecraft:ender_dragon", 1);
self.award_custom_stat("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(id));
// KilledTrigger: the damage source `die` was given.
let blow = blow.map(|(kind, by_player)| {
+45
View File
@@ -21,6 +21,7 @@ use horizon_core::geometry::{Aabb, Direction, Vec3};
use horizon_core::position::BlockPos;
use horizon_world::nbt::Tag;
use horizon_world::{level::data::PushReaction, piston_push};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
/// What `Entity.thunderHit` does to a kind: it sets the entity on fire and
@@ -61,6 +62,8 @@ pub(super) trait Entity: EntityRules {
/// What a selector finds this entity as (`@e`), or `None` for one it cannot
/// name yet. Required, so a new kind states which.
fn selector_target(&self) -> Option<Target>;
/// The entity type id a native mod's join hook names (`minecraft:oak_boat`).
fn native_type(&self) -> Cow<'static, str>;
/// Saves entity `index` of this kind into its chunk's entity list: the
/// chunk and the NBT when it lies in `keys` and is saved with its chunk.
/// Required, so a new kind states how it saves.
@@ -313,6 +316,15 @@ macro_rules! define_entity_handles {
$(.chain((0..self$(.$list)+.len()).map(EntityHandle::$kind)))*
}
/// Takes the entity out of its list (`Entity.discard` before it ever ticked).
pub(super) fn discard_entity(&mut self, handle: EntityHandle) {
match handle {
$(EntityHandle::$kind(i) => {
self$(.$list)+.remove(i);
})*
}
}
/// [`Self::entity_handles`], last of each list first, so the
/// caller can remove the entity it is on.
pub(super) fn entity_handles_rev(&self) -> impl Iterator<Item = EntityHandle> + use<> {
@@ -452,6 +464,9 @@ impl Game<'_> {
}
impl Entity for Mob {
fn native_type(&self) -> Cow<'static, str> {
super::native_hooks::mob_kind(self).into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -534,6 +549,9 @@ impl Entity for Mob {
}
impl Entity for ItemEntity {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:item".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -576,6 +594,9 @@ impl Entity for ItemEntity {
}
impl Entity for ExperienceOrb {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:experience_orb".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -615,6 +636,9 @@ impl Entity for ExperienceOrb {
}
impl Entity for Boat {
fn native_type(&self) -> Cow<'static, str> {
self.entity_type().into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -658,6 +682,9 @@ impl Entity for Boat {
}
impl Entity for Minecart {
fn native_type(&self) -> Cow<'static, str> {
self.kind.id().into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -711,6 +738,9 @@ fn armor_stand_size(stand: &ArmorStand) -> (f64, f64) {
}
impl Entity for ArmorStand {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:armor_stand".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -783,6 +813,9 @@ impl Entity for ArmorStand {
}
impl Entity for PrimedTnt {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:tnt".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -824,6 +857,9 @@ impl Entity for PrimedTnt {
}
impl Entity for FallingBlock {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:falling_block".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -841,6 +877,9 @@ impl Entity for FallingBlock {
}
impl Entity for Projectile {
fn native_type(&self) -> Cow<'static, str> {
self.kind.id().into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -901,6 +940,9 @@ impl Entity for Projectile {
}
impl Entity for Rocket {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:firework_rocket".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -933,6 +975,9 @@ impl Entity for Rocket {
}
impl Entity for ThrownTrident {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:trident".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
+2 -3
View File
@@ -797,9 +797,8 @@ impl Game<'_> {
// centre of its box.
let (lo, hi) = self.paintings[i].bounds();
let at = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
if blast_reaches(center, diameter, at) {
self.break_painting(i, false);
} else {
// A painting a mod keeps stays at `i`.
if !(blast_reaches(center, diameter, at) && self.break_painting(i, false)) {
i += 1;
}
}
+1 -1
View File
@@ -280,7 +280,7 @@ impl Game<'_> {
.get("minecraft:fishes")
.is_some_and(|t| t.contains(stack.item.as_str()))
{
self.stats.custom("fish_caught", 1);
self.award_custom_stat("fish_caught", 1);
}
let mut item = ItemStack {
item: Arc::from(stack.item.as_str()),
+35 -2
View File
@@ -136,6 +136,28 @@ impl Game<'_> {
self.native_hanging_place(uuid, &name, kind, pos, &mut cancel);
cancel
}
/// Asks the mods whether a hanging entity of type `kind` at `pos` may
/// break (`native_hanging_break`): true when one cancelled it. A break by
/// a player is asked only for the acting guest, with them as the player;
/// a break by anything else is asked with none.
pub(super) fn hanging_break_cancelled(
&mut self,
kind: &str,
pos: [i32; 3],
by_player: bool,
) -> bool {
let player = if by_player {
let Some(uuid) = self.lan_acting_uuid else {
return false;
};
Some(uuid)
} else {
None
};
let mut cancel = false;
self.native_hanging_break(player, kind, pos, &mut cancel);
cancel
}
/// `ItemFrameItem.useOn`.
pub(super) fn place_item_frame(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let glow = match &*stack.item {
@@ -296,6 +318,12 @@ impl Game<'_> {
}
let with_frame = self.item_frames[i].item.is_none();
let entity_id = self.item_frames[i].id;
if with_frame {
let (kind, pos) = (self.item_frames[i].kind(), self.item_frames[i].pos);
if self.hanging_break_cancelled(kind, pos, true) {
return true;
}
}
let old_item = self.item_frames[i]
.item
.as_ref()
@@ -403,7 +431,11 @@ impl Game<'_> {
((c[0] - blast[0]).powi(2) + (c[1] - blast[1]).powi(2) + (c[2] - blast[2]).powi(2))
.sqrt()
/ diameter;
if dist > 1.0 || self.item_frames[i].fixed {
let (kind, pos) = (self.item_frames[i].kind(), self.item_frames[i].pos);
if dist > 1.0
|| self.item_frames[i].fixed
|| self.hanging_break_cancelled(kind, pos, false)
{
i += 1;
continue;
}
@@ -432,7 +464,8 @@ impl Game<'_> {
if self.item_frames[i].check_interval >= 100 {
self.item_frames[i].check_interval = 0;
let f = self.item_frames[i].clone();
if !self.frame_survives(&f) {
if !self.frame_survives(&f) && !self.hanging_break_cancelled(f.kind(), f.pos, false)
{
self.item_frames.remove(i);
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
+2 -2
View File
@@ -44,7 +44,7 @@ impl Game<'_> {
if used {
self.swing_arm();
// ItemStack.useOn: an item interaction counts as a use.
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
Ok(used)
@@ -89,7 +89,7 @@ impl Game<'_> {
);
self.damage_held(1);
// ItemStack.useOn: an item interaction counts as a use.
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
// ServerPlayerGameMode.useItemOn: a consuming useOn reports it.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
+1 -1
View File
@@ -834,7 +834,7 @@ impl Game<'_> {
if let Some(mut host) = self.lan.take() {
self.flush_native_net(&mut host);
// PlayerList.removeAll: the owner and every guest leave the game.
host.award_leave_game_to_all();
self.award_leave_game_to_all(&mut host);
self.save_leaving_guests(&mut host);
let conns: Vec<u32> = host.hub.guests.iter().map(|g| g.conn).collect();
for conn in conns {
+1 -1
View File
@@ -143,7 +143,7 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.stats.custom("play_record", 1);
self.award_custom_stat("play_record", 1);
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
+69 -34
View File
@@ -191,29 +191,48 @@ pub(super) struct LanHost {
pub(super) states: lan::entity::States,
}
impl LanHost {
impl Game<'_> {
/// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the
/// player is saved. Once per guest, however often they are saved.
pub(super) fn award_leave_game(&mut self, conn: u32) {
if let Some(play) = self.play.get_mut(&conn)
&& !play.left_game
{
play.left_game = true;
/// player is saved, heard by native mods first. Once per guest, however
/// often they are saved.
pub(super) fn award_leave_game(
&mut self,
host: &mut LanHost,
conn: u32,
player: u128,
name: &str,
) {
let Some(play) = host.play.get_mut(&conn) else {
return;
};
if std::mem::replace(&mut play.left_game, true) {
return;
}
let mut cancel = false;
self.native_player_stat_award(
player,
name,
"minecraft:custom",
"minecraft:leave_game",
1,
&mut cancel,
);
if !cancel && let Some(play) = host.play.get_mut(&conn) {
play.stats.custom("leave_game", 1);
}
}
/// `PlayerList.removeAll`: every player who joined leaves with the world.
pub(super) fn award_leave_game_to_all(&mut self) {
let conns: Vec<u32> = self
pub(super) fn award_leave_game_to_all(&mut self, host: &mut LanHost) {
let players: Vec<(u32, u128, String)> = host
.hub
.guests
.iter()
.filter(|g| g.placed)
.map(|g| g.conn)
.map(|g| (g.conn, g.uuid, g.name.clone()))
.collect();
for conn in conns {
self.award_leave_game(conn);
for (conn, player, name) in players {
self.award_leave_game(host, conn, player, &name);
}
}
}
@@ -1334,7 +1353,7 @@ impl Game<'_> {
let Some(mut host) = self.lan.take() else {
return;
};
host.award_leave_game_to_all();
self.award_leave_game_to_all(&mut host);
self.save_leaving_guests(&mut host);
host.hub.disconnect_all();
// Give the writer threads a moment to flush the disconnect packets.
@@ -1394,10 +1413,14 @@ impl Game<'_> {
guest: &mut horizon_client::lan::guest::Guest,
) {
self.guest_attachments_into(guest);
if guest.placed && host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
playerdata::save_owner(guest, &mut self.level, &self.world_root);
} else if guest.placed {
playerdata::save(guest, &self.world_root);
if guest.placed {
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
playerdata::save_owner(guest, &mut self.level, &self.world_root);
} else {
playerdata::save(guest, &self.world_root);
}
let directory = playerdata::directory(&self.world_root);
self.native_player_save_to_file(guest.uuid, &guest.name, &directory);
}
self.save_guest_records(host, guest);
}
@@ -1593,35 +1616,28 @@ impl Game<'_> {
host.play.entry(conn).or_default().stats = stats;
}
/// The integrated server's owner into the world's player, for `Level::write`.
pub(super) fn store_owner(&mut self) {
let Some(uuid) = self
.lan
.as_ref()
.and_then(|h| h.owner.as_ref())
.map(|o| o.uuid)
else {
return;
};
let Some((riding, dim, others)) = self.lan.as_ref().and_then(|h| {
/// Returns the owner's uuid and name when they were stored.
pub(super) fn store_owner(&mut self) -> Option<(u128, String)> {
let uuid = self.lan.as_ref().and_then(|h| h.owner.as_ref())?.uuid;
let (riding, dim, others) = self.lan.as_ref().and_then(|h| {
let g = h.hub.guests.iter().find(|g| g.uuid == uuid && g.placed)?;
let others = self.ridden_by_others(&h.hub.guests, &g.dimension, Some(uuid), true);
Some((g.vehicle, g.dimension.clone(), others))
}) else {
return;
};
})?;
// The vehicle the owner rides (`ServerPlayer.saveParentVehicle`).
let vehicle = self
.parent_vehicle_in(None, &dim, riding, &others, false)
.map(|(tag, _)| tag);
let Some(host) = &self.lan else {
return;
};
let host = self.lan.as_ref()?;
let mut stored = None;
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
playerdata::store(g, &mut self.level, vehicle);
self.save_guest_records(host, g);
stored = Some(g.name.clone());
} else if let Some(saved) = host.owner.as_ref().and_then(|o| o.saved.as_ref()) {
self.level.position = saved.position;
}
stored.map(|name| (uuid, name))
}
/// The world's default game mode (`defaultgamemode`).
@@ -1883,7 +1899,7 @@ impl Game<'_> {
}
self.lan_crafting_left(&mut host, &mut guest);
if guest.placed {
host.award_leave_game(guest.conn);
self.award_leave_game(&mut host, guest.conn, guest.uuid, &guest.name);
}
// PlayerList.remove: the vehicle it alone rides goes too.
self.take_parent_vehicle(&mut host, &mut guest);
@@ -2007,6 +2023,14 @@ impl Game<'_> {
.take()
.expect("the hub is back after the quiet commands");
}
if !host.hub.native_advancement_grants.is_empty() {
self.lan = Some(host);
self.run_native_advancement_grants();
host = self
.lan
.take()
.expect("the hub is back after the advancement grants");
}
if !host.hub.native_menu_events.is_empty() {
self.lan = Some(host);
self.run_native_menu_events();
@@ -2198,6 +2222,14 @@ impl Game<'_> {
.take()
.expect("the hub is back after the mod messages");
}
if !host.hub.native_advancement_grants.is_empty() {
self.lan = Some(host);
self.run_native_advancement_grants();
host = self
.lan
.take()
.expect("the hub is back after the advancement grants");
}
if !host.hub.native_menu_events.is_empty() {
self.lan = Some(host);
self.run_native_menu_events();
@@ -2276,6 +2308,9 @@ impl Game<'_> {
};
self.guest_attachments
.insert(g.uuid, std::mem::take(&mut g.attachments));
// PlayerEvent.LoadFromFile: the player's data is read and applied,
// the player not yet placed.
self.native_player_load_from_file(g.uuid, &g.name, &playerdata::directory(&root));
if !known || force {
g.game_mode = default_mode;
}
@@ -103,7 +103,7 @@ pub(super) struct GuestPlay {
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
pub(super) stats: super::stats::Stats,
/// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the
/// way out, the statistic is awarded once (`LanHost::award_leave_game`).
/// way out, the statistic is awarded once (`Game::award_leave_game`).
pub(super) left_game: bool,
last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
@@ -3453,6 +3453,10 @@ impl Game<'_> {
};
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
// PlayerEvent.Clone, at the end of `restoreFrom`: the data is copied,
// the new player not yet placed.
let (cloned, name) = (g.uuid, g.name.clone());
self.native_player_clone(cloned, &name, true);
let clock_rate = self.network_clock_rate();
let ctx = TickCtx {
world: &mut self.world,
@@ -4183,7 +4187,7 @@ impl Game<'_> {
for event in &events {
if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event
{
game.stats.custom("fall_one_cm", *cm);
game.award_custom_stat("fall_one_cm", *cm);
}
}
game.record_combat(&events);
@@ -633,7 +633,7 @@ impl Game<'_> {
// `potion_contents`, whose effects the guest's splash and
// whose colour its drawing need. A plain splash potion is the
// client's default and is not sent.
K::Potion(stack) if stack.extra.is_some() || stack.count != 1 => {
K::Potion(stack, _) if stack.extra.is_some() || stack.count != 1 => {
e.meta.push((
8,
serializer::ITEM_STACK,
+2 -2
View File
@@ -320,9 +320,9 @@ impl Game<'_> {
if died {
match s.attacker {
Some(conn) => {
self.as_guest(host, conn, |g| g.stats.custom("player_kills", 1));
self.as_guest(host, conn, |g| g.award_custom_stat("player_kills", 1));
}
None => self.stats.custom("player_kills", 1),
None => self.award_custom_stat("player_kills", 1),
}
}
}
@@ -390,7 +390,7 @@ impl Game<'_> {
// Player.onEnchantmentPerformed, and EnchantmentMenu's awardStat(ENCHANT_ITEM).
self.as_guest(host, conn, |game| {
game.give_levels(-need);
game.stats.custom("enchant_item", 1);
game.award_custom_stat("enchant_item", 1);
// CriteriaTriggers.ENCHANTED_ITEM, with the levels it cost.
game.trigger_enchanted_item(&enchanted, need);
});
+3 -3
View File
@@ -240,7 +240,7 @@ impl Game<'_> {
self.mace_hurt_enemy(index);
}
if weapon.is_some() {
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
}
self.post_attack(index);
// MaceItem.postHurtEnemy
@@ -323,7 +323,7 @@ impl Game<'_> {
.get(&stack.item)
.is_some_and(|i| i.weapon_damage.is_some())
{
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
}
self.post_attack_bursts(&stack, "minecraft:player");
// MaceItem.postHurtEnemy
@@ -352,7 +352,7 @@ impl Game<'_> {
.get(&stack.item)
.is_some_and(|i| i.weapon_damage.is_some())
{
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
}
self.post_attack_bursts(&stack, "minecraft:armor_stand");
// MaceItem.postHurtEnemy
+2 -2
View File
@@ -1178,7 +1178,7 @@ impl Game<'_> {
return true;
}
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
true
}
@@ -1224,7 +1224,7 @@ impl Game<'_> {
self.consume_held();
self.give_or_drop_stack(locked);
}
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
true
}
@@ -258,6 +258,87 @@ impl Game<'_> {
}
}
/// Entities joining the level (`native_entity_join_level`), for every kind in
/// the entity walk. Kept out of `native_hooks.rs`, whose lines may not hold macros.
impl Game<'_> {
/// Tells `native_entity_join_level` once for every entity added since the
/// last call, of any kind, in the order they were created (ids count up
/// from one counter). A mod that cancels takes the entity back out here,
/// before it ticks again, and `/summon` of it fails (`joins_canceled`).
/// `loaded_from_disk` is for the entities that came with a chunk. Returns
/// whether `watch` (an id, or 0) is still in the level. Walks the lists
/// with static dispatch and allocates nothing but the kind name of an
/// entity it announces; nothing runs when no mod is loaded or on a client
/// of a server (which tells nothing).
pub(super) fn native_joins_flush(&mut self, loaded_from_disk: bool, watch: u32) -> bool {
use super::entity::Entity;
if self.remote.is_some() || crate::mod_api::mods().is_empty() {
return true;
}
for t in &mut self.tnts {
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
}
for f in &mut self.falling {
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut f.id);
}
for p in &mut self.creatures.projectiles {
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut p.id);
}
for t in &mut self.tridents {
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
}
for r in &mut self.rockets {
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
}
let mut kept = true;
while let Some((handle, id)) = self.next_unannounced() {
self.persist.joined_mark = id;
let (kind, at) = super::entity::with_entity!(self, handle, e => {
(e.native_type(), e.position())
});
let mut canceled = false;
let dimension = self.dim.id();
self.native_entity_join_level(
dimension,
id,
&kind,
[at.x, at.y, at.z],
loaded_from_disk,
&mut canceled,
);
if canceled {
self.persist.joins_canceled += 1;
self.forget_entity(id);
// Handles are indexes: the entity is taken out before any
// other change to its list.
self.discard_entity(handle);
kept &= id != watch;
}
}
kept
}
/// The entity with the lowest id above the last one announced, by handle.
fn next_unannounced(&self) -> Option<(super::entity::EntityHandle, u32)> {
let mark = self.persist.joined_mark;
let mut best: Option<(super::entity::EntityHandle, u32)> = None;
for handle in self.entity_handles() {
let id = {
use super::entity::Entity;
super::entity::with_entity!(self, handle, e => e.id())
};
if id > mark && best.is_none_or(|(_, b)| id < b) {
best = Some((handle, id));
}
}
best
}
/// Entities that were moved, not added (a portal's arrival keeps being
/// silent): everything now in the level counts as announced.
pub(super) fn native_joins_skip(&mut self) {
self.persist.joined_mark = self.persist.joined_mark.max(self.next_entity);
}
}
#[cfg(test)]
mod tests {
use super::*;
+158 -35
View File
@@ -296,8 +296,10 @@
//! `native_extract_item`, `native_move_items`, and `native_dimension` for a
//! machine's tick, in `machines/native_items.rs`).
//!
//! API 47 adds entities joining a level (`native_entity_join_level`), for
//! item entities and experience orbs so far.
//! API 47 adds entities joining a level (`native_entity_join_level`); it tells
//! every kind the game keeps in its entity lists (items, orbs, mobs, boats,
//! minecarts, armor stands, TNT, falling blocks, projectiles, rockets,
//! tridents).
//!
//! API 46 adds bounded world-space rim and colored-light requests
//! (`native_visuals`) and opt-in texture-matched emissive material
@@ -3395,6 +3397,25 @@ impl Game<'_> {
) {
mod_api::dispatch_hanging_place(self, mod_api::mods(), player, name, kind, pos, cancel);
}
/// An item frame (`minecraft:item_frame`, `minecraft:glow_item_frame`) or
/// a painting (`minecraft:painting`), `kind` its entity type and `pos` the
/// block it hangs in, is about to break: a server player's hit
/// (`player`, their state swapped in, so `native_tell` answers them), a
/// blast, or the wall it hangs on gone (`player` None for both). Where
/// `BlockAttachedEntity.hurtServer` and its survival check would kill
/// it, before anything drops or sounds. Set `cancel` and it stays, as it
/// was. A hit that only takes the item out of a frame does not break the
/// frame and is not asked. The game's own player's hit is not asked, nor
/// is `/kill`. @API:native_hanging_break@.
pub(super) fn native_hanging_break(
&mut self,
player: Option<u128>,
kind: &str,
pos: [i32; 3],
cancel: &mut bool,
) {
mod_api::dispatch_hanging_break(self, mod_api::mods(), player, kind, pos, cancel);
}
/// A server player empties a bucket at `pos` (`fluid`: `minecraft:water`,
/// `minecraft:lava`, `minecraft:powder_snow`; a fish or axolotl bucket
/// empties `minecraft:water`): where `BucketItem.use` has found the block
@@ -3553,6 +3574,57 @@ impl Game<'_> {
) {
mod_api::dispatch_player_advancement_done(self, mod_api::mods(), player, name, advancement);
}
/// A criterion of a server player's advancement was granted or revoked,
/// after its progress changed and before any completion rewards, except
/// the `minecraft:tick` criteria granted on a network player's first tick
/// or a reload, which are heard after that tick's rewards and packets. A
/// grant or revoke that changes nothing is not heard. `granted` is false for a
/// revoke. Helpers looking up the guest through `self.lan` may find
/// nothing during its packet. @API:player_advancement_progress@.
pub(super) fn native_player_advancement_progress(
&mut self,
player: u128,
name: &str,
advancement: &str,
criterion: &str,
granted: bool,
) {
mod_api::dispatch_player_advancement_progress(
self,
mod_api::mods(),
player,
name,
advancement,
criterion,
granted,
);
}
/// A statistic is about to be awarded to a server player, before its count
/// and scoreboard criteria change. `stat_type` and `stat` are ids such as
/// `minecraft:mined` and `minecraft:stone`, and `value` the amount. Set
/// `cancel` to award nothing. Helpers looking up the guest through
/// `self.lan` may find nothing during its packet. Server-side.
/// @API:player_stat_award@.
pub(super) fn native_player_stat_award(
&mut self,
player: u128,
name: &str,
stat_type: &str,
stat: &str,
value: i32,
cancel: &mut bool,
) {
mod_api::dispatch_player_stat_award(
self,
mod_api::mods(),
player,
name,
stat_type,
stat,
value,
cancel,
);
}
/// A server player's experience points are about to increase from an orb pickup,
/// after mending has taken its share. Change `amount` to change the points
/// added. The player's state is not swapped in, so helpers that look them up
@@ -5294,6 +5366,48 @@ impl Game<'_> {
pub(super) fn native_player_respawned(&mut self, player: u128, name: &str) {
mod_api::dispatch_player_respawned(self, mod_api::mods(), player, name);
}
/// A player was saved to the world's store (NeoForge's
/// `PlayerEvent.SaveToFile`, `PlayerList.save`): when they leave or the
/// world closes, when the dedicated server saves everyone, and for an
/// integrated server's owner when the world saves them. LAN guests are
/// not heard on integrated autosaves. Once for each save. `directory`
/// is the world's player directory (`players/data`): write your own file
/// there, named `<uuid>.<suffix>`, never the player's `.dat`. The player
/// may be leaving, so no player's state is swapped in. Not cancellable.
pub(super) fn native_player_save_to_file(
&mut self,
player: u128,
name: &str,
directory: &std::path::Path,
) {
mod_api::dispatch_player_save_to_file(self, mod_api::mods(), player, name, directory);
}
/// A player was loaded from the world's save (NeoForge's
/// `PlayerEvent.LoadFromFile`, `PlayerList.loadPlayerData`): their `.dat`
/// has been read and applied, and they are not placed in a level yet.
/// `directory` is the world's player directory (`players/data`): a mod
/// reads its own file there, named `<uuid>.<suffix>` as
/// `getPlayerFile(suffix)` names it. Heard once for each join, for the
/// owner of an integrated server as for anyone else, on a server, with no
/// player's state swapped in. Not cancellable.
pub(super) fn native_player_load_from_file(
&mut self,
player: u128,
name: &str,
directory: &std::path::Path,
) {
mod_api::dispatch_player_load_from_file(self, mod_api::mods(), player, name, directory);
}
/// A player's data was copied to their new player (NeoForge's
/// `PlayerEvent.Clone`, from `ServerPlayer.restoreFrom`): `was_death` is
/// true after a death, false when they left the End after the credits
/// (`respawn` with `keepAllPlayerData`). The uuid stays the same, so the
/// old and the new player share `player`. Heard before the new player is
/// placed, on a server, with no player's state swapped in. Not
/// cancellable.
pub(super) fn native_player_clone(&mut self, player: u128, name: &str, was_death: bool) {
mod_api::dispatch_player_clone(self, mod_api::mods(), player, name, was_death);
}
/// The number of players with the mod container at `pos` of `dimension`
/// open went from `previous` to `viewers` (vanilla's
/// `ContainerOpenersCounter`, as a barrel keeps it): 0 to more than 0 is
@@ -5528,9 +5642,15 @@ impl Game<'_> {
/// an arrival through a portal). `entity` is its ID and `kind` its type
/// (`minecraft:item`). It is already in the level's lists, so helpers that
/// take an entity ID find it. Set `canceled` to keep it out: it is taken
/// back out as if never added, and a `/summon` of it fails. 47 tells
/// item entities and experience orbs; later APIs add other kinds, so check
/// `kind`. Server-side.
/// back out as if never added, and a `/summon` of it fails. Tells every
/// kind in the game's entity lists (items, orbs, mobs, boats, minecarts,
/// armor stands, primed TNT, falling blocks, projectiles, rockets,
/// tridents), once each, in the order they were created; check `kind`. A
/// gameplay spawn is told when the next tick starts (a drop, an orb, a mob, a
/// `/summon` and a chunk's entities are told at once). A mod that
/// cancels one of the others sees it removed before it ticks again.
/// Not told: end crystals, the dragon, cushions, paintings, item frames and
/// the entities Horizon keeps without simulating. Server-side.
pub(super) fn native_entity_join_level(
&mut self,
dimension: &str,
@@ -7527,6 +7647,8 @@ impl Game<'_> {
}
/// The mobs from index `from` on are new in the world.
pub(super) fn native_mobs_joined(&mut self, from: usize, natural: bool) {
// A mod can keep a mob out; it is then taken out and not told of.
self.native_joins_flush(false, 0);
for i in from..self.mobs.len() {
let m = &self.mobs[i];
let (kind, pos) = (mob_kind(m), [m.position.x, m.position.y, m.position.z]);
@@ -7607,34 +7729,6 @@ impl Game<'_> {
.collect();
self.native_entities_left("minecraft:experience_orb", &gone);
}
/// `native_entity_join_level` for `entity`, just put at the end of its
/// list: false when a mod canceled, and the caller takes it back out.
/// A client of a server tells nothing.
pub(super) fn native_entity_joins(
&mut self,
entity: u32,
kind: &str,
pos: [f64; 3],
loaded_from_disk: bool,
) -> bool {
if self.remote.is_some() {
return true;
}
let mut canceled = false;
let dimension = self.dim.id();
self.native_entity_join_level(
dimension,
entity,
kind,
pos,
loaded_from_disk,
&mut canceled,
);
if canceled {
self.persist.joins_canceled += 1;
}
!canceled
}
/// The acting player's blow killed mob `index`.
pub(super) fn native_kill_event(&mut self, index: usize) {
let (entity, kind) = (self.mobs[index].id, mob_kind(&self.mobs[index]));
@@ -9070,9 +9164,12 @@ mod tests {
// split): horizon-mixins resolves the crate by its package.
format!("horizon_client_lib::crafting::native_{name}")
} else if menu_hooks.contains(name) {
format!("horizon_client::menu::native_{name}")
// menu.rs compiles into horizon-client-lib (#5446's
// split): horizon-mixins resolves the crate by its package.
format!("horizon_client_lib::menu::native_{name}")
} else if breath_hooks.contains(name) {
format!("horizon_client::breathing::native_{name}")
// breathing.rs compiles into horizon-client-lib too.
format!("horizon_client_lib::breathing::native_{name}")
} else if level_hooks.contains(name) {
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
} else {
@@ -9081,6 +9178,32 @@ mod tests {
assert_eq!(hook["target"].as_str().unwrap(), target);
}
assert_eq!(names, declared);
// Every target's crate really compiles its module: #5446 moved
// files between horizon-client and horizon-client-lib, and a
// target left on the old crate applies to no bundle — the string
// comparison above shares its expectation with the catalog, so it
// alone cannot catch a move. The first path segment after the
// crate must be a module of that crate's lib root.
let client_root = include_str!("../lib.rs");
let lib_root = include_str!("../client_lib.rs");
for hook in &hooks {
let target = hook["target"].as_str().unwrap();
let (root, crate_name, rest) =
if let Some(rest) = target.strip_prefix("horizon_client::") {
(client_root, "horizon_client", rest)
} else if let Some(rest) = target.strip_prefix("horizon_client_lib::") {
(lib_root, "horizon_client_lib", rest)
} else {
// The other crates (render, world) are their own packages,
// never split.
continue;
};
let module = rest.split("::").next().unwrap_or(rest);
assert!(
root.contains(&format!("mod {module};")),
"{crate_name} does not compile module `{module}` (target {target})"
);
}
}
/// The damage hook gets a mob's source from its damage: the causing mob,
+37 -5
View File
@@ -297,6 +297,9 @@ impl Game<'_> {
textures,
cem,
block_entity_shapes,
sounds,
ui_metadata,
language,
} = staged;
// Keep the working GPU resources, and hand its window surface over.
if let Some(renderer) = &mut self.renderer {
@@ -396,10 +399,16 @@ impl Game<'_> {
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
std::mem::take(&mut self.gui.atlas.blinks);
horizon_client::ui::load_metadata(&root);
lang::set(lang::Language::load(&[&root], &lang::code()));
horizon_client::ui::set_metadata(ui_metadata);
// Read with the language in use when the reload started: one picked since then wins.
let code = lang::code();
lang::set(if language.code == code {
language
} else {
lang::Language::load(&[&root], &code)
});
self.refresh_remote_language();
self.audio.reload(&root);
self.audio.reload_with(&root, sounds);
self.clear_lod();
// A pack that may replace leaf models falls back to `Off` before the
// reload marks every section dirty.
@@ -452,11 +461,27 @@ pub(super) fn stage_pack(
progress.set(1);
// The world's models and sprites and the GUI atlas read the same root and nothing of
// each other: about a second each, side by side.
let (render, atlas, panorama) = std::thread::scope(|s| {
// The sound events, the GUI sprites' metadata and the language are read here too, not in
// the frame that applies the reload (about 20 ms of it).
let (render, atlas, panorama, (sounds, ui_metadata, language)) = std::thread::scope(|s| {
let atlas =
s.spawn(|| HudAtlas::build_over(&root, vanilla_root).map_err(|e| e.to_string()));
let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root, vanilla_root));
(assets.reload(&root), atlas.join(), panorama.join())
let small = s.spawn(|| {
(
horizon_client::sound_events::SoundRegistry::load(&root),
horizon_client::ui::read_metadata(&root),
lang::Language::load(&[&root], &lang::code()),
)
});
(
assets.reload(&root),
atlas.join(),
panorama.join(),
small
.join()
.unwrap_or_else(|p| std::panic::resume_unwind(p)),
)
});
let render = Arc::new(render?);
let mut atlas = atlas.unwrap_or_else(|p| std::panic::resume_unwind(p))?;
@@ -489,6 +514,9 @@ pub(super) fn stage_pack(
textures,
cem,
block_entity_shapes,
sounds,
ui_metadata,
language,
})
}
@@ -503,6 +531,10 @@ pub(super) struct StagedPack {
pub cem: Arc<horizon_client::mobs::cem::CemModels>,
/// The block entities' models, the packs' custom entity models over vanilla's.
pub block_entity_shapes: Arc<horizon_client::block_entity_render::BlockEntityShapes>,
/// The root's sound events, GUI sprite metadata and current language.
pub sounds: horizon_client::sound_events::SoundRegistry,
pub ui_metadata: horizon_client::ui::UiMetadata,
pub language: lang::Language,
}
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
+11 -6
View File
@@ -274,8 +274,13 @@ impl Game<'_> {
}
best
}
/// `Painting.hurtServer` -> kill and `dropItem`.
pub(super) fn break_painting(&mut self, index: usize, by_player: bool) {
/// `Painting.hurtServer` -> kill and `dropItem`. False when a mod kept it
/// (`native_hanging_break`): it stays at `index`.
pub(super) fn break_painting(&mut self, index: usize, by_player: bool) -> bool {
let pos = self.paintings[index].pos;
if self.hanging_break_cancelled("minecraft:painting", pos, by_player) {
return false;
}
let p = self.paintings.remove(index);
let (lo, hi) = p.bounds();
let at = [
@@ -285,12 +290,12 @@ impl Game<'_> {
];
self.world.modified.insert((p.pos[0] >> 4, p.pos[2] >> 4));
if !self.ui.rules.bool("entity_drops") {
return;
return true;
}
self.audio
.play("minecraft:entity.painting.break", Some(at), 1.0, 1.0);
if by_player && self.creative {
return;
return true;
}
// HangingEntity.spawnAtLocation: 0.15 out from the wall.
let s = p.step().map(f64::from);
@@ -306,6 +311,7 @@ impl Game<'_> {
[0.0; 3],
10,
);
true
}
/// BlockAttachedEntity.tick: check survival every 100 ticks.
pub(super) fn tick_paintings(&mut self) {
@@ -323,8 +329,7 @@ impl Game<'_> {
if self.paintings[i].check_interval >= 100 {
self.paintings[i].check_interval = 0;
let p = self.paintings[i].clone();
if !self.painting_survives(&p) {
self.break_painting(i, false);
if !self.painting_survives(&p) && self.break_painting(i, false) {
continue;
}
}
+14 -19
View File
@@ -43,6 +43,8 @@ pub struct EntityMemory {
/// Entities a native mod kept out of the level (`native_entity_join_level`),
/// counted so `/summon` can fail as vanilla's refused add does.
pub(super) joins_canceled: u32,
/// The highest entity id `native_entity_join_level` has been told of.
pub(super) joined_mark: u32,
}
/// The key that marks a compound queued with the chunk loads as a fresh add
@@ -342,6 +344,8 @@ impl Game<'_> {
let from_disk = !take_fresh(&mut tag);
// An End gateway's traveller never left the level: nothing joins.
let moved = take_moved(&mut tag);
// Whatever was added since the last tick joins before this one.
self.native_joins_flush(false, 0);
let kind = entity_nbt::kind(&tag).unwrap_or_default();
let uuid = entity_nbt::uuid(&tag);
if uuid.is_some_and(|u| self.restored_vehicle(u).is_some()) {
@@ -358,6 +362,8 @@ impl Game<'_> {
| "minecraft:snowball"
| "minecraft:egg"
| "minecraft:ender_pearl"
| "minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:falling_block"
| "minecraft:firework_rocket"
| "minecraft:trident"
@@ -377,37 +383,19 @@ impl Game<'_> {
if let Some(e) =
entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob)
{
let (id, p) = (e.id, e.position);
self.remember(e.id, uuid, tag);
self.dropped.push(e);
if !moved
&& !self.native_entity_joins(
id,
"minecraft:item",
[p.x, p.y, p.z],
from_disk,
)
{
self.dropped.pop();
self.forget_entity(id);
}
}
}
"minecraft:experience_orb" => {
for mut orb in entity_nbt::orbs_from_nbt(&tag) {
self.next_entity += 1;
orb.id = self.next_entity;
let (id, p) = (orb.id, orb.position);
let id = orb.id;
// An orb that came through a portal keeps its cooldown.
self.restore_portal_cooldown(id, &tag);
self.orbs.push(orb);
self.remember_tags(id, &tag);
let kind = "minecraft:experience_orb";
if !moved
&& !self.native_entity_joins(id, kind, [p.x, p.y, p.z], from_disk)
{
self.orbs.pop();
}
}
}
"minecraft:arrow" => {
@@ -669,6 +657,13 @@ impl Game<'_> {
}
_ => self.persist.unknown.entry(key).or_default().push(tag),
}
// PersistentEntitySectionManager.addEntity: every kind that
// just joined tells the mods; a moved one does not join.
if moved {
self.native_joins_skip();
} else {
self.native_joins_flush(from_disk, 0);
}
}
self.native_entities_load(self.dim.id(), [key.0, key.1], count);
}
+1 -1
View File
@@ -603,7 +603,7 @@ impl Game<'_> {
if let Some(mud) = self.pack.assets.registry.default_state("minecraft:mud") {
self.level_set(pos, mud)?;
}
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
Ok(true)
}
@@ -56,7 +56,7 @@ impl Game<'_> {
return Ok(());
};
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
self.stats.add("used", "minecraft:bucket", 1);
self.award_stat("used", "minecraft:bucket", 1);
if let Some(eye) = self
.player
.as_ref()
+1 -1
View File
@@ -87,7 +87,7 @@ impl Game<'_> {
}
self.damage_held(1);
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
self.stats.add("used", "minecraft:shears", 1);
self.award_stat("used", "minecraft:shears", 1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, shears);
Ok(true)
+2 -2
View File
@@ -434,7 +434,7 @@ impl Game<'_> {
.raids
.create_or_extend(center, difficulty, amplifier, seed);
if triggered {
self.stats.custom("raid_trigger", 1);
self.award_custom_stat("raid_trigger", 1);
self.trigger_player("minecraft:voluntary_exile");
self.native_raid_state_change(id, center, "triggered");
}
@@ -660,7 +660,7 @@ impl Game<'_> {
EffectInstance::new("hero_of_the_village", raid::HERO_DURATION, amplifier);
effect.visible = false;
g.potions.effects.add(effect);
g.stats.custom("raid_win", 1);
g.award_custom_stat("raid_win", 1);
g.trigger_player("minecraft:hero_of_the_village");
}
}),
+13 -2
View File
@@ -6468,6 +6468,7 @@ impl Game<'_> {
// collisions and the pull of the nearest player between the server's
// updates; the server removes it on pickup.
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
let border = Some(self.border.geometry());
let mut lava_random = None;
let mut next_float = || {
lava_random
@@ -6501,7 +6502,15 @@ impl Game<'_> {
l.steps -= 1;
}
let before = o.position;
let kept = o.tick(world, facts, collision, target, false, &mut next_float);
let kept = o.tick(
world,
facts,
collision,
target,
false,
&mut next_float,
border,
);
// `tick` set `previous` to the stepped position: keep the
// pre-step one, as `xo` is in vanilla.
o.previous = old;
@@ -6519,11 +6528,12 @@ impl Game<'_> {
// ItemEntity.tick runs on a client too: gravity, collisions and
// friction move the item between the server's updates (every 20
// ticks), and only the server removes it.
let (world, registry, facts, collision) = (
let (world, registry, facts, collision, border) = (
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
Some(self.border.geometry()),
);
for d in self.dropped.iter_mut().filter(|_| running) {
d.tick(
@@ -6535,6 +6545,7 @@ impl Game<'_> {
&self.res.motion().fluids,
// The server decides what an item rests on; this copy only moves between updates.
&[],
border,
);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
@@ -611,16 +611,17 @@ impl Game<'_> {
// fuse down (the swell and flash follow it) between the server's
// updates (every 10 ticks), and a client discards it when the fuse
// runs out; only the server explodes it.
let (world, collision, assets, facts, fluids) = (
let (world, collision, assets, facts, fluids, border) = (
&self.world,
self.collision,
&self.pack.assets,
self.res.facts(),
&self.res.motion().fluids,
Some(self.border.geometry()),
);
self.tnts.retain_mut(|t| {
// A fluid lookup that fails leaves it where its move put it.
let _ = t.tick(world, collision, assets, facts, fluids);
let _ = t.tick(world, collision, assets, facts, fluids, border);
t.fuse > 0
});
// FallingBlockEntity.tick runs on a client too: the block falls by
+1 -1
View File
@@ -71,7 +71,7 @@ impl Game<'_> {
});
} else {
self.close_container();
self.stats.custom("leave_game", 1);
self.award_custom_stat("leave_game", 1);
self.lan_close(false);
self.free_player_beds()?;
self.pending_save = Some(ClosingSave {
@@ -252,6 +252,12 @@ impl Game<'_> {
play.respawn_requested = false;
play.won_game = false;
}
// PlayerList.respawn(keepAllPlayerData = true), CHANGED_DIMENSION:
// PlayerEvent.Clone with `wasDeath` false.
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.native_player_clone(uuid, &name, false);
}
self.start_server_trip(host, conn, Dim::Overworld, None);
self.portals.player.cooldown = 10;
}
+1 -1
View File
@@ -313,7 +313,7 @@ impl Game<'_> {
self.sculk_actor(),
Some(state),
);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.consume_held();
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
+2 -3
View File
@@ -255,7 +255,7 @@ impl Game<'_> {
// itemAttackInteraction: hurtEnemy (statistic, wear), and the
// post-attack enchantments when the target was hurt.
if let Some(stack) = self.held().cloned() {
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
if hurt {
self.post_attack(index);
}
@@ -263,8 +263,7 @@ impl Game<'_> {
}
// damageStatsAndHearts.
let actual = old_health - self.mobs[index].health;
self.stats
.custom("damage_dealt", (actual * 10.0).round() as i32);
self.award_custom_stat("damage_dealt", (actual * 10.0).round() as i32);
if actual > 2.0 {
let indicator = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::DamageIndicator,
+2 -2
View File
@@ -562,7 +562,7 @@ impl Game<'_> {
_ => "",
};
if !stat.is_empty() {
self.stats.custom(stat, 1);
self.award_custom_stat(stat, 1);
}
true
}
@@ -1051,7 +1051,7 @@ impl Game<'_> {
}
}
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.stats.custom("enchant_item", 1);
self.award_custom_stat("enchant_item", 1);
self.trigger_enchanted_item(&enchanted_item, lapis_needed as i32);
self.play_at("minecraft:block.enchantment_table.use", pos, 1.0, pitch);
self.station_changed();
+57 -27
View File
@@ -194,6 +194,30 @@ impl Game<'_> {
self.stats.write(&path);
}
}
/// `ServerPlayer.awardStat`: `n` more of the statistic `ty` (a bare type
/// such as `used`) and `key`, unless a native mod cancels the award.
pub(super) fn award_stat(&mut self, ty: &str, key: &str, n: i32) {
// No mod, no name copied: the per-tick counters come through here.
let acting = if horizon_client::mod_api::mods().is_empty() {
None
} else {
self.lan_acting_uuid.zip(self.lan_acting_name.clone())
};
if let Some((player, name)) = acting {
let mut cancel = false;
namespaced(ty, |stat_type| {
self.native_player_stat_award(player, &name, stat_type, key, n, &mut cancel)
});
if cancel {
return;
}
}
self.stats.add(ty, key, n);
}
/// `Player.awardStat(Identifier)`: `n` more of the custom statistic `key`.
pub(super) fn award_custom_stat(&mut self, key: &str, n: i32) {
namespaced(key, |key| self.award_stat("custom", key, n));
}
/// The per-tick counters (`Player.tick`/`ServerPlayer.tick` awardStat).
pub(super) fn tick_stats(&mut self) {
self.load_stats();
@@ -207,17 +231,16 @@ impl Game<'_> {
};
let alive = !p.health.dead_or_dying();
let crouching = p.pose == horizon_client::pose::Pose::Crouching;
let s = &mut self.stats;
s.custom("play_time", 1);
s.custom("total_world_time", 1);
self.award_custom_stat("play_time", 1);
self.award_custom_stat("total_world_time", 1);
if alive {
s.custom("time_since_death", 1);
self.award_custom_stat("time_since_death", 1);
if !sleeping {
s.custom("time_since_rest", 1);
self.award_custom_stat("time_since_rest", 1);
}
}
if crouching {
s.custom("sneak_time", 1);
self.award_custom_stat("sneak_time", 1);
}
}
fn tick_movement_stats(&mut self) {
@@ -269,51 +292,58 @@ impl Game<'_> {
.map(|m| m.species())
.ok_or(())
});
let s = &mut self.stats;
match riding {
Some(Ok(sp)) => {
use horizon_client::mobs::Species;
// `checkRidingStatistics`: only these mounts count, the horse
// family by the full 3D distance; any other mount nothing.
match sp {
Species::Pig => s.custom("pig_one_cm", total),
Species::Strider => s.custom("strider_one_cm", total),
Species::HappyGhast => s.custom("happy_ghast_one_cm", total),
Species::Pig => self.award_custom_stat("pig_one_cm", total),
Species::Strider => self.award_custom_stat("strider_one_cm", total),
Species::HappyGhast => self.award_custom_stat("happy_ghast_one_cm", total),
Species::Nautilus | Species::ZombieNautilus => {
s.custom("nautilus_one_cm", total)
self.award_custom_stat("nautilus_one_cm", total)
}
sp if horizon_client::mobs::equine::is_equine(sp) => {
s.custom("horse_one_cm", total)
self.award_custom_stat("horse_one_cm", total)
}
_ => {}
}
}
Some(Err(())) => {
if self.boats.iter().any(|b| Some(b.id) == self.riding) {
s.custom("boat_one_cm", total)
self.award_custom_stat("boat_one_cm", total)
} else if self.carts.iter().any(|c| Some(c.id) == self.riding) {
s.custom("minecart_one_cm", total)
self.award_custom_stat("minecart_one_cm", total)
}
}
None if swimming => s.custom("swim_one_cm", total),
None if swimming => self.award_custom_stat("swim_one_cm", total),
None if eye_in_water.fluid == horizon_client::blocks::Fluid::Water => {
s.custom("walk_under_water_one_cm", total)
self.award_custom_stat("walk_under_water_one_cm", total)
}
None if in_water => s.custom("walk_on_water_one_cm", horizontal),
None if climbing => s.custom("climb_one_cm", (d.y * 100.0).round() as i32),
None if on_ground => {
if sprinting {
s.custom("sprint_one_cm", horizontal)
} else if crouching {
s.custom("crouch_one_cm", horizontal)
} else {
s.custom("walk_one_cm", horizontal)
None if in_water => {
if horizontal > 0 {
self.award_custom_stat("walk_on_water_one_cm", horizontal)
}
}
None if gliding => s.custom("aviate_one_cm", total),
None if climbing => {
self.award_custom_stat("climb_one_cm", (d.y * 100.0).round() as i32)
}
None if on_ground => {
if horizontal > 0 {
if sprinting {
self.award_custom_stat("sprint_one_cm", horizontal)
} else if crouching {
self.award_custom_stat("crouch_one_cm", horizontal)
} else {
self.award_custom_stat("walk_one_cm", horizontal)
}
}
}
None if gliding => self.award_custom_stat("aviate_one_cm", total),
// Airborne and not gliding: more than 25 cm sideways per move
// (creative flight, or a long jump), not only flying.
None if horizontal > 25 => s.custom("fly_one_cm", horizontal),
None if horizontal > 25 => self.award_custom_stat("fly_one_cm", horizontal),
None => {}
}
}
+64 -11
View File
@@ -50,17 +50,20 @@ impl PrimedTnt {
assets: &horizon_client::assets::Assets,
facts: &horizon_client::blocks::BlockFacts,
fluids: &horizon_client::motion::FluidResources,
border: Option<&horizon_core::geometry::border::BorderGeometry>,
) -> Result<Vec<BlockPos>> {
self.previous = self.position;
self.velocity.y -= self.get_gravity();
let mut scene = SceneCollisions {
let scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
let wanted = self.velocity;
// Entity.collide: the world border's walls stop it too.
let border = border.map(|b| (b, [self.position.x, self.position.z]));
let moved = scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.resolve_in_border(self.bounds(), wanted, 0.0, self.on_ground, border)
.unwrap_or(Vec3::ZERO);
if horizon_client::entities::move_applies(wanted, moved) {
self.position = Vec3::new(
@@ -246,6 +249,7 @@ impl Game<'_> {
&self.pack.assets,
self.res.facts(),
&self.res.motion().fluids,
Some(self.border.geometry()),
)?;
for pos in tops {
self.bubble_column_particles(pos);
@@ -541,6 +545,55 @@ mod tests {
assert!(!tnt.on_ground);
}
/// `Entity.collectColliders` (#7080): the world border's wall stops primed
/// TNT. A border of size 10 around (20, 20) has its east wall at x = 25;
/// the TNT starts at x = 24 on the test scene's flat ground, 1.5 east.
#[test]
fn the_world_border_stops_primed_tnt() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
let world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
let border =
horizon_core::geometry::border::BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984)
.unwrap();
for bordered in [false, true] {
let at = Vec3::new(24.0, 4.0, 20.5);
let mut tnt = PrimedTnt {
no_gravity: false,
id: 0,
position: at,
previous: at,
velocity: Vec3::new(1.5, 0.0, 0.0),
fuse: 80,
on_ground: true,
owner: None,
guest: None,
power: 4.0,
};
let border = bordered.then_some(&border);
tnt.tick(&world, &collision, &assets, &facts, &fluids, border)
.unwrap();
let east = tnt.bounds().max_position().x;
if bordered {
assert!(
(east - 25.0).abs() < 1.0e-7,
"stops on the wall, got {east}"
);
} else {
assert!(east > 25.0, "no border: goes on, got {east}");
}
}
}
#[test]
fn primed_tnt_follows_water_currents_after_moving() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -580,7 +633,7 @@ mod tests {
guest: None,
power: 4.0,
};
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
assert_eq!(
tnt.position.x, at.x,
@@ -592,14 +645,14 @@ mod tests {
tnt.velocity
);
assert_eq!(tnt.fuse, 79);
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
assert!((tnt.position.x - at.x - 0.014).abs() < 1e-12);
assert!(tnt.on_ground);
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.fuse = 1;
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
assert_eq!(tnt.fuse, 0);
assert_eq!(
@@ -610,7 +663,7 @@ mod tests {
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.fuse = 80;
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
assert_eq!(
tnt.velocity.x, 0.0,
@@ -626,7 +679,7 @@ mod tests {
.unwrap();
tnt.on_ground = false;
tnt.velocity = Vec3::ZERO;
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
assert!(
tnt.position.y < at.y && tnt.velocity.y < 0.0,
@@ -683,7 +736,7 @@ mod tests {
power: 4.0,
};
for _ in 0..10 {
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
}
assert!(
@@ -704,7 +757,7 @@ mod tests {
tnt.velocity = Vec3::ZERO;
tnt.on_ground = false;
for _ in 0..4 {
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
}
assert!(
@@ -764,7 +817,7 @@ mod tests {
let mut tnt = primed();
for _ in 0..30 {
tops.extend(
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap(),
);
}
@@ -778,7 +831,7 @@ mod tests {
let mut tnt = primed();
for _ in 0..10 {
assert!(
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap()
.is_empty()
);
+1 -1
View File
@@ -59,7 +59,7 @@ impl Game<'_> {
*cell = None;
}
}
self.stats.add("used", TOTEM, 1);
self.award_stat("used", TOTEM, 1);
self.trigger_item(
"minecraft:used_totem",
&horizon_client::items::ItemStack::new(TOTEM, 1),
+2 -2
View File
@@ -251,7 +251,7 @@ impl Game<'_> {
self.handle_mob_events(events)?;
return Ok(true);
}
self.stats.custom("talked_to_villager", 1);
self.award_custom_stat("talked_to_villager", 1);
self.ensure_offers(index);
let m = &self.mobs[index];
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
@@ -470,7 +470,7 @@ impl Game<'_> {
/// for a server's player (when the trader is still in the level).
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, offers: &[MerchantOffer]) {
for offer in offers {
self.stats.custom("traded_with_villager", 1);
self.award_custom_stat("traded_with_villager", 1);
let merchant = mob.and_then(|id| self.mobs.iter().find(|m| m.id == id));
let kind = merchant.map(super::native_hooks::mob_kind);
let villager = merchant.map(|m| self.mob_entity(m));
+1 -1
View File
@@ -167,7 +167,7 @@ impl Game<'_> {
};
if taken > 0 {
// awardStat(ITEM_USED), then stackToInsert.consume(count, player).
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
for _ in 0..taken {
self.consume_held();
}
+1 -1
View File
@@ -75,7 +75,7 @@ impl Game<'_> {
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
self.audio
.play("minecraft:entity.wind_charge.throw", Some(eye), 0.5, pitch);
self.stats.add("used", "minecraft:wind_charge", 1);
self.award_stat("used", "minecraft:wind_charge", 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
+26 -4
View File
@@ -35,6 +35,13 @@ pub struct ServerAdvancements {
pub struct FirstTick {
pub recipe_book_add: Option<Vec<u8>>,
pub update_advancements: Vec<u8>,
pub granted: Vec<CriterionGrant>,
}
/// A criterion `CriteriaTriggers.TICK` newly granted, for the game to tell the mods.
pub struct CriterionGrant {
pub advancement: String,
pub criterion: String,
}
impl ServerAdvancements {
@@ -89,9 +96,15 @@ impl ServerAdvancements {
})
.collect();
let date = crate::advancements::date_text(now_millis.div_euclid(1000));
let mut granted = Vec::new();
for (a, got) in self.tree.list.iter().zip(&obtained) {
for c in got {
progress.grant(&a.id, c, &date);
if progress.grant(&a.id, c, &date) {
granted.push(CriterionGrant {
advancement: a.id.clone(),
criterion: (*c).to_owned(),
});
}
}
}
// AdvancementRewards.grant: the recipes, in the advancements' order.
@@ -126,6 +139,7 @@ impl ServerAdvancements {
FirstTick {
recipe_book_add,
update_advancements: w.data,
granted,
}
}
@@ -135,13 +149,15 @@ impl ServerAdvancements {
/// drops a criterion it lost), the next `flushDirty` is a first packet
/// again, and the next `CriteriaTriggers.TICK` meets the new tree's
/// `minecraft:tick` criteria, rewarding the recipes of the advancements
/// that complete. Returns the recipe book packet those recipes need.
/// that complete. The criteria it grants are added to `granted`. Returns
/// the recipe book packet those recipes need.
pub fn reload(
&self,
sync: &SyncData,
now_millis: i64,
progress: &mut Progress,
book: &mut Settings,
granted: &mut Vec<CriterionGrant>,
) -> Option<Vec<u8>> {
let by_id: HashMap<&str, &crate::advancements::Advancement> =
self.tree.list.iter().map(|a| (a.id.as_str(), a)).collect();
@@ -159,8 +175,14 @@ impl ServerAdvancements {
for a in &self.tree.list {
let was_done = progress.done(a);
for c in &a.criteria {
if c.trigger == "minecraft:tick" && c.conditions.is_null() {
progress.grant(&a.id, &c.name, &date);
if c.trigger == "minecraft:tick"
&& c.conditions.is_null()
&& progress.grant(&a.id, &c.name, &date)
{
granted.push(CriterionGrant {
advancement: a.id.clone(),
criterion: c.name.clone(),
});
}
}
if !was_done && progress.done(a) {
+29 -4
View File
@@ -175,6 +175,13 @@ pub enum HostEvent {
},
}
/// The criteria `CriteriaTriggers.TICK` granted one player.
pub struct TickGrants {
pub player: u128,
pub name: String,
pub grants: Vec<super::advancements::CriterionGrant>,
}
/// What one player started and stopped tracking, in order, for the game to
/// tell the mods.
pub struct TrackingBatch {
@@ -201,6 +208,9 @@ pub struct Hub {
/// The entities players started and stopped tracking, collected by
/// `collect_tracking`, for the game to tell the mods.
pub native_tracking: Vec<TrackingBatch>,
/// Criteria `CriteriaTriggers.TICK` granted to players, for the game to
/// tell the mods.
pub native_advancement_grants: Vec<TickGrants>,
/// Players a native mod hid (`native_hide_player`), by UUID, on or off:
/// only players of at least that permission level see them, in the
/// player list, the world and their joining and leaving.
@@ -547,6 +557,7 @@ impl Hub {
mod_commands: (serde_json::Value::Null, None),
native_menu_events: Vec::new(),
native_tracking: Vec::new(),
native_advancement_grants: Vec::new(),
hidden: Default::default(),
teams: Vec::new(),
nicknames: std::collections::HashMap::new(),
@@ -2610,6 +2621,11 @@ impl Hub {
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
}
g.send(&raw(ids::UPDATE_ADVANCEMENTS, first.update_advancements));
self.native_advancement_grants.push(TickGrants {
player: g.uuid,
name: g.name.clone(),
grants: first.granted,
});
g.advancements_visible = sync.advancements.visibility(&g.advancements);
g.advancements_changed.clear();
} else {
@@ -2744,6 +2760,7 @@ impl Hub {
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64);
for g in &mut self.guests {
let mut granted = Vec::new();
// PlayerAdvancements.reload: isFirstPacket again, the progress
// read against the new tree; a player whose advancements were
// never loaded does that on their first tick.
@@ -2752,12 +2769,20 @@ impl Hub {
}
g.advancements_visible.clear();
g.advancements_changed.clear();
if let Some(add) =
sync.advancements
.reload(&sync, now, &mut g.advancements, &mut g.recipe_book)
{
if let Some(add) = sync.advancements.reload(
&sync,
now,
&mut g.advancements,
&mut g.recipe_book,
&mut granted,
) {
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
}
self.native_advancement_grants.push(TickGrants {
player: g.uuid,
name: g.name.clone(),
grants: granted,
});
}
// broadcastAll(ClientboundUpdateTagsPacket).
self.broadcast(&C::UpdateTags(sync.tags.clone()));
+15 -4
View File
@@ -15,11 +15,22 @@ use std::{
sync::Arc,
};
/// `PlayerDataStorage`'s player directory (`players/data`), created on first use
/// as its constructor does with `mkdirs`.
pub fn directory(world: &Path) -> PathBuf {
let dir = directory_path(world);
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!("Horizon: {} is not created: {e}", dir.display());
}
dir
}
fn directory_path(world: &Path) -> PathBuf {
world.join("players").join("data")
}
pub fn path(world: &Path, uuid: u128) -> PathBuf {
world
.join("players")
.join("data")
.join(format!("{}.dat", uuid_string(uuid)))
directory_path(world).join(format!("{}.dat", uuid_string(uuid)))
}
/// `UUIDUtil.uuidToIntArray`.
+17
View File
@@ -957,6 +957,10 @@ pub struct Extra {
pub swing_stab: bool,
pub flag: bool,
pub flag2: bool,
/// `AbstractHorse`'s `Bred` save flag: nothing sets it at runtime (no
/// vanilla code calls `setBred` outside `readAdditionalSaveData`), so only
/// a save or `/summon` gives it, and `followMommy` reads it.
pub bred: bool,
/// `AbstractHorse.allowStandSliding`: the rider's jump sets it, and a
/// horse still rearing then goes on with the rider's keys until the rear
/// ends (`tick` clears it).
@@ -1534,6 +1538,16 @@ pub enum MobEvent {
amount: f32,
source: Damage,
},
/// `Pufferfish.touch` on a mob (`Pufferfish.aiStep`): the hit, then, only
/// when `hurtServer` let it land, the fish's `entity.puffer_fish.sting`
/// and the poison it gives (`poison_ticks`, `60 * getPuffState()`).
PufferfishSting {
mob: u32,
target: u32,
amount: f32,
source: Damage,
poison_ticks: i32,
},
/// `LivingEntity.stabAttack`'s `target.stopRiding()`: a spear knocks the
/// target off its mount.
StabDismount {
@@ -1944,6 +1958,8 @@ pub struct Seen {
pub baby: bool,
pub in_love: bool,
pub tame: bool,
/// `AbstractHorse.isBred`: the `Bred` flag (`followMommy` reads it).
pub bred: bool,
/// A tamed animal's owner (`getOwner`).
pub owner: Option<[i32; 4]>,
pub hostile: bool,
@@ -2145,6 +2161,7 @@ impl Mob {
m.extra.in_love > 0
},
tame: m.extra.tame,
bred: m.extra.bred,
owner: m.extra.owner,
hostile: m.hostile(),
target: m.ai.target,
+146 -18
View File
@@ -2,7 +2,7 @@
//! tropical fish and pufferfish (`AbstractFish`, puffing), and dolphins
//! (breathing, smooth swimming).
use super::{
Mob, MobEvent, MobWorld, Species,
Damage, Mob, MobEvent, MobWorld, Species, Who,
anim::{Poses, State},
control::MoveKind,
goals::{
@@ -333,6 +333,9 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.sound("entity.puffer_fish.sting", events);
}
}
if m.species() == S::Pufferfish {
sting_touching_mobs(m, w, events);
}
}
S::Dolphin => {
// Dolphin.tick: moisture out of water, hop on land.
@@ -364,6 +367,65 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
_ => {}
}
}
/// `EntityTypeTags.NOT_SCARY_FOR_PUFFERFISH`: the mobs a pufferfish leaves
/// alone, its own kind and the water mobs it shares a tank with.
pub fn not_scary_for_pufferfish(s: Species) -> bool {
matches!(
s,
Species::Cod
| Species::Salmon
| Species::TropicalFish
| Species::Pufferfish
| Species::Squid
| Species::GlowSquid
| Species::Dolphin
| Species::Turtle
| Species::Guardian
| Species::ElderGuardian
| Species::Tadpole
| Species::Nautilus
| Species::ZombieNautilus
| Species::SulfurCube
)
}
/// `Pufferfish.aiStep` after `AbstractFish.aiStep`: a puffed pufferfish stings
/// every mob that touches it, not only the player. The query is `Mob.class`
/// in its own box inflated by 0.3, filtered by `TARGETING_CONDITIONS` (alive
/// and not `#not_scary_for_pufferfish`; invisibility and line of sight are
/// not tested), and each one takes `Pufferfish.touch`: `doTeamsAllowDamage`,
/// then a `mobAttack` hit for `getPuffState() + 1`. The poison and the sting
/// sound follow a hit that landed, which only the game layer knows.
fn sting_touching_mobs(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if !m.alive() || m.extra.size <= 0 {
return;
}
let area = m.bounds().inflate(Vec3::new(0.3, 0.3, 0.3));
let own = w.teams.of(Who::Mob(m.id));
let stung: Vec<u32> = w
.others_near(area)
.filter(|o| {
o.id != m.id
&& o.alive
&& !not_scary_for_pufferfish(o.species)
&& o.bounds.intersects(area)
&& crate::scoreboard::teams_allow_damage(own, w.teams.of(Who::Mob(o.id)))
})
.map(|o| o.id)
.collect();
let from = [m.position.x, m.position.y, m.position.z];
for target in stung {
events.push(MobEvent::PufferfishSting {
mob: m.id,
target,
amount: 1.0 + m.extra.size as f32,
source: Damage::mob(m.id, from),
poison_ticks: 60 * m.extra.size,
});
}
}
/// Squid.aiStep: tentacle cycle, propulsion and body rotation.
fn squid_ai_step(m: &mut Mob) {
squid_step(m, false);
@@ -881,23 +943,7 @@ impl Goal for PufferfishPuff {
let mob = cx.w.others_near(area).any(|s| {
s.id != m.id
&& s.alive
&& !matches!(
s.species,
Species::Cod
| Species::Salmon
| Species::TropicalFish
| Species::Pufferfish
| Species::Squid
| Species::GlowSquid
| Species::Dolphin
| Species::Turtle
| Species::Guardian
| Species::ElderGuardian
| Species::Tadpole
| Species::Nautilus
| Species::ZombieNautilus
| Species::SulfurCube
)
&& !not_scary_for_pufferfish(s.species)
&& area.intersects(s.bounds)
});
player || mob
@@ -1099,3 +1145,85 @@ mod school_tests {
assert!((100..=110).contains(&start));
}
}
#[cfg(test)]
mod pufferfish_sting_tests {
use super::*;
use crate::mobs::world::with_test_world;
use std::sync::Arc;
/// What a pufferfish puffed to `puff` stings in one `ai_step`, as
/// `(target, damage)`, with `others` standing `x` blocks east of it.
fn stings(puff: i32, others: &[(&str, f64)]) -> Option<Vec<(u32, f32)>> {
let mut built = None;
with_test_world(&[], |_, types| {
let mob = |id: u32, name: &str, at: Vec3| {
Mob::new(id, Arc::new(types.types[name].clone()), at, 0.0)
};
let mut fish = mob(1, "minecraft:pufferfish", Vec3::new(0.5, 64.0, 0.5));
fish.extra.size = puff;
let seen: Vec<_> = others
.iter()
.enumerate()
.map(|(i, (name, x))| {
mob(i as u32 + 2, name, Vec3::new(0.5 + x, 64.0, 0.5)).seen(None)
})
.collect();
built = Some((fish, seen));
});
let (mut fish, seen) = built?;
let mut events = Vec::new();
with_test_world(&seen, |w, _| ai_step(&mut fish, w, &mut events));
Some(
events
.iter()
.filter_map(|e| match e {
MobEvent::PufferfishSting {
mob: 1,
target,
amount,
source,
poison_ticks,
} => {
assert_eq!(source.attacker, Some(Who::Mob(1)));
assert_eq!(
*poison_ticks,
60 * puff,
"Pufferfish.touch's POISON lasts 60 * getPuffState()"
);
Some((*target, *amount))
}
_ => None,
})
.collect(),
)
}
/// `Pufferfish.aiStep`: a puffed pufferfish stings every mob whose box
/// meets its own (inflated by 0.3), not only the player, for
/// `getPuffState() + 1`.
#[test]
fn a_puffed_pufferfish_stings_the_mobs_that_touch_it() {
let Some(half) = stings(1, &[("minecraft:zombie", 0.5)]) else {
return;
};
assert_eq!(half, vec![(2, 2.0)], "puff state 1 stings for 1 + 1");
let full = stings(2, &[("minecraft:zombie", 0.5)]).unwrap();
assert_eq!(full, vec![(2, 3.0)], "puff state 2 stings for 1 + 2");
assert!(
stings(0, &[("minecraft:zombie", 0.5)]).unwrap().is_empty(),
"a deflated pufferfish stings nobody"
);
assert!(
stings(2, &[("minecraft:zombie", 1.5)]).unwrap().is_empty(),
"a zombie a block away never touches it"
);
// `#not_scary_for_pufferfish`: its tank mates are left alone.
assert!(
stings(2, &[("minecraft:cod", 0.4), ("minecraft:squid", 0.3)])
.unwrap()
.is_empty(),
"a cod and a squid are not scary"
);
}
}
+185
View File
@@ -10,6 +10,7 @@ use super::{
Tempt, goal_clone,
},
};
use horizon_core::geometry::Vec3;
use horizon_core::geometry::trig::Mth;
use horizon_core::random::RandomSource;
use std::f32::consts::PI;
@@ -740,9 +741,13 @@ pub fn tail_tick(m: &mut Mob) {
/// eating/standing/mouth animations.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if super::camel::is_camel(m.species()) {
// Camel.aiStep runs AbstractHorse.aiStep first, and its followMommy is
// the last thing that does.
follow_mommy(m, w);
return super::camel::ai_step(m, events);
}
if super::llama::is_llama(m.species()) {
follow_mommy(m, w);
return super::llama::ai_step(m, w);
}
if m.alive() && m.random.next_int_bound(900).unwrap_or(1) == 0 && m.death_time == 0 {
@@ -772,6 +777,46 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
stand_tick(m);
animate(m);
follow_mommy(m, w);
}
/// `AbstractHorse.aiStep`'s last call, `followMommy`: a bred foal that is not
/// eating keeps a path to the nearest bred `AbstractHorse` within 16 blocks
/// while it is more than two blocks away (`MOMMY_TARGETING`, and
/// `this.distanceToSqr(mommy) > 4.0`). `PathNavigation.createPath(entity, 0)`
/// installs that path and leaves the navigation's speed modifier alone, so the
/// foal walks it at whatever speed its goals last asked for.
pub fn follow_mommy(m: &mut Mob, w: &MobWorld) {
if !m.extra.bred || !m.is_baby() || m.extra.flag {
return;
}
let at = m.position;
// `MOMMY_TARGETING`'s range is 16 from the foal's position, and the search
// box is its own bounding box inflated by the same 16.
let area = m.bounds().inflate(Vec3::new(16.0, 16.0, 16.0));
let mommy = w
.others_near(area)
.filter(|o| {
o.alive
&& o.bred
&& is_equine(o.species)
&& area.intersects(o.bounds)
&& at.distance_squared(o.position) <= 256.0
})
.min_by(|a, b| {
at.distance_squared(a.position)
.total_cmp(&at.distance_squared(b.position))
})
.map(|o| o.position);
let Some(mommy) = mommy else {
return;
};
if at.distance_squared(mommy) <= 4.0 {
return;
}
let speed = m.ai.nav.speed_modifier;
let path = m.create_path_to_entity(w, [mommy.x, mommy.y, mommy.z], 0);
m.nav_move_to_path(w, path, speed);
}
/// `AbstractHorse.tick`: the stand counter runs down to `clearStanding`. It is
/// in `tick`, not `aiStep`, so a horse with no AI stops rearing too.
@@ -1107,3 +1152,143 @@ mod tests {
assert_eq!(swim_volume(&horse, 0.35), 0.35);
}
}
#[cfg(test)]
mod follow_mommy_tests {
use super::*;
use crate::mobs::world::{with_built_test_world, with_test_world};
use std::sync::Arc;
/// The block the foal's navigation path ends on after one `ai_step`, with
/// the foal at x = 0.5 on a stone floor and `others` (name, x, bred)
/// standing on the same floor.
fn target_pos(
foal: (bool, bool, bool),
others: &[(&str, f64, bool)],
) -> Option<Option<[i32; 3]>> {
let (bred, baby, eating) = foal;
let mut built = None;
with_test_world(&[], |_, types| {
let mob = |id: u32, name: &str, at: Vec3| {
let mut m = Mob::new(id, Arc::new(types.types[name].clone()), at, 0.0);
m.on_ground = true;
m
};
let seen: Vec<_> = others
.iter()
.enumerate()
.map(|(i, (name, x, bred))| {
let mut m = mob(i as u32 + 2, name, Vec3::new(*x, 1.0, 0.5));
m.extra.bred = *bred;
m.seen(None)
})
.collect();
let mut foal = mob(1, "minecraft:horse", Vec3::new(0.5, 1.0, 0.5));
foal.extra.bred = bred;
foal.extra.age = if baby { -24_000 } else { 0 };
foal.extra.flag = eating;
built = Some((foal, seen));
});
let (mut foal, seen) = built?;
let mut out = None;
with_built_test_world(
&seen,
|world, registry| {
// The chunks the pathfinder looks through, over a stone floor.
for (cx, cz) in [
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 0),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
] {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
let stone = registry.default_state("minecraft:stone").unwrap();
for x in -16..32 {
for z in -16..32 {
world.set(x, 0, z, stone).unwrap();
}
}
},
|w, _| {
let mut events = Vec::new();
ai_step(&mut foal, w, &mut events);
out = Some(foal.ai.nav.target_pos);
},
);
out
}
/// `AbstractHorse.followMommy`: a bred foal that is not eating keeps a path
/// to the nearest bred `AbstractHorse` within 16 blocks while it is more
/// than two blocks away.
#[test]
fn a_bred_foal_paths_to_the_nearest_bred_horse() {
let Some(_) = target_pos((true, true, false), &[]) else {
return;
};
assert_eq!(
target_pos((true, true, false), &[("minecraft:horse", 6.5, true)]).unwrap(),
Some([6, 1, 0]),
"a bred foal walks to the grown horse"
);
// The nearest of two, whatever the kind.
assert_eq!(
target_pos(
(true, true, false),
&[
("minecraft:horse", 12.5, true),
("minecraft:donkey", 6.5, true)
]
)
.unwrap(),
Some([6, 1, 0]),
"the nearer of the herd"
);
// `isBred`, `isBaby` and `!isEating` on the foal itself.
assert_eq!(
target_pos((false, true, false), &[("minecraft:horse", 6.5, true)]).unwrap(),
None,
"a foal that is not bred does not follow"
);
assert_eq!(
target_pos((true, false, false), &[("minecraft:horse", 6.5, true)]).unwrap(),
None,
"an adult does not follow"
);
assert_eq!(
target_pos((true, true, true), &[("minecraft:horse", 6.5, true)]).unwrap(),
None,
"a foal busy eating does not follow"
);
// `MOMMY_TARGETING`: an AbstractHorse, and bred.
assert_eq!(
target_pos((true, true, false), &[("minecraft:horse", 6.5, false)]).unwrap(),
None,
"the grown horse must be bred too"
);
assert_eq!(
target_pos((true, true, false), &[("minecraft:cow", 6.5, true)]).unwrap(),
None,
"another kind is no mommy"
);
// 16 blocks of `MOMMY_TARGETING`, and more than two away.
assert_eq!(
target_pos((true, true, false), &[("minecraft:horse", 20.5, true)]).unwrap(),
None,
"out of the 16-block range"
);
assert_eq!(
target_pos((true, true, false), &[("minecraft:horse", 1.5, true)]).unwrap(),
None,
"already at its side"
);
}
}
+2 -2
View File
@@ -224,9 +224,9 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
};
let start = Vec3::new(m.position.x, m.eye_y() - 0.1, m.position.z);
let mut p = Projectile::new(
ProjectileKind::Potion(Box::new(
ProjectileKind::potion(
crate::items::ItemStack::new("minecraft:splash_potion", 1).with_potion(potion),
)),
),
start,
Some(Who::Mob(m.id)),
);
+98
View File
@@ -492,3 +492,101 @@ mod generated;
pub use generated::Hooks;
#[doc(hidden)]
pub use generated::*;
#[cfg(test)]
mod tests {
use super::*;
/// A double with the three `Host` methods the probe calls.
struct Reader {
block: &'static str,
lines: Vec<Text>,
}
impl Host for Reader {
fn block(&self, _pos: [i32; 3]) -> String {
self.block.to_string()
}
fn acting_player(&self) -> Option<NativeServerPlayer> {
Some(NativeServerPlayer {
uuid: 7,
name: "Probe".to_string(),
permission: 0,
dimension: "minecraft:overworld".to_string(),
position: [0.0; 3],
game_mode: 0,
health: 20.0,
yaw: 0.0,
pitch: 0.0,
})
}
fn action_bar(&mut self, _uuid: u128, line: Text) -> bool {
self.lines.push(line);
true
}
}
/// Claims a use on stone, after telling the player what the block is.
struct Probe;
impl Hooks for Probe {
fn use_item_on(&self, host: &mut dyn Host, event: &mut UseItemOnEvent<'_>) -> Flow {
let block = host.block(event.pos);
let player = host.acting_player().expect("a player acts");
host.action_bar(player.uuid, Text::literal(block.clone()));
if block == "minecraft:stone" {
Flow::Cancel
} else {
Flow::Continue
}
}
}
/// Asks the host to tell a player without anyone having implemented it.
struct Chatty;
impl Hooks for Chatty {
fn use_item_on(&self, host: &mut dyn Host, _event: &mut UseItemOnEvent<'_>) -> Flow {
host.tell(7, Text::literal("hello"));
Flow::Continue
}
}
fn stick() -> ItemStack {
ItemStack {
item: Arc::from("minecraft:stick"),
count: 1,
damage: 0,
extra: None,
}
}
#[test]
fn a_test_host_implements_only_what_its_hook_calls() {
let mods = [Box::new(Probe) as Box<dyn Hooks>];
let stack = stick();
for (block, expected) in [("minecraft:stone", true), ("minecraft:dirt", false)] {
let mut host = Reader {
block,
lines: Vec::new(),
};
let mut claimed = false;
dispatch_use_item_on(&mut host, &mods, &stack, [1, 2, 3], [0, 1, 0], &mut claimed);
assert_eq!(claimed, expected, "{block}");
let told: Vec<String> = host.lines.iter().map(Text::plain).collect();
assert_eq!(told, [block], "{block}");
}
}
#[test]
#[should_panic(expected = "Host::tell")]
fn an_unexpected_host_call_names_the_method() {
let mods = [Box::new(Chatty) as Box<dyn Hooks>];
let mut host = Reader {
block: "minecraft:stone",
lines: Vec::new(),
};
let mut claimed = false;
dispatch_use_item_on(&mut host, &mods, &stick(), [0; 3], [0, 1, 0], &mut claimed);
}
}
+249 -3
View File
@@ -711,9 +711,15 @@ pub trait Hooks: Send + Sync {
/// an arrival through a portal). `entity` is its ID and `kind` its type
/// (`minecraft:item`). It is already in the level's lists, so helpers that
/// take an entity ID find it. Set `canceled` to keep it out: it is taken
/// back out as if never added, and a `/summon` of it fails. 47 tells
/// item entities and experience orbs; later APIs add other kinds, so check
/// `kind`. Server-side.
/// back out as if never added, and a `/summon` of it fails. Tells every
/// kind in the game's entity lists (items, orbs, mobs, boats, minecarts,
/// armor stands, primed TNT, falling blocks, projectiles, rockets,
/// tridents), once each, in the order they were created; check `kind`. A
/// gameplay spawn is told when the next tick starts (a drop, an orb, a mob, a
/// `/summon` and a chunk's entities are told at once). A mod that
/// cancels one of the others sees it removed before it ticks again.
/// Not told: end crystals, the dragon, cushions, paintings, item frames and
/// the entities Horizon keeps without simulating. Server-side.
///
/// Return `Flow::Cancel` to take it: this sets `canceled`, and later mods are skipped.
fn entity_join_level(&self, host: &mut dyn Host, event: &mut EntityJoinLevelEvent<'_>) -> Flow {
@@ -3212,6 +3218,23 @@ pub trait Hooks: Send + Sync {
let _ = event;
Flow::Continue
}
/// An item frame (`minecraft:item_frame`, `minecraft:glow_item_frame`) or
/// a painting (`minecraft:painting`), `kind` its entity type and `pos` the
/// block it hangs in, is about to break: a server player's hit
/// (`player`, their state swapped in, so `native_tell` answers them), a
/// blast, or the wall it hangs on gone (`player` None for both). Where
/// `BlockAttachedEntity.hurtServer` and its survival check would kill
/// it, before anything drops or sounds. Set `cancel` and it stays, as it
/// was. A hit that only takes the item out of a frame does not break the
/// frame and is not asked. The game's own player's hit is not asked, nor
/// is `/kill`. @API:native_hanging_break@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn hanging_break(&self, host: &mut dyn Host, event: &mut HangingBreakEvent<'_>) -> Flow {
let _ = host;
let _ = event;
Flow::Continue
}
/// A server player places a hanging entity: an item frame
/// (`minecraft:item_frame`, `minecraft:glow_item_frame`), a painting
/// (`minecraft:painting`) or a leash knot (`minecraft:leash_knot`, when a
@@ -3275,6 +3298,17 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// A player's data was copied to their new player (NeoForge's
/// `PlayerEvent.Clone`, from `ServerPlayer.restoreFrom`): `was_death` is
/// true after a death, false when they left the End after the credits
/// (`respawn` with `keepAllPlayerData`). The uuid stays the same, so the
/// old and the new player share `player`. Heard before the new player is
/// placed, on a server, with no player's state swapped in. Not
/// cancellable.
fn player_clone(&self, host: &mut dyn Host, event: &mut PlayerCloneEvent<'_>) {
let _ = host;
let _ = event;
}
/// The command tree a server sends a player (NeoForge's and Paper's
/// "commands sent to a player" stage): when they join and when their
/// level changes (`/op`, `/deop`, the LAN settings). `names` are the
@@ -3405,6 +3439,18 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// A player was loaded from the world's save (NeoForge's
/// `PlayerEvent.LoadFromFile`, `PlayerList.loadPlayerData`): their `.dat`
/// has been read and applied, and they are not placed in a level yet.
/// `directory` is the world's player directory (`players/data`): a mod
/// reads its own file there, named `<uuid>.<suffix>` as
/// `getPlayerFile(suffix)` names it. Heard once for each join, for the
/// owner of an integrated server as for anyone else, on a server, with no
/// player's state swapped in. Not cancellable.
fn player_load_from_file(&self, host: &mut dyn Host, event: &mut PlayerLoadFromFileEvent<'_>) {
let _ = host;
let _ = event;
}
/// A player whose login the server has accepted (NeoForge's
/// `PlayerNegotiationEvent` stage): the name, whitelist, ban and
/// server-full checks have passed and the player holds a place, but their
@@ -3425,6 +3471,18 @@ pub trait Hooks: Send + Sync {
let _ = event;
Flow::Continue
}
/// A player was saved to the world's store (NeoForge's
/// `PlayerEvent.SaveToFile`, `PlayerList.save`): when they leave or the
/// world closes, when the dedicated server saves everyone, and for an
/// integrated server's owner when the world saves them. LAN guests are
/// not heard on integrated autosaves. Once for each save. `directory`
/// is the world's player directory (`players/data`): write your own file
/// there, named `<uuid>.<suffix>`, never the player's `.dat`. The player
/// may be leaving, so no player's state is swapped in. Not cancellable.
fn player_save_to_file(&self, host: &mut dyn Host, event: &mut PlayerSaveToFileEvent<'_>) {
let _ = host;
let _ = event;
}
/// A server player's sprint started or stopped: their client sent
/// `START_SPRINTING` or `STOP_SPRINTING` (the sprint key, a double tap
/// forward, or letting go) and the server's player is now `sprinting`
@@ -3598,6 +3656,21 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// A criterion of a server player's advancement was granted or revoked,
/// after its progress changed and before any completion rewards, except
/// the `minecraft:tick` criteria granted on a network player's first tick
/// or a reload, which are heard after that tick's rewards and packets. A
/// grant or revoke that changes nothing is not heard. `granted` is false for a
/// revoke. Helpers looking up the guest through `self.lan` may find
/// nothing during its packet. @API:player_advancement_progress@.
fn player_advancement_progress(
&self,
host: &mut dyn Host,
event: &mut PlayerAdvancementProgressEvent<'_>,
) {
let _ = host;
let _ = event;
}
/// A server player reset their attack strength by swinging (`handlePunch`).
/// The swing and reset have already happened. The player's state is not
/// swapped in, and helpers looking them up through `self.lan` may find
@@ -3889,6 +3962,19 @@ pub trait Hooks: Send + Sync {
let _ = event;
Flow::Continue
}
/// A statistic is about to be awarded to a server player, before its count
/// and scoreboard criteria change. `stat_type` and `stat` are ids such as
/// `minecraft:mined` and `minecraft:stone`, and `value` the amount. Set
/// `cancel` to award nothing. Helpers looking up the guest through
/// `self.lan` may find nothing during its packet. Server-side.
/// @API:player_stat_award@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn player_stat_award(&self, host: &mut dyn Host, event: &mut PlayerStatAwardEvent<'_>) -> Flow {
let _ = host;
let _ = event;
Flow::Continue
}
/// A server player selected a stonecutter recipe, after the menu accepted
/// its button. `recipe` is the zero-based button index. Guest lookup
/// helpers may find nothing during packet handling. Server-side.
@@ -10410,6 +10496,32 @@ pub fn dispatch_food_level_changed(
}
*to = event.to;
}
/// What [`Hooks::hanging_break`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct HangingBreakEvent<'a> {
pub player: Option<u128>,
pub kind: &'a str,
pub pos: [i32; 3],
}
pub fn dispatch_hanging_break(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: Option<u128>,
kind: &str,
pos: [i32; 3],
cancel: &mut bool,
) {
if *cancel {
return;
}
let mut event = HangingBreakEvent { player, kind, pos };
for mod_hook in mods {
if matches!(mod_hook.hanging_break(host, &mut event), Flow::Cancel) {
*cancel = true;
break;
}
}
}
/// What [`Hooks::hanging_place`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct HangingPlaceEvent<'a> {
@@ -10503,6 +10615,29 @@ pub fn dispatch_player_changed_dimension(
mod_hook.player_changed_dimension(host, &mut event);
}
}
/// What [`Hooks::player_clone`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerCloneEvent<'a> {
pub player: u128,
pub name: &'a str,
pub was_death: bool,
}
pub fn dispatch_player_clone(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
was_death: bool,
) {
let mut event = PlayerCloneEvent {
player,
name,
was_death,
};
for mod_hook in mods {
mod_hook.player_clone(host, &mut event);
}
}
/// What [`Hooks::player_commands_sent`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerCommandsSentEvent<'a> {
@@ -10757,6 +10892,29 @@ pub fn dispatch_player_lectern_page_change(
mod_hook.player_lectern_page_change(host, &mut event);
}
}
/// What [`Hooks::player_load_from_file`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerLoadFromFileEvent<'a> {
pub player: u128,
pub name: &'a str,
pub directory: &'a std::path::Path,
}
pub fn dispatch_player_load_from_file(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
directory: &std::path::Path,
) {
let mut event = PlayerLoadFromFileEvent {
player,
name,
directory,
};
for mod_hook in mods {
mod_hook.player_load_from_file(host, &mut event);
}
}
/// What [`Hooks::player_login`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerLoginEvent<'a> {
@@ -10787,6 +10945,29 @@ pub fn dispatch_player_login(
}
}
}
/// What [`Hooks::player_save_to_file`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerSaveToFileEvent<'a> {
pub player: u128,
pub name: &'a str,
pub directory: &'a std::path::Path,
}
pub fn dispatch_player_save_to_file(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
directory: &std::path::Path,
) {
let mut event = PlayerSaveToFileEvent {
player,
name,
directory,
};
for mod_hook in mods {
mod_hook.player_save_to_file(host, &mut event);
}
}
/// What [`Hooks::player_sprint`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerSprintEvent<'a> {
@@ -11099,6 +11280,35 @@ pub fn dispatch_player_advancement_done(
mod_hook.player_advancement_done(host, &mut event);
}
}
/// What [`Hooks::player_advancement_progress`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerAdvancementProgressEvent<'a> {
pub player: u128,
pub name: &'a str,
pub advancement: &'a str,
pub criterion: &'a str,
pub granted: bool,
}
pub fn dispatch_player_advancement_progress(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
advancement: &str,
criterion: &str,
granted: bool,
) {
let mut event = PlayerAdvancementProgressEvent {
player,
name,
advancement,
criterion,
granted,
};
for mod_hook in mods {
mod_hook.player_advancement_progress(host, &mut event);
}
}
/// What [`Hooks::player_attack_cooldown_reset`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerAttackCooldownResetEvent<'a> {
@@ -11825,6 +12035,42 @@ pub fn dispatch_player_spawn_point_set(
}
}
}
/// What [`Hooks::player_stat_award`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerStatAwardEvent<'a> {
pub player: u128,
pub name: &'a str,
pub stat_type: &'a str,
pub stat: &'a str,
pub value: i32,
}
pub fn dispatch_player_stat_award(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
stat_type: &str,
stat: &str,
value: i32,
cancel: &mut bool,
) {
if *cancel {
return;
}
let mut event = PlayerStatAwardEvent {
player,
name,
stat_type,
stat,
value,
};
for mod_hook in mods {
if matches!(mod_hook.player_stat_award(host, &mut event), Flow::Cancel) {
*cancel = true;
break;
}
}
}
/// What [`Hooks::player_stonecutter_recipe_select`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerStonecutterRecipeSelectEvent<'a> {
File diff suppressed because it is too large. Load diff
+16 -14
View File
@@ -111,11 +111,21 @@ pub enum ProjectileKind {
/// flies as (`ThrowableItemProjectile.getItem`). What it splashes is that
/// stack's `potion_contents`, so a witch's potion and a summoned one are
/// the same thing; its entity type is the stack's item.
Potion(Box<crate::items::ItemStack>),
///
/// The `bool` is whether its entity type is `ThrownLingeringPotion`: the
/// type is the entity's own and `/summon` can pair either with any `Item`,
/// so it is kept next to the stack (a thrown potion's follows its item).
Potion(Box<crate::items::ItemStack>, bool),
/// a content mod's type.
Thrown(&'static ThrownType),
}
impl ProjectileKind {
/// A thrown potion flying as `stack`: a lingering item makes the lingering
/// entity type, any other the splash one.
pub fn potion(stack: crate::items::ItemStack) -> Self {
let lingering = &*stack.item == "minecraft:lingering_potion";
Self::Potion(Box::new(stack), lingering)
}
/// Wind charges take no push from an explosion (vanilla leaves their motion at 0).
pub fn ignores_blast(&self) -> bool {
matches!(self, Self::WindCharge | Self::BreezeWindCharge)
@@ -137,7 +147,7 @@ impl ProjectileKind {
| Self::Egg
| Self::EnderPearl
| Self::LlamaSpit
| Self::Potion(_)
| Self::Potion(..)
| Self::Thrown(_) => 0.25,
Self::SmallFireball
| Self::WindCharge
@@ -163,20 +173,12 @@ impl ProjectileKind {
Self::ShulkerBullet { .. } => "minecraft:shulker_bullet",
Self::WitherSkull { .. } => "minecraft:wither_skull",
// `ThrownSplashPotion`/`ThrownLingeringPotion`: the item names it.
Self::Potion(stack) => potion_entity_type(&stack.item),
Self::Potion(_, true) => "minecraft:lingering_potion",
Self::Potion(_, false) => "minecraft:splash_potion",
Self::Thrown(t) => t.id,
}
}
}
/// The entity type a thrown potion's item names: `ThrownSplashPotion` for a
/// splash potion, `ThrownLingeringPotion` for a lingering one.
fn potion_entity_type(item: &str) -> &'static str {
if item == "minecraft:lingering_potion" {
"minecraft:lingering_potion"
} else {
"minecraft:splash_potion"
}
}
/// `ThrownItemRenderer`'s sprite for a thrown potion.
fn potion_sprite(item: &str) -> &'static str {
if item == "minecraft:lingering_potion" {
@@ -1001,7 +1003,7 @@ impl Projectile {
/// (`ThrownItemRenderer`): the potion stack itself, or the type's item.
pub fn drawn_item(&self) -> Option<crate::items::ItemStack> {
match &self.kind {
ProjectileKind::Potion(stack) => Some((**stack).clone()),
ProjectileKind::Potion(stack, _) => Some((**stack).clone()),
ProjectileKind::Thrown(t) => Some(crate::items::ItemStack::new(t.item, 1)),
_ => None,
}
@@ -1015,7 +1017,7 @@ impl Projectile {
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
"item/fire_charge"
}
ProjectileKind::Potion(stack) => potion_sprite(&stack.item),
ProjectileKind::Potion(stack, _) => potion_sprite(&stack.item),
ProjectileKind::LlamaSpit => "entity/llama/llama_spit",
// Drawn as `WindChargeModel` and `ShulkerBulletModel` by the game.
ProjectileKind::WindCharge
+7
View File
@@ -87,9 +87,13 @@ pub fn poll_game(shared: Arc<Shared>) {
continue;
};
// A new game numbers its events from 1 again.
let mut history = false;
if control.as_ref().is_none_or(|(p, _)| *p != pid) {
control = engine::Control::connect(&shared.dir).ok().map(|c| (pid, c));
since = 0;
// What the game recorded before this first fetch (its saves while it started) happened before
// the management protocol's clients could hear of it.
history = true;
}
let Some((_, game)) = control.as_mut() else {
continue;
@@ -105,6 +109,9 @@ pub fn poll_game(shared: Arc<Shared>) {
if let Some(fields) = event.as_object_mut() {
fields.remove("seq");
}
if !(history && matches!(kind.as_str(), "saving" | "saved")) {
crate::management::game_event(&shared, &kind, &event);
}
shared.emit(&kind, event);
}
}
+70 -2
View File
@@ -72,6 +72,21 @@ pub fn started(shared: &Shared) {
notify(shared, "notification/server/started", None);
}
/// The notification a typed event of the game is, if vanilla sends one for it: a player who joined or left
/// (`playerJoined`, `playerLeft`, with the player as a `PlayerDto`), and a world save that began or ended
/// (`serverSaveStarted`, `serverSaveCompleted`).
pub fn game_event(shared: &Shared, kind: &str, event: &Value) {
let player = || Some(json!({ "id": event["uuid"], "name": event["name"] }));
let (method, params) = match kind {
"join" => ("notification/players/joined", player()),
"leave" => ("notification/players/left", player()),
"saving" => ("notification/server/saving", None),
"saved" => ("notification/server/saved", None),
_ => return,
};
notify(shared, method, params);
}
/// `NotificationService.serverShuttingDown`, which `DedicatedServer.stopServer` sends first of all.
pub fn stopping(shared: &Shared) {
if !shared.management.stopping_told.swap(true, Ordering::SeqCst) {
@@ -765,12 +780,20 @@ mod tests {
stream
}
/// The text of the next frame the server sends (unmasked, short enough for a 7-bit length).
/// The text of the next frame the server sends (unmasked, shorter than 65536 bytes).
fn next_text(stream: &mut TcpStream) -> String {
let mut head = [0u8; 2];
stream.read_exact(&mut head).unwrap();
assert_eq!(head[0], 0x81);
let mut payload = vec![0u8; usize::from(head[1])];
let length = match head[1] {
126 => {
let mut b = [0u8; 2];
stream.read_exact(&mut b).unwrap();
usize::from(u16::from_be_bytes(b))
}
n => usize::from(n),
};
let mut payload = vec![0u8; length];
stream.read_exact(&mut payload).unwrap();
String::from_utf8(payload).unwrap()
}
@@ -829,6 +852,47 @@ mod tests {
assert_eq!(shared.management.sockets.lock().unwrap().len(), 1);
}
#[test]
fn the_games_events_become_the_notifications_vanilla_sends() {
let shared = shared();
let address = listen_on(shared.clone(), settings());
let mut client = upgrade(address);
registered(&shared, 1);
let id = "0123abcd-0000-4000-8000-00000000000a";
let joined = json!({ "type": "join", "name": "Kim0", "uuid": id });
game_event(&shared, "join", &joined);
game_event(
&shared,
"chat",
&json!({ "name": "Kim0", "uuid": id, "message": "hi" }),
);
game_event(&shared, "saving", &json!({ "type": "saving" }));
game_event(&shared, "saved", &json!({ "type": "saved" }));
game_event(&shared, "leave", &json!({ "name": "Kim0", "uuid": id }));
// The chat is not a notification; the rest come in order, the player as a `PlayerDto`.
let player = format!(r#"[{{"id":"{id}","name":"Kim0"}}]"#);
assert_eq!(
next_text(&mut client),
format!(
r#"{{"jsonrpc":"2.0","method":"minecraft:notification/players/joined","params":{player}}}"#
)
);
assert_eq!(
next_text(&mut client),
r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/saving"}"#
);
assert_eq!(
next_text(&mut client),
r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/saved"}"#
);
assert_eq!(
next_text(&mut client),
format!(
r#"{{"jsonrpc":"2.0","method":"minecraft:notification/players/left","params":{player}}}"#
)
);
}
#[test]
fn every_notification_sent_is_one_discover_lists() {
let listed = schema::discover();
@@ -841,6 +905,10 @@ mod tests {
for sent in [
"notification/server/started",
"notification/server/stopping",
"notification/server/saving",
"notification/server/saved",
"notification/players/joined",
"notification/players/left",
] {
assert!(
names.contains(&format!("minecraft:{sent}").as_str()),
@@ -32,6 +32,15 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
Ok(json!(true))
}
"server/system_message" => game.system_message(argument),
"serversettings/autosave" => game.autosave(client, None),
"serversettings/autosave/set" => game.autosave(
client,
Some(
argument
.as_bool()
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
),
),
"serversettings/motd" => game.motd(client, None),
"serversettings/motd/set" => game.motd(
client,
@@ -357,6 +366,21 @@ impl Game<'_> {
Ok(reply["value"].clone())
}
/// `MinecraftServerSettingsServiceImpl.isAutoSave`: read the game's live save flag.
/// `MinecraftServerSettingsServiceImpl.setAutoSave`: update that flag and return its new value.
fn autosave(&self, client: u32, value: Option<bool>) -> Result<Value, Failure> {
let mut request = json!({ "method": "serversettings/autosave" });
if let Some(value) = value {
request["method"] = json!("serversettings/autosave/set");
request["value"] = json!(value);
}
let reply = self.ask(&format!("management {client} {request}"))?;
if let Some(error) = reply["error"].as_str() {
return Err((INTERNAL_ERROR, Some(error.to_owned())));
}
Ok(reply["value"].clone())
}
/// `MinecraftServerSettingsServiceImpl.forceGameMode` and `setForceGameMode`.
fn force_game_mode(&self, client: u32, value: Option<bool>) -> Result<Value, Failure> {
let mut request = json!({ "method": "serversettings/force_game_mode" });
@@ -12,7 +12,7 @@
use std::collections::BTreeSet;
use std::fmt::Write;
use super::scan::significant;
use super::scan::{QUALIFIERS, STORAGE, significant};
use super::{Error, GbuffersInputs, Program, Target};
use crate::Preprocessed;
use crate::macros::lex;
@@ -158,22 +158,9 @@ fn classify(tokens: &[&str], range: std::ops::Range<usize>) -> Statement {
}
k += 1;
}
let quals = [
"in",
"out",
"attribute",
"varying",
"flat",
"smooth",
"noperspective",
"centroid",
"invariant",
"highp",
"mediump",
"lowp",
"const",
];
while k < words.len() && quals.contains(&words[k]) {
// Every qualifier the translator knows (`sample`, `precise`...), in any order (GLSL
// 4.60, 4.12): one it missed would be taken for the type, and the type for a name.
while k < words.len() && (STORAGE.contains(&words[k]) || QUALIFIERS.contains(&words[k])) {
k += 1;
}
match words.get(k) {
@@ -246,8 +233,14 @@ fn combine(
message,
}]
};
let gs_tokens = lex(&geometry.code);
let vs_tokens = lex(&vertex.code);
// Interface blocks first become plain varyings (`interface_blocks`), the vertex stage's
// `out` block and the geometry stage's `in` array alike: what follows reads declarations
// of one type each. The geometry stage's input blocks read as `hz_in_<block>_<member>`,
// apart from its output blocks, which may have the same names.
let gs_code = super::interface_blocks::flatten_as(&geometry.code, "in_");
let vs_code = super::interface_blocks::flatten(&vertex.code);
let gs_tokens = lex(gs_code.as_deref().unwrap_or(&geometry.code));
let vs_tokens = lex(vs_code.as_deref().unwrap_or(&vertex.code));
let gs = statements(&gs_tokens);
let vs = statements(&vs_tokens);
@@ -429,8 +422,12 @@ fn combine(
}
main.push_str(" hz_vs_main();\n");
for (name, _) in &vs_outs {
if gs_ins.iter().any(|(n, _)| n == name) {
writeln!(main, " {name}[hz_i] = hz_vs_{name};").unwrap();
let block = name.strip_prefix("hz_").map(|m| format!("hz_in_{m}"));
if let Some((input, _)) = gs_ins
.iter()
.find(|(n, _)| n == name || Some(n) == block.as_ref())
{
writeln!(main, " {input}[hz_i] = hz_vs_{name};").unwrap();
}
}
main.push_str(" hz_gl_in[hz_i].p = hz_vs_Position;\n }\n hz_gs_main();\n");
@@ -583,17 +580,8 @@ mod tests {
use crate::pack::tests::zip;
use crate::{Macros, Pack, preprocess};
#[test]
fn a_geometry_stage_folds_into_the_vertex_stage() {
let vsh = "#version 430\nout vec3 vPos;\nout vec4 vColor;\nvoid main() {\n \
vPos = gl_Vertex.xyz;\n vColor = gl_Color * float(gl_InstanceID + 1);\n \
gl_Position = ftransform();\n}\n";
let gsh = "#version 430\nlayout(triangles) in;\nlayout(triangle_strip, max_vertices = 4) out;\n\
in vec3 vPos[];\nin vec4 vColor[];\nout vec4 gColor;\nvoid main() {\n \
for (int i = 0; i < 3; i++) {\n gColor = vColor[i];\n \
gl_Position = gl_in[i].gl_Position;\n EmitVertex();\n }\n \
gl_Position = vec4(vPos[0], 1.0);\n EmitVertex();\n EndPrimitive();\n}\n";
let fsh = "#version 430\nin vec4 gColor;\nvoid main() {\n gl_FragData[0] = gColor;\n}\n";
/// `vsh`, `gsh` and `fsh` (a pack's shadow program) folded and translated.
fn fold(vsh: &str, gsh: &str, fsh: &str) -> Result<(Program, u32), Vec<Error>> {
let pack = Pack::from_zip_bytes(
"P.zip",
zip(&[
@@ -617,19 +605,73 @@ mod tests {
.into(),
index: "uint(@)".into(),
};
let (program, triangles) = translate_geometry(
translate_geometry(
&pp("shadow.vsh"),
&pp("shadow.gsh"),
&pp("shadow.fsh"),
&inputs,
)
.unwrap_or_else(|e| panic!("{e:?}"));
}
#[test]
fn a_geometry_stage_folds_into_the_vertex_stage() {
let vsh = "#version 430\nout vec3 vPos;\nout vec4 vColor;\nvoid main() {\n \
vPos = gl_Vertex.xyz;\n vColor = gl_Color * float(gl_InstanceID + 1);\n \
gl_Position = ftransform();\n}\n";
let gsh = "#version 430\nlayout(triangles) in;\nlayout(triangle_strip, max_vertices = 4) out;\n\
in vec3 vPos[];\nin vec4 vColor[];\nout vec4 gColor;\nvoid main() {\n \
for (int i = 0; i < 3; i++) {\n gColor = vColor[i];\n \
gl_Position = gl_in[i].gl_Position;\n EmitVertex();\n }\n \
gl_Position = vec4(vPos[0], 1.0);\n EmitVertex();\n EndPrimitive();\n}\n";
let fsh = "#version 430\nin vec4 gColor;\nvoid main() {\n gl_FragData[0] = gColor;\n}\n";
let (program, triangles) = fold(vsh, gsh, fsh).unwrap_or_else(|e| panic!("{e:?}"));
// A strip of four corners is two triangles.
assert_eq!(triangles, 2);
assert_eq!(program.vertex.entry_points.len(), 1);
assert_eq!(program.fragment.entry_points.len(), 1);
}
/// Stages that pass their varyings in interface blocks (GLSL 4.60, 4.3.9), members with
/// their own qualifiers: the vertex stage's `out` block, the geometry stage's `in` block
/// array and `out` block, the fragment stage's `in` block. The geometry stage's two blocks
/// share a name, as an input and an output block may (4.3.9).
#[test]
fn interface_blocks_pass_between_the_stages() {
let vsh = "#version 430\nuniform float far;\n\
out VertexData {\n vec4 color;\n flat float id;\n vec2 uv;\n} vOut;\n\
void main() {\n vOut.color = gl_Color;\n vOut.id = far;\n \
vOut.uv = gl_Vertex.xy;\n gl_Position = ftransform();\n}\n";
let gsh = "#version 430\nlayout(triangles) in;\nlayout(triangle_strip, max_vertices = 3) out;\n\
in VertexData {\n vec4 color;\n flat float id;\n vec2 uv;\n} vIn[];\n\
out VertexData {\n vec4 color;\n flat float id;\n} gOut;\n\
void main() {\n for (int i = 0; i < 3; i++) {\n \
gOut.color = vIn[i].color * vIn[i].uv.x;\n gOut.id = vIn[0].id;\n \
gl_Position = gl_in[i].gl_Position;\n EmitVertex();\n }\n \
EndPrimitive();\n}\n";
let fsh = "#version 430\nuniform float far;\n\
in VertexData {\n vec4 color;\n flat float id;\n} gIn;\n\
void main() {\n gl_FragData[0] = gIn.color * gIn.id / far;\n}\n";
let (program, triangles) = fold(vsh, gsh, fsh).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(triangles, 1);
assert_eq!(program.fragment.entry_points.len(), 1);
}
/// An output qualified `sample` declares no variable named after its type (GLSL 4.60, 4.3).
#[test]
fn sample_outputs_rename_no_type() {
let vsh = "#version 430\nuniform float far;\n\
out sample float vDepth;\nvoid main() {\n vDepth = far;\n \
gl_Position = ftransform();\n}\n";
let gsh = "#version 430\nlayout(triangles) in;\nlayout(triangle_strip, max_vertices = 3) out;\n\
in float vDepth[];\nout float gDepth;\nvoid main() {\n \
for (int i = 0; i < 3; i++) {\n gDepth = vDepth[i];\n \
gl_Position = gl_in[i].gl_Position;\n EmitVertex();\n }\n \
EndPrimitive();\n}\n";
let fsh = "#version 430\nuniform float far;\nin float gDepth;\n\
void main() {\n gl_FragData[0] = vec4(gDepth / far);\n}\n";
fold(vsh, gsh, fsh).unwrap_or_else(|e| panic!("{e:?}"));
}
#[test]
fn writes_run_once_and_keep_their_else() {
let code =
@@ -4,8 +4,10 @@
//! type, found Interpolation(Flat)"). Each member of such a block becomes a varying of its own,
//! `hz_<block>_<member>` (the name the next stage's block gives it too: stages match blocks by
//! block name and members by name), and `instance.member` reads it. A block without an instance
//! name keeps its members' names. Blocks with an instance array (a geometry stage's input) and
//! blocks whose instance is used whole are left as written. Lines stay where they were.
//! name keeps its members' names. Of a block with an instance array (a geometry stage's input,
//! `in VertexData { vec2 uv; } vIn[];`), each member becomes an array, `vIn[i].uv` reading
//! `hz_VertexData_uv[i]`. Blocks whose instance is used whole are left as written. Lines stay
//! where they were.
use super::scan::significant;
use crate::macros::lex;
@@ -36,10 +38,19 @@ struct Block {
name: String,
members: Vec<(usize, Vec<(usize, String)>)>,
instance: Option<(usize, String)>,
/// The instance's array suffix (`[]`, `[3]`), empty when it is not an array.
array: String,
}
/// `code` with its plain `in`/`out` interface blocks as varyings, or `None` when it has none.
pub(super) fn flatten(code: &str) -> Option<String> {
flatten_as(code, "")
}
/// `flatten`, the members of an `in` block with an instance named `hz_<inputs><block>_<member>`:
/// a geometry stage's input and output blocks may share their name (GLSL 4.60, 4.3.9), and the
/// geometry stage reads its inputs under names of its own.
pub(super) fn flatten_as(code: &str, inputs: &str) -> Option<String> {
let mut tokens: Vec<String> = lex(code).into_iter().map(str::to_owned).collect();
let blocks = find_blocks(&tokens);
if blocks.is_empty() {
@@ -48,17 +59,25 @@ pub(super) fn flatten(code: &str) -> Option<String> {
let mut changed = false;
for b in blocks {
let inside = |i: usize| (b.start..=b.end).contains(&i);
let prefix = if b.storage == "in" { inputs } else { "" };
let renamed = |member: &str| match &b.instance {
Some(_) => format!("hz_{}_{member}", b.name),
Some(_) => format!("hz_{prefix}{}_{member}", b.name),
None => member.to_owned(),
};
// Every use of the instance outside the block must be `instance.member`.
// Every use of the instance outside the block must be `instance.member`, or
// `instance[i].member` for an instance array.
let mut reads = Vec::new();
if let Some((_, instance)) = &b.instance {
let mut whole = false;
for i in (0..tokens.len()).filter(|&i| !inside(i) && tokens[i] == *instance) {
let next = next_significant(&tokens, i + 1);
let member = next
let index = match b.array.is_empty() {
true => Some(i),
false => next_significant(&tokens, i + 1)
.filter(|&o| tokens[o] == "[")
.and_then(|o| closing(&tokens, o)),
};
let member = index
.and_then(|x| next_significant(&tokens, x + 1))
.filter(|&d| tokens[d] == ".")
.and_then(|d| next_significant(&tokens, d + 1));
let known = member.filter(|&m| {
@@ -66,8 +85,8 @@ pub(super) fn flatten(code: &str) -> Option<String> {
.iter()
.any(|(_, names)| names.iter().any(|(_, n)| *n == tokens[m]))
});
match (next, known) {
(Some(_), Some(m)) => reads.push((i, m)),
match (index, known) {
(Some(x), Some(m)) => reads.push((i, x, m)),
_ => whole = true,
}
}
@@ -75,10 +94,11 @@ pub(super) fn flatten(code: &str) -> Option<String> {
continue;
}
}
for (i, member) in reads {
for (i, index, member) in reads {
tokens[i] = renamed(&tokens[member]);
// `.member` goes, with the spaces around it but not its line breaks.
for t in &mut tokens[i + 1..=member] {
// `.member` goes, with the spaces around it but not its line breaks; an index stays
// (`v[i].m` is `hz_B_m[i]`).
for t in &mut tokens[index + 1..=member] {
t.retain(|c| c == '\n');
}
}
@@ -91,7 +111,8 @@ pub(super) fn flatten(code: &str) -> Option<String> {
for (first, names) in &b.members {
tokens[*first] = format!("{} {}", b.storage, tokens[*first]);
for (at, name) in names {
tokens[*at] = renamed(name);
// Of an instance array, each member is an array of the instance's size, outermost.
tokens[*at] = renamed(name) + &b.array;
}
}
changed = true;
@@ -111,6 +132,24 @@ fn next_significant(tokens: &[String], from: usize) -> Option<usize> {
(from..tokens.len()).find(|&i| significant(&tokens[i]))
}
/// The `]` that closes the `[` at `open`.
fn closing(tokens: &[String], open: usize) -> Option<usize> {
let mut depth = 0usize;
for (j, t) in tokens.iter().enumerate().skip(open) {
match t.as_str() {
"[" => depth += 1,
"]" => {
depth -= 1;
if depth == 0 {
return Some(j);
}
}
_ => {}
}
}
None
}
/// The `in`/`out` interface blocks at global scope that can be flattened.
fn find_blocks(tokens: &[String]) -> Vec<Block> {
let mut out = Vec::new();
@@ -235,10 +274,17 @@ fn block(tokens: &[String], start: usize, open: usize) -> Option<Block> {
.filter(|&j| significant(&tokens[j]))
.collect();
let end = (close + 1..tokens.len()).find(|&j| tokens[j] == ";")?;
let instance = match after.as_slice() {
[] => None,
[n] if super::scan::is_ident(&tokens[*n]) => Some((*n, tokens[*n].clone())),
_ => return None, // an instance array, or something else
let (instance, array) = match after.as_slice() {
[] => (None, String::new()),
// `vIn[]`, `vIn[3]`: a geometry stage's input (GLSL 4.60, 4.3.9).
[n, rest @ ..]
if super::scan::is_ident(&tokens[*n])
&& (rest.is_empty() || tokens[rest[0]] == "[" && tokens[*rest.last()?] == "]") =>
{
let array = rest.iter().map(|&j| tokens[j].as_str()).collect();
(Some((*n, tokens[*n].clone())), array)
}
_ => return None,
};
if members.is_empty() {
return None;
@@ -252,6 +298,7 @@ fn block(tokens: &[String], start: usize, open: usize) -> Option<Block> {
name,
members,
instance,
array,
})
}
@@ -286,14 +333,26 @@ mod tests {
);
}
#[test]
fn an_instance_array_makes_each_member_an_array() {
let code = "in VertexData {\n flat int id;\n vec2 st[2];\n} vIn[];\n\
void main() { int i = vIn[0].id; vec2 a = vIn[i + 1] . st[1]; }\n";
let out = flatten(code).unwrap();
assert_eq!(out.lines().count(), code.lines().count());
assert_eq!(
out.split_whitespace().collect::<Vec<_>>().join(" "),
"in flat int hz_VertexData_id[]; in vec2 hz_VertexData_st[][2]; void main() { \
int i = hz_VertexData_id[0]; vec2 a = hz_VertexData_st[i + 1][1]; }"
);
}
#[test]
fn other_blocks_stay_as_written() {
for code in [
// Uniform and storage blocks, a geometry stage's instance array, an instance
// used whole.
// Uniform and storage blocks, an instance used whole.
"uniform Frame { float t; } frame;\nvoid main() {}\n",
"buffer Data { float v[]; } data;\nvoid main() {}\n",
"in VertexData { flat int id; } vIn[];\nvoid main() {}\n",
"in VertexData { flat int id; } vIn[];\nvoid f(VertexData d) {}\nvoid main() { f(vIn[0]); }\n",
"out VertexData { flat int id; } vOut;\nvoid f(int x) {}\nvoid main() { vOut = vOut; }\n",
] {
assert_eq!(flatten(code), None, "{code}");
+2 -1
View File
@@ -80,7 +80,8 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
- **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)).
- **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications other than
`server/started` and `server/stopping`, and the status heartbeat. The methods answer an internal error saying
`server/started`, `server/stopping`, `server/saving`, `server/saved`, `players/joined` and `players/left`,
and the status heartbeat. The methods answer an internal error saying
so; a kick's translatable message is sent as its key.
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
+51 -3
View File
@@ -537,8 +537,10 @@ API 47 adds `native_entity_join_level(dimension, entity, kind, pos, loaded_from_
server level (NeoForge's `EntityJoinLevelEvent`, vanilla's `PersistentEntitySectionManager.addEntity`):
`loaded_from_disk` is true when it comes back with its chunk, false for a fresh add (a drop, an orb, `/summon`, an
arrival through a portal into another level). The entity is already in the level's lists when the hook runs; `canceled` takes it back
out, and a `/summon` of it fails with vanilla's "duplicate UUIDs" message. It tells item entities and experience
orbs so far; later APIs add other kinds, so check `kind`. Bundles that use it need `"hooks": 47`.
out, and a `/summon` of it fails with vanilla's "duplicate UUIDs" message. It tells every kind in the game's entity
lists (items, orbs, mobs, boats, minecarts, armor stands, primed TNT, falling blocks, projectiles, rockets, tridents),
once each, in creation order, so check `kind`. A drop, an orb, a mob, a `/summon` and a chunk's entities are told at
once; other gameplay spawns when the next tick starts, and a cancelled one is removed then. Bundles that use it need `"hooks": 47`.
API 48 adds [a block's items](#a-blocks-items) for server mods, what a pipe or a machine needs to pull from other blocks: `native_container_slots`, `native_extract_item`
and `native_move_items`, and `native_dimension` for a machine's tick to name its level. `native_insert_item` now goes one item at a time, so it no longer overfills a slot,
@@ -1438,6 +1440,23 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
the border. Canceling ends the effect: no
teleport and no more attempts. Foxes eating chorus fruit are not heard. Bundles need
`"hooks": @API:chorus_fruit_teleport@`.
- **`native_player_clone(player, name, was_death)`**: a player's data was copied to their new player (NeoForge's
`PlayerEvent.Clone`, from `ServerPlayer.restoreFrom`): after a death respawn (`was_death` true) and when a player
leaves the End after the credits (`was_death` false, vanilla's `keepAllPlayerData`). It is heard after the copy and
before the new player is placed, once each, on a server. The uuid stays the same, so `player` names the old and the
new player alike; no player's state is swapped in, so the helpers that look a player up may find nothing. It cannot
stop anything.
- **`native_player_load_from_file(player, name, directory)`**: a joining player's saved state was read and applied
(NeoForge's `PlayerEvent.LoadFromFile`, `PlayerList.loadPlayerData`), before they are placed in a level: once for each
join on a server, for an integrated server's owner (loaded from the file `level.dat` names, else their own) as for
anyone else. `directory` is the world's `players/data` folder, created if missing; read your own file there, named `<uuid>.<suffix>`. It
is heard whether or not the player was in the world before (a new player has no file). No player's state is swapped
in. It cannot change or stop anything.
- **`native_player_save_to_file(player, name, directory)`**: a placed player's data was saved (NeoForge's
`PlayerEvent.SaveToFile`, `PlayerList.save`): when they leave or the world closes, when the dedicated server saves
everyone, and for an integrated server's owner when the world saves their state into `level.dat`. `directory` is the
world's `players/data` folder, created if missing; write your own file there, named `<uuid>.<suffix>`, and never the player's `.dat`.
The player may be leaving, so no player's state is swapped in. It cannot change or stop anything.
- **`native_player_commands_sent(player, name, names)`**: the command tree a player is sent, when they join and when
their level changes (`/op`, `/deop`, the LAN settings). `names` are the root commands their client would get, vanilla's
they may use and the mods'; remove a name and that client's tree lacks it, so its chat box neither completes nor
@@ -1468,6 +1487,10 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
frame, a painting or a leash knot (tying a lead to a fence that has none; `pos` is the fence), once it fits and
before it is placed or the item used. Set `cancel` and nothing happens; the item stays in the hand. The player's
state is swapped in, so `native_tell` answers them. `/summon` and a lead tied to a mob are not heard.
- **`native_hanging_break(player, kind, pos, cancel)`**: an item frame, a glow item frame or a painting (`kind`)
hanging in `pos` is about to break: a server player's hit (`player`, whose state is swapped in), a blast, or the
wall it hangs on gone (`player` None for both), before anything drops or sounds. Set `cancel` and it stays as it
was. A hit that only takes a frame's item out, the game's own player and `/kill` are not asked.
- **`native_bucket_empty(player, name, pos, fluid, cancel)`**: a server player empties a bucket (`fluid` is
`minecraft:water`, `minecraft:lava` or `minecraft:powder_snow`; fish and axolotl buckets pour water) into `pos`,
right before the fluid is placed. Set `cancel` and nothing is poured. The player's state is swapped in, so
@@ -1483,6 +1506,8 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
- **`native_player_edit_book(player, name, pages, signing, cancel)`**: A server player book-and-quill edit or signing packet is about to be applied. `pages` is its page count and `signing` says it has a title. Set `cancel` to drop the packet.
- **`native_player_shear_entity(player, name, entity_id, off_hand, cancel)`**: A server player is about to shear a sheep, mooshroom, snow golem or bogged. The hook gives the mob entity id and whether the shears are in the off hand. Set `cancel` to leave the mob and shears unchanged.
- **`native_player_advancement_done(player, name, advancement)`**: A server player completed an advancement after its progress was recorded. `advancement` is its identifier, such as `minecraft:story/mine_stone`; helpers looking up the guest through `self.lan` may find nothing during packet handling.
- **`native_player_advancement_progress(player, name, advancement, criterion, granted)`**: A criterion of a server player's advancement was granted or revoked, after its progress changed and before any completion rewards, except the `minecraft:tick` criteria granted on a network player's first tick or a reload, which are heard after that tick's rewards and packets. `advancement` is its identifier, such as `minecraft:story/mine_stone`, and `granted` is false for a revoke. A grant or revoke that changes nothing is not heard; helpers looking up the guest through `self.lan` may find nothing during packet handling.
- **`native_player_stat_award(player, name, stat_type, stat, value, cancel)`**: A statistic is about to be awarded to a server player, before its count and scoreboard criteria change. `stat_type` and `stat` are ids such as `minecraft:mined` and `minecraft:stone`, or `minecraft:custom` and `minecraft:walk_one_cm`, and `value` is the amount. Set `cancel` to award nothing. Helpers looking up the guest through `self.lan` may find nothing during packet handling.
- **`native_player_riptide(player, name)`**: a server player launched with a riptide trident, after its velocity and spin are applied. The player's state is swapped in, though helpers that look them up through `self.lan` may find nothing during a guest packet.
- **`native_player_fish(player, name, state, cancel)`**: a player's fishing rod, on a server: `state` 0 the cast, 1 a fish
biting, 2 the reel-in of a bite (the catch), 3 the reel-in with nothing on the line, 4 the reel-in of a bobber hooked
@@ -2195,7 +2220,7 @@ anything else a mod may change, a game struct or a list, is a `&mut` field. A ca
the return value; a collection hook's list is a field (`event.lines`). One event goes through every mod, so each
sees the changes before it. Because no mod can build or match the whole
struct, a later version can add a field without breaking any mod, as NeoForge's event objects do; the cost is that
a mod's own tests can't build an event yet.
a mod's own tests drive a hook through its dispatch function ([Testing a trait mod](#testing-a-trait-mod)).
Hook methods take `&self` and require `Send + Sync`. The process-wide mod list is installed once, like mixin module statics. One mod instance serves both
the window's game and the server-thread game. A hook can also fire again inside
@@ -2219,6 +2244,29 @@ renderer list is installed once alongside the mod list. `render_needs` collects
run on the render thread while the frame is built, before any deferred GPU submission.
The renderer module re-exports the game's wgpu types.
### Testing a trait mod
Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements
only what its hook calls, and an unexpected call fails loudly. Drive the hook through
`horizon_client::mod_api::dispatch_<hook>(&mut host, &mods, args…)`, which builds the event as the game does.
If a `Host` method returns a game value a test can't build, a double can return `None` instead, as with
`acting_player() -> Option<NativeServerPlayer>`. If the dispatch function itself takes an unconstructible value,
such as `dispatch_perform_command`'s `&NativeServerPlayer`, test the logic beneath the hook instead. The level
hosts are small enough to implement whole. NeoForge's GameTest is the in-game counterpart.
```rust
struct Stone;
impl Host for Stone {
fn block(&self, _pos: [i32; 3]) -> String {
"minecraft:stone".to_string()
}
}
let mods = [Box::new(MyMod) as Box<dyn Hooks>];
let mut claimed = false;
dispatch_use_item_on(&mut Stone, &mods, &stack, [0, 64, 0], [0, 1, 0], &mut claimed);
assert!(claimed);
```
### Adding a hook
A new hook needs its fragment in `tools/native-hook-fragments/` with its `mod_api` entry (for example
+4 -1
View File
@@ -509,7 +509,10 @@ runtime or new dependency. Server-sent events can replace the polling later if a
- notifications: the supervisor pushes them to every connected client as vanilla's `JsonRpcNotificationService`
does (a JSON-RPC request without an `id`, its one parameter in an array), through a queue and a thread so a slow
client never holds the game's output. Built: `server/started` (the game's `Done`) and `server/stopping`
(when the supervisor or the game's own `stop` begins the shutdown, once per run);
(when the supervisor or the game's own `stop` begins the shutdown, once per run), `server/saving` and
`server/saved` (around every world save of the running game: `save-all`, the autosave, the pause when
empty; a save the game makes while it stops is after the last one the supervisor hears of), and
`players/joined` and `players/left` (from the game's typed events, within a quarter of a second);
- planned: `serversettings/*` and `gamerules`, the other notifications and the heartbeat.
## 9. Benchmark plan (bots against vanilla and Paper)
+2 -2
View File
@@ -1758,7 +1758,7 @@
{
"name": "menu_slot_rules",
"version": 1,
"target": "horizon_client::menu::native_menu_slot_rules",
"target": "horizon_client_lib::menu::native_menu_slot_rules",
"side": "server",
"cancellable": false,
"mod_api": {
@@ -1993,7 +1993,7 @@
{
"name": "living_breathe",
"version": 1,
"target": "horizon_client::breathing::native_living_breathe",
"target": "horizon_client_lib::breathing::native_living_breathe",
"neoforge": "LivingBreatheEvent",
"side": "server",
"cancellable": false,
@@ -0,0 +1,16 @@
{
"hooks": [
{
"name": "hanging_break",
"target": "horizon_client::game::native_hooks::Game<'_>::native_hanging_break",
"side": "server",
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "an item frame or painting about to break, cancellable (`native_hanging_break`).",
"doc_summary": "an item frame or painting about to break: `native_hanging_break(player, kind, pos, cancel)` is heard before a server player's hit, a blast or the loss of its wall breaks an item frame, a glow item frame or a painting, with the hitting player (None for a blast or a lost wall). A mod sets `cancel` and it stays. The hitting player's state is swapped in. A hit that only takes a frame's item out, the game's own player and `/kill` are not asked. Bundles that use it need `\"hooks\": @API:native_hanging_break@`."
}
@@ -0,0 +1,16 @@
{
"hooks": [
{
"name": "player_clone",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_clone",
"neoforge": "PlayerEvent.Clone (not cancellable)",
"side": "server",
"cancellable": false,
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "a player's data copied to their new player (`native_player_clone`).",
"doc_summary": "a player's data copied to their new player: `native_player_clone(player, name, was_death)` is heard from `ServerPlayer.restoreFrom` once for each death respawn (`was_death` true) and for each exit from the End after the credits (`was_death` false), after the retained data is copied and before the new player is placed (NeoForge's `PlayerEvent.Clone`). The uuid stays the same, so `player` names both the old and the new player. It cannot change or stop anything; no player's state is swapped in. Bundles that use it need `\"hooks\": @API:native_player_clone@`."
}
@@ -0,0 +1,16 @@
{
"hooks": [
{
"name": "player_load_from_file",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_load_from_file",
"neoforge": "PlayerEvent.LoadFromFile (not cancellable)",
"side": "server",
"cancellable": false,
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "a player loaded from the world's save (`native_player_load_from_file`).",
"doc_summary": "a player loaded from the world's save: `native_player_load_from_file(player, name, directory)` is heard once for each join (the owner of an integrated server too), after the player's `.dat` is read and applied and before they are placed in a level (NeoForge's `PlayerEvent.LoadFromFile`, `PlayerList.loadPlayerData`). `directory` is the world's player directory (`players/data`); a mod reads its own file there, named `<uuid>.<suffix>`. It cannot change or stop anything; no player's state is swapped in. Bundles that use it need `\"hooks\": @API:native_player_load_from_file@`."
}
@@ -0,0 +1,16 @@
{
"hooks": [
{
"name": "player_save_to_file",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_save_to_file",
"neoforge": "PlayerEvent.SaveToFile (not cancellable)",
"side": "server",
"cancellable": false,
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "a player saved to the world's store (`native_player_save_to_file`).",
"doc_summary": "a player saved to the world's store: `native_player_save_to_file(player, name, directory)` is heard once for each save of a placed player (when they leave or the world closes, when the dedicated server saves everyone, and for an integrated server's owner when the world saves them; LAN guests are not heard on integrated autosaves) after their data is written (NeoForge's `PlayerEvent.SaveToFile`, `PlayerList.save`). `directory` is the world's player directory (`players/data`); write your own file there, named `<uuid>.<suffix>`, never the player's `.dat`. The player may be leaving, so no player's state is swapped in. It cannot change or stop anything. Bundles that use it need `\"hooks\": @API:native_player_save_to_file@`."
}
@@ -0,0 +1,16 @@
{
"hooks": [
{
"name": "player_advancement_progress",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_advancement_progress",
"side": "server",
"neoforge": "AdvancementEvent.AdvancementProgressEvent",
"cancellable": false,
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A criterion of a server player's advancement was granted or revoked (`native_player_advancement_progress`).",
"doc_summary": "A criterion of a server player's advancement was granted or revoked after its progress changed and before any completion rewards, except the `minecraft:tick` criteria granted on a network player's first tick or a reload, which are heard after that tick's rewards and packets; `advancement` is its identifier, such as `minecraft:story/mine_stone`, `criterion` the criterion name and `granted` is false for a revoke. A grant or revoke that changes nothing is not heard. Helpers looking up the guest through `self.lan` may find nothing during packet handling. Bundles need \"hooks\": @API:player_advancement_progress@."
}
@@ -0,0 +1,17 @@
{
"hooks": [
{
"name": "player_stat_award",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_stat_award",
"side": "server",
"neoforge": "StatAwardEvent",
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "A statistic about to be awarded to a server player, cancellable (`native_player_stat_award`).",
"doc_summary": "A statistic about to be awarded to a server player: `native_player_stat_award(player, name, stat_type, stat, value, cancel)`, with `stat_type` such as `minecraft:mined` or `minecraft:custom`, `stat` such as `minecraft:stone` or `minecraft:walk_one_cm`, and `value` the amount added. Set `cancel` to award nothing: the count and its scoreboard criteria stay as they were. Helpers looking up the guest through `self.lan` may find nothing during packet handling. Bundles need \"hooks\": @API:player_stat_award@."
}
+2 -2
View File
@@ -17,7 +17,7 @@ AbstractHorse.fedFood port crates/horizon-client/src/game/mounts.rs::Game::feed_
AbstractHorse.doPlayerRide inline crates/horizon-client/src/game/mounts.rs::Game::interact_mount clears eating and standing, turns the player to the mount and mounts; the block at the end of interact_mount
AbstractHorse.isImmobile port crates/horizon-client/src/mobs/equine.rs::immobile
AbstractHorse.dropEquipment inline crates/horizon-client/src/mobs/drops.rs::die the equine branch drops the inventory items and a donkey's or mule's chest
AbstractHorse.followMommy missing minor: no Bred flag, so a bred foal is not pulled to its mother from 16 blocks; the FollowParent goal covers 8
AbstractHorse.followMommy inline crates/horizon-client/src/mobs/equine.rs::ai_step follow_mommy: a bred foal that is not eating keeps a path to the nearest bred AbstractHorse within 16 blocks and more than 2 away (createPath(entity, 0), which leaves the speed modifier alone); Bred is NBT-only in 26.3, so only a save or a summon gives it
AbstractHorse.openMouth inline crates/horizon-client/src/game/mounts.rs::Game::feed_mount gap: minor: sets the mouth animation to 1 once, it closes after 3 ticks; the mouth flag is not synced to a remote client
AbstractHorse.setStanding inline crates/horizon-client/src/mobs/equine.rs::make_mad the 20 tick rear; RandomStand::start and the jump in tick_mount_riding set the same counter
AbstractHorse.standIfPossible inline crates/horizon-client/src/mobs/equine.rs::make_mad rears only species that can; the hurt caller is missing (see hurtServer)
@@ -129,7 +129,7 @@ AbstractSchoolingFish.addFollowers inline crates/horizon-client/src/mobs/aquatic
AbstractSchoolingFish.finalizeSpawn inline crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize the group shares a leader id and variant, and FollowFlockLeader (crates/horizon-client/src/mobs/aquatic.rs) paths a follower to the leader every 10 ticks
Pufferfish.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt `getIntOr("PuffState", 0)` at most 2; mob_to_nbt writes it, and a reload test covers it
Pufferfish.registerGoals port crates/horizon-client/src/mobs/aquatic.rs::register the puff goal at priority 1
Pufferfish.touch missing minor: only the player is stung (inside ai_step); other mobs touching a puffed fish are not hurt or poisoned
Pufferfish.touch inline crates/horizon-client/src/mobs/aquatic.rs::ai_step sting_touching_mobs: alive mobs outside #not_scary_for_pufferfish whose box meets the fish's inflated by 0.3, when teams allow damage, take a mobAttack hit for puff state + 1, then, once it lands, POISON for 60 * puff state and the sting sound (game/creatures.rs PufferfishSting)
Salmon.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta size entry, small 0 to large 2
Salmon.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::read_variant the type tag, medium by default
Salmon.get inline crates/horizon-client/src/entity_nbt.rs::bucket_components the size component goes onto the bucket
Can't render this file because it contains an unexpected character in line 130 and column 104.
+2 -2
View File
@@ -110,8 +110,8 @@ MinecraftServer.createProfiler skip:server profiler setup
MinecraftServer.startRecordingMetrics skip:server metrics recording for the profiler
MinecraftServer.createGameModeForPlayer missing minor: demo mode is not implemented; --demo is accepted and ignored (dedicated.rs preflight)
MinecraftServer.stopTimeProfiler skip:debug debug profiler stop
MinecraftServer.setAutoSave inline crates/horizon-client/src/game/dedicated.rs::Game::console_command gap: minor: the save-on/save-off flag is kept, but the tick's autosave does not read it
MinecraftServer.isAutoSave inline crates/horizon-client/src/game/dedicated.rs::Game::console_command reads the same flag for the save-on reply only
MinecraftServer.setAutoSave port crates/horizon-client/src/game/dedicated.rs::set_auto_save changes the shared server-level save flag; the tick skips periodic saves while it is disabled
MinecraftServer.isAutoSave port crates/horizon-client/src/game/dedicated.rs::auto_save reads the shared server-level save flag
MinecraftServer.storeChunkIoError skip:debug writes a chunk IO crash report
MinecraftServer.reportChunkLoadFailure missing unsure: no chunk-load failure log or report found in save.rs or streaming.rs
MinecraftServer.reportChunkSaveFailure port crates/horizon-client/src/save.rs::Persistent::save_finished gap: minor: logs only; no debug report and no low-disk warning
1 DelegatingOps.DelegateListBuilder.build skip:java forwards to the original ListBuilder; codec plumbing
110 MinecraftServer.startRecordingMetrics skip:server metrics recording for the profiler
111 MinecraftServer.createGameModeForPlayer missing minor: demo mode is not implemented; --demo is accepted and ignored (dedicated.rs preflight)
112 MinecraftServer.stopTimeProfiler skip:debug debug profiler stop
113 MinecraftServer.setAutoSave inline port crates/horizon-client/src/game/dedicated.rs::Game::console_command crates/horizon-client/src/game/dedicated.rs::set_auto_save gap: minor: the save-on/save-off flag is kept, but the tick's autosave does not read it changes the shared server-level save flag; the tick skips periodic saves while it is disabled
114 MinecraftServer.isAutoSave inline port crates/horizon-client/src/game/dedicated.rs::Game::console_command crates/horizon-client/src/game/dedicated.rs::auto_save reads the same flag for the save-on reply only reads the shared server-level save flag
115 MinecraftServer.storeChunkIoError skip:debug writes a chunk IO crash report
116 MinecraftServer.reportChunkLoadFailure missing unsure: no chunk-load failure log or report found in save.rs or streaming.rs
117 MinecraftServer.reportChunkSaveFailure port crates/horizon-client/src/save.rs::Persistent::save_finished gap: minor: logs only; no debug report and no low-disk warning
+1
View File
@@ -27,6 +27,7 @@ DedicatedServer.setForceGameMode port crates/horizon-client/src/game/dedicated/m
MinecraftServerSettingsServiceImpl.setForceGameMode port crates/horizon-server/src/management_methods.rs::Game::force_game_mode forwards the live setting to the game and persists it in server.toml
MinecraftServerSettingsServiceImpl.setUsingWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.whitelist in server.toml
MinecraftServerSettingsServiceImpl.setEnforceWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.enforce_whitelist in server.toml
MinecraftServerSettingsServiceImpl.setAutoSave port crates/horizon-server/src/management_methods.rs::Game::autosave forwards the live save flag to the game; unlike a server property, it resets when the server restarts
DedicatedServer.setGameMode port crates/horizon-client/src/game/lan.rs::Game::set_default_game_mode the /defaultgamemode command; gap: minor: the value is not written back to server.properties
DedicatedServer.setAcceptsTransfers skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
DedicatedServer.setPauseWhenEmptySeconds skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
1 DedicatedServer.readCodeOfConducts port crates/horizon-client/src/lan/code_of_conduct.rs::read with enable-code-of-conduct the folder's files are read at startup (game/dedicated.rs::run_dedicated_inner) and handed to a joining player, whose client shows the prompt (game/code_of_conduct.rs)
27 MinecraftServerSettingsServiceImpl.setForceGameMode port crates/horizon-server/src/management_methods.rs::Game::force_game_mode forwards the live setting to the game and persists it in server.toml
28 MinecraftServerSettingsServiceImpl.setUsingWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.whitelist in server.toml
29 MinecraftServerSettingsServiceImpl.setEnforceWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.enforce_whitelist in server.toml
30 MinecraftServerSettingsServiceImpl.setAutoSave port crates/horizon-server/src/management_methods.rs::Game::autosave forwards the live save flag to the game; unlike a server property, it resets when the server restarts
31 DedicatedServer.setGameMode port crates/horizon-client/src/game/lan.rs::Game::set_default_game_mode the /defaultgamemode command; gap: minor: the value is not written back to server.properties
32 DedicatedServer.setAcceptsTransfers skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
33 DedicatedServer.setPauseWhenEmptySeconds skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
+43 -24
View File
@@ -16,8 +16,9 @@ sent the same things over TLS, and every answer is compared exactly:
vanilla's data generator), `server/status`, the four lists, `players`, kicking nobody, `server/save` and
`server/system_message` with no one online, the four lists changed (`add`, `remove`, `set`,
`clear`; with no profile service, players named resolve to offline players on both), the live server
message, difficulty, default and forced game mode read and changed (including saved properties), and
allowlist use and enforcement read and changed (including saved properties and restart), and
message, difficulty, default and forced game mode read and changed (including saved properties), autosave
read and changed (including its reset after restart), allowlist use and enforcement read and changed
(including saved properties and restart), and
`server/stop`;
- the TLS version each server agrees on.
@@ -149,24 +150,33 @@ MESSAGES = [
'{"jsonrpc":"2.0","id":61,"method":"minecraft:serversettings/force_game_mode"}',
'{"jsonrpc":"2.0","id":62,"method":"minecraft:serversettings/force_game_mode/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":63,"method":"minecraft:serversettings/force_game_mode"}',
'{"jsonrpc":"2.0","id":65,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":66,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":67,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":68,"method":"minecraft:serversettings/use_allowlist/set","params":[false]}',
'{"jsonrpc":"2.0","id":69,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":70,"method":"minecraft:serversettings/use_allowlist/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":71,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":72,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":73,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":74,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":75,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":76,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":77,"method":"minecraft:serversettings/enforce_allowlist/set","params":[false]}',
'{"jsonrpc":"2.0","id":78,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":79,"method":"minecraft:serversettings/enforce_allowlist/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":80,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":81,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":82,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":65,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":66,"method":"minecraft:serversettings/autosave/set","params":[false]}',
'{"jsonrpc":"2.0","id":67,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":68,"method":"minecraft:serversettings/autosave/set","params":[true]}',
'{"jsonrpc":"2.0","id":69,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":70,"method":"minecraft:serversettings/autosave/set","params":[false]}',
'{"jsonrpc":"2.0","id":71,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":72,"method":"minecraft:serversettings/autosave/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":73,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":75,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":76,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":77,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":78,"method":"minecraft:serversettings/use_allowlist/set","params":[false]}',
'{"jsonrpc":"2.0","id":79,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":80,"method":"minecraft:serversettings/use_allowlist/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":81,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":82,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":83,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":84,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":85,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":86,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":87,"method":"minecraft:serversettings/enforce_allowlist/set","params":[false]}',
'{"jsonrpc":"2.0","id":88,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":89,"method":"minecraft:serversettings/enforce_allowlist/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":90,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":91,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":92,"method":"minecraft:serversettings/enforce_allowlist"}',
]
@@ -421,13 +431,19 @@ def main():
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_force_game_mode'] = reply
ws.send('{"jsonrpc":"2.0","id":83,"method":"minecraft:serversettings/use_allowlist"}')
ws.send('{"jsonrpc":"2.0","id":74,"method":"minecraft:serversettings/autosave"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_autosave'] = reply
ws.send('{"jsonrpc":"2.0","id":93,"method":"minecraft:serversettings/use_allowlist"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_use_allowlist'] = reply
ws.send('{"jsonrpc":"2.0","id":84,"method":"minecraft:serversettings/enforce_allowlist"}')
ws.send('{"jsonrpc":"2.0","id":94,"method":"minecraft:serversettings/enforce_allowlist"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
@@ -468,11 +484,14 @@ def main():
if vanilla['restarted_force_game_mode'] != horizon['restarted_force_game_mode'] or \
vanilla['restarted_force_game_mode'] != {'jsonrpc': '2.0', 'id': 64, 'result': True}:
failures.append(f"restarted forced game mode: vanilla {vanilla['restarted_force_game_mode']!r}, Horizon {horizon['restarted_force_game_mode']!r}")
if vanilla['restarted_autosave'] != horizon['restarted_autosave'] or \
vanilla['restarted_autosave'] != {'jsonrpc': '2.0', 'id': 74, 'result': True}:
failures.append(f"restarted autosave: vanilla {vanilla['restarted_autosave']!r}, Horizon {horizon['restarted_autosave']!r}")
if vanilla['restarted_use_allowlist'] != horizon['restarted_use_allowlist'] or \
vanilla['restarted_use_allowlist'] != {'jsonrpc': '2.0', 'id': 83, 'result': True}:
vanilla['restarted_use_allowlist'] != {'jsonrpc': '2.0', 'id': 93, 'result': True}:
failures.append(f"restarted allowlist use: vanilla {vanilla['restarted_use_allowlist']!r}, Horizon {horizon['restarted_use_allowlist']!r}")
if vanilla['restarted_enforce_allowlist'] != horizon['restarted_enforce_allowlist'] or \
vanilla['restarted_enforce_allowlist'] != {'jsonrpc': '2.0', 'id': 84, 'result': True}:
vanilla['restarted_enforce_allowlist'] != {'jsonrpc': '2.0', 'id': 94, 'result': True}:
failures.append(f"restarted allowlist enforcement: vanilla {vanilla['restarted_enforce_allowlist']!r}, Horizon {horizon['restarted_enforce_allowlist']!r}")
(build / 'report.json').write_text(json.dumps(sides, indent=1))
if failures:
+9 -2
View File
@@ -280,8 +280,15 @@ fn host_method(
args.push(pat.ident.to_string());
}
let docs = doc_comments(&method.attrs, name, true)?;
let unused = args
.iter()
.map(|arg| format!("let _ = {arg};"))
.collect::<String>();
Ok((
format!("{docs}{};\n", sig.to_token_stream()),
format!(
"{docs}{} {{ {unused} unimplemented!(\"Host::{name}\") }}\n",
sig.to_token_stream()
),
format!(
"{} {{ {callee}{original}({}) }}\n",
sig.to_token_stream(),
@@ -964,7 +971,7 @@ fn generate(root: &Path, check: bool) -> Result<(), String> {
imports.push_str("use std::sync::Arc;\n");
}
let host = format!(
"// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse super::*;\n{imports}/// Services supplied by the game to trait mods.\npub trait Host {{\n{host_traits}}}\n"
"// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse super::*;\n{imports}/// Services supplied by the game to trait mods.\n/// Each method's default panics with its name, so a test double implements only what its test calls; the game implements every one.\npub trait Host {{\n{host_traits}}}\n"
);
let implementation = format!(
"// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse horizon_client::mod_api::*;\n{imports}impl Host for super::Game<'_> {{\n{host_impl}}}\n"