mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge upstream main into item expiry hooks
This commit is contained in:
commit
bcc3d47b55
89 files changed
+2881
-546
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
@@ -295,7 +296,7 @@ impl ItemEntity {
|
||||
|| horizontal > 1.0e-5
|
||||
|| self.ticks.wrapping_add(self.id).is_multiple_of(4)
|
||||
{
|
||||
let mut scene = SceneCollisions {
|
||||
let scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
@@ -311,17 +312,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)
|
||||
@@ -860,6 +858,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;
|
||||
@@ -924,8 +923,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,
|
||||
@@ -1962,6 +1963,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::*;
|
||||
@@ -2004,6 +2072,7 @@ mod honey_slide_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
ys.push(item.velocity.y);
|
||||
}
|
||||
@@ -2068,6 +2137,7 @@ mod honey_slide_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
@@ -2364,6 +2434,7 @@ mod slime_bounce_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(falling.position.y, 4.0);
|
||||
assert!(falling.velocity.y > 0.0);
|
||||
@@ -2382,6 +2453,7 @@ mod slime_bounce_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(!falling.needs_sync);
|
||||
}
|
||||
@@ -2474,6 +2546,7 @@ mod entity_collision_tests {
|
||||
false,
|
||||
&fluids,
|
||||
boxes,
|
||||
None,
|
||||
);
|
||||
}
|
||||
item.position.y
|
||||
@@ -2522,6 +2595,7 @@ mod entity_collision_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
item.position.x
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -2414,7 +2415,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(),
|
||||
}
|
||||
}
|
||||
@@ -2430,20 +2431,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
|
||||
@@ -2474,7 +2492,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));
|
||||
@@ -2488,7 +2506,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;
|
||||
@@ -2747,12 +2765,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"))?))
|
||||
}
|
||||
@@ -2865,10 +2896,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,
|
||||
@@ -4904,7 +4932,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();
|
||||
@@ -4926,7 +4957,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"));
|
||||
@@ -4950,6 +4981,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]);
|
||||
@@ -4983,11 +5075,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 {
|
||||
|
||||
@@ -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);
|
||||
@@ -4059,6 +4057,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
|
||||
@@ -4179,7 +4178,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);
|
||||
}
|
||||
@@ -4198,8 +4197,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);
|
||||
}
|
||||
@@ -4430,7 +4428,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()
|
||||
}
|
||||
@@ -5215,7 +5213,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);
|
||||
@@ -5430,7 +5428,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);
|
||||
@@ -5469,7 +5467,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) {
|
||||
@@ -5546,7 +5544,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 {
|
||||
@@ -5565,7 +5563,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,
|
||||
@@ -5750,7 +5754,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);
|
||||
@@ -5859,7 +5863,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);
|
||||
}
|
||||
@@ -6238,7 +6242,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.
|
||||
@@ -6246,8 +6250,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())
|
||||
@@ -6266,8 +6270,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),
|
||||
_ => {}
|
||||
}
|
||||
@@ -6507,7 +6511,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;
|
||||
@@ -6554,7 +6558,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)?;
|
||||
@@ -6648,7 +6652,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;
|
||||
@@ -6882,7 +6886,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
|
||||
@@ -7285,6 +7289,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();
|
||||
@@ -10305,7 +10311,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;
|
||||
@@ -11373,8 +11379,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,
|
||||
@@ -12088,7 +12093,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()?;
|
||||
@@ -12490,7 +12495,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)
|
||||
@@ -12625,7 +12630,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)
|
||||
@@ -12722,7 +12727,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,
|
||||
@@ -12813,7 +12818,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 {
|
||||
@@ -12933,7 +12938,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();
|
||||
}
|
||||
@@ -13134,9 +13139,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]);
|
||||
@@ -13255,6 +13258,7 @@ impl Game<'_> {
|
||||
target,
|
||||
self.remote.is_none(),
|
||||
&mut next_float,
|
||||
Some(self.border.geometry()),
|
||||
)
|
||||
});
|
||||
self.orbs = orbs;
|
||||
@@ -14136,7 +14140,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");
|
||||
}
|
||||
@@ -14818,10 +14822,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<()> {
|
||||
@@ -14837,12 +14850,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
|
||||
@@ -14854,9 +14872,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);
|
||||
}
|
||||
@@ -18986,6 +19006,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}")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", &[]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
@@ -7540,6 +7660,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]);
|
||||
@@ -7620,34 +7742,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]));
|
||||
@@ -9083,9 +9177,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 {
|
||||
@@ -9094,6 +9191,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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
@@ -3221,6 +3227,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
|
||||
@@ -3284,6 +3307,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
|
||||
@@ -3414,6 +3448,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
|
||||
@@ -3434,6 +3480,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`
|
||||
@@ -3607,6 +3665,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
|
||||
@@ -3898,6 +3971,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.
|
||||
@@ -10443,6 +10529,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> {
|
||||
@@ -10536,6 +10648,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> {
|
||||
@@ -10790,6 +10925,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> {
|
||||
@@ -10820,6 +10978,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> {
|
||||
@@ -11132,6 +11313,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> {
|
||||
@@ -11858,6 +12068,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> {
|
||||
|
||||
+572
-111
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -299,6 +308,21 @@ struct Game<'a> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `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
@@ -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
@@ -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
@@ -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
@@ -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@."
|
||||
}
|
||||
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -25,6 +25,7 @@ DedicatedServer.setEntityBroadcastRangePercentage skip:server JSON-RPC manageme
|
||||
DedicatedServer.createTextFilterForPlayer skip:service external chat filter service; Horizon has no text filter
|
||||
DedicatedServer.setForceGameMode port crates/horizon-client/src/game/dedicated/management.rs::Game::management stores force-gamemode, updates the host and applies the default mode to online players
|
||||
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.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
|
||||
|
||||
|
@@ -16,7 +16,8 @@ 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 game mode and forced game mode read and changed (including saved properties), and
|
||||
message, difficulty, default and forced game mode read and changed (including saved properties), autosave
|
||||
read and changed (including its reset after restart), and
|
||||
`server/stop`;
|
||||
- the TLS version each server agrees on.
|
||||
|
||||
@@ -148,6 +149,15 @@ 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/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"}',
|
||||
]
|
||||
|
||||
|
||||
@@ -392,6 +402,12 @@ 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":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
|
||||
finally:
|
||||
ws.sock.close()
|
||||
finally:
|
||||
@@ -419,6 +435,9 @@ 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}")
|
||||
(build / 'report.json').write_text(json.dumps(sides, indent=1))
|
||||
if failures:
|
||||
sys.exit(f'{NAME}: {len(failures)} differences (logs in {build}):\n' + '\n'.join(failures))
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user