mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e322f591a | ||
|
|
95ace2feff | ||
|
|
409ac72702 | ||
|
|
0548953031 | ||
|
|
0235d33aef | ||
|
|
78616f1f94 | ||
|
|
9ec105172f | ||
|
|
a3c239b849 | ||
|
|
04cd6ece23 | ||
|
|
9218dc3e7d | ||
|
|
5b66bd0ec0 | ||
|
|
f75896ec45 | ||
|
|
a80973335c | ||
|
|
c91ca07115 | ||
|
|
955e7fe48d | ||
|
|
ee90b3b34b | ||
|
|
8740d3897c | ||
|
|
a72f06cfab | ||
|
|
fab9c37413 | ||
|
|
57a6f4e9aa | ||
|
|
99207f16b1 | ||
|
|
4a094bee90 | ||
|
|
66e1fc5079 | ||
|
|
c404ef5e2a | ||
|
|
380d71d8a8 | ||
|
|
0543cf0345 | ||
|
|
e1d825312f | ||
|
|
5ea61ed3d8 | ||
|
|
1232745064 | ||
|
|
3382b666ce | ||
|
|
92c073de61 | ||
|
|
139de824aa | ||
|
|
ed01a93df6 | ||
|
|
1a56e74324 | ||
|
|
25b84dfc2e | ||
|
|
a2098c2ffa | ||
|
|
d98dc8edc9 | ||
|
|
bf05176692 |
No files matched your search
@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
_ => string("minecraft:air"),
|
||||
},
|
||||
);
|
||||
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
|
||||
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
|
||||
f.put(
|
||||
"facing",
|
||||
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
|
||||
);
|
||||
f.float("progress", 0.0);
|
||||
f.flag("extending", false);
|
||||
f.flag("source", false);
|
||||
@@ -873,6 +877,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
|
||||
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
|
||||
#[test]
|
||||
fn moving_piston_fields_match_vanilla() {
|
||||
let out = canon(
|
||||
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
|
||||
facing:5,progress:0.5f,extending:1b,source:0b}",
|
||||
);
|
||||
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
|
||||
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
|
||||
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
|
||||
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
|
||||
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
|
||||
let out = canon(
|
||||
|
||||
@@ -54,6 +54,9 @@ pub struct Registries {
|
||||
pub function_tags: Vec<String>,
|
||||
/// The block the source is looking at (relevant coordinates).
|
||||
pub target_block: Option<[i32; 3]>,
|
||||
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID of the
|
||||
/// entity under the crosshair (`Entity.getStringUUID`), if any.
|
||||
pub selected_entities: Vec<String>,
|
||||
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
|
||||
/// the server's registry access and reloadable registries hold them.
|
||||
pub by_registry: BTreeMap<String, RegistryIds>,
|
||||
|
||||
@@ -513,6 +513,56 @@ impl MeshTables {
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
/// chunk mesher tints them (grass blocks included): `None` for an
|
||||
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
|
||||
pub fn block_tints(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
biomes: &Biomes,
|
||||
zoom: BiomeZoom,
|
||||
blend_radius: i32,
|
||||
) -> impl Fn(i32) -> Option<u32> + use<> {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
});
|
||||
move |index| {
|
||||
let (colors, count) = sources?;
|
||||
if index < 0 || index >= count {
|
||||
return None;
|
||||
}
|
||||
colors[(index as usize).min(1)]
|
||||
}
|
||||
}
|
||||
/// `ModelBlockRenderer.tesselateFlat`'s light coords (`LightCoordsUtil`
|
||||
/// packing, the quad's own emission included) for `quad` of `state`
|
||||
/// drawn as the only block at `pos`, as `MovingBlockRenderState` gives
|
||||
/// it: `light` is the world's stored light (sky << 4 | block) at a
|
||||
/// position. For block models drawn outside the chunk mesh.
|
||||
pub fn flat_light(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
quad: &horizon_assets::bake::ModelQuad,
|
||||
light: impl Fn([i32; 3]) -> u8,
|
||||
) -> u32 {
|
||||
let own = self.info.get(state.0 as usize);
|
||||
let full = own.is_some_and(|i| i.full);
|
||||
let at = lighting::flat_position(pos, &quad.positions, quad.facing, quad.cull, full);
|
||||
let packed = light(at);
|
||||
let coords = match own {
|
||||
Some(own) => lighting::packed_coords(own, packed),
|
||||
None => u32::from(packed & 15) << 4 | u32::from(packed >> 4) << 20,
|
||||
};
|
||||
lighting::with_emission(coords, quad.light_emission)
|
||||
}
|
||||
/// `BlockState.getOffset` for blocks with a random model offset.
|
||||
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
|
||||
offset(self.info.get(state.0 as usize)?, x, z)
|
||||
|
||||
@@ -39,10 +39,13 @@ fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
|
||||
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
|
||||
/// (block level << 4 | sky level << 20).
|
||||
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
|
||||
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
|
||||
}
|
||||
/// `coords` of a stored light byte (sky level << 4 | block level).
|
||||
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
|
||||
if state.emissive {
|
||||
return FULL_BRIGHT;
|
||||
}
|
||||
let packed = pad.light[pi(p[0], p[1], p[2])];
|
||||
let block = u32::from((packed & 15).max(state.emission));
|
||||
block << 4 | u32::from(packed >> 4) << 20
|
||||
}
|
||||
@@ -274,8 +277,28 @@ pub(super) fn flat(
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
) -> u32 {
|
||||
let cubic = shape(positions, face, own.full).cubic;
|
||||
coords(pad, own, if cubic { step(pos, face) } else { pos })
|
||||
coords(
|
||||
pad,
|
||||
own,
|
||||
flat_position(pos, positions, face, None, own.full),
|
||||
)
|
||||
}
|
||||
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
|
||||
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
|
||||
/// when the quad is cubic, else the block itself). `full` is the block's
|
||||
/// `isCollisionShapeFullBlock`.
|
||||
pub(super) fn flat_position(
|
||||
pos: [i32; 3],
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
cull: Option<D>,
|
||||
full: bool,
|
||||
) -> [i32; 3] {
|
||||
match cull {
|
||||
Some(d) => step(pos, d),
|
||||
None if shape(positions, face, full).cubic => step(pos, face),
|
||||
None => pos,
|
||||
}
|
||||
}
|
||||
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
|
||||
/// and smooth light coords, by vertex index.
|
||||
@@ -398,6 +421,39 @@ mod tests {
|
||||
assert_eq!(max_coords(packed(3, 9), packed(7, 2)), packed(7, 9));
|
||||
}
|
||||
#[test]
|
||||
fn flat_light_reads_the_neighbour_a_face_looks_at() {
|
||||
let pos = [4, 64, -2];
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
];
|
||||
// A cull face samples its neighbour, whatever the quad's shape.
|
||||
let inset = full_top.map(|[x, _, z]| [x, 0.75, z]);
|
||||
assert_eq!(
|
||||
flat_position(pos, &inset, D::Up, Some(D::Up), false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
// A flush quad without a cull face samples its neighbour too: a block
|
||||
// drawn at a cell that is now opaque (a moving block, #3002) still
|
||||
// takes the light around it.
|
||||
assert_eq!(
|
||||
flat_position(pos, &full_top, D::Up, None, false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
let west = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
];
|
||||
assert_eq!(flat_position(pos, &west, D::West, None, false), [3, 64, -2]);
|
||||
// An inset quad (a piston head's arm) takes the block's own light.
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, false), pos);
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, true), [4, 65, -2]);
|
||||
}
|
||||
#[test]
|
||||
fn quad_shape_classifies_partial_and_cubic_faces() {
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
|
||||
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
|
||||
assert_eq!(tint("minecraft:pink_petals"), None);
|
||||
assert_eq!(tint("minecraft:stone"), None);
|
||||
}
|
||||
|
||||
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
|
||||
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
|
||||
/// as in the world, not left gray like its particles.
|
||||
#[test]
|
||||
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap();
|
||||
let center = Chunk::empty(0, 0);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
let tints = |name| {
|
||||
tables.block_tints(
|
||||
state(name),
|
||||
[5, 70, 9],
|
||||
®ion,
|
||||
&biomes,
|
||||
BiomeZoom::new(0),
|
||||
2,
|
||||
)
|
||||
};
|
||||
let grass = tints("minecraft:grass_block");
|
||||
assert!(grass(0).is_some());
|
||||
assert_eq!(grass(-1), None);
|
||||
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
|
||||
assert_eq!(tints("minecraft:stone")(0), None);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ pub fn registries(r: &Value, permission: u8) -> Registries {
|
||||
reg.timelines = strings(&r["timelines"]);
|
||||
reg.dimensions = strings(&r["dimensions"]);
|
||||
reg.players = strings(&r["players"]);
|
||||
reg.selected_entities = strings(&r["selected_entities"]);
|
||||
reg.teams = strings(&r["teams"]);
|
||||
reg.objectives = strings(&r["objectives"]);
|
||||
reg.tracked = strings(&r["tracked"]);
|
||||
|
||||
@@ -178,6 +178,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
reg.timelines = strings(&r["timelines"]);
|
||||
reg.dimensions = strings(&r["dimensions"]);
|
||||
reg.players = strings(&r["players"]);
|
||||
reg.selected_entities = strings(&r["selected_entities"]);
|
||||
reg.teams = strings(&r["teams"]);
|
||||
reg.objectives = strings(&r["objectives"]);
|
||||
reg.tracked = strings(&r["tracked"]);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! End gateway travel for the entities that step into one:
|
||||
//! `EndGatewayBlock.entityInside` (the gateway takes the entity in and starts
|
||||
//! its 40 tick cooldown), `Entity.setAsInsidePortal` and `Entity.handlePortal`
|
||||
//! (the entity goes through on the next tick), and
|
||||
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
|
||||
use crate::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// The End gateway travel of one entity.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Traveller {
|
||||
/// The entity's portal process and cooldown (`Entity.portalProcess`).
|
||||
state: PortalState,
|
||||
}
|
||||
|
||||
impl Traveller {
|
||||
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
|
||||
/// through it on the next tick.
|
||||
pub fn enter(&mut self, gateway: BlockPos) {
|
||||
self.state.set_inside(gateway, ENTITY_DELAY);
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal`: the entity goes through the gateway it entered
|
||||
/// (the End gateway has no transition time), or stays where it is.
|
||||
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
|
||||
self.state.handle(allowed, 0, ENTITY_DELAY)
|
||||
}
|
||||
|
||||
/// Whether the entity's travel is over: it has no portal process left and
|
||||
/// its portal cooldown has run out.
|
||||
pub fn is_done(&self) -> bool {
|
||||
self.state.cooldown == 0 && self.state.process.is_none()
|
||||
}
|
||||
|
||||
/// The entity's portal cooldown, the ticks it stays out of a portal.
|
||||
pub fn cooldown(&self) -> i32 {
|
||||
self.state.cooldown
|
||||
}
|
||||
}
|
||||
|
||||
/// The End gateway block an entity's box swept through
|
||||
/// (`Entity.checkInsideBlocks`, which calls `EndGatewayBlock.entityInside`).
|
||||
pub fn swept_gateway(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
mut is_gateway: impl FnMut(BlockPos) -> bool,
|
||||
) -> Option<BlockPos> {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let bounds = horizon_core::geometry::Aabb::new(
|
||||
Vec3::new(to.x - half, to.y, to.z - half),
|
||||
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
|
||||
)
|
||||
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
|
||||
let mut found = None;
|
||||
let _ =
|
||||
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, _| {
|
||||
if is_gateway(p) {
|
||||
found = Some(p);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
|
||||
/// lands at a gateway's exit.
|
||||
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The gateway of the issue: a block at [702, 100, 0] in the End.
|
||||
const GATEWAY: [i32; 3] = [702, 100, 0];
|
||||
|
||||
fn at(x: f64, y: f64, z: f64) -> Vec3 {
|
||||
Vec3::new(x, y, z)
|
||||
}
|
||||
|
||||
fn is_gateway(p: BlockPos) -> bool {
|
||||
[p.x, p.y, p.z] == GATEWAY
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_and_a_mob_in_a_gateway_are_found_in_it() {
|
||||
// A dropped item, 0.25 wide, standing in the gateway block.
|
||||
let item = at(702.5, 100.5, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(item, item, 0.25, 0.25, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
// A cow, 0.9 wide and 1.4 tall, in the same block.
|
||||
let cow = at(702.5, 100.0, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(cow, cow, 0.9, 1.4, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_out_of_the_gateway_is_not_in_it() {
|
||||
// Two blocks above the gateway: nothing to travel through.
|
||||
let above = at(702.5, 102.5, 0.5);
|
||||
assert_eq!(swept_gateway(above, above, 0.25, 0.25, is_gateway), None);
|
||||
// Beside the gateway, outside its block.
|
||||
let beside = at(704.5, 100.5, 0.5);
|
||||
assert_eq!(swept_gateway(beside, beside, 0.9, 1.4, is_gateway), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_falling_through_the_gateway_goes_through_it() {
|
||||
// A cow falling past the gateway block passes through it
|
||||
// (Entity.checkInsideBlocks visits the blocks along the way).
|
||||
let from = at(702.5, 103.0, 0.5);
|
||||
let to = at(702.5, 99.0, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(from, to, 0.9, 1.4, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_entity_goes_through_the_gateway_on_the_tick_after_it_entered() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
let mut traveller = Traveller::default();
|
||||
// Nothing happens while the entity is outside the gateway.
|
||||
assert_eq!(traveller.travel(true), None);
|
||||
// It enters the gateway, and goes through on the next tick.
|
||||
traveller.enter(gateway);
|
||||
assert_eq!(traveller.travel(true), Some(gateway));
|
||||
// Its portal cooldown keeps it from going back at once.
|
||||
assert_eq!(traveller.cooldown(), ENTITY_DELAY);
|
||||
assert!(!traveller.is_done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
let mut traveller = Traveller::default();
|
||||
traveller.enter(gateway);
|
||||
// A mob that cannot use a portal does not go through.
|
||||
assert_eq!(traveller.travel(false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
|
||||
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
|
||||
// centre, the same spot the player arrives at.
|
||||
assert_eq!(bottom_center([8, 100, 0]), at(8.5, 100.0, 0.5));
|
||||
}
|
||||
}
|
||||
@@ -3028,6 +3028,10 @@ impl Game<'_> {
|
||||
self.portals.sections.remove(&key);
|
||||
self.block_ticks
|
||||
.remove_chunk(horizon_core::position::ChunkPos::new(key.0, key.1));
|
||||
// Its moving pistons were saved with it and load back with it.
|
||||
self.block_ticks
|
||||
.moving_pistons
|
||||
.retain(|(p, _)| (p.x >> 4, p.z >> 4) != key);
|
||||
}
|
||||
self.tick_chunks.recycle_added(added);
|
||||
}
|
||||
@@ -3239,10 +3243,20 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
|
||||
.collect();
|
||||
self.world.moving_piston_fallback = previous
|
||||
let mut fallback: Vec<_> = previous
|
||||
.into_iter()
|
||||
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
|
||||
.collect();
|
||||
// A finished piston stays drawn until the chunk mesh shows the block
|
||||
// it placed, however many ticks that takes.
|
||||
let older = std::mem::take(&mut self.world.moving_piston_fallback);
|
||||
for (pos, piston) in older {
|
||||
let replaced = current.iter().chain(&fallback).any(|(p, _)| *p == pos);
|
||||
if !replaced && self.section_mesh_pending(pos) {
|
||||
fallback.push((pos, piston));
|
||||
}
|
||||
}
|
||||
self.world.moving_piston_fallback = fallback;
|
||||
self.world.moving_pistons = current;
|
||||
}
|
||||
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
|
||||
@@ -5607,8 +5621,10 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("entity_portals", || self.handle_entity_portals());
|
||||
horizon_client::spans::time("tick_items", || self.tick_items());
|
||||
horizon_client::spans::time("entity_portals", || {
|
||||
self.handle_entity_portals();
|
||||
self.handle_end_gateway_portals();
|
||||
});
|
||||
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
|
||||
}
|
||||
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
|
||||
@@ -5630,6 +5646,11 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_portal", || self.tick_nether_portal())?;
|
||||
if running {
|
||||
horizon_client::spans::time("tick_end", || self.tick_end())?;
|
||||
horizon_client::spans::time("end_gateways", || {
|
||||
self.find_end_gateway_portals();
|
||||
self.resolve_end_gateway_arrivals()
|
||||
})?;
|
||||
self.keep_gateway_destinations();
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("tick_falling", || self.tick_falling())?;
|
||||
@@ -9228,7 +9249,8 @@ impl Game<'_> {
|
||||
book.to_be_displayed.retain(|r| index.contains(r));
|
||||
self.dim = dim;
|
||||
self.world_root = save_root.clone();
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root)
|
||||
.is_some_and(|w| w.preset == horizon_client::save::WorldPreset::Flat);
|
||||
self.block_ticks = Ticks::new(self.level.seed as i32);
|
||||
self.tick_chunks.clear();
|
||||
self.clear_entities();
|
||||
|
||||
@@ -564,7 +564,7 @@ impl Game<'_> {
|
||||
"hardcore": options.hardcore,
|
||||
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
|
||||
"allow_commands": options.commands(*creative),
|
||||
"superflat": options.flat,
|
||||
"world_type": options.world_type.id(),
|
||||
"generate_structures": options.structures,
|
||||
"bonus_chest": options.bonus_chest(),
|
||||
}),
|
||||
@@ -577,7 +577,7 @@ impl Game<'_> {
|
||||
} => json!({
|
||||
"mode": "customize-flat",
|
||||
"fields": { "name": name, "seed": seed },
|
||||
"superflat": options.flat,
|
||||
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
|
||||
"biome": flat.settings().biome,
|
||||
"layers": flat.settings().layers.iter().map(|layer| json!({
|
||||
"block": layer.block,
|
||||
|
||||
@@ -415,9 +415,11 @@ impl Game<'_> {
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
}
|
||||
/// The block the player is looking at, for coordinate suggestions.
|
||||
/// The block the player is looking at, for coordinate suggestions, and
|
||||
/// the entity, for entity arguments (ClientSuggestionProvider).
|
||||
fn refresh_dynamic_registries(&mut self) {
|
||||
self.ui.registries.target_block = self.pick().map(|h| h.pos);
|
||||
self.ui.registries.selected_entities = self.crosshair_entity_uuid().into_iter().collect();
|
||||
// ClientPacketListener.getOnlinePlayerNames on a server.
|
||||
if let Some(names) = self.remote_online_names() {
|
||||
self.ui.registries.players = names;
|
||||
|
||||
@@ -73,7 +73,7 @@ impl Game<'_> {
|
||||
|
||||
/// `EntitySelector.findEntities` with a UUID: the player with it, else
|
||||
/// the entity.
|
||||
pub(super) fn target_by_uuid(&self, uuid: u128) -> Option<Target> {
|
||||
pub(in crate::game) fn target_by_uuid(&self, uuid: u128) -> Option<Target> {
|
||||
let own = self.lan_acting_uuid.or_else(|| {
|
||||
self.level.player_uuid.map(|[a, b, c, d]| {
|
||||
(u128::from(a as u32) << 96)
|
||||
|
||||
@@ -298,15 +298,14 @@ impl Lists {
|
||||
}
|
||||
}
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset`.
|
||||
const WORLD_PRESETS: [&str; 7] = [
|
||||
"minecraft:normal",
|
||||
"minecraft:flat",
|
||||
"minecraft:large_biomes",
|
||||
"minecraft:amplified",
|
||||
"minecraft:single_biome_surface",
|
||||
"minecraft:debug_all_block_states",
|
||||
"minecraft:flat_all_dimensions",
|
||||
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
|
||||
/// names, before the presets had their own ids.
|
||||
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
|
||||
("default", horizon_client::save::WorldPreset::Normal),
|
||||
(
|
||||
"largebiomes",
|
||||
horizon_client::save::WorldPreset::LargeBiomes,
|
||||
),
|
||||
];
|
||||
|
||||
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
|
||||
@@ -323,11 +322,18 @@ impl Game<'_> {
|
||||
/// DedicatedServerProperties.createDimensions for a world that doesn't
|
||||
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
|
||||
/// and `generate-structures`, written before the world opens so that
|
||||
/// streaming reads them. Horizon generates the normal and flat presets.
|
||||
/// streaming reads them.
|
||||
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
|
||||
let level_type = &props.level_type;
|
||||
let preset = parse_identifier(level_type)
|
||||
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
|
||||
.as_deref()
|
||||
.and_then(horizon_client::save::WorldPreset::of_id)
|
||||
.or_else(|| {
|
||||
LEGACY_PRESET_NAMES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == level_type)
|
||||
.map(|(_, preset)| *preset)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
@@ -336,18 +342,9 @@ impl Game<'_> {
|
||||
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
|
||||
),
|
||||
);
|
||||
"minecraft:normal".to_owned()
|
||||
horizon_client::save::WorldPreset::Normal
|
||||
});
|
||||
let flat = preset == "minecraft:flat";
|
||||
if !flat && preset != "minecraft:normal" {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
"WARN",
|
||||
&format!(
|
||||
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
|
||||
),
|
||||
);
|
||||
}
|
||||
let flat = preset == horizon_client::save::WorldPreset::Flat;
|
||||
let settings = if flat {
|
||||
let biomes = self.res.biomes();
|
||||
let (settings, errors) = horizon_client::flatgen::settings_from_json(
|
||||
@@ -363,7 +360,7 @@ impl Game<'_> {
|
||||
None
|
||||
};
|
||||
let options = horizon_client::save::WorldGen {
|
||||
flat,
|
||||
preset,
|
||||
structures: props.generate_structures,
|
||||
bonus_chest: false,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! sky. The dragon fight itself lives in game/dragon.rs: the podium is
|
||||
//! inactive until the dragon dies.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::nether_portal::ENTITY_DELAY;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, EndGateway},
|
||||
@@ -16,10 +17,18 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// A list of doubles, as an NBT list of them.
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
|
||||
/// ServerLevel.END_SPAWN_POINT.
|
||||
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
@@ -38,6 +47,18 @@ pub(super) enum GatewayJob {
|
||||
Known { exit: [i32; 3], exact: bool },
|
||||
}
|
||||
|
||||
/// An entity travelling through an End gateway: its saved compound and the
|
||||
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct GatewayArrival {
|
||||
/// The entity's compound, as it waits for the exit's chunks.
|
||||
tag: Tag,
|
||||
/// The gateway's exit, or the search for one.
|
||||
job: GatewayJob,
|
||||
/// The spot to load before the job is resolved.
|
||||
target: Vec3,
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
|
||||
fn for_gateway(
|
||||
@@ -94,6 +115,11 @@ pub(super) struct EndState {
|
||||
job: Option<GatewayJob>,
|
||||
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
|
||||
gateway_cooldown: u32,
|
||||
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
|
||||
/// The entities that went through a gateway, waiting for its exit.
|
||||
arrivals: Vec<GatewayArrival>,
|
||||
/// The chunks kept loaded for those arrivals.
|
||||
areas: Vec<(i32, i32)>,
|
||||
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
|
||||
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
|
||||
/// Thrown eyes of ender in flight.
|
||||
@@ -931,6 +957,17 @@ impl Game<'_> {
|
||||
self.save_gateway_exits()
|
||||
}
|
||||
|
||||
/// The exit a gateway already leads to: its block entity, or the side file
|
||||
/// that kept it for a gateway whose chunk is not loaded.
|
||||
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let saved = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
|
||||
}
|
||||
|
||||
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
|
||||
/// travel within the End, waiting for the destination chunks to load.
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
@@ -1027,13 +1064,7 @@ impl Game<'_> {
|
||||
/// The teleport position of a gateway job, creating the exit gateway (and
|
||||
/// an island when the chunk has no landing spot) for new destinations.
|
||||
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
let bottom_center = |p: [i32; 3]| {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
};
|
||||
let bottom_center = horizon_client::end_gateway::bottom_center;
|
||||
let find_exit = |game: &Self, exit: [i32; 3]| {
|
||||
let p = game.tallest_block([exit[0], exit[1] + 2, exit[2]], 5, false);
|
||||
[p[0], p[1] + 1, p[2]]
|
||||
@@ -1043,6 +1074,14 @@ impl Game<'_> {
|
||||
find_exit(self, e)
|
||||
}))),
|
||||
GatewayJob::Find { gateway, exact } => {
|
||||
// The player and the entities may resolve the same gateway: the
|
||||
// first one to arrive finds the exit, the others use it.
|
||||
let known = self.known_gateway_exit(gateway);
|
||||
if let Some((exit, exact)) = known {
|
||||
return Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
})));
|
||||
}
|
||||
// findExitPortalXZPosTentative over the loaded chunks.
|
||||
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero();
|
||||
@@ -1404,6 +1443,190 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- End gateway travel for the entities ----
|
||||
|
||||
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
|
||||
/// gateway blocks their box swept through. A gateway that is cooling down
|
||||
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
|
||||
pub(super) fn find_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let items: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, e.position, 0.25, 0.25))
|
||||
.collect();
|
||||
let mobs: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
|
||||
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
|
||||
== "minecraft:end_gateway"
|
||||
});
|
||||
if let Some(pos) = found {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
for (id, pos) in inside {
|
||||
// One entity at a time: the gateway cools down for 40 ticks.
|
||||
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
|
||||
continue;
|
||||
}
|
||||
self.end.gateways.entry(id).or_default().enter(pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal` for the entities in an End gateway: they go
|
||||
/// through to the gateway's exit, where they wait for its chunks.
|
||||
pub(super) fn handle_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let ids: Vec<(u32, bool)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, true))
|
||||
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.end.gateways.retain(|id, _| present.contains(id));
|
||||
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, allowed) in ids {
|
||||
let Some(state) = self.end.gateways.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
// The End gateway has no transition time (Portal.getPortalTransitionTime).
|
||||
if let Some(entry) = state.travel(allowed) {
|
||||
leaving.push((id, entry));
|
||||
}
|
||||
if state.is_done() {
|
||||
self.end.gateways.remove(&id);
|
||||
}
|
||||
}
|
||||
for (id, entry) in leaving {
|
||||
self.send_through_gateway(id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.teleportToPortalDestination` for an entity that goes through an
|
||||
/// End gateway: it is saved and waits for the exit's chunks.
|
||||
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
|
||||
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
|
||||
&self.dropped[i],
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
);
|
||||
self.dropped.remove(i);
|
||||
self.forget_entity(id);
|
||||
tag
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let mut tag = self.mob_tree_nbt(i);
|
||||
// Entity.removeAfterChangingDimensions drops the leash.
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"leash".into());
|
||||
}
|
||||
// The mobs riding it travel with it (their tags are in its compound).
|
||||
let mut gone = vec![id];
|
||||
let mut k = 0;
|
||||
while k < gone.len() {
|
||||
let v = gone[k];
|
||||
gone.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(v))
|
||||
.map(|m| m.id),
|
||||
);
|
||||
k += 1;
|
||||
}
|
||||
self.mobs.retain(|m| !gone.contains(&m.id));
|
||||
for g in gone {
|
||||
self.forget_entity(g);
|
||||
}
|
||||
tag
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
|
||||
self.end.arrivals.push(GatewayArrival { tag, job, target });
|
||||
}
|
||||
|
||||
/// The entities that reached a gateway's exit: they land where the
|
||||
/// gateway leads, once its chunks have loaded.
|
||||
pub(super) fn resolve_end_gateway_arrivals(&mut self) -> Result<()> {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return Ok(());
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.end.arrivals.len() {
|
||||
let (cx, cz) = (
|
||||
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
|
||||
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
|
||||
let at = match self.resolve_gateway(job, target) {
|
||||
Ok(at) => at,
|
||||
Err(e) => {
|
||||
// The traveller waits for the exit: the failure is retried.
|
||||
log::warn!("End gateway: {e}");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut tag = self.end.arrivals.remove(i).tag;
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
|
||||
// setPortalCooldown before the teleport.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
match self
|
||||
.world
|
||||
.loaded_entities
|
||||
.iter_mut()
|
||||
.find(|(k, _)| *k == key)
|
||||
{
|
||||
Some((_, tags)) => tags.push(tag),
|
||||
None => self.world.loaded_entities.push((key, vec![tag])),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The chunks the entities waiting to arrive need, kept loaded like the
|
||||
/// areas a server keeps around its players.
|
||||
pub(super) fn keep_gateway_destinations(&mut self) {
|
||||
let areas = &self.end.areas;
|
||||
self.streaming
|
||||
.extra
|
||||
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
|
||||
let mut areas = Vec::new();
|
||||
for a in &self.end.arrivals {
|
||||
let c = (
|
||||
(a.target.x.floor() as i32) >> 4,
|
||||
(a.target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !areas.contains(&c) {
|
||||
areas.push(c);
|
||||
self.streaming.extra.push((c, 1));
|
||||
}
|
||||
}
|
||||
self.end.areas = areas;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -69,7 +69,8 @@ pub(super) struct LanHost {
|
||||
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
|
||||
/// Blocks broken this tick (their sound is a level event for the guests).
|
||||
pub broken: Vec<[f64; 3]>,
|
||||
/// Hurt time, death and swing time (while swinging) of the mobs guests see.
|
||||
/// Hurt time, death and swing time (while swinging) of the mobs guests
|
||||
/// see; the players' hurt time by entity id.
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
|
||||
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
|
||||
pub mob_players: HashMap<u32, u32>,
|
||||
@@ -1519,6 +1520,7 @@ impl Game<'_> {
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
}
|
||||
@@ -1785,6 +1787,7 @@ impl Game<'_> {
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
|
||||
self.lan_entity_uuids(&mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
@@ -2008,6 +2011,34 @@ impl Game<'_> {
|
||||
}
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
// LivingEntity.hurtServer for a player: a hit that restarted the hurt
|
||||
// time and was not blocked by a shield broadcasts the damage event,
|
||||
// so the clients around (and the player's own) flash it red.
|
||||
let host_player = self.player.as_ref().map(|p| {
|
||||
(
|
||||
host.host_entity,
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
p,
|
||||
)
|
||||
});
|
||||
let guests = host.hub.guests.iter().filter(|g| host.hub.here(g));
|
||||
let players: Vec<_> = host_player
|
||||
.into_iter()
|
||||
.chain(guests.filter_map(|g| g.motion.as_deref().map(|m| (g.entity_id, g.position, m))))
|
||||
.map(|(id, at, p)| (id, at, p.health.hurt_animation_time, p.hurt_log.last()))
|
||||
.collect();
|
||||
for (id, at, hurt, record) in players {
|
||||
let old = host
|
||||
.mob_state
|
||||
.get(&self.dim)
|
||||
.and_then(|s| s.get(&(id as u32)));
|
||||
if hurt > old.map_or(0, |o| o.0) {
|
||||
// Starving keeps no record: any type flashes the same.
|
||||
let kind = record.map_or("minecraft:generic", |r| r.source.as_str());
|
||||
host.hub.damage_event(id, kind, None, None, at);
|
||||
}
|
||||
seen.insert(id as u32, (hurt, false, None));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
|
||||
for attacker in kinetic_hits {
|
||||
@@ -2116,6 +2147,11 @@ impl Game<'_> {
|
||||
m.limb_speed += ((dist * 4.0).min(1.0) - m.limb_speed) * 0.4;
|
||||
m.limb_position += m.limb_speed;
|
||||
m.age = m.age.wrapping_add(1);
|
||||
// The hurt red flash (LivingEntity.hurtTime).
|
||||
m.hurt_time = g
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(0, |p| p.health.hurt_animation_time);
|
||||
m.extra.main_hand = g
|
||||
.inventory
|
||||
.get(g.selected)
|
||||
|
||||
@@ -675,9 +675,7 @@ impl Game<'_> {
|
||||
let new_mode = self.mode_id();
|
||||
self.mode = mode;
|
||||
self.creative = creative;
|
||||
self.selected = std::mem::replace(&mut g.selected, selected);
|
||||
// `g.selected` now holds the host's value: swap the inventories back.
|
||||
std::mem::swap(&mut self.selected, &mut g.selected);
|
||||
g.selected = std::mem::replace(&mut self.selected, selected);
|
||||
std::mem::swap(&mut self.inventory, &mut g.inventory);
|
||||
std::mem::swap(&mut self.armor, &mut g.armor);
|
||||
std::mem::swap(&mut self.offhand, &mut g.offhand);
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::Game;
|
||||
use horizon_client::lan::{
|
||||
entity::{EntityState, Riding, States},
|
||||
hub::Hub,
|
||||
playerdata::ints_uuid,
|
||||
};
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -270,6 +271,16 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ClientboundAddEntityPacket(Entity, ServerEntity, int)`: the entity's
|
||||
/// own UUID (`Entity.getUUID`), the one `/data`, saves and UUID
|
||||
/// selectors use, so a client can name it back (the crosshair UUID that
|
||||
/// `ClientSuggestionProvider.getSelectedEntities` suggests).
|
||||
pub(super) fn lan_entity_uuids(&mut self, states: &mut States) {
|
||||
for e in states.as_mut_slice() {
|
||||
e.uuid = ints_uuid(self.uuid_for(e.id as u32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives the entities Horizon keeps without an id one (`Entity.id`, from
|
||||
/// the same counter as the rest) before they are first tracked.
|
||||
pub(super) fn lan_assign_ids(&mut self) {
|
||||
|
||||
@@ -1227,6 +1227,22 @@ impl Game<'_> {
|
||||
light: u32,
|
||||
diffuse: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.tinted_block_model_vertices(state, seed, m, |_| light, diffuse, |_| None, out);
|
||||
}
|
||||
/// `block_model_vertices` with each face multiplied by `tint` of its
|
||||
/// tint index (RGB, `None` for untinted faces) and lit by `light` of
|
||||
/// each quad.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn tinted_block_model_vertices(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
m: glam::Mat4,
|
||||
light: impl Fn(&horizon_assets::bake::ModelQuad) -> u32,
|
||||
diffuse: bool,
|
||||
tint: impl Fn(i32) -> Option<u32>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
|
||||
return;
|
||||
@@ -1248,11 +1264,17 @@ impl Game<'_> {
|
||||
D::North | D::South => 204,
|
||||
D::West | D::East => 153,
|
||||
};
|
||||
let color = if diffuse {
|
||||
let mut color = if diffuse {
|
||||
u32::MAX
|
||||
} else {
|
||||
0xFF00_0000 | shade << 16 | shade << 8 | shade
|
||||
};
|
||||
if let Some(t) = tint(face.quad.tint_index) {
|
||||
color = horizon_client::hud_layout::multiply(
|
||||
color,
|
||||
horizon_client::mobs::render::abgr(t),
|
||||
);
|
||||
}
|
||||
let normal = if diffuse {
|
||||
use horizon_client::item_render::{direction_vector, transform_normal};
|
||||
let n = transform_normal(m, direction_vector(face.quad.facing));
|
||||
@@ -1261,6 +1283,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
[0; 4]
|
||||
};
|
||||
let light = light(&face.quad);
|
||||
let light = if diffuse {
|
||||
let emission = u32::from(face.quad.light_emission) * 16;
|
||||
(light & 0xFF) | (light >> 8 & 0xFF).max(emission) << 8
|
||||
|
||||
@@ -14,30 +14,58 @@ impl Game<'_> {
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let partial = alpha as f32;
|
||||
let mut states = self.world.moving_pistons.clone();
|
||||
states.extend(self.world.moving_piston_fallback.iter().copied());
|
||||
for (p, piston) in states {
|
||||
let live = self
|
||||
.world
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|&(p, s)| (p, s, false));
|
||||
// A piston that just finished bridges the frames until the chunk mesh
|
||||
// shows the block it placed; drawn over that mesh, it z-fights (#3002).
|
||||
let finished = self
|
||||
.world
|
||||
.moving_piston_fallback
|
||||
.iter()
|
||||
.filter(|(p, _)| self.section_mesh_pending(*p))
|
||||
.map(|&(p, s)| (p, s, true));
|
||||
let states: Vec<_> = live.chain(finished).collect();
|
||||
for (p, piston, placed) in states {
|
||||
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
|
||||
continue;
|
||||
};
|
||||
let step = piston.movement_direction().normal();
|
||||
let from = horizon_core::position::BlockPos::new(
|
||||
p[0] - step[0],
|
||||
p[1] - step[1],
|
||||
p[2] - step[2],
|
||||
);
|
||||
let at = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
// `createMovingBlock`'s block position (seed, tint and light): the
|
||||
// cell the block moves from. A finished one stands in for the
|
||||
// chunk mesh's block at `p`, so it is drawn as that block.
|
||||
let from = if placed {
|
||||
at
|
||||
} else {
|
||||
horizon_core::position::BlockPos::new(
|
||||
p[0] - step[0],
|
||||
p[1] - step[1],
|
||||
p[2] - step[2],
|
||||
)
|
||||
};
|
||||
let offset = piston.interpolated_offset(partial);
|
||||
let light = self.world.light(p[0], p[1], p[2]);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let corner = glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]));
|
||||
let moved = corner + glam::Vec3::from_array(offset).as_dvec3();
|
||||
self.moving_block_vertices(block, from, moved - camera, packed, out);
|
||||
self.moving_block_vertices(block, from, moved - camera, out);
|
||||
if let Some(base) = base {
|
||||
let at = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
self.moving_block_vertices(base, at, corner - camera, packed, out);
|
||||
self.moving_block_vertices(base, at, corner - camera, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the chunk mesh doesn't show the blocks at `p` yet (edited,
|
||||
/// meshing, or waiting for its upload).
|
||||
pub(super) fn section_mesh_pending(&self, p: [i32; 3]) -> bool {
|
||||
let Some(y) = p[1].checked_sub(horizon_client::chunk::MIN_Y) else {
|
||||
return false;
|
||||
};
|
||||
let section = (p[0] >> 4, y.div_euclid(16), p[2] >> 4);
|
||||
self.world.dirty_sections.contains(§ion)
|
||||
|| self.streaming.is_meshing(section)
|
||||
|| self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
}
|
||||
/// The moved block and, for a retracting piston, its extended base.
|
||||
fn moving_piston_blocks(
|
||||
&self,
|
||||
@@ -81,32 +109,51 @@ impl Game<'_> {
|
||||
Some((piston.moved_state, None))
|
||||
}
|
||||
/// One `MovingBlockRenderState`: the baked model at `at` (camera-relative),
|
||||
/// seeded by its block position and tinted like a falling block.
|
||||
/// seeded by its block position `seed`, tinted per face with the block
|
||||
/// colours there and lit as `ModelBlockRenderer.tesselateFlat` lights a
|
||||
/// lone block at `seed`: each face from the cell it looks at. Not from
|
||||
/// the moving piston's own cell, which is dark once the block it placed
|
||||
/// stands there (#3002).
|
||||
fn moving_block_vertices(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: horizon_core::position::BlockPos,
|
||||
at: glam::DVec3,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let tint = self
|
||||
.particle_sprite(state, [seed.x, seed.y, seed.z])
|
||||
.map(|(_, tint)| tint);
|
||||
let start = out.len();
|
||||
self.block_model_vertices(
|
||||
let chunks = &self.world.chunks;
|
||||
let (cx, cz) = (seed.x >> 4, seed.z >> 4);
|
||||
let region: [[Option<&horizon_client::chunk::Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
chunks
|
||||
.get(&(cx + dx as i32 - 1, cz + dz as i32 - 1))
|
||||
.map(|c| &**c)
|
||||
})
|
||||
});
|
||||
let tint = self.pack.context.tables.block_tints(
|
||||
state,
|
||||
[seed.x, seed.y, seed.z],
|
||||
®ion,
|
||||
self.res.biomes(),
|
||||
self.streaming.biome_zoom,
|
||||
self.streaming.biome_blend,
|
||||
);
|
||||
let tables = &self.pack.context.tables;
|
||||
let light = |quad: &horizon_assets::bake::ModelQuad| {
|
||||
let coords = tables.flat_light(state, [seed.x, seed.y, seed.z], quad, |p| {
|
||||
self.world.light(p[0], p[1], p[2])
|
||||
});
|
||||
// Light coords (block << 4 | sky << 20) as the vertex packs them.
|
||||
(coords >> 16 & 0xF0) | ((coords & 0xF0) << 8)
|
||||
};
|
||||
self.tinted_block_model_vertices(
|
||||
state,
|
||||
horizon_core::random::position_seed(seed),
|
||||
glam::Mat4::from_translation(at.as_vec3()),
|
||||
light,
|
||||
false,
|
||||
tint,
|
||||
out,
|
||||
);
|
||||
if let Some(tint) = tint {
|
||||
let tint = horizon_client::mobs::render::abgr(tint);
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
//! portal) until the player is in that dimension and its exit is loaded.
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::nether_portal::PortalState;
|
||||
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Axis, Vec3},
|
||||
position::BlockPos,
|
||||
@@ -29,8 +29,6 @@ use std::collections::HashMap;
|
||||
|
||||
/// `Player.getDimensionChangingDelay`.
|
||||
pub(super) const PLAYER_DELAY: i32 = 10;
|
||||
/// `Entity.getDimensionChangingDelay`.
|
||||
const ENTITY_DELAY: i32 = 300;
|
||||
|
||||
/// Where in its portal a traveller stood, and where its exit is to be found.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -89,7 +87,7 @@ pub(super) struct Portals {
|
||||
/// Items and mobs, by entity id.
|
||||
entities: HashMap<u32, PortalState>,
|
||||
/// Positions before this tick's moves, by entity id.
|
||||
before: HashMap<u32, Vec3>,
|
||||
pub(super) before: HashMap<u32, Vec3>,
|
||||
arrivals: Vec<Arrival>,
|
||||
/// The vehicles server players ride that went through a portal this
|
||||
/// tick, in their level, with their entry: they carry their riders
|
||||
@@ -686,7 +684,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `canUsePortal(false)` of a mob: alive, riding nothing, not asleep,
|
||||
/// not carrying the player, and not a boss that never travels.
|
||||
fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
|
||||
pub(super) fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
|
||||
use horizon_client::mobs::Species;
|
||||
m.alive()
|
||||
&& m.extra.mount.is_none()
|
||||
|
||||
@@ -9,8 +9,13 @@ use horizon_client::{
|
||||
mobs::Who,
|
||||
save::{ChunkExtras, random_uuid},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::ChunkPos, random::RandomSource, ticks::ChunkTicks};
|
||||
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
|
||||
use horizon_core::{
|
||||
geometry::{Direction, Vec3},
|
||||
position::{BlockPos, ChunkPos},
|
||||
random::RandomSource,
|
||||
ticks::ChunkTicks,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, level::MovingPiston, nbt::Tag, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
@@ -66,6 +71,10 @@ fn painting_center(p: &horizon_client::entities::Painting) -> Vec3 {
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
)
|
||||
}
|
||||
fn block_entity_pos(tag: &Tag) -> [i32; 3] {
|
||||
[tag.get("x"), tag.get("y"), tag.get("z")]
|
||||
.map(|v| horizon_client::save::number(v).unwrap_or(0) as i32)
|
||||
}
|
||||
fn chunk_of(p: Vec3) -> (i32, i32) {
|
||||
((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
|
||||
}
|
||||
@@ -86,6 +95,19 @@ impl Game<'_> {
|
||||
extras.fluid_ticks = t.pack(time);
|
||||
}
|
||||
extras.last_update = time;
|
||||
// PistonMovingBlockEntity.saveAdditional: a block caught mid-move is
|
||||
// saved with its chunk (#3310).
|
||||
for (p, piston) in &self.block_ticks.moving_pistons {
|
||||
if (p.x >> 4, p.z >> 4) != (chunk.x, chunk.z) {
|
||||
continue;
|
||||
}
|
||||
extras
|
||||
.block_entities
|
||||
.retain(|tag| block_entity_pos(tag) != [p.x, p.y, p.z]);
|
||||
extras
|
||||
.block_entities
|
||||
.push(self.moving_piston_to_nbt(*p, piston));
|
||||
}
|
||||
// TheEndGatewayBlockEntity saves its Age (the server copy's).
|
||||
for tag in &mut extras.block_entities {
|
||||
let pos = [tag.get("x"), tag.get("y"), tag.get("z")]
|
||||
@@ -142,6 +164,82 @@ impl Game<'_> {
|
||||
s.fluid_ticks.clear();
|
||||
}
|
||||
}
|
||||
// PistonMovingBlockEntity.loadAdditional: blocks saved mid-move carry
|
||||
// on moving; later saves take them from the level (#3310).
|
||||
let pistons: Vec<_> = saved.as_deref().map_or_else(Vec::new, |s| {
|
||||
s.block_entities
|
||||
.iter()
|
||||
.filter_map(|tag| self.moving_piston_from_nbt(tag))
|
||||
.collect()
|
||||
});
|
||||
if pistons.is_empty() {
|
||||
return;
|
||||
}
|
||||
for &(pos, piston) in &pistons {
|
||||
self.block_ticks.load_moving_piston(pos, piston);
|
||||
}
|
||||
if let Some(c) = self.world.chunks.get_mut(&key) {
|
||||
let c = Arc::make_mut(c);
|
||||
if let Some(s) = c.saved.as_mut() {
|
||||
Arc::make_mut(s).block_entities.retain(|tag| {
|
||||
let at = block_entity_pos(tag);
|
||||
!pistons.iter().any(|(p, _)| [p.x, p.y, p.z] == at)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PistonMovingBlockEntity.saveAdditional`.
|
||||
fn moving_piston_to_nbt(&self, pos: BlockPos, piston: &MovingPiston) -> Tag {
|
||||
let flag = |b: bool| Tag::Byte(i8::from(b));
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:piston".into())),
|
||||
("x", Tag::Int(pos.x)),
|
||||
("y", Tag::Int(pos.y)),
|
||||
("z", Tag::Int(pos.z)),
|
||||
("blockState", self.state_to_nbt(piston.moved_state)),
|
||||
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
|
||||
("facing", Tag::Byte(piston.direction as i8)),
|
||||
("progress", Tag::Float(piston.progress_o.to_bits())),
|
||||
("extending", flag(piston.extending)),
|
||||
("source", flag(piston.source)),
|
||||
])
|
||||
}
|
||||
/// `PistonMovingBlockEntity.loadAdditional`, for a `minecraft:piston`
|
||||
/// entity whose block is still a moving piston.
|
||||
fn moving_piston_from_nbt(&self, tag: &Tag) -> Option<(BlockPos, MovingPiston)> {
|
||||
use horizon_client::save::{float, number, text};
|
||||
if text(tag.get("id")).as_deref() != Some("minecraft:piston") {
|
||||
return None;
|
||||
}
|
||||
let [x, y, z] = block_entity_pos(tag);
|
||||
let here = self.world.block(x, y, z);
|
||||
if &*self.res.facts().get(here).name != "minecraft:moving_piston" {
|
||||
return None;
|
||||
}
|
||||
let moved_state = tag
|
||||
.get("blockState")
|
||||
.and_then(|t| self.state_from_nbt(t))
|
||||
.or_else(|| self.pack.assets.registry.default_state("minecraft:air"))?;
|
||||
let direction = usize::try_from(number(tag.get("facing")).unwrap_or(0))
|
||||
.ok()
|
||||
.and_then(|i| Direction::ALL.get(i).copied())
|
||||
.unwrap_or(Direction::Down);
|
||||
let progress = float(tag.get("progress")).unwrap_or(0.0) as f32;
|
||||
let flag = |k: &str| number(tag.get(k)).unwrap_or(0) != 0;
|
||||
Some((
|
||||
BlockPos::new(x, y, z),
|
||||
MovingPiston {
|
||||
moved_state,
|
||||
direction,
|
||||
extending: flag("extending"),
|
||||
source: flag("source"),
|
||||
progress,
|
||||
progress_o: progress,
|
||||
last_ticked: 0,
|
||||
death_ticks: 0,
|
||||
id: 0,
|
||||
},
|
||||
))
|
||||
}
|
||||
/// Spawn the entities of chunks that finished loading.
|
||||
pub(super) fn spawn_loaded_entities(&mut self) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `ServerGamePacketListenerImpl.handlePickItemFromEntity`): the client aims,
|
||||
//! the server answers with `Entity.getPickResult`.
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::{items::ItemStack, lan::playerdata::uuid_ints, save::uuid_text};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The entity the crosshair hits before any block, as `Entity.getId`; an
|
||||
@@ -33,6 +33,38 @@ impl Game<'_> {
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID
|
||||
/// (`Entity.getStringUUID`) of the entity the crosshair hits before any
|
||||
/// block (`Minecraft.hitResult` an `EntityHitResult`).
|
||||
pub(super) fn crosshair_entity_uuid(&self) -> Option<String> {
|
||||
// The nearest entity wins, as in `pick_entity_hit`; one whose UUID
|
||||
// the client doesn't know (the dragon's parts) suggests nothing.
|
||||
let mut best: Option<(f64, Option<[i32; 4]>)> = None;
|
||||
let mut offer = |distance: f64, uuid: Option<[i32; 4]>| {
|
||||
if self.before_block(distance) && best.is_none_or(|b| distance < b.0) {
|
||||
best = Some((distance, uuid));
|
||||
}
|
||||
};
|
||||
let uuid_of = |id: u32| self.persist.uuids.get(&id).copied();
|
||||
if let Some(hit) = self.remote_object_hit(true) {
|
||||
offer(hit.distance, uuid_of(hit.id as u32));
|
||||
}
|
||||
if let Some((i, d)) = self.mob_hit() {
|
||||
offer(d, uuid_of(self.mobs[i].id));
|
||||
}
|
||||
if let Some((conn, d)) = self.guest_hit() {
|
||||
let guest = self.lan.as_ref().and_then(|h| h.hub.guest(conn));
|
||||
offer(d, guest.map(|g| uuid_ints(g.uuid)));
|
||||
}
|
||||
if let Some((i, d)) = self.redirectable_hit() {
|
||||
offer(d, uuid_of(self.creatures.projectiles[i].id));
|
||||
}
|
||||
if let Some((_, d)) = self.dragon_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
best.and_then(|b| b.1).map(uuid_text)
|
||||
}
|
||||
|
||||
/// `Entity.getPickResult` of the entity `id`: a mob's spawn egg, the item
|
||||
/// of a boat, minecart, armor stand, cushion, painting, item frame (the
|
||||
/// framed item when it holds one) or end crystal.
|
||||
|
||||
@@ -1337,10 +1337,21 @@ impl Game<'_> {
|
||||
}
|
||||
fn set_block(&mut self, pos: [i32; 3], state: i32) {
|
||||
let state = BlockStateId(state.max(0) as u32);
|
||||
let previous = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if self.world.loaded(pos[0], pos[2])
|
||||
&& self.world.set(pos[0], pos[1], pos[2], state).is_ok()
|
||||
{
|
||||
self.sync_block_entity((pos[0], pos[1], pos[2]), state);
|
||||
// LevelChunk.setBlockState removes the block entity of a block
|
||||
// that changes: the moving piston the client's moveBlocks made
|
||||
// stops when the server places what it moved (#3002).
|
||||
let facts = self.res.facts();
|
||||
let moving = |s: BlockStateId| &*facts.get(s).name == "minecraft:moving_piston";
|
||||
if moving(previous) && !moving(state) {
|
||||
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.block_ticks.moving_pistons.retain(|(p, _)| *p != at);
|
||||
self.sync_moving_pistons();
|
||||
}
|
||||
}
|
||||
// A block we just placed: play its place sound (the server skips the placer).
|
||||
if let Some((target, _)) = self.remote.as_ref().and_then(|r| r.placing)
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
//! into zombie villagers (`Zombie.killedEntity`), the golden apple that starts
|
||||
//! a cure (`ZombieVillager.mobInteract`), and the cured villager that
|
||||
//! remembers who saved it (`ZombieVillager.finishConversion`).
|
||||
use super::Game;
|
||||
use super::{Game, commands::Target};
|
||||
use horizon_client::{
|
||||
advancements as adv,
|
||||
gossip::GossipType,
|
||||
lan::playerdata,
|
||||
mobs::{Mob, Species, villager, zombie_villager},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
@@ -93,7 +94,8 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
self.consume_held();
|
||||
let starter = self.level.player_uuid;
|
||||
// `player.getUUID()`: the acting player, a server's guest included.
|
||||
let starter = Some(self.acting_player_uuid());
|
||||
let time = zombie_villager::WAIT_MIN
|
||||
+ self
|
||||
.world
|
||||
@@ -127,11 +129,15 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
convert_to_villager(&old, v);
|
||||
// `Villager.onReputationEventFrom(ZOMBIE_VILLAGER_CURED)`.
|
||||
// `level.getPlayerByUUID(conversionStarter)`: the player who fed the
|
||||
// apple, if still in this level (the host or a server's guest).
|
||||
let starter = zombie_villager::cure(&old).and_then(|c| c.starter);
|
||||
let player = self.level.player_uuid;
|
||||
let cured_by_player = starter.is_some() && starter == player;
|
||||
if cured_by_player && let Some(uuid) = starter {
|
||||
let curer = starter.and_then(|uuid| Some((uuid, self.player_here(uuid)?)));
|
||||
// `Villager.onReputationEventFrom(ZOMBIE_VILLAGER_CURED)`.
|
||||
let Some(v) = self.mobs.iter_mut().find(|m| m.id == vid) else {
|
||||
return;
|
||||
};
|
||||
if let Some((uuid, _)) = curer {
|
||||
let g = &mut villager::ext_mut(v).gossips;
|
||||
g.add(uuid, GossipType::MajorPositive, 20);
|
||||
g.add(uuid, GossipType::MinorPositive, 25);
|
||||
@@ -139,8 +145,10 @@ impl Game<'_> {
|
||||
let cured = v.clone();
|
||||
let villager_view = self.mob_entity(&cured);
|
||||
let zombie_view = self.mob_entity(&old);
|
||||
if cured_by_player {
|
||||
self.trigger_cured_zombie_villager(&zombie_view, &villager_view);
|
||||
if let Some((_, who)) = curer {
|
||||
self.for_player(who, |g| {
|
||||
g.trigger_cured_zombie_villager(&zombie_view, &villager_view)
|
||||
});
|
||||
}
|
||||
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(
|
||||
@@ -151,6 +159,18 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ServerLevel.getPlayerByUUID`: the player with `uuid` in this level.
|
||||
fn player_here(&self, uuid: [i32; 4]) -> Option<Target> {
|
||||
match self.target_by_uuid(playerdata::ints_uuid(uuid))? {
|
||||
Target::Player => Some(Target::Player),
|
||||
Target::Guest(conn) => {
|
||||
let g = self.lan.as_ref()?.hub.guest(conn)?;
|
||||
(g.dimension == self.dim.id()).then_some(Target::Guest(conn))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `CuredZombieVillagerTrigger`.
|
||||
fn trigger_cured_zombie_villager(
|
||||
&mut self,
|
||||
|
||||
@@ -90,6 +90,11 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
// Instruments are a synced registry: a goat horn names its own.
|
||||
Component::Instrument(id) => match synced.name("minecraft:instrument", *id) {
|
||||
Some(name) => Tag::String(name.into()),
|
||||
None => continue,
|
||||
},
|
||||
// BannerPatternLayers: each layer's pattern is a synced registry entry.
|
||||
Component::BannerPatterns(layers) => {
|
||||
let layers: Option<Vec<Tag>> = layers
|
||||
|
||||
@@ -128,6 +128,9 @@ impl States {
|
||||
pub fn as_slice(&self) -> &[EntityState] {
|
||||
&self.list[..self.len]
|
||||
}
|
||||
pub fn as_mut_slice(&mut self) -> &mut [EntityState] {
|
||||
&mut self.list[..self.len]
|
||||
}
|
||||
}
|
||||
|
||||
impl Extend<EntityState> for States {
|
||||
|
||||
@@ -668,6 +668,22 @@ fn network_stack(
|
||||
));
|
||||
}
|
||||
stack.added.extend(firework_components(d));
|
||||
// A goat horn's instrument, by its `minecraft:instrument` id.
|
||||
if let Some(id) = d
|
||||
.other("minecraft:instrument")
|
||||
.and_then(|t| crate::save::text(Some(t)))
|
||||
.and_then(|name| {
|
||||
synced_id(
|
||||
"minecraft:instrument",
|
||||
&horizon_world::datafix::dynamic::namespaced(&name),
|
||||
)
|
||||
})
|
||||
{
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::INSTRUMENT,
|
||||
horizon_protocol::item::Component::Instrument(id),
|
||||
));
|
||||
}
|
||||
// The compass's lodestone target.
|
||||
if let Some(t) = d.other("minecraft:lodestone_tracker") {
|
||||
let target = t.get("target").and_then(|g| {
|
||||
@@ -826,6 +842,24 @@ mod tests {
|
||||
assert_eq!(stack.component(component::TRIM), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goat_horns_send_their_instrument_by_registry_id() {
|
||||
let mut horn = ItemStack::new("minecraft:goat_horn", 1);
|
||||
horn.data_mut().other.push((
|
||||
"minecraft:instrument".into(),
|
||||
horizon_world::nbt::Tag::String("minecraft:sing_goat_horn".into()),
|
||||
));
|
||||
let synced_id = |registry: &str, name: &str| match (registry, name) {
|
||||
("minecraft:instrument", "minecraft:sing_goat_horn") => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(horn)).unwrap();
|
||||
assert_eq!(
|
||||
stack.component(component::INSTRUMENT),
|
||||
Some(&Component::Instrument(1))
|
||||
);
|
||||
}
|
||||
|
||||
fn guest_at(position: [f64; 3]) -> Guest {
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
Guest::new(
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
//! (`Entity.setAsInsidePortal` / `handlePortal`) for nether portals.
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
/// `Entity.getDimensionChangingDelay`: the portal cooldown of an entity that
|
||||
/// went through a portal.
|
||||
pub const ENTITY_DELAY: i32 = 300;
|
||||
|
||||
/// `PortalProcessor` for a nether portal.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Process {
|
||||
|
||||
@@ -2503,14 +2503,83 @@ const INT_RULES: [&str; 11] = [
|
||||
"respawn_radius",
|
||||
];
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset` that the create
|
||||
/// screen offers, in the order of `WorldPresetTags.NORMAL`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WorldPreset {
|
||||
Normal,
|
||||
Flat,
|
||||
LargeBiomes,
|
||||
Amplified,
|
||||
SingleBiomeSurface,
|
||||
}
|
||||
impl WorldPreset {
|
||||
/// The presets, in the order the world-type button cycles through them.
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Normal,
|
||||
Self::Flat,
|
||||
Self::LargeBiomes,
|
||||
Self::Amplified,
|
||||
Self::SingleBiomeSurface,
|
||||
];
|
||||
/// The preset's registry id (`minecraft:normal`).
|
||||
pub const fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "minecraft:normal",
|
||||
Self::Flat => "minecraft:flat",
|
||||
Self::LargeBiomes => "minecraft:large_biomes",
|
||||
Self::Amplified => "minecraft:amplified",
|
||||
Self::SingleBiomeSurface => "minecraft:single_biome_surface",
|
||||
}
|
||||
}
|
||||
/// The preset an id names, if it is one of them.
|
||||
pub fn of_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|preset| preset.id() == id)
|
||||
}
|
||||
/// The language key the world-type button shows
|
||||
/// (`Identifier.toLanguageKey("generator")`).
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "generator.minecraft.normal",
|
||||
Self::Flat => "generator.minecraft.flat",
|
||||
Self::LargeBiomes => "generator.minecraft.large_biomes",
|
||||
Self::Amplified => "generator.minecraft.amplified",
|
||||
Self::SingleBiomeSurface => "generator.minecraft.single_biome_surface",
|
||||
}
|
||||
}
|
||||
/// The preset the world-type button cycles to next.
|
||||
pub fn next(self) -> Self {
|
||||
let index = Self::ALL
|
||||
.iter()
|
||||
.position(|preset| *preset == self)
|
||||
.unwrap_or(0);
|
||||
Self::ALL[(index + 1) % Self::ALL.len()]
|
||||
}
|
||||
/// The preset a saved overworld generator is: the noise settings name
|
||||
/// the preset, and the single-biome preset shares the overworld's noise
|
||||
/// settings, so its fixed biome source tells the two apart.
|
||||
pub fn of_generator(generator: &Tag) -> Self {
|
||||
if text(generator.get("type")).as_deref() == Some("minecraft:flat") {
|
||||
return Self::Flat;
|
||||
}
|
||||
let source = generator.get("biome_source");
|
||||
let fixed = text(source.and_then(|s| s.get("type"))).as_deref() == Some("minecraft:fixed");
|
||||
match text(generator.get("settings")).as_deref() {
|
||||
Some("minecraft:large_biomes") => Self::LargeBiomes,
|
||||
Some("minecraft:amplified") => Self::Amplified,
|
||||
Some("minecraft:overworld") if fixed => Self::SingleBiomeSurface,
|
||||
_ => Self::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// World and player state stored in `level.dat`, the saved-data files and
|
||||
/// the player file.
|
||||
/// The world options CreateWorldScreen sets (`WorldOptions` and the world
|
||||
/// preset) that `world_gen_settings` keeps.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WorldGen {
|
||||
/// The `minecraft:flat` preset (Superflat) instead of `normal`.
|
||||
pub flat: bool,
|
||||
/// The world preset the world is created with.
|
||||
pub preset: WorldPreset,
|
||||
pub structures: bool,
|
||||
pub bonus_chest: bool,
|
||||
}
|
||||
@@ -2524,8 +2593,7 @@ impl WorldGen {
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"));
|
||||
Some(Self {
|
||||
flat: text(generator.and_then(|g| g.get("type")))
|
||||
.is_some_and(|t| t.trim_start_matches("minecraft:") == "flat"),
|
||||
preset: generator.map_or(WorldPreset::Normal, WorldPreset::of_generator),
|
||||
structures: flag("generate_structures", true),
|
||||
bonus_chest: flag("bonus_chest", false),
|
||||
})
|
||||
@@ -2558,7 +2626,7 @@ impl WorldGen {
|
||||
impl Default for WorldGen {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
flat: false,
|
||||
preset: WorldPreset::Normal,
|
||||
structures: true,
|
||||
bonus_chest: false,
|
||||
}
|
||||
@@ -2633,6 +2701,34 @@ pub fn write_world_gen_settings_with(
|
||||
("preset", string(p)),
|
||||
])
|
||||
};
|
||||
// The overworld generator of worldgen/world_preset/<preset>.json: the
|
||||
// noise settings and the biome source each preset uses.
|
||||
let overworld = match options.preset {
|
||||
WorldPreset::Flat => flat,
|
||||
WorldPreset::LargeBiomes => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:large_biomes",
|
||||
preset("minecraft:overworld"),
|
||||
),
|
||||
WorldPreset::Amplified => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:amplified",
|
||||
preset("minecraft:overworld"),
|
||||
),
|
||||
WorldPreset::SingleBiomeSurface => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:overworld",
|
||||
Tag::compound([
|
||||
("type", string("minecraft:fixed")),
|
||||
("biome", string("minecraft:plains")),
|
||||
]),
|
||||
),
|
||||
WorldPreset::Normal => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:overworld",
|
||||
preset("minecraft:overworld"),
|
||||
),
|
||||
};
|
||||
write_saved_data(
|
||||
dir,
|
||||
"world_gen_settings",
|
||||
@@ -2646,18 +2742,7 @@ pub fn write_world_gen_settings_with(
|
||||
(
|
||||
"dimensions",
|
||||
Tag::compound([
|
||||
(
|
||||
"minecraft:overworld",
|
||||
if options.flat {
|
||||
flat
|
||||
} else {
|
||||
dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:overworld",
|
||||
preset("minecraft:overworld"),
|
||||
)
|
||||
},
|
||||
),
|
||||
("minecraft:overworld", overworld),
|
||||
(
|
||||
"minecraft:the_nether",
|
||||
dimension(
|
||||
|
||||
@@ -399,7 +399,7 @@ impl Title {
|
||||
} = mode
|
||||
{
|
||||
if apply {
|
||||
options.flat = true;
|
||||
options.world_type = crate::save::WorldPreset::Flat;
|
||||
options.flat_settings = Some(flat.settings);
|
||||
}
|
||||
self.mode = Mode::Create {
|
||||
|
||||
@@ -30,8 +30,8 @@ pub struct WorldOptions {
|
||||
pub difficulty: usize,
|
||||
/// Allow Cheats as toggled; unset follows the game mode.
|
||||
pub commands: Option<bool>,
|
||||
/// The Superflat world preset instead of Default.
|
||||
pub flat: bool,
|
||||
/// The world preset the world is created with.
|
||||
pub world_type: crate::save::WorldPreset,
|
||||
/// CreateFlatWorldScreen's layers and biome, kept with the world type.
|
||||
pub flat_settings: Option<super::flat::FlatSettings>,
|
||||
pub structures: bool,
|
||||
@@ -46,7 +46,7 @@ impl Default for WorldOptions {
|
||||
hardcore: false,
|
||||
difficulty: 2,
|
||||
commands: None,
|
||||
flat: false,
|
||||
world_type: crate::save::WorldPreset::Normal,
|
||||
flat_settings: None,
|
||||
structures: true,
|
||||
bonus_chest: false,
|
||||
@@ -69,9 +69,8 @@ impl WorldOptions {
|
||||
.position(|d| *d == level.difficulty)
|
||||
.unwrap_or(2),
|
||||
commands: Some(commands),
|
||||
flat: generation.flat,
|
||||
flat_settings: generation
|
||||
.flat
|
||||
world_type: generation.preset,
|
||||
flat_settings: (generation.preset == crate::save::WorldPreset::Flat)
|
||||
.then(|| super::flat::FlatSettings::from_world(dir))
|
||||
.flatten(),
|
||||
structures: generation.structures,
|
||||
@@ -94,7 +93,7 @@ impl WorldOptions {
|
||||
}
|
||||
pub fn world_gen(&self) -> crate::save::WorldGen {
|
||||
crate::save::WorldGen {
|
||||
flat: self.flat,
|
||||
preset: self.world_type,
|
||||
structures: self.structures,
|
||||
bonus_chest: self.bonus_chest(),
|
||||
}
|
||||
@@ -119,8 +118,8 @@ impl WorldOptions {
|
||||
23 => self.difficulty = (self.difficulty + 1) % DIFFICULTIES.len(),
|
||||
24 => self.commands = Some(!self.commands(*creative)),
|
||||
25 => {
|
||||
self.flat = !self.flat;
|
||||
if self.flat {
|
||||
self.world_type = self.world_type.next();
|
||||
if self.world_type == crate::save::WorldPreset::Flat {
|
||||
// setWorldType starts the generator from the flat preset.
|
||||
self.flat_settings = Some(super::flat::FlatSettings::default());
|
||||
}
|
||||
@@ -255,14 +254,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
|
||||
gx,
|
||||
gy,
|
||||
150.0,
|
||||
labelled(
|
||||
"selectWorld.mapType",
|
||||
tr(if o.flat {
|
||||
"generator.minecraft.flat"
|
||||
} else {
|
||||
"generator.minecraft.normal"
|
||||
}),
|
||||
),
|
||||
labelled("selectWorld.mapType", tr(o.world_type.label())),
|
||||
true,
|
||||
25,
|
||||
),
|
||||
@@ -272,7 +264,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
|
||||
gy,
|
||||
150.0,
|
||||
tr("selectWorld.customizeType"),
|
||||
o.flat,
|
||||
o.world_type == crate::save::WorldPreset::Flat,
|
||||
26,
|
||||
),
|
||||
// SwitchGrid: 44-wide value-only buttons at the grid's right.
|
||||
|
||||
@@ -797,27 +797,49 @@ fn create_world_tabs_follow_vanilla_and_their_choices_reach_the_action() {
|
||||
};
|
||||
assert!(*creative && !options.hardcore && options.commands(true));
|
||||
// The World tab (second of three 124-wide tabs from x 54): its seed box
|
||||
// takes the focus, Superflat and the switches toggle.
|
||||
// takes the focus, the world type cycles and the switches toggle.
|
||||
let (action, sound) = f.title.click(54.0 + 124.0 + 10.0, 10.0, size);
|
||||
assert!(sound && action == Action::None);
|
||||
let world = labels(&f);
|
||||
assert_eq!((world[0].1, world[0].2), (85.0, 38.0));
|
||||
assert_eq!((world[2].1, world[2].2), (85.0 + 266.0, 38.0 + 73.0));
|
||||
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
|
||||
// The world-type button cycles through vanilla's world presets.
|
||||
let world_type = |f: &Fixture| -> crate::save::WorldPreset {
|
||||
let Mode::Create { options, .. } = &f.title.mode else {
|
||||
panic!()
|
||||
};
|
||||
options.world_type
|
||||
};
|
||||
assert_eq!(world_type(&f), crate::save::WorldPreset::Normal);
|
||||
for expected in [
|
||||
crate::save::WorldPreset::Flat,
|
||||
crate::save::WorldPreset::LargeBiomes,
|
||||
crate::save::WorldPreset::Amplified,
|
||||
crate::save::WorldPreset::SingleBiomeSurface,
|
||||
crate::save::WorldPreset::Normal,
|
||||
] {
|
||||
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
|
||||
assert_eq!(world_type(&f), expected);
|
||||
}
|
||||
// The amplified preset is the third one.
|
||||
for _ in 0..3 {
|
||||
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
|
||||
}
|
||||
f.title.click(world[3].1 + 10.0, world[3].2 + 10.0, size);
|
||||
f.title.click(100.0, 38.0 + 41.0 + 10.0, size);
|
||||
let Mode::Create { focus, options, .. } = &f.title.mode else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(*focus, 1);
|
||||
assert!(options.flat && options.bonus_chest());
|
||||
assert_eq!(options.world_type, crate::save::WorldPreset::Amplified);
|
||||
assert!(options.bonus_chest());
|
||||
let Action::Create { options, .. } = f.title.create_action() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
options.world_gen(),
|
||||
crate::save::WorldGen {
|
||||
flat: true,
|
||||
preset: crate::save::WorldPreset::Amplified,
|
||||
structures: true,
|
||||
bonus_chest: true
|
||||
}
|
||||
@@ -835,26 +857,86 @@ fn new_world_generation_settings_round_trip() {
|
||||
let f = Fixture::new();
|
||||
let dir = f.root.join("flat");
|
||||
let options = crate::save::WorldGen {
|
||||
flat: true,
|
||||
preset: crate::save::WorldPreset::Flat,
|
||||
structures: false,
|
||||
bonus_chest: true,
|
||||
};
|
||||
crate::save::write_world_gen_settings(&dir, 42, options).unwrap();
|
||||
assert_eq!(crate::save::WorldGen::read(&dir), Some(options));
|
||||
let settings = crate::save::world_gen_settings(&dir).unwrap();
|
||||
let generator = settings
|
||||
.get("dimensions")
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"));
|
||||
assert_eq!(
|
||||
crate::save::text(
|
||||
settings
|
||||
.get("dimensions")
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"))
|
||||
.and_then(|g| g.get("type"))
|
||||
)
|
||||
.as_deref(),
|
||||
crate::save::text(generator.and_then(|g| g.get("type"))).as_deref(),
|
||||
Some("minecraft:flat")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_world_preset_writes_its_own_generator() {
|
||||
let f = Fixture::new();
|
||||
let generator = |preset: crate::save::WorldPreset| {
|
||||
let dir = f.root.join(preset.id());
|
||||
let options = crate::save::WorldGen {
|
||||
preset,
|
||||
structures: true,
|
||||
bonus_chest: false,
|
||||
};
|
||||
crate::save::write_world_gen_settings(&dir, 42, options).unwrap();
|
||||
assert_eq!(crate::save::WorldGen::read(&dir), Some(options));
|
||||
let settings = crate::save::world_gen_settings(&dir).unwrap();
|
||||
let generator = settings
|
||||
.get("dimensions")
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
(
|
||||
crate::save::text(generator.get("type")),
|
||||
crate::save::text(generator.get("settings")),
|
||||
crate::save::text(generator.get("biome_source").and_then(|s| s.get("type"))),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::Normal),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:overworld".to_owned()),
|
||||
Some("minecraft:multi_noise".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::Amplified),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:amplified".to_owned()),
|
||||
Some("minecraft:multi_noise".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::LargeBiomes),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:large_biomes".to_owned()),
|
||||
Some("minecraft:multi_noise".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::SingleBiomeSurface),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:overworld".to_owned()),
|
||||
Some("minecraft:fixed".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::Flat).0,
|
||||
Some("minecraft:flat".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_fields_edit_at_their_cursor() {
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
@@ -1662,10 +1662,15 @@ impl ArgType {
|
||||
let _ = context;
|
||||
match self {
|
||||
ArgType::Bool => builder.suggest_matching(["true", "false"]),
|
||||
ArgType::Entity { .. } => {
|
||||
let players = reg.players.clone();
|
||||
ArgType::Entity { players, .. } => {
|
||||
// EntityArgument.listSuggestions: the online player names,
|
||||
// then (unless players only) getSelectedEntities.
|
||||
let mut names = reg.players.clone();
|
||||
if !players {
|
||||
names.extend(reg.selected_entities.iter().cloned());
|
||||
}
|
||||
super::selector::suggest(builder, reg, &|b| {
|
||||
b.suggest_matching(players.iter().map(String::as_str))
|
||||
b.suggest_matching(names.iter().map(String::as_str))
|
||||
});
|
||||
}
|
||||
ArgType::BlockPos => suggest_coordinates(builder, reg, self, true),
|
||||
|
||||
@@ -267,10 +267,11 @@ impl<'r, 'a> SelectorParser<'r, 'a> {
|
||||
}
|
||||
let start = self.reader.cursor;
|
||||
let name = self.reader.read_string()?;
|
||||
// A UUID selects by entityUUID alone: findEntities looks a
|
||||
// playerName up first, among the players only.
|
||||
if let Some(uuid) = java_uuid(&name) {
|
||||
self.s.uuid = Some(uuid);
|
||||
self.s.include_entities = true;
|
||||
self.s.player_name = Some(name);
|
||||
} else {
|
||||
if name.is_empty() || name.encode_utf16().count() > 16 {
|
||||
self.reader.cursor = start;
|
||||
|
||||
@@ -257,6 +257,61 @@ fn suggestions_like_vanilla() {
|
||||
assert!(suggestions("time set ").contains(&"minecraft:day".to_owned()));
|
||||
}
|
||||
#[test]
|
||||
fn entity_arguments_suggest_the_selected_entity() {
|
||||
// EntityArgument.listSuggestions: getSelectedEntities (the crosshair
|
||||
// entity's UUID) joins the player names unless the argument takes
|
||||
// players only; GameProfileArgument and ScoreHolderArgument never ask.
|
||||
let uuid = "0b9c6a7e-3d1f-4a2b-9c8d-7e6f5a4b3c2d";
|
||||
let mut reg = reg();
|
||||
reg.selected_entities = vec![uuid.into()];
|
||||
let d = vanilla::dispatcher();
|
||||
let suggest = |input: &str| -> Vec<String> {
|
||||
let parse = d.parse(input, ®);
|
||||
d.suggestions(&parse, input.len(), ®)
|
||||
.1
|
||||
.into_iter()
|
||||
.map(|s| s.text)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
suggest("tp "),
|
||||
[
|
||||
"0b9c6a7e-3d1f-4a2b-9c8d-7e6f5a4b3c2d",
|
||||
"@a",
|
||||
"@e",
|
||||
"@n",
|
||||
"@p",
|
||||
"@r",
|
||||
"@s",
|
||||
"Player",
|
||||
"~",
|
||||
"~ ~",
|
||||
"~ ~ ~"
|
||||
]
|
||||
);
|
||||
assert!(suggest("kill ").contains(&uuid.to_owned()));
|
||||
assert_eq!(suggest("kill 0b9"), [uuid]);
|
||||
for players_only in ["gamemode creative ", "op ", "scoreboard players set "] {
|
||||
assert!(
|
||||
!suggest(players_only).contains(&uuid.to_owned()),
|
||||
"{players_only}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_uuid_selects_by_entity_uuid() {
|
||||
// EntitySelectorParser.parseNameOrUUID: a UUID sets entityUUID and
|
||||
// includesEntities, never playerName (which findEntities checks first).
|
||||
let reg = reg();
|
||||
let mut r = Reader::new("2de5a9ad-2740-4058-835e-cf255e0d7798");
|
||||
let s = selector::parse(&mut r, ®, true).unwrap();
|
||||
assert_eq!(s.uuid, Some(0x2de5a9ad_2740_4058_835e_cf255e0d7798));
|
||||
assert!(s.include_entities && s.player_name.is_none());
|
||||
let mut r = Reader::new("Steve");
|
||||
let s = selector::parse(&mut r, ®, true).unwrap();
|
||||
assert_eq!((s.uuid, s.player_name.as_deref()), (None, Some("Steve")));
|
||||
}
|
||||
#[test]
|
||||
fn smart_usage_matches_brigadier() {
|
||||
let d = vanilla::dispatcher();
|
||||
let usage = |name: &str| {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `item::Slot` and prints, per stack, whether every byte was consumed, the
|
||||
//! patch's component ids (added, then removed), which of them Horizon
|
||||
//! decoded into its own lossy types (the rest it reads past keeping their
|
||||
//! bytes, or decodes into ids it writes back unchanged, like trims) and
|
||||
//! bytes, or decodes into ids it writes back unchanged, like trims and instruments) and
|
||||
//! whether encoding the decoded stack gives the same bytes back.
|
||||
//!
|
||||
//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl
|
||||
@@ -46,7 +46,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let typed: Vec<i32> = stack
|
||||
.iter()
|
||||
.flat_map(|s| &s.added)
|
||||
.filter(|(_, c)| !matches!(c, Component::Raw(_) | Component::Trim { .. }))
|
||||
.filter(|(_, c)| {
|
||||
!matches!(
|
||||
c,
|
||||
Component::Raw(_) | Component::Trim { .. } | Component::Instrument(_)
|
||||
)
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
json!({
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod component {
|
||||
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
|
||||
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
|
||||
pub const TRIM: i32 = 58;
|
||||
pub const INSTRUMENT: i32 = 63;
|
||||
pub const LODESTONE_TRACKER: i32 = 69;
|
||||
pub const FIREWORK_EXPLOSION: i32 = 70;
|
||||
pub const FIREWORKS: i32 = 71;
|
||||
@@ -117,6 +118,9 @@ pub enum Component {
|
||||
material: i32,
|
||||
pattern: i32,
|
||||
},
|
||||
/// `InstrumentComponent` naming its instrument by `minecraft:instrument`
|
||||
/// registry id; a direct instrument stays [`Component::Raw`].
|
||||
Instrument(i32),
|
||||
/// `BannerPatternLayers`: each layer's `banner_pattern` registry id and
|
||||
/// its dye id (`DyeColor.getId`). A layer with a direct pattern has no
|
||||
/// registry name: that value stays [`Component::Raw`].
|
||||
@@ -358,6 +362,18 @@ impl Component {
|
||||
_ => Self::Raw(bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
// InstrumentComponent.STREAM_CODEC: a holder, a reference's id plus one.
|
||||
// A direct instrument has no registry name: read past.
|
||||
INSTRUMENT => {
|
||||
let start = r.position();
|
||||
skip::component(INSTRUMENT, r)?;
|
||||
let bytes = r.since(start);
|
||||
let mut ids = Reader::new(bytes);
|
||||
match ids.varint() {
|
||||
Ok(id @ 1..) if ids.remaining() == 0 => Self::Instrument(id - 1),
|
||||
_ => Self::Raw(bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
// DyeColor.STREAM_CODEC: its id.
|
||||
BASE_COLOR => Self::Int(r.varint()?),
|
||||
// BannerPatternLayers.STREAM_CODEC: a list of a pattern holder (a
|
||||
@@ -408,6 +424,7 @@ impl Component {
|
||||
w.varint(material + 1);
|
||||
w.varint(pattern + 1);
|
||||
}
|
||||
Self::Instrument(id) => w.varint(id + 1),
|
||||
Self::Int(v) if kind == component::DYED_COLOR => w.i32(*v),
|
||||
Self::Int(v) => w.varint(*v),
|
||||
Self::Unit => {}
|
||||
@@ -1027,6 +1044,8 @@ mod tests {
|
||||
("01890b01004a02200e000b6d696e6563726166743a7801790b", 74),
|
||||
("01880301004c01f60801000001be0c0100000001d30c010000", 76),
|
||||
("01a408010037020f6412a001", 55),
|
||||
// A goat horn's instrument by reference (InstrumentComponent's holder).
|
||||
("01bd0801003f02", 63),
|
||||
] {
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
@@ -1042,6 +1061,7 @@ mod tests {
|
||||
pattern: 1,
|
||||
}
|
||||
}
|
||||
63 => *c == Component::Instrument(1),
|
||||
_ => matches!(c, Component::Raw(_)),
|
||||
};
|
||||
assert!(
|
||||
|
||||
@@ -87,6 +87,13 @@ impl Ticks {
|
||||
self.blocks.add_chunk(pos, blocks)?;
|
||||
self.fluids.add_chunk(pos, fluids)
|
||||
}
|
||||
/// A moving-piston block entity loaded with its chunk, as a new ticker.
|
||||
pub fn load_moving_piston(&mut self, pos: BlockPos, mut piston: super::pistons::MovingPiston) {
|
||||
self.moving_pistons.retain(|(p, _)| *p != pos);
|
||||
self.next_block_entity_id += 1;
|
||||
piston.id = self.next_block_entity_id;
|
||||
self.moving_pistons.push((pos, piston));
|
||||
}
|
||||
/// Register a chunk without saved ticks.
|
||||
pub fn add_empty_chunk(&mut self, pos: ChunkPos) -> Result<(), TickError> {
|
||||
self.add_chunk(pos, ChunkTicks::default(), ChunkTicks::default())
|
||||
|
||||
@@ -3322,4 +3322,56 @@ mod tests {
|
||||
assert!(Arc::ptr_eq(first, hit));
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources the worldgen tests read.
|
||||
fn test_root() -> std::path::PathBuf {
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.or_else(|| std::env::var_os("HORIZON_RESOURCES"))
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3")
|
||||
})
|
||||
}
|
||||
/// The block registry the worldgen bundle needs. `GeneratorData` keeps it
|
||||
/// for as long as the generator lives, so it is shared, not freed.
|
||||
static TEST_REGISTRY: std::sync::LazyLock<BlockRegistry> = std::sync::LazyLock::new(|| {
|
||||
crate::level::data::load_registry(&test_root()).expect("registry")
|
||||
});
|
||||
|
||||
/// The world presets with their own noise settings generate the terrain
|
||||
/// those settings describe: the amplified and large-biome presets are not
|
||||
/// the overworld's terrain under another name.
|
||||
#[test]
|
||||
fn world_presets_use_their_own_noise_settings() {
|
||||
let root = test_root();
|
||||
if !root.join("worldgen/world_presets.json").exists() {
|
||||
eprintln!("skipping: no worldgen bundle at {}", root.display());
|
||||
return;
|
||||
}
|
||||
let data =
|
||||
GeneratorData::load(&root.join("worldgen"), &TEST_REGISTRY).expect("worldgen data");
|
||||
let surface = |preset: &str| {
|
||||
let generator = OverworldGenerator::for_world(
|
||||
data.clone(),
|
||||
1234,
|
||||
preset,
|
||||
"minecraft:overworld",
|
||||
false,
|
||||
)
|
||||
.expect("generator");
|
||||
let mut worker = generator.worker();
|
||||
(0..32)
|
||||
.map(|i| {
|
||||
worker
|
||||
.surface_sample(i * 137, i * 91)
|
||||
.expect("surface")
|
||||
.height
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let normal = surface("minecraft:normal");
|
||||
assert!(normal.iter().all(|height| (0..384).contains(height)));
|
||||
assert_ne!(normal, surface("minecraft:amplified"));
|
||||
assert_ne!(normal, surface("minecraft:large_biomes"));
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ public final class VanillaChatScreenOracle {
|
||||
private Provider() { super(null, null, null); }
|
||||
@Override public Collection<String> getOnlinePlayerNames() { return new ArrayList<>(List.of(VanillaCommandParseOracle.PLAYERS)); }
|
||||
@Override public Collection<String> getCustomTabSuggestions() { return getOnlinePlayerNames(); }
|
||||
@Override public Collection<String> getSelectedEntities() { return List.of(); }
|
||||
@Override public Collection<String> getSelectedEntities() { return new ArrayList<>(List.of(VanillaCommandParseOracle.SELECTED)); }
|
||||
@Override public Collection<String> getAllTeams() { return new ArrayList<>(List.of(VanillaCommandParseOracle.TEAMS)); }
|
||||
@Override public java.util.stream.Stream<Identifier> getAvailableSounds() { return BuiltInRegistries.SOUND_EVENT.keySet().stream(); }
|
||||
@Override public java.util.stream.Stream<Identifier> getAvailablePostEffects() { return source.getAvailablePostEffects(); }
|
||||
|
||||
@@ -67,6 +67,8 @@ import net.minecraft.world.phys.Vec3;
|
||||
public final class VanillaCommandParseOracle {
|
||||
static sun.misc.Unsafe unsafe;
|
||||
static final String[] PLAYERS = {"Player", "Steve", "Alex"};
|
||||
/** ClientSuggestionProvider.getSelectedEntities: the crosshair entity's UUID. */
|
||||
static final String[] SELECTED = {"0b9c6a7e-3d1f-4a2b-9c8d-7e6f5a4b3c2d"};
|
||||
static final String[] TEAMS = {"red", "blue"};
|
||||
static final String[] OBJECTIVES = {"kills", "health", "obj"};
|
||||
static final String[] TRACKED = {"#total", "Steve", "fake"};
|
||||
@@ -83,6 +85,7 @@ public final class VanillaCommandParseOracle {
|
||||
this.lookup = lookup;
|
||||
}
|
||||
@Override public Collection<String> getOnlinePlayerNames() { return new ArrayList<>(List.of(PLAYERS)); }
|
||||
@Override public Collection<String> getSelectedEntities() { return new ArrayList<>(List.of(SELECTED)); }
|
||||
@Override public Collection<String> getAllTeams() { return new ArrayList<>(List.of(TEAMS)); }
|
||||
@Override public RegistryAccess registryAccess() { return access; }
|
||||
@Override public Set<ResourceKey<Level>> levels() { return Set.of(Level.OVERWORLD, Level.NETHER, Level.END); }
|
||||
@@ -374,6 +377,7 @@ public final class VanillaCommandParseOracle {
|
||||
o.add("timelines", dynamic(access, Registries.TIMELINE));
|
||||
o.add("dimensions", arr("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"));
|
||||
o.add("players", arr(PLAYERS));
|
||||
o.add("selected_entities", arr(SELECTED));
|
||||
o.add("teams", arr(TEAMS));
|
||||
o.add("objectives", arr(OBJECTIVES));
|
||||
o.add("tracked", arr(TRACKED));
|
||||
|
||||
+105
-2
@@ -38,7 +38,7 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
respawned player is a new entity to the others); the host dies, and on the server thread
|
||||
the guest too;
|
||||
- hurt: a pig the guest hits, and one the host hits, flashes red on the other side too (the
|
||||
server's damage event) (#848);
|
||||
server's damage event) (#848), and so does a hurt host or guest (#2827);
|
||||
- containers: the guest's ender chest holds the guest's items, not the host's, and a double chest
|
||||
opens as 9x6 with the right half first;
|
||||
- machines: a hopper, a dispenser and a dropper open on the guest's screen with their 5 and 9
|
||||
@@ -262,6 +262,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
sends each target its packet).
|
||||
- hero-trading: the host and the guest each trade 20 wheat for an emerald, paying 14 with Hero I
|
||||
and 12 with Hero III; clearing the effect restores the ordinary price (#1365).
|
||||
- cure-discount: the host and the guest each cure a zombie villager with a golden apple and pay 14
|
||||
wheat instead of 20 for its emerald (the ZOMBIE_VILLAGER_CURED gossip, #3306).
|
||||
- falling: an item, a falling block and primed TNT the host summons high up fall smoothly on the
|
||||
guest, a little further every tick between the server's updates (every 20 ticks, 10 for
|
||||
TNT), as their own client tick moves them (they used to hang, then jump down once a
|
||||
@@ -1354,9 +1356,29 @@ def check_hurt(host, guest, out):
|
||||
watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen')
|
||||
attacker.wait(20)
|
||||
host('command', command='kill @e[type=minecraft:pig]')
|
||||
# A hurt player flashes red for the other one too (#2827).
|
||||
# An in-frame host draws its guests as models of its own, not entities: only the screenshot.
|
||||
watched = ((('host hurt', host, guest),) if not SERVER_THREAD else
|
||||
(('host hurt', host, guest), ('guest hurt', guest, host)))
|
||||
for name, hurt, watcher in watched:
|
||||
hurt.wait(30)
|
||||
# Instant damage hurts on the next effect tick.
|
||||
hurt('command', command='effect give @s minecraft:instant_damage 1 0')
|
||||
for _ in range(10):
|
||||
other = watcher.raw('entities', 6, 'player')['entities'][0]
|
||||
if other['hurt_time'] > 0:
|
||||
break
|
||||
watcher.wait(1)
|
||||
seen[name] = {'watcher': other['hurt_time']}
|
||||
watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen')
|
||||
if not SERVER_THREAD:
|
||||
guest.wait(30)
|
||||
guest('command', command='effect give @s minecraft:instant_damage 1 0')
|
||||
guest.wait(2)
|
||||
host.shot(out, 'hurt-guest-hurt-seen')
|
||||
# LivingEntity.hurtTime: 10 when hurt, one less each tick; the reads come a few ticks later.
|
||||
for name, sides in seen.items():
|
||||
assert all(t > 0 for t in sides.values()), f'{name}: the pig\'s hurt flash {seen}'
|
||||
assert all(t > 0 for t in sides.values()), f'{name}: the hurt flash {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
@@ -6532,6 +6554,87 @@ CHECKS += ['hero-trading']
|
||||
CHECK_FNS.update({'hero-trading': check_hero_trading})
|
||||
|
||||
|
||||
def check_cure_discount(host, guest, out):
|
||||
"""#3306: ZombieVillager.finishConversion credits the player who fed the golden apple
|
||||
(conversionStarter, level.getPlayerByUUID): the ZOMBIE_VILLAGER_CURED gossip (+20 major and +25
|
||||
minor positive, reputation 125) lowers that player's price by floor(125 * 0.05) = 6, for the
|
||||
host on its server thread and for a guest alike."""
|
||||
x, y, z = (int(v // 1) for v in pos_of(host))
|
||||
tag = 'cure_trade'
|
||||
at = (x + 2.5, y, z + 0.5)
|
||||
seen = {}
|
||||
try:
|
||||
host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
|
||||
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
|
||||
host('command', command='time set midnight')
|
||||
for game, other in ((host, guest), (guest, host)):
|
||||
back_to_game(game)
|
||||
back_to_game(other)
|
||||
other('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}')
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='effect clear @s')
|
||||
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} -90 0')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='give @s minecraft:golden_apple')
|
||||
game.wait(10)
|
||||
game.raw('select', 'minecraft:golden_apple')
|
||||
host('command', command=f'summon minecraft:zombie_villager {at[0]} {at[1]} {at[2]} '
|
||||
f'{{Tags:["{tag}"],NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{{type:"minecraft:plains",'
|
||||
'profession:"minecraft:farmer",level:1},Offers:{Recipes:[{buy:{id:"minecraft:wheat",count:20},'
|
||||
'sell:{id:"minecraft:emerald",count:1},maxUses:16,uses:0,xp:0,priceMultiplier:0.05f,demand:0}]}}')
|
||||
host('command', command=f'effect give @e[tag={tag}] minecraft:weakness 60 0')
|
||||
game.wait(20)
|
||||
zombie = next(e['id'] for e in game.raw('entities', 8, 'minecraft:zombie_villager')['entities']
|
||||
if math.dist(e['pos'], at) < 0.5)
|
||||
# ZombieVillager.mobInteract: the apple starts the cure; then it is cut short.
|
||||
game.raw('interact-entity', zombie)
|
||||
game.wait(10)
|
||||
# Horizon counts the cure down in the AI step, so NoAI goes for the last tick.
|
||||
host('command', command=f'data merge entity @e[tag={tag},limit=1] {{ConversionTime:1,NoAI:0b}}')
|
||||
villager = None
|
||||
for _ in range(30):
|
||||
game.wait(5)
|
||||
villager = next((e['id'] for e in game.raw('entities', 8, 'minecraft:villager')['entities']
|
||||
if math.dist(e['pos'], at) < 2), None)
|
||||
if villager is not None:
|
||||
break
|
||||
assert villager is not None, f'{game.name}: the zombie villager was not cured'
|
||||
near = f'x={at[0]},y={at[1]},z={at[2]},distance=..3,sort=nearest,limit=1'
|
||||
host('command', command=f'data merge entity @e[type=minecraft:villager,{near}] '
|
||||
f'{{NoAI:1b,Tags:["{tag}"]}}')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='give @s minecraft:wheat 20')
|
||||
game.wait(10)
|
||||
game.raw('interact-entity', villager)
|
||||
game.raw('wait-for', 'screen', 'station', '--timeout', 10)
|
||||
assert game('screen')['station'] == 'Merchant', f'{game.name}: no merchant screen'
|
||||
game.raw('click-gui', -1, 28)
|
||||
game.wait(10)
|
||||
assert station_slots(game).get(650) == ('minecraft:emerald', 1), f'{game.name}: no result'
|
||||
game.shot(out, f'cure-discount-{game.name}')
|
||||
game.raw('click-slot', 650, 'shift')
|
||||
game.wait(10)
|
||||
game.raw('key', 'esc', 'tap')
|
||||
game.wait(10)
|
||||
case = {item: server_count(game, item) for item in ('wheat', 'emerald')}
|
||||
seen[game.name] = case
|
||||
want = {'wheat': 20 - 14, 'emerald': 1}
|
||||
assert case == want, f'{game.name}: expected {want}, got {case}'
|
||||
host('command', command=f'kill @e[tag={tag}]')
|
||||
game.wait(5)
|
||||
finally:
|
||||
for game in (host, guest):
|
||||
back_to_game(game)
|
||||
game('command', command='clear @s')
|
||||
host('command', command=f'kill @e[tag={tag}]')
|
||||
host('command', command='time set day')
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['cure-discount']
|
||||
CHECK_FNS.update({'cure-discount': check_cure_discount})
|
||||
|
||||
|
||||
def check_spectator_travel(host, guest, out):
|
||||
"""Player.tick's spectator noPhysics survives a replacement body after dimension travel (#1299)."""
|
||||
players = (host, guest) if SERVER_THREAD else (host,)
|
||||
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check #1518: mobs and dropped items go through an End gateway.
|
||||
|
||||
Build horizon-client and horizon-ctl with the fast profile first, then run:
|
||||
python3 tools/singleplayer_end_cargo_audit.py --case gateway
|
||||
Runs the in-frame singleplayer (and the server-thread one with --mode server):
|
||||
a cow and a seven-diamond stack stand in a gateway in the End, and both are
|
||||
expected to leave it and land at the gateway's exit, the stack with its seven
|
||||
diamonds.
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from agent_control_e2e import Cli
|
||||
import lab
|
||||
|
||||
|
||||
# The gateway beside the End's spawn platform, where the player waits.
|
||||
GATEWAY = (104, 50, 0)
|
||||
# The diamonds the dropped stack carries.
|
||||
DIAMONDS = 7
|
||||
|
||||
|
||||
def data_get(cli, selector, path):
|
||||
"""The chat line of a `/data get entity`."""
|
||||
since = cli('events')['last']
|
||||
cli('command', command=f'data get entity {selector} {path}')
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
reply = cli('events', since=since)
|
||||
for event in reply['events']:
|
||||
text = event.get('text', '')
|
||||
if event.get('event') == 'chat' and ('entity data' in text or 'No entity was found' in text):
|
||||
return text
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f'no answer for /data get entity {selector} {path}')
|
||||
|
||||
|
||||
def numbers(text):
|
||||
"""The numbers of a `/data get` answer: "Pos: [104.5, 50.0, 0.5]"."""
|
||||
return [float(v) for v in re.findall(r'-?\d+(?:\.\d+)?', text.split(':', 1)[-1])]
|
||||
|
||||
|
||||
def run(bin_dir, resources, in_frame):
|
||||
with tempfile.TemporaryDirectory(prefix='hz-end-gateway-') as scratch:
|
||||
work = Path(scratch)
|
||||
env = lab.game_env({k: v for k, v in os.environ.items() if k not in (
|
||||
'HORIZON_HOME', 'HORIZON_LAUNCHER_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME')})
|
||||
home = Path(env['HORIZON_HOME'])
|
||||
socket = work / 'game.sock'
|
||||
cli = Cli(bin_dir / 'horizon-ctl', socket, home, env)
|
||||
args = [str(bin_dir / 'horizon-client'), '--resources', str(resources),
|
||||
'--world', 'cargo', '--creative', '--seed', '4242', '--offscreen',
|
||||
'--render-distance', '4', '--control', '--control-socket', str(socket)]
|
||||
if in_frame:
|
||||
args.append('--in-frame')
|
||||
with (work / 'game.log').open('w') as log:
|
||||
process = subprocess.Popen(args, cwd=home, env=env, stdout=log, stderr=log)
|
||||
try:
|
||||
deadline = time.monotonic() + 120
|
||||
while not socket.exists():
|
||||
if process.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError('game did not open its control socket')
|
||||
time.sleep(0.1)
|
||||
cli('wait_ready', seconds=240)
|
||||
# The End, on its spawn platform.
|
||||
cli('command', command='execute in minecraft:the_end run tp @s 100 50 0')
|
||||
for _ in range(60):
|
||||
cli('wait', ticks=20)
|
||||
if cli('state').get('dimension') == 'minecraft:the_end':
|
||||
break
|
||||
else:
|
||||
raise AssertionError('did not enter the End')
|
||||
cli('wait_ready', seconds=240)
|
||||
# A gateway beside the player, with a cow and a stack in it.
|
||||
x, y, z = GATEWAY
|
||||
cli('command', command=(
|
||||
f'execute in minecraft:the_end run setblock {x} {y} {z} minecraft:end_gateway'))
|
||||
cli('command', command=(
|
||||
f'execute in minecraft:the_end run summon minecraft:cow {x}.5 {y}.5 {z}.5 {{NoAI:1b,Tags:["horizon_gateway_cow"]}}'))
|
||||
nbt = '{Tags:["horizon_gateway_item"],Item:{id:"minecraft:diamond",count:' + str(DIAMONDS) + '}}'
|
||||
cli('command', command=(
|
||||
'execute in minecraft:the_end run summon minecraft:item '
|
||||
+ f'{x}.5 {y}.5 {z}.5 ' + nbt))
|
||||
# The gateway takes the stack, and the cow 40 ticks later
|
||||
# (TheEndGatewayBlockEntity.triggerCooldown).
|
||||
cli('wait', ticks=120)
|
||||
# The exit is 1024 blocks away (findOrCreateValidTeleportPos): the
|
||||
# player waits there, so the travellers are near enough to see.
|
||||
length = math.hypot(x, z)
|
||||
ex, ez = 1024 * x / length, 1024 * z / length
|
||||
cli('command', command=f'execute in minecraft:the_end run tp @s {ex:.1f} 80 {ez:.1f}')
|
||||
for _ in range(60):
|
||||
cli('wait', ticks=20)
|
||||
cow = data_get(cli, '@e[type=cow,tag=horizon_gateway_cow,limit=1]', 'Pos')
|
||||
if 'entity data' in cow:
|
||||
break
|
||||
else:
|
||||
raise AssertionError('the cow never arrived')
|
||||
item = data_get(cli, '@e[type=item,tag=horizon_gateway_item,limit=1]', 'Pos')
|
||||
count = numbers(data_get(cli, '@e[type=item,tag=horizon_gateway_item,limit=1]', 'Item.count'))
|
||||
cow_at, item_at = numbers(cow), numbers(item)
|
||||
print(f'cow at {cow_at}, item at {item_at}, count {count}')
|
||||
assert len(cow_at) == 3, f'the cow has no position: {cow_at}'
|
||||
assert len(item_at) == 3, f'the stack has no position: {item_at}'
|
||||
for name, at in (('cow', cow_at), ('item', item_at)):
|
||||
assert abs(at[0] - x) > 64 or abs(at[2] - z) > 64, \
|
||||
f'{name} stayed in the gateway at {at}'
|
||||
assert count and int(count[0]) == DIAMONDS, f'the stack lost diamonds: {count}'
|
||||
cli('quit')
|
||||
assert process.wait(timeout=120) == 0
|
||||
except Exception:
|
||||
log.flush()
|
||||
text = (work / 'game.log').read_text(errors='replace')
|
||||
Path('/tmp/end-gateway-game.log').write_text(text)
|
||||
for log in (home / 'logs').glob('*'):
|
||||
if log.is_file():
|
||||
Path('/tmp/end-gateway-' + log.name).write_text(
|
||||
log.read_text(errors='replace'))
|
||||
print(text[-4000:])
|
||||
raise
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=30)
|
||||
print(f'PASS end gateway: {"in-frame" if in_frame else "server-thread"}, '
|
||||
f'the cow and the stack left the gateway with their data')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--case', choices=['gateway'], default='gateway')
|
||||
parser.add_argument('--bin-dir', type=Path, default=lab.cargo_target() / 'fast')
|
||||
parser.add_argument('--resources', type=Path, default=lab.RESOURCES)
|
||||
parser.add_argument('--mode', choices=['server', 'in-frame', 'both'], default='in-frame')
|
||||
args = parser.parse_args()
|
||||
modes = {'server': (False,), 'in-frame': (True,), 'both': (False, True)}
|
||||
for in_frame in modes[args.mode]:
|
||||
run(args.bin_dir.resolve(), args.resources.resolve(), in_frame)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -70,6 +70,7 @@ FIXTURES = [
|
||||
('bees', '{bees:[{entity_data:{id:"minecraft:bee",Health:5f},ticks_in_hive:5,min_ticks_in_hive:10}]}'),
|
||||
('entity_data', '{entity_data:{id:"minecraft:pig",NoAI:1b}}'),
|
||||
('bucket_entity_data', '{bucket_entity_data:{Health:5f}}'),
|
||||
('instrument registry', '{instrument:"minecraft:sing_goat_horn"}'),
|
||||
('instrument direct', '{instrument:{sound_event:{sound_id:"minecraft:x",range:8f},use_duration:3f,range:64f,description:"Horn"}}'),
|
||||
('painting variant', '{"minecraft:painting/variant":"minecraft:kebab"}'),
|
||||
('sign_text_front', '{sign_text_front:{messages:["a","b","c","d"],filtered_messages:["e","f","g","h"],color:"red",has_glowing_text:1b}}'),
|
||||
@@ -104,7 +105,7 @@ FIXTURES = [
|
||||
# Fixtures on another item than a stick (the components need it, or read better there).
|
||||
ITEMS = {'profile name': 'player_head', 'profile full': 'player_head', 'trim registry': 'diamond_chestplate',
|
||||
'trim direct': 'diamond_chestplate', 'banner_patterns': 'white_banner', 'pot_decorations': 'decorated_pot',
|
||||
'everything at once': 'diamond_chestplate'}
|
||||
'everything at once': 'diamond_chestplate', 'instrument registry': 'goat_horn'}
|
||||
|
||||
|
||||
def compare(vanilla, rust):
|
||||
|
||||
@@ -15,8 +15,8 @@ the worker's number is left out). In a world without structures (`generate-struc
|
||||
finds none) and how many of the saved chunks hold structure starts or references (none:
|
||||
ChunkStatusTasks.generateStructureStarts does nothing).
|
||||
|
||||
Horizon generates the normal and flat presets only: `large_biomes`, `amplified`, `single_biome_surface` and
|
||||
`debug_all_block_states` aren't tested.
|
||||
Horizon generates the normal, flat, amplified, large-biome and single-biome presets:
|
||||
`debug_all_block_states` isn't tested.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -61,6 +61,9 @@ CASES = {
|
||||
'generator-settings': '{"layers":[{"block":"minecraft:glass","height":5}],"biome":"nowhere"}',
|
||||
},
|
||||
'normal': {'level-type': 'minecraft:normal', 'level-seed': 'abc', 'generate-structures': 'false'},
|
||||
'amplified': {'level-type': 'minecraft:amplified', 'level-seed': '1'},
|
||||
'large-biomes': {'level-type': 'minecraft:large_biomes', 'level-seed': '2'},
|
||||
'single-biome': {'level-type': 'minecraft:single_biome_surface', 'level-seed': '3'},
|
||||
'unknown': {'level-type': 'Some Type', 'level-seed': '42'},
|
||||
}
|
||||
WORKER = re.compile(r'\[Worker-Main-\d+/(WARN|ERROR)\]: (.*)')
|
||||
@@ -155,9 +158,10 @@ def main():
|
||||
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
|
||||
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
|
||||
cases=list(CASES), lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'),
|
||||
scope='a new dedicated-server world: level-type (flat, normal, unknown), a flat world\'s '
|
||||
'generator-settings, generate-structures, as world_gen_settings.dat and the lines logged; without '
|
||||
'structures, locate structure\'s replies and the saved chunks\' structure starts and references.'))
|
||||
scope='a new dedicated-server world: level-type (flat, normal, amplified, large_biomes, '
|
||||
'single_biome_surface, unknown), a flat world\'s generator-settings, generate-structures, as '
|
||||
'world_gen_settings.dat and the lines logged; without structures, locate structure\'s replies and '
|
||||
'the saved chunks\' structure starts and references.'))
|
||||
print(f'server world gen: {len(CASES)} cases, {len(vanilla)} lines identical')
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user